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