Server Manager
The Server Manager plugin provides a web UI for managing remote Joinery production servers. Operations include status checks, backups, database copies, and applying updates -- all from the admin interface at /admin/server_manager.
The system has two components:
- PHP plugin (
plugins/server_manager/) -- admin UI, job creation, command generation - Go agent (
/home/user1/joinery-agent/) -- runs a management node's own job queue, and on a managed node takes work from its plane over the agent channel
Quick Start
1. Install the plugin
The plugin is already in the plugins/ directory. From the admin panel:
- Go to
/admin/admin_plugins - Click Actions on "Server Manager" and choose Install
- Click Actions again and choose Activate
mgn_managed_nodes, mjb_management_jobs, ahb_agent_heartbeats, bkt_backup_targets.2. Install and start the Go agent
Release channel (how the agent normally arrives and stays current)
The agent ships inside the platform release. Publishing an upgrade bundles a signed agent artifact into public_html/agent_dist/ — core, not this plugin, because every node must receive it and no plugin may arrive as a side effect of a core upgrade (this one is commercial and entitlement-gated). The plugin builds and signs the artifact; the core archive delivers it:
manifest.json— agent version plus, per architecture, the artifact filename, its sha256, and an Ed25519 signature over the raw binaryjoinery-agent-linux-amd64.gz/joinery-agent-linux-arm64.gz— the binariesjoinery-agent.service— the systemd unit
publish_upgrade.php cross-compiles both architectures from the checkout named by the server_manager_agent_source_path setting (default /home/user1/joinery-agent) whenever the source version differs from the bundled one, and signs them with the key at {site root}/config/agent_signing_key (generated on first publish; the .pub sibling holds the base64 public key that gets baked into the built agent). The key is 600 root:root, pinned by fix_permissions.sh: a publish is a job of the management node's own agent, which runs as root, so root is the key's only reader and an operator login on the box cannot copy it. A publish that runs as root gives every file it creates the owner and group of that file's parent directory before it exits, so nothing root-owned is left in the tree.Bundling is the first thing a publish does, before the VERSION file, the archives or the release row, because its outcome decides whether the release happens at all:
- No agent source on this box — the existing artifact carries forward unchanged and the publish proceeds. Publishing never depends on a Go toolchain being present.
- Source version matches the bundle —
agent_distis left byte-identical. It sits outside every plugin tree, so a rebuild changes no plugin's tree hash and bumps no plugin's version. - Source is newer and the rebuild succeeds — the fresh artifact is bundled, and because this happens before plugin archives are built it is captured in the
server_managerarchive and its tree hash. - Source is newer and the rebuild fails — the publish is refused. The build error is printed, and the VERSION file, archives and release row are all left untouched. Shipping here would mean releasing an agent the publisher already knows is out of date, and the resulting fleet has no way to tell.
plugins/server_manager/tests/agent_bundle_drift_test.php asserts the same invariant on its own, so a bundle that falls behind its source is caught by the safe test tier rather than by the next release.The converger has a second job: it is the root actor that carries out root
requests. The code tree belongs to root and the web server cannot write it, so
an upgrade, a plugin or theme install, a docs save — anything that writes code —
is queued by the page as a named request and run by the converger as root, with
its transcript polled back onto the page that asked. See
docs/deploy_and_upgrade.md. On a managed node the agent does the same work
through its own jobs; the converger is what a self-hosted box has instead.
First install is handled by the core installer maintenance_scripts/install_tools/install_agent.sh, which runs at every root moment — site install, code upgrade, container start, the node-detail Run Plugin Installers action, and the host converger's timer on a box whose upgrades run from the browser (docs/deploy_and_upgrade.md). It installs the bundled binary, writes the env file with the right JOINERY_CONFIG, and sets up systemd or cron supervision automatically.
The installer is core rather than a plugin's, and runs on every Joinery instance: the agent does a machine's own backups, upgrades and health checks, and only a management node has server_manager turned on. The artifact stays in this plugin's tree because this plugin builds and signs it, and it reaches every node regardless — the plugin is included_in_publish and receives_upgrades, both independent of whether it is active there.
The binary lands on every deployment. Installing is not running: the artifact is converged at each root moment regardless of the switch, so a machine that is switched on later starts a service that is already there rather than fetching, decompressing and verifying one at that moment.
Whether it runs is one setting, agent_enabled, which ships off and is read fresh at each root moment: on starts the agent and sets up its supervision, off stops it and takes the supervision away — the cron keepalive included, or stopping would last a minute. The agent's identity survives an off, so turning it back on resumes the same pairing. A database the installer cannot reach leaves the machine untouched rather than being read as off.
Three ways to set it, all writing the same setting:
- the machine's own Admin → System → Management Node page, which also says what is still needed for it to take effect
php utils/agent_control.php --on(also--off,--join=URL,--leave,--status) on the machine- from a management node, the node detail Agent Channel panel's Turn on the agent over SSH, which switches it on, runs the installer, and has the node ask to join — the fleet path, available only while SSH is, and retired with it at the Phase 3 cutover
install.sh --enable-agentat install time, with--management-node=URLto have that agent ask to join the named plane in the same step. A site this management node provisions passes both automatically, so a node it builds comes up running its agent and asking to join — the one case where whether the machine should run one is already answered. The join is still a request an operator approves here
A web request has no root, so flipping the setting does not itself start the agent — the next container start or upgrade does, or sudo bash {site root}/maintenance_scripts/install_tools/install_agent.sh {sitename} does it immediately. The Management Node page says so, and prints that command when the switch is on and no binary has reached the machine yet.
Every later version change is handled by the agent itself. Between jobs, the agent compares its own version with the bundled manifest. When they differ, it decompresses the artifact, checks the sha256, verifies the Ed25519 signature against the public key embedded in its binary, keeps the current binary as .bak, renames the new one into place, and exits cleanly for its supervisor to restart. The signature check is the security boundary: the site tree is writable by the web user while the agent runs as root, so the agent never installs anything the publisher did not sign. An artifact that fails verification is refused, logged under a === Self-update === header, and not retried until the manifest changes. The update check and the poll loop share one job lock, taken before a job is claimed: a process holding the lock is either running a job or about to exit for a swap, and in both cases the poll skips its tick, so the plane never hands a job to a process that will not live to report it.
If the new binary fails to initialise (config, DB, or schema), it restores the .bak over itself and records the bad version in a .rejected marker — the supervisor restarts the previous working agent, and that version is never reinstalled; the next release supersedes the rejection. On the first fully healthy start after an update, the .bak and any stale marker are removed.
The dashboard's Agent Status bar surfaces all of this from the heartbeat row (ahb_bundled_version, ahb_update_state): a pending update, a refused (verification-failed) artifact, a rolled-back version, or an agent built without an update key.
Manual install (bootstrap fallback)
For a management node that has no bundled artifact yet, build and install by hand:
cd /home/user1/joinery-agent
PUBKEY=$(cat /var/www/html/joinerytest/config/agent_signing_key.pub) make release
sudo bash joinery-agent-installer.sh --verbose [--config /path/to/Globalvars_site.php]make release compiles the binary (passing PUBKEY bakes in the update-verification key so the manual build can still self-update later) and packages it into joinery-agent-installer.sh, a self-extracting script that handles both fresh installs and upgrades with automatic rollback if the new version fails to start.
The installer detects the host's supervision capability:
- systemd hosts: installs
/etc/systemd/system/joinery-agent.service; start withsystemctl start joinery-agent. - No systemd (Docker containers, minimal hosts): installs
/usr/local/bin/joinery-agent-superviseplus/etc/cron.d/joinery-agent(@reboot+ a once-a-minute keepalive) and starts the agent immediately. Logs go to/var/log/joinery-agent.log.
/usr/local/bin/joinery-agent— the binary/etc/joinery-agent/joinery-agent.env— configuration (from example, first install only)
--config PATH stamps JOINERY_CONFIG into the env file, pointing the agent at the right site without editing anything.Every management node needs a live agent. Jobs sit pending until an agent claims them — the node's own paired agent for everything but install_node, which is the one bootstrap the plane runs itself (see Customer-Cloud Fulfillment). The Server Manager → Provisioning page shows an agent heartbeat badge as requirement #1.
Configure (usually not needed)
The agent reads database credentials directly from Globalvars_site.php — no manual configuration required on a standard Joinery install.
The default config path is /var/www/html/joinerytest/config/Globalvars_site.php. If your install is at a different path, set it in the env file:
sudo nano /etc/joinery-agent/joinery-agent.env
# Set: JOINERY_CONFIG=/var/www/html/mysite/config/Globalvars_site.phpOther optional settings in the env file:
| Setting | Default | Purpose |
|---|---|---|
JOINERY_CONFIG | /var/www/html/joinerytest/config/Globalvars_site.php | Path to Globalvars_site.php |
HEARTBEAT_INTERVAL | 30s | How often to update the dashboard status |
AGENT_NAME | joinery-agent | Name shown in the admin dashboard |
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD | (from Globalvars) | Override DB credentials if needed |
Start
sudo systemctl start joinery-agent
sudo systemctl status joinery-agentThe dashboard at /admin/server_manager should now show Agent Status: Online.
If anything is wrong, the agent logs to the systemd journal (systemd hosts) or /var/log/joinery-agent.log (cron-supervised hosts):
journalctl -u joinery-agent -f # systemd
tail -f /var/log/joinery-agent.log # cron supervisionCommon startup errors are self-explanatory — missing DB_NAME, wrong password, or plugin tables not installed. Each error message tells you exactly what to fix.
Upgrade
Agents upgrade themselves from the bundled artifact after each platform release lands (see Release channel above). The manual-install path also accepts upgrades: re-running the generated installer stops the service, swaps the binary, restarts, and rolls back automatically if the new version fails to start.
3. Add managed nodes
Go to /admin/server_manager/node_add (or click Add Node on the dashboard). There are two ways to add nodes:
From the node
A site this management node did not install is enrolled from the node's own Admin → System → Management Node page: the admin there enters this management node's URL, the node's agent generates a keypair and asks to join, and the request appears on the node's API keys tab here for approval after a fingerprint comparison. Add the node's record below first so the join has something to be approved against. The plane never needs a shell on someone else's machine — there is no discovery scan (see specs/ssh_single_bootstrap.md).
A machine this management node provisioned
A cloud instance created from Remote Install runs two agents on a docker box: the site's, inside the container, and the host's own, beside it. Both ask to join on their own, named <slug> and <slug>-host. The instance is known by both the addresses the provider reported for it (IPv4 and IPv6), so a join from either family is recognised as that machine. The site's join is approved on the provision's node, from its API Keys tab, where the provider is asked first to confirm the instance is running at the join's address. The host's join is approved from the dashboard banner: the same provider check runs, then the host node is made at the instance's IPv4 and named on the placement record, which is what routes host-scope work (certificates, site removal) to it.
Rejecting a join is reversible for a day. The dashboard lists joins rejected in the last day with a Reopen button; the machine keeps its key and asks again every five minutes, so a reopened request is answered by the same fingerprint the operator already compared and can then be approved like any other. joinery-agent leave on the machine is what discards the key.
Retiring the install password is proven by the machine refusing it. A retirement job that finds the machine already refusing the password before its script runs completes as retired: the refusal is the proof, so a record that still says "held" catches up when the job is re-run.
Manual
Fill in the form fields directly:
| Field | Example (Empowered Health) | Example (ScrollDaddy) |
|---|---|---|
| Display Name | Empowered Health Production | ScrollDaddy Production |
| Slug | empoweredhealthtn | scrolldaddy |
| SSH Host | 23.239.11.53 | 23.239.11.53 |
| SSH User | root | root |
| SSH Key Path | /home/user1/.ssh/id_ed25519 | /home/user1/.ssh/id_ed25519 |
| SSH Port | 22 | 22 |
| Docker Container | empoweredhealthtn | scrolldaddy |
| Container User | (blank) | (blank) |
| Web Root | /var/www/html/empoweredhealthtn/public_html | /var/www/html/scrolldaddy/public_html |
| Site URL | https://empoweredhealthtn.com | https://scrolldaddy.app |
Admin Pages
All pages are at /admin/server_manager/... and require permission level 10 (superadmin).
The UI is organized around a dashboard + node detail pattern. The dashboard shows the fleet overview; clicking a node opens a tabbed detail page with all operations for that node.
| URL | Purpose |
|---|---|
/admin/server_manager | Dashboard -- agent status, node cards with health dots, publish upgrade, recent jobs |
/admin/server_manager/node_detail?mgn_id=N | Node Detail -- tabbed page for a single node (see tabs below) |
/admin/server_manager/node_add | Add Node -- the record a node's own join request is approved against |
/admin/server_manager/targets | Backup Targets -- CRUD for cloud storage targets (B2, S3, Linode) |
/admin/server_manager/jobs | Jobs -- global job history with filters by node, status, and type |
/admin/server_manager/job_detail?job_id=N | Job Detail -- single job output with live polling |
/admin/server_manager/domains | Domains -- managed domain registrations: hand-overs waiting for a registrar push, failures, and the full ledger |
Node Detail Tabs
The node detail page (/admin/server_manager/node_detail?mgn_id=N&tab=...) has six tabs:
| Tab | Purpose |
|---|---|
| Overview | Status summary (health dot, disk/memory/load/postgres/version), action buttons (Check Status, Install Report and Host Report on a node whose agent ships the primitive, Test Connection), the Host card (below), recent jobs for this node, connection settings (collapsed by default), delete node. The Actions dropdown also offers Run Plugin Installers — queues a run_plugin_installers job that executes every active plugin's declared host_installer on the node as root (idempotent); this is how a bare-metal node picks up system-service configuration (e.g. the mail stack) after a plugin is activated, since it has no container-start moment — and Run Host Housekeeping on a node whose agent ships host_converge: fail2ban housekeeping now, as root, through the host runner (idempotent; the job's transcript is the record, and a host_report follows so the Host card shows the machine after the run) |
| Host card (on Overview) | The machine as the host_report observe primitive last described it, from mgn_last_host_report: the expected services (fail2ban, apache2, php-fpm, cron, postgresql) each active, inactive, failed or absent; failed units; fail2ban jails with their banned counts; SSH auth failures in the last 24 hours as a count; sshd's password-authentication and root-login posture; reboot-required; when unattended-upgrades last ran; disk, memory and swap; and when the report was read. Every value is escaped and an unreadable fact shows as unknown. A node with an agent that has never reported shows the card with one line saying so. Host Report queues one now; the uptime pass queues one on the status cadence |
| Backups | Target indicator, run database/project backup, backup file browser with scan, per-file upload-to-cloud and delete, restore full project from a .tar.gz archive, restore from an incremental chain; the Backups box states the last backup, the last full backup, the oldest held and Last verified restorable, and each run has Prepare, Verify and Restore |
| Database | Restore from a backup file, and the record of database operations |
| Updates | Version comparison (node vs management node), apply update |
| Jobs | Job history filtered to this node, with status and type filters |
Running a command on a node
There is no way to. A management node holds no mechanism for running an
instruction it composed at runtime on a managed node — no Console tab, no
node_exec.php, no run_command job (decision A1).
What a node accepts instead is a primitive: a name it looks up in a vocabulary compiled into its own agent, with parameters validated against a declared shape. A job names an operation; it never carries a command. A node refuses anything outside that vocabulary whatever this plane asks, so what the fleet can be made to do is bounded by what was built and signed, not by what someone can compose.
Work with no primitive yet is done over the operator's own SSH key. That is
deliberate and it costs something real: those sessions are not recorded, where a
console run produced a job row with its command, operator, output and exit
status. The audit trail was given up knowingly, because a recorded path that can
run anything is still a path that can run anything, and a compromised plane
would have used it. sshd stays on across the current fleet (A11), so this is
the working route until the vocabulary covers the operation.
Dashboard Features
The dashboard shows:
- Agent Status -- online/offline indicator with version and last heartbeat time
- Managed Nodes -- cards with health-based status dots (green=healthy, yellow=warning, red=problem, gray=no data), key metrics, and action buttons
- Publish Upgrade -- build upgrade archives from management node source code, as a job of the management node's own agent
- Recent Jobs -- latest 20 jobs across all nodes
- Red: Last check failed, disk > 90%, or PostgreSQL not accepting connections
- Yellow: Disk > 80% or load average > 5
- Green: All metrics healthy
- Gray: Never checked or no data
The agent channel
A node's agent polls its management node over an outbound HTTPS connection, takes one job at a time, and posts the result back. Nothing has to reach in: a node behind NAT or Cloudflare works the same as one with a public address, and the poll itself is how the plane knows the node is alive.
A job names an operation; it never carries a command. The payload is {primitive, params} — a name the agent looks up in a vocabulary compiled into its own binary, plus parameters validated against the bounds that primitive declares. A name the agent does not have, a parameter it did not declare, or an operation class the node does not accept is refused on the node, with the reason recorded and reported back, whatever the plane asked for.
Primitives are grouped into three classes, and a node's acceptance policy is set per class:
| Class | What it covers | Accepted unattended |
|---|---|---|
observe | Collectors, status, listing | Yes |
operate | Restarts, disk, certs, upgrades, backup runs | Yes |
destructive | Restores, decommission | No, anywhere |
The policy lives at /etc/joinery-agent/policy.json, root-owned, outside the web tree. A missing file means the shipped fleet-wide policy above. A file that is not root-owned, or is writable by group or other, is refused outright and the node accepts nothing until a human fixes it — trusting a file the web user could have written would defeat the only thing the file is for.
Connecting a node (the join)
Enrollment is node-initiated and shares no secret. On the node, Admin → System → Management Node: the site's admin enters the management node's URL — just an address. The node's root agent generates an Ed25519 keypair, keeps the private half at /etc/joinery-agent/node_identity.json (mode 0600, root-owned), and sends a join request carrying only the public half and a claimed name. The node's page then shows the key's short fingerprint and waits.
On the management node, the request appears on the node's Detail → API Keys tab: claimed name, source address, and the same fingerprint. Approving is superadmin-only and should happen only if the fingerprint matches the node's own page exactly — the name and address are claims anyone could make; the fingerprint comparison is the entire security of the introduction. Approval binds the public key to the node record; the agent picks it up on its next check and both panels flip to Connected.
The management node stores only the public half, so there is no credential on it that could act as the node — and enrollment adds nothing to steal: no token, no code, nothing copied by a human. A wrong approval is visible (the tab stamps the connection time, agent version, and last poll) and severable with the Disconnect button, which forgets the key and drops the node back to API/SSH routing.
Requests are rate-limited, expire after an hour (the agent renews its own while it waits), and are capped in number. A rejected request retires its keypair — the agent introduces itself with a fresh key on the next ask.
The web tier's only involvement on the node is a handoff through three managed settings: agent_join_request (the URL the admin asked for), agent_join_state (the agent's progress, which the page renders), and agent_leave_request (the admin asked to disconnect). None ever holds a credential.
Routing
A connected agent is routed to — approving the join is the routing decision, and there is no further switch. An operation with a primitive implementation runs on the node's own agent, and only there: a node without a paired agent is refused such a job at build time, with the fix (pair the node) in the message. SSH exists only for the bootstrap pair — install_node and retire_install_password, both run plane-side by InstallJobExecutor over a provision's sealed root password. Disconnecting the agent therefore stops a node's operations until it pairs again.
Either side can end the pairing, and neither needs the other's cooperation. This plane's Disconnect button forgets the node's key. The node's own Management Node page has a Disconnect too: its agent finishes any running job, sends one signed goodbye to /api/v1/agent/leave (so this plane forgets the key immediately), deletes its identity, and then takes no work at all until it pairs again — and it leaves even when the goodbye cannot be delivered, in which case this plane just sees the agent go silent until someone disconnects the node here as well. Both endings run through AgentChannelEndpoint::forgetAgent(), so they cannot drift apart.
Operations cross one at a time. An operation has crossed when JobCommandBuilder has a build_<op>_primitive method for it; transports_for() discovers that by reflection, and ManagementJob::createFromBuild() stores the right shape without any caller knowing which transport ran.
A primitive is routed to a node only when that node says it has it. Every claim carries the agent's own list of the primitives its binary compiled in, and the plane stores it (mgn_agent_primitives). Routing consults that list: an operation missing from it is not sent to the agent — the builder refuses at build time (or, for check_status, falls to the probe transport). The node's own account is the only one that is not a guess — a version number says which release a machine runs, and only the machine says what that release compiled into it.
An agent old enough not to send a list leaves the column empty, and those nodes are answered by JobCommandBuilder::PRIMITIVE_MIN_AGENT_VERSION: a per-operation floor naming the agent version that introduced the primitive. A node below the floor, or with no known version, is refused the primitive — dispatching to a vocabulary that cannot be confirmed buys a certain refusal on the node instead of a clear one at build time.
The management node is a node like any other. It pairs to its own site, and the work it does for itself — publishing a release — reaches it the same way every other operation reaches every other machine: as a primitive its own agent looks up in its own vocabulary. There is no second job source anywhere in the fleet. A row written into mjb_management_jobs by hand executes nothing, on any machine.
Reading refusals
A refused job is a terminal failure like any other, and reads as one everywhere a job status is shown — the message says Refused by the node: and then the node's own reason. The outcome the node actually reported is also recorded on its own (mjb_agent_outcome: completed, failed or refused), so a refusal can be counted rather than found by matching the text of an error message. ManagementJob::refusalCountForNode() is that count, windowed.
This matters more as the vocabulary grows. A node refusing work is a node whose plane is asking for something it should not be, or whose policy has been tightened without the plane noticing — either way it is the number, not the prose, that an alert reads.
A job this plane gives up on after repeated lost claims records no node outcome. The node never reported one, and inventing a verdict for it would make the refusal count untrustworthy in exactly the situation where it is being consulted.
When a claim does not come back
An agent claims a job and then reports. If it never reports — it crashed, the box rebooted, the network went — the job would otherwise sit in running holding that node's concurrency lock. A claim older than 15 minutes is returned to the queue with a note in the job output saying so, on every poll and on every scheduled uptime pass. After three such claims the job fails instead, naming the node: a job that kills three agents will not succeed on the fourth.
Status facts are kept current on a cadence. Version, certificate, disk and memory facts are measured only by a check_status job. Every scheduled uptime pass queues one for each enabled node whose agent offers the primitive and whose facts are older than six hours (RunNodeUptimeChecks::STATUS_REFRESH_SECONDS), whatever the node's uptime setting — the up/down probe and the facts measure different things. A queued or running check_status, or one completed inside the window, counts as cover, so a stale node yields one job per window.
Endpoints
POST /api/v1/agent/join, /join_status, /claim, /result, /leave, /quiet, /artifact. Join and join_status are unauthenticated by nature — until approval there is no identity to authenticate — and grant nothing; they are rate-limited, validated, and bounded in number. Claim and result are signed with the node's key; the plane verifies against the public half it holds, and selects jobs by the identity the signature proves, never by anything in the request body. Requests are schema-validated, size-capped, and refused for a clock more than five minutes from the plane's. These are a separate route family from /api/v1/management/*, which runs the other way — the plane calling in to a node's web tier — and stays status-only.
/artifact is the one response that is bytes rather than a JSON envelope, and it is described under Machines with no site. Its body fetches are metered on their own bucket (api_agent_artifact_rate_limit_requests, per address per window) and each is recorded, because they are the expensive ones; the small manifest fetches ride the general agent-channel limit.
The general limit (api_agent_rate_limit_requests, per address per window) counts one request-log row per request on the channel — joins, results, manifest fetches, unknown paths, refused signatures — written at shutdown with the outcome. The one request it does not count is a claim that succeeded: a fleet polls on a seconds cadence, which is tens of thousands of requests a day against a few hundred of everything else, and the rate-limit check counts rows with a query. A claim that fails does count, since an unsigned or mis-signed flood looks exactly like that. AgentChannelEndpoint::meterOutcome() is the rule.
Machines with no site
Some machines a management node manages host no Joinery site at all — a mail relay, a Docker host. They run the same agent in a machine posture: no site root, no local database, no admin page, and no platform release ever delivered to them. Two things follow, and both are served by the same endpoint.
Keeping the agent current
A machine with a site tree finds its next binary in public_html/agent_dist, put there by its own upgrade, and updates without asking anyone. A machine with no site has no such directory, so it fetches from its management node instead: a signed request to /api/v1/agent/artifact for the dist manifest, and — when the version differs from what it is running — for the binary for its architecture.
Nothing about verification moves. The agent decompresses, checks the sha256, and verifies an Ed25519 signature against the public key compiled into its own binary at build time, exactly as it does for a locally delivered artifact. The management node does not hold the release key and cannot sign an agent, so a plane serving a hostile binary produces a refusal and a recorded rejection. The endpoint is a delivery route for bytes that were always verified on arrival.
The request names a kind from a closed set and, for a binary, an architecture matched against a pattern. It never names a file: the plane resolves what to send out of its own manifest, so nothing a node sends is read as a path.
The support bundle
A script-invoking primitive verifies its script against the signed release manifest before running it as root. On a site that manifest is RELEASE_MANIFEST and RELEASE_MANIFEST.sig at the site root, beside public_html; install.sh places it there when it lays down a fresh site (bare metal and the Docker image alike, from whichever archive it actually applied) and upgrade.php replaces it on every upgrade. A site tree without it refuses every script primitive, apply_update included. On a machine with no site there is no release manifest, so there is nothing to verify against and no script primitive can run at all — which would leave the machine's whole vocabulary in embedded Go.
Only the site that built the shipped agent signs. The agent verifies against the key compiled into its binary, so the key that built the bundle in agent_dist/ is the only one whose signatures any node will accept; agent_dist/manifest.json records it as signing_public_key. TreeManifestPublisher::authority() compares that key with the site's own config/agent_signing_key and the publish signs only when they are the same (a bundle that predates the record counts as built here when the agent source is on the box). A site that received its agent from upstream — getjoinery, republishing for the beta testers — holds a different key, and a manifest it signed is one its own agent and every node it serves would refuse. Such a site carries forward the manifest it received: into the core archive from its own site root, and for each theme and plugin from the directory that ships, leaving its live tree exactly as upstream delivered it. The support bundle is not rebuilt there either; the received one rides along in agent_dist/. A received manifest that turns out to be signed with the site's own key, or that does not verify against the bundle's key, aborts the publish and says so, rather than republishing a tree nothing can run.
When a node cannot verify its own scripts
Verification fails in two ways, and they are opposite problems.
A file does not match its signed hash, or is not listed at all. The manifest is good and is doing its job: the file on disk is not the file that was published. That script refuses, and nothing repairs it automatically — find out why the file differs.
The manifest itself cannot be used — absent, unparseable, or signed by a key this agent does not carry. Nothing is known about any file, so every script primitive refuses at once, apply_update among them. A node in this state cannot be repaired by sending it work: the upgrade that would deliver a good manifest is refused by the check that is failing, and no non-script primitive writes a file.
The agent recovers from the second on its own. Every ten minutes it asks whether the core manifest is usable; when it is not, it reads the release its site runs from public_html/VERSION, asks the plane for that release's signed manifest over the artifact endpoint (release_manifest, naming an artifact and a version — never a path), verifies it against the key compiled into its own binary, and writes the pair to the site root. A manifest that does not verify is refused and not asked about again until what is on offer changes. The plane holds no release key and cannot sign one, so serving these bytes gives it no new authority — the same footing on which it already serves agent binaries. ReleaseManifestSource reads the pair straight out of the published archive, so any version still in static_files can be served; one pruned by retention cannot, and the agent says so rather than retrying silently.
Recovery is driven by ArtifactManifests.Usable(), which reports only on loading the manifest. A hash mismatch is raised by Verify() and never reaches it, so recovery is structurally incapable of firing on a modified file — the case where replacing the manifest would destroy the evidence.
The manifest is read back into the agent under an 8 MiB cap of its own; the 64 KiB cap that bounds every ordinary plane answer cannot carry one. Each file is landed through a temporary file created with O_EXCL in the site root, with mode and ownership set on the descriptor rather than by path — the site tree is owned by the web user, so a root process writing there by name is a path a web-layer compromise can redirect.
A verifier is cached only while the manifest on disk is still the one it was parsed from, keyed on size, mtime, mode and inode. One agent build spans many core releases, so a parse held for the life of the process would have a routine upgrade reading as every file having been modified since release, and a manifest broken under a running agent staying invisible while the node reported itself healthy.
Scope is the core release. A plugin or theme carries its own manifest, and a node whose plugin manifest is bad still has a working apply_update, so an ordinary upgrade repairs it.
The state is visible without opening a job. A node reports script_trust on every poll, and a refusal that carries either wording is classified and stamped on the node as it arrives (mgn_script_trust, with when it was first seen, the reason verbatim, and the job type that refused). The dashboard names such nodes above the backup alarm, because failing backups there are a symptom. A node's own report wins over a stale refusal in both directions; an absent report — an older agent, a machine with no site — is left alone and never reads as healthy.
The support bundle closes that. A publish builds public_html/agent_dist/support_bundle.tar.gz: a small tree carrying the scripts those machines' primitives invoke, at site-root-relative paths, with its own RELEASE_MANIFEST and .sig signed by the release key. A siteless machine fetches it over the same artifact endpoint, verifies the signature against its baked-in key, checks every listed file's hash and that the tree holds nothing the manifest does not list, then unpacks it root-owned to /opt/joinery-agent/tree. Script primitives resolve against that tree when there is no site root; a machine with a site root uses the site root, and a machine with neither refuses as it always has.
The bundle's contents are a deliberate list in SupportBundlePublisher, not a directory sweep — every entry is a script some primitive names, and adding one is a visible decision. The Docker host is its consumer: decommission_site runs the bundled remove_account.sh. A script that sources another needs that sibling in the list at the same relative path. Binaries the scripts invoke ship under bin/ with one file per architecture (bin/<tool>-linux-amd64, bin/<tool>-linux-arm64) and the script selecting on uname -m: one bundle carries both, so no machine can end up holding one built for the wrong architecture.
Its version is the hash of its own manifest body, so a publish that changes no bundled script leaves the bundle byte-identical and no machine downloads anything. The plane also advertises the tarball's sha256, which an agent uses only to skip a transfer it already has — what makes the tree runnable is the signature inside it.
Each machine reports the bundle version it holds on every claim, stored as mgn_agent_bundle_version. On a machine nobody logs into, that column is the evidence the bundle arrived.
Enrolling and switching one on
There is no admin page on these machines, so the ceremony is the same one reached from the command line:
joinery-agent join --management-node=https://plane.example.com [--name=NAME] [--no-wait]
joinery-agent status
joinery-agent enable | disable
joinery-agent leavejoin generates the keypair, sends only the public half, and prints the fingerprint to compare against the pending request on the plane — the same comparison, and the same approval, as a node enrolling from its own admin page. --name is what the plane's pending list shows for this machine (default: the hostname, which on a fresh VPS is usually localhost). Without --no-wait the command waits up to five minutes for the approval; with it, the command returns once the ask is lodged. Either way the running agent finishes the join itself: it watches the staged keypair, and when the plane approves — minutes or hours later — it stores the credential and starts polling without a restart or anyone at the terminal. install.sh docker --management-node=URL --node-name=NAME is how a plane-built host asks. The run switch is the marker file /etc/joinery-agent/enabled, which enable and disable write directly: on a machine with no settings table, the marker is the switch rather than a projection of one.
Install with install_agent.sh --siteless, which is explicit and never inferred — a missing site config keeps meaning "not my machine, exit 0" for everything else. --dist-dir=DIR names where the first artifact is, since no release delivered one.
The Docker host as a node
A shared Docker host is a plain ManagedNode in machine posture — paired, addressed and versioned like any node, with no web root and no container name. install.sh docker installs the host's own siteless agent as part of the install and, given --management-node=URL (and --node-name=NAME for the pending list), lodges its join; the machine that runs our Docker is managed by its own agent, which is the only path to certificate renewal or site removal once SSH is gone. The placement record (mgh_managed_hosts) stays what it is: which containers live where. One nullable link joins the two worlds: mgh_mgn_host_node_id on the host record names the host's own paired node. Approving the host agent's join sets it (ManagedHost::link_host_node fills an existing placement record for the host's address that has no host node yet), and the host's edit page sets it by hand. That is the routing chain for host-scope work — a container victim's mgn_mgh_host_id finds the host record, the host record names the host's node, and the job is addressed there. Sibling containers on a host are found by mgn_mgh_host_id and nothing else; ManagedHost::ensure_for_node() mints or links the placement record the moment a node needs a container port, so the FK is never absent where it matters.
A host record can be deleted (soft) from its edit page — last, deliberately: the delete refuses while any container site still names it as placement, and while its own agent node record is live.
Removing a container site (decommission)
Removing one site from a shared Docker host is host-scope work — the container, its {site}_* volumes, the reverse-proxy vhost and the web root all live outside the container — so it runs as the decommission_site destructive primitive on the host's own agent. The wire carries exactly one parameter, the site's name (^[a-z0-9_-]{1,50}$); every path is composed on the host from compiled-in patterns, and the plane cannot express one.
The site being destroyed approves its own removal. The host agent locates the victim through two host-owned files — the vhost names the container's published web port, and the config volume's host-side path (/var/lib/docker/volumes/{site}_config/_data/) carries its database credential — then connects over the container's published loopback database port (web port + 1000) and stages an approval into the victim's settings table, sealed to the victim's proven backup recovery key. The victim's own Backups page renders it (DecommissionApprovalPanel: this site will be destroyed, permanently), with the site's own record of its last completed offsite upload as the load-bearing fact. The ceremony has its own settings rows, HKDF context and plaintext tag (decommission_approval_*, joinery-decommission-approval:), so a decommission answer and a restore answer can never satisfy each other. The plane is not in the path: the primitive declares no parameter an answer could travel through. The victim's credential is held in memory for the life of the connection, never at rest; everything read from the victim is treated as untrusted display data.
Only after the answer verifies does the host run the bundled, self-verifying remove_account.sh, whose output carries its own verdict (DECOMMISSION_VERIFIED / DECOMMISSION_FAILED_VERIFY). Dispatch marks the victim quiet (mgn_agent_quiet_time) so its agent dying with the container reads as a deliberate silence.
build_decommission_node routes or refuses, each refusal naming the operator's next step: a relay goes through the relay flow; a bare-metal node is a whole machine — delete the instance at the provider, then delete the node record; an unpaired host says "pair the host's agent"; a victim below the core release that carries the approval panel (DECOMMISSION_PANEL_MIN_CORE_VERSION) says upgrade it first; a victim with pending or running jobs says finish or cancel them. There is no unattended path to destroying a site — a victim that cannot render its own approval does not take this path.
Job Types
| Job Type | Description | Destructive |
|---|---|---|
install_report | How the node's first-boot install went, as the install_report observe primitive: whether it finished, how its DNS and certificate steps ended, warnings and errors, and the tail of the install log (/var/log/stackscript.log, or cloud-init's), read off the marker lines the installer prints. Covers an install that reached the agent: one that died before the agent was installed, or a machine nobody has paired yet, has no agent to ask and its log is reachable only from the console | No |
host_report | The machine, as the host_report observe primitive: failed systemd units, the expected units and their state, fail2ban jails with ban counts, SSH auth failures in the last 24 hours (a count only, never a username or an address), sshd posture as sshd -T prints it, disk, memory, swap, reboot-required and the unattended-upgrades last run. The node runs maintenance_scripts/sysadmin_tools/host_report.sh from its own tree, verified against the release manifest, with no argv and no stdin; every list and string is capped in the script, a fact it cannot read is the string unknown, and the plane caps the object again on intake (JobResultProcessor::sanitise_host_report) before storing it in mgn_last_host_report. Its own word beside check_status (the site), stored in its own column; both are queued on the same cadence by the uptime pass | No |
host_converge | Run host_housekeeping.sh — fail2ban's jails and drop-ins, the trusted-proxy list, the rest of the host's daily housekeeping — as the host_converge operate primitive: _plugin_installers_start.sh --only=host_housekeeping.sh under the runner lock, the same root runner the host timer uses, touching neither the converge stamp nor the timer's last-run record. The wire carries the name and nothing else; the --only= argument is a constant compiled into the agent, never a parameter, and the runner refuses any name outside its CORE_INSTALLERS anyway. The runner exits 0 on every path, so JobResultProcessor::process_host_converge reads the transcript: host_housekeeping.sh: ok is green (on a container node the installer reports that fail2ban is the host's and does nothing, which is a complete answer); a WARNING, a missing or untrusted installer, a lock it never got, or silence is red with the reason. A completed run queues one host_report for the node. Primitive only; the repair step recipe fail2ban calls | No |
check_status | Disk, memory, load, uptime, PostgreSQL, version and database list. On a node with an agent this is the check_status observe primitive, which collects all of it without running a command; on a Joinery site without one, the management API; on a machine with neither, a probe of what the machine publishes about itself | No |
backup_database | Run backup_database.sh, optionally upload to cloud | No |
backup_project | Run backup_project.sh (DB + files + Apache config), optionally upload | No |
list_backups | List backup files on local server and cloud target | No |
upload_backup | Push one existing backup file from the node to its cloud target; keeps the local copy | No |
delete_backup | Delete backup files from local, cloud, or both | Yes |
copy_database | Dump source DB, transfer, restore on target | Yes |
restore_database | Restore a backup file on a node | Yes |
restore_project | Restore a full project .tar.gz (files + DB) in place on an existing node, then reconcile it to that machine. Runs restore_project.sh --force --domain <domain>, which cascades --non-interactive into restore_database.sh. Pre-restore snapshots of DB and files written to /backups/auto_pre_project_restore_*. Every file in the archive must exist under the project directory afterwards or the restore fails and names what is missing | Yes |
restore_chain | Restore a node from an incremental backup chain — what the fleet's scheduled backups actually produce. Fetches the chain manifest, recovers the chain key on the node from the node's own backup_site_key, downloads every artifact the manifest names up to the chosen run, then runs restore_chain.sh, which verifies each artifact against its recorded size and hash before writing anything and applies them in order | Yes |
apply_update | Run upgrade.php on target | Yes |
publish_upgrade | Build and sign a release from a node's own tree, as a primitive of that node's own agent, dispatched by the management node that manages it (node detail, Updates tab, for any node whose agent reports the primitive). A management node that manages itself is the same case pointed at its own record: it connects to itself from its Management Node page, approves the request on its own dashboard, and ManagedNode::self_node() is the row its Publish page dispatches to. A management node that is another plane's node is published from that plane, and its Publish page says so (ManagedNode::managed_by()). See specs/publish_as_node_action.md | No |
install_node | The bootstrap SSH session: fetch the release, install.sh docker (host agent), install.sh site … --enable-agent (a clone adds --clone-from and pulls the source over HTTPS). Run by InstallJobExecutor on the plane over the provision's sealed install password | No (target must be clean) |
retire_install_password | The bootstrap's closing session, once every agent the install put on the machine is admitted: over the same password, write /etc/ssh/sshd_config.d/00-joinery-agent-managed.conf (password and keyboard-interactive authentication off, root limited to prohibit-password) and restart sshd, so the machine stops accepting it. InstallJobExecutor completes the job only after a fresh login with the password is refused; the provision pipeline then erases the sealed password | No |
provision_certificate | Issue the node's certificate as a primitive on the issuer: the node itself on bare metal, its host's own agent for a container (for_node_id names the site). Driven by ProvisionPendingSsl, which observes a certificate the machine already reports before asking | No |
clone_export_arm | Hand the SOURCE of a clone one export key for the length of a provision (empty disarms). The setting name is compiled into utils/clone_export_arm.php on the source | No |
fleet_enroll | Seed a new site's fleet-service URL and API key pair (three settings; the names are compiled into utils/fleet_enroll.php). The secret is blanked from the job row once the node answers | No |
backup_run | This management node's own backup of a node. The node runs its backup engine — chain, envelope, upload, local sweep — with the bucket and a write-only credential supplied for that run and never stored there. What opens the archive is not supplied: the node seals to the recovery key it holds and has verified | No |
stage_chain | Put a whole backup chain back on the node, ready to restore: the plane signs a link to every object under the chain (JobCommandBuilder::sign_chain_links) and the node reads its own manifest, fetches what a restore of the run needs, checks each against its upload ledger and recovers the chain key from its own backup_site_key. The stage_chain operate primitive (script utils/stage_chain.php); no approval | No |
verify_backup | Prove one of the node's backups restorable without restoring it, as the verify_backup operate primitive (script utils/verify_backup.php, agent 1.24.0+): the same links as stage_chain plus a level — 2 opens and reads every artifact to the end, 3 rehearses a restore into a scratch tree and a throwaway database on the node. Nothing on the site is touched, so no approval; the schedule dispatches level 2, a person chooses 3. The VERIFY_* result lines stamp mgn_backup_verify_* (JobResultProcessor::process_verify_backup). See Verifying backups | No |
decommission_node | Permanently remove one container site from its shared host, as the decommission_site primitive on the host's own paired agent — the site itself approves its removal first (see Removing a container site) | Yes |
Note on bare-metal nodes: install.sh server disables root SSH during the bootstrap; the session that ran it is the last one the plane opens. Everything after it — backups, restores, certificates, upgrades — runs on the node's own agent, which is root.
One-Click Node Install
Dashboard → Install New Node opens a form that provisions a Joinery site in a single click. The Target Host dropdown offers three kinds of target:
- A known host (or Other server with manual SSH details): the form creates the ManagedNode and dispatches the
install_nodejob immediately. - Create a new cloud instance: no server exists yet. The form records an admin-origin
CustomerCloudProvision(connected cloud account, region, instance type, plus all install parameters) and the Provision Customer Cloud task births the instance, creates the node, and dispatches the install — see Customer-Cloud Fulfillment. The instance is created in, and billed to, the selected connected account; Linode grants expire after two hours, so connect (or re-connect) shortly before submitting. In-flight provisions appear in a banner at the top of the dashboard.
mgn_skip_joinery_checks set — but no site is installed (no web root, site URL, or SSL flow). Completion is a passing check_status job. This is how infrastructure nodes that host no Joinery site — a mail relay shard, for example — enter management; the role's own provisioning (e.g. the mailbox plugin's provision-relay job) builds on the bare node afterward. Bare is admin-origin only; orders always install a site.Two install types:
- Fresh: empty Joinery site with default schema. Admin picks the domain. The admin login is
[email protected]with a password generated for that site alone — there is no shared default. Like the generated Postgres password, it stays on the node rather than in the management node: read it at/var/www/html/{sitename}/config/admin_credentials.txt(root only), or set a new one withmaintenance_scripts/sysadmin_tools/reset_admin_password.php.usr_force_password_change=true, so the first sign-in forces a new password. - From Backup: fresh install + restore of a source node's DB and project files, then reconciliation to the new node — its own domain (the node's recorded URL), its own deployment shape, its own paths. Use source admin credentials to log in; cut DNS over when ready and the certificate is issued on its own.
install.sh -y -q docker --management-node=URL --node-name=NAME-host (Docker plus the siteless host agent, which asks to join), then install.sh -y -q site --docker SITENAME - DOMAIN PORT --enable-agent --management-node=URL. Without --no-ssl, install.sh writes the universal proxy vhost on the host (both ports forward X-Forwarded-Proto https; the :443 block is guarded on the certificate path), tries for a certificate, and when DNS is not pointed here yet arms the host's own retry timer, which issues once it is. The container name is the site name this plane chose and is recorded at dispatch; the published port is read back from the CONTAINER_PORT= line install.sh prints. A clone (from_backup) adds --clone-from=https://SOURCE --clone-key=KEY: the new machine pulls the source's database, uploads, themes and plugins over HTTPS from the source's own utils/clone_export, after this plane armed the source through its agent's clone_export_arm primitive (the key is sealed on the provision, cvp_clone_key_sealed, and the source is disarmed when the provision is done). A bare instance is the docker half alone. Two join requests arriving is the verification; nothing after the bootstrap opens SSH.The mgn_install_state column tracks the lifecycle: installing → NULL (success) or install_failed (failure). On failure, the node detail page surfaces a Retry Install button; the target must be cleaned manually (e.g. rm -rf /var/www/html/SITENAME) before retry because install.sh refuses to overwrite an existing site. Postgres passwords are auto-generated and stored in the target's Globalvars_site.php — Server Manager does not capture or display them.
Docker notes:
- The reverse proxy step (
manage_domain.sh) is skipped when the domain is a bare IP address — a routable hostname is required for ApacheServerName-based virtual hosting. With an IP domain, the site is accessible directly on its mapped port. backup_project.shrequiresrsync. The bare-metal and Docker install scripts install rsync as part of the essential packages (install.shline ~948). Sites installed before this was added can install it manually withapt install rsync.- After a Docker install,
mgn_container_nameis automatically recorded in the management node DB so future jobs correctly usedocker execto reach the site.
SSL Management
SSL State
Each node tracks its TLS certificate state in mgn_ssl_state:
| Value | Meaning |
|---|---|
null | Unknown or not configured |
pending | Waiting for DNS propagation; certbot has not run yet |
active | A valid Let's Encrypt cert is installed |
failed | Provisioning failed after repeated retries |
Automatic Detection
A check_status on a node with an agent enumerates the certificate lineages the node holds, and JobResultProcessor matches them against the host this plane expects. Where no certificate is reported, an HTTPS probe from here catches a site whose TLS terminates at an edge. JobResultProcessor updates mgn_ssl_state and stores ssl_domain, ssl_expiry_raw, and ssl_expiry_ts in mgn_last_status_data. State transitions:
CERT_FOUND→ sets state toactive(from any prior state)CERT_MISSING→ clears state tonullonly if currentlynulloractive; never overwritespendingorfailed
Manual Provisioning
The Overview tab shows an SSL Setup card when mgn_ssl_state is not active, the node has a domain in its site URL, and mgn_cert_expiry_ts is empty. The last condition excludes nodes whose served certificate already covers their name (see Certificate monitoring) — their cert lifecycle is owned by an external renewer (e.g. Caddy), and the card's certbot-based provisioning does not apply to them. The card:
- Resolves the domain via DNS and shows whether it points to the node's host IP
- Enables the Provision SSL button when DNS is ready (or when the host IP is not configured)
- On submit: starts the certificate chain (
ProvisionPendingSsl::begin_chain), setsmgn_ssl_state = 'pending', redirects to the jobs tab
provision_certificate primitive on the issuer — the node's own agent on bare metal, its host's paired agent for a container (routed through the placement record, mgn_mgh_host_id → mgh_mgn_host_node_id; a container on a host with no paired host agent has no issuance path and the refusal says to pair the host). The job names the site in for_node_id, and JobResultProcessor reads what the script actually did (SslProvisionOutcome) before setting that site's mgn_ssl_state to active.Cloudflare-proxied domains are gated on a routing probe first: the ssl_probe_place primitive writes a one-time token into the site node's webroot, the management node fetches /sm-ssl-probe.txt through the domain, and ssl_probe_clear removes it either way. The token is only fetchable because core serve.php routes that URL to views/sm_ssl_probe.php — a Joinery front controller never serves arbitrary webroot files, so a node whose code predates that route (PROBE_MIN_CORE_VERSION) is refused by name rather than blamed on Cloudflare. Only a match — proof that traffic for the domain actually lands on this node — dispatches the certificate; a miss leaves the domain pending until the customer's DNS actually routes here. The universal vhost already forwards X-Forwarded-Proto https, so nothing is patched.
Automated Provisioning (installs only)
For nodes installed via Install New Node, ProvisionPendingSsl (scheduled hourly) watches for nodes with mgn_ssl_state = 'pending'. It observes first: a machine this plane installs issues its own certificate during the install or on its host's retry timer, and the node's (or its host's) check_status enumerates every lineage under /etc/letsencrypt/live — a CA-issued, unexpired lineage covering the domain flips the node active with no job at all. Only then does it ask: it checks DNS and drives the certificate chain above on the issuer. After ~16 hours of failed certificate attempts it flips state to failed — except a Cloudflare domain still waiting on its DNS cutover (CF_ROUTING_UNVERIFIED), which never flips: a cutover the customer has not made is not a fault, and can legitimately take days. Instead the routing wait is paced — hourly for the first ROUTING_FAST_ATTEMPTS tries, then one try every ROUTING_SLOW_GAP (six hours) — and entering the slow lane emails the operator once (recipient chain: server_manager_provisioning_admin_alert_email → webmaster_email → first superadmin; the sent marker is stamped into a job row's parameters as routing_alert_sent). The slow lane and the alert apply only while the domain still resolves to Cloudflare: once it repoints, the next attempt is due within the hour, and the 16-hour give-up window opens fresh at the first non-routing failure — time spent parked at Cloudflare never burns it. Manual provisioning via the Setup card is the fallback.
Hosting Provisioning
Paid hosting orders on getjoinery become installed, SSL'd Joinery sites with
no human touch. The Poll Hosting Orders scheduled task polls the
getjoinery API each cron tick for paid orders carrying an answer to the
configured domain Question, and fulfills each one in the mode the product
declares in pro_fulfillment_provider:
- Shared host (the default, any other value): the pipeline picks the
least-loaded provisioning-enabled
ManagedHost, assigns the next Docker port, and dispatches aninstall_nodejob. The buyer's site is a container on infrastructure the operator owns. customer_cloud: the buyer's site runs on its own cloud instance. A product opts in by picking Customer cloud server in the product-edit Purchase grants picker (CustomerCloudFulfillment, registered with the store's FulfillmentRegistry from serve.php); that stamps the provider value and contributes the domain question as a checkout requirement automatically. The reference picked beside it decides *whose cloud account the server is born on, and it is the product's decision rather than the buyer's: - the buyer's own account — they connect it, the provider bills them, and none of the operator's keys are involved. See below. - the operator's account (hosted) — the operator's own token creates it, there is no Connect page, and the mail, billing and banner legs apply. See Hosted tier.
install_node completes, the welcome email goes
out, and ProvisionPendingSsl turns HTTPS on once DNS resolves.A hosting product can also sell the buyer their domain name in the same click — see Managed domain registration below. That leg is orthogonal to compute mode: it attaches to shared-host and customer-cloud products alike, and when it is present the buyer never touches DNS at all.
Activation — the Provisioning page
Server Manager → Provisioning (/admin/server_manager/provisioning_setup)
activates the pipeline: every requirement shows a live status badge, and each
automatable step is a one-click, idempotent action backed by
includes/ProvisioningSetup.php — mint the store API service user
(provisioning@<host>, permission 5, password recovery disabled) and machine
key and write the API settings (with a loopback probe badge and key
rotation), create the domain Question, save the email settings, activate the
scheduled tasks (the provisioning umbrella, which runs order polling,
customer cloud, SSL and the managed-domain phases in one pass, plus the
core Send Queued Emails task that drains the welcome-email queue), the
domain-registrar credentials, and the customer-cloud settings (SSH key path
with key/.pub existence badges, referral URL, instance defaults). The page
also shows what stays manual: attaching the question or the Managed domain
requirement to hosting products, opting a shared host in, and registering the
Linode OAuth app. When the store
is a remote site rather than the management node itself, the service key is
minted on the store site and its values entered in the API settings fields.
The management node holds no SSH key. A machine it creates gets a one-time
root password that the install job uses for its single bootstrap session and
that is retired once the machine's agents are admitted; nothing of the
platform's is ever placed on the machine. The install runner never reads a
key file, and config/ on the management node contains none.
What stays manual
The Provisioning page reports these, and cannot do them:
- Job agent. Every job the pipeline creates sits pending until a
joinery-agent polling this site's queue claims it. Install the agent on
the management node's host (`sudo bash joinery-agent-installer.sh --config
<Globalvars_site.php path>`, built from the agent repo's
build_installer.sh); hosts without systemd are detected and supervised via cron. The page's agent badge must show Online before anything below can execute. - Per hosting product. For customer-cloud fulfilment, pick Customer
cloud server under Purchase grants; that stamps the provider and asks the
domain question at checkout. For a buyer-account product, put the Connect
link (
https://<management-node-host>/profile/server_manager/connect_cloud) in the after-purchase message; the Connect page is deliberately in no member menu. For shared-host products, attach the domain question as a requirement instead — the attachment is what makes an order a hosting order. To sell the domain in the same click, see Managed domain registration below. - Shared-host fulfilment only. Opt at least one managed host in from the dashboard (Edit → Max Sites + Provisioning Enabled). Its IP is sent to customers as the DNS A-record target, so it must be routable.
- Buyer-account fulfilment only. Register the OAuth client in Linode
Cloud Manager (Profile → OAuth Apps → Create OAuth App, not public,
callback
https://<management-node-host>/oauth_callback) and enter the client id and secret at Admin → System → OAuth Providers. The referral URL from Cloud Manager → Profile → Referrals goes in the page's field.
Verifying end to end
- Place a test order for a hosting product with a test domain.
- Wait up to 15 minutes for the next provisioning run.
- A new node appears under Admin → Server Manager at
install_state = installing(shared host), or the buyer's Connect page shows progress (buyer account), or the buyer's sites page does (hosted). - When the install job completes, the welcome email reaches the buyer.
- Point the test domain's A record at the node IP.
- The node's SSL badge flips from
pendingtoactiveon the next Provision Pending SSL run once certbot succeeds.
Failure modes
| Symptom | Likely cause |
|---|---|
| No node appears after 15 min | API credentials wrong (the page's probe badge), or the question not attached to the product — check the provisioning task's last run in Scheduled Tasks |
Node stuck at install_failed | The install job failed — open the job, fix the host, Retry |
SSL stuck at pending for hours | DNS not pointing at the node — dig domain.com |
SSL badge failed | ~16 hours of certbot failures — job output names rate limits or DNS |
| Welcome email not received | The store's queued email queue; welcome_from_email must be SPF/DKIM-authorised |
| Domain field says registration is unavailable | No registrar credentials, or no domain-year product selected — the page's Sellable badge names which |
Domain row pending with a transient error | The registrar is refusing or unreachable; the queue page shows its words. An IP-allowlist error names the address to add |
Domain row failed | Terminal — name taken or refused. Resolve with the buyer, then Retry from the Domains page |
[managed-domain] Paid but never registered: order N | The buyer kept the domain line and dropped the hosting line. Refund the domain line, or register and file the row by hand. Reported once per order |
Domain row failed saying the order did not pay for it | The domain-year line was removed or repriced in the cart. Nothing was registered — settle the difference, then Retry |
| Domain registered but mail records never publish | The box's mailbox provisioning refused, or the mail leg stopped — the row's error says which. A DKIM key not yet generated keeps the step open on purpose |
Customer-Cloud Fulfillment
The buyer connects their Linode account once at
/profile/server_manager/connect_cloud (the Connect page — also the
re-connect page if a grant is later revoked). The grant flows through the
platform OAuth2 core (provider linode, consumer purpose
customer_cloud, scope linodes:read_write only — no account or billing
access). Tokens are SecretBox-encrypted on the buyer's
CustomerCloudAccount row.
Each provision is a CustomerCloudProvision row that the
Provision Customer Cloud scheduled task advances:
pending_connect → (grant arrives) → ready → instance created on the
connected account → booting → running + IP → ManagedNode + install_node
job → installing → done (or failed, which alerts the ops address).
Provisions have two origins (cvp_origin):
- order — created by a customer-cloud purchase. Starts at
pending_connect, installs fresh + Docker, and sends the buyer welcome email on completion (the order-item linkage drives it). - admin — created by the Install New Node form's cloud-instance target.
Starts at
ready(the admin picked an already-connected account), carries its install parameters on the row (cvp_docker_mode,cvp_install_mode,cvp_source_node_id,cvp_backup_source,cvp_port,cvp_sitename), and sends no welcome email. The row belongs to the grant owner (cvp_usr_user_id), so a stale grant is re-connectable by the person who can actually re-consent.
server_manager_linode_referral_url
so new Linode signups carry the referral credit. A token-refresh failure or a
provider 401 parks the provision back at pending_connect and flags the
account link — a fresh grant resumes it automatically.Customer-owned node semantics: the resulting node is a normal
ManagedNode (installs, upgrades, uptime checks, SSL all apply). Provisioning
is keyless — the instance is created with an install password sealed onto the
provision row (cvp_root_pass_sealed) and no SSH key of ours, so
mgn_ssh_key_path is empty; the machine is managed by its own agent, which
joins at install and whose join a human approves here.
The install password is retired, not kept. cvp_install_password tracks
it: held from the moment it is sealed; once the provision is done and
every agent the install put on the machine has been admitted (the site's
agent, and on a docker box the host's own agent too), the provisioning task
queues a retire_install_password job, which writes the sshd drop-in that
turns password login off, over the password (retiring). The executor completes that
job only after it tried the password again and the machine refused it; the
next pass erases the sealed password (retired). A job that could not prove
the refusal fails, the password is kept (retire_failed, reason on the row,
ops emailed), and re-running the job from its detail page tries again. A
provision that fails with a live instance keeps its password too: the
password goes only when the install is complete. The dashboard's provisions
table keeps a finished provision on the board until its password is retired,
and says where it stands.
A join from a provisioned machine is checked with the provider. The join
records the client address this plane can vouch for (behind Cloudflare, the
client header only from a verified edge). When that address is a provision's
instance, the join card names the provision, instance and age; approval asks
the provider for the instance and refuses unless it is running at that
address and the node being approved is the provision's site or a host record
at its address. A second join from that address after the node's agent is
admitted is refused and logged as an alarm. A docker provision's
container gets mgn_mgh_host_id (its placement record, minted at booting), and
the host's own agent, once approved, is named in mgh_mgn_host_node_id. The
server is the customer's property: cancelling their subscription stops
management, never touches the instance.
The install itself runs from this management node, not from a node agent: a
machine we just created has no agent yet. ManagementJob::createJob creates
every bootstrap job (ManagementJob::BOOTSTRAP_JOB_TYPES: install_node and
retire_install_password) in status queued, which the agent channel's claim
(pending only) never matches; the Run Install Jobs scheduled task spawns
plugins/server_manager/utils/run_install_executor.php (detached, single
instance, log at logs/install_executor.log), and InstallJobExecutor claims
each queued job, runs its local steps here and its ssh steps on the target
over the sealed root password (sshpass, password in the environment, never on
a command line), and writes the same output and status contract the agent
runner writes. Every shape travels it — fresh, from_backup (a clone over HTTPS
from the source this plane armed at ready) and bare (a Docker host with no
site) — as one session (specs/ssh_single_bootstrap.md). Fleet enrollment
seeding (the mailbox plugin's FleetProvisionSeeding) is the fleet_enroll
primitive on the new site's own agent, so it waits for that agent to pair:
cvp_fleet_seed_state goes pending at completion, dispatched once the node
reports the primitive, then done or failed by the job's answer.
Settings: server_manager_customer_cloud_region / _type / _image (instance
defaults), server_manager_linode_referral_url. Provider credentials are the
core oauth_linode_* settings (Admin → System → OAuth Providers).
The compute API surface is CloudComputeProvider
(includes/cloud_compute/) with LinodeComputeDriver implementing it; a new
provider is a new driver plus its OAuth provider class.
Managed Domain Registration
A hosting buyer who does not already own a domain can buy one in the same
click as the server. At checkout they type the name they want, see live
availability and the one-year price, and fill a contact block prefilled from
their account. One payment covers both. Behind it the pipeline registers the
name, points it at their box, publishes their mail records and sets reverse
DNS — so their website answers and [email protected] works by the time
the welcome email lands. No registrar dashboard, no DNS panel, no waiting on
the buyer to paste a record somewhere.
The buyer legally owns the domain from the moment it is registered. They are the WHOIS registrant on day one; the operator holds only management and billing, so that buying it could be one click. Those move to the buyer later (see Graduation). Ownership is never in question and never waits on a step.
Selling it
Two things have to be set before a domain can be sold, and until both are the checkout field refuses the order rather than taking money for a name it cannot register:
- Registrar credentials — the Domain registration card on Server Manager → Provisioning. Namecheap needs an API username, an API key (sealed at rest), and the management node's public IPv4 address allowlisted in its API panel. Namecheap grants API access only to accounts with 20+ domains, $50 in the balance, or $50 spent in the last two years. A sandbox switch points every registrar call at Namecheap's sandbox for a full rehearsal.
- A domain-year product — an ordinary store product, not publicly
listed, with one version whose price type is
user. Select it in the store'sstore_domain_registration_product_idsetting. Its price comes from the live registrar quote at checkout, so the buyer pays one year at cost.
server_manager_domain_tlds
(default com net org) bounds what can be asked for.Both gates check the thing they name, not just the setting: a domain-year product that was deleted, or whose version was deactivated, reads as unusable and the checkout field refuses — because a silently skipped cart line would mean registering a domain nobody was charged for.
One payment consequence worth knowing: a subscription hosting line plus a
one-time domain line is a mixed cart, and PayPal cannot process one
(ShoppingCart::is_paypal_available()). Deployments selling subscription
hosting with managed domains take card payment through Stripe.
What the buyer's answer becomes
ManagedDomainRequirement validates the submission against the registrar,
live: the name has to be registrable, in an offered ending, available, and not
premium, and the contact block has to be complete (including a phone number
with an explicit country code — a bare number is refused rather than guessed
at, because guessing puts a stranger's country code on a public WHOIS record).
The quote it gets back drives two things. It becomes a second cart line
against the domain-year product, priced through the existing
prv_price_type = 'user' path — a line rather than a surcharge because a line
carries its own recurrence, and a one-time fee folded into a subscription line
would bill every cycle. And after payment, post_purchase() files an
rdm_registered_domains row for the pipeline to work from. Nothing
price-shaped is ever read from the POST.
Fulfillment
ProvisionManagedDomains runs as a phase of the provisioning umbrella task
and takes at most one step per row per tick:
| Step | Guard |
|---|---|
| register the name with the buyer as registrant, WHOIS privacy on | rdm_status is pending |
publish apex + www A records at the box | rdm_dns_bootstrap_time |
| ask the box for its mail records and publish them | rdm_dns_mail_time |
set the PTR to mail.<domain> | rdm_ptr_time |
The web records unblock certificate issuance, so ProvisionPendingSsl
succeeds without the buyer doing anything. The mail records are not
computed on the management node. The box is asked, as a
managed_domain_prepare job on the agent channel whose whole vocabulary is the
domain: the node runs
plugins/mailbox/utils/managed_domain_prepare.php against its own site, makes
the domain mail-ready, and prints the record set
InboundEmailSetupCheck::dnsPlan() prescribes — the box is what knows its own
topology, SPF shape, DKIM key and Joinery Direct state, and a management node
that guessed would publish a plausible set the box does not match.
The answer therefore lands on a later tick than the question, and the mail step
is a four-state check rather than a call: a completed and unread job is read,
published and marked consumed; a job in flight is waited for; a finished one is
re-asked after PREPARE_RETRY_GAP_MINUTES.
rdm_error, where the Domains page
shows it, and is retried rather than failed.Before anything is bought, the order is checked for the money. The checkout answers and the payment are two separate objects, and the cart lets a buyer separate them: every cart line carries its own Edit and Remove, so the domain-year line can be deleted — or repriced through its own product page — while the hosting line carrying the answers is submitted unchanged. The intake reads the hosting line, so without a check the domain would be registered on the operator's card for free. The rule: the order must hold a paid domain-registration line worth at least the quote, and each such line backs at most one registration. Anything else parks the row with an alert. That turns every one of those doors into something an operator sees rather than a silent loss.
Publishing always goes through DnsReconciler in additive mode, never a
driver's raw call: Namecheap's setHosts replaces a zone's entire host list,
and additive means the pipeline can create records a zone lacks but never
overwrites something a person put there. A shared-host row stamps the PTR step
immediately — one address serving many domains has no per-domain PTR to set.
The mirror case has its own sweep, because there is nothing to run it from. A buyer who removes the
hosting line from the cart and keeps the domain line pays for a domain year whose intake never fires — no row is written, so no queue could ever show it.ManagedDomainWatch therefore looks for the
arithmetic signature directly: an order with more paid domain-year lines than
registration rows. It reports each such order once (a high-water mark over
order-item ids), and gives a fresh charge fifteen minutes to file its row
before judging it.A terminal failure parks the row at failed and emails the provisioning alert
address. It is never auto-retried: a name someone else took needs a
conversation with the buyer, not another attempt.
Ownership and graduation
Legal ownership is immediate and never moves. What moves is custody —
rdm_graduation_state, running operator_managed → push_requested →
push_sent → self_custody.
While the domain sits in the operator's registrar account, its renewal bills
the operator, and the platform never renews a buyer's domain and never
fronts the cost. So the domain has to reach the buyer's own account before its
first expiry. ManagedDomainWatch is what makes sure it does:
- It sweeps for domain years that were paid for but never registered (above).
- It refreshes the expiry from the registrar at most weekly.
- At expiry minus six months it pushes a custody state to the buyer's box,
and
ManagedDomainNoticestarts rendering a take-ownership notice there — calm at first, sharper at 30, 14, 7 and 1 days. That notice is the buyer's first mention of graduation anywhere; nothing in the setup wizard or the welcome email raises it. It is shown to permission-5+ users only, never to the site's visitors. - Once a push is in flight it asks
inAccount().
self_custody, updates the box, and emails the buyer a confirmation with
the auto-renew reminder.
The buyer's side of it is /profile/server_manager/domain: create a free
registrar account, tell us its name, then finish in their own dashboard. That
middle step is the only part that happens here, and submitting it queues an
operator task — Namecheap's Change Ownership push has no API. The push itself
is free and immediate, and DNS records, WHOIS privacy and auto-renew settings
all survive it.The operator queue
Server Manager → Domains (/admin/server_manager/domains) is ordered by
what needs a person: hand-overs waiting for a dashboard push first, then
terminal failures with a Retry button, then every domain as a ledger with its
status, custody, expiry and per-step progress.
Node settings
Four core settings, declared managed so the node's own settings page does not
offer them and the management node is their only author:
managed_domain_name, managed_domain_expiry_time, managed_domain_state,
managed_domain_manage_url. Empty managed_domain_state renders no notice,
which is what every deployment that did not buy a domain this way has.
They are written by a managed_domain_notice job, and
utils/managed_domain_notice.php on the node, which writes them
through Setting::put — so an undeclared name is refused by the declared-
settings gate, and a generic write-a-setting job that could reach the rest of
stg_settings does not exist.ManagedDomainWatch converges on that state rather than pushing at it: each
tick computes what the box should be holding, compares it against the last
notice job that completed for that node and domain, and files one only when they
differ. A push that failed therefore self-heals on the next tick, and
rdm_prompt_pushed_time — the record that the buyer has seen the take-ownership
notice at all — is stamped from a job that COMPLETED carrying a state that
renders one, never from a dispatch.
Adding a registrar
DomainRegistrarProvider (includes/domain_registrar/) is the seam;
DomainRegistrarRegistry discovers implementations by interface, so a second
registrar is a class in that directory and nothing else. It covers
availability and price, registration with a registrant contact, WHOIS privacy,
expiry, a custody probe, and which DNS driver serves its zones. It has no
renewal call and no DNS methods — the platform never renews, and records are
published through the shared DNS stack by driver key.
Backup Targets
Backup targets define where backup files are uploaded after creation. Each node can optionally have a backup target assigned. If no target is set, backups remain local only on the remote server.
Supported Providers
| Provider | Credentials (UI fields) |
|---|---|
| Backblaze B2 | Application Key ID + Application Key (region/endpoint auto-detected via b2_authorize_account at save time) |
| Amazon S3 | Access Key + Secret Key + Region |
| Linode Object Storage | Access Key + Secret Key + Region + Endpoint URL |
S3Signer.php. There is no per-provider CLI dependency — uploads, downloads, deletes, and listings all run as direct HTTPS calls, from the management node (web tier) or from the node's own agent. New S3-compatible providers can be added by configuration alone, no script changes.Nodes with no backup target leave backups local-only on the remote server.
Configuration
- Go to
/admin/server_manager/targetsand click Add Target - Select a provider, enter bucket name, path prefix, and credentials
- Go to a node's Overview tab, expand Edit Connection Settings, and select the target from the Backup Target dropdown
- Save — backups for this node will now auto-upload after creation
Upload Path Structure
All providers use: {prefix}/{node_slug}/{filename}
Example: joinery-backups/empoweredhealthtn/empoweredhealthtn-04_11_2026.sql.gz.enc
Credential Storage
Credentials are stored on the bkt_backup_targets table using a unified shape for every provider:
{"access_key": "...", "secret_key": "...", "region": "...", "endpoint": "..."}Two columns hold two keys: bkt_credentials is the main (delete-capable) credential the management node itself uses, and bkt_node_credentials optionally holds a write-only key handed to nodes instead (see
A persisted job never contains a credential — a node-bound upload (backup_run, upload_backup) carries a placeholder token that the agent channel resolves in memory when the job is handed out: __SM_NODE_CREDS_<target_id>__ for the write-only node slot whenever it is filled, __SM_CREDS_<target_id>__ otherwise. The channel resolves exactly the slot the token names and never falls back to the other, so a job built against a since-emptied slot fails visibly rather than running with a more powerful key than intended.
The operations that need more capability than a write-only key never send one at all: a node-side download receives a presigned URL for the one object it names, signed on the management node with the main credential; a cloud delete runs on the management node itself, in-process. So the main (delete-capable) credential never travels to a node in any form.
Transient Failures
A storage provider that answers a request with a 5xx does not fail the job. S3Signer::request() retries — MAX_ATTEMPTS tries, exponential backoff with jitter — for the failures that are worth another go: 5xx, 429, 408, and the transport errors that mean the connection died rather than the request being wrong. A deterministic error (403 signature, 404, 400) is returned immediately; retrying it would only burn the budget and bury the message. S3Signer::is_retryable() is the whole policy and is pure, so the classification is testable without a network.
Retrying is safe because every request the class makes is idempotent: a PUT overwrites its key (a single PUT, never multipart, so no orphaned parts survive a failure), and GET/DELETE/list have no cumulative effect.
Two bounds keep a retry from doing harm:
- Wall clock. Total time is capped at one attempt's timeout plus
RETRY_WINDOW_SECONDS. An attempt that burns the entire transfer timeout leaves no room for another — the right answer, since a transfer that cannot finish in an hour will not finish on the second try. Job steps that shell out to the uploader take their owntimeoutfromS3Signer::transfer_budget_seconds(), so the agent can never kill a transfer part-way through a retry. - Replay. A retried upload rewinds the body stream before resending. curl consumes the stream on the first attempt while
CURLOPT_INFILESIZEstill claims the full length, so a retry without the rewind sends nothing and then blocks until the timeout — a hang rather than an error. A stream that cannot seek is not retried at all, rather than being sent truncated.
RETRY: attempt 1 failed (HTTP 500 internal incident); retrying in 2s), and a transfer that only succeeded on a later attempt says so. A provider that is degrading looks exactly like a healthy one unless the attempts are visible.Backup Browser
The Backups tab on each node includes a file browser that lists backup files from both local storage and the cloud target. Features:
- Scan for Backups — creates a
list_backupsjob to scan local/backups/on the node - Unified file table — shows filename, size, date, and location (Local / Cloud / Both)
- Upload to cloud — offered on rows that exist only on the node, when the node has an enabled cloud target. Creates an
upload_backupjob that pushes that one file from the node to the target. The transfer runs on the node, where the file already is; routing it through the management node would drag the archive down and push it straight back up. The local copy is kept regardless of the node's delete-after-upload setting — an operator asking for an offsite copy of a file they are looking at did not ask for that file to disappear, and deleting stays an explicit action. The button waits for the job's real verdict, so a failed transfer reports as failed with a link to the job output rather than reading as done - Delete — single Delete button per row that removes the file from every location it exists in (local, cloud, or both); the confirmation dialog names the file and locations explicitly
- Restore Full Project — for
.tar.gzarchives, see therestore_projectrow in the Job Types table - Backups — one row per backup run on the node's shelf, newest first (when, full or incremental, who took it, size), with the last backup, the last full backup and the oldest backup held stated above the list; read from each chain's
manifest.jsonbyBackupChainListHelper. Restoring a run replays the last full before it and every incremental up to it, in order. Chain artifacts are deliberately absent from the flat file table above — listed there,files-0003.tar.gz.encinvites a restore of one incremental with no full under it, which restores nothing at all. Last verified restorable is stated with the three facts (level name, date, the node's own counts, or the reason it failed), and each run's Verify button opens a dialog with two choices,
verify_backup job; the poller reports what the node said, and a reload shows it above and on the node's card
What a restore asks, and what it decides
Every restore form asks one thing and decides the rest.
It asks for the domain, pre-filled from the node's recorded URL. This is the one value a restore cannot work out for itself: a rebuild keeps the site's own domain and cuts DNS afterwards, while a rehearsal must not claim it, and the same backup on the same node wants opposite answers. A node provisioned during an incident carries whatever hostname somebody typed in a hurry, so adopting it silently is a mistake that surfaces only after DNS moves.
It decides the serving config. There is no Apache choice on the form. The restore regenerates the virtualhost for this machine from the platform's own templates and never installs the one the backup carries; a differing capture is preserved as {site}.conf.from-backup and named in the job output. On a container node a further step publishes the domain on the host with manage_domain.sh, because the host's proxy virtualhost is outside the container and therefore in no backup.
Every restore job ends with two gates: the site's identity must match the machine (domain, deployment shape, and a database that opens with this machine's credentials) and the site must actually be served — over HTTPS when the domain already resolves here, or reported as certificate-deferred when it does not. The HTTPS gate is explicit because an HTTP-only check passes comfortably while a site serves under a container's internal virtualhost with a valid certificate sitting unused on disk.
What gets reconciled, and why each item is on the list, is in Backups.
Cloud listings are fetched live via TargetLister on every page render (one SigV4 HTTP GET, ~200–500ms). The local listing comes from the most recent completed list_backups job; both the Backups and Database tabs auto-trigger a refresh on page load when that scan is more than 60 seconds stale, so the listing is effectively always current. Both the merge logic and the staleness window are owned by BackupListHelper::get_for_node().
Stored Backups (target-side)
The Backup Targets edit page has a Stored Backups panel that lists the target's objects directly from the bucket and groups them by site. It runs entirely on the management node via TargetBackups (which lists through S3Signer::list, a continuation-token-paged ListObjectsV2), so it needs no live node — the authoritative view of what is actually stored offsite. Each group is tagged against the node table:
- live — a current node owns the slug; a link jumps to that node's Backups tab for granular local+cloud management
- decommissioned — a soft-deleted node owned the slug; the site is gone but its offsite backups remain here, reachable and deletable
- orphaned — no node, present or deleted, matches the slug
S3Signer from the management node: a single object (guarded so the key must sit under the target's own prefix), or a whole site's prefix (type-to-confirm the slug). This is the deliberate path for erasing a retired site's offsite backups — deleting a node never touches them.Retiring a node
Two distinct actions on the node detail Overview tab, both permission-10 and CSRF-guarded:
- Remove from Dashboard — soft-deletes the node record only. The site keeps running on its host; Server Manager simply stops tracking it. For a box handed back to its owner or managed elsewhere.
- Permanently Delete Site — creates a
decommission_nodejob addressed to the host's own agent as thedecommission_siteprimitive (see Removing a container site). The site approves its own removal on its own Backups page with its own recovery key; the host then runs the bundled self-verifyingremove_account.sh. Only onDECOMMISSION_VERIFIEDin the result does the result processor soft-delete the victim's node record (the job's subject is the host; the victim travels in the job params); a failed, declined or unverified teardown leaves the node intact and enabled to retry. Type-to-confirm the site name; the name is derived from the node's own fields, never operator input. Relays and bare-metal machines are refused — a whole machine is deleted at its provider, then its record removed here.
Removed sites are hidden from the dashboard by default. The Show all sites (including removed) link at the bottom of the Hosts & Sites panel re-renders with them included, each carrying a Removed badge and linking into its still-reachable node detail page (?show_all=1).
Opening a removed node's detail page offers two follow-up actions in its Danger Zone:
- Permanently Delete Site — the same
decommission_siteteardown on the host's agent, for a node that was only removed from the dashboard while its site kept running (e.g. an orphaned container). For a removed node it is offered only when this management node once saw a live site there — a recorded status check, Joinery version, or uptime result. With no such evidence (for example an install that failed and never stood a site up) the action is hidden behind a short note and only Permanently Delete Entry is offered, since there is nothing on the host to tear down. (The page never probes the host directly. Adecommission_sitethat reaches a host with no vhost for the named site is refused by the host, naming the site and the path it looked for, and the job fails: a request naming a site the host does not know is never reported as a verified removal, so a stale entry or a mistyped name cannot be "verified" away. Asking again after a site is gone therefore fails on purpose; the dashboard entry is closed with Permanently Delete Entry.) The consent rule holds here too: the site must still approve on its own admin, so a container too broken to render its own approval is not removable this way — it is recovered by rebuild-and-restore instead. - Permanently Delete Entry — hard-deletes the Server Manager record itself (
purge_node). Offered only for an already-removed node — purging a still-tracked node is refused, since that is how a live site becomes an untracked orphan. It is also refused while the node's slug still has offsite backups on any enabled target (or while a target cannot be listed to confirm): deleting the record would orphan those backups from the node they belong to, so they must be cleared from the target's Stored Backups panel first. Once allowed, the host is not touched and the job history survives the purge (cascade rules null the references).
Backup Encryption and Key Custody
Default Behavior
Encryption is enabled by default on both Database Backup and Full Project Backup forms. backup_database.sh / backup_project.sh encrypt with AES-256-CBC (PBKDF2, random salt) using the key minted for that run and passed as --key-file. The project archive is encrypted as tar streams into openssl, so the plaintext archive never lands on disk; the artifact is .tar.gz.enc. When a node's backup target is Backblaze B2 encryption is mandatory: the UI replaces the checkbox with a message and the server enforces it regardless of form input.
Key model: one envelope per backup
Every backup run mints its own random encryption key. The archive is encrypted
with it, and the key itself is sealed to two recipients and written beside the
archive as a JSON envelope ({archive}.keys.json), which is uploaded with it:
- recovery — the recovery public key
config/backup_site_key. This is
what lets a site restore itself with nobody present: pre-restore rollback
snapshots and routine restores need no operator. It is disposable — lose it and
the recovery key still opens everything, and the next run mints a new one.No key is ever sent to a node. Sealing to a public key always appears to
succeed, so a key supplied over the wire would let whoever supplied it decide who
can open a node's database and mail, with nothing on any machine looking wrong
until a restore was attempted. Every backup job therefore carries no key
material, and backup_envelope.php mint refuses one if a job passes it anyway.
A node with no verified recovery key of its own is refused a backup, loudly, at
build time and again on the node — never quietly downgraded to an unencrypted
archive on somebody else's shelf.
- The recovery keypair is generated with
maintenance_scripts/sysadmin_tools/escrow_keypair.php(standalone PHP + sodium, no platform bootstrap, so it runs during disaster recovery when the platform is gone), or in the browser from the setup panel. - The public key is stored in the core
backup_recovery_public_keysetting. - Minting and sealing happen on the node, in
maintenance_scripts/sysadmin_tools/backup_envelope.php. Only the recovery
ManagementJob row — which persists
forever — carries nothing that can open anything.config/backup_site_key is pinned to 600 www-data:www-data by
fix_permissions.sh. A key that exists but cannot be read is an error, never
treated as absent — minting over a live key would orphan the site recipient for
every backup already sealed to the first one.Possession check
Sealing to a public key always appears to succeed, including when the pasted key
is wrong — every backup would then be permanently unopenable, discovered only
during a real recovery. So the key is honored only after the operator unseals a
challenge with the private key. Until that proof is recorded
(backup_recovery_public_key_proven_fpr), BackupRecoveryKey::public_key()
throws and encrypted backups refuse to run.
The check runs against the copy of the key the operator is actually keeping, which is the copy that has to work in a disaster. Two ways to do it, both proving possession of the same X25519 secret:
- In the page — paste the key (from a password manager, typically) into the
setup panel.
BackupRecoveryKey::browser_challenge()packages the proof string asephemeralPub[32] || iv[12] || ciphertext || tag, sealed with X25519 → HKDF-SHA256 (infoBackupRecoveryKey::BROWSER_INFO+ ephemeral public + recipient public) → AES-256-GCM, sobackup_key_verify.jsandassets/js/recovery-readiness.jsopen it with WebCrypto alone. The HKDF context is sent to the browser with the challenge rather than hardcoded at both ends. The key is read from an input outside the form, used in memory, and cleared; it is never submitted, stored, or sent anywhere. Only the recovered proof string is posted, and the server re-checks it. - At the command line —
escrow_keypair.php unsealopens the libsodium sealed-box form of the same challenge with a key file.
Replacing a proven key is a rotation, not an edit: backups already made carry keys sealed to the old public key. Pasting over a proven value is refused.
Guided setup
Recovery key setup is core, not fleet — a standalone site needs it just as much —
so it lives on the Backups page and is rendered by
includes/RecoveryKeySetupPanel.php (see
Backups). The Backup Targets page
shows the current state and links there rather than carrying a second copy of
the panel. BackupRecoveryKey::setup_state() is the single source of truth for
that state, so the panel, the node Backups tab, and the dashboard cannot
disagree.
That panel covers this management node's own site. Whether a node can be backed
up is a question about the node's key, answered by RecoveryKeyFleet::node_state()
from the last status check: a node whose key is missing, unverified or not yet
checked shows the explanation in place of the Run Backup forms, and the job
builder refuses to build a run for it, so an operator is told while looking at
the button rather than part-way through a backup. NodeMonitorHealth::fleet_backup_health()
leads with the same state, without a grace period — a node that cannot encrypt is
not a node whose backups are late.
Backups across the fleet
This management node takes its own backups of the nodes it manages. They are a
separate party's copies of each site, on this management node's shelf — the
manager profile described in
Backups. A site's own
backups are the site profile: its own schedule, its own business.
Neither owns the other. A site that takes no copies of its own is still backed up from here; a site that takes plenty is still backed up from here. Nothing on either side needs the other to be absent.
Both open with the node's key. The two profiles differ in who schedules them, where the archive lands and who prunes it — not in who can read it. That belongs to the node's administrator in both cases, which is what makes a compromise of this management node a metadata problem rather than a fleet-wide disclosure.
The node does the work. backup_run hands it the bucket and a credential on
stdin, and its own BackupRunner builds the archive, extends the chain, seals the
envelope to the node's own verified recovery key, uploads and sweeps its local
copies. Routing archives through the management node would drag every byte down and
push it back up, and would put this machine in the path of every restore.
Nothing is left on the node, and nothing is given to it. The credential is substituted into the step by the agent at run time and never written to a job row or a node's database, and it leaves with the run. No encryption key goes the other way: a run that arrives carrying key material is refused rather than obeyed, so a management node that had been tampered with cannot re-seal the fleet's next backups to a key of its choosing. A node holds no key to anyone's backups but its own, and a node that leaves this fleet takes nothing with it.
The node may write to the shelf but never erase it
A backup target holds two credential slots. The main credential
(bkt_credentials) is the management node's own — it lists, prunes and downloads.
The node credential (bkt_node_credentials, on the target edit form) is an
optional second key created write-only — writeFiles without deleteFiles
on B2, s3:PutObject without s3:DeleteObject on S3. When it is set, that is
the key nodes are handed during a run: a node can add its archives and remove
nothing. When no node credential is configured, nodes receive the main key —
functional, but a compromised node then briefly holds a key that could erase
the shelf, so a fleet target wants the node slot filled.
FleetBackupRetention prunes from here, with the delete-capable main
credential that never leaves this machine. A credential that can delete is a
credential that can erase the fleet's backups, which is the first move of any
ransomware worth the name and the exact thing these copies exist to survive.
Pruning is driven by a bucket listing, which is the opposite of what a site
does for its own backups, and correct only here: this management node defined the
whole {prefix}/{slug}/manager/ path, knows every slug under it, and is the only
party that can delete from it. It is also stricter — it keeps the newest N sets
of objects that actually exist, so a run that failed part-way can never be
counted as a restore point. Chains are grouped by their directory, so they are
kept or deleted whole by construction.
Two provider notes:
- Linode Object Storage keys are read-only or read-write per bucket with no separate delete capability, so write-without-delete cannot be expressed there. B2 and S3 both express it cleanly.
- A chain rewrites
manifest.jsonevery run. That is a PUT over an existing key, which write-only permits, but on B2 it leaves superseded versions the node cannot remove. Give the fleet bucket a lifecycle rule keeping only the current version.
Scheduling
The Fleet Backups task (plugins/server_manager/tasks/FleetBackupRun.php)
runs every cron tick, finds due nodes, prunes each one's shelf, and dispatches
one backup_run per node.
FleetBackupPolicy resolves each node's schedule: the declared fleet settings,
then that node's own mgn_backup_policy overrides.
The node detail Backups tab edits the policy, as one of three positions:
- Fleet default stores nothing, so the node follows the fleet settings — including future changes to them.
- A schedule of its own stores the full field set (frequency, window, mode, retention, full interval, days between verifications), frozen against the fleet default: a value the operator saw and saved is a value they chose.
- Off stores exactly that decision, which is what lets the dashboard treat a node without fleet backups as somebody's choice rather than a gap.
backup_run the schedule
dispatches, with mode and full-interval taken from the node's policy, so a
manual run extends the same family of restore points the schedule builds.Three rules keep a fleet from behaving like a thundering herd:
- each node's minute is derived from its slug and spread across a window (default 03:00 UTC, 120 minutes wide), so forty nodes do not all begin a multi-hundred-megabyte upload at once;
- a node whose previous run is still pending or running is skipped, so a slow node gets fewer backups rather than a queue;
- no more than
server_manager_fleet_backup_max_concurrentrun at once.
Verifying what was backed up
The same pass proves the backups it takes, at two of the three levels described in Verifying backups:
- The shelf check runs on every retention listing, free: for each backup on
the node's manager shelf the pass reads the manifest (one small GET) and
checks every artifact it names is in the listing at the recorded size, and
that the manifest carries its envelope (
FleetBackupRetention::check_shelf;compare_manifestis the pure rule). What it finds is stamped onmgn_backup_shelf_problem— one line in the pass's words, empty when every backup is whole — and the health check turns anything else into
verify_backup job (level 2, of the
newest backup on the shelf) when FleetBackupPolicy::is_verify_due() says
so: the policy's verify_every_days is above zero (fleet default
server_manager_fleet_backup_verify_every_days, 30; 0 means never, stored
as a decision), the node has a successful backup from here, and either no
verify has ever been attempted, or the last attempt is older than the
interval and a newer backup exists. The last attempt is the later of the
node's verify stamp and the creation of its newest verify_backup job,
whatever became of that job (FleetBackupPolicy::last_verify_attempt): a
verify that fails on the node comes back as a failed job, and a failed job
is never folded into the node's columns, so the stamp alone would read
"never verified" and re-dispatch the whole download every tick. A verify
takes a slot from the same concurrency cap as a backup and is never
dispatched beside a running backup, Prepare or verify of the same node; a
backup due on the same tick waits for it. A failed verify is never retried
automatically. Level 3 is never scheduled: it is a person's choice on the
Backups tab.What is reported, and what raises an alarm
check_status asks each node's management API for both profiles: whether each is
scheduled, when it last ran, how it went, whether it reached the bucket, and
which recovery key it sealed to. The manager profile's answer is denormalised
onto mgn_last_backup_time and mgn_last_backup_outcome so the dashboard reads
columns instead of visiting nodes.
The dashboard alarms only on this management node's own runs —
NodeMonitorHealth::fleet_backup_problems() raises a node whose last backup from
here failed, whose backups have stopped arriving within its schedule's window,
or whose last full backup was a tenth the size of the previous one or smaller
(outcome warning, from the node's own BACKUP_WARNING line: the run is kept,
the card says "Last backup is suspiciously small" with both sizes).
The alarm is "my backups of this node are broken", not "this node is
unprotected", which is not this management node's call to make.
A failed run is reported from the run history, not from the stamp alone. The
line says since when the runs have been failing and how many, when the last one
worked, and the reason the node gave — a refusal's reason is recorded on the
run's result by process_backup_run — and links the failed job. The summary is
NodeMonitorHealth::backup_run_summary() over the node's backup_run jobs
(backup_runs_from_here()), counting back to the last success and stepping over
skipped runs and jobs the sweep has not read. A run with no BACKUP_TIME line is
stamped at its job's completion time, so a refusal read by the sweep hours later
still says when it happened.
The node's word is cross-checked against the bucket. The retention pass
lists each node's shelf with this management node's own credential before every
run, and the scheduler stamps what it saw — when the shelf was listed and the
newest object write on it — onto mgn_backup_shelf_checked_time and
mgn_backup_shelf_newest_time. The health check compares that against the
node's claimed last run: a shelf listed after a claimed success that holds
nothing written since raises "Backups are not landing". The shelf is the
one witness a compromised or misconfigured node cannot talk into its story —
everything else in the health picture is the node reporting on itself.
A node with fleet backups switched off produces nothing either — that was somebody's decision.
Whether a backup is verified restorable is the next question the card answers, frommgn_backup_verify_* (stamped by process_verify_backup from
the job's VERIFY_* lines, and adopted from the node's own status report when
the node verified itself and that is newer — adopt_reported_verify).
NodeMonitorHealth::verify_state() gives four answers: a shelf problem (above)
is a problem; a verify that failed is a problem, in the node's own words, with
the note that nothing is retried automatically; a pass older than 60 days is
stale, a problem; and never verified is information on a healthy card
("Not yet verified restorable") until 45 days after the first backup from
here, after which it is "Backups never verified restorable". A verify that
was skipped (not enough disk — "needs N free, has M" — or a busy machine)
records only its reason, beside the last real result.Which key each node holds, and whether it can be backed up
set_recovery_key.php --report is asked during check_status, and the answer
lands on mgn_backup_recovery_fpr and backup_recovery_state. It prints one
machine-readable line, RECOVERY_KEY=already|none|invalid, and the fleet table
on the Targets page reads the columns rather than reaching out to every node on
page load.
That state decides whether the node can be backed up at all, by anyone — the
Targets page lists it as fleet coverage, and RecoveryKeyFleet::has_own_key() is
the one predicate every surface asks. Whose key it is is not compared against
this management node's: a node holding a key this machine has never seen is a node
whose operator holds their own recovery key, which is the intended arrangement.
It is reported and never written. There is no job type that can write it, and
set_recovery_key.php refuses --public outright so a stale management node finds
out rather than succeeding. A node's key is set up on that node's own Backups
page, with the possession ceremony that makes it trustworthy — the page generates
a keypair in the browser and runs the challenge in one pass.
Disaster recovery
To rebuild a lost node from its offsite backups:
- Fetch the archive and its envelope (
{archive}.keys.json) from the bucket. - Recover the archive key on a machine holding the recovery private key:
php backup_envelope.php open --sidecar {archive}.keys.json --private /path/to/recovery.key --key-out /tmp/k - Restore through the dashboard, or with
restore_database.sh --key-file /tmp/k/restore_project.sh --key-file /tmp/k.
restore_project.sh finds the envelope beside
the archive and opens it with config/backup_site_key.This works when the management node itself is the casualty — the envelopes sit in the bucket alongside the archives, so bucket credentials plus the password-manager private key are sufficient. No site's recovery depends on any other site being alive.
The agent signing key (the fleet trust root) needs no separate recovery record:
it lives at config/agent_signing_key, inside the project tree that a root-run
whole-site backup carries (the key is root-only, so a backup taken as any other
user leaves it out and says so). The dashboard's trust-root check is satisfied by
any offsite whole-site backup of this machine that this site's proven recovery key
opens: one the site made itself, or one a management node made of it sealed to that
same key. A copy sealed to another party's key is that party's to recover from and
does not count.
How It Works: Smart Plugin, Dumb Agent
All job-type intelligence lives in JobCommandBuilder.php. The Go agent is a generic executor that understands four primitives: ssh, scp, local, and api.
When an admin triggers an operation:
- PHP looks up the node's connection details (host, SSH key, container, etc.)
JobCommandBuilder::build_<type>()generates an ordered array of steps- PHP writes a job row with the steps in
mjb_commands(JSON) - Go agent picks up the job, executes each main step in order, streams output
- Agent runs the job's teardown steps (if any), then marks the job completed or failed
JobResultProcessoroptionally parses the output into structured data
{
"steps": [
{"type": "ssh", "label": "Prepare the workspace", "cmd": "mkdir -p ..."},
{"type": "ssh", "label": "Run the operation", "cmd": "...", "continue_on_error": true}
]
}The agent doesn't know this is a "backup." It just runs each step's command, captures output, and moves on.
Not every job is a step list
Two job shapes carry no commands for anything to execute.
A primitive job names an operation the node's own agent compiled in
({"primitive": "check_status", "params": {}}). The plane composes nothing; the
node looks the name up in its own vocabulary and refuses anything it does not
recognise.
A probe job ({"probe": "check_status"}) is work this plane does itself, and
it is already finished by the time the row exists. NodeHealthProbe reads what
the machine publishes about itself over HTTP, or establishes that it answers on
its port, then folds the figures onto the node and writes the row in a terminal
state. This is how a machine that carries no agent and hosts no site is asked
about itself: the ScrollDaddy DNS servers report their own disk and memory in
their /health document, and the mail relay proves it is alive by accepting
connections on port 25, which is what it exists to do.
ManagementJob::createFromBuild() reads which shape a builder returned and
stores it correctly, so a caller dispatches an operation and never chooses a
transport.
Execution phases: main and teardown
A job's steps form two phases. Steps without the teardown flag are the
continue_on_error) stops
the phase and determines the job's outcome. Steps flagged "teardown": true
are the teardown phase: they run on every exit path — success, mid-job
failure, or none-of-the-main-steps-ran — so the scratch files a job creates
(dumps, staged archives, unpacked installers) are removed even when the job
aborts on a shared production host.Teardown semantics:
- Teardown never changes the outcome. A failed job stays failed with the
original failing step in
mjb_error_message; a teardown step erroring is logged under the=== Teardown ===output header and ignored. - Teardown runs before the terminal status is written. The job stays
runningwhile teardown executes, so the per-node concurrency lock holds (no re-run can race the deletions) and the job detail view keeps streaming. - Progress counts main steps only.
mjb_total_stepsexcludes teardown steps and teardown output never advancesmjb_current_step. - Stale-job replay. Jobs force-failed at agent startup (left
runningby a crash or restart) get their teardown steps replayed frommjb_commands— safe because every teardown command is an idempotentrmon a per-job path. - Placement. Builders put teardown steps at the tail of the array, after
every main step, and keep
continue_on_erroron them. An agent that ignores the flag runs the array sequentially, so tail placement makes the steps plain trailing cleanup there — correct, just not failure-proof.
Adding a New Job Type
Adding a new operation requires PHP changes only -- no Go rebuild needed.
- Add a static method to
JobCommandBuilder:
// plugins/server_manager/includes/JobCommandBuilder.php
public static function build_restart_apache($node) {
return [
['type' => 'ssh', 'label' => 'Restart Apache',
'cmd' => 'systemctl restart apache2'],
['type' => 'ssh', 'label' => 'Verify Apache status',
'cmd' => 'systemctl is-active apache2'],
];
}- Add a UI trigger (button/form) in the appropriate admin view that calls:
$steps = JobCommandBuilder::build_restart_apache($node);
$job = ManagementJob::createJob($node->key, 'restart_apache', $steps, null, $session->get_user_id());
header('Location: /admin/server_manager/job_detail?job_id=' . $job->key);- Optionally add a result processor method in
JobResultProcessorif you want to parse the output into structured data.
Step Fields Reference
| Field | Required | Description |
|---|---|---|
type | Yes | ssh, scp, local, or api |
label | Yes | Human-readable description (shown in UI and output) |
cmd | ssh/local | Shell command to execute |
node_id | No | Override target node (defaults to job's node). Used for multi-node operations like copy_database |
on_host | No | If true, run on the SSH host directly, not inside the Docker container. Used for docker stats, etc. |
direction | scp | upload (local to remote) or download (remote to local) |
remote_path | scp | File path on the remote host |
local_path | scp/api | File path on the management node (for api, set to stream the response body to a file instead of appending to job output — used by backups/fetch) |
method | api | HTTP method: GET, POST, PUT, DELETE (in practice always GET — the management API is read-only) |
endpoint | api | Path relative to /api/v1/management/ — e.g. stats, backups/list, backups/fetch |
expect_status | api | HTTP status code that counts as success (default 200) |
query | api | Object of query-string params (e.g. {"path": "/backups/foo.sql.gz"}) |
body | api | Request body object (serialized as JSON; ignored for GET/DELETE) |
continue_on_error | No | If true, don't abort the job when this step fails |
timeout | No | Max seconds for this step (default: 1800 = 30 minutes; teardown steps carry 120) |
teardown | No | If true, the step is teardown-phase: it runs on every exit path, its failure never affects the job outcome, and it must be an idempotent removal of a per-job scratch path. Always placed at the tail of the step array with continue_on_error set. |
Management API (Read-Only)
Every Joinery instance exposes a namespaced read-only HTTP surface at /api/v1/management/*. The management node prefers this over SSH for observability operations (check_status, list_backups) because it's faster, parallelizable, and auditable.
Endpoints (all under /api/v1/management/, all GET, all JSON except backups/fetch which streams binary):
| Endpoint | Replaces SSH step(s) |
|---|---|
health | (new — liveness probe) |
stats | all steps of check_status |
version | Check Joinery version |
databases | List databases |
errors/recent | Recent errors |
backups/list | list_backups |
backups/fetch?path=... | (no management-node consumer — streams a backup file as binary) |
GET /api/v1/management returns every endpoint with its description.Authentication uses the existing API key system (apk_api_keys — same key headers and hashing as public CRUD; resolved by ApiAuth::authenticate()). The gate (ApiAuth::authorize(), with requires_machine_key + min_user_permission: 10) has two requirements: the key must be a machine key (apk_type = machine) — user session keys minted via /api/v1/auth/login get 403 here, so a superadmin logging into a phone app can't reach the management node — and its owning user must be a superadmin (usr_permission >= 10). apk_permission is NOT a gate here — it's the CRUD-axis capability and is orthogonal. A superadmin's machine key with apk_permission=1 can call management endpoints; a permission-5 admin's key cannot, regardless of apk_permission.
Adding a management key for a node: on the target node, Admin → API Keys → New Key (admin-created keys are machine keys, which is what the management node requires), owner = a superadmin user, apk_permission = 1, IP-restrict to the management node's egress IP. Paste the public/secret pair into the node's Overview tab on the management node's Server Manager ("API Credential" panel).
> IP restriction on docker-prod nodes: for sites fronted directly by host Apache (no Cloudflare), the container now reads the real client IP via mod_remoteip + the host's X-Forwarded-For: %{REMOTE_ADDR}s header, so IP restriction works end-to-end. For Cloudflare-fronted sites, the container sees Cloudflare's edge IP — IP restriction is not yet meaningful in that case (a future spec will trust Cloudflare's ranges and read CF-Connecting-IP).
Build-time routing: JobCommandBuilder::build_<op>() picks one implementation in preference order — build_<op>_primitive(), then build_<op>_api(), then build_<op>_probe(), then build_<op>_ssh() — and an operation with none it can reach throws rather than emitting an empty job. The API arm is gated on has_api($node, $op): credentials stored on the node row, a matching build_<op>_api, and a fresh /health probe. There is no runtime fallback; a job is decided at build time and runs that path or fails. check_status has no _ssh implementation at all.
Adding a new management endpoint: drop a file under includes/management_api/<name>_handler.php with <name>_handler($request) + <name>_handler_api() meta function. Nested paths mirror directories (backups/list_handler.php → GET /api/v1/management/backups/list). Parallels the action-endpoint convention in logic/*_logic.php. The machine-key + superadmin default applies automatically; a handler can tighten it (never loosen) by returning an 'auth' block from <name>_handler_api() — e.g. 'auth' => ['capability' => 'delete'] for a destructive endpoint. See docs/api.md.
TLS verification is strict by default. The mgn_tls_insecure boolean on mgn_managed_nodes opts a single node out for dev/local instances without a cert from a trusted CA. Audit: SELECT mgn_slug FROM mgn_managed_nodes WHERE mgn_tls_insecure = true.
Data Models
ManagedNode (mgn_managed_nodes)
Represents a remote Joinery instance. Key fields:
mgn_name-- Display name (e.g., "Empowered Health Production")mgn_slug-- Short identifier, unique (e.g., "empoweredhealthtn")mgn_host-- SSH host (IP or hostname)mgn_ssh_user,mgn_ssh_key_path,mgn_ssh_port-- SSH connection detailsmgn_container_name-- Docker container name (null for bare metal)mgn_web_root-- Path topublic_htmlinside the server/containermgn_last_status_data-- JSON from last status check (disk, memory, load, etc.)mgn_joinery_version-- Last known version stringmgn_bkt_backup_target_id-- FK to backup target (null = local only)
CustomerCloudAccount (cca_customer_cloud_accounts)
A user's OAuth-linked cloud provider account (one row per user + provider).
Holds the SecretBox-encrypted token set via storeToken()/getToken();
cca_status is active, refresh_failed, or revoked (the latter two mean
the buyer must re-connect).
CustomerCloudProvision (cvp_customer_cloud_provisions)
One cloud-instance provision, request to running site. cvp_origin is
order (keyed to the getjoinery order item — cvp_external_order_item_id,
unique, required for this origin) or admin (no order item); cvp_status is
the state machine documented under
Customer-Cloud Fulfillment; install parameters
ride on the row (cvp_docker_mode, cvp_install_mode, cvp_source_node_id,
cvp_backup_source, cvp_port, cvp_sitename); links to the account
(cvp_cca_account_id), instance (cvp_instance_id/_ip), and resulting
node (cvp_mgn_node_id).
RegisteredDomain (rdm_registered_domains)
One domain bought on a buyer's behalf. Two independent axes run along the row
and must not be conflated: rdm_status is fulfillment (pending →
registered → active, or failed), and rdm_graduation_state is custody
(operator_managed → push_requested → push_sent → self_custody). Legal
ownership belongs to neither — the buyer is the registrant from registration.
rdm_domain-- the name, lowercase and uniquerdm_usr_user_id-- the buyer; deletion is refused while a domain is theirsrdm_external_order_item_id-- the order item both this and the compute leg hang off, and the intake's idempotency keyrdm_mgn_node_id-- the box, resolved during fulfillmentrdm_registrant_sealed-- the WHOIS contact block, SecretBox-sealedrdm_dns_bootstrap_time/rdm_dns_mail_time/rdm_ptr_time-- the idempotency ledger: null means outstanding, stamped means never redonerdm_expiry_time,rdm_expiry_checked_time,rdm_prompt_pushed_time-- the countdown, its weekly refresh, and whether the buyer has been told
ManagementJob (mjb_management_jobs)
Represents a queued, running, or completed operation. Key fields:
mjb_mgn_node_id-- Target node (FK to mgn_managed_nodes, null for local-only jobs)mjb_job_type-- Label for display/filtering (e.g., "backup_run")mjb_status--pending,running,completed,failed, orcancelledmjb_commands-- JSON with the step array the agent executesmjb_output-- Progressive text output (appended during execution)mjb_result-- Structured JSON populated byJobResultProcessorafter completionmjb_current_step/mjb_total_steps-- Progress tracking
$job = ManagementJob::createJob(
$node_id, // target node ID (or null for local)
'backup_run', // job type label
$steps, // array of step dicts from JobCommandBuilder
['profile' => 'manager'], // parameters (stored for reference/re-run)
$session->get_user_id() // who triggered it
);BackupTarget (bkt_backup_targets)
Configured storage target for backups. Key fields:
bkt_name-- Display name (e.g., "Production B2")bkt_provider--b2,s3, orlinodebkt_bucket-- Bucket name (required)bkt_path_prefix-- Path prefix within the bucket (default:joinery-backups)bkt_credentials-- JSON with the unified shape{access_key, secret_key, region, endpoint}for every provider; B2's region/endpoint are auto-detected at save timebkt_node_credentials-- optional write-only key handed to nodes during a backup run in place of the main one; same shape, same sealing; B2/S3 onlybkt_delete_local-- Whether to delete local backup after successful uploadbkt_enabled-- Whether this target is active
AgentHeartbeat (ahb_agent_heartbeats)
Single-row table tracking agent liveness. Updated every 30 seconds by the Go agent. The dashboard checks ahb_last_heartbeat to show online/offline status.
Uptime Monitoring
A lightweight per-node uptime check runs on each scheduled-task tick (~15 min). It updates live state on mgn_managed_nodes and emails an admin on up→down and down→up transitions. One alert per transition; no re-alerting while still down.
Augmented mgn_managed_nodes fields:
mgn_uptime_enabled(bool, default true) — per-node on/offmgn_uptime_check_type(varchar, default'http_status') — which check method to use (see below).http_statusis the default because it concludes up/down for any node with a site URL and needs no setup;apiis an opt-in that requires API keys provisioned on the node.mgn_uptime_last_status(varchar) —'up'/'down'/ null (never checked)mgn_uptime_consecutive_failures(int) — streak counter for threshold logicmgn_uptime_down_since(timestamp) — when current outage started, null when upmgn_cert_expiry_ts(timestamp) — observednotAfterof the served TLS cert (see Certificate monitoring)mgn_cert_alerted_ts(timestamp) — last cert-expiry warning send, for re-alert cadence; null when the cert is comfortably valid
mgn_last_status_check — both check types update it.Per-node IP pinning. http_status checks pin the request to the node's own mgn_host IP (CURLOPT_RESOLVE, SNI/Host preserved) when mgn_host is an IP literal and appears in the site hostname's public A records (DnsResolver::getA()) — the same directly-exposed guard check_cert_expiry() uses. A node behind a shared or round-robin hostname (e.g. two DNS servers sharing dns.scrolldaddy.app via dual A records) is therefore checked as
Check types (extensible via a single dispatch switch in RunNodeUptimeChecks::run_check()):
| Value | Behavior |
|---|---|
api | Reuses JobCommandBuilder::fetch_status_via_api($node). reason='transport' (DNS/connect/timeout) counts as down. 3xx responses also count as down — the API endpoint should never redirect, so a 3xx means the request never reached the API handler (typical cause: infrastructure-level HTTP→HTTPS redirect, possibly looping if Cloudflare is in Flexible mode). Auth (401/403), body errors, and non-3xx non-200 statuses all mean the server responded → up. reason='config' (missing API keys) is a misconfiguration: logged to error log and skipped, no false down alert. |
http_status | Plain curl GET to mgn_site_url. Success = HTTP status in 2xx or 3xx. Forced when mgn_skip_joinery_checks=true regardless of stored check type. |
RunNodeUptimeChecks and a case to the dispatch — no schema change needed.Tick logic (plugins/server_manager/tasks/RunNodeUptimeChecks.php):
- Iterate non-deleted nodes where
mgn_enabledandmgn_uptime_enabledare true andmgn_site_urlis set. - Dispatch on
mgn_uptime_check_type(withmgn_skip_joinery_checksoverriding tohttp_status). - Apply state machine:
- On success: clear failure counter and
down_since, set status'up'. Fire recovered alert on down→up transition. - On failure: incrementconsecutive_failures. Once it reachesFAILURE_THRESHOLD(default 2) and prior status wasn't'down', set status'down', setdown_since=now(), fire down alert. - Inconclusive: the probe records why inmgn_uptime_last_errorand returns without touching status, the failure counter ordown_since, and without alerting.mgn_uptime_last_conclusiveis deliberately left alone, so a node that can never conclude eventually surfaces as stale rather than as healthy.
TIMEOUT_SECONDS=10, FAILURE_THRESHOLD=2. The cron tick interval (~15 min) is the natural rate limiter.A probe only concludes when it reached the node. A failure inside the monitoring host's own name resolution is evidence about the monitoring host, not about the node, so it is inconclusive. Without this, one broken resolver on the management node fails every probe within a single tick, carries the whole fleet past the failure threshold together, and mails the operator that every site is down while every site is serving traffic — an inverted signal, since the one machine actually at fault is the only one reporting nothing wrong.
NodeMonitorHealth::is_name_resolution_failure($errno, $message) makes the call, and all three check types route through it. It matches curl's CURLE_COULDNT_RESOLVE_HOST/_PROXY by number, and matches on message text for the two cases that carry no distinguishing number: a resolver that hangs rather than answering (curl reports the generic CURLE_OPERATION_TIMEDOUT, wording it "Resolving timed out after…"), and fsockopen, which reports getaddrinfo's text with errno 0. Everything else — refused connections, TLS failures, timeouts once dialling has begun — stays a genuine down result.
The recorded error is worded from the monitoring host's point of view (monitoring host could not resolve <name> (…)) so the dashboard points at the real fault, and the tick's summary line counts these as skipped with the reason attached.
Alert email recipient is resolved per tick via a fallback chain — no new setting:
server_manager_provisioning_admin_alert_email(existing plugin setting)webmaster_email(existing core setting)- The first permission-10 user's email
EmailSender::quickSend() with hard-coded plain-text bodies — no template editor in v1.UI:
- Node edit form (
node_detailoverview tab andnode_add): a "Monitor uptime" checkbox and a "Check type" dropdown. Whenmgn_skip_joinery_checksis on, the runtime forceshttp_statusregardless of the stored value (so pickingapihere is harmless for non-Joinery nodes). - Node detail overview tab: a one-line uptime status under "Last checked" —
Up,Down since X,disabled, ornot yet checked.
Certificate monitoring
Every enabled node also gets an independent TLS certificate check on each tick (RunNodeUptimeChecks::check_cert_expiry()), separate from the up/down probe. It says when a certificate the node renews itself stops renewing — the failure mode where auto-renewal silently breaks and the certificate expires unnoticed.
It reads the served certificate over the wire (stream_socket_client on ssl://mgn_host:443, capture_peer_cert, SNI = the site hostname), so it sees whatever the cert manager actually serves — Caddy, certbot, anything. Validity is deliberately
notAfter of an already-expired or near-expiry cert is still readable.Fronted nodes are watched the same as direct ones. The probe is pinned to mgn_host, so a name proxied through Cloudflare is answered by the origin, not the edge. The edge renews its own certificate; the origin behind it holds another that expires on its own schedule, and on Cloudflare Full (not Strict) an expired origin certificate is accepted and invisible — right up until Strict is enabled and it is an outage. One test decides whose certificate it is: the served cert's CN/SANs must cover the hostname (cert_covers_host()). A certificate for some other name is not this node's to date, but it is reported (reason 4 below).
On a monitored node it stores mgn_cert_expiry_ts and alerts on the first of four reasons (cert_alert_verdict(), www_gap(), uncovered_alert_text()):
- Renewal overdue. A standard ACME client replaces a certificate at two thirds of its life (
renewal_due_ts()); when that date is more than a day past and the certificate on the wire is still the one that was due, renewal on the node is failing. No stored fingerprint is needed: the due date is computed from the served certificate's own dates, so a replacement carries a freshnotBeforeand its own due date lies ahead. For a 90-day certificate this fires around day 61, nine days before the expiry threshold would. - Expiry approaching. Days remaining under
server_manager_cert_expiry_warn_days(default 21) — the safety net for a certificate whose issue date cannot be read. - www uncovered. When
www.<hostname>resolves, the origin is probed again withwww.as SNI; a certificate that does not cover it is its own reason, because a Strict flip would take the www address dark while the apex looks healthy. A www that does not resolve is not mentioned. - Origin uncovered. The origin answers TLS but with a certificate for other names — a shared fallback vhost on the host, or the placeholder minted at install. Under Full the site serves; under Full (Strict) it goes dark. The mail names the presented names and the wanted one and the tool that fixes it (
issue_origin_cert.sh). Nothing is stored for a foreign certificate: its expiry is not this node's.
CERT_RECHECK_ALERT_DAYS (default 3) while a reason persists, and clearing mgn_cert_alerted_ts once nothing is wrong. The mail carries the diagnosis: issue date, lifetime, renewal-due date and how overdue, issuer, serial, whether the certificate is unchanged since the last alert, and the www re-issue line when that is the gap. The node detail overview shows a "TLS cert: expires …" line (warning-styled under threshold) whenever mgn_cert_expiry_ts is set — which also surfaces certs the certbot-file SSL tile can't see (e.g. Caddy nodes).This is distinct from mgn_ssl_state / the SSL tile, which track certbot provisioning status (does an LE cert exist on disk) — a different question from "is the served cert about to expire," and largely a different set of nodes. The two are orthogonal.
Safety Constraints
- Auto-backup before destructive operations --
copy_database,restore_database, andrestore_projectautomatically prepend backup steps.restore_projectsnapshots both the current database (auto_pre_project_restore_*.sql.gz) and the current project tree (auto_pre_project_restore_*.tar.gz) to/backups/before overwriting; either can be skipped if the corresponding component is unchecked in the form. If any pre-backup step fails, the destructive steps never run.
- Database restores replace -- A database restore leaves the target equal to the snapshot. Every restore site (
restore_database, both copy jobs, the from-backup install) verifies the archive withgunzip -tbefore anything is destroyed, drops and recreates thepublicschema so target-only objects are removed too, and loads withpsql -v ON_ERROR_STOP=1so the first load error fails the job instead of completing a partial restore. Dumps are plainpg_dumpsnapshots -- the restore step owns the replacement guarantee, so it holds for any file it is fed. Job-internal dumps (copy jobs, install clone) add--no-owner --no-aclbecause they are restored as the
- Per-node concurrency lock -- The agent skips jobs if another job is already running on the same node, preventing conflicts.
- Stale job recovery -- On agent startup, any orphaned
runningjobs are markedfailedwith a descriptive message.
- Step timeout -- 30-minute default per step, overridable. On timeout, the SSH session is killed.
- Single-threaded agent -- One job at a time. Queued jobs run sequentially.
- Remote credentials at runtime -- Database credentials for backup/copy/restore are extracted from each node's
Globalvars_site.phpat execution time, never stored on the management node.
API Actions
The dashboard's page JS calls these POST /api/v1/action/server_manager/{name}
actions with the browser-session credential (superadmin only, floor 10) and
reads the response envelope's data. The full set: probe_api, job_status,
backup_actions, refresh_node_status.
server_manager/job_status
Polled by the job detail page for live output.
Parameters:
job_id(int) -- job to queryoutput_offset(int) -- character position; only new output since this offset is returned
{
"success": true,
"status": "running",
"new_output": "=== [Step 2/5] Check memory ===\n...",
"output_offset": 1234,
"current_step": 2,
"total_steps": 5,
"error_message": null
}The UI polls every 2 seconds while a job is running and stops when status is completed or failed.
server_manager/backup_actions
Used by the backup browser on the Backups tab.
| Action | Method | Parameters | Returns |
|---|---|---|---|
refresh_list | POST | node_id | {success, job_id} -- creates a list_backups job |
delete_file | POST | node_id, target (local/cloud/both), local_path, cloud_path | {success, job_id} -- creates a delete_backup job |
list_status | POST | node_id, job_id (optional) | {success, status, backup_list, last_scan} -- returns cached file listing |
Troubleshooting
Agent shows Offline on dashboard
- Check the agent is running:
sudo systemctl status joinery-agent - Check logs:
journalctl -u joinery-agent -f - Verify DB credentials in
/etc/joinery-agent/joinery-agent.envmatch those inGlobalvars_site.php
pending forever
- Agent is not running or can't connect to the database
- Another job is running on the same node (per-node lock)
- Verify SSH key path on the node record matches an actual key file
- Test manually:
ssh -i /path/to/key root@host "echo ok" - For container nodes, verify the container name is correct
- The agent crashed or was restarted mid-job. Check
journalctlfor the crash cause. - The partially-completed job should be inspected manually. Use Re-run to retry.
File Reference
Plugin (plugins/server_manager/)
| File | Purpose |
|---|---|
plugin.json | Plugin metadata |
uninstall.php | Removes settings and menu entries on uninstall |
data/managed_node_class.php | ManagedNode + MultiManagedNode |
data/management_job_class.php | ManagementJob + MultiManagementJob |
data/agent_heartbeat_class.php | AgentHeartbeat + MultiAgentHeartbeat |
data/backup_target_class.php | BackupTarget + MultiBackupTarget |
data/registered_domains_class.php | RegisteredDomain + MultiRegisteredDomain |
includes/domain_registrar/DomainRegistrarProvider.php | The registrar seam + DomainRegistrarException (transient vs terminal) |
includes/domain_registrar/DomainRegistrarRegistry.php | Interface-based registrar discovery, plus the shared domain-name and TLD gates |
includes/domain_registrar/NamecheapRegistrar.php | Namecheap: availability, pricing, registration, WHOIS privacy, expiry, custody probe |
includes/requirements/ManagedDomainRequirement.php | The checkout field, its live quote, the companion cart line, and the intake |
includes/provisioning/ProvisionManagedDomains.php | Register → web DNS → mail DNS → PTR → active |
includes/provisioning/ManagedDomainWatch.php | Expiry refresh, the six-month prompt, custody detection, the node banner push |
logic/domain_check_logic.php | /api/v1/action/server_manager/domain_check — live availability for the checkout field |
includes/JobCommandBuilder.php | Command generation for all job types |
includes/JobResultProcessor.php | Parses completed job output into structured data |
includes/S3Signer.php | AWS SigV4 signer for S3-compatible storage (get/put/delete) |
includes/TargetLister.php | Web-tier paginated bucket listing using S3Signer |
includes/TargetTester.php | Connection test on Save for Backup Targets |
includes/BackupListHelper.php | Merges latest local list_backups job output with live cloud listing into a unified file table |
ajax/job_status.php | Live job output polling |
ajax/backup_actions.php | Backup browser actions (scan, delete) |
migrations/migrations.php | Indexes, admin menu entries, menu consolidation |
views/admin/index.php | Dashboard -- fleet overview, publish upgrade |
views/admin/node_detail.php | Node detail -- tabbed page (overview/backups/database/updates/jobs) |
views/admin/node_add.php | Add node -- the record a join request is approved against |
views/admin/targets.php | Backup target CRUD |
views/admin/jobs.php | Global job history |
views/admin/job_detail.php | Single job output with live polling |
views/admin/domains.php | Managed domain queue -- pending pushes, failures, the full ledger |
views/profile/domain.php | The buyer's take-ownership flow (/profile/server_manager/domain) |
views/admin/nodes_edit.php | Redirect stub (-> node_detail or node_add) |
views/admin/nodes.php | Redirect stub (-> dashboard) |
views/admin/backups.php | Redirect stub (-> dashboard or node_detail) |
views/admin/database.php | Redirect stub (-> dashboard or node_detail) |
views/admin/updates.php | Redirect stub (-> dashboard or node_detail) |
Go Agent (/home/user1/joinery-agent/)
| File | Purpose |
|---|---|
main.go | Entry point, signal handling, poll loop |
config.go | Environment-based configuration |
db.go | PostgreSQL: job claiming, output writing, heartbeat |
runner.go | Step executor dispatching to ssh/scp/local |
ssh.go | SSH connection pooling and command execution |
scp.go | SCP file transfer |
server.go | Node connection info struct |
Makefile | build, test, release targets |
build_installer.sh | Generates self-extracting installer |
install/joinery-agent.service | systemd unit file |
config/joinery-agent.env.example | Example configuration |