Device-signed CLI restart under a Full-only enforcement switch#3926

merged16
main
t3/sec04-signed-restart
opened7d agobyNNick
View on GitHubGitHub import enabledPR last imported 6d ago

Why

Hosted Ployz Cloud must be clearly safer than self-hosting. Someone with root on np1 must not be able to change anything on a customer's Server unless the customer's own Device key approved it. This is the first end-to-end slice of that: ployz service restart and ployz service stop sign each Container's steps with the Device key, and a Machine under Device enforcement acts on an Operator caller's restart only with that approval.

Bets this touches, and the choice each changed:

  • Stable promise. SetDeviceEnforcement is a new catalog row (ployz.machine.set-device-enforcement.v1, Full scope) and the approval on EnsureContainer is a new capability (ployz.restart.device-approval.v2). No existing route changed meaning. A daemon predating this slice ignores the approval field and restarts as before, and ployz device enforce gates on the contract and lists an older Server as missing. Both rows are frozen in capabilities.rs.
  • 6. Machine-local authority. The Machine verifies the signature against its own Device list, checks every bound field against the request in hand, and keeps its own record of consumed steps. Cloud carries the approval but cannot mint, alter or reuse one.
  • 7. Cloud drives, never owns. Enforcement is off by default and only a Full caller (a Device) can turn it on or off. Under it, Cloud's own restart, stop, start and deploy of a Service are refused with a refusal naming the capability, until each carries its own approval. The CLI restart and stop now carry those approvals.
  • 9. Lean on proven primitives. Ed25519 through ed25519-dalek with strict verification, over an RFC 8785-style canonical JSON body under a purpose prefix. No new crypto.
  • 10. Client-first, daemon when forced. The CLI mints and signs. The daemon holds only what the client cannot be trusted with: the switch, the verification and the consumption record.

Enforcement must not be relied on until the separate PR that stops headerless mesh TCP callers receiving Full scope lands.

Summary

mermaid
1sequenceDiagram
2 participant CLI as Approved Device CLI
3 participant Cloud as Operator transport
4 participant Machine as Customer Machine
5 loop Each copy in a rolling restart
6 CLI->>CLI: Sign workflow kind, Container, Cluster, Service, Machine, issue time and expiry
7 CLI->>Cloud: Hold, Stop, Start with that approval
8 Cloud->>Machine: Relay each step
9 Machine->>Machine: Verify Device, target and bounded time
10 Machine->>Machine: Persist consumption and time floor before acting
11 Machine-->>CLI: Result for this copy
12 end

Under enforcement, every Operator lifecycle request on an existing Container needs approval. Restart and stop approvals never authorize Out or Gone. Running(New) is refused entirely, including adoption, until deploy authorization lands. Enforcement off and Full SSH calls retain their behavior. An unavailable Device key sends an unsigned request; only a Machine refusal produces the short Device note.

Each approval names one Container ID. Restart signing happens inside each rolling batch, immediately before that copy's steps. The Machine enforces a maximum signed lifetime of 15 minutes and two minutes of tolerance on issue time and expiry. It parses sorted, distinct Machines and a fixed 64-byte signature.

Initial slice evidence

Focused checks on this head:

text
1cargo test -p ployz-core --lib -- authority all pass
2cargo test -p ployz-core --test capabilities 4 passed
3cargo test -p ployzd --lib -- device_enforcement restart_approval device_slot_tests
4 12 passed
5cargo clippy -p ployz-core -p ployzd -p ployz --all-targets clean
6cargo fmt --check clean

Daemon behaviour tests (local_tests.rs, mod device_enforcement):

  • enforcement off leaves an unsigned Operator restart as it was
  • an Operator restart passes only with an approved Device's approval of it: unsigned, forged by a non-Device key, tampered body, other Service, other Machines and expired each refuse with ployz.restart.device-approval.v1 in the details and no /stop reaching Docker; the valid one stops the Container
  • a used approval runs each step once even after the daemon restarts: Stop then Start pass once, replays refuse, a service rebuilt over the same record refuses both replays, a fresh approval passes
  • a Full caller restarts without an approval
  • the switch answers its new state and the record keeps it

