ガンバラナイ

ポートを開けずに自宅のWSL2へSSHする — Cloudflare Tunnelと短命証明書、そして1週間後にログを見た話

ポートを開けずに自宅のWSL2へSSHする — Cloudflare Tunnelと短命証明書、そして1週間後にログを見た話

はじめに

前回、PCを再起動してもClaude Codeの続きに戻るという記事で、自宅WindowsのWSL2をサーバとして常駐させる話を書きました。あの記事の冒頭で「接続経路はCloudflare Tunnelなのですが、本記事の主題はそこではありません」と切り捨てた部分が、今回の主題です。

作ったものはこれです。

  • 外からssh ssh.example.comと打つだけで、自宅のWSL2にログインできる
  • ルーターのポート開放をしていません。 グローバルIPも固定IPも要りません
  • サーバに公開鍵を1枚も置いていません。 認証のたびに有効期間4分の証明書が発行されます

Cloudflare Tunnel経由でWSL2にSSHする構成。クライアントはCloudflareエッジのAccess認証を通り、自宅のcloudflaredが内側から張ったQUIC接続を逆流してWSL2のsshdに到達する

環境は次のとおりです。

  • Windows 11 / WSL 2.6.3.0 / Ubuntu 24.04.4(systemd=true)
  • OpenSSH 9.6p1 Ubuntu-3ubuntu13.18 / cloudflared 2026.7.3
  • 構築は2026-08-07。本記事にはその後8/14までの運用ログも含みます

そして、この記事の後半は少し趣が違います。構築から1週間たってsshdのログを見返したところ、この構成の売りである短命証明書の経路を、自分はほとんど使っていませんでした。使っていたのは、同じホスト名なのに認証方式が違う別の入口でした。作り方の話と同じくらい、そちらのほうが書く価値があると思ったので後半にまとめてあります。

方式は2つある。そして「推奨」のほうを選ばなかった

最初に判断が要ったのがここです。CloudflareでSSHに短命証明書を使う方式には2系統あり、片方には公式ドキュメントに「新規導入には推奨しません」と書かれています。

Not recommended for new deployments. We recommend using Access for Infrastructure to configure short-lived certificates for SSH.

— Short-lived certificates (legacy)

URLにもshort-lived-certificates-legacyと入っている、そちらの古い方式を選びました。理由は環境要件です。

公開ホスト名 + 短命証明書 Access for Infrastructure
Cloudflareの位置づけ legacy 表記 推奨
クライアント要件 cloudflaredのみ Cloudflare One クライアント(WARP)必須(Traffic and DNS モード)
ターゲットの指定 ホスト名(トンネル経由) IPアドレス + 仮想ネットワーク
ブラウザだけの端末 使える 別機能(browser-rendered terminal)の担当
ポートフォワード 使える SSHのみの機能セット
WSL2 との相性 ◎ ✕

表の「WARP」は、端末に常駐して通信をCloudflare経由に振り向けるクライアントソフトのことです。決め手は2つでした。

1つめは、WSL2のIPアドレスが再起動のたびに変わることです。 WSL2はWindowsの中に作られた仮想ネットワークの内側(NATの内側)にいて、そのIPは固定されていません。当時は172.30.100.230でしたが、再起動すれば変わります。

一方Access for Infrastructureは、ターゲットを登録するときにIPアドレスを指定します。「Target hostname」という欄もありますが、ドキュメントにあるとおりこれはアプリ側でターゲットを識別するためのラベルで、名前解決には使われません。実体の指定はIPです。毎回ターゲット登録をやり直す運用は成り立ちません。

2つめは、WARPを入れられない端末から入りたかったことです。 出先で使うのはタブレットやスマホで、そこにクライアントソフトを常駐させたくありませんでした。トンネル方式なら、ブラウザだけの端末にも別の入口があります(この入口が後半の主役になります)。

「legacy」というラベルを見ると反射的に避けたくなりますが、推奨は「一般的なサーバ」を前提にしたもので、動的IPのWSL2は想定の外にいます。ラベルではなく要件で選び直す価値がありました。

仕組み

Cloudflare Tunnel — 外から穴を開けるのではなく、内側から張った接続を逆流させる

cloudflaredはCloudflareのエッジに向けてアウトバウンドの永続接続を張ります。外部からの接続はエッジで受け止められ、この既存の接続を逆流してローカルに届きます。

INF Registered tunnel connection connIndex=0 ... location=kix05 protocol=quic
INF Registered tunnel connection connIndex=1 ... location=kix03 protocol=quic
INF Registered tunnel connection connIndex=2 ... location=kix04 protocol=quic
INF Registered tunnel connection connIndex=3 ... location=kix05 protocol=quic

冗長化のため4本張られ、大阪(kix)の複数エッジに分散しています。

ingress(トンネルに着いたリクエストを、内側のどのサービスへ流すかの対応表)のルールは実質1行です。

{"ingress": [
  {"hostname": "ssh.example.com", "service": "ssh://localhost:22"},
  {"service": "http_status:404"}
]}

serviceがssh://localhost:22になっているのがポイントです。cloudflaredはWSLの中で動いているので、ここでいうlocalhostはWSL自身を指します。 つまり転送先の指定にIPアドレスが出てきません。前節でAccess for Infrastructureを外す理由になった「WSLのIPが変わる」という弱点が、この方式では最初から問題にならない、ということです。

結果として、インターネットから見て22番ポートはどこにも開いていません。ポートスキャンにも出てきません。

Access — SSOのログインをSSH証明書に引き換える

Cloudflare Accessは「誰がこのアプリに触れるか」を決めるレイヤです。今回の肝は、その認証結果をSSH証明書に変換する部分にあります。

ここでいうSSOは、GoogleアカウントやGitHubのように普段使っているIDでログインする仕組みのことです。今回はいちばん手軽な、メールに届く使い捨てコード(One-time PIN)を使いました。認証が通るとAccessはトークン(図のJWT)を発行し、これが有効なあいだは再認証を求められません。今回の設定では24時間です。

認証チェーンの図。IdP認証からAccessポリシー、JWT、SSH CAを経て有効期間4分の証明書が発行され、sshdがTrustedUserCAKeysで検証する。ブラウザ端末はJWTの段階で分岐し、証明書ではなくパスワード認証になる

実際に発行された証明書がこれです。

Type: [email protected] user certificate
Signing CA: ECDSA SHA256:JB7MbXCeVT... (using ecdsa-sha2-nistp256)
Key ID: "[email protected]"
Serial: 9733625262046388797
Valid: from 2026-08-08T15:49:38 to 2026-08-08T15:53:38
Principals:
        taro

有効期間4分。これが「鍵管理の手間を省く」の実体です。authorized_keysに公開鍵を追記して回る作業も、使わなくなった鍵を探して消す作業も発生しません。とくに権限を外す側はAccessのポリシーからメールアドレスを1行消すだけで、サーバには触りません(追加するときだけサーバ側に1行足します。詳しくは後述の運用の節)。

Principalsの行に出ているtaroがprincipalで、「この証明書はどの名前でログインしてよいか」を表します。証明書認証ではこれがauthorized_keysの代わりに効く、と考えると分かりやすいと思います。あとで出てくるハマりどころは、ほぼこの一語をめぐる話です。

サーバ側の設定はたった3行です。

# /etc/ssh/sshd_config.d/50-cloudflare-access.conf
PubkeyAuthentication yes
TrustedUserCAKeys /etc/ssh/cloudflare_ca.pub
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u

TrustedUserCAKeysは「このCAが署名した証明書は本物として扱う」という宣言です。AuthorizedPrincipalsFileは「その証明書のprincipalがこのファイルに載っていればログインを許す」という宣言で、次節の話につながります。

うまくいっているときのsshd側のログはこうなります。

Accepted publickey for wsluser from 127.0.0.1 port 60542 ssh2:
  ECDSA-CERT SHA256:vfcgn6YZ... ID [email protected] (serial 7860984843814319927)
  CA ECDSA SHA256:JB7MbXCeVT...

