APIs & Devices NotesRohit’s interview study guide
Chapter 06

Telnet, SSH and Command-Line Control

Line-based control channels for devices: what Telnet really is on the wire, a command server in Node that survives partial packets, a grammar machines can parse, notifications on the same socket, SSH with ssh2, one command layer behind REST, Telnet and SSH, and how to test and lock it all down.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#What Telnet is and why devices still expose it

Added

Telnet is a protocol, standardised in its current form by RFC 854 (May 1983), for carrying text between a terminal and a host over a TCP connection, by default on port 23. Most of it is just bytes: what you type goes one way, what the host prints comes back. The one clever part is a tiny control language mixed into the same stream. Byte 255 is called IAC, "Interpret As Command": whenever it appears, the next byte or two are an instruction about the connection, not text. Both ends start as a "Network Virtual Terminal" (NVT): 7-bit ASCII in 8-bit bytes, lines ending in CR LF, and each side echoing its own typing. Anything fancier, like the host echoing your keystrokes or the client reporting its window size, is an option the two sides negotiate with four verbs: WILL, WONT, DO and DONT.

A mental model: Telnet is a phone line between two operators who mostly just talk, except that one special word, "IAC", means "the next words are for the switchboard, not for you". Options are polite proposals: "I'd like to echo for you" (WILL ECHO), "please send me your window size" (DO NAWS). The other side answers yes or no. Saying no is always safe, because the plain NVT is what both sides fall back to; that's the rule that lets a 40-year-old protocol interoperate with anything.

Why would a 2026 video-conferencing device still have it? Because the people who integrate room systems use it. Room control systems (touch panels on the wall that start a call, mute the room, lower the screen) send short text commands over TCP or an RS-232 serial cable, and a line protocol is the lowest common denominator: any language, any controller, even nc from a laptop can drive it, with no HTTP stack and no JSON parser. Integrations written for one generation of hardware are expected to keep working on the next. Poly's public documentation shows exactly this shape:

  • The Poly Video Mode Administrator Guide 4.4.0 (HP, document P10727-001A, last updated November 2024, for the G7500, Studio G62 and Studio X systems; read 2026-10-27) describes the command-line API over Telnet on port 24 (default) or 23, enabled with an "Enable Telnet Access" setting under Security > Access, and over SSH on port 22 "if you want encrypted access", with a setting named "Enable Legacy API Over SSH". Its steps for disabling command-line API access close ports 22, 23 and 24 and also switch off the RS-232 serial port, so on a G7500 the same API is reachable over serial (the same guide says the Studio G62 and the Studio X bars have no serial port).
  • The Poly Studio G62 with VideoOS 4.3.0 Integrator Guide (HP, document P03707-002A, May 2024; read 2026-10-27) documents the same command-line API, recommends status registrations (the notify command) over polling, and recommends putting registrations in the start-up of Crestron and AMX control systems, the typical callers.
  • Poly also publishes a separate Poly VideoOS REST API Reference Guide (P55757-001A, August 2025) for the same G62, G7500 and Studio X family, so the CLI sits next to a REST API, not instead of it. How these are implemented inside the device isn't in the public docs, and nothing in this chapter is a claim about Poly's internals.

These are the bytes you'll meet. Commands come from RFC 854, the option procedure from RFC 855, and the option codes from their own RFCs (all checked on rfc-editor.org, October 2026):

ByteNameMeaning
FF (255)IACthe next byte is a command; FF FF means one literal 255 data byte
FB (251)WILL"I want to start doing option X" (or "OK, I will", as a reply)
FC (252)WONT"I won't do X" (refusal, or "I'm stopping")
FD (253)DO"please start doing X" (or "OK, do it", as a reply)
FE (254)DONT"don't do X"
FA (250) / F0 (240)SB / SEstart / end of a subnegotiation: IAC SB <option> <parameters> IAC SE
01ECHO option (RFC 857)who echoes typed characters; the default is nobody echoing over the connection
03SUPPRESS-GO-AHEAD (RFC 858)stop sending the half-duplex "go ahead" signal
18 (24)TERMINAL-TYPE (RFC 1091)client says what terminal it emulates
1F (31)NAWS (RFC 1073)client reports its window width and height
22 (34)LINEMODE (RFC 1184)client-side line editing, negotiated

One rule matters more than the rest. RFC 854 warns that symmetric negotiation can loop forever (each side treating the other's reply as a new request), so: you may only request a change of state, never announce the state you're already in; if you receive a request for the mode you're already in, you don't reply; and a rejected request isn't repeated until something changes.

Telnet client Device IAC WILL SGA · FF FB 03 device offers: no go-ahead signals IAC DO SGA · FF FD 03 accepted: it answers an offer, so no reply IAC WILL NAWS · FF FB 1F IAC DO NAWS · FF FD 1F yes, tell me your window size IAC SB NAWS 00 50 00 18 IAC SE subnegotiation: 80 columns × 24 rows IAC DO ECHO · FF FD 01 IAC WONT ECHO · FF FC 01 refused: stay line by line, client echoes negotiation done: from here on it is text volume get CR LF volume 30 CR LF
Options are offered and answered with WILL / DO / WONT / DONT; an answer is never answered again. In practice these bytes can arrive glued to text or split across TCP segments, which is why the server parses them byte by byte.

This model is the server's side of that exchange. It parses the stream one byte at a time with a small state machine, applies a policy (we'll suppress go-ahead, we want the window size, we refuse everything else), and the client's bytes arrive cut at awkward places, as TCP is allowed to deliver them. The client bytes are written by hand from the RFCs, not captured from a particular Telnet client:

telnet-negotiation.mjs
// A Telnet server's view of the byte stream: commands start with IAC (255), data is everything else.
// Byte values from RFC 854 (commands), RFC 857 (ECHO), RFC 858 (SGA), RFC 1091 (TTYPE), RFC 1073 (NAWS).
const IAC = 255, DONT = 254, DO = 253, WONT = 252, WILL = 251, SB = 250, SE = 240;
const VERB = { 251: "WILL", 252: "WONT", 253: "DO", 254: "DONT" };
const OPT = { 1: "ECHO", 3: "SGA", 24: "TTYPE", 31: "NAWS" };
const hex = (bytes) => [...bytes].map((b) => b.toString(16).toUpperCase().padStart(2, "0")).join(" ");
const say = (bytes) => [...bytes].map((b, i) => (i === 0 ? "IAC" : VERB[b] ?? OPT[b] ?? b)).join(" ");

// Our policy: we will suppress go-ahead ourselves; we want the client's window size; nothing else.
const weWill = { 3: true }, weWant = { 31: true };
const ours = {}, theirs = {}, pending = {}; // option -> enabled? (both start false: plain NVT)
const send = (verb, opt) => { const b = [IAC, verb, opt]; console.log(`  server sends ${hex(b).padEnd(9)} ${say(b)}`); };

function negotiate(verb, opt) {
  const askingUs = verb === DO || verb === DONT;       // DO/DONT are about what WE do
  const state = askingUs ? ours : theirs;
  const wantOn = verb === DO || verb === WILL;
  if (pending[opt] === verb || pending[opt] + 1 === verb) {     // DO answers our WILL, DONT refuses it
    delete pending[opt]; state[opt] = wantOn;
    return console.log(`  ${VERB[verb]} ${OPT[opt]}: ${wantOn ? "accepts" : "refuses"} our offer, no reply`);
  }
  if (!!state[opt] === wantOn) return console.log(`  ${VERB[verb]} ${OPT[opt]}: already in that mode, no reply (RFC 854 loop rule)`);
  const allowed = wantOn && (askingUs ? weWill[opt] : weWant[opt]);
  state[opt] = !!allowed;
  send(askingUs ? (allowed ? WILL : WONT) : (allowed ? DO : DONT), opt);
}

let mode = "data", verb = 0, sub = [], text = "";
function feed(chunk) {
  for (const b of chunk) {
    if (mode === "data") b === IAC ? (mode = "iac") : (text += String.fromCharCode(b));
    else if (mode === "iac") {
      if (b === IAC) { text += "\xff"; mode = "data"; }          // IAC IAC = a literal 255 byte
      else if (VERB[b]) { verb = b; mode = "opt"; }
      else if (b === SB) { sub = []; mode = "sb"; }
      else mode = "data";                                       // NOP, GA, AYT...: ignored here
    } else if (mode === "opt") { negotiate(verb, b); mode = "data"; }
    else if (mode === "sb") b === IAC ? (mode = "sb-iac") : sub.push(b);
    else if (mode === "sb-iac") {
      if (b === SE) {
        const [opt, w1, w2, h1, h2] = sub;
        console.log(`  SB ${OPT[opt]} ... SE: window ${(w1 << 8) | w2} x ${(h1 << 8) | h2}`);
        mode = "data";
      } else { sub.push(b); mode = "sb"; }                       // IAC IAC inside SB = 255
    }
  }
}

console.log("connect:");
pending[3] = DO; send(WILL, 3);                                 // offer SGA; expect DO SGA back
const client = [
  [IAC, DO, 3, IAC, WILL],                                       // ack of SGA, then a command cut in half
  [31, IAC, WILL, 24, IAC, DO, 1],
  [IAC, SB, 31, 0, 80, 0],                                       // window-size subnegotiation, split
  [24, IAC, SE, ...Buffer.from("volume get\r\n")],
];
for (const chunk of client) { console.log(`chunk ${hex(chunk)}`); feed(chunk); }
console.log(`data for the command layer: ${JSON.stringify(text)}`);
Result
connect:
  server sends FF FB 03  IAC WILL SGA
chunk FF FD 03 FF FB
  DO SGA: accepts our offer, no reply
chunk 1F FF FB 18 FF FD 01
  server sends FF FD 1F  IAC DO NAWS
  server sends FF FE 18  IAC DONT TTYPE
  server sends FF FC 01  IAC WONT ECHO
chunk FF FA 1F 00 50 00
chunk 18 FF F0 76 6F 6C 75 6D 65 20 67 65 74 0D 0A
  SB NAWS ... SE: window 80 x 24
data for the command layer: "volume get\r\n"
What’s happening
  1. On connect the server makes one offer, FF FB 03 (IAC WILL SGA), and remembers in pending that it expects DO SGA back. SGA ("suppress go-ahead") turns off a half-duplex signal from the teletype era; it's a harmless option to offer, which makes it a good first example.
  2. Chunk 1 is FF FD 03 FF FB: a complete IAC DO SGA and then half of the next command. The parser sees FF (go to state iac), FD (a verb, go to opt), 03 (the option) and calls negotiate(DO, SGA). Because SGA is pending, this is the answer to our offer: state goes on and nothing is sent back. Replying here would start the endless acknowledgement loop RFC 854 warns about. Then FF FB leaves the parser in state opt, waiting for one more byte.
  3. Chunk 2 starts with 1F, which completes IAC WILL NAWS across the chunk boundary. The client offers its window size; our policy wants it, so we reply FF FD 1F (IAC DO NAWS). IAC WILL TTYPE (FF FB 18) is refused with FF FE 18 (DONT), and IAC DO ECHO (FF FD 01, "please echo for me") is refused with FF FC 01 (WONT). Refusing is always safe: the NVT default is what's left.
  4. Chunks 3 and 4 carry the subnegotiation IAC SB NAWS 00 50 00 18 IAC SE, cut in the middle of the two-byte height. The parser collects bytes in state sb and only acts on IAC SE: width 0x0050 = 80, height 0x0018 = 24. The same chunk then continues straight into text.
  5. Everything that wasn't a command lands in text: exactly "volume get\r\n", ready for the line framer of the next topic. That separation is the whole point: the command layer never sees a 255 byte or a negotiation; the Telnet layer strips them first.
  6. If you changed the state machine to look at each chunk on its own (say, chunk.indexOf(255)), chunks 1 and 2 would break it: half a command at the end of one chunk is normal TCP behaviour, not an error. Byte-at-a-time state machines are how protocol parsers survive arbitrary segmentation.
AdvancedLine mode, character mode and echo

