演習進捗 0 / 5 未完了
⏱ 約 70 分 · sudoers・Match・snapshot 復旧
Part 3Chapter 04/サーバ運用

sudo と SSH 鍵でアクセスを固める

構文検査に通る「正しい」設定で、自分の sudo を失います。SSH では入れるのに、そのファイルを消せない ── 内側の手段が尽きた状態から、ハイパーバイザに降りて復旧します。クラウドでは、この「外側」に手が届きません。

教材は登録なしで読めます。実機でコマンドを試す場合のみ、Google ログインで2時間枠の予約が必要です。
所要時間約 70 分(概念 20 分+演習 50 分)
章タイプstandard
前提知識Part 1 Chapter 1(SSH の基礎)/ Part 3 Chapter 3(ユーザ・グループ・鍵認証の手順)
使用機材踏み台 workstation01 + 演習用サーバ learner01(Proxmox 電源・スナップショット操作あり)
関連章前: Chapter 3「ユーザ・グループ・権限を設計する」/ 後: Chapter 5「ディスクを拡張する」

正しい順番でやらないと、直せる人が誰もいなくなる

Chapter 3 ではアクセスを設計しました。この章で扱うのは、それを安全に固める作業です。設計と運用は別物で、固める作業には固有の事故があります。

この章で実機でしか学べないのは ハイパーバイザから、内側では直せなくなったサーバに外側から手を入れることです。SSH が通らなくなる、あるいは sudo が完全に死ぬ ── どちらも本番で日常的に起きます。そのとき残る道は、仮想化基盤に降りることだけです。クラウドのマネージドサービスでは、この「外側」に手が届きません。

この章の安全設計(先に読んでください)

「締め出しを体験する」章ですが、あなた自身(管理者 ops)が SSH から締め出される演習ではありません。

6 つのフェーズ
フェーズ A ①  実効設定を実測して記録し、ops の鍵で入れることを先に証明する
      ↓        (=回復経路の確保)
フェーズ B ②  snapshot を取る(★まだ何も変更していない時点)
      ↓
フェーズ C ③④ 演習ユーザと鍵を用意し、sudo を最小権限で設計する
      ↓
フェーズ D ⑤  壊す ── D-1: 鍵を持たない labuser02 が締め出される
      ↓            D-2: 検査を通した sudoers 配置で ops 自身の sudo が死ぬ
フェーズ E ⑥  戻す ── Proxmox snapshot rollback(★内側に手段が無い=唯一の出口)
      ↓
フェーズ F ⑦  正しい順番でやり直し、実効値で検証し、片付ける

ops はフェーズ A で鍵を登録し、鍵で入れることを確認してから先へ進みます。したがって ops の SSH は最後まで生きています。しかしフェーズ D-2 では、構文検査を通るルールが ops 自身の sudo を打ち消します。「入れるのに、直せない」という状態です。

D-1 と D-2 は壊す対象が違います。 D-1 で SSH から締め出されるのは使い捨てユーザ labuser02、D-2 で sudo を失うのは管理者 ops です。ops の SSH は残すので、「入れない」ではなく「入れるのに直せない」を安全に体験できます。

この章のゴール: 引数まで通さない最小権限の sudo を設計し、対象ユーザだけを鍵限定にし、自分の sudo を失った状態からスナップショットで復旧します。最後に正しい順番でやり直して、実効値で検証します。

この章を終えるとできること

アクセスを固める 4 動詞を手に覚えます。

sudoers.d/
ALL=(root)
wrapper ""
引数を通さない
① 絞る
コマンド名だけ
では足りない
sudoers.d · visudo
sshd -T -C
user=ops
user=labuser02
値が違う
② 読む
置いたことと
効くことは別
sshd -T -C
visudo -cf
exit=0
sudo -n id
パスワードが必要
③ 失う
構文の正しさは
安全を保証しない
!ALL · 後勝ち
qm snapshot
qm rollback
$ sudo -n true
exit=0
④ 降りる
外側からしか
戻せない状態を戻す
qm rollback

3 つのしくみを解きほぐす

1. sudoers.d ── 「コマンド名だけ」の許可は最小権限ではない

Chapter 3 で「opssudo を持つが labuser01 は持たない」という非対称を見ました。この非対称を意図して作るのが /etc/sudoers.d/ です。

最初に押さえるべき落とし穴があります。sudoers にコマンドのパスだけを書くと、そのコマンドは任意の引数で実行できます。

一見「apt だけ許可した」ように見えるが
# ❌ 最小権限ではない
labuser01 ALL=(ALL) NOPASSWD: /usr/bin/apt

apt-o で任意の設定を上書きでき、APT の hook(DPkg::Pre-Invoke など)はシェルで実行されます。つまりこのルールを与えられた人は sudo apt -o DPkg::Pre-Invoke::="任意のコマンド" … の形で root として何でも実行できます。「apt だけ」ではなく「root そのもの」を渡したのと変わりません。

同じ落とし穴は systemctlfind-exec)・tar--checkpoint-action)・vi:!)など、外部コマンドを呼べる/引数で挙動が変わるコマンド全般にあります。

安全側に倒す 2 つの方法
# ⭕ 方法1: 引数まで固定する(この組み合わせしか通らない)
labuser01 ALL=(root) NOPASSWD: /usr/bin/systemctl status ssh

# ⭕ 方法2(本章で採用): 引数を受け取らないラッパーだけを許可する
labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""
ラッパーの中身(root:root 0755 で置く)
#!/bin/sh
set -eu
exec /usr/bin/journalctl -u ssh -n 20 --no-pager   # "$@" を渡さない = 引数を通さない

末尾の "" は sudoers の記法で「引数を一切受け付けない」という意味です。ラッパー側の防御("$@" を渡さない)と合わせた二重防御になっています。

ラッパー自体の所有者とパーミッションが本体です。 ラッパーを一般ユーザが書き換えられるなら、それを許可することは root を渡すことと同じです。root:root0755 が必須条件です。

配置は 3 段です。構文が正しいことと、権限設計が安全であることは別問題です。visudo が見るのは文法だけで、「このルールが自分の権限を奪うか」までは判断しません。

