Skip to content

The channel ​

The channel is the one WebSocket a hub keeps with each machine it manages, at protocol 3 from Neutrino 0.5.2. Two roles use it: agent, the service on a managed machine, and client, a person's desktop client or phone app. This page lists the vocabulary a program written outside this repository speaks. It covers the link, the pin, the two HTTP endpoints, the frames, the streams, the documents each role exchanges, and the codes.

The port and the pin ​

The channel is on the agent port, 8443 by default, which serves /api/channel over TLS and nothing else. The panel port is separate, and turning the panel's HTTPS on or off leaves the channel as it is.

The certificate on the agent port is self-signed and valid for ten years, so its fingerprint is the hub's whole identity.

RuleValue
What is pinnedthe SHA-256 digest of the certificate's DER encoding, 64 lowercase hex characters
When it is checkedafter every TLS handshake, before any request bytes leave the machine
Chain and hostname verificationoff
TLS floor1.2
A mismatch on a link's addressthe socket closes and the enrolment stops, with no move to the next address
An https address with no fingerprintno connection

A person creates a client link on the hub's Clients page and an agent link on Devices. Install a client has the steps for a client link, and Install an agent has them for an agent link.

The link is neutrino://enroll/<payload>. The payload is one JSON object written with no spaces, compressed with zlib, RFC 1950 at level 9, and the compressed bytes in base64url without padding. A QR code holds the same link, and a reader inflates the payload before it parses it:

json
{
  "urls": ["https://192.168.100.1:8443", "https://100.92.14.7:8443"],
  "token": "sB1nYt9Qk2_pL0wV7xR4cZ8f",
  "fp": "<sha256-hex>",
  "role": "client",
  "overlays": [
    {
      "provider": "netbird",
      "setup_key": "...",
      "management_url": "",
      "fqdn": "hub.netbird.cloud",
      "hub_address": "100.88.0.1"
    }
  ]
}
FieldHolds
urlsevery address the hub exposes on the agent port; a program tries them all, since one of them is on the joining machine's network
tokenthe enrolment ticket, valid for thirty minutes and spent one time
fpthe fingerprint to pin
roleclient or agent
overlaysa client link only: the same list as the client state's overlays, taken for the default permission when the link is created; empty when that permission leaves out overlay or no overlay has material

A client rejects an agent link with link_not_for_client, and an agent rejects a client link with link_not_for_agent; each code's params names the link's role. The base64url alphabet holds no character a shell splits or a URL escapes, so the link pastes unquoted.

Joining and leaving ​

EndpointBodyReturns
POST /api/channel/join{ticket, role, protocol, machine_id, name, software, platform}{id, token}: the binding id and a secret every later hello includes
POST /api/channel/leave{id, token}{}; a device's row stays on Devices and loses its token, a client's row is deleted with its AI gateway key

machine_id and platform describe the machine itself, and each role reads them from a different place:

FieldAn agent sendsA client sends
machine_id/etc/machine-id, else /var/lib/dbus/machine-id, else emptya uuid4 hex string generated one time per installation and kept
platform{os, family, arch, version}: linux, windows or darwin; debian, rhel or empty; amd64, arm64 or armhf; and the glibc version on Linux (2.36), the build number on Windows (26100), the product version on macOS (15.3.1), or empty{os, family, arch}, with family empty off Linux

The hub compares an agent's version with a module's floor. A module whose floor the machine is below reads as one the system cannot run. An agent that sends no version is not ruled out.

A client that joins again from an installation the hub has a row for returns to that row. The row keeps its key, its switch and its last report.

StatusdetailWhen
409protocol_too_old, params {peer, hub, min}the number is below the hub's PROTOCOL_MIN
409protocol_too_new, params {peer, hub, min}the number is above the hub's PROTOCOL
409role_mismatch, params {role}the ticket was made for the other role
401ticket_spentthe ticket is unknown, expired or already spent
401binding_unknownleave named an id and token that match no binding

The protocol check runs before the ticket is read, so a rejected number spends no ticket. An HTTP error's body is {"detail": {"code": "...", "params": {}}}.

The socket ​

The socket is wss://<hub-address>:8443/api/channel/socket, where <hub-address> is an address the program reached the hub at, on a fresh pinned connection. A text frame is one JSON object whose type is one of the frame names. A binary frame is a big-endian u32 stream id followed by that stream's bytes.

The handshake ​

The first frame each way is an identity card of one shape. hello goes up within ten seconds of the socket opening, and welcome or refused comes down.

Fieldhello, upwelcome, down
protocolthe number this build speaks, 3the hub's number, 3
roleagent or clienthub
idthe binding idthe hub's own id, a uuid
namethe hostname, or the binding's namethe hub's name from Settings
softwareneutrino_agent/0.5.2 or neutrino_client/0.5.2neutrino_hub/0.5.2
tokenthe binding tokenabsent

A client joined to several hubs groups them by the hub's id and labels them by its name. A rejected hello gets refused {code, params} and close 4000. hello_invalid marks a first frame that is late, binary, of another type, or unreadable, and channel_full {limit} a port that already holds its most admitted sockets.

The frames ​

FrameDirectionBody
helloupthe identity card, with token
welcomedownthe hub's identity card
refuseddown{code, params}, then close 4000
statedown{hash, ...sections}: what is to be true
reportup{state_hash, ...sections}: what is true
openboth{stream, kind, ...args}: a stream begins
closeboth{stream, code, params}: it ends, with its result
creditboth{stream, bytes}: the sender can send that many more
pingup{nonce}: a round-trip probe; nonce is the sender's own string of at most 64 characters
pongdown{nonce}: the hub's reply, sent at once on the same socket, with the ping's nonce cut to 64 characters
binaryboth<u32 stream id><bytes>; on a UDP connect stream <u32 stream id><u16 source><one datagram>

The hub sends a WebSocket ping every 20 seconds and closes a socket whose pong has not come back within 60 seconds. These are CHANNEL_PING_INTERVAL_S and CHANNEL_PING_TIMEOUT_S, and every peer replies with a WebSocket pong as the WebSocket standard requires. The hub replies to a ping frame from any admitted peer and stores nothing.

A client sends a ping frame every 20 seconds, and one more right after its channel moves to a new socket, never before welcome. The pong with the nonce of the last ping sent on that socket gives rtt_ms, the time from sending that ping to receiving its pong. A pong with another nonce is dropped. A client sends no WebSocket ping of its own.

Agents and clients treat 45 seconds without a frame as a dead socket, and reconnect with a backoff from 5 to 60 seconds.

How a client picks an address ​

A client reaches a hub in rounds. A round dials every candidate address of the hub at the same moment. The candidates are the hub's hub_address on each of its virtual networks that is on, the hub's name, and the urls the client kept. Each dial has its own connect time.

The first socket to finish its TLS handshake with the pinned fingerprint is the round's socket. The client closes every other socket of the round before any hello on it, and sends one hello on the round's socket. The round ends connected when welcome follows, and in a code otherwise.

A network change starts a round at once for every hub not replaced or disabled. These events are network changes:

  • a virtual network of the hub's turning on;
  • an interface of the device coming up or going down, or a phone moving between mobile data and Wi-Fi;
  • a state whose urls differ from the ones the client kept;
  • a peer appearing or going on a virtual network that is on.

For a hub that is not connected, the change ends the wait and closes any dial still open. For a connected hub, the round runs beside the live channel.

Each candidate's path is set before hello. An address on a virtual network is that engine's, relay_url is the SSH Relay's, an address inside a network the device is on is lan, and any other is direct. The paths rank in this order, best first:

RankPath
1lan
2direct
3netbird, easytier
4relay

A round on a connected hub takes the channel only when its winner's path ranks higher, or ranks the same and its handshake was faster. A winner that is no better is closed before hello. A better winner sends hello, and the hub closes the old socket with 4010 replaced, which the client expects on the socket it left. Between rounds the client keeps no list of reachable addresses and runs no prober.

The stream layer ​

Every action is a stream: its open is the request, its close is the reply, and kind names the method.

ConcernRule
Idsthe hub opens streams with even ids from 0 and the peer with odd ids from 1, each side stepping by 2, so the two never collide
Bytesa binary frame holds one stream's bytes; a stream's text output is one line per frame
Creditcredit {stream, bytes} grants that many more bytes; the window is 1 MiB and the largest frame 64 KiB
Resultclose {stream, code, params} ends a stream from either side; an empty code makes params the result, and a code makes it a refusal
One closea stream one side closed gets no close back
Unknown kindsa kind a program has no handler for is closed with kind_unknown, and an unknown verb on a command stream with verb_unknown

The client's state ​

The hub sends a client its state after a report whose state_hash differs from the hub's or that has is_refresh: true. It also sends it whenever the hub's own changes.

SectionHolds
hashan opaque string the client names back in every report
is_disabledtrue after Disable on Clients: the lists are empty and every action is rejected with client_disabled
servicesthe published entries this client is allowed, resolved for the address its socket came from
urlsevery address the hub serves the channel on, which the client keeps for its next connection round
relay_urlthe member of urls that is the SSH Relay's address, an empty string while the relay is off; the client ranks a candidate address's path by it
overlayswhat the client joins each of the hub's overlays with, the preferred first
terminalsthe managed machines the client is allowed to open a shell on or run a command on, each {device_id, name, is_online, is_shell_allowed, is_exec_allowed, sessions}
is_panel_allowedtrue while the client is switched on and its permission includes panel: it can open the hub's panel through connect
reached_throughthe path this client's socket took to the hub: lan, direct, netbird, easytier or relay, the last being the SSH Relay

Overlays ​

overlays holds one object per running overlay that has material, NetBird first. It is empty when the hub runs no overlay, the client is off, its permission leaves out overlay, or the hub's vault is locked.

json
[
  {
    "provider": "netbird",
    "setup_key": "...",
    "management_url": "",
    "fqdn": "hub.netbird.cloud",
    "hub_address": "100.88.0.1"
  },
  {
    "provider": "easytier",
    "mode": "manual",
    "network_name": "...",
    "network_secret": "...",
    "peer": "tcp://203.0.113.7:11010",
    "hub_address": "10.0.0.1"
  }
]
Provider and modeFields
netbirdsetup_key, the reusable key; management_url, empty for NetBird's own plane; fqdn, the hub's name on the overlay; hub_address
easytier, manualnetwork_name, network_secret; peer, tcp://<join-host>:11010; hub_address
easytier, consoleconfig_server, the console address with its account token; is_secure_mode; hub_address

hub_address is the hub's own address on that network, without its prefix length, and empty when none is known. A client dials it with the other candidate addresses while the network is on.

Terminals and sessions ​

terminals lists every managed machine, the hub's own among them, when the client's permission includes terminal or exec, narrowed to the machines each permission names; is_shell_allowed and is_exec_allowed say which of the two holds for the machine. Each entry's sessions is the shell sessions this client sees on the machine. These are every session it opened, and every shared session where it has terminal rights. They are ordered by started_at, and the list is empty while the machine is offline:

FieldHolds
session_idthe id the opener generated, a uuid
accountthe account the shell runs as
started_atwhen it was opened, in Unix seconds
titlethe title the shell last set, or the shell's name
ownerwho opened it, as the hub stamped the shell open
is_attachedwhether a stream is attached now
is_persistentwhether the session stays when its last stream closes
is_sharedwhether every client with terminal rights on the machine can attach to it; a shared session also stays when its last stream closes
attached_counthow many streams are attached now
device_idthe machine holding the session
device_namewhat the hub calls that machine
is_ownedwhether this client opened it; only the owner sets is_persistent and is_shared
owner_namewhat the owner is called: the hub's name, or the owning client's name

