Skip to content

Commit

Permalink
feat(backend): implemented telnet options linemode and sga
Browse files Browse the repository at this point in the history
- present a DO for SGa
- answer with WILL for LINEMODE
- updated readme for telnet feature
- its now possible to access the previous value of a negotiation
- handler can now be marked as dynamic which does allow later modifications
  • Loading branch information
mystiker committed Nov 4, 2024
1 parent 07101d0 commit b9833c6
Show file tree
Hide file tree
Showing 15 changed files with 324 additions and 176 deletions.
2 changes: 1 addition & 1 deletion backend/src/core/middleware/use-rest-endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Express, Request, Response } from 'express';

import { TelnetOptions } from '../../features/telnet/models/telnet-options.js';
import { TelnetControlSequences } from '../../features/telnet/types/telnet-control-sequences.js';
import { TelnetOptions } from '../../features/telnet/types/telnet-options.js';
import { logger } from '../../shared/utils/logger.js';
import { SocketManager } from '../sockets/socket-manager.js';

Expand Down
2 changes: 1 addition & 1 deletion backend/src/core/sockets/socket-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Server as HttpServer } from 'http';
import { Server as HttpsServer } from 'https';
import { Server, Socket } from 'socket.io';

import { TelnetOptions } from '../../features/telnet/models/telnet-options.js';
import { TelnetClient } from '../../features/telnet/telnet-client.js';
import { TelnetControlSequences } from '../../features/telnet/types/telnet-control-sequences.js';
import { TelnetOptions } from '../../features/telnet/types/telnet-options.js';
import { logger } from '../../shared/utils/logger.js';
import { mapToServerEncodings } from '../../shared/utils/supported-encodings.js';
import { Environment } from '../environment/environment.js';
Expand Down
44 changes: 36 additions & 8 deletions backend/src/features/telnet/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ The TelnetClient class handles these negotiation commands, regardless of their o
| Telnet Option | Client Support | Client Negotiation | Server Negotiation | Remarks | Discussion |
| ----------------------------------------- | -------------- | ------------------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------- |
| ECHO | Full | Dynamic | Dynamic | Is used in the telnet login flow to hide user input (password). This option is negotiated on the fly and can be enabled or disabled whenever needed. | |
| SGA (Suppress Go Ahead) | Full | DO | WONT | We offer a DO at startup and can handle changes on the fly. However, Unitopia WONT accept this option for unknown reasons. | https://github.com/unitopia-de/webmud3/issues/115 |
| NAWS (Negotiate About Window Size) | Partial | WILL (+ Sub) | DO | We support this option to subnegotiate the window size. However, we send static values for the window size (80x25) and it does look like Unitopia is ignoring these values. | https://github.com/unitopia-de/webmud3/issues/108 |
| CHARSET | Partial | DO / WILL (+ Sub) | WILL (+ Sub) / DO | We support this option to subnegotiate the character set with the server. However, we only accept UTF-8. If the server does not subnogitiate for UTF-8, an error will be thrown and the connection will be closed. | https://github.com/unitopia-de/webmud3/issues/111 |
| SGA (Suppress Go Ahead) | Todo | Not negotiated | Not negotiated | The Telnet Suppress Go Ahead (SGA) option disables the need for "Go Ahead" signals, allowing continuous, uninterrupted data flow in both directions, ideal for interactive applications like remote shells. | https://github.com/unitopia-de/webmud3/issues/115 |
| LINEMODE | Todo | WILL | DO | The Telnet LINEMODE option allows the client to send input line-by-line instead of character-by-character, optimizing bandwidth and reducing network load for text-based applications. | https://github.com/unitopia-de/webmud3/issues/114 |
| LINEMODE | Partial | WILL | DO | We support the LINEMODE option. However, the server wants us to send our input buffer whenever ANY ASCII control character is entered (FORWARDMASK), which is unsupported. On the other side, we do support SOFT_TAB and EDIT MODES | https://github.com/unitopia-de/webmud3/issues/114 |
| EOR (End of Record) | Todo | DONT | WILL | Allows for the server to signal the end of a record which is not needed for our client. | https://github.com/unitopia-de/webmud3/issues/112 |
| MSSP (Mud Server Status Protocol) | Todo | DO | WILL | Allows our client to retrieve basic information about the mud, like the current player count or the server name. | |
| TTYPE | Todo | WILL | DO | Allows the client to send its name to the server. | |
Expand All @@ -50,35 +50,63 @@ Define a new TelnetOptionHandler object for the option you want to handle.