With no options agreed, a Telnet connection is in the NVT's line-at-a-time discipline: the client edits and echoes the line locally and sends it when you press Enter. That's what you want for a command API: the device receives whole lines and never has to echo. Interactive login servers do the opposite: they offer WILL ECHO (so the client stops echoing locally, which is how a password prompt hides what you type) and WILL SGA. Many clients treat that pair as the signal to send every keystroke as you type it ("character mode"); that's a widespread client convention, not a rule in RFC 854. RFC 1184's LINEMODE option (34) is the standard way to get client-side editing with server-defined rules, and few device APIs bother with it. For a device API the practical policy is the one in the model: refuse DO ECHO, refuse terminal type, accept or refuse NAWS (you rarely need it), and keep things line by line.

AdvancedEscaping, CR NUL and binary data

Inside the data stream a literal byte 255 must be sent as FF FF (RFC 854: "only the IAC need be doubled"). UTF-8 never produces the byte FF, so text-only APIs rarely meet it, but the parser must still turn IAC IAC back into one byte or a binary upload over the same channel gets corrupted. RFC 854 also says a newline is the two bytes CR LF, and a carriage return that isn't a newline is sent as CR NUL. Real clients differ in what they send for Enter (CR LF, bare LF from scripts and nc), so a device should accept LF and CR LF as end of line and drop a NUL that follows a CR.

What does the device send back?

A line-mode command server, currently not echoing, receives FF FD 01 from a client, and a moment later FF FE 01. What does it send for each?

Show answer

For FF FD 01 it sends FF FC 01. For FF FE 01 it sends nothing.

What’s happening
  1. FF FD 01 is IAC DO ECHO: the client asks the server to start echoing its characters. A line-mode API doesn't want to echo, so it refuses.
  2. A refusal to DO is WONT, with the same option byte: IAC WONT ECHO = FF FC 01. RFC 854 requires a response to a request for a change of mode, even when the mode doesn't change.
  3. FF FE 01 is IAC DONT ECHO: "don't echo". The server is already not echoing, so this asks for the mode it's already in.
  4. RFC 854's loop rule: a request to enter a mode you're already in is not acknowledged. Replying WONT ECHO again could bounce back and forth forever, so the correct answer is silence.

#Building a line-based command server in Node

Added

A command server is a TCP server where each connection is a session and each line is a command. Node's node:net gives you the TCP part: net.createServer calls you once per connection with a socket, and the socket emits data events. The trap is the word "events". TCP is a byte stream, not a message stream: one write("volume get\r\n") on the client may arrive as one data event, as two ("vol", "ume get\r\n"), or glued to the next command ("volume get\r\nmute get\r\n"). Nothing in TCP preserves the boundaries of writes. On localhost you'll almost always see one event per write, which is exactly why this bug ships and then appears on a congested Wi-Fi network or a VPN.

The mental model: TCP is water through a pipe. You can pour it in cupfuls, but at the other end it comes out in whatever amounts the pipe delivers. If you want sentences, you write a full stop between them and the reader collects water until it sees one. For a command protocol the full stop is the newline: keep a per-session buffer of bytes, append every chunk, and run a command each time you find an LF. Whatever is after the last LF stays in the buffer for next time.

Each session also needs its own state (the buffer, the logged-in user, its subscriptions), and the closure of the connection callback is the natural place for it: every connection gets a fresh set of variables, while the device state they act on is shared. And because the buffer grows until a newline appears, the server needs a limit: a client that sends megabytes without a newline must be refused, not buffered.

line-server.mjs
import net from "node:net";

const MAX_LINE = 200;                         // bytes; a device can't buffer forever
const device = { volume: 30, mute: false };   // shared by every session
let nextSession = 1;
const seen = [];                             // complete lines the server parsed, in order

const server = net.createServer((socket) => {
  const id = nextSession++;
  let pending = Buffer.alloc(0);              // bytes received but not yet a full line
  let skipping = false;                       // inside an over-long line: drop it up to its LF
  const reply = (text) => socket.write(`${text}\r\n`);
  reply(`hello, session ${id}`);

  socket.on("data", (chunk) => {
    pending = Buffer.concat([pending, chunk]);
    let nl;
    while ((nl = pending.indexOf(0x0a)) !== -1) {         // one command per LF
      const raw = pending.subarray(0, nl);
      pending = pending.subarray(nl + 1);
      if (skipping) { skipping = false; continue; }       // tail of a line we already refused
      if (raw.length > MAX_LINE) { reply("error: line too long"); continue; }
      const line = raw.toString("utf8").replace(/\r$/, "");
      seen.push(`s${id} ${JSON.stringify(line)}`);
      run(line);
    }
    if (!skipping && pending.length > MAX_LINE) {          // no LF in sight: refuse now, don't grow
      skipping = true;
      reply("error: line too long");
    }
    if (skipping) pending = Buffer.alloc(0);
  });

  function run(line) {
    const [verb, arg, value] = line.trim().split(/\s+/);
    if (verb === "volume" && arg === "get") reply(`volume ${device.volume}`);
    else if (verb === "volume" && arg === "set") { device.volume = Number(value); reply(`volume ${device.volume}`); }
    else if (verb === "mute" && arg === "get") reply(`mute ${device.mute ? "on" : "off"}`);
    else if (verb === "exit") { reply("bye"); socket.end(); }
    else reply(`error: unknown command: ${verb}`);
  }
  socket.on("error", () => {});                            // a reset client must not crash the device
});

// A test client: queues incoming lines so the script can await them one at a time.
function connect(port) {
  const socket = net.connect(port);
  const lines = [], waiting = [];
  let rest = "";
  socket.setEncoding("utf8");
  socket.on("data", (text) => {
    const parts = (rest + text).split("\r\n");
    rest = parts.pop();
    for (const l of parts) waiting.length ? waiting.shift()(l) : lines.push(l);
  });
  const next = () => (lines.length ? Promise.resolve(lines.shift()) : new Promise((r) => waiting.push(r)));
  const write = (s) => new Promise((r) => socket.write(s, r));
  return { next, write, closed: new Promise((r) => socket.on("close", r)) };
}
const pause = () => new Promise((r) => setTimeout(r, 15));

server.listen(0, "127.0.0.1", async () => {
  const { port } = server.address();
  const a = connect(port);
  console.log(`A: ${await a.next()}`);                     // greeted before B connects,
  const b = connect(port);                                 // so A is always session 1
  console.log(`B: ${await b.next()}`);

  // One command split over three writes, and two commands in one write.
  await a.write("vol"); await pause();
  await a.write("ume set 42\r\nmute g"); await pause();
  await a.write("et\r\n");
  console.log(`A: ${await a.next()}`);
  console.log(`A: ${await a.next()}`);

  await b.write("volume get\n");                           // bare LF works too
  console.log(`B: ${await b.next()}`);
  await b.write("x".repeat(300) + "\r\n");                 // longer than MAX_LINE
  console.log(`B: ${await b.next()}`);

  await a.write("exit\r\n"); console.log(`A: ${await a.next()}`);
  await b.write("exit\r\n"); console.log(`B: ${await b.next()}`);
  await Promise.all([a.closed, b.closed]);
  server.close(() => console.log(`server parsed, in order:\n  ${seen.join("\n  ")}`));
});
Result
A: hello, session 1
B: hello, session 2
A: volume 42
A: mute off
B: volume 42
B: error: line too long
A: bye
B: bye
server parsed, in order:
  s1 "volume set 42"
  s1 "mute get"
  s2 "volume get"
  s1 "exit"
  s2 "exit"
What’s happening
  1. net.createServer calls the connection handler once per client. id, pending and skipping are created inside it, so session 1 and session 2 each have their own buffer. device is outside, so a volume change in one session is visible in the other (B reads volume 42 after A set it). The client waits for A's greeting before connecting B, which makes A session 1 on every run.
  2. A writes "vol", then "ume set 42\r\nmute g", then "et\r\n", with a pause between writes so they're likely to arrive as separate chunks. The data handler appends each chunk to pending and loops while there's an LF (0x0a): after the second chunk it finds one and runs volume set 42, leaving "mute g" in the buffer; after the third it completes mute get. The server parsed exactly two commands, in order, no matter how the bytes were cut.
  3. The line is cut at the LF and a trailing \r is stripped, so "volume get\n" from B (bare LF, what nc and many scripts send) works just like "\r\n". Decoding happens per complete line, on a Buffer, never per chunk; the advanced section shows why.
  4. B's 300 x characters plus \r\n exceed MAX_LINE (200 bytes). The server answers error: line too long once and never runs it. If the bytes had arrived without a newline in sight, the check after the loop would have refused them as soon as the buffer passed 200 bytes and set skipping, so the rest of that line is thrown away up to its LF instead of being run as a command. Without that flag, the tail of a refused line would be parsed as a new command.
  5. exit replies bye and calls socket.end(), a graceful close (FIN) after the reply is flushed; socket.on("error", () => {}) means a client that resets the connection can't crash the whole server with an unhandled error event. The final list shows what the server parsed: five whole commands, nothing duplicated or split.
  6. This demonstrates framing: TCP gives you bytes, the protocol gives you messages, and the code between them (buffer, delimiter, limit) is your responsibility.
Two unknown commands from one

A first version of the server did this, and a client's single command arrived as two chunks, "vol" and "ume get\r\n":

JavaScript
socket.on("data", (d) => d.toString().split("\r\n").filter(Boolean).forEach(run));

What does the client get back?

Show answer

Two errors: error: unknown command: vol and error: unknown command: ume.

What’s happening
  1. The first data event carries "vol". split("\r\n") returns ["vol"], filter(Boolean) keeps it, and run("vol") treats it as a whole command: the verb is vol, unknown.
  2. The second event carries "ume get\r\n". split returns ["ume get", ""], the empty string is filtered out, and run("ume get") sees verb ume: unknown again.
  3. The handler assumed one event equals one command. The fix is the buffer from the model: append, extract only up to the last delimiter, keep the remainder.
AdvancedDecoding: bytes until the line is complete

Here's the bug the model avoids by keeping Buffers until it has a whole line. é is two bytes in UTF-8 (C3 A9), and the model cuts the stream between them:

utf8-split.mjs
import { StringDecoder } from "node:string_decoder";

// "é" is two bytes in UTF-8 (C3 A9). TCP may cut the stream between them.
const bytes = Buffer.from('name set "Salle de réunion"\r\n');
const cut = bytes.indexOf(0xc3) + 1;
const chunks = [bytes.subarray(0, cut), bytes.subarray(cut)];
console.log(`chunk sizes: ${chunks.map((c) => c.length).join(" + ")}, cut after byte ${bytes[cut - 1].toString(16)}`);

// 1. Decode each chunk on arrival: each half of "é" is invalid on its own.
let naive = "";
for (const c of chunks) naive += c.toString("utf8");
console.log(`decode per chunk: ${JSON.stringify(naive.trimEnd())}`);

// 2. Keep bytes until a full line, then decode the line.
let pending = Buffer.alloc(0);
for (const c of chunks) pending = Buffer.concat([pending, c]);
const line = pending.subarray(0, pending.indexOf(0x0a)).toString("utf8").replace(/\r$/, "");
console.log(`decode per line:  ${JSON.stringify(line)}`);

// 3. Or a StringDecoder, which holds back an incomplete character until the next chunk.
const decoder = new StringDecoder("utf8");
let streamed = "";
for (const c of chunks) {
  const part = decoder.write(c);
  console.log(`  decoder.write(${c.length} bytes) -> ${JSON.stringify(part)}`);
  streamed += part;
}
console.log(`StringDecoder:    ${JSON.stringify(streamed.trimEnd())}`);
Result
chunk sizes: 21 + 9, cut after byte c3
decode per chunk: "name set \"Salle de r��union\""
decode per line:  "name set \"Salle de réunion\""
  decoder.write(21 bytes) -> "name set \"Salle de r"
  decoder.write(9 bytes) -> "éunion\"\r\n"