配置の 3 段
TMP="$(mktemp)"                        # 固定の /tmp パスは symlink 攻撃の的になる
printf '%s\n' 'labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""' > "$TMP"
sudo visudo -cf "$TMP"                 # ① 単独ファイルの構文検査
sudo install -o root -g root -m 0440 "$TMP" /etc/sudoers.d/10-ch4-labuser01   # ② モードごと配置
rm -f "$TMP"
sudo visudo -c                         # ③ ★ sudoers 全体の整合を検査

visudo -cf <ファイル>そのファイル単体しか見ません。配置後は sudo visudo -c で全体を検査します。mv してから chmod する手順だと、その一瞬だけ不適切なモードで sudoers.d にファイルが存在するため、install でモードごと置きます。

2. sshd_config.d と「鍵限定」── ファイル名では効くことを保証できない

ここには落とし穴が 3 つあります。

落とし穴1: PasswordAuthentication no だけでは鍵限定になりません。

OpenSSH のパスワード入力経路は 1 本ではありません。PasswordAuthentication が塞ぐのは password 方式だけで、PAM 経由の keyboard-interactive は別のキーワードです。

鍵限定にするなら 3 つ揃える
PubkeyAuthentication         yes  ← 公開鍵を有効に保つ(消すと誰も入れない)
PasswordAuthentication       no   ← password 方式を無効化
KbdInteractiveAuthentication no   ← keyboard-interactive(PAM)を無効化 ★別設定

落とし穴2: 99- という名前を付けても「後から上書き」はされません。

