やってみた 2026年9月14日

LocalSendのCLIをコンテナ2台で動かしたら、フォルダは平らになって届き、リンク共有中は暗号化が切れていた

XECIN RustCLIファイル転送mTLS

GitHubのトレンドに localsend/localsend が上がっているのを見かけました。「オープンソースのAirDrop代替」というやつで、名前だけは知っていたんですが、僕はてっきりスマホとPCに入れるGUIアプリだけの話だと思っていました。

恥ずかしながら、リポジトリのルートに Cargo.toml があって、cli/ というディレクトリに Rust製のコマンドラインクライアント が同居していることを、READMEを読み直すまで知りませんでした。「LocalSend Protocol v2 で動くターミナルクライアント」と書いてあります。サーバーやCIの箱みたいに画面のないマシン同士でファイルを投げ合えるなら、ちょっと欲しい。

というわけで、GUIには一切触らず、CLIだけを使い捨てのDockerコンテナ2台に立てて、片方からもう片方へファイルを送ってみました。

動くまで:ビルド1分10秒、送信まで3分半

試した環境は rust:1-bookworm のコンテナ2台(Debian 12、rustc 1.97.1)。同じDockerネットワークに sender-a(172.20.0.2)と receiver-b(172.20.0.3)として並べています。対象は main の先頭コミット e10e6250(2026-09-14)、cli/Cargo.toml のバージョンは 1.18.2 です。リポジトリには rust-toolchain.toml で 1.97.1 が固定されていました。

先に白状すると、Rustのビルドは「10分は覚悟」と思っていました。結果はこうです。

# sender-a: clone とビルド
$ git clone --depth 1 https://github.com/localsend/localsend /work/localsend
clone_sec=1
$ cargo build -p localsend-cli
   ...(Compiling が 365 行)...
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 10s
$ ls -la target/debug/localsend-cli
-rwxr-xr-x 2 root root 89293344 Sep 14 12:10 target/debug/localsend-cli
$ du -sh target
1.5G    target

# receiver-b: 受信側を起動(tmux の中で動かしています)
$ localsend-cli --alias receiver-b --destination /recv
 ▄▀ ▀ ▀▄
▄ ▄███▄ ▄  LocalSend CLI
▀ ▀███▀ ▀  v1.18.2
 ▀▄ ▄ ▄▀
Alias: receiver-b
Port: 53317
Destination: /recv
Config: /root/.config/localsend-cli
Listening on:
 - https://172.20.0.3:53317
Ready to accept requests.

365クレートで1分10秒、警告ゼロ。デバッグビルドなので実行ファイルは89MB、target/ は1.5GBありますが、CPU 2個の制限つきコンテナでこれなら文句はないです。

受信側は引数なし(サブコマンドなし)で起動すると対話モードのTUIになります。CLIといってもキーを押して操作する画面で、--help を見ると Y/N/P で受け入れ、W+S でリンク共有、といったホットキーが並んでいました。コンテナの中には端末がないので、tmux を入れてその中で動かし、tmux send-keys でキーを送り込む形にしています。

送る側はサブコマンド send --to <相手> で、こちらは画面なしで動くヘッドレスモードでした。

# sender-a
$ localsend-cli --alias sender-a send --to receiver-b hello.txt
Discovering destination alias "receiver-b"...
D receiver-b (172.20.0.3)
S receiver-b: Sent 1 file (41 B, took 0s)
S receiver-b [------------------------------] 0 B / 41 B [0 B/s] [ETA: --]
send_rc=0

# receiver-b の画面(送信が来た時点)
D [1] sender-a (172.20.0.2)
R sender-a
  Files (1, 41 B):
    hello.txt (41 B)
  Accept? Y/N/P (P = accept and pair)
# Y を押したあと
R sender-a: You accepted
R sender-a: Received 1 file (41 B, took 0s)

