Raspberry Pi 5を堅牢化する。SSH鍵認証・UFW・fail2banと、再起動でIPv6が復活する罠

セットアップ直後のRaspberry Pi 5は、パスワード認証が有効なまま、ファイアウォールなし、更新可能なパッケージ172件と無防備です。SSHの鍵認証化・UFW・fail2ban・自動更新・IPv6無効化を実コマンドと実測検収で解説します。sysctlのIPv6無効化が再起動で復活する現象と恒久対策も実機で確認しました。

Raspberry Pi 5を堅牢化する。SSH鍵認証・UFW・fail2banと、再起動でIPv6が復活する罠

こんにちは!
Qualitegプロダクト開発部です!

前回の記事で、モニターなしのRaspberry Pi 5をWindows PCだけでセットアップし、SDカード挿入から約3分でSSH接続まで到達しました。

このラズパイ、ゆくゆくは小さなWebサーバーを載せてインターネット側から使えるようにする予定です。

ただ、その前にやることがあります。

セットアップ直後のRaspberry Pi OSは、実測してみると思った以上に無防備

でした。

前回の検証用セットアップで設定した簡易なパスワードでパスワード認証が有効なまま、ファイアウォールなし、更新可能なパッケージが172件たまり、そして意図していないのにIPv6のグローバルアドレスまで付いていました。

この記事では、

SSHの鍵認証化・UFW・fail2ban・更新の自動適用・IPv6無効化までを実コマンドでやり切り、実際に再起動して設定が生き残ることまで検収します

先にことわっておくと、この検収で1回つまずきました。

sysctlで無効化したはずのIPv6が、再起動したら復活していたのです。

原因はNetworkManagerの接続プロファイルでした。

この顛末は後半で詳しく書きます。

この記事の対象はRaspberry Pi OS(64-bit・Debian 13 trixieベース)です。

作業はすべてWindows PCからSSH越しに行い、ラズパイにモニターは最後まで繋ぎません。記事中の数値は2026年8月14日時点の実測値です。

図1 Raspberry Pi 5 連載の全体像
図1 連載の全体像。今回は②の堅牢化で、前回のセットアップ直後の状態から外に出せる土台をつくる

初期状態を実測したら、ここまで無防備だった

手を入れる前に、まず現状を実測しました。「たぶん危ない」ではなく、どこがどう開いているかを確かめてから塞ぎます。

観点初期状態(実測)何がまずいか
SSH認証パスワード認証が有効(パスワードは前回設定した検証用の簡易なもの)総当たり攻撃の対象になる
rootログインpermitrootlogin without-passwordroot直ログインの余地が残る
ファイアウォールなし(ufw未導入・nftルール0件)全ポート素通し
ブルートフォース対策なし(fail2ban未導入)何回でも試行され放題
IPv6グローバルアドレスを保持・[::]:22 で待受運用対象にしていない経路が残っている
更新更新可能なパッケージ172件・自動更新なし既知の修正が当たらないまま残る
待受ポート22・111(rpcbind)がすべて0.0.0.0で待受(このほか検証用に立てたWebサーバーの8080も稼働中)使っていないサービスまで露出

IPv6は意外に思うかもしれません。LAN内運用のつもりでも、ルーター広告(RA)を受けて 2409: で始まるグローバルIPv6アドレスが自動で付いていました。

IPv6のグローバルアドレスはNATを前提としないため、IPv4とは露出の考え方が異なります。

本記事ではIPv6を運用対象にしない方針なので、使わない経路を残さないために明示的に無効化します。

方針。ポートは開けない、公開はトンネルで

堅牢化の方針はこう決めました。

SSHは鍵認証のみにして、接続元も自宅LANに限定します。それ以外の受信ポートは、Webサーバー用の8080を含めて一切開けません

「外から使えるようにしたいのにポートを開けない」のは矛盾に見えますが、公開は次回、WireCanalのトンネル経由で行います。トンネル方式ならラズパイ側は外向きの接続だけで済むので、受信ポートを開ける必要も、ルーターのポート開放も要りません。