StringDecoder:    "name set \"Salle de réunion\""
What’s happening
  1. The command is 30 bytes; the cut falls after C3, the first byte of é, so the chunks are 21 and 9 bytes.
  2. Decoding each chunk on its own turns each half of é into U+FFFD, the replacement character (��). The room name the admin typed is now corrupted in the device's settings, and no error was raised anywhere.
  3. Concatenating the Buffers and decoding only the complete line gives "Salle de réunion". That's what the line server does.
  4. StringDecoder (and socket.setEncoding("utf8"), which uses one internally) also works: decoder.write returns "...Salle de r" and holds the dangling C3 back until the next chunk completes it. Either approach is fine; decoding raw chunks with toString() is not.
AdvancedLimits: sessions and idle time

A device has a fixed amount of memory and threads, and a forgotten control-system connection can hold a session open for months. Two limits are cheap in Node: server.maxConnections and socket.setTimeout.

limits.mjs
import net from "node:net";

// Two limits every device command server needs: how many sessions, and how long one may sit idle.
const server = net.createServer((sock) => {
  sock.setEncoding("utf8");
  sock.write("hello\r\n");
  sock.setTimeout(200);                                     // ms without traffic in either direction
  sock.on("timeout", () => sock.end("error: E_IDLE closing idle session\r\n"));
  sock.on("data", () => sock.write("ok\r\n"));             // any traffic resets the idle timer
});
server.maxConnections = 2;                                  // the 3rd is closed by Node right away
server.on("drop", (info) => console.log(`server: dropped a connection over the limit (${server.maxConnections})`));

function client(name, port) {
  const sock = net.connect(port, "127.0.0.1");
  let text = "";
  sock.setEncoding("utf8");
  sock.on("data", (d) => (text += d));
  const closed = new Promise((r) => sock.on("close", () => r(`${name} closed after: ${JSON.stringify(text)}`)));
  sock.on("error", () => {});
  return { sock, closed };
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));

server.listen(0, "127.0.0.1", async () => {
  const { port } = server.address();
  const a = client("A", port); await wait(20);
  const b = client("B", port); await wait(20);
  const c = client("C", port);
  console.log(await c.closed);                              // over the limit: closed, no greeting
  for (let i = 0; i < 5; i++) { await wait(50); b.sock.write("volume get\r\n"); }   // B stays busy
  console.log(await a.closed);                              // A was idle for 200 ms
  console.log(`B, busy every 50 ms, still open: ${!b.sock.destroyed}`);
  console.log(await b.closed);                              // B goes idle too
  server.close();
});
Result
server: dropped a connection over the limit (2)
C closed after: ""
A closed after: "hello\r\nerror: E_IDLE closing idle session\r\n"
B, busy every 50 ms, still open: true
B closed after: "hello\r\nok\r\nok\r\nok\r\nok\r\nok\r\nerror: E_IDLE closing idle session\r\n"
What’s happening
  1. server.maxConnections = 2: A and B are accepted. When C connects, Node closes its socket immediately and emits the server's drop event, so C sees no greeting at all (its text is ""). A real device would usually prefer to accept, write error: E_BUSY too many sessions, and close, so the integrator sees why; either way, the limit is enforced before any per-session memory is allocated.
  2. sock.setTimeout(200) arms an inactivity timer on each socket: if no bytes move in either direction for 200 ms, the socket emits timeout. Node doesn't close it for you; the handler writes error: E_IDLE closing idle session and ends it. A sent only its greeting, so it's closed.
  3. B writes volume get every 50 ms, so its timer keeps being reset and it's still open after A has gone. Once B stops, it gets the same idle message 200 ms later. Its transcript shows five ok replies and then the close.
  4. On a real device the numbers are product decisions (a handful of sessions, minutes rather than milliseconds of idle time); the model uses milliseconds so it runs instantly. What matters is that both limits exist and that hitting one produces a clear message.
AdvancedTelnet and framing together

On a real Telnet port the layers stack in a fixed order: bytes from the socket → Telnet parser (strip IAC, answer options, unescape FF FF, drop the NUL after CR) → line framer → command layer. Getting the order wrong breaks things: if you frame lines first, an IAC SB ... IAC SE that happens to contain the byte 0A would split a line in the middle of a negotiation. Also decide what a session looks like when the device itself goes away: before a reboot, write a final line (for example info: rebooting) and end every socket, so integrators' scripts see a clean close and reconnect with back-off instead of hanging on a dead TCP connection (see Reconnection and back-off).

#Designing a command grammar

Added

A command grammar is the contract between the device and every script that will ever drive it. The person typing at a prompt is the easy customer; the hard one is a control-system program written years ago that matches responses with a regular expression and can't be updated when you change a word. So the grammar has to be predictable for machines first and pleasant for humans second: what a command looks like (verbs, arguments, quoting), what every response looks like (success, error, information, notification), and how errors are identified.

The public Poly guides show one concrete shape. Commands are a noun followed by an action, with a published syntax per command, for example volume <get|up|down|set {0..50}> and volume <register|unregister> in the Studio G62 guide. Successful feedback echoes the setting: its example for sleeptime 30 is feedback sleeptime 30. Registrations are acknowledged (notify callstatus returns notify callstatus success), and an unsupported command returns feedback such as "command is not available in current system configuration". The same guide says commands not listed are unsupported and may change. Everything below is our own grammar, shaped like that, not Poly's.

A mental model: design the grammar as a tiny language whose main reader is a regex. Every response is one line; its first token says what kind of line it is; values that could contain spaces are quoted the same way on the way out as on the way in; errors carry a stable code that a script can match and a message a human can read.

RuleExampleWhy
Noun first, then actionvolume get, volume set 42groups related commands; help volume can list them
Success echoes the resulting statevolume set 42 → volume 42the reply is the truth after the change, and matches the reply to volume get
Every non-success line is taggederror: E_RANGE …, info: …, notification:…a client can classify any line with one prefix check
Stable error codes plus free texterror: E_RANGE level must be 0..50, got 70scripts match the code; people read the text, which may change
Quote on the way out like on the way inname "Board Room \"East\""responses can be parsed by the same tokenizer
Verbs case-insensitive, values notVOLUME GET works; the name keeps its caseforgiving to humans, exact for data
Usage on wrong argumentsusage: volume get | volume set <level 0..50>self-documenting at the prompt
A machine format when needed{"ok":false,"code":"E_RANGE",…}one JSON object per line for new integrations
grammar.mjs
// A small command grammar: verb, sub-verb, arguments; "double quotes" with \" and \\ escapes.
class CommandError extends Error {
  constructor(code, message) { super(message); this.code = code; }
}

