├── renovate.json ├── justfile ├── src ├── utils.zig ├── main.zig ├── opts.zig ├── telnet.zig └── client.zig ├── .github └── workflows │ ├── pr-title.yaml │ ├── test.yaml │ ├── stale.yaml │ └── build.yaml ├── .vscode └── launch.json ├── .devcontainer └── devcontainer.json ├── .gitignore ├── LICENSE └── README.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "labels": [ 7 | "no-stale" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | fmt: 2 | zig fmt . 3 | 4 | run: 5 | zig build run 6 | 7 | build: 8 | zig build 9 | 10 | nasa: 11 | zig build run -- telnet://horizons.jpl.nasa.gov:6775 12 | 13 | clean: 14 | rm -rf zig-cache 15 | rm -rf zig-out 16 | -------------------------------------------------------------------------------- /src/utils.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | /// Removes the specified prefix from the given string if it starts with it. 4 | pub fn removePrefix(input: []const u8, prefix: []const u8) []const u8 { 5 | if (std.mem.startsWith(u8, input, prefix)) { 6 | return input[prefix.len..]; 7 | } 8 | return input; 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/pr-title.yaml: -------------------------------------------------------------------------------- 1 | name: Check PR title 2 | 3 | on: [pull_request_target] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-24.04 8 | permissions: 9 | statuses: write 10 | steps: 11 | - uses: aslafy-z/conventional-pr-title-action@v3 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "lldb", 6 | "request": "launch", 7 | "name": "Debug", 8 | "program": "${workspaceFolder}/zig-out/bin/telnet-zig", 9 | "args": [ 10 | "telnet://horizons.jpl.nasa.gov:6775", 11 | ], 12 | "cwd": "${workspaceFolder}", 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Zig Dev Container", 3 | "image": "mcr.microsoft.com/devcontainers/base:jammy", 4 | "features": { 5 | "ghcr.io/devcontainers-contrib/features/zig:1": {}, 6 | "ghcr.io/guiyomh/features/just:0": {} 7 | }, 8 | "customizations": { 9 | "vscode": { 10 | "extensions": [ 11 | "ziglang.vscode-zig", 12 | "skellock.just", 13 | "vadimcn.vscode-lldb" 14 | ] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is for zig-specific build artifacts. 2 | # If you have OS-specific or editor-specific files to ignore, 3 | # such as *.swp or .DS_Store, put those in your global 4 | # ~/.gitignore and put this in your ~/.gitconfig: 5 | # 6 | # [core] 7 | # excludesfile = ~/.gitignore 8 | # 9 | # Cheers! 10 | # -andrewrk 11 | 12 | zig-cache/ 13 | zig-out/ 14 | /release/ 15 | /debug/ 16 | /build/ 17 | /build-*/ 18 | /docgen_tmp/ 19 | 20 | # Custom 21 | test.zig 22 | notes.md 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Run Zig Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - 'src/**' 8 | - 'build.zig' 9 | - 'build.zig.zon' 10 | 11 | env: 12 | ZIG_VERSION: 0.11.0 13 | 14 | jobs: 15 | test: 16 | name: Tests on Linux 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: goto-bus-stop/setup-zig@v2 21 | with: 22 | version: ${{ env.ZIG_VERSION }} 23 | - uses: Hanaasagi/zig-action-cache@master 24 | - name: Build 25 | run: zig build 26 | - name: Run Tests 27 | run: zig build test 28 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | permissions: 8 | pull-requests: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-24.04 13 | permissions: 14 | contents: write 15 | issues: write 16 | pull-requests: write 17 | steps: 18 | - uses: actions/stale@v9 19 | with: 20 | repo-token: ${{ secrets.GITHUB_TOKEN }} 21 | days-before-stale: 30 22 | days-before-close: 14 23 | stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' 24 | stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs.' 25 | exempt-issue-labels: 'in-progress, no-stale' 26 | exempt-pr-labels: 'in-progress, no-stale' 27 | remove-stale-when-updated: true 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build Project 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths: 7 | - 'src/**' 8 | - 'build.zig' 9 | - 'build.zig.zon' 10 | 11 | env: 12 | ZIG_VERSION: 0.11.0 13 | 14 | jobs: 15 | build: 16 | name: Build for ${{ matrix.target }} 17 | runs-on: ubuntu-latest 18 | strategy: 19 | matrix: 20 | target: 21 | - 'x86_64-windows' 22 | - 'x86-windows' 23 | - 'x86_64-macos' 24 | - 'aarch64-macos' 25 | - 'x86_64-linux' 26 | - 'x86-linux' 27 | - 'arm-linux-gnueabihf' 28 | - 'aarch64-linux-gnu' 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: goto-bus-stop/setup-zig@v2 32 | with: 33 | version: ${{ env.ZIG_VERSION }} 34 | - uses: Hanaasagi/zig-action-cache@master 35 | 36 | - name: Build 37 | run: zig build -Dtarget=${{ matrix.target }} 38 | 39 | - name: Upload Artifact 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: telnet-zig-${{ matrix.target }} 43 | path: zig-out/bin/ 44 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const io = std.io; 3 | const net = std.net; 4 | const print = std.debug.print; 5 | const client = @import("client.zig"); 6 | const opts = @import("opts.zig"); 7 | const clap = @import("clap"); 8 | 9 | pub const std_options = struct { 10 | pub const log_level = .debug; // Set this to `.warn` to disable all debug info 11 | }; 12 | pub fn main() !void { 13 | // Setup allocator 14 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 15 | defer std.debug.assert(gpa.deinit() == .ok); 16 | 17 | var alloc = gpa.allocator(); 18 | 19 | // Parse command line arguments 20 | const parsedArgs = try opts.parse(alloc); 21 | if (parsedArgs) |args| { 22 | defer args.deinit(); 23 | 24 | // Commenct to the server 25 | std.log.info("Connecting to {?s}:{?d}\n", .{ args.uri.host, args.uri.port }); 26 | const stream = try net.tcpConnectToHost(alloc, args.uri.host.?, args.uri.port.?); 27 | defer stream.close(); 28 | 29 | var tnClient = client.TelnetClient.init(stream); 30 | 31 | // Start the input thread 32 | std.log.info("Press CTL-C to exit.", .{}); 33 | const handle = try std.Thread.spawn(.{}, readInput, .{&tnClient}); 34 | handle.detach(); 35 | 36 | while (true) { 37 | try tnClient.read(); 38 | } 39 | } 40 | } 41 | 42 | fn readInput(tnClient: *client.TelnetClient) !void { 43 | // Read from stdin and write to the telnet client 44 | 45 | // Sadly, this does not work, since data is only read once enter is pressed 46 | // Normaly, telnet would send a key press as soon as it is pressed 47 | // But for this to work, we would need to use a terminal library 48 | // Also our enter would have to be translated from \n to \r\n 49 | 50 | const stdin = std.io.getStdIn().reader(); 51 | while (true) { 52 | var buf: [64]u8 = undefined; 53 | const len = try stdin.read(&buf); 54 | if (len > 0) { 55 | try tnClient.write(buf[0..len]); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telnet Client in Zig 2 | 3 | [![MIT License](https://img.shields.io/badge/license-MIT-blue)](https://choosealicense.com/licenses/mit/) [![Build Project](https://github.com/michidk/telnet-zig/actions/workflows/build.yaml/badge.svg)](https://github.com/michidk/telnet-zig/actions/workflows/build.yaml) [![Run Zig Tests](https://github.com/michidk/telnet-zig/actions/workflows/test.yaml/badge.svg)](https://github.com/michidk/telnet-zig/actions/workflows/test.yaml) 4 | 5 | This project is a Zig implementation of a simple telnet client. 6 | 7 | [Telnet](https://en.wikipedia.org/wiki/Telnet), one of the earliest internet protocols, is used to provide a bidirectional interactive text-based communication facility, primarily over a terminal interface, allowing users to connect to a remote host or server. 8 | 9 | [Zig](https://ziglang.org/) is a general-purpose programming language designed for robustness, optimality, and clarity, primarily aimed at maintaining performance and improving upon concepts from C. 10 | 11 | This implementation is not as feature-rich and customizable as the [inetutils implementation](https://github.com/guillemj/inetutils/tree/master/telnet) but aims to cover the same feature set as the [curl implementation](https://github.com/curl/curl/blob/master/lib/telnet.c). 12 | 13 | This project initially helped me to learn and evaluate Zig. I documented my insights and reflections in a blog post found [here](https://blog.lohr.dev/after-a-day-of-programming-in-zig). 14 | 15 | 16 | ## Features 17 | 18 | - Basic telnet protocol functionality 19 | 20 | ## Installation 21 | 22 | - Install the [Zig](https://ziglang.org/download/) compiler 23 | - Run `zig build` to build the project 24 | - Run `zig build run` to run the telnet client (or use [just](https://github.com/casey/just)) 25 | 26 | ## Run 27 | 28 | Run with Zig: 29 | 30 | ```bash 31 | zig build run -- telnet://horizons.jpl.nasa.gov:6775 32 | ``` 33 | 34 | Or run the executable directly (after building): 35 | 36 | ```bash 37 | ./telnet-zig horizons.jpl.nasa.gov:6775 38 | ``` 39 | 40 | ## Usage 41 | 42 | To display the help, run the telnet client with the `--help` flag: 43 | 44 | ```bash 45 | ./telnet-zig --help 46 | -h, --help 47 | Display this help. 48 | 49 | -u, --usage 50 | Displays a short command usage 51 | 52 | 53 | The telnet URI to connect to. 54 | ``` 55 | -------------------------------------------------------------------------------- /src/opts.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const clap = @import("clap"); 3 | const utils = @import("utils.zig"); 4 | const telnet = @import("telnet.zig"); 5 | const io = std.io; 6 | 7 | const OptErrors = error{ 8 | MissingArgument, 9 | InvalidUri, 10 | }; 11 | 12 | pub const Opts = struct { 13 | alloc: std.mem.Allocator, 14 | uriStr: []const u8, 15 | uri: std.Uri, 16 | 17 | pub fn init(alloc: std.mem.Allocator, uriStrInp: []const u8) !Opts { 18 | const uriStrWithoutPrefix: []const u8 = utils.removePrefix(uriStrInp, "telnet://"); 19 | const uriStr: []const u8 = try std.fmt.allocPrint(alloc, "telnet://{s}", .{uriStrWithoutPrefix}); 20 | 21 | var uri = std.Uri.parse(uriStr) catch |err| { 22 | std.log.err("Invalid telnet URI ({any}): {s}\n", .{ err, uriStrInp }); 23 | alloc.free(uriStr); 24 | return error.InvalidUri; 25 | }; 26 | 27 | if (uri.host == null) { 28 | std.log.err("Missing host in telnet URI: {s}\n", .{uriStrInp}); 29 | alloc.free(uriStr); 30 | return error.InvalidUri; 31 | } 32 | 33 | // Handle default port 34 | if (uri.port == null) { 35 | std.log.warn("Missing port, using default port {d}.\n", .{telnet.DEFAULT_PORT}); 36 | uri.port = telnet.DEFAULT_PORT; 37 | } 38 | 39 | return Opts{ .alloc = alloc, .uri = uri, .uriStr = uriStr }; 40 | } 41 | 42 | pub fn deinit(self: *const Opts) void { 43 | self.alloc.free(self.uriStr); 44 | } 45 | }; 46 | 47 | pub fn parse(alloc: std.mem.Allocator) !?Opts { 48 | // CLI Parameters 49 | const params = comptime clap.parseParamsComptime( 50 | \\-h, --help Display this help. 51 | \\-u, --usage Displays a short command usage 52 | \\ The telnet URI to connect to. 53 | \\ 54 | ); 55 | 56 | // Clap diagnostics are used to report errors to the user. 57 | var diag = clap.Diagnostic{}; 58 | var res = clap.parse(clap.Help, ¶ms, clap.parsers.default, .{ 59 | .diagnostic = &diag, 60 | .allocator = alloc, 61 | }) catch |err| { 62 | diag.report(io.getStdErr().writer(), err) catch {}; 63 | return err; 64 | }; 65 | defer res.deinit(); 66 | 67 | if (res.args.help != 0) { 68 | try clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}); 69 | return null; 70 | } else if (res.args.usage != 0) { 71 | try clap.usage(std.io.getStdOut().writer(), clap.Help, ¶ms); 72 | return null; 73 | } else { 74 | if (res.positionals.len < 1) { 75 | std.log.err("Missing telnet URI. Use -h to print the help.\n", .{}); 76 | return error.MissingArgument; 77 | } 78 | 79 | const uriStrInp: []const u8 = res.positionals[0]; 80 | return try Opts.init(alloc, uriStrInp); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/telnet.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | // Default port for the application 4 | pub const DEFAULT_PORT: u16 = 23; 5 | 6 | // Constant byte values for Telnet protocol 7 | pub const IAC_BYTE: u8 = 255; // "Interpret as Command" byte 8 | pub const IS_BYTE: u8 = 0; // "IS" byte 9 | pub const SEND_BYTE: u8 = 1; // "SEND" byte 10 | 11 | pub const Command = enum(u8) { 12 | se = 240, // End of subnegotiation parameters 13 | nop = 241, // No operation 14 | dm = 242, // Data mark 15 | brk = 243, // Break 16 | ip = 244, // Suspend, interrupt or abort process 17 | ao = 245, // Abort output 18 | ayt = 246, // Are you there 19 | ec = 247, // Erase character 20 | el = 248, // Erase line 21 | ga = 249, // Go ahead 22 | sb = 250, // Start of subnegotiation of the indicated option 23 | will = 251, // Willing to begin performing the indicated option 24 | wont = 252, // Refusing to perform the indicated option 25 | do = 253, // Request to perform the indicated option 26 | dont = 254, // Demand to stop performing the indicated option 27 | iac = 255, // data byte 255 28 | }; 29 | 30 | pub const Option = enum(u8) { 31 | transmitBinary = 0, // Binary Transmission (RFC 856) 32 | echo = 1, // Echo (RFC 857) 33 | reconnection = 2, // Reconnection (NIC 15391 of 1973) 34 | suppressGoAhead = 3, // Suppress Go Ahead (RFC 858): No "go ahead" signal will be sent (required for half-duplex transmissions) -> full-duplex 35 | approxMessageSizeNegotiation = 4, // Approx Message Size Negotiation (NIC 15393 of 1973) 36 | status = 5, // Status (RFC 859) 37 | timingMark = 6, // Timing Mark (RFC 860) 38 | remoteControlledTransAndEcho = 7, // Remote Controlled Trans and Echo (RFC 726) 39 | outputLineWidth = 8, // Output Line Width (NIC 20196 of August 1978) 40 | outputPageSize = 9, // Output Page Size (NIC 20197 of August 1978) 41 | outputCarriageReturnDisposition = 10, // Output Carriage-Return Disposition (RFC 652) 42 | outputHorizontalTabStops = 11, // Output Horizontal Tab Stops (RFC 653) 43 | outputHorizontalTabDisposition = 12, // Output Horizontal Tab Disposition (RFC 654) 44 | outputFormfeedDisposition = 13, // Output Formfeed Disposition (RFC 655) 45 | outputVerticalTabstops = 14, // Output Vertical Tabstops (RFC 656) 46 | outputVerticalTabDisposition = 15, // Output Vertical Tab Disposition (RFC 657) 47 | outputLinefeedDisposition = 16, // Output Linefeed Disposition (RFC 658) 48 | extendedASCII = 17, // Extended ASCII (RFC 698) 49 | logout = 18, // Logout (RFC 727) 50 | byteMacro = 19, // Byte Macro (RFC 735) 51 | dataEntryTerminal = 20, // Data Entry Terminal (RFC 1043, RFC 732) 52 | supdup = 21, // SUPDUP (RFC 736, RFC 734) 53 | supdupOutput = 22, // SUPDUP Output (RFC 749) 54 | sendLocation = 23, // Send Location (RFC 779) 55 | terminalType = 24, // Terminal Type (RFC 1091): Requests the name of the terminal type in ASCII format 56 | endOfRecord = 25, // End of Record (RFC 885) 57 | tacacsUserIdentification = 26, // TACACS User Identification (RFC 927) 58 | outputMarking = 27, // Output Marking (RFC 933) 59 | terminalLocationNumber = 28, // Terminal Location Number (RFC 946) 60 | telnet3270Regime = 29, // Telnet 3270 Regime (RFC 1041) 61 | x3pad = 30, // X.3 PAD (RFC 1053) 62 | negotiateAboutWindowSize = 31, // Negotiate About Window Size (RFC 1073) 63 | terminalSpeed = 32, // Terminal Speed (RFC 1079) 64 | remoteFlowControl = 33, // Remote Flow Control (RFC 1372) 65 | linemode = 34, // Linemode (RFC 1184) 66 | xDisplayLocation = 35, // X Display Location (RFC 1096) 67 | environmentOption = 36, // Environment Option (RFC 1408) 68 | authenticationOption = 37, // Authentication Option (RFC 2941) 69 | encryptionOption = 38, // Encryption Option (RFC 2946) 70 | newEnvironmentOption = 39, // New Environment Option (RFC 1572) 71 | tn3270e = 40, // TN3270E (RFC 2355) 72 | xauth = 41, // XAUTH 73 | charset = 42, // CHARSET (RFC 2066) 74 | telnetRemoteSerialPort = 43, // Telnet Remote Serial Port (RSP) 75 | comPortControlOption = 44, // Com Port Control Option (RFC 2217) 76 | telnetSuppressLocalEcho = 45, // Telnet Suppress Local Echo 77 | telnetStartTLS = 46, // Telnet Start TLS 78 | kermit = 47, // KERMIT (RFC 2840) 79 | sendurl = 48, // SEND-URL 80 | forwardx = 49, // FORWARD_X 81 | unassigned50To137 = 50, // Unassigned (50-137) 82 | teloptpragmalogon = 138, // TELOPT PRAGMA LOGON 83 | teloptsspilogon = 139, // TELOPT SSPI LOGON 84 | teloptpragmaheartbeat = 140, // TELOPT PRAGMA HEARTBEAT 85 | unassigned141To254 = 141, // Unassigned (141-254) 86 | extendedOptionsList = 255, // Extended-Options-List (RFC 861) 87 | }; 88 | 89 | // Function to create an instruction array from a command and option 90 | pub fn instruction(command: Command, option: Option) [3]u8 { 91 | return [3]u8{ IAC_BYTE, @intFromEnum(command), @intFromEnum(option) }; 92 | } 93 | 94 | // Function to create an instruction array from a command and option 95 | pub fn subnegotiate(option: Option, comptime payload: []const u8) [payload.len + 5]u8 { 96 | var data = [_]u8{0} ** (payload.len + 5); 97 | 98 | data[0] = IAC_BYTE; 99 | data[1] = @intFromEnum(Command.sb); 100 | data[2] = @intFromEnum(option); 101 | std.mem.copy(u8, data[3 .. 3 + payload.len], payload); 102 | data[payload.len + 3] = IAC_BYTE; 103 | data[payload.len + 4] = @intFromEnum(Command.se); 104 | 105 | return data; 106 | } 107 | -------------------------------------------------------------------------------- /src/client.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const io = std.io; 3 | const net = std.net; 4 | const fs = std.fs; 5 | const os = std.os; 6 | const print = std.debug.print; 7 | const telnet = @import("telnet.zig"); 8 | const Command = telnet.Command; 9 | const Option = telnet.Option; 10 | 11 | const State = enum { 12 | normal, 13 | iac, 14 | negotiating, 15 | subnegotiating, 16 | }; 17 | 18 | const StateInfo = union(State) { 19 | normal: void, 20 | iac: void, 21 | negotiating: telnet.Command, 22 | subnegotiating: telnet.Option, 23 | }; 24 | 25 | pub const TelnetClient = struct { 26 | stream: net.Stream, 27 | reader: net.Stream.Reader, 28 | writer: net.Stream.Writer, 29 | state: StateInfo, 30 | 31 | pub fn init(stream: net.Stream) TelnetClient { 32 | return TelnetClient{ 33 | .stream = stream, 34 | .reader = stream.reader(), 35 | .writer = stream.writer(), 36 | .state = .normal, 37 | }; 38 | } 39 | 40 | pub fn write(self: *TelnetClient, data: []u8) anyerror!void { 41 | std.log.debug("Writing {d} bytes", .{data.len}); 42 | 43 | // TODO: escape IAC bytes 44 | try self.writer.writeAll(data); 45 | } 46 | 47 | pub fn read(self: *TelnetClient) anyerror!void { 48 | const byte = try self.reader.readByte(); 49 | 50 | switch (self.state) { 51 | 52 | // Normal state: print characters and wait for IAC byte 53 | .normal => { 54 | if (byte == telnet.IAC_BYTE) { 55 | self.state = .iac; 56 | } else { 57 | print("{c}", .{byte}); 58 | } 59 | }, 60 | 61 | // Command state: determine command and set negotiating state 62 | .iac => { 63 | var cmd: telnet.Command = @enumFromInt(byte); 64 | switch (cmd) { 65 | .nop => { 66 | // Do nothing 67 | std.log.debug("Recieved NOP", .{}); 68 | self.state = .normal; 69 | }, 70 | .iac => { 71 | // Escaped IAC byte 72 | print("{c}", .{telnet.IAC_BYTE}); 73 | self.state = .normal; 74 | }, 75 | .will, .wont, .do, .dont, .sb => { 76 | self.state = StateInfo{ .negotiating = cmd }; 77 | }, 78 | .se => { 79 | // Subnegotiation end 80 | self.state = .normal; 81 | }, 82 | else => { 83 | std.log.warn("Unhandled command: {s} (state: {s})", .{ @tagName(cmd), @tagName(self.state) }); 84 | self.state = .normal; 85 | }, 86 | } 87 | }, 88 | 89 | // Negotiating state: determine option and send response 90 | .negotiating => |command| { 91 | const option: telnet.Option = @enumFromInt(byte); 92 | std.log.debug("S: {s} {s}", .{ @tagName(command), @tagName(option) }); 93 | 94 | switch (option) { 95 | .echo => { 96 | // https://datatracker.ietf.org/doc/html/rfc857 97 | switch (command) { 98 | .will => { 99 | std.log.debug("Server wants to echo, we allow him", .{}); 100 | try self.send(.do, .echo); 101 | }, 102 | .wont => { 103 | std.log.debug("Server does not want to echo, we are fine with that", .{}); 104 | try self.send(.dont, .echo); 105 | }, 106 | .do => { 107 | std.log.debug("Server asks us to echo, we decline", .{}); 108 | try self.send(.wont, .echo); 109 | }, 110 | .dont => { 111 | std.log.debug("Server asks us not to echo, we won't", .{}); 112 | try self.send(.wont, .echo); 113 | }, 114 | else => { 115 | std.log.warn("Unsupported negotiation command `{s}` for option `{s}` (state: {s})", .{ @tagName(command), @tagName(option), @tagName(self.state) }); 116 | }, 117 | } 118 | self.state = .normal; 119 | }, 120 | .suppressGoAhead => { 121 | // https://datatracker.ietf.org/doc/html/rfc858 122 | switch (command) { 123 | .will => { 124 | std.log.debug("Server wants to suppress go ahead, we allow him", .{}); 125 | try self.send(.do, .suppressGoAhead); 126 | }, 127 | .wont => { 128 | std.log.warn("Server refused to suppress go ahead", .{}); 129 | try self.send(.do, .suppressGoAhead); 130 | }, 131 | .do => { 132 | std.log.debug("Server asks us to suppress go ahead, we accept", .{}); 133 | try self.send(.will, .suppressGoAhead); 134 | }, 135 | .dont => { 136 | std.log.warn("Server asks us not to suppress go ahead, we won't", .{}); 137 | try self.send(.wont, .suppressGoAhead); 138 | }, 139 | else => { 140 | std.log.warn("Unsupported negotiation command `{s}` for option `{s}` (state: {s})", .{ @tagName(command), @tagName(option), @tagName(self.state) }); 141 | }, 142 | } 143 | self.state = .normal; 144 | }, 145 | .negotiateAboutWindowSize => { 146 | // https://datatracker.ietf.org/doc/html/rfc1073 147 | switch (command) { 148 | .do => { 149 | std.log.debug("Server wants to negotiate about window size, we send the info", .{}); 150 | try self.send(.will, .negotiateAboutWindowSize); 151 | 152 | // TODO: get the correct width and height from the terminal 153 | const windowSizeData = &[_]u8{ 154 | 0, 80, // Width 155 | 0, 24, // Height 156 | }; 157 | const negotiation = &telnet.subnegotiate(Option.negotiateAboutWindowSize, windowSizeData); 158 | try self.writer.writeAll(negotiation); 159 | }, 160 | .dont => { 161 | std.log.debug("Server does not want to negotiate about window size, we accept", .{}); 162 | try self.send(.wont, .negotiateAboutWindowSize); 163 | }, 164 | else => { 165 | std.log.warn("Unsupported negotiation command `{s}` for option `{s}` (state: {s})", .{ @tagName(command), @tagName(option), @tagName(self.state) }); 166 | }, 167 | } 168 | self.state = .normal; 169 | }, 170 | .terminalType => { 171 | // https://datatracker.ietf.org/doc/html/rfc1091 172 | switch (command) { 173 | .do => { 174 | std.log.debug("Server wants to ask us for our terminal type, we agree", .{}); 175 | try self.send(.will, .terminalType); 176 | 177 | self.state = .normal; 178 | }, 179 | .dont => { 180 | std.log.debug("Server does not want to know our terminal type", .{}); 181 | try self.send(.wont, .terminalType); 182 | 183 | self.state = .normal; 184 | }, 185 | .sb => { 186 | std.log.debug("Server wants to know our terminal type...", .{}); 187 | 188 | self.state = StateInfo{ 189 | .subnegotiating = Option.terminalType, 190 | }; 191 | }, 192 | else => { 193 | std.log.warn("Unsupported negotiation command `{s}` for option `{s}` (state: {s})", .{ @tagName(command), @tagName(option), @tagName(self.state) }); 194 | }, 195 | } 196 | }, 197 | .transmitBinary => { 198 | // https://datatracker.ietf.org/doc/html/rfc856 199 | switch (command) { 200 | .do => { 201 | std.log.debug("Server wants to transmit binary, we agree", .{}); 202 | try self.send(.will, .transmitBinary); 203 | }, 204 | .dont => { 205 | std.log.debug("Server does not want to transmit binary, we agree", .{}); 206 | try self.send(.wont, .transmitBinary); 207 | }, 208 | .will => { 209 | std.log.debug("Server wants us to transmit binary, we agree", .{}); 210 | try self.send(.do, .transmitBinary); 211 | }, 212 | .wont => { 213 | std.log.debug("Server does not want us to transmit binary, we agree", .{}); 214 | try self.send(.dont, .transmitBinary); 215 | }, 216 | else => { 217 | std.log.warn("Unsupported negotiation command `{s}` for option `{s}` (state: {s})", .{ @tagName(command), @tagName(option), @tagName(self.state) }); 218 | }, 219 | } 220 | }, 221 | else => { 222 | switch (command) { 223 | .do => { 224 | std.log.debug("Server wants us to perform subcommand `{s}`, we refuse", .{@tagName(option)}); 225 | try self.send(.wont, option); 226 | }, 227 | .dont => { 228 | std.log.debug("Server does not want us to perform subcommand `{s}`, we refuse", .{@tagName(option)}); 229 | }, 230 | .will, .wont => { 231 | std.log.warn("Server wants to negotiate option `{s}`", .{@tagName(option)}); 232 | }, 233 | else => { 234 | std.log.warn("Unsupported negotiation command `{s}` for option `{s}` (state: {s})", .{ @tagName(command), @tagName(option), @tagName(self.state) }); 235 | }, 236 | } 237 | self.state = .normal; 238 | }, 239 | } 240 | }, 241 | 242 | // Subnegotiating state: determine option and read until IAC SE 243 | .subnegotiating => |option| { 244 | std.log.debug("Subnegotiating option `{s}`", .{@tagName(option)}); 245 | switch (option) { 246 | .terminalType => { 247 | if (byte == telnet.SEND_BYTE) { 248 | std.log.debug("Send terminal type", .{}); 249 | const terminalTypeData: []const u8 = &[_]u8{ 250 | telnet.IS_BYTE, // Is 251 | 'X', 'T', 'E', 'R', 'M', '-', '2', '5', '6', 'C', 'O', 'L', 'O', 'R', // Terminal type (`XTERM-256COLOR` is what the inetutils implementation sends) 252 | }; 253 | const negotiation: []const u8 = &telnet.subnegotiate(Option.terminalType, terminalTypeData); 254 | try self.writer.writeAll(negotiation); 255 | 256 | self.state = .normal; 257 | } else { 258 | std.log.warn("Unsupported data byte {d} during subnegotiation option `{c}` (state: {s}),", .{ byte, @tagName(option), @tagName(self.state) }); 259 | self.state = .normal; 260 | } 261 | }, 262 | else => { 263 | std.log.warn("Unsupported subnegotiation option `{s}` (state: {s})", .{ @tagName(option), @tagName(self.state) }); 264 | self.state = .normal; 265 | }, 266 | } 267 | }, 268 | } 269 | } 270 | 271 | fn send(self: *TelnetClient, command: Command, option: Option) anyerror!void { 272 | std.log.debug("C: {s} {s}", .{ @tagName(command), @tagName(option) }); 273 | try self.writer.writeAll(&telnet.instruction(command, option)); 274 | } 275 | }; 276 | --------------------------------------------------------------------------------