The hub stamps owner on every shell open it sends a machine: client:<id> for a client's stream, hub for the panel's terminal. The hub pushes every client its state when an agent's channel opens or ends, and when a machine's list of sessions changes.

The client's report ​

json
{
  "type": "report",
  "state_hash": "<state-hash>",
  "machine": {
    "hostname": "laptop",
    "platform": { "os": "darwin", "family": "", "arch": "arm64" },
    "os_machine_id": "<os-machine-id>"
  }
}

A report goes up after the welcome, every 30 seconds after that, and after each state is applied. state_hash is empty before the first state. A report with is_refresh: true, sent when the person presses refresh, gets the whole state back whatever its hash.

os_machine_id is the operating system's id for the machine: /etc/machine-id on Linux, IOPlatformUUID on macOS, MachineGuid on Windows, or empty. The hub compares it with each provider's machine_id to set is_own_machine on the service entries.

The service entries ​

Each entry of services is {id, type, title, payload, is_healthy, source, description, description_code, description_params, device_id, device_name, is_own_machine}.

typepayload
web{url, is_token_required}; is_token_required is present and true on a VS Code, code-server, CloudCLI or Codeg instance only
port{host, port, protocol}; protocol is tcp or udp, and a payload without it is tcp. A number used on both protocols is two entries
ai{endpoint, protocol, models}, protocol being openai
file{protocol, host, share, users}, protocol being smb
rdp{protocol, host, port, attention, platform_os}, protocol being rustdesk, platform_os the sharing machine's linux, windows or darwin

Every host, port, url and endpoint in a payload is where the service sits on the hub's networks. It is resolved for the address the client's socket came from. A client shows it to the person and dials none of it; every byte to a service goes over a connect stream.

attention on an rdp entry names what somebody must do at the sharing machine first: rdp_nobody_seated, rdp_screen_not_allowed, or empty. users on a file entry lists the accounts that can open the share. A phone fills in the user name from it and prompts for the password. The list is empty on a declared share.

FieldHolds
is_healthythe last probe's result, null where nothing probed it
sourcemodule, declared or device
description_codethe provenance as a code a program words itself: ai_gateway, container, declared, device_share, gitea_module, samba_module, vscode_module, code_server_module, cloudcli_module, codeg_module
description_paramsthe values that sentence names; vscode_module, code_server_module, cloudcli_module and codeg_module take {host, account}
device_idthe id of the managed machine providing the entry; empty for a declared record and for the hub's own gateway. A program keys what it keeps per machine by this id
device_namethe name of the machine providing the entry, empty when no machine on the hub's records provides it
is_own_machinetrue when the machine providing the entry is the one the client runs on; false when either id is empty

The streams a client opens ​

The service stream ​

open {kind: service, id} requests what one entry needs from the hub. The checks run in this order, and the first that fails gives the close its code:

codeGiven when
binding_unknownno client row has this socket's binding id
client_disabledthe client is switched off on Clients
service_unknownthe id names no entry in the list resolved for this client
permission_deniedthe entry's type, or the machine providing it, is outside this client's permission
rdp_not_sharedthe entry's machine stopped sharing its desktop
vault_lockedthe hub's vault is locked, so the AI key, the VS Code token, or the code-server, CloudCLI or Codeg secret cannot be opened

A close with no code holds the material. A client opens a web entry with is_token_required at its own forward's address with ?tkn=<token> added, never at the entry's address:

EntryThe close's params
rdp{password}: the seat password of the machine sharing the desktop
ai{api_key, model}: this client's own key, which the gateway checks on every request, and the first model the gateway serves
web with description_code vscode_module{token}: the instance's connection token
web with description_code code_server_module, cloudcli_module or codeg_module{token}: a token the hub makes for this reply alone, valid for 60 seconds and for one use
other web, port, fileempty; the payload is all the client needs

The connect stream ​

open {kind: connect, id} holds one TCP connection to one published entry, and open {kind: connect, is_panel: true} holds one to the hub's own panel. A client opens one stream for each connection its local listener accepts, and one stream for as long as a UDP port entry is connected. It dials no address an entry's payload names. The hub runs the service stream's checks with the stream limit among them, without vault_locked:

codeGiven when
binding_unknownno client row has this socket's binding id
client_disabledthe client is switched off on Clients
connect_limit {limit}the socket already holds 256 open connect streams
service_unknown {service_id}the id names no entry in the list resolved for this client
permission_denied {kind}the entry's type, or panel for the panel, is outside this client's permission
rdp_not_shared {service_id}the entry's machine stopped sharing its desktop
agent_offline {device}the machine that provides the entry has no channel, or its channel ended under the stream
connect_failed {reason}the dial to the far end failed; reason is refused, timeout after 10 seconds, or unreachable
port_not_published {port}the machine that provides the entry no longer publishes the port
The entryWhere the hub connects the stream
provided by a managed machinethat machine's agent, which dials the port on its own loopback
a declared recordthe record's own address, dialled by the hub
aithe gateway on the hub's loopback
the panelthe panel's HTTP port on the hub's loopback, with no HTTPS redirect

Bytes go as binary frames both ways under credit, with the window and frame size of a shell stream. End of file on either end closes the stream with empty params after everything read is sent. The side that receives the close writes what it holds and closes its own socket. A stream has no half-close, and a client socket that ends ends every connect stream on it.

On a UDP stream each binary frame is one datagram, preceded by its source. The source is a big-endian u16, the port the datagram left from on the client's machine.

A reply holds the source it replies to. A side that holds too little credit for a frame drops the datagram. The far end forgets a source that is quiet for 60 seconds.

The shell and command streams ​

openWhat the hub does
{kind: shell, device_id, cols, rows, session_id, is_resumed, is_shared}opens a shell on that machine under session_id, stamped as this client's and shared when is_shared is true, and relays terminal bytes both ways; an id the machine holds attaches to that session beside every stream already attached, and its recent output comes first; is_resumed: true attaches only, and closes session_unknown when the machine holds no such session
{kind: command, module: agent, verb: resize, shell, cols, rows}resizes the client's own shell stream; shell_unknown when no such stream is open
{kind: command, module: agent, verb: persist, session_id, is_persistent, is_shared}sets whether the session stays after its last stream closes, and whether it is shared; a flag the command leaves out keeps its value
{kind: command, module: agent, verb: stop_session, session_id}ends the session on its machine

Any number of streams attach to one session at once. Each receives all the output, input from any of them reaches the shell, and the shell's size is the smallest attached window's columns and rows. A shell stream is refused with binding_unknown, client_disabled, permission_denied {kind: terminal} or agent_offline {device} before the hub opens anything on the machine.

persist and stop_session close with session_unknown when no machine holds the session. persist closes with session_not_owned on a session another viewer opened, and stop_session ends any session the client sees. An agent restart or update ends every session on that machine.

The exec stream ​

A client with the exec permission opens exec {device_id, argv, is_tty, cols, rows} to run one command on a machine; the hub opens the same stream to the machine's agent, with no session and no owner. The stream may close with permission_denied {kind: exec}, shell_program_unusable {path} for an empty argv or a program the machine cannot run, or agent_offline {device}. Frames from the machine carry one leading byte, the file descriptor: 1 for stdout and 2 for stderr, every byte 1 when is_tty is set; frames from the client are the command's stdin. The text frame eof {stream} ends stdin, and the agent then closes the process's stdin. The close carries exit_code, the command's own, or 128 plus the signal's number when a signal ended it. A resize names an exec stream opened with is_tty as it names a shell stream.

The agent's sections ​