function tokenize(line) {
  const tokens = [];
  let i = 0;
  while (i < line.length) {
    if (line[i] === " " || line[i] === "\t") { i++; continue; }
    let t = "";
    if (line[i] === '"') {
      for (i++; ; ) {
        if (i >= line.length) throw new CommandError("E_SYNTAX", "unterminated quote");
        const c = line[i++];
        if (c === '"') break;
        t += c === "\\" && i < line.length ? line[i++] : c;
      }
    } else while (i < line.length && !/[ \t"]/.test(line[i])) t += line[i++];
    tokens.push(t);
  }
  return tokens;
}
// Quote on the way out, so every response can be read back by the same tokenizer.
const quote = (s) => (/^[\w.:@-]+$/.test(s) ? s : `"${s.replace(/[\\"]/g, "\\$&")}"`);

const int = (name, lo, hi) => ({ hint: `<${name} ${lo}..${hi}>`, parse(s) {
  if (!/^\d+$/.test(s) || +s < lo || +s > hi) throw new CommandError("E_RANGE", `${name} must be ${lo}..${hi}, got ${quote(s)}`);
  return +s;
} });
const text = (name, max) => ({ hint: `"<${name}>"`, parse(s) {
  if (s.length > max) throw new CommandError("E_RANGE", `${name} is longer than ${max} characters`);
  return s;
} });

const state = { volume: 30, name: "Room 1", calls: 0 };
const commands = {
  volume: {
    get: { args: [], run: () => ["volume", state.volume] },
    set: { args: [int("level", 0, 50)], run: ([v]) => ["volume", (state.volume = v)] },
  },
  name: {
    get: { args: [], run: () => ["name", state.name] },
    set: { args: [text("name", 32)], run: ([v]) => ["name", (state.name = v)] },
  },
  call: { dial: { args: [text("address", 128)], run: ([to]) => ["call", ++state.calls, "dialing", to] } },
};
const usage = (verb) => Object.entries(commands[verb]).map(([sub, c]) => [verb, sub, ...c.args.map((a) => a.hint)].join(" ")).join(" | ");

function execute(line, format) {
  try {
    const [verb = "", sub = "", ...args] = tokenize(line);
    const v = verb.toLowerCase(), s = sub.toLowerCase();             // verbs are case-insensitive
    const group = Object.hasOwn(commands, v) && commands[v];        // not "constructor", "__proto__"
    if (!group) throw new CommandError("E_UNKNOWN", `unknown command ${quote(verb)}`);
    const cmd = Object.hasOwn(group, s) && group[s];
    if (!cmd || args.length !== cmd.args.length) throw new CommandError("E_USAGE", `usage: ${usage(v)}`);
    const result = cmd.run(cmd.args.map((a, k) => a.parse(args[k])));  // values keep their case
    return format === "json" ? JSON.stringify({ ok: true, result }) : result.map(String).map(quote).join(" ");
  } catch (e) {
    if (!(e instanceof CommandError)) throw e;
    return format === "json" ? JSON.stringify({ ok: false, code: e.code, message: e.message }) : `error: ${e.code} ${e.message}`;
  }
}

const session = [
  "volume set 42", "VOLUME GET", "volume set 70", "volume set loud", "volume",
  'name set "Board Room \\"East\\""', "name get", 'name set "Lobby', "reboot now", "constructor",
  "call dial sip:alice@example.com",
];
for (const line of session) console.log(`> ${line}\n< ${execute(line, "text")}`);
console.log("-- same commands, machine format --");
for (const line of ["volume get", "volume set 70"]) console.log(`> ${line}\n< ${execute(line, "json")}`);
console.log(`round trip: ${JSON.stringify(tokenize(execute("name get", "text")))}`);
Result
> volume set 42
< volume 42
> VOLUME GET
< volume 42
> volume set 70
< error: E_RANGE level must be 0..50, got 70
> volume set loud
< error: E_RANGE level must be 0..50, got loud
> volume
< error: E_USAGE usage: volume get | volume set <level 0..50>
> name set "Board Room \"East\""
< name "Board Room \"East\""
> name get
< name "Board Room \"East\""
> name set "Lobby
< error: E_SYNTAX unterminated quote
> reboot now
< error: E_UNKNOWN unknown command reboot
> constructor
< error: E_UNKNOWN unknown command constructor
> call dial sip:alice@example.com
< call 1 dialing sip:alice@example.com
-- same commands, machine format --
> volume get
< {"ok":true,"result":["volume",42]}
> volume set 70
< {"ok":false,"code":"E_RANGE","message":"level must be 0..50, got 70"}
round trip: ["name","Board Room \"East\""]
What’s happening
  1. tokenize walks the line once. Outside quotes, a token is a run of characters up to a space, tab or quote. Inside "…", \" and \\ are escapes for a quote and a backslash, and reaching the end of the line without a closing quote throws E_SYNTAX unterminated quote (name set "Lobby). Every rejection is a CommandError with a code, never a raw exception.
  2. execute lower-cases only the verb and sub-verb, so VOLUME GET works, while name set "Board Room \"East\"" stores the value with its case and its quotes intact. Lookups use Object.hasOwn, so constructor is an unknown command rather than a match on Object.prototype.constructor; the testing topic shows the crash that check prevents.
  3. Arguments are parsed by their declared type: int("level", 0, 50) turns "42" into 42 and rejects 70 and loud with the same code, E_RANGE, and a message naming the argument. A missing argument (volume) is E_USAGE, and the usage line is generated from the same table that defines the commands, so help can't drift from behaviour.
  4. Success replies echo the resulting state: volume set 42 answers volume 42, the same line volume get would give. Strings go out through quote(), which adds quotes and escapes only when needed: sip:alice@example.com stays bare, Board Room "East" becomes "Board Room \"East\"".
  5. The same execute with format = "json" returns one JSON object per line: {"ok":true,"result":["volume",42]} or {"ok":false,"code":"E_RANGE",…}. The last line proves the round trip: tokenizing the text reply to name get gives back ["name","Board Room \"East\""], exactly the stored value.
  6. The point: the grammar is data-driven (one command table for parsing, validation, help and output) and every reply is machine-classifiable by its first token. Those two properties are what keep integrations working across releases.
AdvancedHuman output and machine output

Prompts, banners, colours, paging (--More--) and aligned tables are lovely at a terminal and poison for scripts: a prompt with no newline never completes a "line", a pager waits forever for a key, and a column width change breaks every regex. Common ways to serve both audiences: a session flag (format json, prompt off), separate entry points (an interactive shell versus exec of one command over SSH, as in the SSH topic), or simply never emitting decoration on the API port. Whatever you choose, document the default and never change it in an update.

AdvancedErrors as a contract

Pick a small, stable set of codes and document each one: E_SYNTAX (can't be tokenized), E_UNKNOWN (no such command), E_USAGE (wrong number or shape of arguments), E_RANGE (value out of range), E_STATE (valid but not now: dialling while in a call), E_FORBIDDEN (role too low), E_BUSY (session or resource limit). Scripts branch on the code; the message after it is for humans and may be reworded or translated. That's the same split as type versus detail in HTTP problem details (see Errors with problem details), and the shared command layer later in this chapter maps one onto the other.

AdvancedChanging a grammar without breaking integrations

The rules from Rules for compatible change apply line by line: add new commands and new optional arguments; never change the meaning or format of an existing reply; never reuse a code for something else. When a command must go, publish its replacement and keep the old one working for a long deprecation period. Poly's public guide does this: the sleep form of its listen command (sleep and wake messages) is marked deprecated, with sleep register recommended instead. A help or capabilities command generated from the command table lets scripts check what a particular firmware supports instead of guessing from version numbers.

Tokenize this

Using the model's tokenizer, what tokens does this line produce?

name set "Lab \"B\" \\ 2"

Show answer

Three tokens: name, set and Lab "B" \ 2.

What’s happening
  1. name and set are bare tokens, ended by spaces.
  2. The " starts a quoted token. Lab is copied as is, including its space, because spaces only separate tokens outside quotes.
  3. \" is an escape: the backslash is dropped and a literal " is kept, twice, giving Lab "B". Then a space is copied.
  4. \\ is an escaped backslash: one \ is kept. Then 2 is copied, and the final unescaped " closes the token: Lab "B" \ 2.
  5. If the reply quoted it back with quote(), it would print "Lab \"B\" \\ 2", which tokenizes to the same value. That round trip is the property worth testing.

#Asynchronous notifications on a command channel

Added

So far every line from the device was an answer to a command. But a device also has news of its own: a call connects, someone presses mute on the table microphone, the system goes to sleep. A control panel on the wall needs to show that within a second, and asking "are we in a call?" every second (polling) costs traffic and still lags. So command channels let a session subscribe, and the device then writes notification lines on the same connection whenever something happens, in between the answers to commands.

Poly's public guide describes both ways to get status, registrations or polling, and recommends registrations. It says to send registration and notification commands only once, and that registrations are written to flash memory and retained when the system restarts. Registrations are specific to the port they were made on (a registration from serial port 1 isn't sent to Telnet port 24). Its notify command registers for types such as callstatus, linestatus, mutestatus and screenchanges, answers notify callstatus success, and answers info: event/notification already active:callstatus if you register again. Its example notification lines are colon-separated, such as notification:mutestatus:near:near:near:near:muted and a notification:callstatus:outgoing:…:connected:… line carrying the call id, the far site's name and address, the state and the speed. (Poly Studio G62 with VideoOS 4.3.0 Integrator Guide, HP, P03707-002A, May 2024, read 2026-10-27.)

The mental model: the connection is a radio channel shared between your conversation and the announcements. Your answers come back in the order you asked, but at any moment the device may cut in with an announcement. That's fine as long as every announcement starts with "attention all stations", so you never take one for your answer. A client that assumes "the next line is my answer" will, sooner or later, take a notification as an answer and then be off by one for the rest of the session.

One socket, lines in arrival order notify callstatus success notification:callstatus:…:connecting notification:callstatus:…:connected volume 30 mute off Notification handler update the UI, log, react Pending commands (FIFO) 1 notify callstatus 2 volume get 3 mute get Rule: a line that starts with notification: is news; any other line answers the oldest command still waiting.
Demultiplexing one stream: notifications are recognised by their prefix; replies are matched to commands by order, which works because the device answers each session's commands in the order it received them.

The model has a device with a call, three sessions, and two kinds of client: a naive one that takes the next line as its answer, and a demultiplexing one. Another session starts the call, and the far end answers while nobody is sending anything:

notifications.mjs
import net from "node:net";
import { EventEmitter } from "node:events";

// The device: one call that moves connecting -> connected when the far end answers.
const device = new EventEmitter();
device.volume = 30;
device.dial = (to) => { device.call = { id: 1, to }; device.emit("callstatus", "connecting"); };
device.answer = () => device.emit("callstatus", "connected");

let live = 0;
const server = net.createServer((socket) => {
  live++;
  socket.setEncoding("utf8");                                     // decoder keeps split characters whole
  const subs = new Set();
  const send = (line) => socket.write(line + "\r\n");
  const onCall = (state) => send(`notification:callstatus:outgoing:${device.call.id}:${device.call.to}:${state}`);
  socket.on("close", () => {
    device.off("callstatus", onCall);                             // or every closed session leaks a listener
    if (--live === 0) server.close(() => console.log(`sessions closed, listeners left: ${device.listenerCount("callstatus")}`));
  });
  let rest = "";
  socket.on("data", (text) => {
    const lines = (rest + text).split("\r\n");
    rest = lines.pop();
    for (const line of lines) {
      const [verb, arg] = line.split(" ");
      if (verb === "notify" && subs.has(arg)) send(`info: already registered:${arg}`);
      else if (verb === "notify") { subs.add(arg); device.on("callstatus", onCall); send(`notify ${arg} success`); }
      else if (verb === "dial") { device.dial(arg); send(`dial ${arg} ok`); }   // event fires BEFORE the reply
      else if (verb === "volume") send(`volume ${device.volume}`);
      else if (verb === "mute") send("mute off");
    }
  });
});

// The transport both clients share: hands every line from the device to onLine.
function open(port, onLine) {
  const socket = net.connect(port);
  let rest = "";
  socket.setEncoding("utf8");
  socket.on("data", (t) => { const l = (rest + t).split("\r\n"); rest = l.pop(); l.forEach(onLine); });
  return socket;
}
// Naive: "the next line after my command is my answer".
function naiveClient(port) {
  const inbox = [], waiting = [];
  const socket = open(port, (l) => (waiting.length ? waiting.shift()(l) : inbox.push(l)));
  const next = () => (inbox.length ? Promise.resolve(inbox.shift()) : new Promise((r) => waiting.push(r)));
  return { socket, send: (cmd) => (socket.write(cmd + "\r\n"), next()) };
}
// Demultiplexing: notifications go to a handler; other lines answer the oldest command, in order.
function demuxClient(port, onNotification) {
  const pending = [];
  const socket = open(port, (l) => (l.startsWith("notification:") ? onNotification(l) : pending.shift()(l)));
  return { socket, send: (cmd) => new Promise((r) => { pending.push(r); socket.write(cmd + "\r\n"); }) };
}

server.listen(0, "127.0.0.1", async () => {
  const { port } = server.address();
  const seen = [];
  const naive = naiveClient(port);
  const smart = demuxClient(port, (n) => seen.push(n));
  const other = demuxClient(port, () => {});
  const show = async (who, c, cmd) => console.log(`${who.padEnd(6)} > ${cmd.padEnd(32)} < ${await c.send(cmd)}`);

  await show("naive", naive, "notify callstatus");
  await show("demux", smart, "notify callstatus");
  await show("demux", smart, "notify callstatus");
  await show("other", other, "dial sip:bob@example.com");      // another session starts a call
  device.answer();                                            // far end answers: a second event
  await show("naive", naive, "volume get");
  await show("naive", naive, "mute get");
  await show("demux", smart, "volume get");
  await show("demux", smart, "mute get");
  console.log(`demux client's notification handler got:\n  ${seen.join("\n  ")}`);
  for (const c of [naive, smart, other]) c.socket.end();
});
Result
naive  > notify callstatus                < notify callstatus success
demux  > notify callstatus                < notify callstatus success
demux  > notify callstatus                < info: already registered:callstatus
other  > dial sip:bob@example.com         < dial sip:bob@example.com ok
naive  > volume get                       < notification:callstatus:outgoing:1:sip:bob@example.com:connecting
naive  > mute get                         < notification:callstatus:outgoing:1:sip:bob@example.com:connected
demux  > volume get                       < volume 30
demux  > mute get                         < mute off
demux client's notification handler got:
  notification:callstatus:outgoing:1:sip:bob@example.com:connecting
  notification:callstatus:outgoing:1:sip:bob@example.com:connected
sessions closed, listeners left: 0
What’s happening
  1. Both naive and demux send notify callstatus, and the server adds the session's onCall function as a listener on the device's callstatus event. The second notify from demux gets info: already registered:callstatus instead of a second listener: registering is idempotent, so a client can safely re-register after every reconnect.
  2. other (not subscribed) sends dial sip:bob@example.com. Inside the handler, device.dial emits connecting before the server writes dial … ok, so both subscribed sessions receive the notification line first. other just gets its answer. Then device.answer() emits connected while no command is in flight; the line is written to both subscribers immediately.
  3. The naive client now sends volume get and takes the next line: it's the connecting notification, already waiting in its inbox. Its mute get gets the connected notification. From here on every answer is shifted by two lines, and nothing in the client knows. This is the classic integration bug on command channels.
  4. The demux client routes every line by its prefix: lines starting with notification: go to onNotification (they're collected in seen), and any other line resolves the oldest pending command. It gets volume 30 and mute off, the right answers, and its handler got both call-state changes in order.
  5. On close, each session removes its own listener (device.off), and the final line confirms listeners left: 0. Without that, every closed session would leave a function that writes to a dead socket, and a device running for months would leak memory and CPU on every event.
  6. Look at the notification line itself: notification:callstatus:outgoing:1:sip:bob@example.com:connecting. The address contains colons, so a client that splits on : gets the wrong fields. The gotcha below is about that.
AdvancedTagged requests for out-of-order replies

FIFO matching only works if the device answers each session's commands in order. As soon as one command is slow (a network diagnostic, a log export), either everything behind it waits, or replies come back out of order and FIFO matching breaks. The usual fix is a tag chosen by the client and echoed in the reply, like JSON-RPC's id:

tagged.mjs
import net from "node:net";

// Tagged requests: the client numbers each command; the reply carries the same tag, so replies
// may come back in any order and notifications (untagged) can't be mistaken for answers.
let finishDiag;
const server = net.createServer((socket) => {
  socket.setEncoding("utf8");
  const send = (line) => socket.write(line + "\r\n");
  let rest = "";
  socket.on("data", (text) => {
    const lines = (rest + text).split("\r\n");
    rest = lines.pop();
    for (const line of lines) {
      const [, tag, cmd] = line.match(/^#(\d+) (.*)$/) ?? [];
      if (!tag) { send("error: E_SYNTAX missing #tag"); continue; }
      if (cmd === "diag run") finishDiag = () => {      // slow: answers later
        send("notification:diag:complete");
        send(`#${tag} diag passed 12/12`);
      };
      else if (cmd === "volume get") send(`#${tag} volume 30`);
      else send(`#${tag} error: E_UNKNOWN ${cmd}`);
    }
  });
});

function taggedClient(port, onNotification) {
  const socket = net.connect(port);
  const pending = new Map();
  let nextTag = 1, rest = "";
  socket.setEncoding("utf8");
  socket.on("data", (t) => {
    const lines = (rest + t).split("\r\n");
    rest = lines.pop();
    for (const l of lines) {
      const [, tag, body] = l.match(/^#(\d+) (.*)$/) ?? [];
      if (tag && pending.has(tag)) { pending.get(tag)(body); pending.delete(tag); }
      else onNotification(l);
    }
  });
  const send = (cmd) => new Promise((resolve) => {
    const tag = String(nextTag++);
    pending.set(tag, resolve);
    socket.write(`#${tag} ${cmd}\r\n`);
    console.log(`sent     #${tag} ${cmd}`);
  });
  return { send, socket, inFlight: () => pending.size };
}

server.listen(0, "127.0.0.1", async () => {
  const c = taggedClient(server.address().port, (n) => console.log(`untagged ${n}`));
  const diag = c.send("diag run").then((r) => console.log(`answer   diag run -> ${r}`));
  const vol = await c.send("volume get");
  console.log(`answer   volume get -> ${vol}   (in flight: ${c.inFlight()})`);
  finishDiag();                                    // the slow command completes after the fast one
  await diag;
  console.log(`in flight: ${c.inFlight()}`);
  c.socket.end();
  server.close();
});
Result
sent     #1 diag run
sent     #2 volume get
answer   volume get -> volume 30   (in flight: 1)
untagged notification:diag:complete
answer   diag run -> diag passed 12/12
in flight: 0
What’s happening
  1. The client numbers commands itself: #1 diag run, #2 volume get, and stores a resolver per tag in a Map. The tag is the client's, so two clients can both use #1 without confusion: each tag is only meaningful within its own session.
  2. The server answers volume get immediately as #2 volume 30. The diagnostic is slow (here it finishes when the script calls finishDiag()), so its reply isn't sent yet.
  3. The client receives #2 …, looks up tag 2 and resolves the volume get promise while #1 is still in flight. With FIFO matching this reply would have been handed to diag run.
  4. When the diagnostic finishes, the device writes notification:diag:complete (no tag, so it goes to the notification handler) and then #1 diag passed 12/12, which resolves the first promise. In flight drops to 0.
  5. Tags cost a few bytes per line and make the protocol robust to concurrency inside the device. The price is that old scripts which don't send tags need a compatibility path; the model's server simply rejects untagged lines with E_SYNTAX, which you'd only do on a new protocol.
AdvancedOrdering and slow readers

Two decisions to make and document. First, ordering: when a command causes an event (dialling causes connecting), does the notification come before or after the command's reply? Either is defensible; the model writes it first. What breaks clients is when it varies. Second, slow readers: if a client stops reading, socket.write keeps buffering in the device's memory (it returns false once the buffer passes its high-water mark, but it doesn't refuse the data). For state-like notifications (mute on/off, volume level) the right policy is usually latest value wins: keep one pending value per type and drop older ones. For event-like ones you cap the buffer (socket.writableLength) and disconnect the session with a clear reason when it's exceeded, and the client resyncs on reconnect by re-registering and reading current state. Chapter 7 covers the same problem for WebSockets and SSE (see Constrained devices and many clients).

Which line is the answer?

A demultiplexing client has two commands pending, volume get then mute get, and receives these lines in this order: notification:mutestatus:near:muted, volume 30, mute on. Which line answers which command, and what does a naive "next line" client report for volume get?

Show answer

volume 30 answers volume get, mute on answers mute get, and the notification goes to the handler. The naive client reports notification:mutestatus:near:muted as the volume.

What’s happening
  1. The first line starts with notification:, so the demux client hands it to its notification handler and leaves the pending queue alone.
  2. volume 30 doesn't, so it resolves the oldest pending command, volume get. The queue now holds only mute get.
  3. mute on resolves mute get. Note that the notification and the answer agree that the room is muted; one is news, the other is an answer.
  4. The naive client resolved volume get with the first line that arrived, the mute notification, and will now report volume 30 as the answer to mute get. One unexpected line shifts every later answer.

#SSH as the secure alternative

Added

Telnet sends everything, including the password, as plain bytes that anyone on the path can read and change. SSH (the Secure Shell, RFCs 4251–4254, 2006) solves exactly that. It has three layers: a transport that negotiates encryption and proves the server's identity with its host key; user authentication (a password, sent inside the encrypted transport, or better a public key, where the client proves it holds a private key without sending it); and a connection protocol that multiplexes channels over the one TCP connection. A session channel can then ask for a shell (an interactive session), an exec (run one command and report an exit status), or a subsystem (a named service such as SFTP, or NETCONF on network devices). Poly's Video Mode Administrator Guide 4.4.0 describes the command-line API on SSH port 22 as the option "if you want encrypted access".

The mental model: Telnet is a postcard; SSH is a sealed courier pouch with ID checks at both ends. The host key is the courier company's badge (you check it's the same courier as last time), the client key is your signature, and inside the pouch you can carry several separate conversations at once.

For a device, SSH brings two decisions Telnet never forced. The host key is the device's identity: generate it on first boot, unique per device, and give admins a way to see and record its fingerprint. And exec versus shell: automation should use exec (one command, clean stdout and stderr, an exit code, no prompts), while humans get a shell. The ssh2 package (1.17) implements both server and client in Node. In this model the device's host key and the admin's key are generated with node:crypto as P-256 keys in SEC1 PEM (BEGIN EC PRIVATE KEY), a format ssh2's key parser accepts:

ssh-device.mjs
import { generateKeyPairSync, createHash, timingSafeEqual } from "node:crypto";
import ssh2 from "ssh2";
const { Server, Client, utils } = ssh2;

// Keys made with node:crypto: P-256 in SEC1 PEM ("BEGIN EC PRIVATE KEY"), a format ssh2 reads.
const newKey = () => generateKeyPairSync("ec", { namedCurve: "prime256v1",
  privateKeyEncoding: { type: "sec1", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } }).privateKey;
const hostKey = newKey();          // the device's identity, made once at first boot
const adminKey = newKey();         // the admin's key; the device stores only its public half
const authorized = utils.parseKey(adminKey);
const fingerprint = (blob) => createHash("sha256").update(blob).digest("base64");
const pinned = fingerprint(utils.parseKey(hostKey).getPublicSSH());   // what the client trusts

const device = { volume: 30 };
function run(line) {                                   // stand-in for the shared command layer
  const [verb, sub, arg] = line.trim().split(/\s+/);
  if (verb === "volume" && sub === "get") return { out: `volume ${device.volume}` };
  if (verb === "volume" && sub === "set" && /^\d+$/.test(arg) && +arg <= 50) return { out: `volume ${(device.volume = +arg)}` };
  return { err: `error: E_USAGE ${line.trim()}`, code: 2 };
}

const server = new Server({ hostKeys: [hostKey] }, (client) => {
  client.on("authentication", (ctx) => {
    const keyOk = ctx.method === "publickey" && ctx.username === "admin" && ctx.key.algo === authorized.type
      && ctx.key.data.length === authorized.getPublicSSH().length && timingSafeEqual(ctx.key.data, authorized.getPublicSSH());
    if (!keyOk) return ctx.reject(["publickey"]);      // passwords are not offered at all
    if (!ctx.signature) return ctx.accept();           // "would you take this key?" probe
    authorized.verify(ctx.blob, ctx.signature, ctx.hashAlgo) === true ? ctx.accept() : ctx.reject();
  });
  client.on("ready", () => client.on("session", (accept) => {
    const session = accept();
    session.on("exec", (accept, reject, info) => {     // one command, an exit status, done
      const ch = accept();
      const r = run(info.command);
      r.out ? ch.write(r.out + "\n") : ch.stderr.write(r.err + "\n");
      ch.exit(r.code ?? 0);
      ch.end();
    });
    session.on("shell", (accept) => {                  // a long-lived, interactive session
      const ch = accept();
      let rest = "";
      ch.write("device> ");
      ch.on("data", (d) => {
        const lines = (rest + d).split("\n");
        rest = lines.pop();
        for (const line of lines) {
          if (line.trim() === "exit") { ch.write("bye\n"); ch.exit(0); return ch.end(); }
          const r = run(line);
          ch.write(`${r.out ?? r.err}\ndevice> `);
        }
      });
    });
  }));
  client.on("error", () => {});
});

const connect = (port, opts) => new Promise((resolve, reject) => {
  const c = new Client();
  c.on("ready", () => resolve(c)).on("error", reject);
  c.connect({ host: "127.0.0.1", port, username: "admin", ...opts });
});
const exec = (c, cmd) => new Promise((resolve) => {
  c.exec(cmd, (err, stream) => {
    let out = "", errText = "", code;
    stream.on("data", (d) => (out += d)).stderr.on("data", (d) => (errText += d));
    stream.on("exit", (c) => (code = c)).on("close", () => resolve({ out, errText, code }));
  });
});

server.listen(0, "127.0.0.1", async () => {
  const { port } = server.address();
  const trustPinned = (key) => fingerprint(key) === pinned;

  await connect(port, { password: "admin", hostVerifier: trustPinned })
    .catch((e) => console.log(`password login: ${e.message}`));
  await connect(port, { privateKey: adminKey, hostVerifier: () => false })
    .catch((e) => console.log(`unknown host key: ${e.message}`));

  const c = await connect(port, { privateKey: adminKey, hostVerifier: trustPinned });
  console.log("key login: ready (host key matched the pinned fingerprint)");
  for (const cmd of ["volume get", "volume set 70"]) {
    const r = await exec(c, cmd);
    console.log(`exec ${JSON.stringify(cmd)}: stdout=${JSON.stringify(r.out)} stderr=${JSON.stringify(r.errText)} exit=${r.code}`);
  }
  const transcript = await new Promise((resolve) => c.shell(false, (err, stream) => {
    let all = "";
    stream.on("data", (d) => (all += d)).on("close", () => resolve(all));
    stream.end("volume set 42\nvolume get\nexit\n");
  }));
  console.log(`shell transcript:\n${transcript}`);
  c.end();
  server.close();
});
Result
password login: All configured authentication methods failed
unknown host key: Host denied (verification failed)
key login: ready (host key matched the pinned fingerprint)
exec "volume get": stdout="volume 30\n" stderr="" exit=0
exec "volume set 70": stdout="" stderr="error: E_USAGE volume set 70\n" exit=2
shell transcript:
device> volume 42
device> volume 42
device> bye
What’s happening
  1. generateKeyPairSync("ec", { namedCurve: "prime256v1", … type: "sec1" }) makes the device's host key and the admin's key pair. The server keeps only what it needs: the host private key, and the admin's public key (utils.parseKey(adminKey) here; on a device it would be stored from an upload). The client "pins" the host key: pinned is the SHA-256 of the host's public key blob, the same kind of fingerprint OpenSSH prints.
  2. Password login is attempted first and fails with All configured authentication methods failed: the authentication handler rejects everything that isn't the admin's public key and tells the client only publickey is allowed. Not offering passwords at all removes password guessing from the attack surface.
  3. The second attempt has the right key but a hostVerifier that returns false, standing in for "this isn't the key I expected". The client aborts during the handshake (Host denied (verification failed)) before sending any credentials. This is the protection Telnet can't give: a machine impersonating the device on the network doesn't have its host key.
  4. With a valid pin and key, auth runs in two steps: the client first asks whether this key would be accepted (ctx.signature is absent, the server says yes), then proves possession by signing session data, which the server checks with authorized.verify(ctx.blob, ctx.signature, ctx.hashAlgo). The key comparison uses timingSafeEqual so response timing doesn't leak how much of a key matched.
  5. Each exec opens a channel, runs one command and closes it: volume get returns stdout volume 30\n and exit status 0; volume set 70 returns nothing on stdout, the error on stderr, and exit 2. A script can branch on the exit code without parsing text, which is why exec is the right channel for automation.
  6. The shell channel is long-lived: the server prints a device> prompt and handles lines until exit. The transcript shows replies and prompts but not the commands, because the client asked for no terminal (shell(false, …)), so nothing echoes them. With a pty, a real shell's terminal driver echoes what you type; an ssh2-based server would have to do that itself. The prompt is for humans; scripts shouldn't have to strip it.
AdvancedReal OpenSSH client against the ssh2 server

The same ssh2 server, run as a standalone script on a local port, answers the real OpenSSH 10.3 client on macOS; this is that client's output (the port replaced by PORT, the first connection adding the device to a fresh known_hosts file):

OpenSSH client against the ssh2 server
$ ssh -p PORT -i admin_key -o IdentitiesOnly=yes admin@127.0.0.1 'volume get'
Warning: Permanently added '[127.0.0.1]:PORT' (ECDSA) to the list of known hosts.
volume 30
$ echo $?
0
$ ssh -p PORT -i admin_key admin@127.0.0.1 'volume set 70'
error: E_USAGE volume set 70
$ echo $?
2
$ ssh -p PORT -o PubkeyAuthentication=no admin@127.0.0.1 'volume get'
admin@127.0.0.1: Permission denied (publickey).
$ printf 'volume set 42\nvolume get\nexit\n' | ssh -T -p PORT -i admin_key admin@127.0.0.1
device> volume 42
device> volume 42
device> bye
$ ssh -v ... 2>&1 | grep -E 'kex: algorithm|host key algorithm|server->client cipher'
debug1: kex: algorithm: ecdh-sha2-nistp256
debug1: kex: host key algorithm: ecdsa-sha2-nistp256
debug1: kex: server->client cipher: aes128-gcm@openssh.com MAC: <implicit> compression: none
What’s happening
  1. The first connection uses StrictHostKeyChecking=accept-new (left out of the printed command for width): OpenSSH has never seen this host, records its ECDSA host key in known_hosts and continues. That's trust on first use: safe if the first connection wasn't intercepted, and every later connection is checked against it.
  2. exec behaves exactly as in the model: stdout and exit status 0 for volume get, the error text and exit status 2 for a rejected command. Real tools (ssh, Ansible, CI scripts) rely on that exit status.
  3. With public-key auth turned off, OpenSSH has nothing the server accepts and reports Permission denied (publickey), listing the only method the server offered.
  4. ssh -T pipes three lines into a shell channel with no terminal: the same transcript as the model. The -v output shows what was negotiated: ECDH on P-256 for key exchange and AES-128-GCM for the channel. SSH picks the first algorithm in the client's list that the server also supports, and on this Mac ssh -G lists ecdh-sha2-nistp256 first (ahead of mlkem768x25519-sha256 and curve25519-sha256), so that's what was used. Checking what gets negotiated with the clients your integrators actually run is part of shipping an SSH API.
AdvancedHost keys on a fleet

Each device needs its own host key, generated on the device on first boot (never baked into the firmware image, or every device shares one identity and one leaked image impersonates the whole fleet). Show the fingerprint in the admin UI and in provisioning reports so it can be recorded. The cost is the moment the key changes, for example after a factory reset. This is the real OpenSSH output when the ssh2 server came back with a new host key on the same address:

After a device's host key changes
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!
...
Host key for [127.0.0.1]:PORT has changed and you have requested strict checking.
Host key verification failed.

That warning is the system working: from the client's point of view a changed key and an attacker look the same. Fleets avoid training admins to ignore it either by recording each device's fingerprint at provisioning and distributing known_hosts entries, or by using SSH certificates (OpenSSH supports a certificate authority that signs host keys, so clients trust the CA rather than each key). For TLS certificates on the same devices, see TLS on devices.

AdvancedChannels: exec, shell, subsystem
Channel request (RFC 4254)What it isUse it for
execrun one command; stdout, stderr, exit-status; channel closesautomation, CI, monitoring checks
shellinteractive session, usually with a pty and a prompthumans; legacy scripts written for the Telnet session
subsystema named protocol over the channel (sftp, netconf)file transfer (logs, config backups), structured management

A device CLI over SSH usually offers shell for people and should offer exec for machines. If integrators' existing Telnet scripts are line-by-line conversations, the shell channel can carry exactly the same protocol, notifications included, which makes "move from Telnet to SSH" a transport change rather than a rewrite.

exec or shell for a monitoring script?

A monitoring system checks every device once a minute by running callinfo all and alerting on failures. Should it open a shell channel and type the command, or use exec? Why?

Show answer

exec.

What’s happening
  1. With exec, the device runs exactly one command and closes the channel: the script reads stdout until EOF, needs no prompt detection and no "wait until the reply looks complete" timeouts.
  2. Errors are separate: an error goes to stderr and the exit status is non-zero, so "failed" is exit != 0, not a regex over text that might be reworded in the next firmware.
  3. With shell the script must wait for the prompt, send the line, recognise where the reply ends (the next prompt), cope with banners and echo, and send exit. Every one of those is a place for a firmware update to break it.
  4. shell is right for long-lived sessions that want notifications, like a control panel; exec is right for one-shot checks.

#One command layer behind REST, Telnet and SSH

Added

A device that offers a REST API for its web UI and partners, a Telnet port for control systems and SSH for admins has three front doors. If each door has its own implementation of "set the volume", they will drift: the REST API checks the range and the Telnet command doesn't, the SSH command skips the role check, the audit log only records web changes. The fix is architectural: one command layer that owns the operations (what exists, who may run it, how arguments are validated, what gets audited, what actually happens), and thin adapters per transport that only translate syntax: HTTP paths and JSON into command calls and back, lines into command calls and back.

The mental model: a restaurant with a dining room, a phone line and a delivery app. Three ways to order, but one kitchen, one menu and one set of rules: the phone operator doesn't get to invent dishes or skip the allergy check. The adapters are waiters; they write the order down in the kitchen's format and carry the plate back in their customer's format.

Web UI, partner app HTTPS Room controller Telnet / raw TCP Admin, automation SSH REST adapter paths + JSON → command errors → problem details Telnet adapter strip IAC, frame lines login per session SSH adapter host key, key auth exec and shell → lines Command layer registry · role check · schema validation · handlers one audit log · one set of error codes Device services audio, calls, settings store, firmware events → notifications
Adapters translate syntax and handle their transport's security; everything that decides what is allowed and what happens lives once, in the command layer.

The model puts all three doors on one registry: Express for REST (exercised with supertest), a node:net line server for Telnet, and an ssh2 server whose exec channel reuses the line adapter. Validation uses zod 4.6 schemas, roles are viewer and admin, and every attempt is audited in one format:

one-command-layer.mjs
import express from "express";
import request from "supertest";
import net from "node:net";
import { generateKeyPairSync } from "node:crypto";
import ssh2 from "ssh2";
import { z } from "zod";

// ---- The command layer: one definition per operation, used by every transport ----
class CommandError extends Error {
  constructor(code, status, message) { super(message); Object.assign(this, { code, status }); }
}
const device = { volume: 30 };
const users = { "tok-admin": { name: "ana", role: "admin" }, "tok-view": { name: "raj", role: "viewer" } };
const RANK = { viewer: 1, admin: 2 };
const TITLE = { E_UNKNOWN: "Unknown command", E_FORBIDDEN: "Not allowed", E_INVALID: "Invalid argument" };
const audit = [];
const commands = {
  "volume.get": { role: "viewer", args: [], input: z.object({}), run: () => ({ level: device.volume }) },
  "volume.set": { role: "admin", args: ["level"], input: z.object({ level: z.number().int().min(0).max(50) }),
    run: ({ level }) => ({ level: (device.volume = level) }) },
};
function invoke(user, name, raw, via) {
  try {
    const cmd = Object.hasOwn(commands, name) ? commands[name] : null;
    if (!cmd) throw new CommandError("E_UNKNOWN", 404, `unknown command ${name}`);
    if (RANK[user.role] < RANK[cmd.role]) throw new CommandError("E_FORBIDDEN", 403, `${name} needs role ${cmd.role}`);
    const parsed = cmd.input.safeParse(raw);
    if (!parsed.success) {
      const i = parsed.error.issues[0];
      throw new CommandError("E_INVALID", 422, `${i.path.join(".")}: ${i.message}`);
    }
    const result = cmd.run(parsed.data);
    audit.push(`${via.padEnd(6)} ${user.name} ${name} ${JSON.stringify(raw)} -> ok`);
    return result;
  } catch (e) {
    audit.push(`${via.padEnd(6)} ${user.name} ${name} ${JSON.stringify(raw)} -> ${e.code}`);
    throw e;
  }
}

// ---- Adapter 1: REST (resource paths, JSON, problem details) ----
const app = express().use(express.json());
app.use((req, res, next) => (req.user = users[req.get("authorization")?.slice(7)]) ? next() : res.sendStatus(401));
app.get("/api/v1/audio/volume", (req, res) => res.json(invoke(req.user, "volume.get", {}, "rest")));
app.put("/api/v1/audio/volume", (req, res) => res.json(invoke(req.user, "volume.set", req.body, "rest")));
app.use((err, req, res, next) => res.status(err.status ?? 500).type("application/problem+json")
  .json({ type: `urn:device:error:${err.code}`, title: TITLE[err.code], status: err.status, detail: err.message }));

// ---- Adapter 2: lines ("volume set 42"), shared by Telnet and SSH ----
function runLine(user, line, via) {
  const [verb, sub, ...rest] = line.trim().split(/\s+/);
  const name = `${verb}.${sub}`;
  try {
    const keys = Object.hasOwn(commands, name) ? commands[name].args : [];
    const typed = rest.map((t) => (/^-?\d+$/.test(t) ? Number(t) : t));   // text -> JSON types, here only
    const result = invoke(user, name, Object.fromEntries(keys.map((k, i) => [k, typed[i]])), via);
    return { ok: true, text: `${verb} ${Object.values(result).join(" ")}` };
  } catch (e) {
    return { ok: false, text: `error: ${e.code} ${e.message}` };
  }
}
const telnet = net.createServer((sock) => {          // first line logs in, the rest are commands
  let user, rest = "";
  sock.setEncoding("utf8");
  sock.on("data", (d) => {
    const lines = (rest + d).split("\r\n");
    rest = lines.pop();
    for (const line of lines) {
      if (!user) { user = users[line.replace("login ", "")]; sock.write(user ? "ok\r\n" : "denied\r\n"); continue; }
      sock.write(runLine(user, line, "telnet").text + "\r\n");
    }
  });
});
const hostKey = generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "sec1", format: "pem" },
  publicKeyEncoding: { type: "spki", format: "pem" } }).privateKey;
const ssh = new ssh2.Server({ hostKeys: [hostKey] }, (client) => {   // password = token, to keep the model short
  let user;
  client.on("authentication", (ctx) => ((user = ctx.method === "password" && users[ctx.password]) ? ctx.accept() : ctx.reject(["password"])));
  client.on("ready", () => client.on("session", (accept) => accept().on("exec", (accept, reject, info) => {
    const ch = accept(), r = runLine(user, info.command, "ssh");
    (r.ok ? ch : ch.stderr).write(r.text + "\n");
    ch.exit(r.ok ? 0 : 1);
    ch.end();
  })));
});

// ---- The same operations through all three doors ----
const rest = async (token, level) => {
  const r = level === undefined ? await request(app).get("/api/v1/audio/volume").set("Authorization", `Bearer ${token}`)
    : await request(app).put("/api/v1/audio/volume").set("Authorization", `Bearer ${token}`).send({ level });
  return `${r.status} ${JSON.stringify(r.body)}`;
};
const telnetSession = (port, token, cmds) => new Promise((resolve) => {
  const s = net.connect(port), out = [];
  let buf = "";
  s.setEncoding("utf8");
  s.on("data", (d) => { buf += d; const l = buf.split("\r\n"); buf = l.pop(); out.push(...l); if (out.length === cmds.length + 1) s.end(); });
  s.on("close", () => resolve(out.slice(1)));
  s.write([`login ${token}`, ...cmds].map((c) => c + "\r\n").join(""));
});
const sshExec = (port, token, cmd) => new Promise((resolve) => {
  const c = new ssh2.Client();
  c.on("ready", () => c.exec(cmd, (err, st) => {
    let text = "", code;
    st.on("data", (d) => (text += d)).stderr.on("data", (d) => (text += d));
    st.on("exit", (x) => (code = x)).on("close", () => { c.end(); resolve(`exit ${code} ${text.trim()}`); });
  })).connect({ host: "127.0.0.1", port, username: "x", password: token, hostVerifier: () => true });
});

telnet.listen(0, "127.0.0.1", () => ssh.listen(0, "127.0.0.1", async () => {
  const tp = telnet.address().port, sp = ssh.address().port;
  console.log(`REST   PUT level 42 (ana)    ${await rest("tok-admin", 42)}`);
  console.log(`REST   PUT level 70 (ana)    ${await rest("tok-admin", 70)}`);
  const [t1, t2] = await telnetSession(tp, "tok-admin", ["volume set 70", "volume get"]);
  console.log(`Telnet volume set 70 (ana)   ${t1}\nTelnet volume get (ana)      ${t2}`);
  console.log(`SSH    volume set 70 (ana)   ${await sshExec(sp, "tok-admin", "volume set 70")}`);
  console.log(`SSH    volume set 10 (raj)   ${await sshExec(sp, "tok-view", "volume set 10")}`);
  console.log(`REST   PUT level 10 (raj)    ${await rest("tok-view", 10)}`);
  console.log(`REST   GET (raj)             ${await rest("tok-view")}`);
  console.log(`audit log, one format for every door:\n  ${audit.join("\n  ")}`);
  telnet.close();
  ssh.close();
}));
Result
REST   PUT level 42 (ana)    200 {"level":42}
REST   PUT level 70 (ana)    422 {"type":"urn:device:error:E_INVALID","title":"Invalid argument","status":422,"detail":"level: Too big: expected number to be <=50"}
Telnet volume set 70 (ana)   error: E_INVALID level: Too big: expected number to be <=50
Telnet volume get (ana)      volume 42
SSH    volume set 70 (ana)   exit 1 error: E_INVALID level: Too big: expected number to be <=50
SSH    volume set 10 (raj)   exit 1 error: E_FORBIDDEN volume.set needs role admin
REST   PUT level 10 (raj)    403 {"type":"urn:device:error:E_FORBIDDEN","title":"Not allowed","status":403,"detail":"volume.set needs role admin"}
REST   GET (raj)             200 {"level":42}
audit log, one format for every door:
  rest   ana volume.set {"level":42} -> ok
  rest   ana volume.set {"level":70} -> E_INVALID
  telnet ana volume.set {"level":70} -> E_INVALID
  telnet ana volume.get {} -> ok
  ssh    ana volume.set {"level":70} -> E_INVALID
  ssh    raj volume.set {"level":10} -> E_FORBIDDEN
  rest   raj volume.set {"level":10} -> E_FORBIDDEN
  rest   raj volume.get {} -> ok
What’s happening
  1. commands is the registry: each entry has a required role, positional args for line-based doors, a zod input schema and a run handler. invoke is the only way in, and it always does the same four things in the same order: does the command exist, may this user run it (RANK), is the input valid (safeParse), then run it and audit the outcome. Failures are CommandErrors with a code and an HTTP status, and they're audited too.
  2. REST: PUT /api/v1/audio/volume with { "level": 42 } calls volume.set with the JSON body as is, and the reply is the handler's result, 200 {"level":42}. With 70, zod rejects it and the error middleware renders the same CommandError as RFC 9457 problem details: 422 with type: "urn:device:error:E_INVALID", a stable title for the code and the specific detail level: Too big: expected number to be <=50 (zod's own message).
  3. Telnet: the first line logs the session in (login tok-admin); after that runLine splits volume set 70 into the command name volume.set and its positional argument, turns the token "70" into the number 70 (only the line adapter knows its input is text), and calls the same invoke. Same code, same detail: error: E_INVALID level: Too big: expected number to be <=50. volume get shows the state REST set: volume 42.
  4. SSH: the exec handler calls the same runLine, writes failures to stderr and sets exit status 1. For ana the range error is identical; for raj (a viewer) it's E_FORBIDDEN volume.set needs role admin, and the same request over REST gets 403 with the same detail. The role check exists once, so no door can forget it.
  5. The audit log has one line per attempt in one format, whatever the door: who, which command, which arguments ({"level":70} from all three, because conversion happens before validation), and the outcome. The audit trail behind Least privilege and audit logs is only trustworthy if a change through the Telnet port lands in it too.
  6. What stays in the adapters is genuinely per-transport: URL paths and JSON, problem details and status codes, line splitting and number conversion, the Telnet login line, SSH keys, stderr and exit codes. The model's SSH password-equals-token auth and hostVerifier: () => true are shortcuts to keep it short; the SSH topic shows the real thing.
