Skip to content

Sandboxed tenants: an account gets its own machine, not a shell on ours - #56

Merged
ralyodio merged 1 commit into
masterfrom
worktree-sandbox-tenants
Sep 6, 2026
Merged

Sandboxed tenants: an account gets its own machine, not a shell on ours#56
ralyodio merged 1 commit into
masterfrom
worktree-sandbox-tenants

Conversation

@ralyodio

@ralyodio ralyodio commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What this is

"Resell a VPS if they want root, otherwise put them somewhere they can't break other people's stuff."

The name for the thing you were describing is a container — specifically an unprivileged system container, which is a whole machine (own init, own package manager, own users) sharing this box's kernel. "Sandbox" normally means something much smaller (seccomp/bubblewrap around one process) and is the wrong model here. The paid tier is a VM (own kernel).

The FOSS manager is incus — the LXD fork the original LXC/LXD maintainers moved to. Apache-2.0, in Ubuntu 24.04's own archive, one daemon, one CLI, and the same CLI drives both containers and VMs — which is why both tiers here are ~20 lines apart instead of two implementations.

Usage

./root-ubuntu.sh alice --sandbox        # alice: container, no shell on this box
./root-ubuntu.sh bob --sandbox=vm       # bob: a real KVM VM, root inside it
./root-ubuntu.sh sandbox                # who the tenants are and their ports
./root-ubuntu.sh sandbox enter alice    # a root shell inside her machine
./root-ubuntu.sh sandbox rm alice       # delete the instance, keep her /home

--refresh reconciles every existing tenant. --skip-sandbox leaves them alone.

What actually stops a tenant reaching anything of ours

no host shell nologin, password locked, never in sudo/admin
unprivileged container root inside maps to a high uid out here; a setuid file they make is setuid to nobody-in-particular
no host-side execution the curl | sh installers (oh-my-zsh, mise, moshcode) and oh-my-tmux run inside the instance for a tenant. Running them on the host reads their dotfiles as their uid — that is host code execution for anyone who edits their own ~/.gitconfig, and it was the sharpest edge in the whole change
network ACL egress to the internet, but RFC1918, 169.254.169.254 (cloud metadata) and the 100.64/10 tailnet are rejected. That also stops tenant→tenant, since a neighbour is only ever an address inside our own bridge
resource caps cpu / memory / processes. Disk only where the pool driver can enforce it (btrfs/zfs — dir cannot, and the script says so out loud rather than accepting a quota it will ignore)
one port each 2200 + slot, forwarded to the instance's own sshd. The host's sshd is never told about them

How their work still shows up on the web

/home/<login> is bind-mounted into the instance (idmapped, so uids match on both sides). That single device is what keeps the rest of the script working untouched — ~/public_html is still served by the host's nginx, ~/apps/<app>/.port is still compiled into the devapps map, and no vhost changed.

Each tenant owns a band of loopback ports (slot 0 → 21000-21099) forwarded 127.0.0.1:N ↔ 127.0.0.1:N into their instance, so nginx's existing proxy_pass http://127.0.0.1:$devapp_port reaches it with no idea anything moved.

profullstack-devapps now refuses a .port outside the writer's own band. Without that, alice could put bob's port in her own ~/apps/x/.port and serve his unreleased work under her name. Bands are enforced only where allocated — an account already listening on a port in the region is not broken by turning this on.

Idempotency

Pools, bridges, ACLs, profiles and devices are compared before they are written (YAML documents against the checksum of what we last sent, since incus echoes back defaults and reorders maps). The in-instance bootstrap is gated on a revision marker. A slot, once allocated, is never moved — the ports are in DNS, in people's ~/.ssh/config and in nginx. A re-run reconciles; it never recreates.

Testing

Nothing here was run against a live incus daemon — this needs a real box.

  • bash -n clean; shellcheck -S warning clean apart from one pre-existing SC2088 at line 998
  • slot allocation exercised standalone: allocate, re-register (slot held), change kind (containervm, no duplicate row), free, reallocate into the gap
  • the generated profullstack-devapps was extracted, parsed and run against fake homes: tenant publishes own band ✅, tenant reaching a neighbour's band ✅ blocked, non-tenant squatting an allocated band ✅ blocked, non-tenant on an unallocated port in the region ✅ still allowed, a box with no tenants ✅ behaves exactly as before
  • sandbox --help, bare sandbox, --sandbox=<bad kind>, non-root sandbox rm, and groups --help all check out

One bug the tests caught and the fix kept: _tenant_register originally printed its slot on stdout while also calling note(), so a caller reading it through $(...) spliced the log line into the slot number and lost the CHANGED entry to the subshell. It sets $TENANT_SLOT now.

