Render node fleet management.
Release-line note: v2.0.30 exists on
releases.experiencenet.combut not in git — it was a phantom build accidentally produced from a misplacedv2.0.30tag (intended forhydraheadflatscreen) that was deleted seconds later, after CI had already cut a binary. The v2.0.30 binary is byte-identical to v2.0.26. v2.0.31 is the next clean release-line version on master and supersedes both.
| Resource | Value |
|---|---|
| Server | hydracluster.experiencenet.com (46.224.29.125) |
| Config | /root/.hydracluster/config.yaml |
| Data | /root/.hydracluster/nodes.yaml |
| Service | systemctl status hydracluster |
| Logs | journalctl -u hydracluster -f |
HydraCluster manages render node enrollment, provisioning, and monitoring. Render nodes are Windows machines running LarkXR Standalone (cloud rendering). They are enrolled via a web UI and managed through the node agent (hydranode).
/enroll, admin assigns roles and venues. The form accepts optional ?name= and ?ip= URL params (pre-filled by HydraNeck's Enrol button for admin-initiated remote enrolment — the submitted ip field overrides the request's remote address so the correct device IP is stored).ssh root@hydracluster.experiencenet.com
# or
ssh root@46.224.29.125
curl -s https://hydracluster.experiencenet.com/api/v1/health
The admin UI includes a real-time event stream at /admin/events (requires admin login). All significant cluster events — eligible body discovery, sunshine PIN submissions, stream start/stop, head status changes — appear here as they happen, colour-coded by type.
Use this first when debugging iPad/kiosk pairing and streaming issues. It is faster than SSHing in and tailing journalctl.
eligible_bodies: body discovery ran; shows count and names foundsunshine_pin: PIN was submitted to a body's Sunshine; green = accepted, red = failedstream_start: head started streamingstream_stop (Success: true): stream ended cleanly — either the body self-reported idle (Sunshine closed after Moonlight disconnect) or an operator/head issued a stopstream_stop (Success: false): stream ended uncleanly — body went offline while streaming, or an operator stop failedterminate_pending (Success: true): iPad tapped exit; body will receive TerminateStream on its next heartbeat (≤5 s)terminate_pending (Success: false): session watchdog fired (body heartbeat silent >60 s, or head-only heartbeat silent >120 s); body will receive TerminateStream on next heartbeatstream_stop is emitted for every session close that had an active session record: body self-reports idle, body goes offline mid-stream, admin stop, iPad cancel, and flatscreen stop. It is NOT emitted as a duplicate if the session was already closed by a prior path (e.g. iPad cancel closes the session immediately; when the body then reports idle, no second event fires).
The SSE endpoint (GET /api/v1/events/stream) is also accessible directly with an admin bearer token — useful for scripted monitoring. The server buffers the last 200 events; new connections receive the full buffer before live events begin.
https://hydracluster.experiencenet.com/admin/events first — the stream going dead confirms the service is down.ssh root@46.224.29.125systemctl status hydraclusterjournalctl -u hydracluster --since '10 min ago' --no-pagersystemctl restart hydraclusterIf POST endpoints (e.g. /api/v1/nodes/{id}/exec) start hanging with 0 bytes received while GETs still respond, and the journal shows lots of GETs but no recent POSTs to the slow endpoint, suspect an auth-middleware self-deadlock.
This was the v2.0.32 → v2.0.33 regression: requireAdminOrNodeToken used defer s.mu.Unlock() and then called next(). The downstream heads handlers (handleListHeads et al.) also acquired s.mu → non-reentrant mutex → goroutine deadlocked on its own lock → mutex pinned → every other lock acquirer queued forever. hydranode's network-recovery routine on the same box then misread the wedged API as a network failure and rebooted the cluster.
Rule for any new server middleware:
s.mu.Lock() briefly inside a middleware to look something up.s.mu BEFORE calling next(). Do NOT hold it via defer across a handler invocation. Downstream handlers do their own locking.requireNodeToken survives this rule only because every handler it wraps (handleBodyHeartbeat, handleBodyShellCheck, etc.) reads node state via the nodeContext value rather than re-locking s.mu. Don't break that invariant.If you suspect this is happening live, the fingerprint in journalctl -u hydracluster is many GET /api/v1/body/shell/check lines but no recent matching POST log line for the slow request — the POST is queued waiting for the lock and never reaches its log statement.
HydraCluster supports two exec modes for running commands on body machines.
Commands are sent as JSON via the API, executed directly by the node agent (PowerShell on Windows, bash on Linux), with clean stdout/stderr separation and proper exit codes. No PTY involved.
# Basic command
hydracluster exec <nodeId> "Get-Process | Select-Object -First 5"
# With timeout
hydracluster exec <nodeId> "choco list --local-only" --timeout 60s
# From a local file (avoids all shell escaping)
hydracluster exec <nodeId> --file ./setup.ps1
# JSON output (for scripting)
hydracluster exec <nodeId> "hostname" --json
The command is queued on the server and picked up by the node agent on its next heartbeat (up to 30 seconds). The CLI polls for the result until it arrives or the timeout expires.
hydracluster exec --shell <nodeId> "top -bn1 | head -5" --timeout 10s
These are two distinct transports. Understanding the difference matters when the node is slow to respond or the queue is backed up.
| Async exec queue (default) | WebSocket shell (--shell) |
|
|---|---|---|
| Transport | HTTP poll: hydranode fetches commands on its tick (up to 30 s delay) | WebSocket PTY: direct connection, responds immediately |
| Queue | Commands pile up in-memory on hydracluster; processed FIFO | No queue — each --shell call opens a fresh PTY session |
| Use when | Scripting, JSON output, fire-and-forget, parallel commands | Queue is backed up; need immediate response; binary data (base64) |
| Failure mode | Times out if queue is long or hydranode stopped polling | Fails fast if WebSocket connection can't be established |
When the async queue backs up: this happens when many exec requests accumulate (e.g. after a debugging session with many rapid execs, or after a node reboot where hydranode was offline and the queue grew). The node heartbeat still arrives (node shows online) but exec results stay pending indefinitely. Switch to --shell until the queue drains.
Detecting a backed-up queue: use the queue status endpoint to see exactly what is waiting:
curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
https://hydracluster.experiencenet.com/api/v1/nodes/<ID>/exec/queue | jq .
# {"queued": 4, "in_flight": 1, "items": [...]}
Clearing a backed-up queue: drop all pending (not yet dequeued) execs:
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
https://hydracluster.experiencenet.com/api/v1/nodes/<ID>/exec/queue | jq .
# {"cleared": 4}
To cancel a single exec by ID:
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://hydracluster.experiencenet.com/api/v1/nodes/<ID>/exec/queue/<execId>" | jq .
# {"cancelled": "exec-1716123456789"}
In-flight execs (already dequeued to the agent) cannot be cancelled — only queued ones. A hydracluster restart is the nuclear option to drop in-flight execs as well.
The queue is in-memory on hydracluster — it does not survive a hydracluster restart.
Exec is for ad-hoc, one-off operations. When a workflow is used regularly by operators (stream control, status checks, etc.), the receiver binary must expose a proper named Cobra subcommand for it. Exec then invokes that named command — no curl+JSON quoting, no piped commands, no inline shell scripting.
Bad (fragile):
hydracluster exec node-X "curl -s -X POST -H 'Content-Type: application/json' -d '{\"experience\":\"mercator-talks\"}' http://127.0.0.1:9740/api/v1/stream/start"
Good (named command on the receiver):
hydracluster exec node-X "hydraheadflatscreen stream-start mercator-talks"
A named command on the binary is testable, readable, and unambiguous. If an exec command starts looking like a shell script (quoting, pipes, flags), that is a signal to add a Cobra subcommand to the receiver instead.
The exec shell does not include ~/.hydranode/bin in PATH. Always use the full binary
path when calling receiver-side commands:
# macOS kiosk heads (hydraheadflatscreen)
hydracluster exec node-X "~/.hydranode/bin/hydraheadflatscreen stream-start mercator-talks"
hydracluster exec node-X "~/.hydranode/bin/hydraheadflatscreen stream-stop"
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/v1/nodes/{id}/exec |
Admin | Submit command, returns {"id": "exec-..."} |
GET /api/v1/nodes/{id}/exec/{execId}/result |
Admin | Poll for result (200=done, 202=pending) |
GET /api/v1/nodes/{id}/exec/queue |
Admin | Inspect queue: {"queued": N, "in_flight": N, "items": [...]} |
DELETE /api/v1/nodes/{id}/exec/queue |
Admin | Clear all queued (not yet dequeued) execs, returns {"cleared": N} |
DELETE /api/v1/nodes/{id}/exec/queue/{execId} |
Admin | Cancel one queued exec by ID; 404 if in-flight or already complete |
GET /api/v1/body/exec |
Node token | Agent fetches next queued command |
POST /api/v1/body/exec/result |
Node token | Agent reports result |
POST /api/v1/body/scales |
Node token | hydraskin node reports its Incus inventory (403 unless the node has the hydraskin role) |
GET /api/v1/nodes |
Admin or read-only | Node list, including scale inventory |
server.read_only_token in the config grants GET /api/v1/nodes and nothing
else. It exists so hydrascalerouter — which runs on a public-facing district
server — can read which scales claim which domains without holding a credential
that could mutate the fleet.
Scales may report a domain and endpoint, carried through verbatim on
GET /api/v1/nodes. hydracluster applies no routing policy: it does not
validate the domain or resolve conflicts between scales claiming the same one.
That belongs to hydrascalerouter, which is the only place that sees every node
at once. Both fields are capped at 253 bytes because they are node-supplied and
render into the dashboard.
Assign roles to a node via API (roles must be in the RoleCatalog):
curl -s -X POST https://hydracluster.experiencenet.com/api/v1/nodes/<ID>/roles \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"roles": ["hydraguard-air", "hydraheadflatscreen"]}'
The node agent picks up new roles on its next provision poll and executes the matching recipe.
Use this to retire a node hydracluster should forget — decommissioned hardware, a device you no longer possess, or a duplicate/mis-enrolled entry.
curl -s -X DELETE https://hydracluster.experiencenet.com/api/v1/nodes/<ID> \
-H "Authorization: Bearer <ADMIN_TOKEN>"
# {"status":"ok"} — or 404 {"error":"node \"<ID>\" not found"}
Three equivalent paths, all of which do the same thing under the hood
(Store.Remove(id) + Store.Save()):
DELETE /api/v1/nodes/{id} (admin token). Use this for remote/scripted removal.hydracluster nodes remove <id>. Edits the local nodes.yaml, so it must run on the server (the store on an operator laptop is empty)./admin/nodes/{id} (session-cookie auth).What it does and does not do. Removal only deletes the node's entry from hydracluster's YAML store. It does not tear down WireGuard peers, close active sessions, uninstall the agent, or send anything to the device. Practical implications:
401 invalid token on every heartbeat (POST /api/v1/body/heartbeat
for bodies; the GET /api/v1/heads/{id} config poll for iPad/flatscreen heads) —
the store lookup by token no longer resolves. Nothing recreates the node; the
body agent's GET /api/v1/enroll/{token}/status fallback also 404s, so it just
logs failures. A deleted device cannot silently rejoin the fleet.hydranode install /
reinstall); iPad heads re-scan the fleet enrollment QR (POST /api/v1/heads).
The old ID does not return.GET /api/v1/nodes — deletion is by ID, and names
are not unique guarantees.Head devices (hydraheadflatscreen, hydraheadwindows) stream from body nodes via Moonlight/Sunshine. In production, heads pick their body through the eligibility discovery procedure (GET /api/v1/bodies/eligible). See body-selection.md for the selection discipline and head-identity design principles.
The endpoint below is an admin override for exceptional situations. For normal operations, do not manually pin a body to a head. Instead, change the inputs to selection (body district/venue/owner, eligibility rules, drain flag). See body-selection.md.
curl -s -X POST https://hydracluster.experiencenet.com/api/v1/nodes/<HEAD_ID>/head-assignment \
-H "Authorization: Bearer <ADMIN_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"body_id": "<BODY_NODE_ID>", "app_id": "Desktop"}'
curl -s https://hydracluster.experiencenet.com/api/v1/heads/<HEAD_ID> \
-H "Authorization: Bearer <ADMIN_TOKEN>"
Returns stream config with both WireGuard (stream_url) and LAN (stream_url_lan) addresses resolved from the body node. The hydraheadflatscreen agent probes LAN first (TCP 47990, 1s timeout) and falls back to WireGuard. Also returns sunshine_username and sunshine_password from the head's district provider config.
Moonlight pairing PIN submission: Sunshine's web UI (port 47990) is localhost-only, so remote heads cannot submit the PIN directly. Use the sunshine-pin proxy endpoint instead — it execs the submission on the body machine where localhost:47990 is always reachable:
curl -s -X POST https://hydracluster.experiencenet.com/api/v1/nodes/<BODY_NODE_ID>/sunshine-pin \
-H "Authorization: Bearer <ADMIN_OR_NODE_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"pin":"<PIN>"}'
HydraHeadiPad calls this endpoint automatically during self-service pairing.
curl -s https://hydracluster.experiencenet.com/api/v1/heads \
-H "Authorization: Bearer <ADMIN_TOKEN>"
Returns an array of head objects. Each entry includes node_status ("online"/"offline" — node heartbeat state), last_seen (RFC3339 timestamp of the last hydranode heartbeat), assigned_body_name (the name of the body currently in use — see priority below), and diagnostics (a map of agent-reported key/value pairs, omitted when empty) in addition to the head-level status ("idle"/"streaming"/"error" — reported by the kiosk agent). Use node_status to distinguish a kiosk that is genuinely offline from one that is online but idle. Use assigned_body_name to correlate heads to bodies without resolving IP addresses. Diagnostic keys set by hydraheadflatscreen include version, wireguard ("up"/"down"), app ("kiosk"/"moonlight"/"none"), routing ("lan"/"wireguard"/"unknown"), and latency_ms (TCP RTT in milliseconds to Sunshine port 47990, present only when streaming).
assigned_body_name resolution priority: (1) live_body_id — the body node ID reported in the most recent hydraheadflatscreen heartbeat (PUT /api/v1/heads/{id} body_id field); clears automatically when no stream is active. (2) head_body_id — the admin-configured body assignment (set via POST /api/v1/nodes/{id}/head-assignment). Self-service kiosk heads populate the live field; centrally-assigned heads populate the admin field. The stream config (stream_url, stream_url_lan) is always derived from the admin assignment only.
iPad enrollment is a two-step self-registration flow. No head entry needs to exist before scanning.
Step 1 — display the QR code (admin web UI, recommended):
https://hydracluster.experiencenet.com/enrollStep 1 (alternative) — get the raw payload via API:
curl -s https://hydracluster.experiencenet.com/api/v1/enroll-qr \
-H "Authorization: Bearer <ADMIN_TOKEN>"
Returns {"server_url":"...","enrollment_token":"..."}. Encode this JSON as a QR code and print/display it — one QR serves all iPads in the fleet.
Step 2 — iPad scans the QR, app self-registers:
The app POSTs to POST /api/v1/heads using the enrollment token:
curl -s -X POST https://hydracluster.experiencenet.com/api/v1/heads \
-H "Authorization: Bearer <FLEET_ENROLLMENT_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"name":"ipad-lobby","district":"bxl1","venue":"cloud-seven"}'
Returns {"head_id":"node-...","token":"...","server_url":"..."}. The iPad saves these as its permanent identity. The head is auto-approved with role hydraheadipad and OS ios, and immediately starts heartbeating. It appears in GET /api/v1/heads with type: "hydraheadipad".
If HydraGuard is configured (hydraguard_url + hydraguard_token in config.yaml), enrollment also calls POST /api/v1/headipad/provision on HydraGuard and stores the resulting WireGuard config and peer IP on the node. Failure is non-fatal — the head is still enrolled.
Config: fleet_enrollment_token must be set in config.yaml under server:. The token is the same for all iPads — treat it as a moderately sensitive credential (anyone with it can enroll new heads).
After enrollment, the iPad can retrieve its WireGuard configuration:
curl -s https://hydracluster.experiencenet.com/api/v1/heads/<HEAD_ID>/wireguard-config \
-H "Authorization: Bearer <ADMIN_TOKEN>"
Returns the WireGuard config as text/plain (the full [Interface] + [Peer] block). Returns 404 if the head was enrolled without HydraGuard provisioning or if the node does not exist.
curl -s https://hydracluster.experiencenet.com/api/v1/heads/<HEAD_ID>/experiences \
-H "Authorization: Bearer <ADMIN_TOKEN>"
Returns the catalog of experiences a kiosk should display, sourced from hydraexperiencelibrary's /api/v1/experiences/live endpoint keyed by the head's district + venue. If the head has a non-empty AllowedExperiences whitelist, the result is intersected with it.
This endpoint is the source of truth for kiosk grids and is intentionally independent of body availability — a venue with no body online still returns its catalog (or [] if nothing is planted there yet). Body discovery only runs at stream-start time (POST /api/v1/stream/start on the agent's local API).
Status codes:
200 — catalog (possibly [])400 — node exists but is not a hydraheadflatscreen head404 — no node with that id502 — hydraexperiencelibrary upstream returned an error503 — experienceLibrary.url is not configured on hydraclusterReturns image/jpeg directly. The mechanism differs by head type:
TOKEN="<admin-token>"
curl -sf -H "Authorization: Bearer $TOKEN" \
"https://hydracluster.experiencenet.com/api/v1/heads/<HEAD_ID>/screenshot" \
> /tmp/kiosk.jpg
Flatscreen heads (hydraheadflatscreen): fetches via exec channel → curl http://127.0.0.1:9740/api/v1/screenshot | base64 on the head. Requires the Terminal screenshot loop to be running (see hydraheadflatscreen runbook → Screenshots). Times out after 20 s.
Status codes: 200 JPEG on success; 502 if screenshot file is stale or absent (Terminal loop not running); 504 if the exec channel doesn't respond within 20 s.
If 504 (exec queue backed up): use the WebSocket shell path instead — it bypasses the async exec queue:
"$HC_BIN" exec <node-id> "base64 -i /tmp/hydra-live-screenshot.jpg" \
--shell --server "$HC_SERVER" --admin-token "$HC_TOKEN" --timeout 30s \
> /tmp/raw.txt
grep -E '^[A-Za-z0-9+/]+=*$' /tmp/raw.txt | tr -d '\n' | base64 -d > /tmp/kiosk.jpg
Note: macOS base64 requires -i <file> not a positional argument. The grep strips shell prompts before decoding.
iPad heads (hydraheadipad): no exec channel — uses a push flow instead. The server sets a pending flag and waits up to 15 s for the iPad to poll GET /api/v1/heads/{id}/commands (every 3 s), capture via RPScreenRecorder, and upload to POST /api/v1/heads/{id}/screenshot. Full Metal framebuffer is captured (Moonlight video included). Times out after 15 s.
Status codes: 200 JPEG on success; 504 if the iPad does not respond within 15 s (app backgrounded, device offline, or still booting).
The admin node detail page also exposes a Take Screenshot button for iPad heads — click it and the JPEG appears inline within ~5 s.
Returns the last 200 lines of the head agent log as JSON ({"log_path":"...","lines":[...]}):
curl -sf -H "Authorization: Bearer $TOKEN" \
"https://hydracluster.experiencenet.com/api/v1/heads/<HEAD_ID>/logs" | jq '.lines[-20:]'
Status codes: 200 JSON on success; 504 if the head exec channel doesn't respond within 15 s.
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/v1/nodes/{id}/roles |
Admin | Set roles on a node |
DELETE /api/v1/nodes/{id} |
Admin | Remove a node from the store. Returns {"status":"ok"}, or 404 if the ID is unknown. Store-only: no WireGuard peer teardown, no session cleanup, no signal to the device. See "Removing a node" below. |
POST /api/v1/nodes/{id}/update-node |
Admin | Flag node for immediate hydranode self-update |
POST /api/v1/nodes/{id}/update-services |
Admin | Flag node to update all provisioned service binaries |
POST /api/v1/nodes/{id}/head-assignment |
Admin | Assign body to head |
POST /api/v1/nodes/{id}/allowed-experiences |
Admin | Set experience filter for kiosk head |
DELETE /api/v1/nodes/{id}/stream |
Admin | Close the active Sunshine session on a body and clear stream_status (orphaned stream recovery). Sends exec-based ForceStop (synchronous, 15 s timeout) AND sets TerminatePending so the body receives TerminateStream=true on its next heartbeat (≤5 s) as a fallback if exec fails. Session closed as admin_stop. See body-recovery.md. |
GET /api/v1/heads |
Admin | List all head nodes with stream config |
GET /api/v1/heads/{id} |
Admin | Get head config (includes experience_library_url, allowed_experiences) |
GET /api/v1/heads/{id}/experiences |
Admin | Get the kiosk experience catalog for a head (proxied from hydraexperiencelibrary) |
GET /api/v1/heads/{id}/screenshot |
Admin | Fetch kiosk screen as JPEG — flatscreen: exec channel (20 s timeout); iPad: push flow via commands poll (15 s timeout) |
POST /api/v1/heads/{id}/screenshot |
Admin or node token | iPad uploads captured JPEG after seeing screenshot: true in commands response |
GET /api/v1/heads/{id}/commands |
Admin or node token | Lightweight fast-poll for pending iPad commands (iPad polls every 3 s); returns {"screenshot": true/false} |
GET /api/v1/heads/{id}/logs |
Admin | Fetch last 200 lines of head agent log as JSON via exec channel |
GET /api/v1/heads/{id}/wireguard-config |
Admin | Fetch stored WireGuard config (text/plain) provisioned at iPad enrollment |
POST /api/v1/heads/{id}/stream |
Admin | Start stream on flatscreen head (exec hydraheadflatscreen stream-start <experience>) |
DELETE /api/v1/heads/{id}/stream |
Admin or node token | Stop stream. Flatscreen: exec hydraheadflatscreen stream-stop on the head (blocks up to 15 s). iPad: immediately marks body idle in cluster memory, sets TerminatePending so body receives TerminateStream=true on its next heartbeat (≤5 s), closes session as head_stop, and fire-and-forgets a body exec as belt-and-suspenders; returns immediately. Request body {"body_id":"<id>"} is optional for iPads (falls back to last heartbeat live_body_id). |
PUT /api/v1/heads/{id} |
Admin | Update head status |
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/v1/sessions |
Admin | List all currently active streaming sessions |
GET /api/v1/sessions/history |
Admin | List closed sessions (last 500), most recent first |
GET /api/v1/sessions/{id}/logs |
Admin | Fetch correlated body + head logs for a session; see below |
GET /api/v1/nodes/{id}/body-logs |
Admin | Fetch last 500 lines of hydrabody.log from a body node; accepts ?since=<RFC3339>&until=<RFC3339> for time-window filtering |
Returns last 500 lines of C:\Windows\System32\config\systemprofile\.hydrabody\hydrabody.log
from the body node via the exec channel:
curl -sf -H "Authorization: Bearer $TOKEN" \
"https://hydracluster.experiencenet.com/api/v1/nodes/<BODY_ID>/body-logs" | jq '.lines[-20:]'
Optional time filter (RFC3339):
curl -sf -H "Authorization: Bearer $TOKEN" \
"https://hydracluster.experiencenet.com/api/v1/nodes/<BODY_ID>/body-logs?since=2026-05-22T10:00:00Z&until=2026-05-22T10:30:00Z" | jq '.lines[]'
Status codes: 200 JSON on success; 400 if node is not a body node; 504 if the exec channel doesn't respond within 30 s.
Fetches both the body log and the head log for the duration of a specific session,
returned in a single response. Session ID comes from GET /api/v1/sessions or GET /api/v1/sessions/history.
curl -sf -H "Authorization: Bearer $TOKEN" \
"https://hydracluster.experiencenet.com/api/v1/sessions/<SESSION_ID>/logs" | jq '{body_logs: .body_logs[-10:], head_logs: .head_logs[-10:]}'
Response shape:
{
"session_id": "abc12345-7",
"body_id": "node-abc12345",
"body_name": "cosmic-pretzel-98",
"head_id": "node-xyz99",
"started_at": "2026-05-22T10:15:00Z",
"ended_at": "2026-05-22T10:45:00Z",
"body_logs": ["2026/05/22 12:15:00 [stream] ...", "..."],
"head_logs": ["...", "..."]
}
head_logs is empty ([]) if the session has no associated head ID (session opened before
head heartbeat was received). ended_at is null for active sessions.
Status codes: 200 on success (even if exec failed — errors are logged server-side and the
affected log array is empty); 404 if session not found.
Render nodes are Windows machines that run LarkXR Standalone (cloud rendering). They are enrolled in HydraCluster, which provisions them via the node agent.
hydracluster.experiencenet.com and releases.experiencenet.comhttps://hydracluster.experiencenet.com/enroll on the machinerender-node role and set district/venueThe node agent will:
larkxr-standalone-windows.zip from the release serverC:\LarkXR\larkxr-standalone\install/ folder)larkxr_center MySQL databaseAfter provisioning, verify these ports are listening:
Check via remote shell:
Get-Process *lark*,*java*,*mysql*,*redis*,*nginx* | Format-Table Name,Id
netstat -an | Select-String '8181|8282|13306'
The dashboard should show provider_status: running.
When LarkXR needs to be fully reinstalled:
Stop all LarkXR processes:
Stop-Process -Name LarkXRLauncher,LarkXRServer,CloudLarkRenderServer,java,mysqld,nginx,redis-server -Force -ErrorAction SilentlyContinue
Remove old installation and cached state:
Remove-Item C:\LarkXR -Recurse -Force
Remove-Item C:\Windows\System32\config\systemprofile\.hydranode\provider_version.txt -Force
Remove-Item C:\Windows\System32\config\systemprofile\.hydranode\config_cache.yaml -Force
Remove-Item C:\Windows\System32\config\systemprofile\.hydranode\downloads -Recurse -Force
The node agent will detect the missing installation on its next heartbeat (30s) and reprovision automatically.
Alternatively, trigger reprovision from the dashboard (node detail page > Reprovision button). As of v0.23.10+, the Reprovision button triggers a forced reinstall -- the node agent stops the provider and re-downloads regardless of existing files.
If the node is offline (node agent not heartbeating):
Windows:
# Check if the scheduled task exists
schtasks /query /tn HydraNode
# Start it
schtasks /run /tn HydraNode
# If missing, reinstall
C:\hydranode\hydranode.exe install
schtasks /run /tn HydraNode
Recovery instructions are also available at https://hydracluster.experiencenet.com/enroll (no admin login needed).
The node agent auto-updates from the release server. As of v0.23.9:
ensureInstall)To force an update on a remote machine via API:
curl -s -X POST https://hydracluster.experiencenet.com/api/v1/nodes/<ID>/update-node \
-H "Authorization: Bearer <ADMIN_TOKEN>"
The node picks up the flag on its next heartbeat and triggers the self-updater immediately. To update service binaries (not hydranode itself):
curl -s -X POST https://hydracluster.experiencenet.com/api/v1/nodes/<ID>/update-services \
-H "Authorization: Bearer <ADMIN_TOKEN>"
Manual update on Windows (last resort):
# Download new binary
Invoke-WebRequest -Uri 'https://releases.experiencenet.com/hydranode/production/latest/hydranode-windows-amd64.exe' -OutFile C:\hydranode\hydranode-new.exe
# Stop, replace, reinstall, start
schtasks /End /TN HydraNode
Start-Sleep 3
Copy-Item C:\hydranode\hydranode-new.exe C:\hydranode\hydranode.exe -Force
Remove-Item C:\hydranode\hydranode-new.exe
C:\hydranode\hydranode.exe install
schtasks /Run /TN HydraNode
Warning: Stopping the node agent on a remote-only machine (private LAN) means you lose remote access until it restarts. The task's repetition interval (1 minute) should auto-restart it, but if the binary is locked, the replacement will fail silently.
| Path | Purpose |
|---|---|
C:\hydranode\hydranode.exe |
Node agent binary |
C:\hydranode\enroll.yaml |
Enrollment token |
C:\Windows\System32\config\systemprofile\.hydranode\ |
SYSTEM profile data dir |
...\.hydranode\config.yaml |
Node config (server URL, token) |
...\.hydranode\config_cache.yaml |
Cached config from server |
...\.hydranode\provider_version.txt |
Installed provider version |
...\.hydranode\hydranode.log |
Node agent log |
C:\LarkXR\larkxr-standalone\ |
LarkXR installation |
C:\LarkXR\larkxr-standalone\log\ |
LarkXR Launcher logs |
CreateProcessAsUser to launch it in the logged-in user's session. If no user is logged in, the launcher won't start.C:\LarkXR and provider_version.txt first on those.Nodes with the hydraskin role run Incus as
container hosts. A container on such a node is called a scale.
The operational runbook lives in the hydraskin repo:
hydraskin/docs/runbooks/hydraskin.md —
instance kinds (OCI / system container / VM), scale defaults and overrides, disk
caps and the btrfs quota requirement, hydraskin project and hydraskin expose
(publishing a scale on the node's LAN address so the mesh can reach it),
GOMEMLIMIT guidance, debugging the reporter, port 8443, and reprovisioning.
Moving a service onto a scale is covered in
hydraskin/docs/runbooks/service-cutover.md.
What lives in this repo:
recipes/hydraskin-linux.yaml — the provisioning recipe (5 steps). Node
construction lives in the hydraskin binary: the recipe installs it from
releases.experiencenet.com and runs hydraskin install, which is idempotent
and leaves running scales alone. Incus is installed by that binary, not
embedded in it — incusd is a ~59MB CGO daemon linked against liblxc and
libcowsql.pkg/api/ — POST /api/v1/body/scales, the node-token endpoint scales are
reported to, surfaced on GET /api/v1/nodes as scales / scales_reported_at
/ skin_host (see the endpoint table above)pkg/api/handlers_api.go (RoleCatalog, category container)Note the recipe is loaded at startup from /root/.hydracluster/recipes/ and is
deployed separately from the binary — editing it in git is not enough, it must be
copied to the server and hydracluster restarted.
Hetzner automated daily server snapshots are enabled on hydracluster (46.224.29.125), context hydraexperiencenet. Backup window: 14:00–18:00 UTC, 7-day retention.
The snapshot covers the entire server disk, including:
~/.hydracluster/nodes.yaml — full render node fleet config~/.hydracluster/config.yaml — server config and tokenshydraexperiencenet project), open Servers → hydracluster → Backups.curl https://hydracluster.experiencenet.com/api/v1/health
curl -H "Authorization: Bearer $TOKEN" https://hydracluster.experiencenet.com/api/v1/nodes | jq length