- TypeScript 98.5%
- Svelte 1.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The lookup rendered as a form, a "Result" row with a long sentence squeezed right, a table of matches, and the all-keys table underneath — and before searching, the matches table showed column headers over nothing, which is scaffolding pretending to be a result. It is a form and one list now. The list is never empty: it says "Nothing searched yet" before a search and "Not installed on this host" after one that found nothing, with the fingerprint quoted, so every state is a sentence rather than an empty grid. A match row leads with the account and carries the file, the verdict, the comment and whether this module owns that file. The all-keys table lost its Origin column, which ran off the right edge — a column nobody can read is worse than one nobody asked for, and origin is on the account page next to the key it describes. |
||
| backend | ||
| frontend | ||
| .gitignore | ||
| CLAUDE.md | ||
| opsdeck.module.json | ||
| README.md | ||
| tsconfig.check.json | ||
OpsDeck module: Users
Unix accounts on the OpsDeck host, and the SSH keys that open them.
This is a key manager that happens to create accounts, not an account manager that happens to store keys. Adding a person to a server is two things that only look like one — an account exists, and something lets that person in — and almost every tool does the first well and leaves the second as a file you edit over SSH. That is the wrong way round: the account is cheap, the key is the credential.
The idea
Every screen answers "who can get into this box, and how".
- One sentence per account.
2 keys · password set — key or password, orno keys · password locked — cannot log in over SSH. Computed from the keys,/etc/shadow, the account's expiry and the effective sshd policy, because reasoning about four files at once is what makes this area error-prone. - "Disable" actually disables.
usermod -Llocks the password and leaves every key working — the single most common mistake here. This module's disable expires the account (chage -E), which PAM refuses whatever the authentication method was, and locks the password, and says so on the button. - The key list is the key list sshd reads. The files come from the effective
AuthorizedKeysFile(viasshd -T), so a hardened host that moved it does not get a key list that omits every key that works. AnAuthorizedKeysCommand,TrustedUserCAKeysor a second key file are reported as blind spots rather than quietly ignored. - Every key is identified the way a human identifies it: SHA256 fingerprint,
comment, type, and where it lives — never a wall of base64. A key carrying
command=is badged as automation, not a person. - Removals name the consequence. Removing the last usable key of an account whose password cannot be used is refused unless overridden, and so is the last key of the last account that can become root.
Key custody: where the file lives
The question underneath "manage SSH keys" is whether the account holder can edit their own key list. Both answers are defensible, so both exist and the active one is named on the page and in the startup log.
home (default) |
root |
|
|---|---|---|
| file | ~/.ssh/authorized_keys |
/etc/ssh/authorized_keys.d/<user>, root:root |
| needs | nothing | sshd pointed at that directory |
| cost | the account owner can add keys this module never approved | a config change and a reload you must do yourself |
In root custody the module writes only its own file: keys found in a
user-owned file are listed, labelled self-added, and marked not-managed —
and a revocation that cannot reach one says so instead of reporting success.
OPSDECK_MOD_USERS_CUSTODY=root is only active once sshd -T shows that
directory in AuthorizedKeysFile. Until then the module says the model is
configured but not in effect, because writing to a file sshd does not read is
the worst failure available: the UI says the key was installed and the login is
refused.
Importing keys from a URL: fetch, don't pull
Pulling at login — sshd's AuthorizedKeysCommand asking a remote service on
every connection — makes that service's uptime this host's uptime, its
compromise immediate access here, and leaves no local record of what was
accepted. This module refuses to configure that, and never writes
AuthorizedKeysCommand.
What it does instead is what ssh-import-id does: fetch once, show the diff,
write real key lines with provenance. On top of that:
- an origin allow-list (
OPSDECK_MOD_USERS_KEY_SOURCES), empty by default, so importing is off until somebody says where keys may come from; - HTTPS only, no redirects followed, size cap, timeout, and private/loopback/ link-local addresses refused — this server sits inside the LAN somebody would otherwise point it at;
- a per-account sync policy: report-only (default), add-only, or mirror. Mirror removes only keys this module recorded importing from the same source, and no automated sync ever removes an account's last key;
- an empty successful response is treated as a failure, not as "upstream has no keys" — a deleted account and an emptied profile look identical over HTTP.
What it does not do
Reading sudoers, yes; writing it, no — granting sudo means adding an account to
a group that already confers it. No sshd_config writing (the posture panel is
read-only). No private keys, ever: it asks for a .pub and tells you the
ssh-keygen command. No password generation or display. No LDAP/AD. No PAM or
nsswitch editing. One host.
Requirements
The OpsDeck container needs the host-root bind mount (/ → /host/root),
writable — key files are written on the host. Reads (/etc/passwd,
/etc/group, /etc/shadow, every authorized_keys) go straight through that
mount and need no privilege at all.
Changes to the account database run on the host through ctx.host (the shared
chroot/nsenter probe), because shadow-utils take the passwd lock, allocate the
UID, honour login.defs and copy /etc/skel. No pid: host is needed:
nothing here addresses a process. If host exec is unavailable the module
degrades to a readable inventory and says why; it does not disappear.
On the host: shadow-utils (useradd, usermod, userdel, chage,
gpasswd, chpasswd — present on every distribution) and openssh-server for
sshd -T.
Configuration
All variables are prefixed OPSDECK_MOD_USERS_.
| variable | default | what it does |
|---|---|---|
READONLY |
off | every mutating route answers 403; the UI hides the controls |
CUSTODY |
home |
home or root — see above |
KEY_DIR |
/etc/ssh/authorized_keys.d |
where root-owned key files live |
ALLOW_WEAK_KEYS |
off | accept RSA 2048–3071 (with a warning) instead of refusing |
MANAGE |
– | system accounts to unprotect, by name. Never root |
KEY_SOURCES |
– | https origins a key import may fetch from. Empty disables import |
IMPORT_TIMEOUT_MS |
10000 | fetch timeout |
IMPORT_MAX_BYTES |
65536 | cap on a fetched key list |
DRIFT_CHECK |
on in root custody |
notify about keys this module did not add |
DRIFT_EVERY_SEC |
3600 | how often to look |
SNAPSHOTS |
10 | key-file versions kept per account, for undo |
There is deliberately no role variable: the module is admin-only in its
manifest, on every route including GETs, and there is no knob to set wrong. A
typo in any of the above narrows or falls back — never widens — and the
effective policy is one startup log line and a panel on the page.
API
All routes require the admin role.
| Method | Path | Purpose |
|---|---|---|
| GET | /summary, /policy |
counts, posture headline, what the module may do |
| GET | /accounts, /account/:name |
the inventory, and one account with its keys |
| GET | /keys |
every key on the host, the offboarding starting point |
| GET | /groups, /group/:name |
groups, members, what confers root |
| GET | /posture |
effective sshd policy, as findings with consequences |
| GET | /shells, /groupnames, /account/:name/snapshots |
pickers and undo history |
| POST | /accounts |
create; optionally with a key and a password in the same run |
| POST | /account/:name/keys, .../keys/remove, .../keys/disable |
key lifecycle |
| POST | /account/:name/keys/import, .../import/preview, .../source, .../sync |
fetch-and-snapshot |
| POST | /account/:name/keys/restore |
put back a previous key file |
| POST | /account/:name/disable, /enable, /expiry, /edit, /groups, /password |
lifecycle |
| POST | /account/:name/offboard |
revoke keys, drop privileged groups, expire; keeps home |
| POST | /account/:name/delete |
requires the account name in the body, checked server-side |
| POST | /account/:name/fix-modes |
apply the StrictModes correction the page offered |
| POST | /groups, /group/:name/delete, /group/:name/members |
groups |
Pages
Everything renders from the portable UI schema, so the web shell and the Android app show the same module — including creating accounts and adding keys. The only custom code is the dashboard card, which has no schema equivalent.
`` · new · account/:name (+ /edit, /groups, /password, /delete) ·
keys · groups · group/:name · posture.
Deleting an account and offboarding one use confirmPhrase, so the operator
types the account name; the routes re-check it, because every client is a
client.
Development
deno test --allow-read --allow-env backend/ # no core needed
cp -r . <core>/packages/modules/users && cd <core>
deno check packages/modules/users/backend/mod.ts
deno run -A npm:svelte-check@^4 --workspace packages/modules/users \
--tsconfig ./tsconfig.check.json --threshold error
deno run -A packages/module-builder/main.ts --builtin
OPSDECK_AUTH=disabled OPSDECK_FAKE_DATA=1 OPSDECK_MODULES=users deno task start
Remove the copy afterwards — this repository is the source of truth.
OPSDECK_FAKE_DATA=1 serves a synthetic host (people, a contractor whose
account expired while holding a key, a forced-command deploy key, an sshd that
accepts passwords) and refuses every mutation: a fake write that reported
success would train exactly the wrong reflex.
Mutations are only ever tested against docker/dev-rig in core, never a
developer's own machine. The rig is what makes chroot /host/root real; without
it the host-exec probe drops to direct and useradd would run against your
own /etc/passwd.