Before this runs on dev.chovy.com

  • incus network acl needs the nftables firewall driver. If ACL creation fails the script warns and continues — the tenant would then have no egress isolation, so check that warning on the first run.
  • Wildcard DNS: each tenant needs *.<user>.dev.chovy.com (unchanged from today, but now it matters per customer).
  • SANDBOX_MEMORY defaults to 2GiB each. Ten tenants is 20GiB of cap; check the box against [fleet OOM hardening] before selling the tenth.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y32FRASN3UZhUgwFdbEAzb

…ne, not a shell

Selling access to a box currently means handing out a normal UNIX account,
which is only as strong as every world-readable file, every localhost
service and every kernel LPE published before the next reboot. That is fine
for colleagues and wrong for customers.

A tenant now gets an incus instance instead: an unprivileged container by
default, or a real KVM VM (--sandbox=vm) for the "I need root" tier. Their
host account exists only to own /home/<login> -- nologin shell, never in
sudo or admin -- and that home is idmapped into the instance, so
~/public_html and ~/apps keep working through the existing nginx with no
change to the vhosts.

  ./root-ubuntu.sh alice --sandbox
  ./root-ubuntu.sh bob --sandbox=vm
  ./root-ubuntu.sh sandbox {init,create,refresh,enter,exec,start,stop,rm}

What keeps a tenant off everything else:

  * no host-side execution at all -- the curl|sh tool installers (oh-my-zsh,
    mise, moshcode) and oh-my-tmux run INSIDE the instance for a tenant.
    Running them out here reads their own dotfiles as their own uid, which
    is host code execution for anyone who edits ~/.gitconfig
  * a network ACL rejecting RFC1918, 169.254 (cloud metadata) and the
    tailnet, which also stops tenant reaching tenant
  * caps on cpu/memory/processes, and disk when the pool driver can enforce
    one (btrfs/zfs; dir cannot, and says so)
  * one forwarded ssh port each, derived from a slot allocated once and
    never moved

Dev apps keep working because each tenant owns a band of loopback ports
forwarded into their instance, so nginx's proxy_pass to 127.0.0.1 is
unchanged. profullstack-devapps now refuses a .port outside the writer's
own band -- otherwise alice could publish bob's running service under her
own name. Bands are enforced only where they are ALLOCATED, so an existing
account already listening on a port in the region is not broken by turning
this on.

Idempotent throughout: pools, bridges, ACLs, profiles and devices are
compared before they are written, the in-instance bootstrap is gated on a
revision marker, and a re-run reconciles rather than recreates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y32FRASN3UZhUgwFdbEAzb
Comment thread root-ubuntu.sh Dismissed
Comment thread root-ubuntu.sh
ZSH="$HOME/.oh-my-zsh" ZDOTDIR="$HOME" RUNZSH=no CHSH=no KEEP_ZSHRC=yes \
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" >/dev/null 2>&1
curl -fsSL https://mise.run | sh >/dev/null 2>&1
curl -fsSL https://moshcode.sh/install.sh | sh >/dev/null 2>&1
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

12 finding(s)

HIGH/CRITICAL: 4 | MEDIUM: 4 | LOW: 4

Severity Rule Location
HIGH sh-remote-script-execution root-ubuntu.sh:2418
HIGH sh-remote-script-execution root-ubuntu.sh:2419
HIGH sh-remote-script-execution root-ubuntu.sh:4026
HIGH sh-remote-script-execution root-ubuntu.sh:4030
MEDIUM sh-remote-script-execution root-ubuntu.sh:4179
MEDIUM sh-remote-script-execution root-ubuntu.sh:5241
MEDIUM redos-nested-quantifier src/domain-free.ts:56
MEDIUM redos-nested-quantifier src/mail.ts:1042
LOW secret-generic-credential src/credentials.ts:36
LOW secret-generic-api-key test/credentials.test.ts:208
LOW secret-generic-credential test/mail.test.ts:135
LOW secret-generic-credential test/shorten.test.ts:36

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit f355df1 into master Sep 6, 2026
4 of 5 checks passed
ralyodio added a commit that referenced this pull request Sep 6, 2026
master merged #56, which puts a tenant in their own incus container or VM
under the name `sandbox`. This branch confines accounts in place. They are
layers rather than rivals -- a tenant with an instance is still a non-admin
account out here -- so both survive the merge intact.

The rename in the previous commit did most of the work; what was left:

  * both features had inserted a section at the same point, and git had
    folded the shared "esac }" tail of cmd_confine and cmd_sandbox together.
    cmd_confine's case is closed explicitly and both sections are kept
  * one subcommand list, one dispatch arm each
  * a comment about ssh-agent lingering that #56 orphaned above the sandbox
    stage is back with the stage it describes

