#What Telnet is and why devices still expose it
AddedTelnet 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
notifycommand) 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):
| Byte | Name | Meaning |
|---|---|---|
FF (255) | IAC | the 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 / SE | start / end of a subnegotiation: IAC SB <option> <parameters> IAC SE |
01 | ECHO option (RFC 857) | who echoes typed characters; the default is nobody echoing over the connection |
03 | SUPPRESS-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.
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:
// 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)}`);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"- On connect the server makes one offer,
FF FB 03(IAC WILL SGA), and remembers inpendingthat it expectsDO SGAback. 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. - Chunk 1 is
FF FD 03 FF FB: a completeIAC DO SGAand then half of the next command. The parser seesFF(go to stateiac),FD(a verb, go toopt),03(the option) and callsnegotiate(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. ThenFF FBleaves the parser in stateopt, waiting for one more byte. - Chunk 2 starts with
1F, which completesIAC WILL NAWSacross the chunk boundary. The client offers its window size; our policy wants it, so we replyFF FD 1F(IAC DO NAWS).IAC WILL TTYPE(FF FB 18) is refused withFF FE 18(DONT), andIAC DO ECHO(FF FD 01, "please echo for me") is refused withFF FC 01(WONT). Refusing is always safe: the NVT default is what's left. - 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 statesband only acts onIAC SE: width0x0050= 80, height0x0018= 24. The same chunk then continues straight into text. - 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. - 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.
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.
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.
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.
FF FD 01isIAC DO ECHO: the client asks the server to start echoing its characters. A line-mode API doesn't want to echo, so it refuses.- A refusal to
DOisWONT, 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. FF FE 01isIAC DONT ECHO: "don't echo". The server is already not echoing, so this asks for the mode it's already in.- RFC 854's loop rule: a request to enter a mode you're already in is not acknowledged. Replying
WONT ECHOagain could bounce back and forth forever, so the correct answer is silence.