Operating a node
CLI reference
Every uninet-cli subcommand, flag, default and exit code, with the UNP bootstrap the client performs before any of them can run.
What uninet-cli is
uninet-cli is a terminal client for one running node. It exposes seven subcommands: six
fixed routes on the node's inner API router, plus raw, an escape hatch that issues an
arbitrary method and path against that same router.
It is a read-only client. The only key material it generates is the ephemeral X25519 keypair
for the tunnel handshake, which lives for one invocation. It derives no identity, signs no
transaction and mints no session token; and while raw accepts any method and a --body, the
CLI holds no key with which to sign a transaction the chain would accept. The uninet-session
token a write path needs is produced elsewhere and pasted in with --token: the node accepts
one signed by the identity's own Ed25519 key, or one it minted itself after a password login.
Registration is described in the quickstart, not here.
The problem the binary exists to solve is that a node's /api/* routes are not on its public
HTTP surface at all. The external listener serves only /unp/* — the three bootstrap paths
this client uses, alongside the relay, delivery, onion-cell and domain-gateway routes — and
answers every other path with 400 UNP_TUNNEL_REQUIRED. Reaching a node means speaking the
tunnel, and this client is the tunnel implementation that ships with the workspace.
How a command reaches the node
Every invocation performs the full bootstrap before it sends its request, and exits afterwards. There is no cached session on disk.
GET /unp/resolve?addr=... node Ed25519 key, node X25519 key, 32-byte nonce,
timestamp, signature over nonce | ts_le | node_id
where node_id = blake3(node_pubkey)
|
POST /unp/handshake client ephemeral X25519 key + client nonce
--> session id, server X25519 key, server nonce
|
v
session key = BLAKE3-derive-key(
"uninet unp session v1",
ecdh | client_nonce | server_nonce)
|
v
POST /unp session_id (32B) | AES-256-GCM(TunneledRequest)
AAD "unp-tunnel-v1", nonce = counter | direction
--> tx_counter (u64 LE) | sealed response envelope
Four checks are fail-closed. The bootstrap signature is verified before a session is opened. The node's X25519 key is compared between resolve and handshake, and a change aborts rather than deriving a key. Every response envelope's Ed25519 signature is verified against the key pinned at resolve, and an envelope that does not verify is discarded without being printed. A response whose counter is below the expected receive counter is rejected as replayed.
Requests are strictly serial, and &mut self on the tunnel enforces that at the type level:
the request frame carries no counter of its own, so the node decrypts with its own monotonic
receive counter and two requests in flight would desync it. A session is valid for 24 hours.
Global flags
Every flag below is global — it may appear before or after the subcommand.
| Flag | Env | Default | Effect |
| --- | --- | --- | --- |
| --node <ORIGIN> | UNINET_NODE | http://127.0.0.1:3001 | Node origin. http:// only; an https:// URL is refused rather than downgraded, and a URL carrying a path is rejected. A missing port means 80. |
| --addr <UNP> | UNINET_ADDR | unp://self.node/ | The UNP address resolved on that node. |
| --token <TOKEN> | UNINET_SESSION_TOKEN | none | Sent as Authorization: Bearer ... inside the sealed frame. The gateway verifies it and injects a trusted identity header. |
| --json | — | off | Print the node's raw JSON body, envelope included, instead of the formatted view. |
| --timeout <SECS> | — | 10 | Per-request connect, read and write timeout. A value of 0 is raised to 1. |
| --dev-insecure | — | off | Bypass the tunnel and speak plain HTTP to a node's --dev-insecure-api loopback listener. |
| -v, --verbose | — | off | Print one transport line to stderr. Through the tunnel: node key prefix, operator wallet prefix, session id prefix, session expiry. Under --dev-insecure: the transport name and the origin. |
--dev-insecure is refused for any host that is not localhost, ::1 or 127.*, because
the listener it targets stamps every caller with the node operator's identity. That listener
exists only when the node was started with both --dev-insecure-api <port> and
UNINET_ALLOW_INSECURE=1 in the same environment. The flag on its own is logged as an error
and ignored — the node keeps running, serving only /unp/* — so an inherited launcher script
cannot switch the bypass on by itself.
Subcommands
| Command | Arguments | Route | Read-only | Auth |
| --- | --- | --- | --- | --- |
| status | — | GET /api/status | yes | none |
| health | — | GET /api/health | yes | none |
| block <HEIGHT> | height, or the literal latest | GET /api/blocks/{height}, or /api/blocks/latest | yes | none |
| receipt <TX_HASH> | 64 hex characters, optional 0x | GET /api/receipt/{tx_hash} | yes | none |
| chains | — | GET /api/chains | yes | none |
| nodes | — | GET /api/nodes/registry | yes | --token required |
| raw <METHOD> <PATH> | --body <JSON> | whatever you name | no | depends on the route |
Two arguments are validated locally so a typo fails in the terminal rather than after a round
trip. block accepts a u64 or latest and nothing else — tip, -1 and 1.5 are
rejected before a connection is opened. receipt trims whitespace, strips a 0x prefix,
lowercases, and requires exactly 64 hex characters; the error names the length you supplied.
nodes reads /api/nodes/registry rather than the sibling /api/nodes, because that sibling
fabricates its validator list's latency, hardware and usage figures from the validator's index
in the set. The registry does not: the node's own row is read from its live reservation
ledger, and a peer's row is whatever that peer last sent in a NodeHeartbeat. That gossip
travels over the p2p swarm, so on a node started without --enable-p2p the only populated row
is the node's own: peer validators are seeded from the validator set with every capacity
figure at zero, and stay that way until a heartbeat replaces them. Without a token the
registry answers 401 with {"error":"authentication required"} and the CLI exits 3.
raw prints the HTTP status above the formatted body, because an arbitrary route may answer
4xx with a body worth reading, and a response the node did not label as JSON is printed as
text with its content type rather than reported as a parse failure. Under --json the body
prints alone, without the status line. --body must parse as JSON locally first, and a path
that does not start with / is rejected.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | The node answered and the answer was printed. |
| 1 | The node could not be reached, its identity or a response envelope failed to verify, or an argument failed local validation. |
| 2 | Usage error, emitted by clap: unknown subcommand, bad flag. |
| 3 | The node answered with a refusal: an error field, or HTTP 4xx/5xx. |
Code 3 is deliberately not 2, so a script can tell "you typed the command wrong" from "the
node said no". That distinction matters more than it looks: get_block and get_receipt both
answer HTTP 200 with an error field, so status alone does not separate success from
failure. The envelope check runs under --json too — an error body prints and still exits 3.
Output
Formatted output is aligned key: value lines. Nothing is invented: a field the node did not
send renders as an em dash rather than as a plausible zero, an empty result prints an explicit
empty state rather than nothing, and keys appear in the alphabetical order serde_json's
default map ordering produces. Hex values print in full — the samples below abbreviate them
only to fit the page.
$ uninet-cli status
block_time_ms: 2000
chain_id: 4f0c...
child_chains: {}
height: 142
max_block_size: 4194304
max_txs_per_block: 1000
max_validators: 1
multi_node: false
node_id: 9a3b...
state_root: 7c11...
tip_hash: b208...
validator_count: 1
block_time_ms, max_block_size and max_txs_per_block are shown at the defaults the
primary chain is built with — 2 s, 4 MiB and 1000 transactions. A spawned child chain carries
whatever it was created with.
multi_node is true only with real configured validators and a live p2p transport;
max_validators collapses to 1 otherwise, since a child chain cannot exceed the real count.
block renders height, tx_count and timestamp as numbers and hash, parent_hash,
state_root and proposer as hex. /api/blocks/{height} adds view and sequence;
/api/blocks/latest does not carry them.
A receipt is the TransactionReceipt struct serialized directly, which is why tx_hash
arrives as a 32-element byte array rather than the hex string the block routes emit — that
handler hex-encodes nothing.
$ uninet-cli receipt 0xdeadbeef…64 hex characters in total…
error: —
events: []
gas_used: 21000
status: Success
tx_hash: [222,173,190,239,...]
tx_index: 0
Two commands render tables instead. chains lists the hierarchy, counting the validators
and children arrays rather than printing them; nodes pairs each capacity figure with the
node's declared total as free/total.
$ uninet-cli chains
CHAIN ID NAME TYPE HEIGHT VALIDATORS CHILDREN ACTIVE
----------------- --------- -------- ------ ---------- -------- ------
0123456789abcdef… Nexus Nexus 0 1 1 true
fedcba9876543210… alice-dlt Personal 142 1 0 true
When the node flags its own numbers, the CLI carries the flag through. /api/overview ships
estimated_fields: ["tps","score","latency_ms","assets","uptime"], and each named field is
tagged in the rendered output — those five are fixed constants or proxies in the handler,
uptime being a binary 1.0 as soon as any time has elapsed since the genesis block rather
than a measured availability ratio. The tag only covers what the node itself flagged:
containers_online and containers_total are not on that list, yet both are the chain's
validator count, not a count of running containers.
$ uninet-cli raw GET /api/overview
HTTP 200
assets: 1 (estimated by the node)
block_height: 142
containers_online: 1
…
The real output carries every key the handler sends, alphabetically; the sample is cut short.
What the CLI does not address
- Key custody. The binary reads. It has no
keygen,loginorsign, produces no signature of any kind — it only verifies the node's — and holds no long-lived secret: the one secret it creates is the X25519 handshake key, which dies with the process. A--tokenis something you paste in, not something it mints. - First-contact trust. The node's Ed25519 key is pinned from the same
/unp/resolveresponse that the key signs, and the client has no out-of-band record of which key a given node should present. An adversary positioned at the bootstrap from the first byte can present a key of its own, and every later verification will succeed against it. Pinning protects the rest of the session; it does not establish who you reached. - The relay overlay. The CLI implements the direct tunnel only: if
/unp/resolveever answered with arelay_descriptorit would abort rather than fall back to a direct connection. No node can produce that answer today — the confidential resolver and the relay pool are bothNoneon a node as constructed, and are populated only in the gateway's own tests. An address this node does not serve itself answers404 UNP_ADDR_REMOTEinstead. - Transport metadata. Confidentiality comes from the tunnel, not from TLS: the client
writes HTTP/1.1 straight onto a plain
TcpStream, which is why anhttps://origin is refused rather than downgraded. An observer on the path sees a TCP connection to the bootstrap listener, the resolve and handshake exchange in the clear, and then sealed frames whose sizes it can read. - The dev bypass.
--dev-insecureverifies nothing, because the listener it targets provides nothing to verify: no envelope signature, no session, and an injected operator identity for every caller. The CLI refuses the flag outright for any host that is not loopback, so it cannot be aimed across a network.
How it composes
The tunnel this client speaks reuses the primitives described under
private networking — the same X25519 exchange and AES-256-GCM the
onion layers use, with one hop instead of several. Reaching a node is not permission on it.
The gateway turns a verified --token into a trusted x-unp-identity header — a client
cannot set that header itself, because the gateway strips the reserved prefix off every
inbound tunnel header — and the routes that guard ownership compare the identity you claim in
the request against the one in that header. /api/nodes/registry is the one this CLI reaches;
what an identity is then allowed to do is the subject of
delegated authority.
What the commands report — height, tip, state root, validator count, registry capacity — is the state produced by the node described in running a node. Per-subsystem grades for everything named here are on build status.
What is not finished
The CLI is read-only — it has no key generation, no identity creation and no transaction signing — so any walkthrough involving a write path is written against the client application rather than the terminal.
The repository ships no public bootstrap or seed address, no committed genesis/chainspec file and no hosted endpoint; peer discovery is a Kademlia behaviour that is constructed but never driven, and the only route onto a network is to be pre-listed as a validator in a config file every participant already holds.