つまり今回の堅牢化は「全部閉じる」、
次回の公開は「閉じたまま外に出す」という分担です。

手順1: SSH鍵を仕込む(締め出さない順序がすべて)

最初に鍵を仕込みます。ここで鉄則がひとつあります。鍵ログインの成功を実測してから、パスワード認証を止める。順序を誤ると、自分がSSHで入れなくなります。モニターなし運用のラズパイでは、ヘッドレスのままの復旧がかなり面倒になるので、この順序だけは崩せません。

SSH鍵をまだ持っていない場合は、先にWindows側で ssh-keygen -t ed25519 を実行して作っておきます。

途中で訊かれるパスフレーズは設定しておくのがおすすめです(鍵ファイルが漏れたときの保険になります)。公開鍵は %USERPROFILE%\.ssh\id_ed25519.pub にできます。

まず、ラズパイ側に公開鍵を置きます。

# ラズパイ側(piユーザーで実行)
D=/home/pi/.ssh
mkdir -p "$D"; chmod 700 "$D"
cat > "$D/authorized_keys" <<'EOF'
ssh-ed25519 AAAA...(あなたの公開鍵)
EOF
chmod 600 "$D/authorized_keys"; chown -R pi:pi "$D"

なお、cat > は既存の authorized_keys を丸ごと上書きします。今回は新規構築直後なのでこの書き方にしていますが、既に鍵を登録してある環境では追記(>>)にしてください。

GitHubを使っている方は、https://github.com/<アカウント名>.keys で自分の公開鍵一覧を取得できるので、そこからコピーするのが手早いです。

置いたら、Windows側から鍵だけでログインできることを確認します。

ssh -o PasswordAuthentication=no pi@192.168.12.17

これが通ってから次へ進みます。

手順2: パスワード変更・IPv6無効化・SSHを鍵のみにする

ここからはroot権限での作業です(sudo -i で入るか、各コマンドに sudo を付けてください)。まずパスワードの変更です。前回の検証用の簡易なパスワードから、きちんとしたものに変えます。鍵のみ化した後もsudoでパスワードは使うので、ここで整えておきます。

passwd pi

次に、OS側でIPv6を無効化します。

cat > /etc/sysctl.d/99-disable-ipv6.conf <<'EOF'
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
EOF
sysctl --system

そしてSSHを固めます。設定は本体ファイルを書き換えず、drop-inで追加します。

cat > /etc/ssh/sshd_config.d/00-hardening.conf <<'EOF'
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AddressFamily inet
EOF
sshd -t && systemctl restart ssh

AddressFamily inet はIPv4のみで待ち受ける指定です。UbuntuだとsshdがソケットActivation(ssh.socket)経由のことがあり、socket側にもdrop-inが要るのですが、今回の実機では ssh は service 直起動(ssh.socket は disabled)だったので、sshd_config の指定だけで [::]:22 が消えました(実測)。

ここで一度、別のターミナルからパスワード認証を試しておきます。拒否されれば成功です。既存の鍵ログインが生きていることも忘れずに確認します。

パスワード認証が拒否され、鍵ログインは通ることの実測
パスワード認証は Permission denied (publickey)、鍵ログインは通る(実測)

手順3: 保留172件の更新と、fail2ban・自動更新

export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get -y -q full-upgrade          # 保留172件を全適用
apt-get -y -q install ufw fail2ban unattended-upgrades

fail2banはSSHへの試行失敗を数えて自動でBANしてくれる番人です。ひとつ押さえておきたいのは、今回のRaspberry Pi OS trixie実機にはrsyslogが入っておらず /var/log/auth.log も存在しないことです。

sshdのログはjournaldから読むことになります。Debianのfail2banパッケージにはsshd向けにjournaldを見る設定(sshd_backend = systemd)が入っていますが、古い解説記事はauth.logを前提にしていることが多いので、今回はjail設定でもbackendを明示しました。

cat > /etc/fail2ban/jail.d/sshd.local <<'EOF'
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 1h
EOF
systemctl enable --now fail2ban

更新の自動適用(unattended-upgrades)も入れます。

