Hosting accounts

Create, suspend and remove hosting accounts — each one a Linux user on its server.

In preview accounts version 0.1.0 Part of Resellers and plans

Actions

18 actions, callable from the panel, the command palette and the API as POST /api/v1/a/<id>. Internal actions used between modules are not listed.

ActionWhat it doesRiskPreview
accounts.create Create hosting account medium No dry run
accounts.list List hosting accounts (read) low No dry run
accounts.get Get a hosting account (read) low No dry run
accounts.suspend Suspend hosting account medium
accounts.email_status Can the panel email account owners (read) low No dry run
accounts.unsuspend Unsuspend hosting account medium
accounts.suspension.get What a suspension did (read) low No dry run
accounts.delete Delete hosting account critical No dry run
accounts.reconcile Re-apply all accounts medium No dry run
accounts.unmanaged.scan Scan now for objects made outside the panel low No dry run
accounts.unmanaged.list Objects made outside the panel (read) low No dry run
accounts.unmanaged.ignore Ignore or un-ignore an unmanaged object low
accounts.unmanaged.adopt Adopt an object made outside the panel medium
accounts.unmanaged.remove Remove an object made outside the panel high
accounts.cron.list Crontab lines of accounts (read) low No dry run
accounts.cron.create Add a crontab line to an account medium
accounts.cron.update Change an adopted crontab line medium
accounts.cron.delete Delete an adopted crontab line high

Permissions and limits

Permissions

  • accounts.manage Create, suspend and delete hosting accounts
  • accounts.view View hosting accounts
  • accounts.reconcile Re-apply every account to its server

Plan limits

  • accounts.count Hosting accounts

Engineering notes

Generated from modules/accounts/docs.md at build d90e9e2. These are the notes the engineers keep next to the code: precise, technical, and honest about what is not done yet.

Every module copies this one. It shows the whole contract in ~350 lines:

Piece File Rule it demonstrates
Manifest module.yaml everything the module can do is declared: permissions, limit keys, actions (risk, mutates), agent ops, events, UI routes + nav
Schemas schemas/*.json params are validated by the host before the plugin sees them; strict (additionalProperties: false)
Migrations migrations/0001_init.up.sql the module owns schema m_accounts only, via its own Postgres role
Embed files.go the plugin binary carries its manifest + schemas (module.New(accounts.Files))
Plugin cp/main.go module.Handle(m, "<action>", fn) per action; m.Serve()

Patterns to copy

  • Desired state first, then apply. create writes the row (provisioning) before calling the agent; apply is idempotent and records active / error; reconcile re-runs apply for every row.
  • Compensate on refusal, keep on outage. If the server refuses (invalid/conflict) the create is fully undone (row, core identity, limit). If the server is unreachable the row stays error for reconcile.
  • Limits through the host. c.Host.Reserve(principal, "accounts.count", 1) before creating, Release on undo/delete.
  • Cross-module work through actions. The identity is created with c.Host.Call("core.principal.create", …) — never by writing another schema.
  • Servers only through declared agent ops (system.user.ensure, system.user.remove), typed with sdk/agentop.
  • Scope every query (visible()): admin = all, reseller = own accounts, user = itself.
  • Emit events (accounts.created …) so the UI updates live.
  • Return module.Errorf(code, …) for user-facing failures; anything else becomes a logged internal error.

Verified (2026-10-09, throwaway container, Ubuntu 24.04 + Postgres 18)

create -> Linux user alice uid 1001, home 0750, bash when shell_access; invalid name -> invalid_params with the schema path; reserved name postgres -> conflict and full rollback; duplicate -> conflict; suspend -> account locked (passwd -S = L); delete -> user + home removed, identity deleted; audit log has a hash-chained row for every mutation.

Next for this module

UI package (ui/) once @rc/ui lands; move between servers; rename; per-account overview; disk/inode quotas and cgroup slice limits (agent ops system.slice.apply, system.quota.set) once cp-core packages land.

Identity glue (w3-03)

  • accounts.create takes an optional password. It is forwarded only to auth.password.set_for (permission auth.users.set_password; target must be strictly below the caller; strength policy applies; password is redacted in the audit log). If the password is refused the identity and the limit unit are rolled back. API tokens cannot use this path (auth.* is never in a token's scope).
  • accounts.suspend / unsuspend call core.principal.suspend / unsuspend (cascade) before locking the Linux user, so a suspended account's sessions and API tokens die in the same transaction.

Suspension cascade (w10-fix-15, accounts/013)

Trigger. accounts.suspend / accounts.unsuspend (and the scheduled accounts.suspend_due, which calls the same code) save the status, bump accounts.suspension_gen, apply the Linux user and publish accounts.suspended / accounts.unsuspended (payload: id, username, node_id, home, status, suspend_reason, suspend_message, gen). accounts.updated is still emitted (branding sends the owner's email from it). A dry run lists every part.

Parts. Each module reacts to the event as the system, does its part, and reports it with accounts.suspension.report ({account_id, part, gen, state: ok|failed|skipped, detail}; declared in each module's system_calls). Handlers are idempotent, so the same event can be published again.

part module suspended unsuspended
login accounts + agent usermod --lock and --expiredate 1 (key-based SSH refused), the user's processes killed unlocked, expiry cleared
web web (+ branding) every site serves 503 + Retry-After: 3600 with the reseller-branded page (the owner's message HTML-escaped; the reason is internal and never printed (accounts/014); $ \ ' become character references for nginx), through the normal validate / swap / reload / health / roll-back apply sites that the suspension disabled get their old status back; a site that was already suspended by hand stays suspended (sites.acct_hold, prev_status)
mail mail Dovecot nologin and the submission sender map reject the account's mailboxes; inbound delivered, or held in the queue when the node setting suspended_inbound is hold account removed from mail_suspended, files re-rendered
apps apps + agent system.account.hold crontab saved and removed, running units of the account's slice recorded and stopped, slice refuses to start (timers and workers stay down) crontab restored, the recorded units started again
files / terminal / editor each live terminal sessions closed, editor stopped, new sessions and file-manager calls by the owner or collaborators refused (staff keep access to the data) allowed again

Result. accounts.suspension.get {id} returns the parts of the current generation (shown on the account detail: "What was suspended" / "What was restored"). A failed part keeps its message and is retried by accounts.suspension_retry (every minute, up to 30 attempts) by publishing the event again; a failing handler also returns its error so the host retries it. A report from an older generation is ignored.

w10-fix-16 follow-ups.

  • Creation while suspended is refused (not created held): a new site, mailbox, app or cron job answers "This account is suspended, so new ... cannot be created until it is unsuspended." (precondition_failed).
  • No answer. The installed cascade modules (web, mail, apps, files, terminal, editor, from core.module.list) are all expected to report. A part with no report is listed by accounts.suspension.get as waiting, and as no_answer a minute after the start (shown "No answer" on the account); accounts.suspension_retry publishes the event again every minute for such accounts (at most 30 times, suspension_republish) until every part answered.
  • Brand change. web keeps the reason and message with the stored page and, on branding.brand.published / branding.page.published, renders the page again for every account that is suspended now and re-applies the vhosts.