```typescript
const newOptionHandler: TelnetOptionHandler = {
negotiate: () => {
negotiate: (socket) => {
// In this handler you can send a negotiation yourself uppon initialization.
// Use this if you want the server to enable/disable the option.
},
handleDo: () => {
handleDo: (socket, getPreviousNegotiation?) => {
// Handle the DO command for the new option
// Return a TelnetNegotiationResult object with the appropriate control sequence and subnegotiation result
},
handleDont: () => {
handleDont: (socket, getPreviousNegotiation?) => {
// Handle the DON'T command for the new option
// Return a TelnetNegotiationResult object with the appropriate control sequence and subnegotiation result
},
handleWill: () => {
handleWill: (socket, getPreviousNegotiation?) => {
// Handle the WILL command for the new option
// Return a TelnetNegotiationResult object with the appropriate control sequence and subnegotiation result
},
handleWont: () => {
handleWont: (socket, getPreviousNegotiation?) => {
// Handle the WON'T command for the new option
// Return a TelnetNegotiationResult object with the appropriate control sequence and subnegotiation result
},
handleSub: (serverChunk: Buffer) => {
handleSub: (socket, serverChunk: Buffer) => {
// Handle the subnegotiation data for the new option
// Return a TelnetSubnegotiationResult object with the appropriate client chunk and client option
},

// Whether this handler is dynamic and can be called again after initial negotiation
isDynamic?: boolean,
};
```

_Note_: If you mark an option handler as dynamic, it will be called again after the initial negotiation with the server if the server requests a change to the option. This allows for more flexible and dynamic negotiation of Telnet options between the client and server.

Add the `newOptionHandler` object to the optionsHandler map in your TelnetClient class:

```typescript
this.optionsHandler.set(TelnetOptions.TELOPT_LINEMODE, newOptionHandler);
```

### Accessing Previous Negotiation State

In some cases, you may need to access the previous negotiation state to determine how to respond to a new negotiation command. The `TelnetOptionHandler` provides a way to do this through the `getPreviousNegotiation` function.

The `getPreviousNegotiation` function returns the previous negotiation result, which can be used to determine the current state of the option. This can be useful in cases where the client needs to respond differently depending on the previous state of the option.

Here is an example of how to use `getPreviousNegotiation` in a `TelnetOptionHandler`:

```typescript
const newOptionHandler: TelnetOptionHandler = {
// ...
handleDo: (getPreviousNegotiation: () => TelnetNegotiationResult | undefined) => {
const previousNegotiation = getPreviousNegotiation();
if (previousNegotiation?.client !== undefined) {
// Handle the case where the option is already enabled
} else {
// Handle the case where the option is not enabled
}
},
// ...
};
```
131 changes: 75 additions & 56 deletions backend/src/features/telnet/telnet-client.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@
// Das siegreiche Gnomi sagt: Es gibt so ein paar Telnet-Optionen, die m.E.
// jeder Client unterstuetzen sollte: NAWS, CHARSET, EOR, ECHO,
// STARTTLS.
// Das siegreiche Gnomi sagt: Ah, und SGA oder LINEMODE

import EventEmitter from 'events';
import net from 'net';
import { TelnetSocket } from 'telnet-stream';
import tls from 'tls';

import { logger } from '../../shared/utils/logger.js';
import { TelnetOptions } from './models/telnet-options.js';
import { TelnetControlSequences } from './types/telnet-control-sequences.js';
import { TelnetNegotiations } from './types/telnet-negotiations.js';
import { TelnetOptionHandler } from './types/telnet-option-handler.js';
import { TelnetOptions } from './types/telnet-options.js';
import { handleCharsetOption } from './utils/handle-charset-option.js';
import { handleEchoOption } from './utils/handle-echo-option.js';
import { handleLinemodeOption } from './utils/handle-linemode-option.js';
import { handleNawsOption } from './utils/handle-naws-option.js';
import { handleSGAOption } from './utils/handle-sga-option.js';
import { TelnetSocketWrapper } from './utils/telnet-socket-wrapper.js';

type TelnetClientEvents = {
Expand Down Expand Up @@ -75,6 +72,8 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {
[TelnetOptions.TELOPT_CHARSET, handleCharsetOption(this.telnetSocket)],
[TelnetOptions.TELOPT_ECHO, handleEchoOption(this.telnetSocket)],
[TelnetOptions.TELOPT_NAWS, handleNawsOption(this.telnetSocket)],
[TelnetOptions.TELOPT_SGA, handleSGAOption(this.telnetSocket)],
[TelnetOptions.TELOPT_LINEMODE, handleLinemodeOption(this.telnetSocket)],
]);

this.telnetSocket.on('connect', () => this.handleConnect());
Expand All @@ -96,8 +95,6 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {
this.telnetSocket.on('data', (chunkData: string | Buffer) => {
this.emit('data', chunkData);
});

this.connected = true;
}

public sendMessage(data: string): void {
Expand All @@ -113,7 +110,21 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {
}

private handleConnect(): void {
logger.info(`[Telnet-Client] Connected`);
logger.info(`[Telnet-Client] Connected. Starting negotiation process.`);

this.connected = true;

for (const [option, handler] of this.optionsHandler) {
const handlerResult = handler.negotiate?.();

if (handlerResult !== undefined) {
this.updateNegotiations(option, {
client: handlerResult.controlSequence,
clientChunk: handlerResult.subNegotiationResult?.clientChunk,
clientOption: handlerResult.subNegotiationResult?.clientOption,
});
}
}
}

private handleClose(hadErrors: boolean): void {
Expand All @@ -129,6 +140,13 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {

const handler = this.optionsHandler.get(option);

if (
this._negotiations[option]?.client !== undefined &&
handler?.isDynamic !== true
) {
return;
}

const handlerResult = handler?.handleDo();

if (handlerResult !== undefined) {
Expand All @@ -145,19 +163,6 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {
});
}

// switch (option) {

// case TelnetOptions.TELOPT_TM: {
// this.updateNegotiations(option, {
// server: TelnetControlSequences.DO,
// client: TelnetControlSequences.WILL,
// });

// this.telnetSocket.writeWill(option);

// return;
// }

return;
}

Expand All @@ -168,6 +173,13 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {

const handler = this.optionsHandler.get(option);

if (
this._negotiations[option]?.client !== undefined &&
handler?.isDynamic !== true
) {
return;
}

const handlerResult = handler?.handleDont();

if (handlerResult !== undefined) {
Expand All @@ -192,6 +204,13 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {

const handler = this.optionsHandler.get(option);

if (
this._negotiations[option]?.client !== undefined &&
handler?.isDynamic !== true
) {
return;
}

const handlerResult = handler?.handleWill();

if (handlerResult !== undefined) {
Expand All @@ -207,20 +226,6 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {
client: TelnetControlSequences.DONT, // we answer negatively but we should DO everything possible
});
}

// switch (option) {

// case TelnetOptions.TELOPT_GMCP: {
// this.telnetSocket.writeDo(option);

// this.updateNegotiations(option, {
// server: TelnetControlSequences.WILL,
// client: TelnetControlSequences.DO,
// });

// return;
// }
// }
}

private handleWont(option: TelnetOptions): void {
Expand All @@ -230,6 +235,13 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {

const handler = this.optionsHandler.get(option);

if (
this._negotiations[option]?.client !== undefined &&
handler?.isDynamic !== true
) {
return;
}

const handlerResult = handler?.handleWont();

if (handlerResult !== undefined) {
Expand Down Expand Up @@ -288,29 +300,36 @@ export class TelnetClient extends EventEmitter<TelnetClientEvents> {
// Update the control sequences for server and client
const updatedNegotiation = {
...existing,
...{
server: negotiations.server ?? existing.server,
client: negotiations.client ?? existing.client,
server: negotiations.server ?? existing.server,
client: negotiations.client ?? existing.client,
subnegotiation: {
// Only define serverChunks if there is a new serverChunk or it already exists
...(existing.subnegotiation?.serverChunks || negotiations.serverChunk
? {
serverChunks: [
...(existing.subnegotiation?.serverChunks || []),
...(negotiations.serverChunk
? [`0x${negotiations.serverChunk.toString('hex')}`]
: []),
],
}
: {}),
// Only define clientChunks if there is a new clientChunk or it already exists
...(existing.subnegotiation?.clientChunks || negotiations.clientChunk
? {
clientChunks: [
...(existing.subnegotiation?.clientChunks || []),
...(negotiations.clientChunk
? [`0x${negotiations.clientChunk.toString('hex')}`]
: []),
],
}
: {}),
clientOption:
negotiations.clientOption ?? existing.subnegotiation?.clientOption,
},
};

// Update the subnegotiation properties if they exist
if (existing) {
updatedNegotiation.subnegotiation = {
...existing.subnegotiation,
...{
serverChunk: negotiations.serverChunk
? negotiations.serverChunk.toString()
: existing.subnegotiation?.serverChunk,
clientChunk: negotiations.clientChunk
? negotiations.clientChunk.toString()
: existing.subnegotiation?.clientChunk,
clientOption:
negotiations.clientOption ?? existing.subnegotiation?.clientOption,
},
};
}

// Update the negotiations object
this._negotiations[option] = updatedNegotiation;

Expand Down
31 changes: 28 additions & 3 deletions backend/src/features/telnet/types/telnet-negotiation-result.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { TelnetControlSequences } from './telnet-control-sequences.js';
import { TelnetSubnegotiationResult } from './telnet-subnegotiation-result.js';

export type TelnetNegotiationResult = {
subNegotiationResult?: TelnetSubnegotiationResult;
/**
* The control sequence received from the server (DO, DON'T, WILL, WON'T).
*/
server?: TelnetControlSequences;

controlSequence: TelnetControlSequences;
/**
* The control sequence sent by the client (DO, DON'T, WILL, WON'T).
*/
client?: TelnetControlSequences;

/**
* Optional subnegotiation data exchanged between the server and client.
*/
subnegotiation?: {
/**
* The data chunk sent by the server during subnegotiation.
*/
serverChunks?: string[];

/**
* The data chunk sent by the client during subnegotiation.
*/
clientChunks?: string[];

/**
* The client option used during subnegotiation (e.g., a charset or mode).
*/
clientOption?: string;
};
};
Loading

0 comments on commit b9833c6

Please sign in to comment.