# 2回目: 20MB のファイルとフォルダをまとめて、今度は IP 指定で
$ localsend-cli --alias sender-a send --to 172.20.0.3 random-20mb.bin ./project-backup
# receiver-b に出た一覧
  Files (3, 21.0 MB):
    random-20mb.bin (21.0 MB)
    project-backup/src/main.rs (27 B)
    project-backup/README.md (6 B)
# ところが /recv に保存されたのは
$ find /recv -type f -printf "%s %p\n"
27 /recv/main.rs
41 /recv/hello.txt
6 /recv/README.md
20971520 /recv/random-20mb.bin

cloneからここまで、自分が Y を押すのにもたついた時間を含めて3分半くらいでした。sha256は送信側と受信側で一致。相手はエイリアス名でもIPアドレスでも指定でき、名前で指定したときは「Discovering destination alias」と出て、マルチキャストで相手を見つけてから送っています(コードを読むと 224.0.0.167 のグループに参加していました)。

ひとつだけ細かいところで、送信完了のあとに 0 B / 41 B の進捗行がもう一度描かれます。実害はないのですが、ログをファイルに落として見ると「送れてないのでは」と一瞬ヒヤッとします。

フォルダが平らになって届いた理由と、ペアリングの正体

最初に「あれ?」となったのは上の2回目です。送信側の一覧には project-backup/src/main.rs とパス付きで出ているのに、受信側の /recv には main.rs と README.md が直に置かれていました。READMEには「ディレクトリは再帰的に集められ、選んだルート名と入れ子のパスは受信側で保たれる」とあるので、最初は自分の操作を疑いました。

調べてみたところ、受信側のCLIが保存先を決めている util::unique_path から、コアの filename::sanitize_path という関数を呼んでいて、そのdocコメントに答えがそのまま書いてありました。

// packages/core/src/util/filename.rs(受信側で保存名を決める関数)
/// Sanitizes a peer-supplied *path* — a name that may carry directory
/// components, as protocol v2 allows for folder transfers — into a single
/// legal file name.
///
/// Only the last segment survives, so `../../etc/passwd` becomes `passwd`:
/// this collapses the path rather than preserving the directory structure.
pub fn sanitize_path(path: &str, rules: Rules) -> String {
    let last = path
        .rsplit(['/', '\\'])
        .find(|segment| !segment.is_empty() && !is_relative(segment))
        .unwrap_or("");

    sanitize(last, rules)
}

「最後のセグメントだけを残す。../../etc/passwd は passwd になる。ディレクトリ構造を保つのではなく潰す」。つまりわざとです。相手が送ってきたパスをそのまま書き込み先に使うと、.. を混ぜられたときに好きな場所へファイルを置かれてしまう。CLIの受信側は、それを避けるために構造ごと捨てる設計になっていました。

先輩にこの話をしたら「送ってくる名前は全部、相手が自由に書ける入力だからね。ファイル名の .. はWebで言うパストラバーサルと同じ」と言われて、ようやく腹落ちしました。READMEの「保たれる」は送信側の振る舞い(相対パスを付けて送る)の話で、受け取る側が何をするかはクライアント次第、ということなんだと思います。GUIアプリで受けたときにどう見えるかは今回試していないので、そこは分かりません。

代わりに困るのが同名ファイルです。project-backup/src/main.rs と project-backup/tests/main.rs を一緒に送ったら、受信側では main.rs(これは前の回に届いた分)、main (1).rs、main (2).rs になりました。どれがどれかは、もう分かりません。

もうひとつ知らなかったのがペアリングです。2回目の受信で P(accept and pair)を押したところ「Paired. Future requests are auto-accepted.」と出て、3回目からは確認なしで受け取るようになりました。200MBのファイルとフォルダを投げてみたら、コマンド開始から終了まで2,992ミリ秒。ただし41バイトのファイルでも5回とも2,617〜2,626ミリ秒かかっていて、エイリアス指定でもIP指定でも変わりません。差し引きすると、200MBの転送そのものは0.4秒足らずで、残りは相手を見つけるための段階的な待ち時間でした(コード上は1秒単位で段階を踏む設計に見えましたが、内訳の厳密なところまでは追えていません)。