AdvancedMapping the doors
CommandRESTTelnet / SSH lineError mapping
volume.getGET /api/v1/audio/volume → 200 {"level":42}volume get → volume 42—
volume.setPUT /api/v1/audio/volume {"level":42}volume set 42E_INVALID → 422 / error: E_INVALID … / exit 1
call.dialPOST /api/v1/calls {"address":…} → 202 + operationcall dial "<address>" → call 1 dialing …E_STATE → 409
device.rebootPOST /api/v1/device/reboot → 202reboot → info: rebooting, then the session closesE_FORBIDDEN → 403

Not every command maps one-to-one onto a REST resource. Long-running operations are a 202 with an operation resource on REST (see Long-running operations) and an immediate acknowledgement plus notifications on the CLI. That difference belongs in the adapters; the operation and its rules are the same.

AdvancedGotcha: coercion in the shared schema
AdvancedWhat the registry gives you for free

Because every operation is described as data, the registry can generate the CLI's help output, a capabilities command for Capability discovery, and the request schemas for the REST adapter's OpenAPI document. It's also the natural seam for tests: the command layer is tested once, thoroughly, with plain function calls; each adapter is tested for its translation only (does volume set 42 reach volume.set with { level: 42 }, does E_INVALID become 422).

#Testing and securing command interfaces

Added

A command interface is used by scripts that type at it, so test it the same way: scripted sessions that connect, send lines and check the replies, run against a simulator in CI and against real hardware in the lab. Add tests for what scripts can't be trusted to avoid: fragmented and oversized input, many sessions at once, notifications in the middle of replies, and malformed lines found by fuzzing. And secure it with the assumption that someone else is on the network: Telnet gives them everything, so it's off by default and local-only when it's on; SSH or the REST API over TLS are the supported ways in.

The mental model for testing: record the conversation you expect, then make the device have it. A transcript of > lines (sent) and < lines (expected) is readable by an integrator, a tester and a developer, and it's the same artefact you'd attach to a bug report. For security: assume the hotel Wi-Fi. Any byte that leaves the device in clear text is readable and changeable by whoever shares the network.

session-test.mjs
import net from "node:net";

// The device under test: a line server (any implementation; the test only sees the socket).
const state = { volume: 30, name: "Room 1" };
const device = net.createServer((sock) => {
  let rest = "";
  sock.setEncoding("utf8");
  sock.on("data", (d) => {
    const lines = (rest + d).split("\r\n");
    rest = lines.pop();
    for (const line of lines) {
      const m = line.match(/^(\w+) (\w+)(?: (.*))?$/) ?? [];
      const [, verb, sub, arg] = m;
      if (verb === "volume" && sub === "set") {
        state.volume = +arg;                                  // bug: stored before it is checked
        sock.write(+arg <= 50 ? `volume ${arg}\r\n` : `error: E_RANGE level must be 0..50, got ${arg}\r\n`);
      }
      else if (verb === "volume" && sub === "get") sock.write(`volume ${state.volume}\r\n`);
      else if (verb === "name" && sub === "set") sock.write(`name ${(state.name = arg)}\r\n`);
      else if (verb === "diag") {}                            // bug: never answers
      else sock.write(`error: E_UNKNOWN ${line}\r\n`);
    }
  });
});

// A transcript: "> " lines are sent, "< " lines are expected back; "*" matches any rest of line.
const transcript = `
> volume set 42
< volume 42
> volume set 70
< error: E_RANGE *
> name set "Board Room"
< name "Board Room"
> volume get
< volume 42
> diag run
< diag passed*
> reboot
< error: E_UNKNOWN reboot
`;

async function runTranscript(port, text, timeoutMs = 200) {
  const sock = net.connect(port);
  sock.setEncoding("utf8");
  const inbox = [], waiting = [];
  let rest = "";
  sock.on("data", (d) => {
    const lines = (rest + d).split("\r\n");
    rest = lines.pop();
    for (const l of lines) waiting.length ? waiting.shift()(l) : inbox.push(l);
  });
  const next = () => new Promise((resolve) => {
    if (inbox.length) return resolve(inbox.shift());
    const t = setTimeout(() => { waiting.splice(waiting.indexOf(done), 1); resolve(null); }, timeoutMs);
    const done = (l) => { clearTimeout(t); resolve(l); };
    waiting.push(done);
  });
  const steps = text.trim().split("\n");
  let n = 0, failed = 0;
  for (let i = 0; i < steps.length; i += 2) {
    const cmd = steps[i].slice(2), want = steps[i + 1].slice(2);
    sock.write(cmd + "\r\n");
    const got = await next();
    const ok = got !== null && (want.endsWith("*") ? got.startsWith(want.slice(0, -1)) : got === want);
    n++;
    if (!ok) failed++;
    console.log(ok ? `ok ${n} - ${cmd}` : `not ok ${n} - ${cmd}\n    expected: ${want}\n    got:      ${got ?? `(nothing within ${timeoutMs} ms)`}`);
  }
  sock.destroy();
  console.log(`${n - failed}/${n} passed`);
}

device.listen(0, "127.0.0.1", async () => {
  await runTranscript(device.address().port, transcript);
  device.close();
});
Result
ok 1 - volume set 42
ok 2 - volume set 70
ok 3 - name set "Board Room"
not ok 4 - volume get
    expected: volume 42
    got:      volume 70
not ok 5 - diag run
    expected: diag passed*
    got:      (nothing within 200 ms)
