📟

Telnetクライアント自作入門

2023/09/06に公開

「Telnetって、23番ポートで通信するやつでしょ?」
「Telnetって、古代のプロトコルでしょ?」
「Telnetって、速度遅いんでしょ?」

……そんなことありません!!

この記事では簡易的なTelnetクライアントを開発し、Telnetプロトコルを解説していきます。
TCP通信とプロトコルについて理解を深めていただけたらと思います。

https://github.com/kumavale/mini-telnet

Telnet とは

Telnetとは、ネットワーク通信のプロトコル(通信のルールや手順の決まりごとみたいなもの)の一つです。TCP通信を用いてコンピュータ間でテキストベースの通信を行うためのプロトコルです。Telnetを使うと、自分のコンピュータから遠く離れた別のコンピュータにリモート接続し、データを送受信できます。これは遠隔地のコンピュータを操作するために使われたり、システム管理者がサーバーにアクセスするのに役立ちます。
23番ポートを使うことでよく知られていますが、23番でTCP通信をすればTelnet通信かというと、そうではありません。TelnetプロトコルはRFCで厳密に定義されており、だたのTCP通信では成しえない役割があります。Telnetに限ったことではないですが、ウェルノウンポート以外のポートを使ってもプロトコルは正常に動作します。
通信速度においても、TCPソケット通信と同等の速度が出せます。

ここでは一般的な概念に留め、Telnetプロトコルの詳細は実装フェーズで解説します。

TCPクライアントの開発

この記事では Rust🦀 を使用して開発していきます。
インストールがお済みでない方は 公式サイト よりインストールをお願いします。
※Rustの解説はしません

環境

  • Linux (WSL2)
  • cargo 1.72.0 (103a7ff2e 2023-08-15)

まずは簡単にTCP通信を試してみる

標準ライブラリの サンプル を参考に作成します。

use std::io::prelude::*;
use std::net::TcpStream;

fn main() {
    let mut stream = TcpStream::connect("127.0.0.1:2323").unwrap();

    loop {
        let mut input = String::new();
        std::io::stdin().read_line(&mut input).unwrap();
        stream.write_all(input.as_bytes()).unwrap();

        let mut buf = [0; 128];
        let n = stream.read(&mut buf).unwrap();
        std::io::stdout().write_all(&buf[..n]).unwrap();
    }
}

先に nc コマンドでサーバーを建てます。

$ nc -lp 2323

別ターミナルからクライアントで接続します。

$ cargo run

すると、サーバーとクライアントで交互にメッセージのやり取りが出来るようになりました!

非同期対応

このままだと半二重通信のようになってしまい快適ではないので、非同期化して全二重通信にします。

$ cargo add tokio -F full 
use std::io::prelude::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::TcpStream;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;

#[tokio::main]
async fn main() {
    let stream = TcpStream::connect("127.0.0.1:2323").await.unwrap();
    let (sender, receiver) = broadcast::channel(1);
    let (stream, sink) = stream.into_split();

    let input_handle = stdin(sender);
    let tx_handle = tx(sink, receiver);
    let rx_handle = rx(stream);

    tokio::select! {
        _ = input_handle => (),
        _ = tx_handle => (),
        _ = rx_handle => (),
    }
}

fn stdin(sender: broadcast::Sender<Vec<u8>>) -> JoinHandle<()> {
    tokio::task::spawn_blocking(move || loop {
        let mut input = String::new();
        std::io::stdin().read_line(&mut input).unwrap();
        sender.send(input.into_bytes()).unwrap();
    })
}

async fn tx(mut sink: OwnedWriteHalf, mut proxy: broadcast::Receiver<Vec<u8>>) {
    loop {
        let input = proxy.recv().await.unwrap();
        sink.write_all(&input).await.unwrap();
    }
}

async fn rx(mut stream: OwnedReadHalf) {
    loop {
        let mut buf = vec![];
        match stream.read_buf(&mut buf).await {
            Ok(0) | Err(_) => return,
            Ok(_) => std::io::stdout().lock().write_all(&buf).unwrap(),
        }
    }
}

Telnetプロトコルの実装

いよいよ本題です。
RFC854, RFC855 を参考に実装していきます。(有志が和訳を公開しているのでそちらも参考に)

そもそもどんなプロトコルなん?

RFCをざっと眺めると……色々書いてあって複雑そうですね……
しかしやっていることは単純で、通信開始時や通信中にオプションやパラメータのやり取りを挟んでいるだけです。それ以外は先に述べたようにTCP通信と同じような通信をしています。

Telnetには主に3つの役割があります(RFC854):

  1. NVT(ネットワーク仮想端末)を作ること
     ・クラサバ間で共通仕様の端末とみなして通信
  2. セッション確立時に ネゴシエーション にてオプションをやり取り
     ・どんな端末を使うか、入力した文字をエコーするか、など
  3. 随時通知
     ・ターミナルサイズが変わったら通知したり

この記事では 2 に関して最小限の実装を行います。

ネゴシエーションとは