では何をもって「ペア済み」と判断しているのか。受信側の設定ディレクトリに paired-v2.json ができていました。

// receiver-b: /root/.config/localsend-cli/paired-v2.json
{
  "version": 1,
  "devices": {
    "7EFB8009C90A966342022DA7A9BCC0B46D0547C3C912EB33A0AAB19DEB4DDF21": {
      "alias": "sender-a",
      "channels": [ { "host": "172.20.0.2", "port": 53317, "protocol": "HTTPS" } ]
    }
  }
}

このキーは、送信側の identity.pem に入っている自己署名証明書のSHA-256指紋(openssl x509 -fingerprint -sha256 で出した値)とぴったり一致しました。試しに送信側の identity.pem を含む設定ディレクトリを丸ごと消して、同じ --alias sender-a、同じIPから送り直したら、受信側には D [2] sender-a (172.20.0.2) と別デバイスとして現れ、また Accept? Y/N/P を聞かれました。名前でもIPでもなく、鍵で相手を覚えている。ここは素直に安心できるところです。ちなみにそこで N を押すと、送信側は Declined のあと Error: Transfer failed で終了コード1でした。スクリプトから使うなら、この戻り値で分岐できます。

ブラウザに見せるモードでは、サーバーごと平文になる

CLIには W+S(Share via link)と W+R(Receive via link)というホットキーがあって、LocalSendを入れていない相手ともブラウザ経由でやり取りできます。これも試しました。

送信側のTUIで W S と押すとファイル選択画面が開き、3つ選んで確定すると、こう表示されました。

S Share via link enabled: 3 files (21.0 MB), encryption is off
  Open in a browser:
   - http://172.20.0.2:53317
  (ここに端末描画のQRコードが出る)
  Press W+S again to stop sharing.

# ホスト側から curl(コンテナの 53317 を 127.0.0.1:48456 に公開)
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:48456/
200
$ curl -s -o /dev/null -w '%{http_code}\n' \
  'http://127.0.0.1:48456/api/localsend/v2/download?sessionId=bogus&fileId=5f615bed-...'
403
# TUI 側に残ったログ
S Web: 172.20.0.1 opened the shared link (Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Chrome/152.0.0.0 ...)
S Web: 172.20.0.1 downloads hello.txt

# 共有を止めたあとに、通常モードの https を curl で叩くと
$ curl -skv https://172.20.0.2:53317/api/localsend/v2/info
* OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required

「encryption is off」と正直に書いてあります。ブラウザで開いた画面がこれです。

ブラウザで開いたLocalSend CLIの共有リンク画面。見出し「LocalSend」の下に「Files (3)」とあり、緑の帯で 1 hello.txt 41 B、2 send1.log 497 B、3 random-20mb.bin 20.0 MB の3行が並んでいる

ページの各行はダウンロードリンクで、URLに sessionId=172.20.0.1 が入っています。クライアントのIPアドレスがそのままセッションIDで、でたらめな sessionId にすると403になりました。誰が開いて何を落としたかは、上のログのようにUser-Agentごと端末に流れてきます。

逆向きの W+R では、ブラウザ側に「Upload」ボタンだけの画面が出ます。ファイルを選ぶとページは「Waiting for response…」で止まり、端末側に R Web Browser / Files (1, 52 B) / Accept? Y/N と確認が来ました。Y を押すとページが「1/1 file(s)」になり、コンテナ側にファイルが保存されて、sha256も一致しました。

アップロード完了後の画面。「LocalSend」の見出しの下に緑の「Upload」ボタン、「Place items to share.」の説明、その下に「1/1 file(s)」と表示されている

ブラウザから受け取るときも端末側で承認が要るのは、知らないうちに置かれないという意味で良い挙動だと思いました。