ECDSA-CERTかつCA ...が出ていれば、証明書認証が効いている証拠です。記事の後半は、この行が出ているかどうかに尽きます。

ハマりどころ

1. 証明書のprincipalは、UNIXのユーザー名と一致しない

Cloudflareが発行する証明書のprincipalはSSOメールのローカルパートになります。ドキュメントにも “Cloudflare Access will always set the principal to the user’s email address prefix” と明記されています。

一致しないので、そのままでは弾かれます。ここを橋渡しするのがAuthorizedPrincipalsFileです。

# /etc/ssh/auth_principals/wsluser
taro
[email protected]

2行書いてあるのは、Cloudflare側の仕様変更でprincipalがフルアドレスになっても動くようにするためです。実際に発行された証明書ではローカルパートのみでした。

AuthorizedPrincipalsFile /etc/ssh/auth_principals/%uとグローバルに書けば、%uがログイン先のユーザー名に展開されます。この設定は証明書認証にのみ作用し、通常のauthorized_keysによる認証には影響しません。

2. 「sshd_config.d/にMatchを書くと設定が漏れる」は、少なくともOpenSSH 9.6では起きなかった

複数ユーザーのマッピングにはMatch userを使う手もあります。今回それを避けたのは、こういう理屈を疑ったからでした。