通信を確立する際に、クライアントとサーバーの間でさまざまなオプションやパラメータを交渉することを「ネゴシエーション」といいます。これによって、通信の特性や設定を調整できます。
ネゴシエーションの際に利用するのが TELNETコマンド です。すべての TELNETコマンド は「IAC」から始まる少なくとも2バイトの連続から成り立ちます。

重要なコマンドは以下:

名前 コード 意味
WILL (option code) 251 使うオプションの宣言 or DOに対する合意
WON'T (option code) 252 使わないオプションの宣言 or DON'Tに対する合意
DO (option code) 253 相手に使って欲しいオプションの宣言 or WILLに対する合意
DON'T (option code) 254 相手に使って欲しくないオプションの宣言 or WON'Tに対する合意
SE 240 サブネゴシエーションの終了
SB 250 サブネゴシエーションの開始
IAC 255 Data Byte 255.

実際の通信例)端末タイプを伝える場合

通信を受信した際、「IAC」から始まらないデータはプレーンテキストと見なして端末へ表示します。

実装

最小限の実装に当たって、やり取りするオプションは以下に絞ります:

  • WINDOW_SIZE:端末のサイズ(80x24)
     接続確認に使用するものがサイズに依存するため
  • SUPPRESS_GO_AHEAD:「Go Ahead」コマンドを抑止
     「以上、送信終わり」という合図を送らないことを意味します。TCP通信では全二重通信ができるので、このオプションはおまじないのように使います。

それ以外は自分からは「WILL」も「DO」もしません。サーバーから「WILL」「DO」が来た場合、「DON'T」「WON'T」と返すという裏技を使用します。

  #[tokio::main]
  async fn main() {
-     let stream = TcpStream::connect("127.0.0.1:2323").await.unwrap();
+     let stream = TcpStream::connect("1984.ws:23").await.unwrap();
      let (sender, receiver) = broadcast::channel(1);
-     let (stream, sink) = stream.into_split();
+     let (mut stream, mut sink) = stream.into_split();
+ 
+     negotiation(&mut stream, &mut sink).await;

      let input_handle = stdin(sender);
      let tx_handle = tx(sink, receiver);
      let rx_handle = rx(stream);

      tokio::select! {
          _ = input_handle => (),
          _ = tx_handle => (),
          _ = rx_handle => (),
      }
  }
  async fn rx(mut stream: OwnedReadHalf) {
      loop {
          let mut buf = vec![];
          match stream.read_buf(&mut buf).await {
              Ok(0) | Err(_) => return,
-             Ok(_) => std::io::stdout().lock().write_all(&buf).unwrap(),
+             Ok(_) => {
+                 std::io::stdout().lock().write_all(&buf).unwrap();
+                 std::io::stdout().flush().unwrap();
+             }
          }
      }
  }
+ async fn negotiation(stream: &mut OwnedReadHalf, sink: &mut OwnedWriteHalf) {
+     // options
+     const SUPPRESS_GO_AHEAD: u8 = 3;
+     const WINDOW_SIZE:       u8 = 31;
+     // commands
+     const SE:   u8 = 240;
+     const SB:   u8 = 250;
+     const WILL: u8 = 251;
+     const WONT: u8 = 252;
+     const DO:   u8 = 253;
+     const DONT: u8 = 254;
+     const IAC:  u8 = 255;
+ 
+     // My negotiation
+     sink.write_all(&[IAC, WILL, WINDOW_SIZE]).await.unwrap();
+ 
+     // Server negotiation
+     loop {
+         let mut buf = vec![0; 3];
+         match stream.peek(&mut buf).await {
+             Ok(0) | Err(_) => return,
+             Ok(_) => {
+                 if buf[0] == IAC {
+                     if buf[1] == DO {
+                         if buf[2] == WINDOW_SIZE {
+                             buf = vec![IAC, SB, WINDOW_SIZE, 0, 80, 0, 24, IAC, SE];
+                         } else {
+                             buf[1] = WONT
+                         }
+                     }
+                     if buf[1] == WILL {
+                         if buf[2] == SUPPRESS_GO_AHEAD {
+                             buf[1] = DO
+                         } else {
+                             buf[1] = DONT
+                         }
+                     }
+                     sink.write_all(&buf).await.unwrap();
+                     stream.read_exact(&mut [0; 3]).await.unwrap();
+                 } else {
+                     return;  // End of Negotiation
+                 }
+             }
+         }
+     }
+ }

negotiation() では、3バイト固定で peek() することでコマンドの判定を行います。「IAC」から始まればコマンド、そうでなければネゴシエーションは終了です。

1984.ws というサイトへアクセスしてみました。

まとめ

  • Telnetはプロトコルでありポート番号は問わない(公開する時はウェルノウンポート推奨)
  • Telnetはまだまだ現役
  • Telnetは速い

実装する際は既存のtelnet通信をキャプチャして真似するのが手っ取り早いかと思います。
RFCで暗号化も定義されているので実装してみると面白いかも。

参考

Discussion