Checked rather than assumed, since git auto-merged several regions both
branches had touched:

  * fix_home_permissions keeps BOTH sides -- the _confine_home_mode branch
    and the per-app publishing walk
  * add_user_spec keeps both the tenant-groups shortcut and ask_groups
  * DEFAULT_GROUPS still moves to `users` under confinement
  * stage order is right: incus up, accounts created, THEN confinement,
    which is what makes group membership the thing that decides a tier

Tenants are excluded from _confine_humans (nologin), which is correct: they
have no host processes to cap. Their home mode is still set, because that
runs per-home out of fix_home_permissions rather than off that list.

Three defects fixed on the way through:

  * the accounts table hand-padded "admin" to the width of "sandboxed", so
    the rename to the shorter "confined" misaligned the column. It pads in
    the format string now
  * _confine_sysctl_floor's `want` local collided with an array of the same
    name elsewhere; the linter does not scope locals, so it cost two false
    warnings. Renamed to `floor`
  * two tests pinned the whole subcommand alternation and broke when
    `sandbox` joined it. They match the verb inside the list now, so the
    next subcommand does not break them

791 tests pass; shellcheck clean apart from one SC2088 that predates both
branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y32FRASN3UZhUgwFdbEAzb
ralyodio added a commit that referenced this pull request Sep 6, 2026
`/cli-tools crawlproof update` printed the dashboard's usage and exited 2. The
wrapper spells its own flags `--self-*` so that every plain word belongs to
upstream, which is the right default and was the wrong answer here: `update` is
the word `cli-tools update` already uses in this repo, so getting usage back is
the command being wrong rather than the person.

So the wrapper claims exactly three words -- update, upgrade, self-update -- and
only in first position, which keeps `crawlproof ads budget update` upstream's.
Installing is the wrapper's job and can never come to mean something in the
dashboard, so this is the one place the rule is safe to break.

The predicate lives in src/ and is exported, so the tests exercise the real
thing rather than a copy of the word list.

Note: test/root-ubuntu.test.ts fails on master as of #56, unrelated to this.


Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ralyodio added a commit that referenced this pull request Sep 6, 2026
`crawlproof update` said "installed with pnpm" and left the old version in
place, so the bug it was run to fix was still there. Same shape for `hqtui`.

pnpm 11 ships a `minimumReleaseAge` cooldown that refuses versions published
in the last little while, and it does not fail when it refuses one: it resolves
to the newest release old enough to pass, writes a note about an exclude list,
and exits 0. Reproduced in an empty directory on pnpm 11.18.0, with the
registry reporting 0.2.0:

  pnpm add @profullstack/crawlproof@latest  -> 0.1.0, exit 0
  npm install @profullstack/crawlproof@latest -> 0.2.0

So both wrappers now ask what landed instead of trusting the exit code, and
move to the next package manager when the answer is the wrong version. The
version installed is printed, because "installed" without a number is exactly
the claim that turned out to be false.

Deliberately not `--config.minimumReleaseAge=0`. The cooldown is a real
supply-chain protection, and switching it off wholesale in a tool that installs
on other people's machines is a bigger decision than fixing an update. Falling
through to npm leaves it as pnpm's default and still lets a deliberate update
finish.

An unreachable registry means the wanted version is unknown, and an unknown
want passes: an offline box must still be able to reinstall what it has.

Two failures on this branch are not from it: root-ubuntu's `groups` test fails
on master as of #56, and registry's summary test fails on an untracked
bin/argontv.ts sitting in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD
ralyodio added a commit that referenced this pull request Sep 6, 2026
`crawlproof update` said "installed with pnpm" and left the old version in
place, so the bug it was run to fix was still there. Same shape for `hqtui`.

pnpm 11 ships a `minimumReleaseAge` cooldown that refuses versions published
in the last little while, and it does not fail when it refuses one: it resolves
to the newest release old enough to pass, writes a note about an exclude list,
and exits 0. Reproduced in an empty directory on pnpm 11.18.0, with the
registry reporting 0.2.0:

  pnpm add @profullstack/crawlproof@latest  -> 0.1.0, exit 0
  npm install @profullstack/crawlproof@latest -> 0.2.0

So both wrappers now ask what landed instead of trusting the exit code, and
move to the next package manager when the answer is the wrong version. The
version installed is printed, because "installed" without a number is exactly
the claim that turned out to be false.

Deliberately not `--config.minimumReleaseAge=0`. The cooldown is a real
supply-chain protection, and switching it off wholesale in a tool that installs
on other people's machines is a bigger decision than fixing an update. Falling
through to npm leaves it as pnpm's default and still lets a deliberate update
finish.

An unreachable registry means the wanted version is unknown, and an unknown
want passes: an offline box must still be able to reinstall what it has.

Two failures on this branch are not from it: root-ubuntu's `groups` test fails
on master as of #56, and registry's summary test fails on an untracked
bin/argontv.ts sitting in the working tree.


Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD

Co-authored-by: Claude Opus 5 (1M context) <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.

2 participants