CLI test (exec_tests/user_commands.rs): a Machine that refuses the Hold answered before acting, so service restart and service stop give the refusal alone, send no bring-in, and leave the copy In.

Boat proof (scripts/verify-cluster up --cloud, one Machine, daemon checkout at revision 505d2d999b, this PR's head, built with --features ployz/verify-faults,ployzd/verify-faults so ployz debug volume-rpc can send a raw EnsureContainer through Cloud as the Device CLI's Operator slot). Project verify, Service web on nginx:1.29-alpine, Container 685518d2f7b7…. cli below is the Organization Token through Cloud, cli --device the seeded owner's CLI with its approved Device. Each case checks docker inspect on the Machine before and after.

Enforcement off, the Organization Token restart passes as today:

text
1$ cli -- service restart web
2Restarted verify-production/web on machine-1.
3StartedAt 09:03:37 -> 09:04:08

The Device turns enforcement on, and the unsigned Operator restart is refused naming the capability, Container untouched:

text
1$ cli --device -- device enforce
2SERVER DEVICE ENFORCEMENT
3machine-1 on
4$ cli -- service restart web
5! Could not restart verify-production/web on machine-1: Container 685518d2f7b7… was not restarted:
6 ployz.restart.device-approval.v1 needs a Device-signed restart approval: the workflow approval carries no signature.
7 It is left Held and not routed.
8[exit 3] StartedAt unchanged 09:04:08

The Device-signed CLI restart restarts the Container and the Machine records the operation:

text
1$ cli --device -- service restart web
2Restarted verify-production/web on machine-1.
3StartedAt 09:04:08 -> 09:04:16
4$ exec machine-1 -- cat /var/lib/ployz/restart-approvals.json
5-rw------- 1 root root 155 /var/lib/ployz/restart-approvals.json
6{"b7095c1942bb…":{"expires_at":1793956753463,"containers":{"685518d2f7b7…":"start"}}}

Hand-minted approvals (a Python signer over the same canonical body; the genuine one passing below proves it matches the Rust encoding) sent as raw EnsureContainer wants through Cloud:

text
1forged, signed by a fresh key that is no Device of this Machine
2 error: ployz.restart.device-approval.v1 needs a Device-signed restart approval: device qaxgy6zr4w is not approved on this Machine
3 next: ployz service restart from an approved device Container untouched
4
5genuine, one operation cd54c064…: Hold -> ensured; Stop -> ensured, Container exited 09:10:24
6 Stop again -> error: … operation cd54c064… already ran this step; a new restart signs a new approval
7 systemctl restart ployz (ActiveEnterTimestamp 09:10:32)
8 Stop again -> error: … operation cd54c064… already ran this step; a new restart signs a new approval
9 Start -> ensured, rotation in, Container running, StartedAt 09:10:41
10 Start again -> error: … already ran this step
11
12expired, signed by the real Device with expires_at 1
13 error: ployz.restart.device-approval.v1 needs a Device-signed restart approval: the approval expired
14 Container untouched

Enforcement off again, the Organization Token restart passes, and doctor is clean afterwards:

text
1$ cli --device -- device enforce --off
2machine-1 off
3$ cli -- service restart web
4Restarted verify-production/web on machine-1. StartedAt 09:10:41 -> 09:11:14

One observation from the proof: a step is recorded before the Machine acts, so a Stop the workflow then refuses (sent before its Hold) spends that step and the next attempt needs a new approval. That is the fail-closed choice the record makes on purpose; the CLI always sends Hold first.

Two independent Boat runs at the same revision, each on its own worktree and --cloud with two Machines, covered what the run above did not:

  • Two Machines. Two replicas, one per Machine. Enforcement on both; the Organization Token restart, stop, start and deploy refused on both naming the capability, the Device-signed restart restarted both and each Machine recorded the same operation with its own Container; the Organization Token's device enforce --off refused as needing Full access; --connect ssh:// restarted both with no record written; enforcement off restored every Cloud path. A first deploy of a brand-new Service is also refused, at the Start of its Container.
  • Older daemon. Both Machines on release 0.3.16: device enforce names both Servers and exits partial; the Device-signed restart restarts both and the release daemon logs nothing about the approval. Machine-1 moved to the checkout daemon: enforcement is off after the upgrade, turning it on gates only machine-1, the signed restart restarts both and only machine-1 writes restart-approvals.json.