夜中に勝手に再起動されると困るので、自動再起動だけは切っておきます。なお定期実行の実体はAPT側のタイマー(apt-daily.timer / apt-daily-upgrade.timer)なので、検収ではタイマーが有効なことまで確認します。

cat > /etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
EOF
cat > /etc/apt/apt.conf.d/52unattended-upgrades-local <<'EOF'
Unattended-Upgrade::Automatic-Reboot "false";
EOF
systemctl enable --now unattended-upgrades

ひとつ、full-upgradeの副作用で面白い発見がありました。

今回の実機では、初期状態に /etc/sudoers.d/010_pi-nopasswd があってsudoがパスワードなしで通っていたのですが、

full-upgrade後にこのファイルが消えて、sudoがパスワード必須に変わりました
(今回の環境での実測で、一般の仕様変更として確認したものではありません)。堅牢化としては望ましい方向なので、そのまま採用しています。

手順4: UFWは「許可を先に入れてから」有効化する

ファイアウォールで怖いのは自分の締め出しです。UFWはインストール直後は無効(disabled)なので、既定ポリシーを設定した瞬間に遮断が始まるわけではありません。

危険なのは、SSHの許可ルールを入れないまま ufw enable することです。

だから許可ルールを先に入れてから有効化します。

ufw default deny incoming
ufw default allow outgoing
ufw allow from 192.168.12.0/24 to any port 22 proto tcp   # 自宅LANのセグメントに合わせる
ufw --force enable

新規の受信は原則拒否し、ユーザー定義の許可ルールは自宅LANからのSSH(22)だけにします。検証用に動かしているWebサーバーの8080も、あえて許可しません(次回、ここをトンネルで公開します)。有効化したら、いまのSSHセッションは閉じずに、別のターミナルから新しくSSH接続できることを確認してください。

検収。全項目を実測で通す

設定を書き終わったら検収です。「設定ファイルにそう書いたから大丈夫」ではなく、実際の挙動を外形から確かめます。

検収項目実測結果
鍵ログイン成功
パスワード認証拒否(Permission denied (publickey))
sshd実効設定(sshd -T)passwordauthentication no / permitrootlogin no / addressfamily inet
[::]:22 の待受消滅(ss -tlnH で確認)
IPv6グローバルアドレス0件(ip -6 addr show scope global が空)
UFWactive・deny incoming・22/tcpのLAN限定許可のみ
ポート外形開発機からTCP22=到達・TCP8080=遮断
fail2bansshd jail稼働(journald監視)
自動更新apt-daily.timer / apt-daily-upgrade.timer とも enabled・次回実行が予定されている・Automatic-Reboot false
更新可能パッケージ172件 → 0件
IPv6グローバルアドレスが空・8080が外形から遮断されていることの実測
IPv6グローバルアドレスは0件。8080はサーバー内で待受していても、外からは繋がらない(実測)

ここまでは順調でした。問題は次です。

再起動したら、IPv6が復活していた

設定の永続性は、実際に再起動しないと分かりません。1回目の再起動検収では、再起動をかけてから約21秒でSSHが復帰し、UFWもfail2banも自動起動していました。ところが。

ip -6 addr show scope global
# → 2409:xx:xxxx:... が、いる

消したはずのIPv6グローバルアドレスが、復活していました。

原因はNetworkManagerでした。接続プロファイル側でIPv6が有効なままだったため、sysctlの disable_ipv6 を適用していても、

Wi-Fi接続が確立するタイミングでインターフェース単位の disable_ipv6 が0に書き戻されていました(今回の環境での観測)。

適用直後の確認では ip -6 addr が空になるので成功に見える。しかし再起動してWi-Fiが繋ぎ直されると、設定が巻き戻る。この動きは1回目の再起動検収で初めて表面化しました。

恒久化するには、NetworkManagerの接続プロファイル側でIPv6を無効にします。

# プロファイル名を確認(今回の実機では preconfigured という名前でした)
nmcli connection show --active

# プロファイル側でIPv6を無効化
nmcli connection modify "<上で確認した接続名>" ipv6.method "disabled"