さて、気になった「encryption is off」です。最初は「リンク共有の部分だけ平文なんだろう」と思っていたんですが、違いました。共有をONにしている間は 53317 番が丸ごと平文HTTPになり、OFFに戻すと「encryption is back on」と出て、今度は平文で叩けなくなります。そしてその通常モードのHTTPSは、上のログの通り certificate required で curl では入れません。クライアント証明書を出さないと握手が終わらない、いわゆるmTLSでした。

コアの http/server/mod.rs を読むと、mandatory_client_auth = matches!(app_state.web.share, WebShare::Disabled) という一行があって、その上に「ブラウザはクライアント証明書を持っていないので、Webページを出している間は提示を任意にする」というコメントが付いています。つまりコア側は「TLSのまま、クライアント証明書だけ任意にする」道を用意しているのですが、CLIの web_link.rs を見ると、Webモードのときは tls_config を None にして平文で再起動していました。ブラウザで自己署名証明書の警告を毎回踏ませたくない、という判断なのかなと想像していますが、理由はコードからは読み取れていません。

ここは好みが分かれるところですが、僕は「家のWi-Fiで手元のスマホに1枚渡す」用途なら気にしないし、会社のネットワークで使うなら、共有をONにしている間だけは同じセグメントの誰からでも中身が読めることを分かった上で使いたい、という感想です。ちなみに自己署名証明書の中身は CN=LocalSend User、有効期間は1975年1月1日から4096年1月1日まででした。人類より長生きする証明書です。

試したこと結果(実測)
cargo build -p localsend-cli1分10秒、365クレート、警告0、debugバイナリ89,293,344バイト
41Bをエイリアス指定で送信(初回、手動承認)発見→承認待ち→受信、sha256一致
フォルダを送信受信側で平坦化(sanitize_path の仕様)、同名は「main (1).rs」
ペア後に200MB+3ファイル自動受理、2,992ms(41Bでも2,617〜2,626ms)
送信側の identity.pem を消して再送同じ名前・IPでも新規デバイス扱いで再確認、拒否時は終了コード1
Share via link / Receive via link53317番が平文HTTPに切替、偽sessionIdは403、アップロードは端末承認制
通常モードのHTTPSにcurltlsv13 alert certificate required(mTLS必須)

細かいところだと、同じ 20,971,520 バイトのファイルが、TUIでは「21.0 MB」、ブラウザ画面では「20.0 MB」と出ます。片方はMB、片方はMiBの計算で、意味が分かれば困りませんが、初見で「別のファイル?」と思いました。「took 0s」も秒未満は切り捨てのようで、200MBが0秒と言われると嬉しいような、測れていないような。

試してみての所感

学んだことを並べておきます。

  • LocalSendにはGUIとは別にRust製のCLIがあり、コンテナ2台でも普通に動く。ビルドは1分強
  • 受信はデフォルトで手動承認。P でペアにすると以後は自動受理。ペアの単位は名前でもIPでもなく証明書の指紋
  • フォルダは送れるが、CLIで受けると構造は潰れて最後のファイル名だけになる。パストラバーサル対策として意図した挙動で、同名ファイルは連番になる
  • 端末同士の通信はクライアント証明書必須のmTLS。ブラウザ共有をONにすると、その間はポートごと平文HTTPになる
  • 送信コマンドは失敗時に終了コード1を返すので、スクリプトに組み込める

正直なところ、僕が一番覚えておきたいのは「READMEの一文が、送る側と受ける側のどちらの話なのか」を確かめる癖です。フォルダ構造の件は、コードのコメントを読むまで自分の操作ミスだと思い込んでいました。

自分の用途としては、画面のない検証用サーバーからログや生成物を手元に引き上げるのに send --to <IP> は十分使えそうです。会社のLANでブラウザ共有を常用するかは、平文になる時間を短く保てる運用が思いつくまで様子見にします。GUIアプリとCLIの間でフォルダがどう見えるかは、今度スマホを相手に試してみようと思います。