Skip to content

Enrol your first Node ​

Every other tutorial works with tenancy — Domains, Projects, identities, labels. This one works with the mesh: the overlay network of Nodes that plexsphere enrols, connects, and re-keys. You will take a fresh Node from nothing to a full mesh identity, watch a second Node join so a real topology forms, and then rotate a Node's mesh key the way an operator and an agent do it together.

You will:

  • mint a single-use bootstrap token for a Project,
  • redeem it from a Node at the unauthenticated POST /v1/register seam to exchange that one-shot credential for a mesh IP, a node secret key, and the Domain's signing key,
  • enrol a second Node and read the resulting mesh topology with plexctl peer and plexctl mesh,
  • and rotate a Node's mesh key — trigger it as the operator, then complete it as the Node.

By the end you will understand how a Node becomes a first-class member of a Domain's mesh, and why enrolment and key rotation are split between an operator action and a node action.

This lesson takes about twenty minutes.

Before you start ​

This lesson opens the mesh track and does not build on the core or provisioning tracks. You only need a running, logged-in stack from Set up your local plexsphere. It does add three command-line tools beyond that lesson's set, because you will play the part of the Node yourself rather than running the plexd agent:

ToolPurpose
jqread fields out of the JSON responses
wg (WireGuard tools)generate the Node's X25519 keypair
xxd (ships with vim)turn the Node id into the raw bytes of the rotation envelope
curldrive POST /v1/register and POST /v1/keys/rotate directly

There is no plexctl register command: enrolment and key completion are the contract the plexd agent speaks, so this lesson drives those two endpoints with curl to show the exchange in the open. Everything an operator does — issue tokens, inspect peers, trigger a rotation — is plain plexctl.

Recreate the shell environment and read the Acme Corp Domain id straight off the stack — plexctl login needs the Domain UUID before any authenticated call could look it up:

bash
export PATH="$PWD/bin:$PATH"
export PLEXSPHERE_URL=http://localhost:8080

eval "$(make -s dev-ids)"
echo "$DOMAIN_ID"
text
019ed19b-68dc-7aa9-8995-80e810444cce

Device-code tokens are short-lived, so sign in fresh, completing the browser prompt as admin@example.com with the password password:

bash
plexctl login --domain-id "$DOMAIN_ID"

Step 1 — Create a Project to enrol into ​

A Node always enrols into a Project, and the bootstrap token is scoped to one. Create a Project under Acme Corp to hold your fleet:

bash
PROJECT_ID=$(plexctl project create \
  --domain "$DOMAIN_ID" \
  --slug edge-fleet \
  --display-name "Edge Fleet" \
  --output json | jq -r '.id')
echo "$PROJECT_ID"
text
019ed19f-d2f5-7afb-aaf6-4c598fb22c3c

The Project's parent Domain carries the mesh CIDR every Node in the Domain draws its address from — for Acme Corp that is 10.50.0.0/24. You do not configure it here; the seed planted it when it created the Domain.

Give the grant a moment to land. Your admin access to a new Project reaches the authorization mirror a moment after the Project is created — the same eventual consistency you will see again when a Node becomes a peer in Step 5. If the next command returns Permission Denied, wait a second and re-run it.

Step 2 — Mint a bootstrap token ​

A BootstrapToken is the single-use credential a fresh Node presents on its very first call. Mint one for the Project, scoped to the node kind:

bash
plexctl bootstrap-token issue \
  --project "$PROJECT_ID" \
  --kind node \
  --env-prefix dev \
  --ttl 1h
text
# WARNING: this is the only time this plaintext will be displayed
psb_dev_…_node_…

token_id:   019ecc68-1a2b-7c3d-8e4f-0a1b2c3d4e5f
issued_at:  2026-06-15T18:30:00Z
expires_at: 2026-06-15T19:30:00Z

The plaintext is shown exactly once — the server stores only a hash, so capture it now. --kind node binds the token to the Node register seam (a bridge token presented there is rejected); --ttl is bounded server-side to [5m, 24h]. Capture the plaintext for the next step:

bash
TOKEN_A=$(plexctl bootstrap-token issue \
  --project "$PROJECT_ID" --kind node --env-prefix dev --ttl 1h \
  --output json | jq -r '.token')

Step 3 — Redeem the token from the Node ​

This is the moment a credential becomes an identity, in three moves: generate a WireGuard keypair, redeem the bootstrap token against POST /v1/register, and read back the identity the platform minted. The seam is unauthenticated: the bootstrap token is the credential.

First the keypair. The private half lands in node-a.key and never leaves the Node; wg pubkey derives the public half — the only part that ever goes on the wire — into node-a.pub:

bash
wg genkey | tee node-a.key | wg pubkey > node-a.pub

Now redeem. The heredoc is the JSON body --data @- reads from stdin: it names the Resource handle, presents the token, mints a fresh anti-replay nonce, and inlines the public key. The response goes to register-a.json rather than the terminal because it carries a secret you will need again later — the node secret key, returned exactly once:

bash
curl --silent --show-error --fail-with-body \
  --request POST --header "Content-Type: application/json" \
  --data @- "${PLEXSPHERE_URL}/v1/register" > register-a.json <<EOF
{
  "project_id": "${PROJECT_ID}",
  "resource_handle": "edge-router-01",
  "requested_resource_id": "edge-router-01",
  "bootstrap_token": "${TOKEN_A}",
  "nonce": "$(openssl rand -hex 16)",
  "public_key": "$(cat node-a.pub)"
}
EOF

On success the call prints nothing — the receipt is the file. Read the identity fields back out of it:

bash
jq '{node_id, mesh_ip, signing_key_id, domain_mesh_cidr}' register-a.json
text
{
  "node_id": "019ed1c4-26e4-7465-be1b-f54078f63315",
  "mesh_ip": "10.50.0.1",
  "signing_key_id": "did:plexsphere:domain/dev#k1",
  "domain_mesh_cidr": "10.50.0.0/24"
}

The response is the Node's whole new world: an allocator-assigned mesh_ip inside the Domain CIDR, the Domain's signing_public_key and signing_key_id (so the Node can verify signed events later), and an nsk — the node secret key, returned base64-encoded exactly once. Capture the bits the Node keeps:

bash
NODE_A=$(jq -r '.node_id' register-a.json)

What is requested_resource_id? A Node enrols against a Resource handle. When the handle does not already exist, a non-empty requested_resource_id tells plexsphere "create the Resource for this substrate as you enrol it" — the adoption path. That is why this works on a clean stack with no manual Resource setup: the Resource and the Node are created together in one transaction. Redeem the same token twice and the second attempt is rejected with 403 token_consumed.

Step 4 — Enrol a second Node ​

A mesh of one is just a Node. Enrol a second so a topology forms. The moves are the same as Step 3, with fresh material throughout.

A fresh token first — the one from Step 2 is consumed, and a BootstrapToken never redeems twice:

bash
TOKEN_B=$(plexctl bootstrap-token issue \
  --project "$PROJECT_ID" --kind node --env-prefix dev --ttl 1h \
  --output json | jq -r '.token')

A fresh keypair, because a mesh identity is its key — two Nodes must never share one:

bash
wg genkey | tee node-b.key | wg pubkey > node-b.pub

Then the redemption, against a fresh resource_handle so adoption mints a second Resource:

bash
curl --silent --show-error --fail-with-body \
  --request POST --header "Content-Type: application/json" \
  --data @- "${PLEXSPHERE_URL}/v1/register" > register-b.json <<EOF
{
  "project_id": "${PROJECT_ID}",
  "resource_handle": "edge-router-02",
  "requested_resource_id": "edge-router-02",
  "bootstrap_token": "${TOKEN_B}",
  "nonce": "$(openssl rand -hex 16)",
  "public_key": "$(cat node-b.pub)"
}
EOF

Read the allocated address out of the receipt:

bash
jq -r '.mesh_ip' register-b.json
text
10.50.0.2

The allocator handed the second Node the next free address, 10.50.0.2.

Step 5 — Read the mesh ​

Enrolment writes a Node into the inventory; a short moment later the control plane anchors it as a mesh peer and issues its pairwise key. That step is asynchronous — give it a few seconds, then list the peers in the Domain:

bash
plexctl peer list --domain "$DOMAIN_ID"
text
NODE_ID                               MESH_IP    REACHABILITY
019ed1c4-26e4-7465-be1b-f54078f63315  10.50.0.1  never_reported
019ed1c4-7314-7488-85b4-3aeb1220f38a  10.50.0.2  never_reported

Both Nodes are anchored, and both read never_reported. That is the verdict for a Node the platform has never heard from: you enrolled these two by hand, and no agent is running on either, so neither has ever sent a heartbeat. A Node keeps that verdict until its agent's first heartbeat is admitted, and the reachability vocabulary you would expect (healthy, stale, unreachable) starts from there. The next lesson runs a real agent and you will see a row earn healthy.

Now look at the topology — the directed edges between them:

bash
plexctl mesh topology --domain "$DOMAIN_ID"
text
FROM                                  TO                                    MESH_IP    FALLBACK  REACH_FROM      REACH_TO
019ed1c4-26e4-7465-be1b-f54078f63315  019ed1c4-7314-7488-85b4-3aeb1220f38a  10.50.0.2            never_reported  never_reported
019ed1c4-7314-7488-85b4-3aeb1220f38a  019ed1c4-26e4-7465-be1b-f54078f63315  10.50.0.1            never_reported  never_reported

Two Nodes, two directed edges: plexsphere models the overlay as a full mesh, so every Node holds a direct WireGuard edge to every other. The FALLBACK column is empty because both edges are direct — a relay only appears when a Node sits behind NAT it cannot traverse directly.

Step 6 — Rotate a Node's mesh key (operator side) ​

Mesh keys rotate. Rotation is deliberately a two-party act: an operator triggers it, and the Node completes it by generating fresh key material. Start as the operator. Preview the impact first — a --dry-run reads what a rotation would touch without recording anything:

bash
plexctl key rotate --node "$NODE_A" --dry-run
text
NODE_ID                               PEER_ID                               AFFECTED_PEERS  ALREADY_PENDING  ETA_SECONDS
019ed1c4-26e4-7465-be1b-f54078f63315  019ed1c4-4b5c-7d6e-8f70-2b3c4d5e6f71  1               false            0

One peer edge would be re-keyed, and no rotation is already pending. Now trigger it for real:

bash
plexctl key rotate --node "$NODE_A"
text
NODE_ID                               PEER_ID                               ROTATION_ID                           ALREADY_PENDING
019ed1c4-26e4-7465-be1b-f54078f63315  019ed1c4-4b5c-7d6e-8f70-2b3c4d5e6f71  019ecc68-5c6d-7e7f-9081-3c4d5e6f7082  false

The trigger records a pending rotation and dispatches a rotate_keys command toward the Node. The operator's job ends here: plexsphere will not invent key material on the Node's behalf — only the Node holds its private keys.

Step 7 — Complete the rotation (node side) ​

The Node receives the rotate_keys hint, generates a fresh Curve25519 keypair, and submits the new public key to POST /v1/keys/rotate. This route carries no Node id in its path — the Node is identified solely by the node secret key it presents in the Authorization header. The wire form of that credential is nsk_<env>_<base64url(node-id ‖ nsk)>; assemble it from the values the register response handed you — strip the dashes off the Node id and unhex it into its sixteen raw bytes, decode the base64 nsk into its thirty-two, and re-encode the concatenation as unpadded base64url:

bash
ENVELOPE="nsk_dev_$(
  {
    printf '%s' "$NODE_A" | tr -d '-' | xxd -r -p
    jq -r '.nsk' register-a.json | base64 -d
  } | base64 | tr '+/' '-_' | tr -d '='
)"
echo "$ENVELOPE"
text
nsk_dev_AZ7RxCbkdGW-G_VAePYzFUDIvJQHCgp5lncke8C2jc3Cx1O0R2tktCs6IIgFjPtP

Sixty-four unpadded base64url characters — exactly forty-eight bytes, sixteen of Node id and thirty-two of secret. Decode the first sixteen and you get $NODE_A back.

Generate the Node's new public key. The pipe derives the public half and keeps nothing else — fine here, where you play a Node that never programs a real WireGuard interface; a real agent stores the private half before it submits:

bash
NEW_PUB=$(wg genkey | wg pubkey)
echo "$NEW_PUB"
text
n0dLUZ8rPIgx621ts5jLjtJV08XEO+8rcPkFn+E4ixg=

Submit it. Note what the request does not carry: no Node id, no path parameter — the Authorization header's envelope alone names the Node:

bash
curl --silent --show-error --fail-with-body \
  --request POST \
  --header "Authorization: Bearer ${ENVELOPE}" \
  --header "Content-Type: application/json" \
  --data "{\"new_public_key\":\"${NEW_PUB}\"}" \
  "${PLEXSPHERE_URL}/v1/keys/rotate"
text
{"rotation_id":"019ecc68-5c6d-7e7f-9081-3c4d5e6f7082","kid":"psk-software-v1","wrap_key_version":1}

The 200 carries a receipt — the completed rotation's id plus the (kid, wrap_key_version) reference of the re-issued pairwise key. It never carries key plaintext: the Node already holds its node secret key and resolves the new wrapping locally. In one transaction the control plane overwrote the Node's public key, retired the old pairwise key, issued a fresh one, and flipped the rotation from pending to completed. Submit the same key against an already-completed rotation and you get the same receipt back, idempotently.

Do this in the Console ​

Minting the token and rotating the key have browser equivalents in the Console. To mint a bootstrap token, open the Project detail page (Projects → Open) and use the Issue token action in the Bootstrap tokens panel: pick the kind (node/bridge), the environment prefix (lowercase letters), and a TTL in the 5m–24h window. On success the dialog reveals the psb_… plaintext exactly once — copy it immediately, because no read surface ever returns it again; the list below only ever shows the token's metadata (Issued, Expires, Consumed, Revoked).

To rotate a Node's mesh key (the operator side of Step 6), open the Nodes page under the Mesh group, click a Node's Open link, and use the Rotate mesh key action. It first shows a non-mutating impact preview — the affected-peer count, whether a rotation is already pending, and an estimated duration — and confirming dispatches the rotation. In the dev/Floci build the rotation path is wired in a later slice, so the preview shows a "not provisioned in this build" state and gates the confirm until it lands.

What you learned ​

  • A bootstrap token is a one-shot credential. plexctl bootstrap-token issue mints it, the plaintext is shown once, and POST /v1/register burns it — a second redemption is rejected.
  • Enrolment is an exchange, not a lookup. The Node trades a bootstrap token and a WireGuard public key for a mesh IP, a node secret key, and the Domain's signing key, atomically.
  • Adoption removes the setup friction. requested_resource_id lets a Node create its own Resource as it enrols, so a fresh substrate joins without an operator pre-creating anything.
  • A Node becomes a peer asynchronously. Registration writes the inventory row; the control plane anchors the mesh peer and issues its pairwise key a moment later, which is when it appears in plexctl peer list and plexctl mesh topology.
  • The mesh is a full mesh. N anchored Nodes project N×(N−1) directed edges; each edge is direct until NAT forces a relay fallback.
  • Key rotation is split on purpose. The operator triggers (plexctl key rotate) and the Node completes (POST /v1/keys/rotate), because only the Node may generate its own private key material.

Where to go next ​

  • Keep learning by doing — Run your first agent closes the mesh track: it hands the contract you just drove by hand to the released agent, so one Docker command does everything Step 3 made you do.

Or pick the quadrant that matches what you need now: