UniNet

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.

In developmentDistributable client (CLI / SDK)Not implementedPublic network access for external developers

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, login or sign, 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 --token is something you paste in, not something it mints.
  • First-contact trust. The node's Ed25519 key is pinned from the same /unp/resolve response 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/resolve ever answered with a relay_descriptor it 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 both None on a node as constructed, and are populated only in the gateway's own tests. An address this node does not serve itself answers 404 UNP_ADDR_REMOTE instead.
  • Transport metadata. Confidentiality comes from the tunnel, not from TLS: the client writes HTTP/1.1 straight onto a plain TcpStream, which is why an https:// 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-insecure verifies 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.

See the full build status