No description
  • TypeScript 98.5%
  • Svelte 1.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tobias Oehring fdc3e8def6
fix: a search that is one interaction, not four cards
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.
2026-08-12 11:56:02 +02:00
backend fix: a search that is one interaction, not four cards 2026-08-12 11:56:02 +02:00
frontend feat: unix accounts and the SSH keys that open them 2026-08-11 23:15:27 +02:00
.gitignore feat: unix accounts and the SSH keys that open them 2026-08-11 23:15:27 +02:00
CLAUDE.md feat: unix accounts and the SSH keys that open them 2026-08-11 23:15:27 +02:00
opsdeck.module.json feat: unix accounts and the SSH keys that open them 2026-08-11 23:15:27 +02:00
README.md feat: unix accounts and the SSH keys that open them 2026-08-11 23:15:27 +02:00
tsconfig.check.json feat: unix accounts and the SSH keys that open them 2026-08-11 23:15:27 +02:00

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, or no 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 -L locks 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 (via sshd -T), so a hardened host that moved it does not get a key list that omits every key that works. An AuthorizedKeysCommand, TrustedUserCAKeys or 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 20483071 (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.