Ubuntuの/etc/ssh/sshd_configは、Include /etc/ssh/sshd_config.d/*.confを先頭に置いています。ここで読み込まれるファイルにMatchブロックを書くと、そのブロックが閉じられないまま本体側に流れ込み、以降の全設定がそのMatch条件下に入ってしまうのではないか、と。

もっともらしいのですが、実際に試したら起きませんでした。再現できる形で置いておきます。

mkdir -p inc
cat > inc/50-test.conf <<'EOF'
Match User nosuchuser
    PasswordAuthentication no
EOF
cat > main.conf <<EOF
Include $PWD/inc/*.conf
PrintMotd no
HostKey $PWD/hostkey
EOF
ssh-keygen -q -t ed25519 -N '' -f hostkey

PrintMotdの既定値はyesです。もしMatchが漏れているなら、Includeの後ろに書いたPrintMotd noはMatchブロックの中に飲み込まれ、グローバルには効かないはずです。

sshd -Tは、設定ファイルを読んで最終的に効く値だけを吐き出すモードです。-Cで接続条件を渡すと、Matchまで評価した結果を見られます。

$ /usr/sbin/sshd -T -f main.conf | grep -E 'printmotd|passwordauth'
passwordauthentication yes      # Match の中身はグローバルに漏れていない
printmotd no                    # Include の後ろの設定はちゃんとグローバルに効いている

$ /usr/sbin/sshd -T -C user=nosuchuser,host=localhost,addr=127.0.0.1 -f main.conf | grep passwordauth
passwordauthentication no       # 当該ユーザーにはちゃんと適用される

Matchの中身はnosuchuserのときだけ適用され、Includeより後ろの設定はグローバルに効いています。Matchのスコープは、そのIncludeされたファイルの終わりで閉じています(OpenSSH 9.6p1 Ubuntu-3ubuntu13.18 で確認)。

結果として、避けた理由のほうが間違っていたわけです。ただ、選んだ%u展開の方式そのものは今も妥当だと思っています。ユーザーが増えてもsshd_configを触らずファイルを1枚足すだけで済み、条件分岐が要りません。「理由は間違っていたが結論は変わらなかった」という、後から検算して初めて分かる種類の話でした。

3. ~/.ssh/configはHostとMatchを分けたほうがいい

公式ドキュメントが提示するクライアント設定はこの形です。

Match host ssh.example.com exec "/usr/local/bin/cloudflared access ssh-gen --hostname %h"
    HostName ssh.example.com
    ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
    IdentityFile ~/.cloudflared/ssh.example.com-cf_key
    CertificateFile ~/.cloudflared/ssh.example.com-cf_key-cert.pub

4行のうちProxyCommandが経路そのものです。sshは自分でTCP接続する代わりに、ここで指定したコマンドを起動してその標準入出力を通信路として使います。cloudflared access sshがトンネルの口になっているわけです。これが無いと、sshは普通にそのホストの22番へ直接繋ぎにいきます。

そしてMatch ... execは、コマンドの終了コードが0のときだけブロックが適用されます。つまり証明書を発行するssh-genが失敗した瞬間、同じブロックに入っているProxyCommandごと消えます。

何が起きるかを実際に見てみます。上の形をそのまま、ホスト名をdemo、execを必ず失敗するコマンドに置き換えたファイルをcfg_officialとして用意し、ssh -G(接続はせず、解決後の設定だけ表示する)を叩きます。

$ ssh -G -F cfg_official demo
[exec ran]
hostname demo
identityfile ~/.ssh/id_rsa          ← 素の既定鍵に落ちている
identityfile ~/.ssh/id_ecdsa
...
                                    ← ProxyCommand が無い

ProxyCommandが消えているので、sshはトンネルを経由せずdemoの22番へ直結を試みます。当然どこにも届かず、「タイムアウト」という原因の分かりにくいエラーになります。「証明書が発行できていない」という本当の理由は、どこにも表示されません。

そこで、接続設定と証明書生成を分離しました。

Host ssh.example.com
    ProxyCommand /usr/local/bin/cloudflared access ssh --hostname %h
    IdentityFile ~/.cloudflared/ssh.example.com-cf_key
    CertificateFile ~/.cloudflared/ssh.example.com-cf_key-cert.pub
    IdentitiesOnly yes
    User wsluser

# 証明書の発行は Match の副作用として行う。失敗しても上の Host ブロックは生き残る。
Match host ssh.example.com exec "/usr/local/bin/cloudflared access ssh-gen --hostname %h"
    HostName ssh.example.com

これを同じようにdemoへ置き換えたものをcfg_splitとして、やはりexecを失敗させて確認します。

$ ssh -G -F cfg_split demo
[exec ran]
hostname demo
identityfile ~/.cloudflared/demo-cf_key
proxycommand /usr/local/bin/cloudflared access ssh --hostname %h    ← 生き残っている

ProxyCommandが常に効くので、失敗したときのエラーが「トンネルには届いているが証明書が無い」に変わります。タイムアウトと認証エラーでは、切り分けにかかる時間がまるで違います。

ひとつ注意があります。上のログの[exec ran]は、execに仕込んだ標準エラー出力です。つまり、設定を表示するだけに見えるssh -Gが、Matchのexecを実際に実行しています。設定を確認するだけのつもりで本物のホスト名に対してssh -Gを叩くと、裏でssh-genが走ってブラウザ認証を待ち始めます。今回まさにそれで端末が固まりました。-Gは副作用の無いコマンドではありません。

4. DNSのネガティブキャッシュ。しかもAレコードだけ引けない

セットアップ中、DNSレコードを作る前に何度かssh.example.comを引いてしまいました。その結果「存在しない(NXDOMAIN)」という否定的な結果がリゾルバ(名前を引きにいく側のDNSサーバ)にキャッシュされ、レコードを作った後もしばらく解決できませんでした。この否定的な結果をどれだけ覚えておくかは、ドメイン側のSOAレコードに書かれたminimumという値で決まります。今回は1800秒だったので、最大30分です。

症状が紛らわしかったのはこの組み合わせです。

$ getent ahostsv4 ssh.example.com           → 失敗
$ getent ahosts   ssh.example.com           → たまに IPv6 だけ返る
$ nslookup -type=A ssh.example.com 1.1.1.1  → ちゃんと返る

Aレコード(IPv4のアドレス)だけが引けず、AAAA(IPv6のアドレス)は引ける。 設定が間違っているなら両方引けないはずなので、この非対称さ自体が手がかりになりました。リゾルバによっては否定的な結果を「名前とレコード種別の組」で覚えるので、Aだけを引いてしまっていたなら、Aだけが引けない状態になります。実際そうなっていました。公開DNS(1.1.1.1)に直接聞けば正常に返るので、そこでも切り分けられます。

以前Home Assistant用のトンネルを張ったときにも同じ罠を踏んでいます。ただしあのときはブラウザもローカルも一律に引けず、A・AAAAの違いまでは見ていませんでした。同じ原因でも、見え方は状況で変わります。 覚えるべきは症状のパターンではなく、「公開DNSに直接聞いて層を切り分ける」という手順のほうでした。

教訓としては、レコードを作る前に名前を引かないことに尽きます。引いてしまったら、それは設定ミスではありません。

5. ダッシュボードが分からなくなったら、APIに逃げる

これは技術的な罠ではなく進め方の話です。トンネル作成の途中でCloudflareのダッシュボードのUIが変わっていて手が止まったので、残りを全部APIで片付けました。Tunnel Tokenをbase64デコードするとAccount IDとTunnel IDが入っているので、そこから先はAPIだけで完結します。

GET  /user/tokens/verify
GET  /zones?name=<domain>
PUT  /accounts/{acc}/cfd_tunnel/{tunnel}/configurations     # ingress
POST /zones/{zone}/dns_records                              # CNAME → <tunnel>.cfargotunnel.com
POST /accounts/{acc}/access/apps                            # type: "ssh" = ブラウザ端末に対応
POST /accounts/{acc}/access/apps/{app}/ca                   # 短命証明書の CA を発行
GET  /accounts/{acc}/access/apps/{app}/ca                   # CA 公開鍵を取得

APIトークンの権限は必要最小限に絞ります。

  • Account | Cloudflare Tunnel | Edit
  • Account | Access: Apps and Policies | Edit
  • Account | Access: Organizations, Identity Providers, and Groups | Read
  • Zone | DNS | Edit(対象ゾーンのみ)

GUIの手順書は陳腐化しますが、APIは残ります。 記事や公式手順のスクリーンショットと画面が食い違ったときの逃げ道として、この手は覚えておく価値がありました。

1週間後、ログを見たら短命証明書を使っていなかった

ここからが本題です。

構築から1週間たった2026-08-14に、sshdのログを見返しました。journal(systemdが集めているログ)に残っていたのは8/12以降の分だけでしたが、その全ログインが同じ形をしていました。抜粋します。

Aug 12 16:17:15 sshd[1857014]: Invalid user taro from 127.0.0.1 port 49916
Aug 12 16:17:33 sshd[1857093]: Accepted password for wsluser from 127.0.0.1 port 49920 ssh2
Aug 13 08:32:24 sshd[391737]:  Invalid user taro from 127.0.0.1 port 57024
Aug 13 08:32:35 sshd[391739]:  Accepted password for wsluser from 127.0.0.1 port 57030 ssh2
Aug 14 12:57:58 sshd[3067270]: Invalid user taro from 127.0.0.1 port 35682
Aug 14 12:58:14 sshd[3067272]: Accepted password for wsluser from 127.0.0.1 port 35698 ssh2

全体を集計するとこうです。

$ journalctl -u ssh | grep -c 'ECDSA-CERT'
0
$ journalctl -u ssh | grep -oE 'Accepted (password|publickey)' | sort | uniq -c
      5 Accepted password

証明書によるログインはゼロ。この期間の5回すべてがパスワード認証でした。

journalは8/12以降しか残っていないので、これだけでは1週間ぶんの証拠になりません。そこで手元の証明書ファイルを見ると、Valid: from 2026-08-08T15:49:38のままでした。素のsshで入ればMatch execが毎回ssh-genを走らせて証明書を作り直すので、このファイルが8/8で止まっている=6日間その経路を1度も通っていない、と言えます。

つまり、この構成の売りである「鍵管理をなくす短命証明書」を、私は日常的には使っていませんでした。

なぜそうなるのか

犯人はブラウザ端末(https://ssh.example.comをブラウザで開くとシェルが出る機能)です。ログの形がそれを示しています。

1行目のInvalid user taroは、SSOメールのローカルパート(@より前の部分)です。ブラウザ端末はログイン中のAccessアイデンティティをそのままUNIXユーザー名として接続を試みます。そんなユーザーは居ないので弾かれ、人間がwsluserに打ち直すと、2行目のAccepted passwordで通ります。この2行が毎回10〜20秒差でセットになっているのが、その打ち直しの痕跡です。

これは不具合ではなく、ドキュメントに書いてある仕様でした。ブラウザ端末を使うには “user email prefixes must match their username on the server” とあります。サーバ側のユーザー名をメールのローカルパートに合わせろ、という前提で作られています。

/etc/ssh/auth_principals/wsluserを用意したのは証明書のprincipalをUNIXユーザーに対応づける設定であって、クライアントが名乗るユーザー名を書き換えるものではありません。ここが紛らわしく、私は当初これで解決していると思い込んでいました。

そして素のsshでは~/.ssh/configにUser wsluserと書いてあるので、この問題は表面化しません。入口が変われば認証経路も変わるわけです。

なぜ使う入口がそちらに寄ったのか

理由は単純で、出先で開くのがタブレットだからです。ブラウザでURLを開けばシェルが出てくる手軽さには、cloudflaredを入れて~/.ssh/configを書く手順は勝てませんでした。前回の記事に書いたc <project>のような「1コマンドで作業に戻る」工夫と、動機は同じです。

問題は、その手軽な入口が、当初の要件を満たさない経路だったことです。要件は「(a) WSL2にSSHしたい (b) 再起動後も生きていてほしい (c) 短命証明書で鍵管理をなくしたい」の3つでした。実際に使っている経路では(c)が抜け落ちています。

直すなら

対処の方向は3つあります。

  • (A) UNIX側にtaroを作り、wsluserと同等に扱う。 ドキュメントが想定している形に寄せる案です。ユーザー名の打ち直しは消えますが、それで証明書認証になるかは別問題として残ります
  • (B) 出先の端末にもcloudflaredを入れて素のsshに寄せる。 要件は完全に満たせますが、いちばん手軽だから選んでいた入口を捨てることになり、おそらく続きません
  • (C) ブラウザ端末は緊急用と割り切り、常用はPCからのsshに戻す。 運用でカバーする案です

正直に書くと、現状は(C)ですらありません。ブラウザ端末が緊急用ではなく常用になっていて、要件(c)が満たせていない状態です。まず(A)を試すのが次の一手だと思っています。

この節の教訓

  • 構成の売りが実際に使われているかは、ログを見るまで分かりません。 「証明書でログインできることを確認した」は「証明書でログインしている」ではありませんでした。前者は構築直後に検証済みで、私はそこで満足していました
  • sshdは認証方式を必ずログに書いてくれます。 publickeyかpasswordか、ECDSA-CERTが付いているか。繋がった後に一度は目で確認する価値があります
  • 前回の記事で「AIに作らせた構成は、AI自身が書いた未検証リストが本当のTODOだ」と書きました。今回はその先があって、検証済みリストのほうも「使われているか」までは保証していません

セキュリティ

攻撃面はどう変わったか

従来のポート開放 この構成
インターネットから22番 開いている 開いていない
ポートスキャンで見つかるか 見つかる 見つからない
総当たり攻撃 受ける(ログが埋まる) sshdまで到達しない
認証前の攻撃面 sshd本体 Cloudflareのエッジ
必要なルーター設定 ポートフォワード なし

sshdに到達するには、先にCloudflare Accessの認証を通る必要があります。未認証のトラフィックはエッジで落ちるので、sshdの脆弱性を突く攻撃は認証済みユーザーしか試せません。認証前の攻撃面が自分のsshdからCloudflareのエッジに移った、というのがこの構成の実質です。

監査の面でも効きます。証明書にはKey IDとしてメールアドレスが入り、1枚ごとに異なるSerialも付くので、Cloudflare側のログ(Zero Trust > Logs)とsshdのログを突き合わせられます。共有アカウントwsluserで入っていても、サーバ側のログだけで個人が特定できる形です。ただし前節のとおり、現状の主経路はパスワード認証なので、この利点も効いていません。

パスワード認証を塞がなかった判断

sshd_configにPasswordAuthenticationの記述が無く、OpenSSHの既定であるyesのままにしてあります。当初は「落ち着いたら塞ぐ」と考えていたのですが、入れないことにしました。

理由は、塞いで得られるものが実質ないと判断したからです。この構成でインターネットから22番に届く経路はCloudflare Tunnelだけで、そこはAccessの認証を通らないと開きません。0.0.0.0:22で待ち受けてはいるものの、WSL2はNATの内側なのでLANからも直接は届きません。

一方でコストははっきりあります。サーバ側にauthorized_keysを1枚も置いていない構成なので、証明書の経路が壊れるとパスワードが最後の入口になります。塞ぐと、その最後の一枚を自分で外すことになります(実際にはWindowsからwslと打てばコンソールに入れるので締め出しは起きませんが、それでも「守るものが増えないのに復旧経路だけ減る」変更は割に合わないという整理です)。

そして前節の発見のあと、この判断の意味は変わりました。いま塞ぐと、実際に使っている出先からの主経路がその瞬間に死にます。 据え置きという結論は同じでも、理由は「保険だから」から「主経路だから」に変わっています。これは強くなったのではなく、弱くなったほうへの変化です。

塞ぐべきなのは次の2つのどちらかをやるときです。

  • Windows側でportproxy(Windowsが受けたポートをWSL側へ転送する機能)を張って22番を外に出した場合
  • WSLをNATではなくブリッジ構成(networkingMode=bridged。WSLがLAN上に直接IPを持つ形)にした場合

このどちらかをやるなら、同時にPasswordAuthentication noを入れる必要があります。そのときは先にブラウザ端末の経路を(A)か(B)で直しておくことになります。

秘密情報の受け渡し

この作業はClaude Codeに手伝わせたのですが、Tunnel Tokenをチャットに直接貼ってしまいました。トンネルを名乗れてしまう秘密情報なので、これは失敗です。

途中から方式を変えて、APIトークンはチャットを経由せずファイルで渡しました。

read -rs -p 'Token: ' t && printf '%s' "$t" > ~/.cf_api_token && chmod 600 ~/.cf_api_token && unset t

-sでエコーを止めているので画面にも出ず、シェル履歴にも残りません。エージェントはファイルを読むだけなので、トークン本文は会話ログに残りません。AIエージェントに秘密情報を扱わせるときは、この形を基本にしたいと思っています。

作業のために一時的に付けたNOPASSWD sudoも、撤去したつもりで残るのがいちばん怖いところです。目視ではなくsudo自身に聞くのが確実でした。

$ sudo -n -l
sudo: a password is required     # これが出れば撤去できている

-n(non-interactive)を付けると、パスワードを要求される状況ならプロンプトを出さずに失敗します。パスワードを打たずに「NOPASSWDが残っていないこと」を判定できます。 /etc/sudoers.d/を眺めるだけでは/etc/sudoers本体に直接書かれた行を見落とすので、こちらのほうが本命です。

実測値と、できること・できないこと

構築時に測った値です。計測はWSL → 大阪エッジ → 同じWSLに戻るループバック経路なので、実際の遠隔地からはもう少し変わります。

項目 結果
接続確立(2回目以降) 約 1.1 秒
接続確立(初回・証明書発行を含む) 約 2.6 秒
scp 5MB 1.43 秒(約 3.5 MB/s)、md5一致
ローカルポートフォワード -L 動作
sftp 動作

1秒強のオーバーヘッドは素のSSH(数十ms)に比べれば大きいものの、対話利用では気になりません。ControlMaster(1本の接続を張ったまま以降のセッションを相乗りさせるSSHの機能)を使えば、2回目以降のこの待ちは消せます。

cloudflared access sshは素のTCPをトンネルするだけなので、SSHの機能はほぼそのまま使えます。

  • ポートフォワード(-L / -R)
  • scp / sftp / rsync
  • VS Code Remote SSH(クライアントにcloudflaredと~/.ssh/configがあれば可)
  • ブラウザ端末。クライアント側に何も要らない代わりに、ユーザー名の打ち直しが要り、認証もパスワードになる

クライアントを増やすときは、cloudflaredを入れて~/.ssh/configを書くだけです。鍵の配布は発生しません。 新しいマシンでも「ブラウザで認証 → つながる」で終わります。

コストは無料です。Cloudflare Zero TrustのFreeプランは50ユーザーまで、Tunnelも無料で、必要なのはCloudflareで管理しているドメイン1つだけでした。

運用

人を追加する / 外す

追加は2手です。AccessアプリのポリシーにEmailを追加し、サーバの/etc/ssh/auth_principals/<UNIXユーザー名>にメールのローカルパートを1行足します。

外すのは1手で、AccessポリシーからEmailを削除するだけです。サーバ側の作業は要りません。 発行済みの証明書も4分で期限切れになります。「退職者の鍵を消し忘れる」という事故が構造的に起きないのが、この方式のいちばん効くところだと思います。

cloudflaredの更新と、1か所だけ足した設定

cloudflared service installを実行すると、サービス定義と一緒に日次の更新用timerまで置かれます。本体は--no-autoupdateで走り、更新は別のユニットが担当する、という分担です。ここは既定のままにしてあります。

# /etc/systemd/system/cloudflared-update.service(インストーラが生成したもの)
ExecStart=/bin/bash -c '/usr/bin/cloudflared update; code=$?; if [ $code -eq 11 ]; then systemctl restart cloudflared; exit 0; fi; exit $code'

cloudflared updateは更新があったときだけ終了コード11を返すので、そのときだけ再起動する作りです。

自分で足したのは1か所だけで、Restart=alwaysのdrop-in(ユニット定義の一部を上書きする追加ファイル)です。

# /etc/systemd/system/cloudflared.service.d/override.conf
[Service]
Restart=always

インストーラが書く定義はRestart=on-failureで、これは異常終了したときしか再起動しません。ところが前回の記事で見たように、cloudflaredは繋ぎ先が消えるとno more connections active and exitingと言って正常終了で抜けることがあります。その場合on-failureでは上がってこないので、alwaysに変えました。

ロールバック

sudo systemctl disable --now cloudflared && sudo cloudflared service uninstall
sudo rm -f /etc/ssh/sshd_config.d/50-cloudflare-access.conf /etc/ssh/cloudflare_ca.pub
sudo rm -rf /etc/ssh/auth_principals
sudo systemctl restart ssh.socket ssh.service

ssh.socketだけでなくssh.serviceも再起動しているのは、Ubuntu 24.04がsocket activation(接続が来たときに初めてsshdを起こす方式)を使っていて、設定を読み直させたい相手はssh.serviceのほうだからです。

Cloudflare側はトンネル / Accessアプリ / DNSレコードを削除します。

まとめ

  • ポート開放なしで自宅にSSHする経路は、無料で、ルーターを一切触らずに作れます。 cloudflaredが内側から張った接続を逆流させる仕組みなので、22番はどこにも開きません
  • 「legacy」と書かれた方式のほうが正解のことがあります。 推奨の Access for Infrastructure はターゲットをIPで登録し、クライアントにWARPを要求します。動的IPのWSL2とブラウザだけの端末、という要件には合いませんでした
  • ~/.ssh/configはHostとMatch execを分けます。 公式の形のままだと、証明書の発行に失敗した瞬間ProxyCommandごと消えて、原因の分からないタイムアウトになります。なおssh -Gはexecを本当に実行するので、確認のつもりで叩くと副作用があります
  • sshd_config.d/のMatchが全体に漏れることは、OpenSSH 9.6では起きませんでした。 避けた理由のほうが間違っていた例です。設定の挙動はsshd -Tで数分あれば検算できます
  • 構成の売りが使われているかは、ログを見るまで分かりません。 短命証明書の経路を作り、動作を検証し、それで満足していた1週間のあいだ、実際のログインは全部パスワード認証でした

最後の点が、今回いちばん残ったものです。前回の記事では「未検証と書いた項目がそのまま本番で壊れた」と書きました。今回は逆で、検証済みの項目が、検証したまま使われずに置いてあるという壊れ方でした。動くことを確かめるのと、それが使われていることを確かめるのは、別の作業です。