これを入れて2回目の再起動検収を実施し、今度は再起動後もグローバルアドレス0件を維持できました。

この件の教訓は、IPv6の設定方法そのものより、こちらだと思っています。

「設定を入れて、その場で確認した」だけでは、永続性は保証されない。再起動まで含めて検収して、初めて完了になる。

検収で1項目でも落ちたら全体を未完として扱い、直してから再検収する。この運用にしていたおかげで、「無効化したつもりの設定が、再起動後には元に戻っていた」状態を公開前に見つけられました。

今回のtrixie実機で確認した、古い手順との違い

Debian 13 trixieベースになって、昔からある手順の前提と違っていた箇所がいくつかありました。

今回の実機で確認できた範囲で整理しておきます。

古い前提今回のtrixie実機での実際
fail2banは /var/log/auth.log を読むrsyslogが無くauth.logも存在しない。sshdのログはjournaldから読む(Debianパッケージ側で sshd_backend = systemd 設定済み)
piユーザーのsudoはNOPASSWDfull-upgradeで 010_pi-nopasswd が消えパスワード必須化(今回の環境での実測)
[::]:22 退治にはssh.socketのdrop-inが必要(Ubuntu系)今回の実機はサービス直起動で、AddressFamily inet だけで済んだ

まとめ

セットアップ直後のRaspberry Pi 5は、検証用の簡易なパスワードでパスワード認証が有効・ファイアウォールなし・更新可能パッケージ172件・意図しないIPv6アドレス保持、という状態でした。

今回の作業で、SSHは鍵のみ・接続元LAN限定になり、UFWは受信を原則拒否してSSHだけを自宅LANから明示的に許可する構成になり、unattended-upgradesの対象となる更新は自動適用されるようになりました。

作業はすべてSSH越しで、モニターは一度も繋いでいません。

いちばんの収穫は、sysctlのIPv6無効化がNetworkManagerの接続プロファイルに書き戻される現象を、実再起動の検収で捕まえられたことです。設定直後の確認だけで完了にしていたら、この巻き戻りには公開後まで気づけないところでした。

次回はいよいよ、このラズパイの上のWebサーバーを、受信ポートを一切開けないままWireCanalでインターネット側から使えるようにします。

それでは、また次回をお楽しみに!

出典・参考

関連記事

Read more

LINE Botをローカルで開発する。WebhookのURLが毎回変わる問題を固定URLで解決する

LINE Botをローカルで開発する。WebhookのURLが毎回変わる問題を固定URLで解決する

こんにちは! LINE Botを作ろうとして最初にぶつかる壁は、コードではなくWebhookです。 LINEのMessaging APIは、ユーザーがBotに送ったメッセージを、LINEのサーバーからこちらのサーバーへHTTPSのPOSTで通知してきます。 この受け口(Webhook URL)にはインターネットから届くHTTPSのURLしか指定できず、開発中の http://localhost:5000 をそのまま書くことはできません。 トンネルツールで一時URLを発行して回避する方法が定番ですが、今度は別の問題が待っています。トンネルの方式によっては、起動のたびにランダムなURLが発行されます。その場合、開発を再開するたびにLINE Developersコンソールを開いてWebhook URLを書き換えることになります。コードを直すたび、翌日再開するたび、まずURLの貼り直しから。これが地味に堪えます。 結論から言うと、 ローカルPCの開発サーバーに「変わらない公開URL」を1本付けてしまえば、Webhook URLの設定は最初の1回だけで終わります。 この記事では、L

By Qualiteg プロダクト開発部
MCPサーバーの作り方 — 自作MCPサーバーをWeb版のChatGPT・Claudeから使えるようにする(後編)

MCPサーバーの作り方 — 自作MCPサーバーをWeb版のChatGPT・Claudeから使えるようにする(後編)

自作MCPサーバーをWeb版のChatGPT・Claudeから使えるようにする手順を、実際に接続した画面つきで解説します。localhostのサーバーに公開URLとOAuth認証を付け、コードを1行も変えずにブラウザのAIから売上データベースへ日本語で聞けるようにします。

By Qualiteg プロダクト開発部