sshd_config は先頭付近で Include /etc/ssh/sshd_config.d/*.conf を読み、glob は辞書順に展開されます。そして OpenSSH は各キーワードについて 最初に得た値を採用します。sudoers の「後にマッチしたルールが勝つ」とはです。

先勝ちなので、番号を大きくすると負ける
辞書順に読む:
  00-hardening.conf       PasswordAuthentication no    ← 最初に読まれた値が採用 ⭕
  50-cloud-init.conf      PasswordAuthentication yes   ← 無視される

  ── もしファイル名が 99- だったら ──
  50-cloud-init.conf      PasswordAuthentication yes   ← こちらが採用される
  99-hardening.conf       PasswordAuthentication no    ← 無視される ❌「置いたのに効かない」

落とし穴3: ただし Match は文書化された例外です。

Match ブロックの中は、条件に合致した接続に対して先行する値を上書きします。「最初の値が勝つ」の唯一の例外です。だからこそ「どの接続として評価するか」を指定しないと、実際の値は分かりません。それが sshd -T-C です。

sshd 自身に計算させる
sudo sshd -T -C user=ops,host=workstation01,addr=<workstation01のIP> \
  | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication)'

-Cuser接続してくるユーザ名hostaddr接続元です(接続先ではありません)。-C を付けないと Match が評価されず、実際の接続時と違う値が出ます。

適用は 1 コマンドに閉じます: sudo sshd -t && sudo systemctl reload ssh&& でつなぐのが要点で、別々の行に書くと検査が失敗しても次の行が走って壊れた設定が適用されます

反映確認の「正」は sshd -T -C の実効値です。 systemctl is-active ssh は構成によって答えが変わります。SSH が socket activation(ssh.socket)で動く環境では ssh.service が常駐しないため is-active sshinactive を返し、reload も意味を持ちません(新規接続ごとに新しい設定で sshd が起動するため)。STEP ① でどちらの構成かを実測します。

3. スナップショットによる復旧 ── 内側から直せない VM に外から手を入れる

スナップショットの作成
SNAP="ch4-$(date +%Y%m%d-%H%M%S)"          # 一意名にする(固定名は再実行時に重複エラー)
qm snapshot <VMID> "$SNAP" --description "Ch4 演習前の状態"
qm listsnapshot <VMID>

このスナップショットに RAM は含まれません。 --vmstate 1 を付けていないため、保存されるのは VM 構成とディスクだけです。rollback 後の VM は停止状態になり qm start が必要です。また、稼働中に取るディスク snapshot は「その瞬間に電源を抜いた状態」に相当します(crash-consistent)。

巻き戻しの手順
qm shutdown <VMID>            # まずは正常停止(ゲストへ ACPI 停止要求)
qm status <VMID>              # stopped を確認
qm rollback <VMID> "$SNAP"
qm start <VMID>

qm rollback は内部で停止処理も行うため事前停止は必須ではありませんが、状態を明示するために先に停止します。使うのは qm stop(強制電源断相当)ではなく qm shutdown(正常停止)で、ゲストが応答しないときだけ qm stop に切り替えます。

実機価値: クラウドにもスナップショットはありますが、ハイパーバイザに直接入り、内側から手を出せなくなったゲストを外側から復旧する経験は、抽象化の向こう側では得にくいものです。基盤を握っているからこそできる操作を、この章で体験します。

試してみよう:絞り込み、締め出し、外側から戻す

項目
出発地点踏み台 workstation01
演習対象learner01(設定変更・スナップショット操作)
管理者ユーザーops(NOPASSWD sudo・本章で鍵を登録し回復経路にする
演習用ユーザー(鍵あり)labuser01(限定 sudo の対象)
使い捨てユーザー(鍵なし)labuser02(★締め出される側)
sudoers 設定/etc/sudoers.d/10-ch4-labuser01(限定 sudo)+ /etc/sudoers.d/99-ch4-lab(D-2 の自己剥奪)
sudo ラッパー/usr/local/sbin/ch4-ssh-log(root:root 0755)
SSH 設定/etc/ssh/sshd_config.d/50-ch4-lab.confMatch User labuser02 ブロックのみ
所要時間約 50 分(7 ステップ)
★ グローバル設定には一切触れません。 この演習が変更するのは Match User labuser02 ブロックの中身だけです。同じサーバでは他の受講者がパスワードでログインしており、グローバルな PasswordAuthentication を切るとその人たちを巻き込んで壊します。「自分の演習範囲に閉じる」こと自体が、本番でアクセス制御を触るときの第一の作法です。

ターミナルの使い方

3 つ使います。T3 は STEP ②・⑥・⑦の最後でしか使いません。

用途
T1learner01ops セッションサーバ側の設定変更。章の間ずっと閉じない
T2workstation01 のローカルシェル鍵生成・接続試験
T3Proxmox ホストの ops セッションqm 操作(②・⑥・⑦末)

STEP 01 · 実測し、回復経路を確保する(フェーズ A)

【T1: learner01 の ops セッション】 まず前回の残骸が無いことを確認します。残骸があればこの章を始めず、先に片付けてください(既存のものを自動で消さない=他章の成果物を壊さないため)。

bash · learner01(ops)
ssh ops@learner01

for p in /etc/sudoers.d/10-ch4-labuser01 /etc/ssh/sshd_config.d/50-ch4-lab.conf \
         /usr/local/sbin/ch4-ssh-log; do
  [ -e "$p" ] && echo "NG: $p が既に存在します(この章を開始しないでください)"
done
for u in labuser01 labuser02; do
  id "$u" >/dev/null 2>&1 && echo "NG: ユーザ $u が既に存在します"
done
echo "残骸チェック完了"
期待される出力
残骸チェック完了

実効設定を記録します。章末はこの記録値へ戻すため、必ず控えてください

bash · learner01(ops)
sudo sshd -T -C user=ops,host=workstation01,addr=<workstation01のIP> \
  | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|allowusers|allowgroups|denyusers|denygroups|usepam)' \
  | tee ~/ch4-baseline-sshd.txt
出力例
usepam yes
pubkeyauthentication yes
passwordauthentication yes
kbdinteractiveauthentication no

allowusers / allowgroups / denyusers / denygroups に値が出たら、この章はここで中止してください。 そのサーバには既存のユーザ許可リストという別のアクセス制御が入っています。labuser02 を通すにはそのリストを変更することになり、「自分の演習用ファイルしか触らない」という本章の境界を越えます。値を記録して指導者の判断を仰いでください。

SSH の起動方式も実測します(reload の意味が変わるため)。

bash · learner01(ops)
systemctl is-active ssh; systemctl is-enabled ssh 2>/dev/null
systemctl is-active ssh.socket 2>/dev/null; systemctl is-enabled ssh.socket 2>/dev/null
実測結果以降の扱い
ssh.serviceactivesudo sshd -t && sudo systemctl reload ssh をそのまま使う
ssh.socketenabledssh.serviceinactivereload は不要sudo sshd -t だけ実行し、確認は sshd -T -C で行う

socket activation 構成では reload が「失敗」しますが、それは手順の破綻ではありません。 Unit ssh.service is not active 等が出ても、sshd -t が通っていれば設定ファイルは正しく、次の接続から反映されています。この章での「反映できたか」の判定は、常に sshd -T -C の実効値です。

回復経路を用意します。受講環境の鍵ですでに ops@learner01 へ入れますが、それでもこの章専用の鍵をもう 1 本作ります。これから sshd を触る以上、「与えられた経路が生きているはず」と「自分で確かめた別経路がある」は別物だからです。この規律自体が本章の主題の一部です。

bash · learner01(ops)
# ops が sudo 以外に root を取れないことを確認(D-2 の前提)
sudo passwd -S ops; sudo passwd -S root
出力例
ops L 2026-05-09 0 99999 7 -1
root L 2025-08-05 0 99999 7 -1

2 列目の LLocked(パスワード施錠)です。opsroot もパスワードでは入れません。これが STEP ⑤ で効いてきます。

【T2: workstation01】 この章専用の鍵を作り、既存の経路を使って登録します。

bash · workstation01
ssh-keygen -t ed25519 -N '' -C 'ch4-ops-recovery' -f ~/.ssh/ch4_ops_ed25519
ssh-copy-id -i ~/.ssh/ch4_ops_ed25519.pub ops@learner01

ssh-copy-id は既存の鍵で認証して新しい公開鍵を追加します。これが通ること自体が「既存の経路が生きている」証明です。失敗したら回復経路が無い状態なので、ここで中止し、sshd の設定には進まないでください。

bash · workstation01
# ★鍵だけで入れることを証明する。通らない限り先へ進まない
ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \
    -i ~/.ssh/ch4_ops_ed25519 ops@learner01 'id -un; echo OPS_KEY_LOGIN_OK'
期待される出力
ops
OPS_KEY_LOGIN_OK
ここが本章で最も重要な規律です: OPS_KEY_LOGIN_OK が出ない限り、STEP ② へ進まないでください。この鍵が、これから起こす事故のあとに戻ってくるための命綱です。

最後に「もう 1 つの入口」も確認します。Proxmox の Web UI から該当 VM のコンソールを開いてください。learner01 login: というテキストのログイン画面が出て、ユーザー名の一覧は表示されません(画面に利用者名を並べないため、グラフィカルなログイン画面は停止してあります)。rootops はパスワード施錠なのでここからは入れません。つまりコンソールも回復手段にはならないことを、目で確認しておきます。

STEP 02 · スナップショットを取る(フェーズ B・★まだ何も変更していない)

【T3: Proxmox ホスト】

bash · Proxmox ホスト
ssh ops@<Proxmox管理IP>

qm list | grep learner01
qm config <VMID> | grep -E '^(scsi|virtio|sata|ide|efidisk|tpmstate)[0-9]*:'   # 全ボリューム
pvesm status                                                                  # 保存先の空き容量
bash · Proxmox ホスト
SNAP="ch4-$(date +%Y%m%d-%H%M%S)"; echo "$SNAP"     # ★この名前を控える
qm snapshot <VMID> "$SNAP" --description "Ch4 演習開始前のクリーン状態"
qm listsnapshot <VMID>
期待される出力
ch4-20260728-101147
`-> ch4-20260728-101147 2026-07-28 10:11:47 Ch4 演習開始前のクリーン状態
 `-> current                                  You are here!
このスナップショットが、この章のすべての安全網です。 T3 は SNAP を保持したまま STEP ⑥ まで閉じないでください(閉じた場合は qm listsnapshot <VMID> で名前を確認し SNAP=<控えた名前> で再設定します)。

STEP 03 · 演習ユーザを作り、labuser02 だけパスワードで入れるようにする(フェーズ C-1)

【T1: learner01 の ops セッション】

bash · learner01(ops)
sudo useradd -m -s /bin/bash labuser01
sudo useradd -m -s /bin/bash labuser02
echo 'labuser02:Ch4-Throwaway-Pass' | sudo chpasswd
id labuser01; id labuser02

この固定パスワードは本章専用の使い捨てです。実運用ではこの書き方をしません(コマンド履歴に平文が残ります)。labuser02 はこのあとの rollback で消えます。

labuser02 だけがパスワードで入れる状態を Match ブロックで作ります。

bash · learner01(ops)
sudo tee /etc/ssh/sshd_config.d/50-ch4-lab.conf > /dev/null << 'EOF'
Match User labuser02
    PubkeyAuthentication yes
    PasswordAuthentication yes
    KbdInteractiveAuthentication yes
EOF
sudo chmod 644 /etc/ssh/sshd_config.d/50-ch4-lab.conf
sudo sshd -t && sudo systemctl reload ssh

★ここが -C の出番です。同じサーバなのに、接続してくるユーザによって実効値が違うことを確かめます。

bash · learner01(ops)
for U in ops labuser02; do
  echo "--- user=$U ---"
  sudo sshd -T -C user=$U,host=workstation01,addr=<workstation01のIP> \
    | grep -iE '^(passwordauthentication|kbdinteractiveauthentication)'
done
期待される出力
--- user=ops ---
passwordauthentication yes
kbdinteractiveauthentication no
--- user=labuser02 ---
passwordauthentication yes
kbdinteractiveauthentication yes

kbdinteractiveauthentication の値が opsnolabuser02yes と分かれました。同じ設定ファイル、同じサーバ、同じ瞬間なのに、誰として接続するかで答えが違います-C を付けなければこの違いは見えません。

【T2: workstation01】 labuser01 の鍵を作ります。

bash · workstation01
ssh-keygen -t ed25519 -N '' -C 'ch4-labuser01' -f ~/.ssh/ch4_labuser01_ed25519
cat ~/.ssh/ch4_labuser01_ed25519.pub     # この1行をコピーする

【T1: learner01】 公開鍵を配置します。下の <ここに公開鍵を貼り付ける> を、コピーした 1 行に置き換えてから実行してください。

bash · learner01(ops)
sudo -u labuser01 mkdir -p /home/labuser01/.ssh
sudo chmod 700 /home/labuser01/.ssh
sudo -u labuser01 tee /home/labuser01/.ssh/authorized_keys << 'EOF'
<ここに公開鍵を貼り付ける>
EOF
sudo chmod 600 /home/labuser01/.ssh/authorized_keys
sudo chown -R labuser01:labuser01 /home/labuser01/.ssh

# ★プレースホルダのまま貼っていないかを確認する(1 が出れば正しい)
sudo grep -c '^ssh-ed25519 ' /home/labuser01/.ssh/authorized_keys

1 が出ることを必ず確認してください。0 なら、プレースホルダの行をそのまま貼り付けています。

【T2: workstation01】 両方のユーザが、意図した方式で入れることを確認します。

bash · workstation01
# labuser01 は鍵で入れる
ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \
    -i ~/.ssh/ch4_labuser01_ed25519 labuser01@learner01 'id -un'

# labuser02 はパスワードで入れる(この後これを奪う)
ssh -o PubkeyAuthentication=no -o IdentitiesOnly=yes -o PreferredAuthentications=password \
    -o NumberOfPasswordPrompts=1 labuser02@learner01 'echo LABUSER02_PASSWORD_LOGIN_OK'

ssh host 'id' と 1 行で書く理由: 「ssh host で入ってから id を打つ」形で手順書に 2 行並べると、コピー&ペーストしたときに id手元のマシンで実行されてしまい、確認になりません。

STEP 04 · sudoers.d でラッパーだけを許可する(フェーズ C-2)

【T1: learner01 の ops セッション】 まずラッパーを置きます。

bash · learner01(ops)
TMPW="$(mktemp)"
cat > "$TMPW" << 'EOF'
#!/bin/sh
set -eu
exec /usr/bin/journalctl -u ssh -n 20 --no-pager
EOF
sudo install -o root -g root -m 0755 "$TMPW" /usr/local/sbin/ch4-ssh-log
rm -f "$TMPW"
ls -l /usr/local/sbin/ch4-ssh-log
期待される出力
-rwxr-xr-x 1 root root 67 7月 28 10:13 /usr/local/sbin/ch4-ssh-log

root root と、他者に書き込みが無いことが要点です。ここが緩ければ、以降の設計はすべて無意味になります。

次に sudoers を 3 段で配置します。

bash · learner01(ops)
TMP="$(mktemp)"
printf '%s\n' 'labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""' > "$TMP"
sudo visudo -cf "$TMP"                                                    # ① 単独検査
sudo install -o root -g root -m 0440 "$TMP" /etc/sudoers.d/10-ch4-labuser01  # ② 配置
rm -f "$TMP"
sudo visudo -c                                                            # ③ ★全体検査
ls -l /etc/sudoers.d/10-ch4-labuser01
期待される出力
/tmp/tmp.rzysBWype4: 正しく構文解析されました
/etc/sudoers: 正しく構文解析されました
/etc/sudoers.d/10-ch4-labuser01: 正しく構文解析されました
(中略)
-r--r----- 1 root root 62 7月 28 10:13 /etc/sudoers.d/10-ch4-labuser01

【T2: workstation01】 最小権限が効いていることを確認します。判定は終了コードで行います(エラー文言は sudo のバージョンで揺れるため)。

bash · workstation01
K="-o IdentitiesOnly=yes -i $HOME/.ssh/ch4_labuser01_ed25519"

ssh $K labuser01@learner01 'sudo -l'

# 許可されたラッパーは通る
ssh $K labuser01@learner01 'sudo -n /usr/local/sbin/ch4-ssh-log >/dev/null 2>&1; echo "wrapper exit=$?"'

# ★ラッパーの中身に直接オプションを渡す経路が無い
ssh $K labuser01@learner01 \
  'sudo -n /usr/bin/journalctl -u ssh --output=cat >/dev/null 2>&1; echo "直接実行 exit=$? (0以外なら設計通り)"'
期待される出力
ユーザー labuser01 は learner01 上で コマンドを実行できます
    (root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""
wrapper exit=0
直接実行 exit=1 (0以外なら設計通り)

sudo -l にラッパー 1 件だけが並び、journalctl への直接実行が拒否されれば、最小権限の設計が機能しています。ラッパー経由なら同じログが読めるのに、直接は読めない ── ここが「引数を渡さない」の効果です。

STEP 05 · 壊す(フェーズ D)── 2 段階で「直せない」を作る

D-1: 鍵を持たない labuser02 が締め出される

【T1: learner01 の ops セッション】 Match ブロックの中身を no に書き換えます。グローバル設定には触れません。

bash · learner01(ops)
sudo tee /etc/ssh/sshd_config.d/50-ch4-lab.conf > /dev/null << 'EOF'
Match User labuser02
    PubkeyAuthentication yes
    PasswordAuthentication no
    KbdInteractiveAuthentication no
EOF
sudo sshd -t && sudo systemctl reload ssh

# labuser02 だけが変わり、ops は不変であることを確認する
for U in ops labuser02; do
  echo "--- user=$U ---"
  sudo sshd -T -C user=$U,host=workstation01,addr=<workstation01のIP> \
    | grep -iE '^(passwordauthentication|kbdinteractiveauthentication)'
done
期待される出力
--- user=ops ---
passwordauthentication yes
kbdinteractiveauthentication no
--- user=labuser02 ---
passwordauthentication no
kbdinteractiveauthentication no

ops の値は STEP ① で記録した baseline と同じままです。これが「演習が他の利用者を巻き込んでいない」ことの証跡になります。

【T2: workstation01】 締め出しを確認します。ポートが開いていることと、認証が通ることは別問題なので分けて見ます。

bash · workstation01
# ポートは開いているか
timeout 30 bash -c 'until (echo > /dev/tcp/learner01/22) 2>/dev/null; do sleep 2; done' \
  && echo "port 22 OPEN"

# サーバが提示する認証方式を見る
ssh -vv -o PubkeyAuthentication=no -o IdentitiesOnly=yes \
    -o PreferredAuthentications=password,keyboard-interactive \
    -o NumberOfPasswordPrompts=0 -o ConnectTimeout=10 \
    labuser02@learner01 'echo SHOULD_NOT_APPEAR' 2>&1 \
  | grep -E 'Authentications that can continue|Permission denied'
期待される出力
port 22 OPEN
debug1: Authentications that can continue: publickey
labuser02@learner01: Permission denied (publickey).

Authentications that can continue: publickey が要点です。成否ではなくサーバが提示した認証方式で、パスワード経路が閉じたことを証明できました。labuser02 は鍵を持たないので、入る手段がありません。

ここまでなら、まだ簡単に直せます。 ops は SSH で入れるので、T1 で sudo rm /etc/ssh/sshd_config.d/50-ch4-lab.conf すれば終わりです。本番の事故が深刻になるのは、その「直す手段」自体を失ったときです。次でそれを作ります。

D-2: 検査を通した sudoers 配置で、ops 自身の sudo が死ぬ

ここで再現するのは、検査を飛ばす事故ではありません。visudo -cf が exit=0 を返す、文法的に正しいルールで自分の権限を剥奪する事故です。

直前に扱った 2 つは、勝ち方が逆
sshd_config    … 最初に現れた値が勝つ(後から同じ設定を書いても無視される)
sudoers        … 最後にマッチしたルールが勝つ(後から書いたルールが上書きする)

「設定は先勝ち」「設定は後勝ち」と決めつけるのは、どちらも危険です。設定の種類ごとに規則を確かめ、実効値で検証する必要があります。

/etc/sudoers.d/ はファイル名の辞書順に読まれます。既存の 90-ops より後に読ませるため、ファイル名を 99-ch4-lab にします。数字は飾りではなく読み込み順です。

配置する 1 行を、記号ごとに読む
ops        ALL=       (ALL)      !ALL
 │          │           │          └─ 実行してよいコマンド =「どれも許可しない」
 │          │           └──────────── どのユーザーとして実行してよいか
 │          └──────────────────────── どのホストで
 └─────────────────────────────────── 誰が

! は否定です。ALL が「すべてのコマンド」なので、!ALL は「すべてのコマンドを禁止」を意味します。

bash · learner01(ops)
# 演習用ファイルを作る(構文は完全に正しい)
TMPF="$(mktemp)"
printf '%s\n' 'ops ALL=(ALL) !ALL' > "$TMPF"

# ★ 検査は通る。visudo は「文法」しか見ない
sudo visudo -cf "$TMPF"; echo "visudo -cf exit=$?"

# 90-ops より後に読ませるため 99- で始まる名前にする
sudo install -o root -g root -m 0440 "$TMPF" /etc/sudoers.d/99-ch4-lab
rm -f "$TMPF"

# 何が起きたか
sudo -n id -un 2>&1; echo "exit=$?"
期待される出力
visudo -cf exit=0
sudo: パスワードが必要です
exit=1
visudo は exit=0 を返しました。 文法はどこも間違っていません。それでも、最後に読まれた !ALL が既存の NOPASSWD:ALL を打ち消し、ops の sudo は使用不能になりました。構文の正しさは、意味の安全を保証しません。

1 つの経路が塞がっただけでは「内側から直せない」とは判断できません。事故対応と同じように、考えられる経路を並べて 1 つずつ潰します。

bash · learner01(ops)
# 1. sudo で演習ファイルを消せるか
sudo -n rm /etc/sudoers.d/99-ch4-lab 2>&1 | tail -2

# 2. 一般ユーザの権限で消せるか
rm /etc/sudoers.d/99-ch4-lab 2>&1

# 3. root のパスワード認証へ切り替えられるか
echo "" | timeout 5 su - root -c "id" 2>&1 | tail -2

# 4. polkit 経由で昇格できるか
timeout 5 pkexec --disable-internal-agent id 2>&1 | tail -2

# 5. SSH アクセス自体は残っているか
whoami
#試す経路実測結果なぜ塞がっているか
1sudo -n rm …パスワードが必要です!ALL で NOPASSWD が消えた。ops のパスワードは施錠されており供給できない
2rm …許可がありませんファイルは root 所有・0440。一般ユーザでは消せない
3su - root認証失敗root のパスワード欄は施錠済みでログインできない
4pkexec …No authentication agent found.非対話の SSH セッションには認証エージェントがない
5SSH で whoamiops(アクセス成功)アクセスは残っているが、権限だけを失っている

5 番が最も重要です。入れないから直せないのではなく、入れるのに直せません。 ops は SSH で入れてファイルも見えますが、root 権限を得られないため消せません。思いつく 5 経路がすべて塞がったので、残るのはこのサーバの外側だけです。

STEP 06 · 戻す(フェーズ E)── ハイパーバイザに降りる

【T3: Proxmox ホスト】

bash · Proxmox ホスト
echo "$SNAP"                  # 巻き戻し先を確認(空なら qm listsnapshot で再設定)
qm shutdown <VMID>            # 正常停止(ゲストへ ACPI 停止要求)
qm status <VMID>              # stopped を確認

qm rollback <VMID> "$SNAP"
qm start <VMID>
qm status <VMID>
期待される出力
status: stopped
Logical volume "vm-<VMID>-disk-0" successfully removed.
Logical volume "vm-<VMID>-disk-0" created.
rollback done in 1s
status: running

qm shutdown が返ってこない場合(ゲストが ACPI に応答しない場合)だけ qm stop <VMID> を使います。qm stop は電源ケーブルを抜くのと同じ強制停止です。

【T1: learner01 へ再接続】(rollback で元のセッションは切れています)

bash · workstation01 → learner01
ssh ops@learner01

# ★まず sudo が生き返っていることを確認する
sudo -n true; echo "sudo exit=$?"

# ②の時点=何も作っていない状態に戻っている
for p in /etc/ssh/sshd_config.d/50-ch4-lab.conf /etc/sudoers.d/10-ch4-labuser01 \
         /etc/sudoers.d/99-ch4-lab /usr/local/sbin/ch4-ssh-log; do
  [ -e "$p" ] && echo "残存: $p" || echo "消えた: $p"
done
for u in labuser01 labuser02; do
  id "$u" >/dev/null 2>&1 && echo "残存: $u" || echo "消えた: $u"
done
期待される出力
sudo exit=0
消えた: /etc/ssh/sshd_config.d/50-ch4-lab.conf
消えた: /etc/sudoers.d/10-ch4-labuser01
消えた: /etc/sudoers.d/99-ch4-lab
消えた: /usr/local/sbin/ch4-ssh-log
消えた: labuser01
消えた: labuser02
スナップショットを「何も変更する前」に取った意味が、ここで出ます。 巻き戻し先には ops の権限を奪う 99-ch4-lab が存在しないため、確実に直ります。もしスナップショットを 99-ch4-lab を配置したに取っていたら、自己剥奪ルールごと復元されて詰みます

ops の回復用鍵は残っています。 STEP ① で登録したのは STEP ② の snapshot を取るなので、巻き戻し先にも含まれているからです。消えたのは STEP ③ 以降に作ったものだけ ── 「いつ snapshot を取ったか」が、何が残り何が消えるかを決めます。

STEP 07 · 正しい順番でやり直し、検証し、片付ける(フェーズ F)

同じ設定を、今度は 鍵を先に用意してから適用します。

【T1: learner01 の ops セッション】 labuser02 を作り直し、パスワードは与えずMatch を先に置きます。

bash · learner01(ops)
sudo useradd -m -s /bin/bash labuser02
sudo tee /etc/ssh/sshd_config.d/50-ch4-lab.conf > /dev/null << 'EOF'
Match User labuser02
    PubkeyAuthentication yes
    PasswordAuthentication no
    KbdInteractiveAuthentication no
EOF
sudo chmod 644 /etc/ssh/sshd_config.d/50-ch4-lab.conf

【T2: workstation01】 鍵を作ります。

bash · workstation01
ssh-keygen -t ed25519 -N '' -C 'ch4-labuser02' -f ~/.ssh/ch4_labuser02_ed25519
cat ~/.ssh/ch4_labuser02_ed25519.pub

【T1: learner01】 鍵を配ってから反映します。

bash · learner01(ops)
sudo -u labuser02 mkdir -p /home/labuser02/.ssh
sudo chmod 700 /home/labuser02/.ssh
sudo -u labuser02 tee /home/labuser02/.ssh/authorized_keys << 'EOF'
<ここに公開鍵を貼り付ける>
EOF
sudo chmod 600 /home/labuser02/.ssh/authorized_keys
sudo chown -R labuser02:labuser02 /home/labuser02/.ssh
sudo grep -c '^ssh-ed25519 ' /home/labuser02/.ssh/authorized_keys      # 1 を確認

sudo sshd -t && sudo systemctl reload ssh
実効値(user=labuser02)
pubkeyauthentication yes
passwordauthentication no
kbdinteractiveauthentication no
同じ瞬間の実効値(user=ops)
pubkeyauthentication yes
passwordauthentication yes
kbdinteractiveauthentication no

opspasswordauthentication yes のままです。Match User labuser02 の外側は一切影響を受けていません ── 「自分の演習範囲に閉じる」が実効値で確認できました。

【T2: workstation01】 検証します。鍵は通り、パスワード経路は閉じていることを方式で確認します。

bash · workstation01
# 1. 鍵は通る(今回は締め出されない)
ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \
    -i ~/.ssh/ch4_labuser02_ed25519 labuser02@learner01 'id -un; echo KEY_AUTH_OK'

# 2. パスワード経路は閉じている
ssh -vv -o PubkeyAuthentication=no -o IdentitiesOnly=yes \
    -o PreferredAuthentications=password,keyboard-interactive \
    -o NumberOfPasswordPrompts=0 -o ConnectTimeout=10 \
    labuser02@learner01 'echo SHOULD_NOT_APPEAR' 2>&1 \
  | grep -E 'Authentications that can continue|Permission denied'
期待される出力
labuser02
KEY_AUTH_OK

debug1: Authentications that can continue: publickey
labuser02@learner01: Permission denied (publickey).

SHOULD_NOT_APPEAR表示されていません。パスワード経路で入れていたら、この文字列が出るように仕込んでありました。「出なかったこと」自体が証拠です。

同じ PasswordAuthentication no でも、鍵を先に用意したかどうかで結果が正反対になりました。 これが本章の要点です。設定の内容は同じ。違うのは順番だけです。

Permission denied (publickey) は「正しく閉じた」印です。 括弧の中はエラー原因ではなく、サーバが受け付ける残りの認証方式を示します。publickey だけが残っている=パスワード経路が無い、ということです。

章末クリーンアップ

【T1: learner01】 STEP ① で記録した状態へ戻します(決め打ちで yes にしない)。

bash · learner01(ops)
sudo rm -f /etc/ssh/sshd_config.d/50-ch4-lab.conf
sudo userdel -r labuser02 2>/dev/null; sudo rm -f /var/mail/labuser02
sudo sshd -t && sudo systemctl reload ssh

# ①の記録と一致するかを差分で確認する
sudo sshd -T -C user=ops,host=workstation01,addr=<workstation01のIP> \
  | grep -iE '^(passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|allowusers|allowgroups|denyusers|denygroups|usepam)' \
  > /tmp/ch4-after-sshd.txt
diff ~/ch4-baseline-sshd.txt /tmp/ch4-after-sshd.txt && echo "baseline へ復帰しました"

# この章で足した鍵行だけを消す(他の行は残す)
sed -i '/ch4-ops-recovery/d' ~/.ssh/authorized_keys
rm -f ~/ch4-baseline-sshd.txt /tmp/ch4-after-sshd.txt

labuser01・2 つの sudoers ファイル・ラッパーの削除は不要です。 STEP ⑥ の rollback で既に消えています。手で消すのは rollback より後に作ったものlabuser0250-ch4-lab.confops の鍵行)だけです。

bash · workstation01
rm -f ~/.ssh/ch4_labuser01_ed25519 ~/.ssh/ch4_labuser01_ed25519.pub \
      ~/.ssh/ch4_labuser02_ed25519 ~/.ssh/ch4_labuser02_ed25519.pub \
      ~/.ssh/ch4_ops_ed25519 ~/.ssh/ch4_ops_ed25519.pub
ls ~/.ssh/ch4_* >/dev/null 2>&1 && echo "NG: 演習鍵が残っています" || echo "演習鍵 削除済み"
bash · Proxmox ホスト(T3)
echo "$SNAP"; qm delsnapshot <VMID> "$SNAP"; qm listsnapshot <VMID>
クリーンアップ完了の確認
確認項目期待値
sudo sshd -T -C user=ops,… の実効値STEP ① の記録と一致(diff なし)
グローバル設定演習前後で不変(触っていない)
ls /etc/ssh/sshd_config.d/50-ch4-lab.confNo such file or directory
ls /etc/sudoers.d/99-ch4-labNo such file or directory
id labuser01 / id labuser02いずれも「そのようなユーザは存在しません」
sudo visudo -cすべて「正しく構文解析されました」
opsauthorized_keysch4-ops-recovery 行なし・既存行は保持
Proxmox の ch4-<日時> スナップショット削除済み

詰まったら

ハマりポイント

STEP ① で OPS_KEY_LOGIN_OK が出ない

先へ進まないでください。この鍵が唯一の命綱です。

ls -la ~/.sshauthorized_keys0600.ssh0700、所有者が ops であることを確認します。sudo journalctl -u ssh -n 50Authentication refused: bad ownership or modes が出ていれば、それが原因です。

STEP ① で allowusers などに値が出た

この章を中止してください。既存のユーザ許可リストを変更しないと labuser02 が接続できず、それは本章の境界(演習用ファイルしか触らない)の外側です。

Match 側に許可を足して回避しようとしないでください。グローバルの制御を演習で書き換えるのは、この章が最も戒めていることそのものです。値を記録して判断を仰いでください。

STEP ⑤ D-2 のあと、rollback せずに直したい

内側での削除を試し続けないでください。STEP ⑤ の 5 経路で、内側の手段が尽きていることは確認済みです。

唯一の例外は、STEP ① で Web コンソールから root で入れると分かっている場合です。そのときだけコンソールから rm /etc/sudoers.d/99-ch4-lab で直せます。この演習環境では root がパスワード施錠のため入れないので、rollback が唯一の道です。

STEP ⑥ のあと SSH がつながらない

起動が遅い: crash-consistent なスナップショットからの復帰では、初回起動でファイルシステムの復旧処理が走ることがあります。timeout 120 のポート待ちで吸収してください。

Connection timed out during banner exchange: ポートは開いているが sshd がまだ応答を返し始めていない状態です。1〜2 分待って再試行してください。

REMOTE HOST IDENTIFICATION HAS CHANGED: 同じ VM のスナップショットなのでホスト鍵は同一のはずで、通常は出ません。出た場合は ssh-keygen -R learner01 で古いエントリを消して再接続します。

上記以外の詰まりと対処を一覧にまとめます。

起きたこと対処
labuser01 に鍵で入れないsudo grep -c '^ssh-ed25519 ' /home/labuser01/.ssh/authorized_keys0 ならプレースホルダのまま貼っている。0700/0600/所有者も確認
-C の出力が opslabuser02 で同じMatch が効いていない。-C の付け忘れか、50-ch4-lab.conf のインデント(Match 行の次から字下げ)を確認
visudo -cf がエラー一時ファイルの構文誤り。cat "$TMP" で内容を見て書き直す。エラーのまま install しない
⑤ D-2 のあと sudo がパスワードを求める正常な実験結果。 ops のパスワードは施錠されているので入力しても通らない。⑥へ進む
qm snapshot がエラーpvesm status で空き容量、qm config <VMID> で全ディスクの保存先を確認。同名エラーなら SNAP を作り直す
qm shutdown が返ってこないゲストが ACPI に応答していない。qm stop <VMID>(強制停止)を使う
T3 を閉じて $SNAP が消えたqm listsnapshot <VMID> で名前を確認し SNAP=<その名前> で再設定

AI に相談

AI sudo と SSH の詰まりを自由に質問できます
この章の sudoers.d・sshd_config.d・snapshot 復旧を前提に、インフラの基礎をやさしく答えます

修了確認

この章で体験した 2 つの「逆」を確認します。

問 1 · なぜ visudo が通ったのに sudo が死んだのか

STEP ⑤ D-2 で配置した ops ALL=(ALL) !ALL は、visudo -cf が exit=0 を返しました。それなのに ops の sudo は使えなくなりました。なぜですか? また、ファイル名を 99- にした理由を説明してください。

解答を見る

visudo が検査するのは文法だけだからです。「このルールが運用者自身の権限を奪うか」という意味の妥当性は判断しません。ops ALL=(ALL) !ALL は文法として完全に正しく、意味としては「ops はどのコマンドも実行してはならない」です。

99- にした理由: /etc/sudoers.d/ はファイル名の辞書順に読まれ、sudoers は最後にマッチしたルールが勝ちます。既存の 90-ops より後に読ませないと、後から読まれた NOPASSWD:ALL に上書きされてしまい、この事故は再現しません。10- のような名前では先に読まれて負けます。数字は飾りではなく読み込み順です。

そしてここが重要ですが、sshd_config はこれと逆です。

設定勝つのは番号を大きくすると
sudoers.d最後にマッチしたルール強くなる(後勝ち)
sshd_config.d最初に現れた値弱くなる(先勝ち)

「設定は後勝ち」と覚えてしまうと sshd で失敗し、「先勝ち」と覚えると sudoers で失敗します。種類ごとに規則を確かめ、実効値で検証するしかありません。

問 2 · 同じ設定なのに結果が正反対になった理由

STEP ⑤ D-1 と STEP ⑦ では、まったく同じ PasswordAuthentication no を適用しました。それなのに、片方では締め出され、もう片方では問題なく入れました。何が違ったのですか? また、締め出しの確認に Permission denied ではなく Authentications that can continue を見たのはなぜですか。

解答を見る

違いは順番だけです。

やった順序結果
D-1(STEP ⑤)パスワードを閉じた。鍵は配っていない入る手段がゼロ = 締め出し
フェーズ F(STEP ⑦)先に鍵を配ってからパスワードを閉じた鍵で入れる = 事故なし

設定ファイルの中身は 1 文字も違いません。「安全な設定」という概念は存在せず、あるのは「安全な順序」だけです。これは本番でアクセス制御を変更するときの鉄則そのものです。

Authentications that can continue を見た理由: これはサーバが提示した認証方式の一覧で、サーバ側の状態そのものを表します。一方 Permission denied は「入れなかった」という結果でしかなく、鍵が無いのか、パスワードが違うのか、経路が閉じているのかを区別できません。

Authentications that can continue: publickey なら、パスワード経路がサーバ側で閉じていることが確定します。成否ではなく方式で見るのが、この種の検証の作法です。

あわせて、SHOULD_NOT_APPEAR という文字列を仕込んでおき、それが出ないことを証拠にしている点にも注目してください。「エラーが出た」ではなく「通っていたら必ず出るはずのものが出ていない」で判定しています。

段階ヒント(問 1・問 2 共通)

段階ヒント 1 段目:順序と実効値がこの章のすべて

この章で守る規律は 3 つです。①回復経路が通ることを確認してから設定を触る ②何も変更していない時点で snapshot を取る ③反映確認は systemctl の結果ではなく sshd -T -C の実効値で行う。詰まったときは、この 3 つのどれを飛ばしたかを疑ってください。

段階ヒント 2 段目:要点だけを抜き出したコマンド
回復経路の確認(★これが通るまで先へ進まない)
ssh -o IdentitiesOnly=yes -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no \
    -i ~/.ssh/ch4_ops_ed25519 ops@learner01 'echo OPS_KEY_LOGIN_OK'
Match が効いているかは -C の差で見る
for U in ops labuser02; do
  echo "--- user=$U ---"
  sudo sshd -T -C user=$U,host=workstation01,addr=<workstation01のIP> \
    | grep -iE '^(passwordauthentication|kbdinteractiveauthentication)'
done
sudoers は 3 段(単独検査 → install で配置 → 全体検査)
TMP="$(mktemp)"
printf '%s\n' 'labuser01 ALL=(root) NOPASSWD: /usr/local/sbin/ch4-ssh-log ""' > "$TMP"
sudo visudo -cf "$TMP"
sudo install -o root -g root -m 0440 "$TMP" /etc/sudoers.d/10-ch4-labuser01
rm -f "$TMP"
sudo visudo -c

よくある落とし穴:

  • sudo sshd -treload を別の行に書く → 検査が失敗しても壊れた設定が適用される。必ず && でつなぐ
  • -C を付けずに sshd -T を打つ → Match が評価されず、実際と違う値が出る
  • snapshot を「壊した後」に取る → 自己剥奪ルールごと復元されて詰む
答えを見る(この章の設計を 1 枚に)
フェーズと、そこで守っていること
A ①  実効値を記録し、鍵で入れることを証明する
      └─ 触る前に「戻れること」を確かめる

B ②  snapshot(★まだ何も変更していない)
      └─ 取得点が、何が残り何が消えるかを決める

C ③④ 演習ユーザ・鍵・最小権限 sudo
      └─ ラッパー+"" で引数を構造的に通さない

D ⑤  D-1: 鍵なしで閉じる → 締め出し
      D-2: 検査を通る !ALL → sudo が死ぬ
      └─ 5 経路を潰して「内側に手段が無い」を確定させる

E ⑥  qm rollback(唯一の出口)
      └─ B の取得点が正しかったからこそ確実に戻る

F ⑦  鍵を先に配ってから同じ設定を適用
      └─ 中身は同じ。違うのは順番だけ

この章で覚えること:

  • sudoers にコマンド名だけ書くのは、root を渡すのと変わらないことがある
  • visudo が通ることは、安全であることを意味しない
  • sudoers は後勝ち、sshd_config は先勝ち。逆である
  • 反映確認は systemctl ではなく sshd -T -C の実効値で
  • snapshot は「壊す前」に取る。取得点がすべてを決める
  • 安全な設定という概念は無い。あるのは安全な順序だけ

次の章へ

ここまでで、サービスを常駐させ(Ch1)、ログで追い(Ch2)、権限を設計し(Ch3)、それを安全に固める(Ch4)ところまで来ました。次の Chapter 5 では、運用のもう 1 つの軸である容量を扱います。

Chapter 5: ディスクを拡張する(LVM・容量管理):
  • 稼働中の VM に仮想ディスクを追加し、ゲストが再起動なしに認識する様子を見る
  • LVM を組み、マウントしたまま 8G から 15G へオンライン拡張する
  • 「割り当てた容量」と「実際に消費した容量」が違うことを、物理層の数字で確かめる

本章で使った qm コマンドとスナップショットの考え方が、そのまま土台になります。

もっと知りたい方へ