Both runs found the same wrong sentence: a refused Operator restart ended with "It is left Held and not routed" while the copy stayed In and serving. The CLI treated the refusal like a lost Hold and tried to bring the copy back In. Fixed in the last commit, with the CLI test above. The two runs also found that device enforce reports a too-old daemon as "did not answer", the same as the pre-existing device approve and device ls (#3928, F 4 × S 3), and that a refused deploy's retry: hint repeats a failing command (#3929, F 3 × S 2). Both are below the fix bar and left alone. The doc now gives the real reason deploys are refused.

Round 1 fixes

The lifecycle gate now classifies Held, Out, Stopped, Running(Existing) and Gone exhaustively. Restart and stop sign each Container separately. A captured approval cannot affect another copy. New-creation requests, including adoption of an exited same-slot Container, cannot use restart authorization.

The consumption log persists expiry_time_floor and uses max(now, expiry_time_floor) for time checks and pruning, including expiry tolerance. It keeps the latest expiry seen for a reused operation ID, invalidates its cache after write errors, and is owned once by the shared RecordOwner. Refusal reasons travel as error causes. The OperationId comment no longer claims to distinguish a retry from a replay.

Focused checks on the fix source:

text
1cargo test -p ployz-core --lib authority 18 passed
2cargo test -p ployz-core --test capabilities 4 passed
3cargo test -p ployzd --lib restart_approval 6 passed
4cargo test -p ployzd --lib device_enforcement 10 passed
5cargo test -p ployz --lib handlers::service::tests 16 passed
6cargo test -p ployz --lib user_commands 10 passed
7cargo clippy -p ployz-core -p ployzd -p ployz --all-targets
8cargo fmt --check

All listed checks passed on head 20ae8e51465e8e3830b5a57bae369e0e0297c6d5.

Regression coverage includes unsigned Out then Gone on a serving Container, Running(New) adoption of an exited Container, pruning followed by clock rollback and log reopen, a malformed Device key still sending an unsigned restart, a captured approval rejected on a second copy, Operator refusal at LocalMachine::set_device_enforcement, bounded lifetime and skew, malformed signatures and Machines, reused operation expiry, and a write that lands before returning an error. Deslop completed. Two Codex no-comments passes removed internal narration and replaced the new indexing suppression with checked access.

Boat proof used verify-server, --cloud, one Boat Machine and two nginx copies. Evidence is retained in core/.verify/20261106T125101-4518213567/evidence/commands.jsonl in the implementation checkout. The checkout daemon and CLI ran the final production source. The final manifest names head 20ae8e51465e8e3830b5a57bae369e0e0297c6d5; doctor verified daemon SHA-256 3d634ed18bf500071c839cb1a40d895b29c52152c8025a0210432727b39429f2 and CLI SHA-256 f465a737b0f18b8b8ec6f8c6821c7b6c1d8f957b834a5051bbb22d7b11d5c174. Enforcement was on for the refusal and signed cases.

text
1Unsigned Operator Out(allow_empty=true), then Gone, on copy 903d5567…
2 both refused with ployz.restart.device-approval.v1
3 StartedAt stayed 2026-11-06T13:13:26.780592247Z; HTTP 200 before and after
4
5Device-signed service restart web
6 both copies restarted, one at a time
7Device-signed service stop web
8 both copies exited; FinishedAt 13:11:41.977 and 13:11:42.516
9
10Captured approval d682e943… names only copy 4779b096…
11 Hold then Stop on 4779b096… succeeded
12 Stop on 903d5567… refused before and after, with cause 'names a Container'
13 4779b096… exited; 903d5567… stayed running and returned HTTP 200
14
15Malformed device.key, enforcement on
16 restart was sent, refused by the Machine, and printed the short Device note
17Malformed device.key, enforcement off
18 --connect ssh://… service restart web succeeded on both copies
19 Cloud service restart web also succeeded; both running, HTTP 200

The unsigned and signed paths were checked again after updating the checkout binaries. The interim lab log's time-floor field was renamed while preserving all consumption records; the regression tests separately prove reading this PR's original map-only log without changing its contents.

Remaining boundaries are explicit. Cloud can still choose which Cluster the CLI connects to, so the Device can sign for a Cluster the user did not intend. Displaying Cluster and Server targets before signing and pinning the Cluster per context remain follow-ups. UpdateContainerLimits, Leave, Forget, RemoveVolume and SetVolumeSize are still Operator-scoped without this Device gate. They are later slices; this PR does not protect every way an Operator can stop a Service.

Round 2 fixes

A captured restart approval previously admitted Out { allow_empty: true } and then Gone. The new daemon behavior test reproduced the first effect on the old head as Ensured with rotation Out; the same test now passes with both requests refused and the Container still running.

WorkflowKind now distinguishes Restart (Hold, Stop, Start) from Stop (Hold, Stop). Neither permits Out or Gone. The CLI signs the requested kind. Removal has no signed Operator workflow in this slice and remains refused under enforcement; it cannot borrow either approval. The changed contract advertises ployz.restart.device-approval.v2, replacing v1. An older parser refuses the new Stop kind rather than interpreting it as Restart. Historical evidence above names the earlier v1 contract.

Admission returns the want pinned to the verified ContainerId, and the RPC handler executes that returned want. An empty target under enforcement refuses before execution. Regression tests replace A after admission and verify B stays running, refuse A's approval once its slot holds B, and refuse an empty slot. With enforcement off, admission returns the original want unchanged.

Bets changed by these fixes are the Stable promise (v2 names the changed contract), 5. Two-tier identity (the slot selects a Container but never substitutes for its ID), 6. Machine-local authority (the Machine authorizes only the signed steps and verified ID), and 10. Client-first, daemon when forced (the CLI chooses and signs the kind; the Machine enforces it).

Focused checks on e58f75f583d0374270995f2164c87041b7b7abb9 all passed:

text
1cargo test -p ployz-core --lib authority 19 passed
2cargo test -p ployz-core --test capabilities 4 passed
3cargo test -p ployzd --lib restart_approval 7 passed
4cargo test -p ployzd --lib device_enforcement 13 passed
5cargo test -p ployz --lib handlers::service::tests 16 passed
6cargo test -p ployz --lib user_commands 10 passed
7cargo clippy -p ployz-core -p ployzd -p ployz --all-targets
8cargo fmt --check

Deslop completed. The requested no-comments review ran on Codex gpt-6.1-sol over the fix diff, with zero deletions, flags or changed suppressions. Independent security review remains with the coordinating task. This PR remains draft.

Boat proof used verify-server, one Machine, --cloud, the checkout CLI and daemon with verify-faults, and an exact checkout SDK. The manifest revision equals that head. Evidence remains in core/.verify/20261106T133235-c2ae0ace3f/evidence/, including commands.jsonl, round-2-summary.md, the signed envelope, focused check logs and exact-sdk.log.

On postgres:18 Container 9a8f945bed40…, with an image-created anonymous Volume and a sentinel file:

  • Enforcement on, the same approved Device's signed restart envelope relayed through Cloud as an Operator was refused for Out and Gone with v2 and cause the approval names a workflow this request does not match. Docker remained running with StartedAt 2026-11-06T13:39:51.727551008Z; the same anonymous Volume and r2-volume-survives sentinel survived.
  • Device-signed service restart db passed, with StartedAt 13:40:44.797224823Z. Device-signed service stop db passed; Docker reported exited with FinishedAt 13:40:50.985488673Z.
  • Enforcement off, unsigned Cloud start and restart passed. Docker returned running with StartedAt 13:41:20.231440923Z, the same Volume and sentinel. An unsigned restart also passed before enabling enforcement. Doctor passed before and after.

The slot-race cases were exercised by the local daemon behavior tests, not forced on Boat. No requested Boat case was skipped. Doctor verified daemon SHA-256 308ec5e3e801dc54fb48f55ee9b1e54f7fcec36c17ac18a2fc5d59633a3ce81d and CLI SHA-256 f8da0b74da8fb55f7ca0215f22fb13ad10809263c0ad426f1f3420b901f96bde.

Deleting the consumption log locally resets its history; doing that requires root on the customer's Machine, which already holds Full, and is outside this PR's threat boundary.

Merge Danger

Door: two-way

Enforcement is off by default and turning it off restores ordinary Operator lifecycle behavior. The signed approval format belongs to this unshipped first slice. Machine records and RPC additions remain additive. The log reads the previous map-only form and adds a persisted time floor.

Blast Radius: lifecycle

On enforcing Machines, unsigned Operator lifecycle changes and every Running(New) request are refused. Approved Device CLI restart and stop work per Container. Headerless mesh TCP Full access and the later Operator mutation slices remain outside this PR.

Checks

16 successful · 5 skipped
All checks passed
f6a5946
Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

External image Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 01e4b47c-cf20-41ff-a14d-81cfeed7b18a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

PASS+NOTES

Independent verification of head e58f75f583d0374270995f2164c87041b7b7abb9. The run's manifest revision equals that head.

Setup. verify-server on Boat with 2 Machines and --cloud. The checkout daemon and CLI were built with --features ployz/verify-faults,ployzd/verify-faults. Run 20261107T012351-a18a0041cf; doctor was clean after boot. Project verify, Service web on nginx:1.29-alpine with 2 copies (one per Machine), and a db Service on postgres:18 with Managed Volume pgdata. cli below means the Organization Token through Cloud, which is an Operator. cli --device means the seeded owner's CLI with its approved Device. Each step was checked with docker inspect State and StartedAt on both Machines.

1. Enforcement off: matches main

CommandEnd state
cli service restart webboth copies restarted, StartedAt advanced, exit 0
cli service stop web / start webboth exited, then both running, exit 0
cli --device service restart web (signed)both restarted, exit 0; no restart-approvals.json written on either Machine
set web.env.FOO=bar + deployboth copies replaced, switched 2/2
cli --device exec -T web -- …FOO=bar, exit 0
cli --device service port-forward web 18080:80curl 127.0.0.1:18080 returned 200
volume move pgdata --to machine-2run done, "db was stopped 12s"; db on machine-2; sentinel row sentinel-3926 survived
--connect ssh://root@<machine-1> service restart/stop/start web, execall exit 0, Docker state followed
device.key malformed (7 bytes): cli --device service restart/stop/start, and restart over --connect ssh://all exit 0, all copies changed
device.key mode 644: cli --device service restartexit 0
device.key missing: cli --device service stop / startexit 0
Dashboard web actions menu: Restart, Stop (confirm dialog), then Starteach one changed both Containers in Docker

I did not boot a separate main Cluster. "Matches main" means the outputs and end states equal the documented, unchanged behavior, and no new line such as the Device note appeared on any enforcement-off path.

2. Enforcement on

CommandEnd state
cli device enforce (Org Token)refused on both: ployz.machine.set-device-enforcement.v1 needs Full access, exit 3
cli --device device enforcemachine-1 on, machine-2 on
cli service restart web / stop / starteach refused per copy with ployz.restart.device-approval.v2 … the workflow approval carries no signature, exit 3; StartedAt unchanged, HTTP 200 from each Container
cli deploy (env change)create Container failed. cause: the approval names a workflow this request does not match; old copies still running
cli --device service restart webboth restarted; each Machine recorded its own operation for its own Container
cli --device service stop webboth exited
--connect ssh:// service restart web (Full)all 4 copies restarted
malformed device.key, cli --device service restart webrefused per copy with the short Device note, Containers untouched
cli --device device enforce --off, then cli service restart and deployboth work again, doctor clean

Hand-sent approvals. I sent raw EnsureContainer requests with cli debug volume-rpc through Cloud as the Org Token. Each approval was signed with the Device key by a one-off binary that calls this head's ployz_core::authority::Signed::sign, the same call the CLI makes. web was scaled to 4 copies so each Machine held two (A1, A2 on machine-1; B1 on machine-2).

  • Approval X (restart, A1): Hold ensured, then Stop ensured, and A1 exited. That proves the signer matches.
  • Replaying Stop and Hold of X: refused, operation 1237c867… already ran this step.
  • X used on A2, on the same Machine, for Hold and for Stop: refused, the approval names a Container this request does not match. A2 stayed running.
  • X used on B1 on machine-2: refused, the approval names Machines this request does not match. B1 stayed running.
  • Start of A1 with X: ensured, rotation in. Replaying Start, and Stop after Start: refused, already ran this step.
  • A fresh restart approval used for Out {allow_empty:true} and then Gone: both refused, the approval names a workflow this request does not match. A fresh stop approval used for the same: also refused. Unsigned Out and Gone: refused. A1 stayed running with StartedAt unchanged.
  • A 16-minute lifetime: refused, names a lifetime. Expired 6 minutes ago: refused, the approval expired. Body changed from web to db: refused, signature by device p2tep5a6du does not verify.
  • Approval Z on A2: Hold and Stop ensured. Then systemctl restart ployz on machine-1. Replaying Stop and Hold: still refused. Start ensured. Replaying Start: refused.

3. Nearby breakage

  • server ls and doctor: correct at every stage.
  • Mixed Cluster via update --daemon machine-2=stable (release 0.3.17): restarts from the Org Token and from the Device both worked. device enforce gave machine-1 on and ! machine-2 did not answer (#3928). With only machine-1 enforcing, the Org Token restart refused machine-1 and restarted machine-2. The signed restart restarted both.
  • Upgrading machine-2 back to the checkout: enforcement read false after the upgrade, Org Token restart, stop, start and redeploy all worked, and exec saw the new env.
  • Cloud server upgrade stable machine-2: Upgraded Server machine-2 to 0.3.17, and a restart afterwards worked.

Notes (not blocking)

  1. With enforcement on, the dashboard Restart button leaves the Containers untouched, which is correct. The banner, though, reads Restart stopped. Container 1 on machine-1 didn't come back. machine-1 couldn't start it. The rest are untouched. The Container was never stopped. Only users who turned enforcement on see this. F 2 × S 3.
  2. With enforcement on, cli --device service start web from an approved Device with a valid key prints No Device approval was sent. Check this computer's Device key and approval. Start is never signed in this slice, so the hint sends the user to the wrong place. The docs do say start is refused. F 2 × S 2.
  3. The refused deploy's cause for an unsigned new Container reads the approval names a workflow this request does not match, although no approval was sent. This is close to #3929.

Not covered: slot-race substitution was not forced on Boat (the PR's unit tests cover it), and I booted no main baseline Cluster. The Boat run was torn down; its evidence is kept under core/.verify/20261107T012351-a18a0041cf/evidence/.

Added 16 commits

  • test: reproduce restart approval authorizing removal0cd8080
  • Drop a needless borrow in the consumption log test12f721b
  • Sign service restarts with the Device key and add ployz device enforce1588342
  • Refuse an Operator's restart step without a Device's approval when enforced2b87bfe
  • Update lifecycle fixtures for container-bound approvals505dc9f
  • Add the Device-signed restart approval envelope and its catalog rows6721017
  • Document Device enforcement for service restarts6aa0c18
  • Bind lifecycle approvals to one Container and a bounded lifetime748a24b
  • Say only the refusal when a Machine refuses a restart's Hold7a5e687
  • fix: restrict signed approvals to restart or stop steps7ac210d
  • Send lifecycle requests unsigned when the Device key cannot load7e1dfdb
  • Document per-copy approvals and clock toleranceb52ffae
  • fix: pin admitted lifecycle requests to verified container IDsc498009
  • Persist the approval clock floor and share consumption ownershipe76aa65
  • Set main's new leaving field in the restart fixture719b176
  • Update the uptime lock file for the authority cratesf6a5946