Registration
The internal/registration package handles node self-registration and bootstrap authentication with the Plexsphere control plane. It resolves a one-time bootstrap token, generates a Curve25519 keypair, registers with the control plane, persists the resulting identity, and manages auth token lifecycle.
Config
Config holds registration parameters passed to the Registrar constructor. Config loading is the caller's responsibility.
| Field | Type | Default | Description |
|---|---|---|---|
DataDir | string | — | Data directory for identity files (required) |
ProjectID | string | — | Platform project UUID to register into (required for fresh registration) |
ResourceHandle | string | — | Platform Resource handle to bind to (required for fresh registration) |
RequestedResourceID | string | — | Optional resource ID override when substrate naming differs from the handle |
TokenFile | string | /etc/plexd/bootstrap-token (Linux) | Path to bootstrap token file (per platform) |
TokenEnv | string | PLEXD_BOOTSTRAP_TOKEN | Environment variable for bootstrap token |
TokenValue | string | — | Direct token value override |
UseMetadata | bool | false | Enable cloud metadata source for registration inputs |
MetadataTokenPath | string | /plexd/bootstrap-token | Metadata key path for bootstrap token |
MetadataTimeout | time.Duration | 2s | Timeout for metadata service requests |
MaxRetryDuration | time.Duration | 5m | Maximum retry duration for transient errors |
cfg := registration.Config{
DataDir: "/var/lib/plexd",
}
cfg.ApplyDefaults() // sets TokenFile, TokenEnv, the metadata paths, MetadataTimeout, MaxRetryDuration
if err := cfg.Validate(); err != nil {
log.Fatal(err) // DataDir is required
}Validate only enforces DataDir. ProjectID and ResourceHandle are required inputs but are validated at fresh-registration time, not during config parsing: if either is missing, Register fails before any HTTP call with an error naming the config key, CLI flag, and environment variable.
TokenResolver
Resolves the bootstrap token by checking sources in priority order. The first non-empty result wins.
Source Priority
- Direct value —
Config.TokenValue - File —
Config.TokenFile(content trimmed of whitespace) - Environment variable —
os.Getenv(Config.TokenEnv)(trimmed) - Metadata service — via
MetadataProviderinterface (only ifConfig.UseMetadatais true)
Token Validation
- Non-empty
- Maximum 512 bytes
- Printable ASCII only (bytes 0x20–0x7E)
TokenResult
| Field | Type | Description |
|---|---|---|
Value | string | The resolved token value |
FilePath | string | Non-empty if token was read from a file |
FilePath is used by the Registrar to remove the token file after successful registration, or to empty it when it cannot be removed, as under plexd's systemd unit, which keeps /etc read-only and grants write access to the token file alone. The resolver skips an empty file, so an emptied token is never read again.
resolver := registration.NewTokenResolver(&cfg, nil) // nil = no metadata provider
result, err := resolver.Resolve(ctx)
if err != nil {
// error lists all attempted sources
}MetadataProvider
Pluggable interface for cloud-specific value resolution. A single call reads one value (bootstrap token, project ID, resource handle, or requested resource ID) from the metadata service at the given path.
type MetadataProvider interface {
ReadValue(ctx context.Context, path string) (string, error)
}The concrete implementation IMDSProvider reads the value at any metadata path from cloud instance metadata services (IMDSv2 with IMDSv1 fallback). See Cloud-Init Deployment Reference for details.
ResolveValue layers config over metadata for project_id, resource_handle, and requested_resource_id: a non-empty direct config value always wins; otherwise, when UseMetadata is true, the value at the corresponding metadata path is read. A path the metadata service does not serve (ErrMetadataNotFound) means "not provisioned" and yields an empty string, so optional inputs stay optional. Every other read error is returned — a transient IMDS failure must not be reported as a missing config setting.
plexd up and plexd join attach an IMDSProvider automatically when use_metadata is true; no wiring is required to provision registration inputs through instance metadata.
GenerateKeypair
Generates a Curve25519 keypair for WireGuard mesh encryption.
- Private key: 32 random bytes from
crypto/rand, clamped per Curve25519 spec - Public key: derived via
curve25519.X25519(privateKey, Basepoint) - Private key never leaves the node and is never logged
keypair, err := registration.GenerateKeypair()
if err != nil {
log.Fatal(err)
}
pubKeyBase64 := keypair.EncodePublicKey() // standard base64, 44 charactersKeypair
| Field | Type | Description |
|---|---|---|
PrivateKey | []byte | 32-byte clamped Curve25519 key |
PublicKey | []byte | 32-byte derived public key |
NodeIdentity
Holds the registration identity of a node after successful enrollment.
| Field | Type | JSON Tag | Persisted To |
|---|---|---|---|
NodeID | string | "node_id" | identity.json |
MeshIP | string | "mesh_ip" | identity.json |
SigningPublicKey | string | "signing_public_key" | identity.json + signing_public_key |
SigningKeyID | string | "signing_key_id" | identity.json |
DomainMeshCIDR | string | "domain_mesh_cidr" | identity.json |
PrivateKey | []byte | "-" (excluded) | private_key (base64) |
NodeSecretKey | string | "-" (excluded) | node_secret_key |
SigningKeyID is the key id used for rotation-aware verification of control plane signatures (e.g. did:web:plexsphere.com#key-2026-04); DomainMeshCIDR is the domain mesh address range (e.g. 100.64.0.0/10). Both are read from the POST /v1/register response. Legacy identity.json files written before these keys existed still load — the missing fields decode as empty strings.
SigningPublicKey and SigningKeyID hold the signing key that registration handed out until a state pull carries another. The pull (GET /v1/nodes/{node_id}/state) carries the Domain signing key in its signing_key block; when the verifier accepts a pulled key that differs from the persisted pair, plexd up writes it into identity.json and the signing_public_key file with CommitSigningKey, so plexd starts from that key after a restart. A null or absent block keeps the persisted pair. Unless tunnel.session_signing_public_key is set, Linux's socket-activated session helper pins the identity's signing key on its first run, so a helper that has not pinned yet pins the key plexd last persisted. A helper that already pinned keeps its pin (see Trust anchor).
Data Directory Layout
{data_dir}/
├── identity.json (0600) — NodeID, MeshIP, SigningPublicKey, SigningKeyID, DomainMeshCIDR
├── private_key (0600) — base64-encoded Curve25519 private key
├── node_secret_key (0600) — NSK (standard base64); source of the bearer envelope
└── signing_public_key (0600) — control plane signing public key- Directory created with
0700permissions if missing - All files written atomically (temp file + fsync + rename)
PrivateKeyandNodeSecretKeyusejson:"-"tags to prevent accidental JSON serialization
SaveIdentity writes the whole group at registration, and two functions rewrite part of it later. CommitRotatedKey rewrites private_key and identity.json's last_rotation after a mesh key rotation. CommitSigningKey rewrites the signing_public_key file and identity.json's signing_key_id and signing_public_key when a state pull carries a new signing key. Both re-read the group first and write back every field they do not own as loaded.
BearerToken
Assembles the Authorization bearer credential the control plane admits on every post-registration call:
nsk_<env>_<base64url(node_id_bytes(16) || nsk_plaintext_bytes(32))>The payload is exactly 48 bytes — the node's UUID followed by the decoded NSK — encoded with unpadded base64url; plexd stamps plexd as the <env> segment, which the control plane treats as informational. The raw base64 NSK string is never presented as a bearer; the control plane refuses it with 401. Both registrar paths (fresh registration and resuming a persisted identity) arm the shared API client with this envelope, and an identity whose node_id is not a canonical UUID cannot form it — the registrar treats such an identity like corrupt identity files and re-registers.
bearer, err := identity.BearerToken()SaveIdentity / LoadIdentity
// Persist after registration
err := registration.SaveIdentity("/var/lib/plexd", identity)
// Load on restart
identity, err := registration.LoadIdentity("/var/lib/plexd")
if errors.Is(err, registration.ErrNotRegistered) {
// no identity files — need to register
}CommitSigningKey
func CommitSigningKey(dataDir, keyID, publicKey string) errorPersists a Domain signing key learned after registration. plexd up calls it when a state pull carries a signing_key the verifier accepts and that differs from the pair the identity files hold. A failure logs signing key not persisted; retrying on the next pull at Warn, and plexd retries the commit on every pull until it succeeds.
Holding the lock that SaveIdentity and CommitRotatedKey also take, it re-reads the identity group with LoadIdentity, writes the signing_public_key file, then rewrites identity.json with the new signing_key_id and signing_public_key. Every other field, last_rotation included, is written back as loaded. When LoadIdentity fails, for example with ErrNotRegistered on an empty data directory, it returns an error that starts with registration: commit signing key: and wraps that failure, and it writes nothing. A crash between the two writes leaves a mismatched pair, which the next pull repairs.
ErrNotRegistered
Sentinel error returned by LoadIdentity when identity files are absent from the data directory.
var ErrNotRegistered = errors.New("registration: node is not registered")Supports errors.Is matching:
if errors.Is(err, registration.ErrNotRegistered) {
// proceed with fresh registration
}Registrar
Orchestrates the complete registration lifecycle: check existing identity, resolve token, generate keypair, register with retries, persist identity, clean up token file, and set auth token.
Constructor
func NewRegistrar(client *api.ControlPlane, cfg Config, logger *slog.Logger) *Registrar- Applies config defaults
- Logger tagged with
component=registration - Optional: call
SetMetadataProvider,SetClockafter construction (theplexd up/plexd joincommands callSetMetadataProviderfor you whenuse_metadatais enabled)
Register
func (r *Registrar) Register(ctx context.Context) (*NodeIdentity, error)Orchestration flow:
- Load existing identity — if valid, set auth token and return (idempotent)
- Corrupt identity — log warning, proceed with fresh registration
- Resolve bootstrap token — via
TokenResolver.Resolve - Generate Curve25519 keypair
- Resolve registration inputs —
project_id,resource_handle, andrequested_resource_idviaResolveValue(direct config value, else IMDS). Each required input is checked as soon as it resolves, so a missingproject_idfails before the later metadata lookups run - Fail fast — return an error before any HTTP call if
project_idorresource_handleis empty - Clear the auth token —
client.SetAuthToken("").POST /v1/registeris unauthenticated (security: []); the bootstrap token travels in the request body. Clearing also drops a stale NSK on the re-registration path. The previous token is restored unless registration completes, so a failed re-registration does not leave the shared client unauthenticated. - POST /v1/register with retry — one UUIDv4 nonce per logical registration, reused across attempts; exponential backoff on transient errors. The nonce is replay protection, not an idempotency key: a nonce the control plane has already recorded is answered with
403 nonce_collision, so retrying a request that may already have been committed is denied rather than granted a second node. The nonce is never persisted — after a restart it could only produce that denial, so a fresh registration starts with a fresh nonce - Build NodeIdentity from response + private key —
nskmust decode from standard-padded base64 to a 32-byte AES-256-GCM key, or registration fails before anything is persisted - Persist identity atomically to data directory
- Remove token file if token was file-based. When the unlink fails, the file is truncated to zero bytes and
token file emptied, it could not be removedis logged at Info; only when that fails too isfailed to delete token filelogged at Warn, withtruncate_error. A file that is already gone logs nothing, and no outcome is fatal - Set node_secret_key (nsk) as auth —
client.SetAuthToken(nsk)
Retry Logic
The control plane returns errors as RFC 9457 application/problem+json. plexd classifies each failure on its HTTP status and optional machine-readable code member (via api.ClassifyError); unknown codes are tolerated. The bootstrap token is never consumed on an error branch — a stopped or retried attempt leaves the token spendable.
| Status | Problem code(s) | plexd behavior |
|---|---|---|
400 | public_key_invalid (or none) | Stop — invalid request |
401 | — | Stop — bootstrap token rejected |
403 | kind_mismatch, project_mismatch, token_consumed, token_expired, token_revoked, nonce_collision | Stop — terminal denial |
404 | resource_not_found | Stop — resource unresolved |
409 | — | Stop |
422 | — | Stop — invariant violation |
429 | — | Retry, honoring the Retry-After header |
503 | pool_exhausted, subrange_exhausted, allocator_contention | Retry with backoff |
500 | — | Retry with backoff |
| Network / transport error | — | Retry with backoff |
Backoff parameters (consistent with internal/api/ReconnectEngine):
| Parameter | Value |
|---|---|
| Base interval | 1s |
| Multiplier | 2x |
| Max interval | 60s |
| Jitter | ±25% |
| Timeout | Config.MaxRetryDuration (default 5m) |
IsRegistered
func (r *Registrar) IsRegistered() boolReturns true if valid identity files exist in Config.DataDir.
Usage Example
// Create control plane client
cpClient, err := api.NewControlPlane(api.Config{
BaseURL: "https://api.plexsphere.com",
}, "1.0.0", slog.Default())
if err != nil {
log.Fatal(err)
}
// Create registrar
reg := registration.NewRegistrar(cpClient, registration.Config{
DataDir: "/var/lib/plexd",
ProjectID: "0190a8b8-a0c0-7a0a-8a0a-a0a0a0a0a0a0",
ResourceHandle: "edge-router-01",
}, slog.Default())
// Run registration (idempotent — skips if already registered)
identity, err := reg.Register(ctx)
if err != nil {
log.Fatalf("registration failed: %v", err)
}
log.Printf("registered as %s with mesh IP %s", identity.NodeID, identity.MeshIP)
// Control plane client now has node_secret_key set as auth tokenAuth Token Lifecycle
| Phase | Auth Token Value |
|---|---|
| Before registration | Bootstrap token (resolved, not yet sent) |
| During POST /v1/register | none (unauthenticated; bootstrap token in body) |
| After registration | NodeSecretKey |
| On restart (cached) | NodeSecretKey from disk |