Fleet management: Nostr-native remote control & telemetry for ATM operators #42
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A real operator runs anywhere from a handful to ~100 machines. Today the only way to interact with a deployed
lamassu-nextmachine is to SSH in and runatm-tui. That doesn't scale, and it doesn't match the spirit of the rest of the architecture, where every other component (Lightning.Pub, CLINK, customer wallets) talks over Nostr.This issue proposes a fleet management plane that reuses the primitives we already have in the codebase, plus a seed URL bootstrap that lets an operator enroll a brand-new machine from the dashboard without ever touching the machine's filesystem.
Goals
.envediting.Non-goals
.envas a recovery path. They remain the ultimate escape hatch — they're just not the day-to-day provisioning surface.Three planes, separated
30000or30003),dtag =lamassu-fleet, signed by the operator pubkey, with oneptag per machine npub30078availability already exists. Add30079(inventory + health snapshot). Optionally a non-replaceable kind for transaction recordsrequest_id. Same pattern as the Lightning.Pub RPC client inpackages/lightningBootstrap: seed URLs
The hardest part of fleet management is the bootstrap problem: a brand-new machine has no idea who its operator is, has no Lightning.Pub account yet, doesn't know which relay to talk to, and has no
.env. We solve this with a single connection-string-style URL, modeled directly on NWC connection strings (nostr+walletconnect://...) and NIP-46 bunker URLs (bunker://...).Format
A URL scheme (not a new bech32 prefix) is intentional: easy to QR-encode, easy to debug, easy to parse with the standard
URLclass everywhere, and consistent with NWC/bunker conventions.Enrollment flow
On the dashboard (operator is logged in with their nsec):
UseInviteLinkis already in the autogenerated LP RPC client).nonce) waiting for the new machine to announce itself.On the new machine (first boot, no
.envyet):exp, then in order:identifier). Stores the resulting LP credentials..env(orconfig.json) with: own nsec, relay URLs, LP URL + token, operator npub allow-list =[<operator-pubkey>], fiat, model.useAvailabilityBroadcast.ts).["e", <nonce>]and["p", <operator-pubkey>], signed with the new machine key. This is the "I claimed your seed, here is my pubkey" handshake.After this, the machine is fully provisioned. Steady-state fleet management uses the same primitives that just bootstrapped it (same relay, same encryption, same signing keys) — there is no separate "provisioning protocol" to maintain.
Seed URL security
Reliability: how do we know a command was received?
There are two distinct levels of acknowledgement, and we must not conflate them.
OKframe). After publishing, the relay returns["OK", <event_id>, true|false, <reason>]. This proves the relay accepted and stored the event. It does not prove the recipient saw it.request_id. This is the only thing that proves end-to-end delivery.The dashboard must wait on (2), with (1) used only as an early failure signal. This is exactly what the existing Lightning.Pub RPC client and CLINK client already do — we should factor that pattern into a shared helper rather than reinventing it.
The same two-level acknowledgement applies to the enrollment handshake: the dashboard treats the seed as unclaimed until it sees the signed ack event from the new machine, not just an OK from the relay.
Additional reliability rules:
Authorization model (steady state)
After enrollment, the machine has an operator allow-list seeded with the pubkey from the seed URL. From then on:
GetInventory,GetHealth,GetRecentTransactions) from cash-affecting ones (SetRate,EmptyCassette,Reboot,RotateKeys). A "viewer" key can be granted without "operator" privileges. Capabilities are declared in a manifest published by the machine so the dashboard knows what it can ask for.AddOperator <npub> --scope ...), signed by an existing operator. No physical access needed.RevokeOperator <npub>), again signed by an existing operator.Relevant NIPs
OKframes for relay-level ack.nostr+walletconnect://is the closest existing analog).Proposed components
packages/fleet-protocol(new)Shared schemas and types between dashboard and machine:
d-tag conventionsFleetClienthelper wrapping request/response withrequest_idcorrelation, multi-relay publish, timeout, and idempotency. Built on top of@lamassu/nostr-client.apps/machineadditionsp-tag = own pubkey, kind = fleet control kind), filter by allow-list, validate against the capability schema.30079inventory/health snapshots on a debounce + heartbeat (mirror of the existinguseAvailabilityBroadcastcomposable).d=lamassu-capabilities) at boot.apps/dashboard(currently empty)shockwallet-vuestack — operators get a familiar feel).file://.FleetClient. Surfaces the three response states clearly.Open questions
CLAUDE.mdmentions kind30079for "Transaction Record (replaceable)" but replaceable doesn't fit transactions (each is unique). Reconcile:30079= inventory snapshot (replaceable, makes sense), and use a non-replaceable kind for individual transaction records..envis wiped after first enrollment? It boots into the wizard again, generates a new keypair, and the operator must issue a fresh seed. The old machine npub becomes a stale entry in the fleet roster — needs a "remove machine" action in the dashboard.Suggested phasing
packages/fleet-protocolwith schemas +FleetClient+ seed URL parser/encoder. Refactor existing kind-21000 RPC client to use the shared helper.30079snapshots and a capability manifest. Dashboard skeleton can subscribe and render a read-only fleet view for machines that are already configured (manual.env). No control commands, no enrollment yet. Already useful on its own.GetInventory,GetHealth,GetRecentTransactions. Exercises the full request/response path with no risk of cash movement.SetRate,Reboot,EmptyCassette,AddOperator,RevokeOperator, etc. Requires the authorization model and idempotency to be solid first.References
apps/machine/src/composables/useAvailabilityBroadcast.ts— existing kind 30078 telemetry, the template for kind 30079.packages/lightning/andpackages/clink/— existing request/response patterns to factor intoFleetClient.docs/architecture-comparison.md— frames why this is a natural fit for the Nostr-native approach.nostr+walletconnect://URL pattern.