An agent's documents hold these sections; the modules entries come from the hub's manifests, resolved for the agent's platform.

Sectionstate to an agentreport from an agent
machine{hostname, platform, accounts, metrics, sessions}
network{link: {interface, mac, address}, interfaces: [{name, mac, addresses}]}
modules{<name>: {want, config, install, uninstall, retry_mark}}{<name>: {state, is_active, code, params, details}}
desktop{seat_password}{is_shared, origin, account, share_id, port, attention, connected_count}
ai_tools{is_enabled, base_url, api_key, tool_configs, accounts: [{account, password}], cc_switch_version, retry_mark}{accounts: [{account, state, code, params, has_records}]}
urlsevery address the hub serves the channel on
error{code, params}: the agent's most recent failure worth showing
Field or valueMeaning
retry_marka short random token the hub writes when a person presses a failed module's action, or the AI tools switch, again; it changes the hash and means nothing else
queued, as a module's statethe module's want differs from its report, and the agent applies another module first
originhow the desktop share started: switch for the Remote desktop switch, command for a share an old nagent rdp start record keeps running; empty while nothing is shared
ai_tools.is_enabledtrue while the machine's AI tools setting is on and the gateway serves a model; then base_url, api_key and tool_configs name the gateway, the machine's key and the models
ai_tools.accountsin the state, every account with a VS Code, code-server, CloudCLI or Codeg instance, with its login on Windows alone; in the report, each account's state: switched, switched_back or failed
cc_switch_versionthe version of cc-switch the agent fetches from the hub and runs; a failed fetch reports cc_switch_download_failed on every account

An agent reports every 5 seconds. The hub opens shell, exec, file, command and connect streams to it, and the agent opens log and package streams to the hub.

A connect {port, protocol} stream is one TCP connection the agent dials on 127.0.0.1, or on the one address a container port is published on. On UDP it is every datagram of one entry to that port, and protocol absent is tcp. A port the machine does not publish on that protocol at that moment closes with port_not_published {port}, and a failed dial with connect_failed {reason}.

Refusals and the binding ​

A refusal is {code, params} wherever it appears: detail on an HTTP error, a refused frame, or the code on a stream's close. A refusal keeps the binding; the program records it and sends hello again a minute later.

CodeWhereEffect on the binding
protocol_too_oldjoin, hellokept
protocol_too_newjoin, hellokept
role_mismatchjoin, hellokept
hello_invalidhellokept
channel_fullhellokept; transient
admission_pausedjoinkept; transient, the ticket stays valid
ticket_spentjoinnothing is bound yet; a person creates a fresh link
binding_unknownhello, leave, a streamremoved: the program deletes its binding and needs a new link
kind_unknown, verb_unknowna streamkept

binding_unknown is the one refusal that unbinds, because it means the row was removed on the panel. Only the hub holding the pinned certificate can send it.

Close codeMeaning
4000refused; the refused frame before it holds the code
4010replaced: a second socket opened for the same binding; the program reconnects only on a person's action

Protocol numbers ​

Each build declares one integer, PROTOCOL, and package versions take no part in admission. The hub also holds PROTOCOL_MIN and admits a peer whose number is within PROTOCOL_MIN to PROTOCOL.

PROTOCOLFirst minor
10.3.0
20.4.0
30.5.2

PROTOCOL_MIN is 3 from 0.5.2. Protocol 3 renamed the link's and the client state's overlay, one object or null, to overlays, a list. A 0.3 or 0.4 agent or client is rejected with protocol_too_old and does not update itself from a 0.5.2 hub. Each such machine installs the 0.5.2 package and joins with a new link.

Reading is tolerant and writing is strict. An unknown field is ignored, an unknown kind closes with kind_unknown, and a program sends only what its own number defines. Adding a kind, a field or a code keeps the number. Removing anything, changing its meaning, or changing the link, the ticket, the pin or the words hello, state, report and open raises it by one.

Neutrino · personal developer infrastructure