Skip to content

root-w3bs-os.sh: W3BS OS (Alpine/OpenRC) counterpart of root-ubuntu.sh - #198

Merged
ralyodio merged 1 commit into
masterfrom
root-w3bs-os
Oct 19, 2026
Merged

ralyodio merged 1 commit into
masterfrom
root-w3bs-os

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

root-w3bs-os.sh is the W3BS OS version of root-ubuntu.sh. W3BS OS is Alpine with musl, apk, OpenRC and busybox ash. The script was ported section by section, so it keeps the same structure, comment style, flags, server.conf, state dir (/var/lib/profullstack), the install_managed rule that leaves user-edited files alone, prompts that only appear on a TTY, and the subcommands (groups, confine, mount/umount/mounts/share, sandbox). It is idempotent: a second run reports "nothing changed".

./root-w3bs-os.sh alice --groups wheel
./root-w3bs-os.sh --refresh
curl -fsSL .../root-w3bs-os.sh | bash -s -- --refresh
sh root-w3bs-os.sh   # installs bash with apk and re-runs itself

What changed from the Ubuntu script

root-ubuntu.sh root-w3bs-os.sh
apt update/upgrade w3bs dist-upgrade --yes (switches to w3bs os update --yes once the CLI has it), then apk for base packages. A bootstrap step first installs the GNU tools the script relies on (coreutils, findutils, util-linux, shadow, runuser, …) and enables the community repo on the same branch as main
curl installers for moshcode / threatcrush / hqsh w3bs install moshcode.sh/moshcode, threatcrush.com/threatcrush, hqterm.sh/hqterm, plus sh1pt.com/sh1pt with --with-sh1pt. Runs per account, checked against w3bs.org/api/packages. The publisher's curl installer is only the fallback when w3bs is missing or W3BS_INSTALL=0. --without-moshcode turns moshcode off (it is on by default)
unattended-upgrades /etc/periodic/daily/w3bs-os-update, run by crond (AUTO_UPGRADE=0 turns it off)
ufw ufw, which Alpine packages: IPV6=yes, adds 443/udp for HTTP/3, runlevel boot
swapfile, zram-generator, earlyoom swapfile (OpenRC swap service), zram-init in /etc/conf.d/zram-init, earlyoom in /etc/conf.d/earlyoom. Under systemd it uses zram-generator as on Ubuntu
nginx + certbot + acme.sh + wildcard via DNS-01 Caddy. Every page and dev app is a named site, so Caddy gets and renews a certificate for each one; no wildcard, no DNS plugin. Each account gets /etc/caddy/users/<login>.caddy, written by profullstack-devapps (cron, every minute; it validates and rolls back a bad config). Admin API is on localhost only. /etc/caddy/conf.d/*.caddy is for the operator and never touched. WEB_TLS=auto|internal|off. Ported features: /~user, user.domain, app.user.domain (static or reverse_proxy, websockets included), AI-crawler 403, .git 404, robots.txt, Moshpit parking on the port-80 catch-all (Caddy map)
systemd timers crond jobs with # root-w3bs-os:<tag> markers, or timers when systemd is the running init
ssh-agent systemd user unit + linger Under OpenRC: one long-lived agent per account on ~/.ssh/agent.sock, started at login (/etc/profile.d). Under systemd: same as Ubuntu
user slice caps (user-.slice.d) One cgroup v2 group per confined account, /sys/fs/cgroup/w3bs.confined/<login> (memory.high/max, pids.max, cpu.weight). A cron job moves processes in every minute, and where sshd uses PAM a pam_exec hook does it at login. Under systemd: same slices as Ubuntu
sudo / admin wheel is the admin group (CONFINE_EXEMPT_GROUPS=wheel,sudo,admin). Without confinement, DEFAULT_GROUPS is wheel
passwd -l for key-only accounts usermod -p '*'. Alpine's sshd is built without PAM, and then a ! password means a locked account, so even a valid key is refused. The run summary also warns about any account that still has !
OS check Refuses anything but Alpine (or ID_LIKE alpine, e.g. postmarketOS). Ubuntu/Debian get pointed at root-ubuntu.sh; --force overrides. Reads /etc/w3bs-os-release (ID, EDITION)

Detecting init. The script uses systemd if it is PID 1, OpenRC if /run/openrc exists, and otherwise none. With none, packages, accounts and files still converge, but no services are started.

Not ported

  • Sponsor ad. It depends on nginx SSI, random_index and add_before_body. It is off by default anyway. If SPONSOR_AD_SLOT is set, the script says so and nothing gets billed.
  • networkd-dispatcher. Alpine has no systemd-networkd.
  • threatcrush enforcing daemon under OpenRC. threatcrush install-service writes a systemd unit, so it only runs where systemd is the running init. The threatcrush CLI itself is installed everywhere.
  • certbot / acme.sh / wildcard DNS-01. Caddy replaces them. The Porkbun DNS-record and SPF checks are kept, because Caddy still needs names that resolve to the box.
  • PAM limits under the default sshd. The limits file is written, but nothing reads it unless sshd is built with PAM (openssh-server-pam, UsePAM yes). confine reports this as "written, not read".
  • sandbox (incus) is ported but not tested here. Host-side install moved to apk with the incusd OpenRC service. The tenant instance is still Ubuntu, so its bootstrap is unchanged. It was not exercised in the docker test because incus inside docker is out of scope.

Two-run test (dev3, OpenRC-booted w3bs.org/os:server)

Test image: w3bs.org/os:server + openrc + /etc/w3bs-os-release (ID=w3bs-os, EDITION=docker-server), booted with /sbin/init and run as docker run --privileged --cgroupns=private. Config came from /etc/cli-tools/server.conf: WEB_DOMAIN=dev.w3bs.test, WEB_TLS=internal, SKIP_TAILSCALE=1. Tailscale was skipped because joining needs an auth key.

RUN 1: ./root-w3bs-os.sh alice --groups wheel   exit 0  4m46s  all steps completed, changed this run (38)
RUN 2: ./root-w3bs-os.sh --refresh              exit 0  2m06s  all steps completed, nothing changed -- the box was already up to date
state diff between run 1 and run 2: IDENTICAL (611 lines)
  (path+mode+owner+sha256 of /etc, /var/lib/profullstack, /usr/local/{bin,sbin}, /var/www, homes;
   plus passwd, group, crontab, ufw status, rc-update, home ACLs)
assertions: 36/36 PASS
  alice: exists, shell /bin/zsh, in wheel, not confined, password '*', home group/other ---, ACL user:caddy:--x
  root shell zsh, w3bs present, caddy installed, caddy validate OK, caddy running under OpenRC
  https landing page, alice.dev.w3bs.test, /~alice/, /~alice -> 301 /~alice/, GPTBot -> 403, /.git -> 404,
  :80 -> https, unpointed mosh.eggs -> 302 pit.moshcode.sh, admin API on 127.0.0.1:2019
  ufw active, 22/80/443/60000:61000 allowed, v6 rules present, iptables chains loaded
  daily upgrade job, devapps cron, crond + fail2ban in runlevels, ssh-agent snippet, sshd confine drop-in, sshd -t OK
dev app: echo 3000 > ~alice/apps/api/.port -> generator -> reverse_proxy -> https://api.alice.dev.w3bs.test 200
catalog tools via w3bs: moshcode 0.119.0, threatcrush 0.13.49, hqterm installed for root, w3bs (image user), alice

Extra check: ./root-w3bs-os.sh bob --skip-tools puts bob in users and confined, sets his home to 0700 with the caddy ACL, and confine lists him as confined. Inside a container, confine correctly reports that hidepid and cgroup caps are not available.

vitest run test/root-w3bs-os.test.ts: 97 passed, 2 skipped. One skipped test needs root on a non-Alpine box; the other needs caddy on PATH. The tests cover syntax, --help, the sh→bash re-exec and the one-sentence message when piped into sh, refusal as non-root, the public-file invariants, the server.conf override sweep, read_w3bs_os_release, ensure_apk_repos, the w3bs_tool install/update/failure/fallback paths and the fingerprint lookup, the * password, every WEB_TLS mode in the generated Caddyfile, sh -n on the generated devapps script, the crontab and sysctl no-flip-flop rules, the unprivileged groups / confine / mount --dry-run commands, and shellcheck at warning level. test/root-ubuntu.test.ts still passes.

Bugs found while testing (fixed here)

These also exist in root-ubuntu.sh, which this PR does not change:

  1. Confine sysctl flip-flop. Run 1 raises the floors. On run 2 the kernel is already at the floors, so the file gets deleted, and the hardening is lost at the next boot. This script now keeps any line that its own file is responsible for.
  2. Running from /root copies root's shell config to everyone. After run 1, /root has a .zshrc, so run 2 treats /root as a dotfiles checkout and copies root's shell config into every account. This script now only treats the script directory as dotfiles if it is a git checkout.

Specific to Alpine/OpenRC (fixed):

  • Lock held by supervise-daemon. It inherited the lock file descriptor, so run 2 died with "already in progress". The descriptor is now closed for services and as_user.
  • Root-owned Caddy logs. caddy validate run as root created root-owned log files, which the caddy user then could not open, so Caddy failed to start. Validation now runs as the caddy user.
  • Host sysctls changed from the container. In a privileged container, the swap/sysctl steps wrote the host's sysctls. Everything that touches the kernel now skips when it detects a container. The values this changed on dev3 (kptr_restrict, protected_fifos, suid_dumpable) were restored.
  • No W3BS repository for new accounts. w3bs requires a repository per account. New accounts get the official one, using the fingerprint from the image's /usr/share/w3bs-os/FINGERPRINT (or W3BS_REPO_FINGERPRINT, or the official installer). The fingerprint is never hard-coded.

Not merged; for review.

🤖 Generated with Claude Code

…tu.sh

Bootstrap and maintain a W3BS OS server as root, idempotently: accounts and
groups, confinement, dotfiles, oh-my-zsh/mise, per-user pages and dev apps,
remote shares, sandboxed tenants -- ported section by section from
root-ubuntu.sh with the same flags, server.conf, state dir and managed-file
rule.

- packages through the w3bs CLI: w3bs dist-upgrade (w3bs os update once it
  exists), apk for base packages, w3bs install <catalog id> per account for
  moshcode/threatcrush/hqterm/sh1pt (curl installers only as fallback),
  per-account W3BS repository added with the image's pinned fingerprint
- Caddy replaces nginx+certbot+acme.sh: per-user import files regenerated
  every minute, admin API on loopback, WEB_TLS=auto|internal|off
- OpenRC first-class, systemd when it is the running init; crond jobs,
  zram-init, earlyoom, fail2ban, ufw (dual-stack), one ssh-agent per account
- cgroup v2 per confined account (sweep + pam_exec hook), wheel as admin
- key-only accounts get '*' not '!' (Alpine sshd has no PAM: '!' locks keys)
- refuses non-Alpine unless --force; --with-sh1pt, --with/--without-moshcode

Tested twice in a row as root in an OpenRC-booted w3bs.org/os:server
container: second run "nothing changed", state snapshot identical, 36/36
assertions pass. test/root-w3bs-os.test.ts covers the parse, dry-run and
helper paths (and shellcheck when installed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

41 finding(s)

HIGH/CRITICAL: 15 | MEDIUM: 12 | LOW: 14

Severity Rule Location
HIGH py-ssrf-outbound-request dev2/dev2-site:151
HIGH py-ssrf-outbound-request dev2/dev2-site:1060
HIGH py-ssrf-outbound-request dev2/dev2-site:1775
HIGH sh-remote-script-execution root-ubuntu.sh:3250
HIGH sh-remote-script-execution root-ubuntu.sh:3251
HIGH sh-remote-script-execution root-ubuntu.sh:5146
HIGH sh-remote-script-execution root-ubuntu.sh:5150
HIGH sh-remote-script-execution root-w3bs-os.sh:3632
HIGH sh-remote-script-execution root-w3bs-os.sh:3633
HIGH sh-remote-script-execution root-w3bs-os.sh:5558
HIGH sh-remote-script-execution root-w3bs-os.sh:5568
HIGH sh-remote-script-execution root-w3bs-os.sh:5575
HIGH sh-remote-script-execution root-w3bs-os.sh:5591
HIGH sh-remote-script-execution root-w3bs-os.sh:5596
HIGH sh-plaintext-download root-w3bs-os.sh:6278
MEDIUM sql-template-interpolation dev2/dev2-site:1286
MEDIUM sql-template-interpolation dev2/dev2-site:1363
MEDIUM sh-remote-script-execution root-ubuntu.sh:5354
MEDIUM redos-nested-quantifier src/domain-free.ts:56
MEDIUM redos-nested-quantifier src/emoji.ts:167
MEDIUM redos-nested-quantifier src/icon.ts:166
MEDIUM redos-nested-quantifier src/mail.ts:1047
MEDIUM sql-template-interpolation src/users-dump.ts:487
MEDIUM sql-string-concatenation src/users-dump.ts:507
MEDIUM sql-template-interpolation src/users-dump.ts:540
MEDIUM sql-string-concatenation src/users-dump.ts:574
MEDIUM redos-nested-quantifier src/wcag.ts:556
LOW secret-generic-credential src/credentials.ts:36
LOW secret-generic-credential src/credentials.ts:53
LOW secret-generic-credential src/credentials.ts:56
LOW secret-generic-credential src/credentials.ts:61
LOW secret-generic-credential src/user-export.ts:689
LOW secret-generic-credential src/user-export.ts:695
LOW secret-generic-api-key test/credentials.test.ts:208
LOW secret-generic-credential test/mail.test.ts:141
LOW secret-generic-credential test/proxy.test.ts:69
LOW secret-generic-credential test/proxy.test.ts:85
LOW secret-generic-credential test/shorten.test.ts:36
LOW secret-database-url test/users-dump.test.ts:108
LOW secret-database-url test/users-dump.test.ts:119
LOW secret-database-url test/users-dump.test.ts:120

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 166e1bc into master Oct 19, 2026
5 checks passed
ralyodio added a commit that referenced this pull request Oct 19, 2026
…/root for dotfiles (#201)

Two flip-flops found while porting this script to W3BS OS (root-w3bs-os.sh, #198):

- sysctl floors: run 1 raised the keys below the floor and wrote
  62-profullstack-confine.conf; run 2 saw the raised running values, wrote nothing and
  DELETED the file, so the next boot lost the hardening. A key at the floor because of
  our own file now keeps its line (same for fs.suid_dumpable = 0).
- dotfiles: run from /root (the documented way), run 2 found the .zshrc run 1 put there,
  took /root for the team's dotfiles and copied root's shell config into every account.
  The script's own directory now counts only when it is a git checkout.

Regression tests for both; the suid test sets CONFINE_SYSCTL_CONF instead of rewriting
the function. vitest root-ubuntu: 193 passed; no new shellcheck warnings.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant