Appearance
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/registerseam 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 peerandplexctl 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 is standalone: it does not build on the explore, build, or Group lessons. 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:
| Tool | Purpose |
|---|---|
jq | read fields out of the JSON responses |
wg (WireGuard tools) | generate the Node's X25519 keypair |
python3 | assemble the node-secret-key envelope for the rotation step |
curl | drive 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 the way the setup lesson did:
bash
export PATH="$PWD/bin:$PATH"
export PLEXSPHERE_URL=http://localhost:8080
DOMAIN_ID=$(kubectl exec statefulset/postgres -- \
env PGPASSWORD=plexsphere psql -U plexsphere -d plexsphere -tAc \
"SELECT id FROM plexsphere.domains WHERE slug='acme-corp'")
echo "$DOMAIN_ID"text
019ed19b-68dc-7aa9-8995-80e810444cceStep 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-4c598fb22c3cThe 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
adminaccess 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 returnsPermission 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 1htext
# WARNING: this is the only time this plaintext will be displayed
psb_dev_…_node_…
token_id: <token-uuid>
issued_at: <timestamp>
expires_at: <timestamp>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. The Node generates a WireGuard keypair — the private half never leaves it — and posts the public half plus the bootstrap token to POST /v1/register. The seam is unauthenticated: the bootstrap token is the credential.
bash
wg genkey | tee node-a.key | wg pubkey > node-a.pub
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_id": "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
jq '{node_id, mesh_ip, signing_key_id, domain_mesh_cidr}' register-a.jsontext
{
"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-emptyrequested_resource_idtells 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 with403 token_consumed.
Step 4 — Enrol a second Node
A mesh of one is just a Node. Enrol a second so a topology forms. The steps are identical — a fresh token, a fresh keypair, a fresh resource_id:
bash
TOKEN_B=$(plexctl bootstrap-token issue \
--project "$PROJECT_ID" --kind node --env-prefix dev --ttl 1h \
--output json | jq -r '.token')
wg genkey | tee node-b.key | wg pubkey > node-b.pub
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_id": "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
jq -r '.mesh_ip' register-b.jsontext
10.50.0.2The 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 healthy
019ed1c4-7314-7488-85b4-3aeb1220f38a 10.50.0.2 healthyBoth Nodes are anchored and 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 healthy healthy
019ed1c4-7314-7488-85b4-3aeb1220f38a 019ed1c4-26e4-7465-be1b-f54078f63315 10.50.0.1 healthy healthyTwo 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-runtext
NODE_ID PEER_ID AFFECTED_PEERS ALREADY_PENDING ETA_SECONDS
019ed1c4-26e4-7465-be1b-f54078f63315 <peer-uuid> 1 false 0One 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 <peer-uuid> <rotation-uuid> falseThe 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:
bash
ENVELOPE=$(python3 - "$NODE_A" "$(jq -r '.nsk' register-a.json)" <<'PY'
import base64, sys, uuid
node_id, nsk_b64 = sys.argv[1], sys.argv[2]
payload = uuid.UUID(node_id).bytes + base64.b64decode(nsk_b64)
print("nsk_dev_" + base64.urlsafe_b64encode(payload).rstrip(b"=").decode())
PY
)Generate the Node's new public key and submit it:
bash
NEW_PUB=$(wg genkey | wg pubkey)
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":"<rotation-uuid>","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.
What you learned
- A bootstrap token is a one-shot credential.
plexctl bootstrap-token issuemints it, the plaintext is shown once, andPOST /v1/registerburns 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_idlets 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 listandplexctl 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
- You have a job to do — the enrolment how-to guides cover the operator runbooks for issuing and redeeming tokens.
- You want to understand why the mesh is shaped this way — the Node Registration context and the mesh key-rotation context explain the aggregates, the atomic registration transaction, and the rotation state machine.
- You want the exact contract — the
plexctl bootstrap-token,plexctl peer, andplexctl meshreferences document every flag and output shape. - Back to the guided path — the tutorials overview lists the core learning path from an empty machine to a working mental model.