lshell + manage_ltd_users
The per-tenant restricted-shell layer is the boundary that keeps Octopus
tenants isolated from each other and from the host system. It is driven by
lshell (Limited Shell) plus BOA's manage_ltd_users.sh orchestrator
(aegir/tools/system/manage_ltd_users.sh, deployed to
/var/xdrago/manage_ltd_users.sh).
The two-account model per Octopus instance
Each Octopus instance gets two Unix accounts:
| Account | Shell | Purpose |
|---|---|---|
oN |
bash | Quick file inspection; minimal scripting |
oN.ftp |
MySecureShell → lshell | All drush, vdrush, Composer, PHP-CLI work |
oN.ftp's login shell in /etc/passwd is /usr/bin/mysecureshell in the normal
BOA state (MySecureShell installed and /etc/ssh/sftp_config present).
Interactive SSH still lands in lshell: manage_ltd_users.sh adds the account
to the lshellg group, and sftp_config's <Group lshellg> block sets
Shell /usr/bin/lshell for that group. Only when MySecureShell is absent does the
passwd shell become /usr/bin/lshell directly.
The rest of this page refers to that effective restricted shell simply as "lshell".
The lshell wrapper layer is only active under oN.ftp. The two accounts have
distinct identities and homes: oN is the instance owner of /data/disk/oN/,
while the lshell/SFTP account is oN.ftp, homed at /home/oN.ftp/ with a
static symlink back to the instance tree.
What lshell restricts
A tenant in lshell can:
cdinto directories under their home.ls,cat,less,grep,findfiles they own.- Run BOA's curated whitelist of commands (
drush,vdrush,mybackup,composer,gem,npmif enabled,git, …). - Chain allowed commands with
&&,||or a pipe (|): lshell checks every command in the chain against the whitelist, so a chain is refused as soon as any command in it is not allowed. - Exit /
logout.
A tenant in lshell cannot:
- Run arbitrary binaries (
bash,sh,python,perlas general-purpose shells). - Separate commands with
;, or use command substitution (a backtick,$(or${): BOA's lshell configuration refuses these outright. - Read files outside their home directory.
- Escape the chroot/jail boundary.
The exact command whitelist is in lshell's config under /etc/lshell.conf plus
the per-user overrides BOA writes.
What holds an allowed command that could spawn a shell. The whitelist has
two tiers. Commands in allowed_shell_escape (composer, drush, git,
rsync, tar, scp, myloader, …) legitimately start other programs and
run unhindered. Every other allowed command (find, sed, openssl, wget,
…) runs with sudo's sudo_noexec.so preloaded, so it cannot execute anything
itself: find -exec or GNU sed's e command fail with Permission denied.
The command line runs through BOA's /bin/sh dispatcher (websh) on lshell
0.10 and on the 0.11 fork alike, and the dispatcher could not take that preload
itself, so lshell 0.11.8 writes the library into the line as an LD_PRELOAD=
prefix on each such segment of a pipeline and leaves the shell-escape segments
alone (a legacy 0.10 box does the same through per-command aliases): drush status | grep x
still lets Drush run PHP while find … | true cannot exec anything. The
prefix never touches setuid tools (passwd, ping) or the SFTP protocol
binaries, and a tenant's own LD_PRELOAD= is refused. A barracuda or
octopus run puts the box on dash and its wrapper puts websh back on
/bin/sh as the last thing it does (see
Strict binary permissions); the ltd worker
and the agent pass enforce the same symlink on every tick. Four allowed tools
that spawn helpers are confined by design, as they were under lshell 0.10:
mc runs without its shell pane and F3/F4 viewers, svn commit needs -m
(no editor is spawned; diff, status, update and commit with a message all
work), and cvs/bzr cannot start an external editor or diff. Drush's
file-loading options are refused for every shell account: --config/-c
(a drushrc), --early and --include/-i all make Drush load a file as
PHP, so they would run a tenant's own code as Drush and carry every option
the forbidden list refuses; websh answers This drush command is not available (D) however the option is spelled or quoted. The forbidden Drush
commands themselves (php-eval, php-script, core-cli, site-install,
sql-create, site-ssh, core-execute, core-config, archive-restore,
runserver, core-quick-drupal, shell-alias and their short aliases) are
refused by their command word wherever the @alias, the options or a
redirection sit, in every segment of a pipeline and in either spelling
(php-eval or php:eval), with This drush command is not available (E):
lshell's own filter reads the typed line for drush8 scr, which
drush8 @site scr file.php does not contain. An environment word of your own
in front of Drush, Composer or PHP (PHPRC=… drush8 …) is refused too, with
(F): it would hand the PHP process a php.ini of the account's, and its
auto_prepend_file is code no layer below sees. Landlock confinement (see
the per-instance group page) and the forbidden-token filter stand in front
of that layer; the preload is what stops the exec even when a path is
permitted.
websh decides what a line is by its leading word, so a line that merely
names Drush in an argument is an ordinary command: grep -c drush file,
grep -c "drush make" file and drush8 status | grep -i "drush version" all
do what they say, drush8 status sa filters the status table instead of
printing the alias list, and the arguments of a Drush line reach Drush as
typed, single quotes included (drush8 @site vset site_name 'My Site' sets
both words). A database dump is imported by redirecting the file into Drush,
with Drush as the leading word: drush @foo.com sqlc < ~/static/dump.sql.
Drush 8 and Composer, when websh starts them for a shell account or a
sub-account, run on the account's own ~/.drush/php.ini (the file BOA writes
from the pinned version's template: the temp, upload and session paths under
~/.tmp), handed to the interpreter as an option and never through the
environment. That is what makes a shell account independent of the box-wide
/tmp. The file carries no open_basedir: that directive breaks Drush and,
when set, makes PHP disable its realpath cache and check every file operation
against every listed tree uncached — a tenant's minimal Drush 8 start took
eight times longer and a hostmaster bootstrap thirty times, which piled the
control panel's per-minute queue runners up on every server (2026-09-08). It
was never meant for the CLI, where lshell, websh and the per-account uid are
the confinement; the FPM pools keep their own open_basedir. The worker
removes the line from every account ini already on disk, once, keeping the
file immutable, and logs each repair in /var/log/boa/drush-ini.incident.log.
The instance user and the Ægir master stay on the global php.ini: they are
the backend, and the instance user runs the queue against its hostmaster
every minute. A modern Drush (drush10, drush11, a platform's
vendor/drush/drush/drush.php) and a plain php line stay on the global
php.ini: modern Drush refuses to run with open_basedir set. When a
phpNN.info switch selects a version the file was not built for, the global
php.ini of that version is used until the next manage_ltd_users.sh pass
rewrites the account's file.
du is off by default. BOA regenerates /etc/lshell.conf from its own
template (/var/xdrago/conf/lshell.conf) on every manage_ltd_users.sh pass,
and unless du is explicitly allowed it strips du out of that copy first —
both the entry in the allowed command list and the du → du -s -h alias.
The supported switch is _ALLOW_DU=YES in /root/.barracuda.cnf, with the
legacy marker /etc/boa/.allow.du.cnf honoured for one release beside it. The
test is an AND of both negations, so either one is enough to keep du for
every lshell account on the box, and the strip happens only when the key is
not YES and the file does not exist — to take du away again, set
_ALLOW_DU=NO and remove the marker. Either way the change lands on the
next pass (within three minutes), and only when the regenerated config actually
differs from the installed one — in which case the outgoing /etc/lshell.conf
is kept under /var/backups/ltd/old/. The marker is read from /etc/boa/
only; a legacy /root/.allow.du.cnf is copied across once by BOA during an
upgrade and ignored afterwards. Note that /root/.allow.mc.cnf, tested a few
lines earlier in the same block, has not moved — it is still read from
/root/, and by design it has no variable form either: the lshell
shell-escape markers are kept file-only, because a cnf line is copyable
box-to-box in a way a root-owned marker is not.
Single commands over SSH — the overssh allow-list
The whitelist above is for an interactive lshell session. A command sent
non-interactively — ssh oN.ftp@server "<command>" — is governed by a
separate, narrower allow-list in the same config, lshell's overssh set.
overssh permits drush (and drush8/drush10/drush11),
mysql/mysqldump/mydumper/myloader, rsync, scp, git, composer and
cp/mv/rm/ls/grep — enough to drive remote dumps, file syncs and Drush
work. It does not permit tar, cat, vdrush or the site-local
vendor/drush/drush/drush.php, even though the interactive whitelist allows all
four. So a command that runs fine when typed into an interactive session (a raw
cat or tar, say) can still be refused as a one-shot SSH command — a common
surprise when scripting against a tenant account from outside.
Node / NPM security gate
Node/NPM is not enabled by default because Node can be used to bypass lshell.
node, npm, npx (and scp, which rides the same gate) are stripped from the
lshell allowed command list unless one of two conditions is met:
- The Octopus instance type is PHANTOM, CLUSTER, ULTRA or MONSTER — for these
instance types Node/NPM/npx and scp are auto-enabled with no control file
required (BOA greps the instance type out of
/root/.<USER>.octopus.cnf). -
The control file exists:
SHtouch /root/.allow.node.lshell.cnf
Only create the control file on hosts with trusted tenants. Shared / public hosting hosts should keep this disabled.
Even where Node/NPM is enabled, only the main oN.ftp account gets it.
Per-Client shell subaccounts (oN.<client>) receive Ruby Gems access only —
never Node/NPM.
Per-client sub-accounts: files, not code
A per-client shell sub-account (oN.<client>, group ltd-shell) exists to
manage the files and themes of its client's Drupal and Backdrop sites, plus
small custom modules in the site-level space. It never gets composer, bee
or any drush — neither the site-local vdrush nor the global
drush/drush8/drush10/drush11 — nor the database clients and dump tools,
mybackup, s4cmd, outbound ssh, openssl, the Grav CLI, svn/bzr/cvs
or the Node toolchain, in the interactive shell or over one-shot SSH. Grav and
Textpattern sites are never linked into a sub-account. Cache clears and other
site operations go through the client's control-panel account. The removals
live in the [grp:ltd-shell] section of the shipped template
(aegir/tools/system/conf/lshell.conf), applied to allowed,
allowed_shell_escape and overssh; the main oN.ftp account keeps the full
whitelist above. Both are initialised from the same ~/static/control/compass.info
control file, but the NPM half is skipped for every account other than the main
one, which keeps the lshell-bypass surface on a single main-tenant account rather
than spreading it across every client subaccount.
The platform-level developer account (operator-enabled)
_LTD_PLATFORM_CLIENTS="<client dir> ..." in /root/.<oN>.octopus.cnf (root-owned;
never a tenant file) gives each named Client one extra account, oN.<client>-dev,
alongside its ordinary sub-account: the same creation, store, groups and reaper
tokens, but a [<user>] section whose path also carries the app root of every
platform where every site is that Client's, with composer, drush,
drush8, drush10, drush11, vdrush and vendor/drush/drush/drush.php
re-added on allowed and allowed_shell_escape (never on overssh), a
root-placed ~/platforms/ farm for SFTP, and a timed Drush window that is a real
provision-dunlock / provision-dlock cycle requested by touching
~/.tmp/drush-window.request. The platform test is fail-closed and re-run every
pass; refusals go to /var/log/boa/manage_ltd.incident.log with the offending
site directory; Drupal and Backdrop only; removing the name retires the account
on the next pass. The whole procedure — enabling, what the pass builds, verifying,
the window, retiring, troubleshooting — is on the
platform developer account page.
manage_ltd_users.sh — the orchestrator
The BOA script that:
- Provisions new tenant accounts (when a new Octopus instance is created).
- Rotates passwords every 90 days per BOA security policy.
- Configures
lshellper user with the BOA command whitelist. - Manages SFTP/FTPS via MySecureShell / Pure-FTPd.
- Manages SSH
authorized_keys. - Grants extra subaccounts (per Ægir Client — see shell extras).
-
Enforces filesystem permissions under each tenant home (including the per-user pool
disable_functionsapplied to the FPM pool). Late in every pass it also runs one wildcardchmod 700 /home/*, which resets every entry directly under/home— tenantoN.ftphomes, client subaccount homes, and anything else parked there — to owner-only._HOME_NO_WILDCARD_CHMOD=YESin/root/.barracuda.cnfsuppresses that single wildcard chmod, with the legacy marker/etc/boa/.home.no.wildcard.chmod.cnfhonoured for one release beside it: the test is an AND of both negations, so the chmod runs every three minutes only while neither is in force. Nothing else changes — the same pass still keeps/var/log/lshin order (below), and re-asserts the0400/0710modes on the/var/aegir/.drushalias files and theserver_masterconfig tree modes.The lshell log directory.
/var/log/lshisroot:lshellg 1770: every tenant shell user is inlshellgand writes its own<user>.logthere, and the sticky bit (set at install and healed on every pass) keeps one tenant from removing or renaming another's log. Because a tenant can still create any not-yet-used name in that directory, every pass also sweeps it as root: anything that is not a regular*.logfile (a symlink, a directory, a FIFO, a stray name) is removed, and so is a*.logwhose owner is a user other than the one its name claims — including a dead tenant's log whose recycled uid now belongs to someone else, which that someone could otherwise read. A log owned by a uid that no longer exists stays as history unless a live tenant of that name needs the file name back. The owner rule assumes lshell's default<user>.lognaming; settinglogfilenameinlshell.confturns it off. Only then does the pass re-assert0600on the remaining logs. The escape monitor (escapecheck.sh) reads only regular*.logfiles there for the same reason. Set it when a non-tenant directory under/homehas to keep modes of its own; the marker is read from/etc/boa/only, a legacy/root/copy having been relocated there once by BOA. -
Reaps ghost Drush aliases (opt-in). On each pass it detects stale per-site Drush alias copies in the
oN.ftptree — "ghosts" whose site directory no longer has adrushrc.php— and, when_GHOST_ALIASES_CLEANUP=YESis set (read inline from/root/.<account>.octopus.cnfor/root/.barracuda.cnf; the defaultNOonly detects and logs), moves the account-side alias to<account>/undo/(recoverable) and removes the.ftpcopy. Safeguards:- Skips while any
provisionprocess is running. - Skips aliases written under 60 minutes ago (mid-install/clone protection).
- Keeps any alias whose
site_pathparses empty or outside/data/disk(fail-closed). - Never touches an alias whose
site_pathlives underaegir/distro— a front-end control-panel companion belongs to the nightly classifier's operator-review path, not to this sweep. - Holds every ghost candidate for 48 hours after its first sighting
(marker
log/ctrl/ghost-ltd-<domain>.seen; any valid sighting clears it, and markers only accumulate while the flag is YES, so a flip never mass-reaps a backlog). The hold is what protects an inboundxoct/xcopytransfer:rsync -apreserves old alias mtimes, so the 60-minute guard alone cannot tell a freshly-arrived alias from a stale one, and a hand-set/etc/boa/.pause_tasks_maint.cnfdoes not survive on a hosted system (the box automation removes it within minutes outside abarracudarun). - Skips the Drush yml alias rebuild on any run that reaped, so a deregistration is never immediately baked in.
On builds that predate the 48h hold, keep
_GHOST_ALIASES_CLEANUP=NOin both control files on any box while it is receiving an account transfer. Where the nightly site reaper is also enabled it usually acts first (with classification and the client notice); this sweep is the between-nights backstop that also cleans the mirrored ltd-shell copy.See ghost cleanup for the full reaper-flag family.
- Skips while any
It lives at /var/xdrago/manage_ltd_users.sh and runs:
- Every 3 minutes via cron (state sync) — the
*/3root crontab entry (aegir/tools/system/cron/crontabs/root). - On
octopus installandoctopus upgrade(initial provision). - On password-change events.
- Purges the legacy panel alias symlink. Older passes planted
<panel-fqdn>.alias.drushrc.phpas a symlink to the instance'shostmaster.alias.drushrc.php, a crutch for accounts whose panel internals an old migration had left half replaced; it also made the panel look like a site to anything keyed on per-site alias files. Every symlink in the instance's.drushthat points at the hostmaster alias is now removed, whatever it is called — a renamed box keeps the old-name one too — so an account that still depends on it misbehaves visibly and is repaired before its next migration. Instances carryinglog/proxied.pidare skipped by this pass as by every other.
When the wrapper is deactivated
manage_ltd_users.sh defers while a barracuda or octopus run is in
progress: at startup it checks for the BOA run-lock files
(/run/boa_run.pid, /run/octopus_install_run.pid, plus its own
/run/manage_ltd_users.pid) and exits early (exit 0) if any is present, which
protects the upgrade procedure from interfering with itself. During this window:
- Existing tenant sessions keep working.
- New SSH connections may not respect the latest config changes.
- Some BOA-specific shell-wrapper features (such as PHP-CLI version switching via
cli.info) are bypassed.
After the upgrade finishes, manage_ltd_users.sh is restored and catches up on
any pending state.
What an Ægir Client is
Beyond the main oN.ftp account, each Octopus instance can host multiple Ægir
Clients — independent customer identities within the instance. Each Client can
have:
- Their own Ægir UI login (non-admin).
- One extra SSH/SFTP/FTPS shell subaccount (
oN.<client>, one per Client). - Per-client quotas (
hosting_quotamodule). - Per-client sites (only their own sites visible in the UI).
See shell extras for the operator how-to on adding extra shell subaccounts per client.
Per-tenant chroot
Each oN.ftp SFTP/FTPS session is chrooted to the tenant's home via
MySecureShell + Pure-FTPd; the tenant cannot cd .. out of /home/oN.ftp/.
For SSH, the chroot is enforced through the lshell config rather than via
chroot(). Tenants cannot escape because the only commands they can run do not
include shell-spawning functions.
Attached-storage paths in the jail
When an account's static file store lives on attached storage, the oN.ftp
jail's allow-list also carries the account's store path on that mount: the
internal _MNT_STATIC_FILES path, <mount>/files/<oN>/static/files (with a
per-client-domain variant <mount>/files/<oN>/static/files/<domain>/).
_MNT_STATIC_FILES is computed at wiring time — it is an internal variable,
not an operator cnf knob.
BOA locates the attached mount by what it is, not what it is named: each
directory under /mnt is tested with mountpoint -q (falling back to a
st_dev-differs-from-/mnt stat check), and the mount-side path is wired
only when exactly one real mountpoint exists under /mnt.
Zero or multiple mounts fail closed — no usable attached-storage path is granted
(two of the three writers omit the mount entries entirely; the /etc/lshell.conf
writer emits a placeholder /files/<oN>/static/files that resolves nowhere).
A plainly named mount such as /mnt/extra works; the old heuristic that required
a dot in the mount directory name is retired.
Multiple mounts under /mnt are unsupported fleet-wide — BOA cannot disambiguate
which one is the files disk — and the same single-mount invariant is enforced by
migratefs (refuses to run, exit 1, even with an explicit --target) and the
xoct/xcopy/xmass migration tools.
The identical detection block is carried at all three wiring sites:
manage_ltd_users.sh— per-client-domain access paths (per-domain_MNT_STATIC_FILESappended to the allowed-dirs list),manage_ltd_users.sh— the per-user jail path list foroN.ftp,satellite.sh.inc— the[oN.ftp]path :allow-list in/etc/lshell.conf, which lists_MNT_STATIC_FILESalongside/opt/user/gems/oN.ftpand/opt/user/npm/oN.ftp.
See migratefs for the operator tool that relocates file stores onto that mount.
When lshell itself is updated
BOA's agent pass installs a new lshell release on its own (a publish to the
box's tree is the rollout) and then ends the sessions of the lshell group's
members (tenants log in through mysecureshell, which hands lshellg to
lshell, so the group is the discriminator, not the shell field), so they pick
up the new binary on their next login. Other logins on the box — an instance
user or an operator over ssh — are never touched by that step.
What breaks lshell — common gotchas
Things that bypass the lshell layer (avoid on multi-tenant hosts):
/root/.allow.mc.cnf— opens Midnight Commander to lshell users. MC can spawn sub-shells. Do not enable on untrusted-tenant hosts./root/.allow.node.lshell.cnf— allows Node/NPM. Node can bypass lshell. Only enable on trusted-tenant hosts — see the Node / NPM security gate section above for the full gate (instance-type auto-enable, main-account-only scope).chsh -s /bin/bash oN— temporarily swapsoN.ftp's shell to bash. The legacyremote_importprocedure does this and must reverse it (chsh -s /bin/false) afterwards.mv /var/xdrago/manage_ltd_users.sh /var/backups/— also from the legacyremote_importprocedure. Disables the orchestrator entirely and must be reversed afterwards.
If you do steps 3 or 4 for migration, always reverse them before re-enabling tenant access.
Inspecting + auditing
# List active lshell sessions
ps -fu oN.ftp | grep lshell
# View lshell logs (BOA overrides the lshell default to /var/log/lsh/)
ls /var/log/lsh/
# View per-tenant authorized_keys
# (oN is homed at /data/disk/oN; oN.ftp and client subaccounts live under /home)
cat /data/disk/oN/.ssh/authorized_keys
cat /home/oN.ftp/.ssh/authorized_keys
For SSH-key auditing:
# Find all authorized_keys under both tenant-home roots
find /home /data/disk -name authorized_keys -type f
Password rotation
Every 90 days the tenant's password expires and rotates:
- A new password is generated automatically.
- It is emailed to the Octopus owner's address.
- Old SSH keys still work (key auth bypasses password expiry).
Tenants who use only SSH key auth still get a new password emailed that they do not need to use; tenants who use password auth must update their saved password.
Related
- Security model — the broader security model.
- SSH + SFTP hardening — SSH/SFTP server-side config.
- Shell extras — extra accounts per client.
- Migration & cloning — the
remote_importprocedure that temporarily disables the lshell layer.