ok 6 - reboot
4/6 passed
What’s happening
  1. The transcript is plain text: each > line is sent, the < line after it is the expected reply, and a trailing * matches the rest of the line (for error messages whose wording may change while their code doesn't: error: E_RANGE *). The runner knows nothing about the device's implementation; it only sees the socket, so the same transcript runs against a simulator in CI and against a real device in the lab.
  2. Tests 1 to 3 pass: the set, the rejected set and the quoted name all reply as expected.
  3. Test 4 catches a real bug: after volume set 70 was rejected, volume get reports volume 70. The device stored the value before validating it. The reply to the bad command looked right; only checking state afterwards reveals it. "A rejected command doesn't change state" is one of the most valuable assertions in a device test suite.
  4. Test 5 catches a hang: diag run never answers, and the runner reports (nothing within 200 ms) instead of waiting forever. Every wait in a session test needs a timeout, and a timeout should fail loudly.
  5. Test 6 still passes because this device never sends the late diag reply. If it did, it would arrive as the "answer" to reboot and shift every later check; a robust runner reconnects after a timeout instead of carrying on in the same session.
AdvancedFuzzing the grammar

Hand-written tests check the cases you thought of. A fuzzer generates thousands you didn't, and checks invariants, properties that must hold for any input, instead of exact outputs. This one builds lines from "interesting" pieces (command words, quotes, backslashes, é, and property names like __proto__ that JavaScript lookups trip on), runs them through a naive implementation and a fixed one with the same seed, and shrinks each failure to its smallest form:

fuzz-grammar.mjs
// Grammar-aware fuzzing with a seeded PRNG: build lines from "interesting" pieces, check invariants.
const mulberry32 = (a) => () => ((a = (a + 0x6d2b79f5) | 0), (((a ^ (a >>> 15)) * (1 | a)) >>> 0) / 2 ** 32);
class CommandError extends Error { constructor(code) { super(code); this.code = code; } }
const commands = { volume: { get: { args: 0 }, set: { args: 1 } }, name: { set: { args: 1 } } };

function tokenizeNaive(line) {                  // looks fine in code review
  const out = []; let i = 0;
  while (i < line.length) {
    if (line[i] === " ") { i++; continue; }
    let t = "";
    if (line[i] === '"') {
      for (i++; i < line.length && line[i] !== '"'; ) t += line[i] === "\\" ? line[++i, i++] : line[i++];
      i++;
    } else while (i < line.length && line[i] !== " ") t += line[i++];
    out.push(t);
  }
  return out;
}
function tokenizeFixed(line) {
  const out = []; let i = 0;
  while (i < line.length) {
    if (line[i] === " ") { i++; continue; }
    let t = "";
    if (line[i] === '"') {
      for (i++; ; ) {
        if (i >= line.length) throw new CommandError("E_SYNTAX");
        const c = line[i++];
        if (c === '"') break;
        t += c === "\\" && i < line.length ? line[i++] : c;
      }
    } else while (i < line.length && line[i] !== " " && line[i] !== '"') t += line[i++];
    out.push(t);
  }
  return out;
}
const lookupNaive = (obj, k) => obj[k];
const lookupFixed = (obj, k) => (Object.hasOwn(obj, k) ? obj[k] : undefined);
const quote = (s) => (/^[\w.-]+$/.test(s) ? s : `"${s.replace(/[\\"]/g, "\\$&")}"`);

function check(tokenize, lookup, line) {          // returns the broken invariant, or null
  let tokens;
  try {
    tokens = tokenize(line);
    const [verb = "", sub = "", ...args] = tokens;
    const group = lookup(commands, verb);
    const cmd = group && lookup(group, sub);
    if (cmd && args.length !== cmd.args.length) throw new CommandError("E_USAGE");
  } catch (e) {
    if (e instanceof CommandError) return null;   // a clean, reported error is fine
    return `crash: ${e.constructor.name}`;
  }
  if (tokens.join("").length > line.length) return "tokens longer than the input";
  const again = tokenize(tokens.map(quote).join(" "));
  return JSON.stringify(again) === JSON.stringify(tokens) ? null : "round trip changed the tokens";
}

const pieces = ["volume", "set", "get", "name", "42", " ", " ", '"', "\\", "Room", "__proto__", "constructor", "é"];
function shrink(line, fails) {                    // delete characters while it still fails
  for (let i = 0; i < line.length; i++) {
    const shorter = line.slice(0, i) + line.slice(i + 1);
    if (fails(shorter)) return shrink(shorter, fails);
  }
  return line;
}
for (const [label, tokenize, lookup] of [["naive", tokenizeNaive, lookupNaive], ["fixed", tokenizeFixed, lookupFixed]]) {
  const rand = mulberry32(2026);                  // same seed, same 5,000 inputs for both
  const found = new Map();
  for (let n = 0; n < 5000; n++) {
    let line = "";
    for (let k = 1 + Math.floor(rand() * 6); k > 0; k--) line += pieces[Math.floor(rand() * pieces.length)];
    const broken = check(tokenize, lookup, line);
    if (broken && !found.has(broken)) found.set(broken, shrink(line, (l) => check(tokenize, lookup, l) === broken));
  }
  console.log(`${label}: ${found.size ? "" : "no invariant broken in 5000 inputs"}`);
  for (const [broken, line] of found) console.log(`  ${broken.padEnd(30)} smallest input: ${JSON.stringify(line)}`);
}
Result
naive: 
  crash: TypeError               smallest input: "volume __proto__"
  tokens longer than the input   smallest input: "\"\\"
fixed: no invariant broken in 5000 inputs
What’s happening
  1. mulberry32(2026) is a small seeded PRNG, so both implementations get the same 5,000 lines on every run and the result is reproducible (a fuzz failure you can't reproduce is just a rumour). Each line is 1 to 6 random pieces glued together.
  2. check enforces three invariants: the only exceptions allowed are CommandErrors (clean, reported errors); tokens can't contain more characters than the input; and quoting the tokens and tokenizing again gives the same tokens. None of them needs to know the "right" reply for a random line.
  3. The naive version crashes with a TypeError on volume __proto__: group["__proto__"] is Object.prototype, which is truthy, so it's treated as a command and cmd.args.length throws. Over a network port that's one line from any client killing the session handler, or the process, if nothing catches it. Object.hasOwn in the fixed version makes it a clean E_USAGE.
  4. The naive tokenizer turns the two-character input "\ (printed as the JSON string "\"\\") into a token containing the text undefined: at a trailing backslash it reads past the end of the line and appends undefined. The shrinker deleted characters one at a time while the failure persisted, so the reported input is minimal and goes straight into the regression suite.
  5. The fixed version breaks no invariant in 5,000 inputs. That isn't proof of correctness; it's evidence, and the fuzzer keeps running in CI with new seeds, adding each failure it ever finds as a fixed test case.
AdvancedWhat anyone on the network sees

The strongest argument for SSH is easiest to make by looking at the wire. The model puts a "tap" (a TCP proxy that keeps a copy of every byte, as a compromised switch or a shared Wi-Fi would) in front of a Telnet-style login and an SSH login with the same password:

sniff.mjs
import net from "node:net";
import { generateKeyPairSync } from "node:crypto";
import ssh2 from "ssh2";

const PASSWORD = "Rm7-north-desk";

// A "tap": forwards TCP both ways and keeps a copy of every byte, like anyone on the path could.
function tap(targetPort) {
  const seen = [];
  const proxy = net.createServer((inside) => {
    const outside = net.connect(targetPort, "127.0.0.1");
    inside.on("data", (d) => { seen.push(d); outside.write(d); });
    outside.on("data", (d) => { seen.push(d); inside.write(d); });
    inside.on("end", () => outside.end()).on("error", () => outside.destroy());
    outside.on("end", () => inside.end()).on("error", () => inside.destroy());
  });
  return { proxy, bytes: () => Buffer.concat(seen) };
}
const listen = (srv) => new Promise((r) => srv.listen(0, "127.0.0.1", () => r(srv.address().port)));

// Telnet-style login: the password crosses the network as typed.
const telnetDevice = net.createServer((s) => {
  let typed = "";
  s.write("Password: ");
  s.on("data", (d) => {                                   // framed: wait for the whole line
    typed += d;
    if (typed.includes("\n")) s.end(typed.trim() === PASSWORD ? "Welcome\r\n" : "Denied\r\n");
  });
});
// SSH: same password, inside the encrypted channel.
const hostKey = generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "sec1", format: "pem" },
  publicKeyEncoding: { type: "spki", format: "pem" } }).privateKey;
const sshDevice = new ssh2.Server({ hostKeys: [hostKey] }, (c) => {
  c.on("authentication", (ctx) => (ctx.method === "password" && ctx.password === PASSWORD ? ctx.accept() : ctx.reject(["password"])));
  c.on("error", () => {});
});

const t = tap(await listen(telnetDevice));
const telnetReply = await new Promise(async (resolve) => {
  const s = net.connect(await listen(t.proxy), "127.0.0.1");
  let text = "";
  s.on("data", (d) => { text += d; if (text.startsWith("Password: ") && text.length === 10) s.write(PASSWORD + "\r\n"); });
  s.on("close", () => resolve(text.slice(10).trim()));
});

const k = tap(await listen(sshDevice));
const sshPort = await listen(k.proxy);
const sshLoggedIn = await new Promise((resolve) => {
  const c = new ssh2.Client();
  let ready = false;
  c.on("ready", () => { ready = true; c.end(); }).on("close", () => resolve(ready));
  c.connect({ host: "127.0.0.1", port: sshPort, username: "admin", password: PASSWORD, hostVerifier: () => true });
});

const telnetBytes = t.bytes(), sshBytes = k.bytes();
console.log(`telnet: device said ${JSON.stringify(telnetReply)}; password visible on the wire: ${telnetBytes.includes(PASSWORD)}`);
console.log(`  captured: ${JSON.stringify(telnetBytes.toString())}`);
console.log(`ssh:    logged in: ${sshLoggedIn}; password visible on the wire: ${sshBytes.includes(PASSWORD)}`);
console.log(`  in clear: ${JSON.stringify(sshBytes.toString("latin1").match(/SSH-2\.0-[^\r]+/g))}, algorithm names: ${sshBytes.includes("aes128-gcm")}, user name: ${sshBytes.includes("admin")}`);
for (const srv of [telnetDevice, sshDevice, t.proxy, k.proxy]) srv.close();
Result
telnet: device said "Welcome"; password visible on the wire: true
  captured: "Password: Rm7-north-desk\r\nWelcome\r\n"
ssh:    logged in: true; password visible on the wire: false
  in clear: ["SSH-2.0-ssh2js1.17.0","SSH-2.0-ssh2js1.17.0"], algorithm names: true, user name: false
What’s happening
  1. tap(targetPort) accepts a connection, opens one to the real server, and forwards bytes both ways while pushing every chunk into seen. Neither end can tell it's there; that's the position of anyone on the network path.
  2. The Telnet-style device sends Password: , the client answers, and the device says Welcome. The capture contains the whole exchange, password included: "Password: Rm7-north-desk\r\nWelcome\r\n". Anyone who saw it can log in later, and a tap that modifies bytes could also change commands in flight.
  3. The SSH login uses the same password and succeeds, but includes(PASSWORD) on the captured bytes is false: the password, and even the user name (admin), travel inside the encrypted transport. What is readable is what SSH sends before encryption starts: each side's identification string, SSH-2.0-ssh2js1.17.0, and the lists of algorithms each side offers in its key-exchange message (KEXINIT), such as aes128-gcm. Even that tells anyone watching which SSH software and version the device runs.
  4. The model's SSH client accepts any host key (hostVerifier: () => true), so this tap only watches. If it impersonated the server instead, the client would only notice with a pinned or known host key; encryption without host verification protects against eavesdropping, not against a man in the middle.
AdvancedSecuring the command ports

Most of this is the general hardening checklist (see Hardening), applied to command channels:

  • Telnet off by default; local only when on. Ship with the Telnet port closed, enable it per device for integrations that need it, and bind it to the management network or VLAN, never to every interface. Say in the admin UI and docs that it's unencrypted.
  • Prefer SSH with keys, and REST over TLS. Offer the same commands over SSH so moving off Telnet is a transport change; support public keys and, where you can, let admins turn passwords off.
  • Limit guessing across sessions, not just within one. Poly's Video Mode Administrator Guide 4.4.0 is a useful public example of why: a Telnet session disconnects after three failed login attempts, a new session gets another three, and other than disabling it, Telnet access can't be restricted in other ways, such as locking the port after too many failures (which the same guide offers for web and SSH access). A per-session limit alone leaves unlimited guesses at the speed of reconnecting; count failures per source and per account, with back-off or lockout.
  • Authenticate by default. The same guide says a password is required for Telnet by default, and that the requirement can be turned off. If your product allows that, warn loudly and log it.
  • Bound everything: sessions (maxConnections), idle time, line length, output buffered for slow readers, commands per second per session.
  • Audit and don't echo secrets. Every state-changing command from every door goes to the same audit log with the user and the transport; passwords, keys and tokens are never echoed or logged.
  • Fuzz and scan in CI. Run the fuzzer and session transcripts on every build, and scan the built image for open ports, so a debug Telnet listener can't ship by accident.
Three tries per session

A device disconnects a Telnet session after three failed passwords, and a new session gets three more tries. Connecting and failing three times takes about one second. Roughly how many passwords can an attacker on the network try per day, and what would you change?

Show answer

About 260,000 a day from one connection loop (3 per second × 86,400 seconds), more with parallel sessions.

What’s happening
  1. Each cycle is connect, three wrong passwords, disconnect: three guesses per second.
  2. A day has 86,400 seconds, so 3 × 86,400 = 259,200 guesses, from a single loop. With the session limit allowing several parallel connections, multiply by that.
  3. The per-session limit only slows the attacker down by the cost of a reconnect. What actually limits guessing is state that survives the session: failures counted per source address and per account, with growing delays or a temporary lockout, and an alert.
  4. The better answers remove the problem: Telnet off or bound to a management network, and SSH with public keys, where there's no password to guess.

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.