@andste82/telnet-tty
Version:
TTY interface for Telnet sessions
507 lines (431 loc) • 11.6 kB
JavaScript
import { EventEmitter } from 'events';
import { createServer } from 'node:net';
import { Duplex } from 'node:stream';
import hexdump from 'hexdump-nodejs';
const COMMANDS =
{
SE: 0xF0, // end of subnegotiation parameters
NOP: 0xF1, // no operation
DM: 0xF2, // data mark
BRK: 0xF3, // break
IP: 0xF4, // suspend (a.k.a. "interrupt process")
AO: 0xF5, // abort output
AYT: 0xF6, // are you there?
EC: 0xF7, // erase character
EL: 0xF8, // erase line
GA: 0xF9, // go ahead
SB: 0xFA, // subnegotiation
WILL: 0xFB, // will
WONT: 0xFC, // wont
DO: 0xFD, // do
DONT: 0xFE, // dont
IAC: 0xFF // interpret as command
};
const COMMAND_NAMES = Object.keys(COMMANDS).reduce((out, key) =>
{
const value = COMMANDS[key];
out[value] = key.toLowerCase();
return out;
}, {});
const OPTIONS =
{
TRANSMIT_BINARY: 0x00, // http://tools.ietf.org/html/rfc856
ECHO: 0x01, // http://tools.ietf.org/html/rfc857
RECONNECT: 0x02, // http://tools.ietf.org/html/rfc671
SUPPRESS_GO_AHEAD: 0x03, // http://tools.ietf.org/html/rfc858
AMSN: 0x04, // Approx Message Size Negotiation
// https://google.com/search?q=telnet+option+AMSN
STATUS: 0x05, // http://tools.ietf.org/html/rfc859
TIMING_MARK: 0x06, // http://tools.ietf.org/html/rfc860
RCTE: 0x07, // http://tools.ietf.org/html/rfc563
// http://tools.ietf.org/html/rfc726
NAOL: 0x08, // (Negotiate) Output Line Width
// https://google.com/search?q=telnet+option+NAOL
// http://tools.ietf.org/html/rfc1073
NAOP: 0x09, // (Negotiate) Output Page Size
// https://google.com/search?q=telnet+option+NAOP
// http://tools.ietf.org/html/rfc1073
NAOCRD: 0x0A, // http://tools.ietf.org/html/rfc652
NAOHTS: 0x0B, // http://tools.ietf.org/html/rfc653
NAOHTD: 0x0C, // http://tools.ietf.org/html/rfc654
NAOFFD: 0x0D, // http://tools.ietf.org/html/rfc655
NAOVTS: 0x0E, // http://tools.ietf.org/html/rfc656
NAOVTD: 0x0F, // http://tools.ietf.org/html/rfc657
NAOLFD: 0x10, // http://tools.ietf.org/html/rfc658
EXTEND_ASCII: 0x11, // http://tools.ietf.org/html/rfc698
LOGOUT: 0x12, // http://tools.ietf.org/html/rfc727
BM: 0x13, // http://tools.ietf.org/html/rfc735
DET: 0x14, // http://tools.ietf.org/html/rfc732
// http://tools.ietf.org/html/rfc1043
SUPDUP: 0x15, // http://tools.ietf.org/html/rfc734
// http://tools.ietf.org/html/rfc736
SUPDUP_OUTPUT: 0x16, // http://tools.ietf.org/html/rfc749
SEND_LOCATION: 0x17, // http://tools.ietf.org/html/rfc779
TERMINAL_TYPE: 0x18, // http://tools.ietf.org/html/rfc1091
END_OF_RECORD: 0x19, // http://tools.ietf.org/html/rfc885
TUID: 0x1A, // http://tools.ietf.org/html/rfc927
OUTMRK: 0x1B, // http://tools.ietf.org/html/rfc933
TTYLOC: 0x1C, // http://tools.ietf.org/html/rfc946
REGIME_3270: 0x1D, // http://tools.ietf.org/html/rfc1041
X3_PAD: 0x1E, // http://tools.ietf.org/html/rfc1053
NAWS: 0x1F, // http://tools.ietf.org/html/rfc1073
TERMINAL_SPEED: 0x20, // http://tools.ietf.org/html/rfc1079
TOGGLE_FLOW_CONTROL: 0x21, // http://tools.ietf.org/html/rfc1372
LINEMODE: 0x22, // http://tools.ietf.org/html/rfc1184
X_DISPLAY_LOCATION: 0x23, // http://tools.ietf.org/html/rfc1096
ENVIRON: 0x24, // http://tools.ietf.org/html/rfc1408
AUTHENTICATION: 0x25, // http://tools.ietf.org/html/rfc2941
// http://tools.ietf.org/html/rfc1416
// http://tools.ietf.org/html/rfc2942
// http://tools.ietf.org/html/rfc2943
// http://tools.ietf.org/html/rfc2951
ENCRYPT: 0x26, // http://tools.ietf.org/html/rfc2946
NEW_ENVIRON: 0x27, // http://tools.ietf.org/html/rfc1572
TN3270E: 0x28, // http://tools.ietf.org/html/rfc2355
XAUTH: 0x29, // https://google.com/search?q=telnet+option+XAUTH
CHARSET: 0x2A, // http://tools.ietf.org/html/rfc2066
RSP: 0x2B, // http://tools.ietf.org/html/draft-barnes-telnet-rsp-opt-01
COM_PORT_OPTION: 0x2C, // http://tools.ietf.org/html/rfc2217
SLE: 0x2D, // http://tools.ietf.org/html/draft-rfced-exp-atmar-00
START_TLS: 0x2E, // http://tools.ietf.org/html/draft-altman-telnet-starttls-02
KERMIT: 0x2F, // http://tools.ietf.org/html/rfc2840
SEND_URL: 0x30, // http://tools.ietf.org/html/draft-croft-telnet-url-trans-00
FORWARD_X: 0x31, // http://tools.ietf.org/html/draft-altman-telnet-fwdx-01
PRAGMA_LOGON: 0x8A, // https://google.com/search?q=telnet+option+PRAGMA_LOGON
SSPI_LOGON: 0x8B, // https://google.com/search?q=telnet+option+SSPI_LOGON
PRAGMA_HEARTBEAT: 0x8C, // https://google.com/search?q=telnet+option+PRAMGA_HEARTBEAT
EXOPL: 0xFF // http://tools.ietf.org/html/rfc861
};
var OPTION_NAMES = Object.keys(OPTIONS).reduce((out, key) =>
{
const value = OPTIONS[key];
out[value] = key.toLowerCase();
return out;
}, {});
const SUB =
{
IS: 0,
SEND: 1,
INFO: 2,
VARIABLE: 0,
VALUE: 1,
ESC: 2, // unused, for env
USER_VARIABLE: 3
};
const DECODE_IDLE = 0;
const DECODE_COMMAND = 1;
const DECODE_OPTION = 2;
const DECODE_SUBNEGOTIATION = 3;
const DECODE_SUBNEGOTIATION_END = 4;
const DECODE_IGNORE_2 = 5;
const DECODE_IGNORE_1 = 6;
const log = (...args) =>
{
// console.log(...args);
};
const loge = (...args) =>
{
// console.error(...args);
};
const logh = (name, arg) =>
{
// console.log(name);
// console.log(hexdump(arg));
};
export class TelnetSession extends Duplex
{
#socket;
#send;
#s2c = {}; // send commands from server to client
#c2s = {}; // handler for commands sent from client
#isRaw = true;
#isTTY = true;
#columns = 80;
#rows = 24;
constructor(socket)
{
super();
this.#socket = socket;
this.#send = (data, encoding, callback) =>
{
logh("TX", data);
this.#socket.write(data, encoding, callback);
};
['DO', 'DONT', 'WILL', 'WONT'].forEach((commandName) =>
{
const cmdName = commandName.toLowerCase();
this.#s2c[cmdName] = {};
Object.keys(OPTIONS).forEach((optionName) =>
{
const optName = optionName.toLowerCase();
this.#s2c[cmdName][optName] = () =>
{
const buf = Buffer.alloc(3);
buf[0] = COMMANDS.IAC;
buf[1] = COMMANDS[commandName];
buf[2] = OPTIONS[optionName];
this.#send(buf);
};
});
});
this.#c2s.naws = (data) =>
{
if (data.length < 4) return -1;
this.#columns = data.readUInt16BE(0);
this.#rows = data.readUInt16BE(2);
this.emit('resize');
};
// tty enable
this.setRawMode(true);
this.#s2c.do.transmit_binary();
this.#s2c.do.terminal_type();
this.#s2c.do.naws();
this.#s2c.do.new_environ();
let decode = DECODE_IDLE;
let command = 0;
let option = 0;
let param;
let len = 0;
this.#socket.on('data', (data) =>
{
logh("RX", data);
const buffer = Buffer.alloc(data.length);
let buflen = 0;
for (const char of data)
{
switch (decode)
{
case DECODE_IDLE:
if (char === COMMANDS.IAC)
{
// interpret as command
command = 0;
option = 0;
decode = DECODE_COMMAND;
}
else
{
// interpret as payload
buffer[buflen++] = char;
}
break;
case DECODE_COMMAND:
log(`COMMAND: ${COMMAND_NAMES[char]}`);
if (COMMAND_NAMES[char])
{
// command is known
command = char;
decode = DECODE_OPTION;
}
else
{
// command is unknown, throw await the next 2 bytes
decode = DECODE_IGNORE_2;
}
break;
case DECODE_OPTION:
log(`OPTION: ${OPTION_NAMES[char]}`);
option = char;
if (command === COMMANDS.SB)
{
// start of subnegotiation parameters
param = Buffer.alloc(128);
len = 0;
decode = DECODE_SUBNEGOTIATION;
}
else
{
const handler = this.#c2s[OPTION_NAMES[option]];
if (typeof handler === 'function')
{
handler(Buffer.from([command, option]));
}
decode = DECODE_IDLE;
}
break;
case DECODE_SUBNEGOTIATION_END:
if (char === COMMANDS.SE)
{
// end of subnegotiation parameters
const handler = this.#c2s[OPTION_NAMES[option]];
if (typeof handler === 'function')
{
handler(param.slice(0, len));
}
decode = DECODE_IDLE;
break;
}
// subnegotiation end sequence incomplete, go further ...
decode = DECODE_SUBNEGOTIATION;
// [[fallthrough]]
case DECODE_SUBNEGOTIATION:
if (char === COMMANDS.IAC)
{
// interpret as subnegotiation end sequence
decode = DECODE_SUBNEGOTIATION_END;
}
else
{
param[len] = char;
len++;
}
break;
case DECODE_IGNORE_2:
// byte ignored, ignore next byte, too
decode = DECODE_IGNORE_1;
break;
case DECODE_IGNORE_1:
// byte ignored, goto normal operation
decode = DECODE_IDLE;
break;
}
}
if (buflen)
{
this.emit('data', buffer.slice(0, buflen));
}
});
this.#socket.on('close', () =>
{
this.emit('close');
});
this.#socket.on('error', (e) =>
{
this.emit('error', e);
});
}
get socket()
{
return this.#socket;
}
/* node:tty interface */
get isRaw()
{
return this.#isRaw;
}
get isTTY()
{
return this.#isTTY;
}
get columns()
{
return this.#columns;
}
get rows()
{
return this.#rows;
}
setRawMode(mode)
{
this.#isRaw = mode;
if (mode)
{
this.#s2c.do.suppress_go_ahead();
this.#s2c.will.suppress_go_ahead();
this.#s2c.will.echo();
}
else
{
this.#s2c.dont.suppress_go_ahead();
this.#s2c.wont.suppress_go_ahead();
this.#s2c.wont.echo();
}
}
getWindowSize()
{
return [this.#columns, this.#rows];
}
// TODO
// hasColors(count, env)
// {
// }
/* protected 'Duplex' member overloads */
_construct(callback)
{
log("_construct", callback);
callback();
}
_write(chunk, encoding, callback)
{
log("_write", chunk, encoding, callback);
// telnet specifies \r\n as line ending!
if (chunk instanceof String)
{
chunk = chunk.replace(/\r?\n/g, '\r\n');
}
else if (chunk instanceof Buffer)
{
const str = chunk.toString('utf8').replace(/\r?\n/g, '\r\n');
chunk = Buffer.from(str, 'utf8');
}
this.#send(chunk, encoding, callback);
}
_final(callback)
{
log("_final", callback);
this.#socket._final(callback);
}
_read(n)
{
log("_read", n);
this.#socket._read(n);
}
_destroy(err, callback)
{
log("_destroy", err, callback);
this.#socket._destroy(err, callback);
}
}
export class TelnetServer extends EventEmitter
{
#server;
#sessions = {};
constructor()
{
super();
this.#server = createServer();
this.#server.on('listening', () =>
{
this.emit('listening');
});
this.#server.on('error', (e) =>
{
this.emit('error', e);
});
this.#server.on('close', () =>
{
this.emit('close');
});
this.#server.on('drop', () =>
{
this.emit('drop');
});
this.#server.on('connection', (socket) =>
{
const fd = socket._handle.fd;
log("client connected", fd);
const session = new TelnetSession(socket);
this.#sessions[fd] = session;
socket.on('close', () =>
{
log("client disconnected", fd);
delete this.#sessions[fd];
});
socket.on('error', (e) =>
{
log("socket error", fd);
loge(e);
socket.close();
});
this.emit('connection', session);
});
}
listen(port)
{
this.#server.listen(port);
}
close()
{
this.#server.close();
}
}