Telnetクライアント自作入門
「Telnetって、23番ポートで通信するやつでしょ?」
「Telnetって、古代のプロトコルでしょ?」
「Telnetって、速度遅いんでしょ?」
……そんなことありません!!
この記事では簡易的なTelnetクライアントを開発し、Telnetプロトコルを解説していきます。
TCP通信とプロトコルについて理解を深めていただけたらと思います。
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):
- NVT(ネットワーク仮想端末)を作ること
 ・クラサバ間で共通仕様の端末とみなして通信
- セッション確立時に ネゴシエーション にてオプションをやり取り
 ・どんな端末を使うか、入力した文字をエコーするか、など
- 随時通知
 ・ターミナルサイズが変わったら通知したり
この記事では 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