Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Telnet Linemode and SGA Option #127

Open
wants to merge 1 commit into
base: feat/telnet-other-options
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading