TLS とファイアウォールで、公開範囲を絞る
演習用証明書の信頼を確かめ、5 台の到達表を変更前後で比べて、必要なサービス経路だけを残します。
| 所要時間 | 約 110 分(概念 20 分+演習 90 分) |
|---|---|
| 前提知識 | Part 2 の TCP・port・経路・通信制御、Part 3 の SSH・権限、Part 4 Chapter 1〜4 の概念 |
| 使用機材 | 入口サーバ front01、アプリサーバ app01・app02、DB サーバ db01、操作端末 workstation01、LAN 内の教材取得経路 |
| 関連章 | 前: Part 4 / Chapter 4「2 台へ振り分け、故障した 1 台を経路から外す」/後: Part 4 / Chapter 6「改札サービスを建て直し、運用へ引き渡す」 |
動く経路を、公開してよい経路へ変える
入口が 2 台の App(タップを処理するアプリケーション)へ要求を振り分けられても、通信相手を確かめる仕組みと、各層へ到達できる送信元の制限がなければ、サービスを引き渡せる状態とは言えません。本章では入口に演習用の TLS(通信を暗号化し、相手を証明書で確かめる仕組み)を設定し、5 台をまたぐ到達性を変更前と変更後で比較します。
本章も初期状態から独立して成立します。Chapter 4 と同じ手順で 4 層を短縮構築しますが、ほかの章の構築物は前提にしません。振り分けと共有状態の理由を確認したい場合は Chapter 4 を参照してください。
許可するサービス経路
workstation01 ── HTTPS ──> front01
├──> app01 ──┐
└──> app02 ──┴──> db01
変更前: 5 台から各公開面を同じ方法で測る
変更後: 同じ測定を繰り返し、許可・拒否の差だけを見る
この章で受講者が変更するのは、サービスを構成する VM 同士の経路だけです。管理経路や、受講者が触れない層の保護はこの演習では変更しません。また、TLS を 1 台へ設定するだけで終わらず、入口、2 台の App、DB、操作端末を横断して最小経路を証明します。
この章のゴール: 演習用証明書の名前・発行者・期限を読み、信頼前後の違いを説明できる。さらに 5 台から採った到達性の表を変更前後で比較し、入口から DB までのサービス経路だけが許可されたことを証明できる。
この章は、街の「サービスエリア」を作る Part 4 の一部です。街の全体像は Part 0(この教材で作る街)の地図で確かめられます。
扱わない範囲
本章では、次の範囲は扱いません。
- 公開インターネット、外部 DNS、本番用の証明書。演習環境内の信頼と到達性だけを扱います。
- 侵入検知、継続監視、アラート。これらは Part 7 で扱います。
- 通信の仕組みそのもの。Part 2 の知識を使い、本章では完成したサービスの公開面を絞ります。
- VM の clone、配置、基盤側の高可用化。これらは Part 5 の範囲です。
- 受講者が触れない層にある保護の実装と設定内容。
この章を終えるとできること
TLS の信頼と層間の到達性を別々に観測し、サービスの公開範囲を根拠付きで絞れるようになります。本章で身につく 4 動作 は次のとおりです。
curl と openssl s_client を使い、暗号化された通信と相手の検証を分けて観測する。暗号化、相手の確認、公開範囲を分けて考える
1. 証明書は名前・発行者・期限の 3 点で読む
server certificate は、単に「暗号化を有効にするファイル」ではありません。クライアントは、接続に使った名前が SAN に含まれるか、信頼する発行者へ証明書チェーン(chain:証明書から信頼する CA までのつながり)をたどれるか、現在時刻が有効期間内かを確認します。
名前 : 接続先として指定した名前が SAN にあるか 発行者 : 信頼する CA へ chain をたどれるか 期限 : notBefore から notAfter の範囲内か
本章では openssl x509 で作成物を読み、openssl s_client でサーバが実際に提示した chain を読みます。「作成したファイル」と「通信時に提示されたもの」を同じだと思い込まず、別々に確認します。
2. 暗号化されていることと、相手が正しいことは別
TLS ハンドシェイク(handshake:通信を始める前の確認と取り決め)が始まり暗号化方式を合意できても、クライアントが証明書の発行者を信頼していなければ検証は失敗します。また、発行者を信頼しても、接続名が SAN と一致しなければ別の相手かもしれません。
そのため STEP 5 では、まず CA を指定せずに失敗を観測し、その後で演習用 CA を明示して成功を確認します。失敗を飛ばして成功だけを見ると、何を信頼した結果なのかを説明できません。
3. 公開面は「どこで待つか」と「どこから届くか」の組み合わせ
ss -lntp は各 VM がどの port で待っているかを示します。しかし、待受があるだけでは、どの送信元からそこへ到達できるかは分かりません。そこで 5 台それぞれを送信元にして同じ TCP probe(TCP 接続を試して届くかを確かめること)を行い、送信元と宛先の組を表にします。
本章で受講者が適用するルールは service network(サービスを構成する VM 同士が通信するネットワーク。管理経路とは別)側だけです。目標は、操作端末から入口の HTTPS、入口から 2 台の App、2 台の App から DB というサービス経路を残し、それ以外の直接到達を止めることです。
この環境には、受講者の設定とは別の層で保たれている保護があります。本章ではその層を変更せず、実装や設定内容にも立ち入りません。受講者が作る表とルールは、サービスを構成する VM 同士の到達性だけを対象にします。
4. ファイアウォール(FW)は許可の順序と確認の位置で安全性が変わる
通信を絞るときは、必要な許可を用意する前に広い拒否を適用すると、自分の SSH 接続まで切るおそれがあります。受講者には VM console(VM の画面を直接操作する窓口)がなく、SSH を切るとその場では設定を直せません。
本章では、最初に管理接続を明示的に許可し、その後はサービス経路のルールを 1 つだけ適用するたびに、別ターミナルから新しい管理接続が成功することを確認します。最後にまとめて確認する方式にはしません。
⚠️ 接続できなくなった場合の退路: 新しい SSH 接続を確認できなかったら、その先のルールを適用しないでください。接続できなくなった場合は、演習セッションを終了すると環境が初期状態へ戻ります。
演習:TLS と 5 台の到達表で最小経路を証明する
演習環境
| 項目 | 値 |
|---|---|
| 構築対象 | front01、app01、app02、db01 |
| 操作・観測端末 | workstation01 |
| 接続方法 | workstation01 から各 VM の接続名を使って SSH 接続 |
| 必要権限 | sudo。package 導入、証明書と秘密鍵の配置、nginx 設定、service network 側のルール設定に使う |
| 取得経路 | 演習環境で指定された LAN 内ミラーと教材配布元 |
| 所要時間 | 約 90 分(STEP 1〜8 の実行) |
🛟 失敗しても安全: 変更するのは演習用 VM 内の TLS 設定と service network 側のルールです。STEP 6 は 1 ルールごとに新しい SSH 接続を確認します。接続できなくなった場合は先へ進まず、演習セッションを終了すると環境が初期状態へ戻ります。
山括弧で囲んだ値は、演習環境または教材の案内に表示された値へ置き換えてください。接続先・port・interface の実値は、演習環境ごとに異なります。
4 層を clean から用意する
演習環境は毎回、素の状態から始まります。この STEP では db01、app01、app02、front01 の 4 台を、取得経路から「入口経由で要求が通る」状態まで作ります。役割ごとに 1 回だけ手順を示し、App 用の手順は 2 台へ順に適用します。構成する理由と観測の分解は第 3 章と第 4 章を参照してください。
1-1. 各 VM 共通 · 取得経路を設定する
db01 → app01 → app02 → front01 の順に、各 VM へ接続します。取得経路の設定は既存の source には触れず、章専用ディレクトリ /etc/apt/sources.list.d.part4/ だけを使います。鍵の fingerprint は使い捨ての GnuPG home で照合し、apt-get には章専用 source への限定と APT::Get::List-Cleanup=0 を渡します。この設定から package 導入までを 1 回の接続で完結させ、別のブロックへ配列を持ち越しません。
取得経路を変える前に、各 VM の package 一覧を記録します。終了時はこの一覧との差分で確認します(gpg の鍵照合は使い捨ての GnuPG home で行うため、~/.gnupg には触れません)。また、8 分類の外側に残る変更を確認するため、4 台の VM と workstation01 で、path・type・mode・uid・gid・size・mtime の sorted manifest(ファイルの場所・権限・更新日時などを一覧にしたもの)を最初に記録します。
sudo find /etc /var/lib /srv /opt /usr/local "$HOME" \
-not -path '/var/lib/apt/*' \
-not -path '/var/lib/dpkg/*' \
-not -path '/var/lib/systemd/*' \
-not -path '/var/log/*' \
-not -path '/var/cache/*' \
-not -name '.bash_history' \
-not -path '/etc/passwd-' -not -path '/etc/group-' \
-not -path '/etc/shadow-' -not -path '/etc/gshadow-' \
-not -path '/var/lib/fwupd' -not -path '/var/lib/fwupd/*' \
-not -path '/etc/fwupd' -not -path '/etc/fwupd/*' \
-not -path '/var/lib/ucf' -not -path '/var/lib/ucf/*' \
-not -path '/var/lib/landscape' -not -path '/var/lib/landscape/*' \
-not -path '/etc/ld.so.cache' \
-not -path '/var/lib/ubuntu-release-upgrader/release-upgrade-available' \
-not -path '/var/lib/snapd/state.json' \
-not -path '/var/lib/snapd/maintenance.json' \
-not -path '/var/lib/logrotate/status' \
-printf '%p\t%y\t%m\t%U\t%G\t%s\t%T@\n' 2>/dev/null \
| LC_ALL=C sort \
| sudo tee /tmp/part4-manifest-before.tsv >/dev/null
dpkg-query -W -f='${binary:Package}\t${Version}\n' | sort > /tmp/part4-package-before.tsv
1-2. DB 役割(db01)
第 4 章 STEP 01 の 1-2 と同じ手順で作ります。PostgreSQL の導入、書庫の SHA-256 照合、schema.sql の実行、App 専用ユーザと GRANT、待受アドレスのドロップイン(既存の設定ファイルを書き換えず、別のファイルで設定を足す方式)、app01・app02 の 2 行の接続許可、restart 後の SHOW・ss 確認までがひとまとまりです。
ssh deployer@db01
(
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" -o /tmp/infra-study-archive-keyring.asc
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
EXPECTED_FINGERPRINT='089E07BDFFA26DB7C7203016297FAD1317CA1DC4'
ACTUAL_FINGERPRINT="$(gpg --homedir "$GPG_HOME" --show-keys --with-colons /tmp/infra-study-archive-keyring.asc | awk -F: '$1=="fpr" {print $10; exit}')"
test "$ACTUAL_FINGERPRINT" = "$EXPECTED_FINGERPRINT"
fingerprint_status=$?
rm -rf -- "$GPG_HOME"
test "$fingerprint_status" -eq 0
sudo install -d -m 0755 /etc/apt/keyrings /etc/apt/sources.list.d.part4
sudo install -o root -g root -m 0644 /tmp/infra-study-archive-keyring.asc /etc/apt/keyrings/infra-study-archive-keyring.asc
sudo tee /etc/apt/sources.list.d.part4/infra-study.sources >/dev/null <<EOF
Types: deb
URIs: $REPO_URI
Suites: noble
Components: part4
Architectures: amd64
Signed-By: /etc/apt/keyrings/infra-study-archive-keyring.asc
EOF
sudo chmod 0644 /etc/apt/sources.list.d.part4/infra-study.sources
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
sudo apt-get "${APT_ONLY_PART4[@]}" update
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" install -y postgresql postgresql-client
TAPAPI_ARCHIVE=/tmp/tapapi-1.1.tar.gz
TAPAPI_SHA256=8a4e518211fc661f6fdcc0b2dd45be50eb1a199b36eaeb8db1b0a985a4eb2a9e
curl --fail --show-error --location \
--output "$TAPAPI_ARCHIVE" \
"http://172.16.10.22/assets/part4/tapapi-1.1.tar.gz"
printf '%s %s\n' "$TAPAPI_SHA256" "$TAPAPI_ARCHIVE" \
| sha256sum --check - || { echo 'SHA-256 mismatch: stop' >&2; exit 1; }
TAPAPI_TMP="$(mktemp -d)"
tar -xzf "$TAPAPI_ARCHIVE" -C "$TAPAPI_TMP"
sudo -u postgres createuser --login tap_app
sudo -u postgres psql -c '\password tap_app'
sudo -u postgres createdb transit_lab
sudo -u postgres psql -d transit_lab < "$TAPAPI_TMP/tapapi/schema.sql"
sudo -u postgres psql -d transit_lab <<'SQL'
GRANT CONNECT ON DATABASE transit_lab TO tap_app;
GRANT USAGE ON SCHEMA public TO tap_app;
GRANT SELECT, UPDATE ON cards TO tap_app;
GRANT INSERT ON tap_events TO tap_app;
SQL
rm -rf "$TAPAPI_TMP"
PG_CONF="$(sudo -u postgres psql -Atc 'SHOW config_file;')"
PG_HBA="$(sudo -u postgres psql -Atc 'SHOW hba_file;')"
PG_CONF_DIR="$(dirname "$PG_CONF")"
sudo install -d -m 0755 "$PG_CONF_DIR/conf.d"
sudo tee "$PG_CONF_DIR/conf.d/part4.conf" > /dev/null <<'EOF'
listen_addresses = '172.16.10.24'
EOF
sudo grep -nE "^[[:space:]]*include_dir[[:space:]]*=[[:space:]]*'conf\.d'" "$PG_CONF"
for APP_ADDR in 172.16.10.20 172.16.10.21; do
HBA_LINE="host transit_lab tap_app ${APP_ADDR}/32 scram-sha-256"
sudo grep -qsF "$HBA_LINE" "$PG_HBA" || \
printf '%s\n' "$HBA_LINE" | sudo tee -a "$PG_HBA" > /dev/null
done
sudo systemctl restart postgresql
sudo -u postgres psql -Atc 'SHOW listen_addresses;'
sudo ss -lntp | grep postgres
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2
(2026-09-02 実測)
=== schema ===
CREATE TABLE
CREATE TABLE
INSERT 0 1
=== GRANT ===
GRANT
GRANT
GRANT
GRANT
=== include_dir の確認 ===
817:include_dir = 'conf.d' # include files ending in '.conf' from
=== pg_hba への追記(2 行)===
133:host transit_lab tap_app 172.16.10.20/32 scram-sha-256
134:host transit_lab tap_app 172.16.10.21/32 scram-sha-256
=== restart 後の待受 ===
172.16.10.24
LISTEN 0 200 172.16.10.24:5432 0.0.0.0:* users:(("postgres",pid=3752,fd=6))
SHOW listen_addresses(listen_addresses は DB が接続を待つアドレスの設定)が 172.16.10.24 を返し、ss の待受も同じアドレスだけになっていることを 2 つの出力で確かめます。片方だけでは足りません。設定値が読めても、実際にその socket で 待っているとは限らないからです。
1-3. App 役割(app01 → app02 の順)
次の 1 組を app01、app02 の順に適用します。配置、所有者、権限、DB 接続項目は共通にし、環境ファイルの TAPAPI_NODE だけを対象名へ変えます。SHA-256 が一致しなければ、その場で中止します。
まず対象の App へ接続します(1 台目は app01、終わったら exit して 2 台目は app02)。
ssh deployer@<app01の接続名>
続いて、接続した VM の中で次のブロックを実行します。
# app01 で一度実行し、次に app02 でも同じブロックを実行する
(
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" -o /tmp/infra-study-archive-keyring.asc
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
EXPECTED_FINGERPRINT='089E07BDFFA26DB7C7203016297FAD1317CA1DC4'
ACTUAL_FINGERPRINT="$(gpg --homedir "$GPG_HOME" --show-keys --with-colons /tmp/infra-study-archive-keyring.asc | awk -F: '$1=="fpr" {print $10; exit}')"
test "$ACTUAL_FINGERPRINT" = "$EXPECTED_FINGERPRINT"
fingerprint_status=$?
rm -rf -- "$GPG_HOME"
test "$fingerprint_status" -eq 0
sudo install -d -m 0755 /etc/apt/keyrings /etc/apt/sources.list.d.part4
sudo install -o root -g root -m 0644 /tmp/infra-study-archive-keyring.asc /etc/apt/keyrings/infra-study-archive-keyring.asc
sudo tee /etc/apt/sources.list.d.part4/infra-study.sources >/dev/null <<EOF
Types: deb
URIs: $REPO_URI
Suites: noble
Components: part4
Architectures: amd64
Signed-By: /etc/apt/keyrings/infra-study-archive-keyring.asc
EOF
sudo chmod 0644 /etc/apt/sources.list.d.part4/infra-study.sources
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
sudo apt-get "${APT_ONLY_PART4[@]}" update
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" install -y python3-psycopg2
TAPAPI_ARCHIVE=/tmp/tapapi-1.1.tar.gz
TAPAPI_SHA256=8a4e518211fc661f6fdcc0b2dd45be50eb1a199b36eaeb8db1b0a985a4eb2a9e
curl --fail --show-error --location \
--output "$TAPAPI_ARCHIVE" \
"http://172.16.10.22/assets/part4/tapapi-1.1.tar.gz"
printf '%s %s\n' "$TAPAPI_SHA256" "$TAPAPI_ARCHIVE" \
| sha256sum --check - || { echo 'SHA-256 mismatch: stop' >&2; exit 1; }
test "$(stat -c %s "$TAPAPI_ARCHIVE")" -eq 2728
tar -tzf "$TAPAPI_ARCHIVE"
TAPAPI_TMP="$(mktemp -d)"
tar -xzf "$TAPAPI_ARCHIVE" -C "$TAPAPI_TMP"
id tapapi >/dev/null 2>&1 || \
sudo useradd --system --home-dir /opt/tapapi --shell /usr/sbin/nologin tapapi
sudo install -d -o root -g root -m 0755 /opt/tapapi /etc/tapapi
sudo install -o root -g root -m 0755 "$TAPAPI_TMP/tapapi/tapapi.py" /opt/tapapi/tapapi.py
sudo install -o root -g root -m 0644 "$TAPAPI_TMP/tapapi/schema.sql" /opt/tapapi/schema.sql
sudo install -o root -g root -m 0644 "$TAPAPI_TMP/tapapi/tapapi.service" /etc/systemd/system/tapapi.service
sudo install -o root -g tapapi -m 0640 "$TAPAPI_TMP/tapapi/tapapi.env.example" /etc/tapapi/tapapi.env
rm -rf "$TAPAPI_TMP"
sudoedit /etc/tapapi/tapapi.env
sudo systemctl daemon-reload
sudo systemctl start tapapi.service
systemctl status tapapi.service --no-pager
# 待受が開くまで待つ(開かなければ 30 秒で諦める)
timeout 30 bash -c 'until curl -sf http://localhost:8080/health > /dev/null; do sleep 1; done'
curl --fail --show-error http://localhost:8080/health
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2
環境ファイルには TAPAPI_NODE を接続中の VM 名(app01 または app02)に合わせ、PGHOST=172.16.10.24、PGPORT=5432、PGDATABASE=transit_lab、PGUSER=tap_app は 2 台で同じ値にします。秘密値は端末出力や教材の証跡へ貼りません。各台の /health(App 自身が状態を答える確認用の URL)が "db":"ok" を返すことが、この役割の完了条件です。
(2026-09-02 実測) app01。app02 は `TAPAPI_NODE` だけが異なります。
=== 依存 package ===
python3-psycopg2 2.9.9-1build1
=== 書庫の照合 ===
/tmp/tapapi-1.1.tar.gz: OK
size=2728 OK
tapapi/
tapapi/schema.sql
tapapi/tapapi.env.example
tapapi/tapapi.py
tapapi/tapapi.service
=== 非機密の環境設定(秘密値は伏せる)===
TAPAPI_NODE=app01
PGHOST=172.16.10.24
PGPORT=5432
PGDATABASE=transit_lab
PGUSER=tap_app
/etc/tapapi/tapapi.env owner=root group=tapapi mode=640
=== service 状態 ===
* tapapi.service - Part 4 transit-card tap API
Loaded: loaded (/etc/systemd/system/tapapi.service; disabled; preset: enabled)
Active: active (running) since Wed 2026-09-02 10:34:50 JST; 2s ago
Main PID: 2029 (python3)
=== /health ===
{"status":"ok","db":"ok","node":"app01"} ← app02 では "node":"app02"
2 台で違うのは TAPAPI_NODE の 1 項目だけです。/health の "db":"ok" が、 その App から DB へ実際に繋がったことの確認になります。
1-4. 入口役割(front01)
nginx を導入し、第 4 章 STEP 04 と同じ正常時の振り分け(upstream:転送先の一覧に 2 台を登録)を設定します。受動的な故障検出の値は本章では使いません。
ssh deployer@front01
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" -o /tmp/infra-study-archive-keyring.asc
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
EXPECTED_FINGERPRINT='089E07BDFFA26DB7C7203016297FAD1317CA1DC4'
ACTUAL_FINGERPRINT="$(gpg --homedir "$GPG_HOME" --show-keys --with-colons /tmp/infra-study-archive-keyring.asc | awk -F: '$1=="fpr" {print $10; exit}')"
test "$ACTUAL_FINGERPRINT" = "$EXPECTED_FINGERPRINT"
fingerprint_status=$?
rm -rf -- "$GPG_HOME"
test "$fingerprint_status" -eq 0
sudo install -d -m 0755 /etc/apt/keyrings /etc/apt/sources.list.d.part4
sudo install -o root -g root -m 0644 /tmp/infra-study-archive-keyring.asc /etc/apt/keyrings/infra-study-archive-keyring.asc
sudo tee /etc/apt/sources.list.d.part4/infra-study.sources >/dev/null <<EOF
Types: deb
URIs: $REPO_URI
Suites: noble
Components: part4
Architectures: amd64
Signed-By: /etc/apt/keyrings/infra-study-archive-keyring.asc
EOF
sudo chmod 0644 /etc/apt/sources.list.d.part4/infra-study.sources
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
sudo apt-get "${APT_ONLY_PART4[@]}" update
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" install -y nginx
章専用の設定を作ります。
log_format tap_with_upstream
'$remote_addr $request status=$status event_id=$http_x_event_id '
'upstream=$upstream_addr upstream_status=$upstream_status';
upstream tap_app_backend {
server <app01の接続名>:8080;
server <app02の接続名>:8080;
}
server {
listen <入口の待受port>;
access_log /var/log/nginx/tap_access.log tap_with_upstream;
location / {
proxy_set_header X-Event-ID $http_x_event_id;
proxy_set_header Host $host;
proxy_pass http://tap_app_backend;
}
}
sudoedit "<nginxの章専用設定ファイル>" sudo ln -s "<nginxの章専用設定ファイル>" "<nginxの有効化先>" sudo nginx -t sudo systemctl reload nginx systemctl status nginx --no-pager
(2026-09-02 実測)
=== 導入 ===
nginx version: nginx/1.24.0 (Ubuntu)
nginx 1.24.0-2ubuntu7.15
=== 有効化先 ===
lrwxrwxrwx 1 root root 34 default -> /etc/nginx/sites-available/default
lrwxrwxrwx 1 root root 35 tap.conf -> /etc/nginx/sites-available/tap.conf
=== nginx -t ===
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== reload 後の待受 ===
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("nginx",...))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",...))
⚠️ 既定サイトが残ります:Ubuntu の nginx は
sites-enabled/default(port 80)が最初から有効です。 章専用設定は別の port なので演習は成立しますが、入口には教材が意図していない待受が 1 つあります。 STEP 6 で「最小に絞る」ときは、この既定サイトをどう扱うかも決めてください。
1-5. 成立ゲート · 入口経由で 4 層を貫通する
workstation01 から、入口経由で /health を要求します。教材アプリの GET(読み取るだけの要求の種類)は /health だけを受け付け、入口はすべての path(URL のうち場所を示す部分)を App へ転送するため、この 1 回の要求で「入口 → App → DB」の 3 つの接続が同時に検査されます。
# workstation01 で実行する curl --fail --show-error "http://<front01の接続名>:<入口の待受port>/health"
応答が {"status":"ok","db":"ok","node":...} であれば、絞る前の 4 層は成立しています。node の値は振り分け先によって app01 にも app02 にもなります。どちらでも正常です。
(2026-09-02 実測) 振り分けの両方を示すため 4 回続けて実行。
{"status":"ok","db":"ok","node":"app01"}
{"status":"ok","db":"ok","node":"app02"}
{"status":"ok","db":"ok","node":"app01"}
{"status":"ok","db":"ok","node":"app02"}
1 回の要求で「入口 → App → DB」の 3 接続が同時に検査されます。node が交互に変わるのは、 入口が 2 台へ振り分けているためで、どちらが返っても正常です。
5 台すべてで時刻を確認します。証明書の期限判定に使うため、大きなずれがある場合は変更せず、演習環境の案内へ戻ってください。
date --iso-8601=seconds
(2026-09-02 実測) workstation01 2026-09-02T10:35:42+09:00 front01 2026-09-02T01:35:44+00:00 learner01 2026-09-02T10:35:45+09:00 learner02 2026-09-02T10:35:46+09:00 db01 2026-09-02T01:35:47+00:00
🔴 表示のタイムゾーンが 5 台で揃っていません。上の実測では front01 と db01 だけが UTC、 残る 3 台は JST です。時刻そのものはずれていません(10:35 JST = 01:35 UTC)。 ずれているように見えたら、まずタイムゾーンの表示か時刻そのものかを分けてください。 certificate の
notBefore/notAfterは UTC で書かれているので、 比較したいときはdate --iso-8601=seconds --utcに揃えるのが確実です。
絞る前の到達性の表を作る
この STEP の表が、STEP 7 の比較基準です。先に保存しなければ、変更後に「もともと届かなかった」のか「ルールで絞れた」のかを区別できません。
まず各サービス VM で待受 port を列挙し、どのプロセスが公開面を持つかを記録します。
sudo ss -lntp
(2026-09-02 実測) 実アドレスは対象 port の特定に要る範囲だけ示します。 --- front01 --- LISTEN 0 511 0.0.0.0:8080 nginx ← 入口(章専用設定) LISTEN 0 511 0.0.0.0:80 nginx ← 既定サイト LISTEN 0 4096 0.0.0.0:22 sshd --- app01 / app02 --- LISTEN 0 5 0.0.0.0:8080 python3 ← App service LISTEN 0 4096 0.0.0.0:22 sshd --- db01 --- LISTEN 0 200 172.16.10.24:5432 postgres ← VLAN10 のアドレスだけで待つ LISTEN 0 4096 0.0.0.0:22 sshd
| 対象 | port | 待受の範囲 |
|---|---|---|
| 入口 HTTPS | 8443 | この時点ではまだ無い(STEP 4 で追加) |
| 入口 HTTP | 8080 | すべての interface |
| App service | 8080 | すべての interface |
| DB | 5432 | 172.16.10.24 だけ |
DB だけが待受アドレスを絞っている点に注目してください。STEP 2 の表で db01 から db-service が DENY になるのは、この設定の当然の結果です。
次の同一コマンド群を workstation01、front01、app01、app02、db01 の順に実行します。成功・失敗のどちらでも全行を残すため、途中で処理を止めません。
ここでは名前解決と TCP 到達性を分けて判定します。getent で接続名を IPv4 アドレスへ解決できなければ RESOLVE_DENY、解決後のアドレスへ TCP 接続できなければ DENY です。これにより、名前解決側の異常をファイアウォールの拒否と取り違えません。
probe_tcp() {
TARGET="$1"
PORT="$2"
LABEL="$3"
TARGET_IP="$(getent ahostsv4 "$TARGET" | awk 'NR == 1 { print $1 }')"
if [ -z "$TARGET_IP" ]; then
printf '%s\t%s\tRESOLVE_DENY\n' "$(hostname)" "${LABEL}"
elif timeout 3 bash -c ": </dev/tcp/${TARGET_IP}/${PORT}" 2>/dev/null; then
printf '%s\t%s\tALLOW\n' "$(hostname)" "${LABEL}"
else
printf '%s\t%s\tDENY\n' "$(hostname)" "${LABEL}"
fi
}
PHASE=before
{
probe_tcp "<front01の接続名>" "<HTTPの待受port>" "front-http"
probe_tcp "<front01の接続名>" "<HTTPSの待受port>" "front-https"
probe_tcp "<app01の接続名>" "<Appの待受port>" "app01-service"
probe_tcp "<app02の接続名>" "<Appの待受port>" "app02-service"
probe_tcp "<db01の接続名>" "<DBの待受port>" "db-service"
} | tee "/tmp/ch5-reach-${PHASE}-$(hostname).tsv"
各ホストの結果を、同じ列順の TSV ファイルへ保存します。
# workstation01 で 5 台ぶんを 1 つの表にまとめる
PHASE=before
OUT="/tmp/ch5-reachability-${PHASE}.tsv"
cat "/tmp/ch5-reach-${PHASE}-$(hostname).tsv" > "$OUT"
for H in front01 172.16.10.20 172.16.10.21 db01; do
ssh deployer@"$H" "cat /tmp/ch5-reach-${PHASE}-\$(hostname).tsv" >> "$OUT"
done
sort -o "$OUT" "$OUT"
cat "$OUT"
各台で date --iso-8601=seconds も記録し、変更前と変更後を同じ順序・同じ列で採ったことを示せるようにします。
workstation01 front-http ALLOW workstation01 front-https DENY workstation01 app01-service ALLOW workstation01 app02-service ALLOW workstation01 db-service ALLOW front01 front-http ALLOW front01 front-https DENY front01 app01-service ALLOW front01 app02-service ALLOW front01 db-service ALLOW learner01 front-http ALLOW learner01 front-https DENY learner01 app01-service ALLOW learner01 app02-service ALLOW learner01 db-service ALLOW learner02 front-http ALLOW learner02 front-https DENY learner02 app01-service ALLOW learner02 app02-service ALLOW learner02 db-service ALLOW db01 front-http ALLOW db01 front-https DENY db01 app01-service ALLOW db01 app02-service ALLOW db01 db-service DENY
db01 自身の db-service が DENY なのは、接続名が loopback を指す一方、PostgreSQL はサービス用アドレスだけで待ち受けているためです。ファイアウォール適用前の拒否として、測定値をそのまま残します。
変更前の表は次の形へまとめます。セルは実測した ALLOW または DENY で埋め、予想で書きません。
| 送信元 | front HTTP | front HTTPS | app01 service | app02 service | DB service |
|---|---|---|---|---|---|
workstation01 | ALLOW | DENY | ALLOW | ALLOW | ALLOW |
front01 | ALLOW | DENY | ALLOW | ALLOW | ALLOW |
app01 | ALLOW | DENY | ALLOW | ALLOW | ALLOW |
app02 | ALLOW | DENY | ALLOW | ALLOW | ALLOW |
db01 | ALLOW | DENY | ALLOW | ALLOW | DENY |
表の完成時点では、結果を理想形に直しません。現在の公開面をそのまま保存することが目的です。
演習用 CA と server certificate を作って読む
この STEP では、秘密鍵を作る場所と、certificate に署名する場所を分けます。server の秘密鍵は front01 で作り、front01 の外へ一度も出しません。workstation01 へ渡すのは CSR(署名要求)だけです。CSR には公開鍵と名前しか入っておらず、秘密鍵は含まれません。
📔 コラム · 秘密鍵は運ばないのが原則 秘密鍵を安全に運ぶ方法を考えるより、運ばなくて済む置き方を選ぶほうが確実です。運ばなければ、転送中に盗まれることも、端末の履歴やスクロールバックに残ることもありません。この章では「配布物は公開してよいものだけ」という形を最初から取ります。
まず workstation01 で演習用 CA の秘密鍵と certificate を作ります。秘密鍵を表示したり、章の証跡へ貼ったりしないでください。
mkdir -p "$HOME/ch5-tls" chmod 700 "$HOME/ch5-tls" cd "$HOME/ch5-tls" openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out lab-ca.key chmod 600 lab-ca.key openssl req -x509 -new -sha256 \ -key lab-ca.key \ -days 7 \ -subj "/CN=<演習用CA名>" \ -out lab-ca.crt
次に front01 へ接続し、server の秘密鍵と CSR を front01 上で作ります。秘密鍵は最初から最後まで front01 に置いたままにします。
ssh deployer@front01 sudo install -d -m 700 /etc/nginx/tls sudo openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \ -out /etc/nginx/tls/front01.key sudo chmod 600 /etc/nginx/tls/front01.key sudo openssl req -new -sha256 \ -key /etc/nginx/tls/front01.key \ -subj "/CN=<front01のTLS接続名>" \ -out /tmp/front01.csr
(2026-09-02 実測) 秘密鍵の内容は表示しません。 /etc/nginx/tls/front01.key owner=root group=root mode=600 size=1708 /tmp/front01.csr owner=root group=root mode=644 size=887 Certificate request self-signature verify OK subject=CN = front01
秘密鍵は mode=600・所有者 root で、この VM の外へ出す手順がどこにもありません。 一方 CSR は mode=644 で、公開してよい情報だけが入っています。
CSR を workstation01 へ取り出し、中身を読んでから署名へ進みます。取り出すのは CSR だけです。
scp deployer@<front01の接続名>:/tmp/front01.csr "$HOME/ch5-tls/front01.csr" openssl req -in "$HOME/ch5-tls/front01.csr" -noout -subject -verify
Certificate request self-signature verify OK subject=CN = front01
SAN を拡張ファイルへ記載し、演習用 CA で署名します。
cd "$HOME/ch5-tls" cat > front01.ext <<'EOF' subjectAltName=DNS:<front01のTLS接続名> keyUsage=digitalSignature,keyEncipherment extendedKeyUsage=serverAuth EOF openssl x509 -req -sha256 \ -in front01.csr \ -CA lab-ca.crt \ -CAkey lab-ca.key \ -CAcreateserial \ -days 7 \ -extfile front01.ext \ -out front01.crt
作成後は SAN、期限、発行者、subject(証明書の持ち主を示す名前)を読みます。秘密鍵ではなく certificate を入力にします。
cd "$HOME/ch5-tls" openssl x509 -in front01.crt -noout \ -subject -issuer -dates -ext subjectAltName openssl verify -CAfile lab-ca.crt front01.crt
subject=CN = infra-study-lab-ca notBefore=Sep 1 13:11:21 2026 GMT notAfter=Sep 8 13:11:21 2026 GMT
Certificate request self-signature verify OK
subject=CN = front01
subject=CN = front01
issuer=CN = infra-study-lab-ca
notBefore=Sep 1 13:11:55 2026 GMT
notAfter=Sep 8 13:11:55 2026 GMT
X509v3 Subject Alternative Name:
DNS:front01
front01.crt: OK
ここで、接続に使う名前が SAN にあり、発行者が演習用 CA で、有効期間が現在時刻を含むことを指差し確認します。どれか 1 つでも違えば入口へ配置しません。
front01 に certificate を配置する
TLS 用ディレクトリと秘密鍵は STEP 3 で front01 に作ってあります。ここで配置するのは certificate だけです。秘密鍵を転送する手順はありません。
# workstation01 から certificate を渡す(certificate は公開してよい情報) scp "$HOME/ch5-tls/front01.crt" deployer@<front01の接続名>:/tmp/front01.crt
ssh deployer@front01 sudo install -m 644 /tmp/front01.crt /etc/nginx/tls/front01.crt sudo stat /etc/nginx/tls/front01.crt /etc/nginx/tls/front01.key
(2026-09-02 実測) /etc/nginx/tls/front01.crt owner=root group=root mode=644 size=1172 /etc/nginx/tls/front01.key owner=root group=root mode=600 size=1708
certificate は読める権限(644)、秘密鍵は所有者だけ(600)です。同じディレクトリに置いても 権限を変えていることを確認してください。
配置した certificate が、front01 にある秘密鍵と対になっていることを確認します。秘密鍵と certificate を別々の場所で作ったので、組が合っていることを自分で確かめてから nginx へ渡します。
sudo openssl x509 -noout -modulus -in /etc/nginx/tls/front01.crt | openssl sha256 sudo openssl rsa -noout -modulus -in /etc/nginx/tls/front01.key | openssl sha256
(2026-09-02 実測) modulus そのものは表示せず、ハッシュだけを比べます。 crt: SHA2-256(stdin)= bf02321de9e2d48a92a0a530179903c45c13ee9992644e6b92965daac1c1cf8c key: SHA2-256(stdin)= bf02321de9e2d48a92a0a530179903c45c13ee9992644e6b92965daac1c1cf8c
2 行が完全に一致すれば、この certificate はこの秘密鍵に対応しています。 一致しなければ nginx へ渡してはいけません(起動はしても TLS が成立しません)。
章専用の nginx 設定へ、HTTP から HTTPS への転送と TLS 待受を追加します。upstream と API(プログラム同士が要求と応答をやり取りする窓口)の設定は STEP 1 で用意したものを参照します。
server {
listen <HTTPの待受port>;
server_name <front01のTLS接続名>;
return 301 https://$host:<HTTPSの待受port>$request_uri;
}
server {
listen <HTTPSの待受port> ssl;
server_name <front01のTLS接続名>;
ssl_certificate /etc/nginx/tls/front01.crt;
ssl_certificate_key /etc/nginx/tls/front01.key;
location / {
proxy_set_header X-Event-ID $http_x_event_id;
proxy_set_header Host $host;
proxy_pass http://tap_app_backend;
}
}
構文検査が成功した場合だけ reload します。reload 後は待受と提示 certificate を別々に確認します。
sudoedit "<nginxの章専用設定ファイル>" sudo nginx -t sudo systemctl reload nginx systemctl status nginx --no-pager sudo ss -lntp | grep nginx openssl s_client \ -connect "<front01の接続名>:<HTTPSの待受port>" \ -servername "<front01のTLS接続名>" \ -showcerts </dev/null
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
LISTEN 0 511 0.0.0.0:8443 0.0.0.0:* users:(("nginx",...))
(2026-09-02 実測)
=== nginx -t ===
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
=== reload 後の待受 ===
LISTEN 0 511 0.0.0.0:8443 0.0.0.0:* users:(("nginx",...))
LISTEN 0 511 0.0.0.0:8080 0.0.0.0:* users:(("nginx",...))
LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:(("nginx",...))
=== 提示された certificate ===
subject=CN = front01
issuer=CN = infra-study-lab-ca
HTTPS の待受(8443)が増えたことと、提示された certificate が STEP 3 で署名したものであることを、 別々の出力で確かめます。
この時点では HTTP の転送も構成されています。STEP 6 でクライアントから許可する入口を HTTPS だけへ絞った後は、service network 側の HTTP 転送へ直接到達できないことも表で確認します。
信頼前の失敗と信頼後の成功を比較する
workstation01 から、まず演習用 CA を指定せずに HTTPS へ接続します。失敗は予定された観測結果なので、終了 status を保存します。
curl --show-error --verbose \ "https://<front01のTLS接続名>:<HTTPSの待受port>/health" printf 'curl_exit=%s\n' "$?"
(2026-10-12 実測・抜粋。--verbose の経過行〔* > < で始まる行〕は省略) curl: (60) SSL certificate problem: unable to get local issuer certificate More details here: https://curl.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could not establish a secure connection to it. ... curl_exit=60
次に、サーバが提示する chain と検証結果を読みます。
openssl s_client \ -connect "<front01の接続名>:<HTTPSの待受port>" \ -servername "<front01のTLS接続名>" \ -showcerts </dev/null
(2026-09-02 実測) === 信頼前(演習用 CA を渡さない)=== curl: (60) SSL certificate problem: unable to get local issuer certificate More details here: https://curl.se/docs/sslcerts.html curl failed to verify the legitimacy of the server and therefore could not establish a secure connection to it. http=000 curl_exit=60 === 提示された chain と検証結果 === depth=0 CN = front01 subject=CN = front01 issuer=CN = infra-study-lab-ca Verification error: unable to verify the first certificate
chain は 1 段だけ(depth=0)で、その発行者は演習用 CA です。 失敗の理由は「暗号化できない」ではなく、その CA を信頼する材料を client が持っていないことです。
演習用 CA を --cacert で明示し、同じ URL へ再接続します。OS 全体の信頼ストア(信頼する CA の証明書を集めた置き場)は変更しません。
curl --fail --show-error --verbose \ --cacert "$HOME/ch5-tls/lab-ca.crt" \ "https://<front01のTLS接続名>:<HTTPSの待受port>/health"
(2026-09-02 実測)
{"status":"ok","db":"ok","node":"app02"}
http=200
Verify return code: 0 (ok)
渡したのは CA の certificate 1 本だけです。OS 全体の信頼ストアは変更していません。 同じ URL・同じ接続名で、結果だけが変わったことを確認してください。
信頼前後で変わったのは、通信を暗号化できるかではなく、提示された certificate を信頼できる相手のものとして検証できるかです。成功時も SAN、発行者、期限のどれを満たしたかを説明してください。
サービス側の経路を 1 ルールずつ最小に絞る
⚠️ 最後にまとめて確認しない: 受講者には VM console がありません。各ルールを 1 つ適用するたびに、別ターミナルから対象 VM への新しい SSH 接続を確認します。失敗したら次のルールへ進みません。
この章では ufw(簡単な書き方でファイアウォールを設定する道具)を使います。演習環境の 4 台には ufw / nft / iptables(Linux の通信制御を操作する道具)がいずれも入っていますが、初期状態では ufw が inactive で、ルールは 1 行もありません。つまり、これから受講者が書くルールが、この層で最初のルールになります。
ufw の既定は 受信は拒否・送信は許可です。有効化した瞬間から、許可を書いていない受信はすべて落ちます。管理接続(SSH)の許可を先に置いてから有効化するのはこのためです。
⚠️ 順序を逆にすると戻れません: 許可を置く前に有効化すると、その瞬間に自分の SSH が落ちます。受講者には VM console がないため、演習セッションを終了して環境を初期状態へ戻す以外に復旧手段がありません。
まず workstation01 に制御用ターミナルを 1 つ残し、4 台のサービス VM への新しい SSH 接続が成功する初期状態を保存します。あわせて、各 VM で ufw が inactive であることと interface の一覧を記録します。
# 対象 VM 内で初期状態を記録する sudo ufw status verbose ip -br addr show
(2026-09-02 実測) 実アドレスは VLAN10 側だけを示します。 --- front01 --- Status: inactive eth0 UP <管理LANのアドレス> ens19.10@ens19 UP 172.16.10.23/24 --- app01 --- Status: inactive ens18 UP <管理LANのアドレス> ens20.10@ens20 UP 172.16.10.20/24 --- app02 --- Status: inactive ens18 UP <管理LANのアドレス> ens20.10@ens20 UP 172.16.10.21/24 --- db01 --- Status: inactive eth0 UP <管理LANのアドレス> ens19.10@ens19 UP 172.16.10.24/24
4 台とも Status: inactive で、ルールは 1 行もありません。 これから書くルールが、この層で最初のルールになります。
for HOST in front01 172.16.10.20 172.16.10.21 db01; do
ssh -o BatchMode=yes -o ConnectTimeout=5 "deployer@${HOST}" \
'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"'
done
(2026-09-02 実測) management-ok host=front01 time=2026-09-02T01:43:34+00:00 management-ok host=learner01 time=2026-09-02T10:43:35+09:00 management-ok host=learner02 time=2026-09-02T10:43:35+09:00 management-ok host=db01 time=2026-09-02T01:43:35+00:00
4 台すべてで成功することを、ルールを 1 つも入れる前に確かめます。 これが後の比較基準になります(失敗したときに「元から届いていなかった」を排除できます)。
6-1. 管理接続の許可を先に置く
対象 VM は 1 台ずつ扱います。最初の VM に管理接続の許可ルールを 1 つだけ登録してから有効化し、直後に制御用ターミナルからその VM へ新しく SSH 接続します。成功を記録してから次の VM へ移ります。
ufw は inactive の間もルールを登録できます。登録が先、有効化が後です。
# 対象 VM 内で 1 ルールだけ登録してから有効化する sudo ufw allow from <workstation01の接続元> to any port <管理接続のport> proto tcp sudo ufw --force enable sudo ufw status verbose # 直後に workstation01 の別ターミナルから確認する ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@<対象VMの接続名> \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"'
この「1 ルール適用 → 新しい SSH 接続」の対を front01、app01、app02、db01 の順に繰り返します。4 台分を先にまとめて適用してはいけません。
(2026-09-02 実測) 4 台とも同じ形。以下は 1 台目(front01)。 Rules updated Firewall is active and enabled on system startup Status: active Logging: on (low) Default: deny (incoming), allow (outgoing), disabled (routed) New profiles: skip To Action From -- ------ ---- 22/tcp ALLOW IN 172.16.10.22 --- 直後の新しい SSH 接続 --- management-ok host=front01 time=2026-09-02T01:43:58+00:00
4 台すべてで「1 ルール登録 → 有効化 → 直後に新しい SSH が成功」を確認しました。 Default: deny (incoming) になった後も管理接続が生きているのは、先に許可を置いたからです。
6-2. workstation01 から front01 の HTTPS を許可する
front01 に、操作端末から入口 HTTPS への許可を 1 つだけ適用します。直後に front01 への新しい SSH 接続を確認し、続けて HTTPS を試します。
🔴 この時点で HTTPS はまだ通りません。入口までは届きますが、入口から App への経路をまだ開けていないので応答が返らないためです。通らないことを確認するのがこの手順の目的です。「入口までは届いている」ことだけを見たいときは、TLS の成立(Verify return code: 0)を確かめてください。
📔 なぜ、通らないタイミングで測るのか 通るようになってから測ると、観測できるのは「通った」だけです。いまのように 1 段ずつ開けていく途中で測ると、「入口までは届く」「TLS は成立する」「App までは届かない」を層ごとに分けて確認できます。 障害の切り分けは、まさにこの順で見ます。どこまで届いているかを層で言えることが、「なんとなく繋がらない」と「App の手前で止まっている」を分ける力になります。 クラウドのマネージド型の入口では、この「絞った途中の状態」自体を作れません。実機だから見られる景色です。
sudo ufw allow from <workstation01の接続元> to any port <HTTPSの待受port> proto tcp sudo ufw status numbered
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@front01 \
'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"'
curl --fail --show-error --max-time 10 \
--cacert "$HOME/ch5-tls/lab-ca.crt" \
-o /dev/null -w 'http=%{http_code}\n' \
"https://<front01のTLS接続名>:<HTTPSの待受port>/health"
(2026-09-02 実測) === ルール === [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 8443/tcp ALLOW IN 172.16.10.22 === 直後の管理接続 === management-ok host=front01 time=2026-09-02T01:45:12+00:00 === 直後の HTTPS 応答 === curl: (28) Operation timed out after 10002 milliseconds with 0 bytes received http=000
🔴 この時点では HTTPS は通りません。成功しないのが正しい観測結果です。 6-1 で 4 台とも
deny (incoming)にしたため、入口から App への経路がまだ塞がっています。 TLS の終端まではできますが、入口が App に届かないので応答が返りません。 確認できるのは「入口までは届く」ところまでです。TLS が成立していることだけを見たいなら、openssl s_clientでVerify return code: 0 (ok)を確かめてください。 HTTPS 経由でアプリの応答まで通るのは、6-6 まで終えた後です(そこで改めて確認します)。
6-3. front01 から app01 を許可する
app01 に、front01 から App service への許可を 1 つだけ適用します。直後に app01 への新しい SSH 接続を確認し、front01 から App service へ probe します。
sudo ufw allow from <front01の接続元> to any port <Appの待受port> proto tcp sudo ufw status numbered
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@<app01の接続名> \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"' ssh deployer@front01 'timeout 3 bash -c ": </dev/tcp/<app01の接続名>/<Appの待受port>"'
(2026-09-02 実測) === ルール === [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 8080/tcp ALLOW IN 172.16.10.23 === 直後の管理接続 === management-ok host=learner01 time=2026-09-02T10:45:22+09:00 === front01 から App service への probe === app01-probe ALLOW
6-4. front01 から app02 を許可する
app02 も同じ順序です。許可を 1 つ適用し、app02 への新しい SSH 接続を確認してから service probe を行います。
sudo ufw allow from <front01の接続元> to any port <Appの待受port> proto tcp sudo ufw status numbered
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@<app02の接続名> \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"' ssh deployer@front01 'timeout 3 bash -c ": </dev/tcp/<app02の接続名>/<Appの待受port>"'
(2026-09-02 実測) === ルール === [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 8080/tcp ALLOW IN 172.16.10.23 === 直後の管理接続 === management-ok host=learner02 time=2026-09-02T10:45:24+09:00 === front01 から App service への probe === app02-probe ALLOW
6-5. app01 から db01 を許可する
db01 に、app01 から DB service への許可を 1 つだけ適用します。直後に db01 への新しい SSH 接続を確認し、app01 から DB service へ probe します。
sudo ufw allow from <app01の接続元> to any port <DBの待受port> proto tcp sudo ufw status numbered
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@db01 \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"' ssh deployer@<app01の接続名> 'timeout 3 bash -c ": </dev/tcp/<db01の接続名>/<DBの待受port>"'
(2026-09-02 実測) === ルール === [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 5432/tcp ALLOW IN 172.16.10.20 === 直後の管理接続 === management-ok host=db01 time=2026-09-02T01:45:24+00:00 === app01 から DB service への probe === app01->db ALLOW
6-6. app02 から db01 を許可する
最後の必要経路も 1 つだけ適用します。db01 への新しい SSH 接続を確認してから、app02 から DB service へ probe します。
sudo ufw allow from <app02の接続元> to any port <DBの待受port> proto tcp sudo ufw status numbered
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@db01 \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"' ssh deployer@<app02の接続名> 'timeout 3 bash -c ": </dev/tcp/<db01の接続名>/<DBの待受port>"'
(2026-09-02 実測) === ルール === [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 5432/tcp ALLOW IN 172.16.10.20 [ 3] 5432/tcp ALLOW IN 172.16.10.21 === 直後の管理接続 === management-ok host=db01 time=2026-09-02T01:45:24+00:00 === app02 から DB service への probe === app02->db ALLOW
ここまでで、絞った状態のまま入口経由の要求が通るようになります。
{"status":"ok","db":"ok","node":"app02"}
http=200
6-7. 必要経路以外が拒否されていることを確認する
必要な許可をすべて置いたら、それ以外を拒否するルールを書き足す作業はありません。ufw は有効化した時点で「許可していない受信は拒否」という既定で動いているため、書かなかったものが既に落ちているからです。ここで行うのは、その状態を自分の目で確かめることです。
🔴 「拒否を書いていないから塞がっていない」は、この方式では誤りです。 逆に明示的な拒否を書き足すと、公開面を知るために許可リストと拒否リストの両方を読む必要が生じ、読み手が増えるほど間違えやすくなります。許可だけを並べ、それ以外は既定で落とす形を保ちます。
各 VM で、有効な許可の全量を読みます。
sudo ufw status verbose sudo ufw status numbered
表示された許可が STEP 1 の port 表と一致し、想定にない行が無いことを確認します。想定外の許可があれば番号を指定して 1 つずつ削除し、その都度、別ターミナルから新しい SSH 接続を確認します。
sudo ufw --force delete <想定にない許可の番号> ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@<対象VMの接続名> \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"'
(2026-09-02 実測) 4 台とも `Default: deny (incoming), allow (outgoing)`。 --- front01 --- [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 8443/tcp ALLOW IN 172.16.10.22 --- app01 --- [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 8080/tcp ALLOW IN 172.16.10.23 --- app02 --- [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 8080/tcp ALLOW IN 172.16.10.23 --- db01 --- [ 1] 22/tcp ALLOW IN 172.16.10.22 [ 2] 5432/tcp ALLOW IN 172.16.10.20 [ 3] 5432/tcp ALLOW IN 172.16.10.21
許可は合計 9 行だけです。port 表と突き合わせ、表に無い許可が 1 つも無いことを確認してください。
📔 コラム · この層は「出ていく通信」を止めていない
ufwの既定は 受信は拒否・送信は許可です。つまりここまでの作業で絞ったのは、各 VM がどこから受け取るかだけです。各 VM から外側へ出ていく通信は、この層では止まっていません。 それでも演習環境から外部へは出られません。止めているのは受講者が触っているこの層ではなく、§03 の 3 で触れた別の層です。本章ではその層を変更せず、実装にも立ち入りません。 🔴 自分が書いたルールで塞がっているのか、別の層で塞がっているのかを取り違えないでください。 「届かない」という観測結果は同じでも、どちらが効いているかで、設定を変えたときの結果が変わります。
各回で新しい SSH 接続に失敗したら、その先のコマンドは実行しません。接続できなくなった場合は、演習セッションを終了すると環境が初期状態へ戻ります。
絞った後の到達性の表を作り、差を比較する
STEP 2 と 同じ 5 台、同じ 5 宛先、同じ順序、同じ probe_tcp 関数で測定します。コマンドや timeout を変えると、ルールの差と測定方法の差が混ざるため変更しません。
probe_tcp() {
TARGET="$1"
PORT="$2"
LABEL="$3"
if timeout 3 bash -c ": </dev/tcp/${TARGET}/${PORT}" 2>/dev/null; then
printf '%s\t%s\tALLOW\n' "$(hostname)" "${LABEL}"
else
printf '%s\t%s\tDENY\n' "$(hostname)" "${LABEL}"
fi
}
PHASE=after
{
probe_tcp "<front01の接続名>" "<HTTPの待受port>" "front-http"
probe_tcp "<front01の接続名>" "<HTTPSの待受port>" "front-https"
probe_tcp "<app01の接続名>" "<Appの待受port>" "app01-service"
probe_tcp "<app02の接続名>" "<Appの待受port>" "app02-service"
probe_tcp "<db01の接続名>" "<DBの待受port>" "db-service"
} | tee "/tmp/ch5-reach-${PHASE}-$(hostname).tsv"
変更前と同じ手順で、5 台ぶんを 1 つの表にまとめます。
# workstation01 で 5 台ぶんを 1 つの表にまとめる
PHASE=after
OUT="/tmp/ch5-reachability-${PHASE}.tsv"
cat "/tmp/ch5-reach-${PHASE}-$(hostname).tsv" > "$OUT"
for H in front01 172.16.10.20 172.16.10.21 db01; do
ssh deployer@"$H" "cat /tmp/ch5-reach-${PHASE}-\$(hostname).tsv" >> "$OUT"
done
sort -o "$OUT" "$OUT"
cat "$OUT"
(2026-09-02 実測) db01 app01-service DENY db01 app02-service DENY db01 db-service DENY db01 front-http DENY db01 front-https DENY front01 app01-service ALLOW front01 app02-service ALLOW front01 db-service DENY front01 front-http ALLOW front01 front-https ALLOW learner01 app01-service ALLOW learner01 app02-service DENY learner01 db-service ALLOW learner01 front-http DENY learner01 front-https DENY learner02 app01-service DENY learner02 app02-service ALLOW learner02 db-service ALLOW learner02 front-http DENY learner02 front-https DENY workstation01 app01-service DENY workstation01 app02-service DENY workstation01 db-service DENY workstation01 front-http DENY workstation01 front-https ALLOW
RESOLVE_DENY は 1 件もありません。名前解決は全台で生きたまま、TCP 到達性だけが変わったことを、 この列で示せます。
変更後の表を同じ列順で埋めます。
| 送信元 | front HTTP | front HTTPS | app01 service | app02 service | DB service |
|---|---|---|---|---|---|
workstation01 | DENY | ALLOW | DENY | DENY | DENY |
front01 | ALLOW | ALLOW | ALLOW | ALLOW | DENY |
app01 | DENY | DENY | ALLOW | DENY | ALLOW |
app02 | DENY | DENY | DENY | ALLOW | ALLOW |
db01 | DENY | DENY | DENY | DENY | DENY |
STEP 2 と STEP 7 の記録を行単位で比較し、変化したセルだけを抽出します。
diff -u \ /tmp/ch5-reachability-before.tsv \ /tmp/ch5-reachability-after.tsv || true
(2026-09-02 実測) 変わったのは 25 セル中 14 セル。 db01 app01-service ALLOW -> DENY db01 app02-service ALLOW -> DENY db01 front-http ALLOW -> DENY front01 db-service ALLOW -> DENY front01 front-https DENY -> ALLOW learner01 app02-service ALLOW -> DENY learner01 front-http ALLOW -> DENY learner02 app01-service ALLOW -> DENY learner02 front-http ALLOW -> DENY workstation01 app01-service ALLOW -> DENY workstation01 app02-service ALLOW -> DENY workstation01 db-service ALLOW -> DENY workstation01 front-http ALLOW -> DENY workstation01 front-https DENY -> ALLOW
変わったセルだけを読みます。
- 操作端末に残ったのは
front-httpsだけ。App にも DB にも直接は届かなくなりました front01からdb-serviceがDENYに。入口は DB を必要としません- App どうしが互いに
DENY。同じ役割でも横に通す理由がありません db01は全項目DENY。DB は自分から接続しない役割であることが表に出ます
変わらなかったセルにも意味があります。learner01 から app01-service が ALLOW のままなのは 自分自身への接続だからで、ファイアウォールの対象ではありません。
次の 3 点を実測結果から確認します。
workstation01からfront01の HTTPS は成功し、App と DB への直接接続は失敗する。front01から両 App の service は成功する。- 両 App から DB service は成功し、それ以外の不要な直接経路は失敗する。
最後に HTTPS の入口から教材アプリのタップ要求を送り、入口を経由した処理が成功することを確認します。
EVENT_ID="event-tls-path-check"
curl --fail --show-error --include \
--cacert "$HOME/ch5-tls/lab-ca.crt" \
-H "X-Event-ID: ${EVENT_ID}" \
-H 'Content-Type: application/json' \
--data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\",\"amount\":10}" \
"https://<front01のTLS接続名>:<HTTPSの待受port>/tap"
(2026-09-02 実測)
=== 応答 ===
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"ok":true,"card_id":"card-demo","balance":990,"event_id":"event-tls-path-check","node":"app01"}
=== 入口の記録 ===
172.16.10.22 POST /tap HTTP/1.1 status=200 event_id=event-tls-path-check upstream=172.16.10.20:8080 upstream_status=200
=== App の記録(app01)===
{"event_id":"event-tls-path-check","card_id":"card-demo","amount":10,"node":"app01","status":200,"result":"ok"}
=== DB の記録 ===
event_id | card_id | amount | created_at
----------------------+-----------+--------+-------------------------------
event-tls-path-check | card-demo | 10 | 2026-09-02 01:48:31.237054+00
(1 row)
card_id | balance
-----------+---------
card-demo | 990
同じ event_id で入口・App・DB の 3 つの記録が並びます。残高は 1000 から 990 へ 1 回だけ減っています。
📔 DB 側に
node列はありません。どの App が処理したかは App の記録にだけ出ます。
「どこで処理されたか」と「何が確定したか」は別の場所に残る、という形をここで確認してください。
ゴール到達 ✓ ① server certificate の SAN・発行者・期限を読んだ / ② 信頼前の失敗と信頼後の成功を比較した / ③ 5 台の変更前後の到達表を同じコマンド群で作った / ④ 表の差から、入口 HTTPS、入口から App、App から DB だけが必要なサービス経路として残ったことを証明した。
TLS と到達性を 1 要因ずつ壊して戻す
この STEP は 2 回の独立した実験です。最初の TLS の失敗を直し、正常へ戻ったことを確認してから、到達性の失敗へ進みます。2 要因を同時に壊しません。
8-1. 名前が一致しない certificate で TLS の失敗を見る
演習用 CA を使い、接続に使う名前を SAN に含めない別の server certificate を作ります。秘密鍵は STEP 3 で作ったものをそのまま使い、名前だけを変えます。 秘密鍵を作り直すと、失敗の原因が「名前が違うから」なのか「鍵の組が違うから」なのか区別できなくなります。差し替えるのは certificate 1 本だけです。
# front01 で、同じ秘密鍵から SAN の違う CSR を作る ssh deployer@front01 sudo openssl req -new -sha256 \ -key /etc/nginx/tls/front01.key \ -subj "/CN=<接続に使わない名前>" \ -out /tmp/wrong-name.csr
# workstation01 で CSR を受け取り、演習用 CA で署名する scp deployer@<front01の接続名>:/tmp/wrong-name.csr "$HOME/ch5-tls/wrong-name.csr" cd "$HOME/ch5-tls" cat > wrong-name.ext <<'EOF' subjectAltName=DNS:<接続に使わない名前> keyUsage=digitalSignature,keyEncipherment extendedKeyUsage=serverAuth EOF openssl x509 -req -sha256 \ -in wrong-name.csr \ -CA lab-ca.crt \ -CAkey lab-ca.key \ -CAcreateserial \ -days 7 \ -extfile wrong-name.ext \ -out wrong-name.crt scp wrong-name.crt deployer@<front01の接続名>:/tmp/wrong-name.crt
front01 で正常な certificate を退避し、名前不一致の certificate へ差し替えて構文検査後に reload します。秘密鍵は触りません。
sudo cp /etc/nginx/tls/front01.crt /tmp/ch5-front01.crt.good sudo install -m 644 /tmp/wrong-name.crt /etc/nginx/tls/front01.crt sudo nginx -t sudo systemctl reload nginx
workstation01 から、正しい接続名のまま接続して失敗を保存します。
curl --show-error --verbose \ --cacert "$HOME/ch5-tls/lab-ca.crt" \ "https://<front01のTLS接続名>:<HTTPSの待受port>/health" printf 'name_mismatch_exit=%s\n' "$?"
(2026-09-02 実測)
=== 差し替えた certificate ===
subject=CN = not-front01
X509v3 Subject Alternative Name:
DNS:not-front01
=== 正しい接続名のまま接続 ===
* subject: CN=not-front01
* subjectAltName does not match front01
* SSL: no alternative certificate subject name matches target host name 'front01'
curl: (60) SSL: no alternative certificate subject name matches target host name 'front01'
name_mismatch_exit=60
Verification error: hostname mismatch
Verify return code: 62 (hostname mismatch)
失敗の理由が STEP 5 とは違うことに注目してください。STEP 5 は「発行者を信頼できない」(unable to verify the first certificate)、ここは「名前が一致しない」(hostname mismatch)です。 どちらも TLS の検証失敗ですが、直すべき場所が違います。
⚠️
reloadした直後に確認すると、壊す前の結果が返ることがあります。 nginx のreloadは動いている worker(nginx の要求処理プロセス)をすぐには終わらせないため、古い certificate のまま 応答し続ける時間があります。差し替えたことを確かめてから測ってください。bash · コマンド# 提示されている certificate を先に見る openssl s_client -connect "<front01の接続名>:<HTTPSの待受port>" -servername "<front01のTLS接続名>" </dev/null \ | openssl x509 -noout -subject
正常な certificate を戻し、構文検査、reload、同じ curl の成功まで確認します。戻すのも certificate 1 本だけです。壊したものと戻すものが同じ 1 本であることを確かめてください。
sudo cp /tmp/ch5-front01.crt.good /etc/nginx/tls/front01.crt sudo chmod 644 /etc/nginx/tls/front01.crt sudo nginx -t sudo systemctl reload nginx
curl --fail --show-error \ --cacert "$HOME/ch5-tls/lab-ca.crt" \ "https://<front01のTLS接続名>:<HTTPSの待受port>/health"
(2026-09-02 実測)
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
{"status":"ok","db":"ok","node":"app01"}
http=200
Verify return code: 0 (ok)
戻したのも certificate 1 本だけです。秘密鍵にも nginx の設定にも触れていません。
8-2. 必要な許可を 1 つ外して到達性の失敗を見る
TLS が正常へ戻ったことを確認してから、front01 から app01 への service 許可だけを一時的に外します。管理接続の許可と、ほかのサービス経路は変更しません。
外すのは app01 に登録した「front01 からの App service 許可」1 件です。番号を指定して削除するため、先に番号を確認してから削除します。
# app01 で、外す 1 件の番号を確認してから削除する sudo ufw status numbered sudo ufw --force delete <front01からのApp service許可の番号> sudo ufw status verbose
🔴 番号は削除のたびに振り直されます。 続けて 2 件消すつもりで番号を控えておくと、2 件目で別のルールを消します。1 件消したら必ず番号を取り直してください。
ルール変更の直後に、まず app01 への新しい SSH 接続が生きていることを確認します。その後、front01 から app01 への service probe が失敗することを確認します。
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@<app01の接続名> \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"' ssh deployer@front01 \ 'timeout 3 bash -c ": </dev/tcp/<app01の接続名>/<Appの待受port>"' printf 'service_probe_exit=%s\n' "$?"
(2026-09-02 実測)
=== 削除前後の番号 ===
[ 1] 22/tcp ALLOW IN 172.16.10.22
[ 2] 8080/tcp ALLOW IN 172.16.10.23 ← これを削除
(削除後)
[ 1] 22/tcp ALLOW IN 172.16.10.22
=== 直後の管理接続(生きている)===
management-ok host=learner01 time=2026-09-02T10:50:20+09:00
=== front01 からの service probe ===
app01-probe DENY
app02-probe ALLOW ← 変えていない方は通る
=== 入口経由を 3 回 ===
{"status":"ok","db":"ok","node":"app02"} http=200
curl: (28) Operation timed out after 10002 milliseconds with 0 bytes received
http=000
{"status":"ok","db":"ok","node":"app02"} http=200
🔴 入口経由の要求は「毎回失敗」しません。入口は 2 台へ順番に振り分けるので、
app01に当たった回だけ固まります。必ず複数回試してください。1 回だけ試して成功すると 「壊れていない」と誤解します。どちらに振られたかは、入口ログの
upstream=で分かります。 「たまに固まる」は現場でいちばん診断しにくい壊れ方で、その形をここで体験できます。
失敗を保存したら、外した許可 1 件を戻します。直後に管理接続、service probe、HTTPS 入口経由の要求を順に確認します。
# app01 で、外したのと同じ 1 件を戻す sudo ufw allow from <front01の接続元> to any port <Appの待受port> proto tcp sudo ufw status verbose
ssh -o BatchMode=yes -o ConnectTimeout=5 deployer@<app01の接続名> \ 'printf "management-ok host=%s time=%s\n" "$(hostname)" "$(date --iso-8601=seconds)"' ssh deployer@front01 'timeout 3 bash -c ": </dev/tcp/<app01の接続名>/<Appの待受port>"' curl --fail --show-error \ --cacert "$HOME/ch5-tls/lab-ca.crt" \ "https://<front01のTLS接続名>:<HTTPSの待受port>/health"
(2026-09-02 実測)
=== 戻したルール ===
[ 1] 22/tcp ALLOW IN 172.16.10.22
[ 2] 8080/tcp ALLOW IN 172.16.10.23
=== 直後の管理接続 ===
management-ok host=learner01 time=2026-09-02T10:50:57+09:00
=== front01 からの service probe ===
app01-probe ALLOW
=== 入口経由を 6 回(両方へ戻ったことを示す)===
{"status":"ok","db":"ok","node":"app01"} http=200
{"status":"ok","db":"ok","node":"app02"} http=200
{"status":"ok","db":"ok","node":"app01"} http=200
{"status":"ok","db":"ok","node":"app02"} http=200
{"status":"ok","db":"ok","node":"app01"} http=200
{"status":"ok","db":"ok","node":"app02"} http=200
外したのと同じ 1 件を戻しただけで、両方の App へ振り分けが戻りました。 1 回では確認になりません(たまたま app02 に当たるため)。複数回試して両方の node が出ることが 戻った証拠です。
TLS の実験ではクライアント側の証明書検証エラー、到達性の実験では TCP 接続の失敗が観測されます。表示の出どころが異なることを確認し、両方が正常へ戻ってから章を終えてください。
演習の証跡を保存した後、章が作ったものを台ごとに削除します。これは前章の再学習ではなく、自分が作ったものは毎章自分で消すという運用上の規律です。削除コマンドの成功ではなく、最後の 8 分類の検査を修了条件にします。
8-3. 記録して App を止める
4 台で hostname、package 基準、UFW の状態とルールを記録します。db01 では PostgreSQL が動いているうちに設定パスを取得します。この章の UFW 追加は、front01 の管理・HTTPS、各 App の管理・front01 からの service、db01 の管理・2 App からの DB service の計 9 件です。
hostname test -s /tmp/part4-package-before.tsv sudo ufw status verbose sudo ufw status numbered # 以下 3 行は db01 だけ PG_CONF="$(sudo -u postgres psql -Atc 'SHOW config_file;')" PG_HBA="$(sudo -u postgres psql -Atc 'SHOW hba_file;')" PG_CONF_DIR="$(dirname "$PG_CONF")"
app01 で次を実行し、終了後に新しく app02 へ接続してもう一度実行します。先頭の出力を残し、同じ台で 2 回実行していないことを確認します。
hostname sudo systemctl disable --now tapapi.service systemctl is-active tapapi.service || true
8-4. front01 の nginx 参照を外す
TLS 資産がまだ存在する状態で、有効化先、章専用設定本体の順に外します。nginx 本体がある間に nginx -t と reload を通し、章専用 access log も削除します。
hostname sudo rm -f "<nginxの有効化先>" sudo rm -f "<nginxの章専用設定ファイル>" sudo nginx -t sudo systemctl reload nginx sudo rm -f /var/log/nginx/tap_access.log /var/log/nginx/tap_access.log.*
8-5. db01 の DB、role、設定、cluster を戻す
DB、role(DB 内でユーザや権限を表すもの)の順に削除し、動的に取得した設定パスから章専用 drop-in と pg_hba.conf の 2 行を除去します。再起動後に DB・role・追記が 0 行であることを確認してから、章が作った cluster(1 つのデータディレクトリで管理される DB のまとまり)と package を削除します。
(
sudo -u postgres dropdb --if-exists transit_lab
sudo -u postgres dropuser --if-exists tap_app
sudo rm -f "$PG_CONF_DIR/conf.d/part4.conf"
sudo sed -i -e '\|^host transit_lab tap_app 172\.16\.10\.20/32 scram-sha-256$|d' -e '\|^host transit_lab tap_app 172\.16\.10\.21/32 scram-sha-256$|d' "$PG_HBA"
sudo systemctl restart postgresql
test "$(sudo grep -cE '^host transit_lab tap_app (172\.16\.10\.20|172\.16\.10\.21)/32 scram-sha-256$' "$PG_HBA" || true)" -eq 0
test "$(sudo -u postgres psql -Atc "SELECT count(*) FROM pg_database WHERE datname='transit_lab';")" -eq 0
test "$(sudo -u postgres psql -Atc "SELECT count(*) FROM pg_roles WHERE rolname='tap_app';")" -eq 0
# 基準状態の db01 に PostgreSQL は無い。ここに出る cluster は本章が作ったもの。
# 見覚えのない cluster が出たら、消さずに止めて演習環境の担当へ知らせる。
pg_lsclusters --no-header
pg_lsclusters --no-header | awk '{print $1, $2}' | while read -r version cluster; do sudo pg_dropcluster --stop "$version" "$cluster"; done
mapfile -t chapter_packages < <(comm -13 <(cut -f1 /tmp/part4-package-before.tsv | LC_ALL=C sort) <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort))
unexpected=()
for pkg in "${chapter_packages[@]}"; do
case "${pkg%:amd64}" in
postgresql|postgresql-16|postgresql-client|postgresql-client-16|postgresql-client-common|postgresql-common|libcommon-sense-perl|libjson-perl|libjson-xs-perl|libllvm17t64|libpq5|libtypes-serialiser-perl|ssl-cert) ;;
*) unexpected+=("$pkg") ;;
esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
((${#chapter_packages[@]} == 0)) || sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" purge -y "${chapter_packages[@]}"
sudo userdel postgres 2>/dev/null || true
sudo groupdel postgres 2>/dev/null || true
sudo groupdel ssl-cert 2>/dev/null || true
sudo chown root:root /etc/ssl/private
sudo chmod 0700 /etc/ssl/private
getent passwd postgres || true
getent group postgres || true
getent group ssl-cert || true
stat -c '%a %u %g %n' /etc/ssl/private
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2
8-6. UFW を inactive・0 ルールへ戻す
workstation01 に制御用ターミナルを残し、4 台を 1 台ずつ処理します。先に無効化して管理接続を保ち、その後で章が作った永続ルールを reset します。
hostname
sudo ufw --force disable
# ufw reset は元の規則を *.rules.<日時> へ「名前を変えて移す」ので、更新日時は元のまま。
# 日時では選べないため、reset の前に在った退避の一覧を控え、増えた分だけを消す
mapfile -t UFW_OLD < <(ls /etc/ufw/*.rules.[0-9]*_[0-9]* 2>/dev/null)
sudo ufw --force reset
for f in /etc/ufw/*.rules.[0-9]*_[0-9]*; do
[ -e "$f" ] || continue
printf '%s\n' "${UFW_OLD[@]}" | grep -qxF -- "$f" || sudo rm -f -- "$f"
done
printf 'ufw の退避: 開始前 %s 件 / 現在 %s 件(同数なら今回の分は消えた)\n' "${#UFW_OLD[@]}" "$(ls /etc/ufw/*.rules.[0-9]*_[0-9]* 2>/dev/null | wc -l)"
sudo ufw status verbose
sudo ufw status numbered8-7. App、nginx、TLS 資産を削除する
app01 と app02 では App 用、front01 では nginx 用のブロックを、それぞれ対象ホストへ接続して実行します。役割の異なる package 許可リストを同じホストで続けて評価しないでください。
(
# app01 / app02
hostname
sudo rm -f /etc/systemd/system/tapapi.service
sudo systemctl daemon-reload
sudo rm -rf /opt/tapapi /etc/tapapi
sudo userdel tapapi 2>/dev/null || true
getent group tapapi >/dev/null && sudo groupdel tapapi || true
mapfile -t chapter_packages < <(comm -13 <(cut -f1 /tmp/part4-package-before.tsv | LC_ALL=C sort) <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort))
unexpected=()
for pkg in "${chapter_packages[@]}"; do
case "${pkg%:amd64}" in
python3-psycopg2|libpq5) ;;
*) unexpected+=("$pkg") ;;
esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
((${#chapter_packages[@]} == 0)) || sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" purge -y "${chapter_packages[@]}"
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2front01 では、nginx が TLS 資産を参照しなくなったことを確認してから次を実行します。
(
# front01(nginx が参照しなくなった後)
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
mapfile -t chapter_packages < <(comm -13 <(cut -f1 /tmp/part4-package-before.tsv | LC_ALL=C sort) <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort))
unexpected=()
for pkg in "${chapter_packages[@]}"; do
case "${pkg%:amd64}" in
nginx|nginx-common) ;;
*) unexpected+=("$pkg") ;;
esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }
((${#chapter_packages[@]} == 0)) || sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" purge -y "${chapter_packages[@]}"
sudo rm -rf /etc/nginx/tls
sudo rm -f /tmp/front01.csr /tmp/front01.crt /tmp/wrong-name.csr /tmp/wrong-name.crt /tmp/ch5-front01.crt.good
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&28-8. APT source、一時物、workstation01 の TLS 資産を戻す
4 台で章専用ディレクトリ /etc/apt/sources.list.d.part4/ と keyring を削除します。既存 source は最初から変更していないため、復元も無限定の apt update も不要です。
sudo rm -rf /etc/apt/sources.list.d.part4
sudo rm -f /etc/apt/keyrings/infra-study-archive-keyring.asc
rm -f /tmp/infra-study-archive-keyring.asc /tmp/tapapi-1.1.tar.gz /tmp/ch5-reach-before-*.tsv /tmp/ch5-reach-after-*.tsv
test ! -e /etc/apt/sources.list.d.part4
test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc
test ! -e /tmp/infra-study-archive-keyring.asc
[ -z "${TAPAPI_TMP:-}" ] || rm -rf -- "$TAPAPI_TMP"本章の gpg 呼び出しはすべて mktemp -d の使い捨て GnuPG home で行い、使用後にその場で削除しています(C-1)。そのため章のどの操作も ~/.gnupg に触れず、削除対象がありません。workstation01 の一時物だけを削除します。
# workstation01 rm -f /tmp/ch5-reachability-before.tsv /tmp/ch5-reachability-after.tsv rm -rf "$HOME/ch5-tls"
8-9. 8 分類で残留を検査する
各台で package 一覧を章開始前と比較します。差分があれば、直接指定した package だけ消して完了とは判定しません。apt -s autoremove --purge の候補を差分と照合し、章由来だけを削除して差分 0 行まで再確認します。
dpkg-query -W -f='${binary:Package}\t${Version}\n' | sort > /tmp/part4-package-after.tsv
diff -u /tmp/part4-package-before.tsv /tmp/part4-package-after.tsv
systemctl status tapapi.service --no-pager || true
systemctl status nginx --no-pager || true
systemctl status postgresql --no-pager || true
sudo ss -lntp | grep -E ':(5432|8080|8443)([[:space:]]|$)' || true
test ! -e /opt/tapapi; test ! -e /etc/tapapi; test ! -e /etc/systemd/system/tapapi.service
test ! -e "<nginxの章専用設定ファイル>"; test ! -e "<nginxの有効化先>"
test ! -e /etc/apt/sources.list.d.part4; test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc
getent passwd tapapi || true; getent group tapapi || true
getent passwd postgres || true; getent group postgres || true; getent group ssl-cert || true
stat -c '%a %u %g %n' /etc/ssl/private 2>/dev/null || true
sudo ufw status numbered
test "$(ls /etc/ufw/*.rules.20* 2>/dev/null | wc -l)" -eq 0
test ! -e /etc/nginx/tls/front01.key; test ! -e /etc/nginx/tls/front01.crt
test ! -e "$HOME/ch5-tls"8-10. 8 分類の外を manifest 差分で確認する
8 分類を自分で列挙して検査したあと、その外側に残る変更を、構築前後の sorted manifest で確認します。差分は列挙の代わりではなく、列挙から漏れた分類を見つける安全網です。全差分は /var/tmp/part4-chapter-manifest.diff に一時保存し、「残留候補」と「更新日時だけ変わった通常ファイル」に分けて全件を画面に表示してから削除します。途中で切りません。表示に失敗したときは、構築前・章末の manifest と差分ファイルを消さずに残し、理由を表示します。表示された理由を確かめるまで、後片付けへ進まないでください。
次の除外には、それぞれ理由があります。
/var/lib/apt/は apt が索引を更新する場所で、章の作業とは無関係に必ず変わります。/var/lib/dpkg/は package 操作で更新される管理情報で、package 差分として別に判定します。/var/lib/systemd/は service の状態管理で更新される場所で、service・unit の分類として別に判定します。/var/log/は操作の証跡そのもので、コマンドを実行するだけでも変わります。/var/cache/は再生成できる一時的な cache で、章の構築物とは分けて扱います。.bash_historyは操作履歴として更新されるため、章が配置したファイルとは分けて扱います。/var/lib/snapd/state.json・/var/lib/snapd/maintenance.jsonと/var/lib/logrotate/statusは、snapd と logrotate が章と無関係に定期的に書き換える状態ファイルです。親ディレクトリは除外しません。
sudo find /etc /var/lib /srv /opt /usr/local "$HOME" \
-not -path '/var/lib/apt/*' \
-not -path '/var/lib/dpkg/*' \
-not -path '/var/lib/systemd/*' \
-not -path '/var/log/*' \
-not -path '/var/cache/*' \
-not -name '.bash_history' \
-not -path '/etc/passwd-' -not -path '/etc/group-' \
-not -path '/etc/shadow-' -not -path '/etc/gshadow-' \
-not -path '/var/lib/fwupd' -not -path '/var/lib/fwupd/*' \
-not -path '/etc/fwupd' -not -path '/etc/fwupd/*' \
-not -path '/var/lib/ucf' -not -path '/var/lib/ucf/*' \
-not -path '/var/lib/landscape' -not -path '/var/lib/landscape/*' \
-not -path '/etc/ld.so.cache' \
-not -path '/var/lib/ubuntu-release-upgrader/release-upgrade-available' \
-not -path '/var/lib/snapd/state.json' \
-not -path '/var/lib/snapd/maintenance.json' \
-not -path '/var/lib/logrotate/status' \
-printf '%p\t%y\t%m\t%U\t%G\t%s\t%T@\n' 2>/dev/null \
| LC_ALL=C sort \
| sudo tee /tmp/part4-manifest-after.tsv >/dev/null
sudo diff -u /tmp/part4-manifest-before.tsv /tmp/part4-manifest-after.tsv > /var/tmp/part4-chapter-manifest.diff
diff_rc=$?
if [ "$diff_rc" -le 1 ]; then
if ! awk 'BEGIN { FS = "\t"; c = "LC_ALL=C sort" }
/^\+\+\+|^---|^@@/ { next }
/^[+-]/ {
if (NF != 7) { printf "manifest の列数が 7 ではありません: %s\n", $0 > "/dev/stderr"; bad = 1; exit 2 }
t++; s = substr($1, 1, 1); p = substr($1, 2)
k = $2 " " $3 " " $4 " " $5 " " ($2 == "d" ? "-" : $6)
if (s == "-") { if (p in b) { dup = p; exit 2 } b[p] = k; bm[p] = $7; bt[p] = $2 }
else { if (p in a) { dup = p; exit 2 } a[p] = k; am[p] = $7 }
}
END {
if (bad) exit 2
if (dup != "") { printf "同じパスが重複しています: %s\n", dup > "/dev/stderr"; exit 2 }
print "== 残留候補(0 件で合格)=="
n = 0
for (p in b) {
if (!(p in a)) { print "消えた " p | c; n++ }
else if (a[p] != b[p]) { print "変わった " p " (" b[p] " -> " a[p] ")" | c; n++ }
}
for (p in a) if (!(p in b)) { print "増えた " p | c; n++ }
fflush(); close(c)
print "残留候補: " n " 件"
print "== 更新日時だけ変わった通常ファイル(1 件ずつ理由を説明できること)=="
m = 0; d = 0
for (p in b) if ((p in a) && a[p] == b[p] && am[p] != bm[p]) { if (bt[p] == "d") d++; else { print p | c; m++ } }
fflush(); close(c)
print "更新日時だけ変わった通常ファイル: " m " 件 / ディレクトリ: " d " 件"
print "差分行総数: " (t + 0)
}' /var/tmp/part4-chapter-manifest.diff; then
echo "差分の表示に失敗しました。構築前・章末の manifest と /var/tmp/part4-chapter-manifest.diff は残してあります。表示された理由を確認してください" >&2
fi
else
echo "差分を採れませんでした(diff の終了コード $diff_rc)。構築前 manifest /tmp/part4-manifest-before.tsv があるか確認してください" >&2
fi
表示は 2 つの節に分かれます。合格の条件は、1 つ目の「残留候補」が 0 件であることです。残留候補は、構築前になかったパスが増えた、あったパスが消えた、種別・権限・所有者・サイズが変わった、のいずれかです(ディレクトリのサイズは比べません)。
2 つ目の「更新日時だけ変わった通常ファイル」は、中身の属性は同じで更新日時だけが違うファイルです。ここに出る行は 1 件ずつ、次のどちらかで説明できなければ完了にしません。
- OS の自動処理(package 索引の更新、通知用の記録など)が書いたもの。
- 章の操作の跡で、中身は戻っているもの(例: ユーザを作って消したあとの
/etc/passwd)。
どちらでも説明できない行が 1 件でもあれば、それは自分が挙げた 8 分類の外側にある残留です。何の分類にあたるかを考えて「9 番目の分類」を自分の後片付け手順へ足し、解消後に再確認してください。
(形の例です。M・K・T には実行したときの件数が入ります。更新日時だけ変わった通常ファイルの行と件数は、台・章によって変わります。合格の条件は「残留候補: 0 件」です) == 残留候補(0 件で合格)== 残留候補: 0 件 == 更新日時だけ変わった通常ファイル(1 件ずつ理由を説明できること)== (該当するパスが 1 行ずつ並びます) 更新日時だけ変わった通常ファイル: M 件 / ディレクトリ: K 件 差分行総数: T
合格条件は、package 差分 0 行、章専用 service なし、章の port 待受なし、DB と role 0 行(8-5 で確認)、章専用設定なし、tapapi user・group なし、UFW が 4 台とも inactive・0 ルール・退避ファイル 0 個、TLS 秘密鍵と certificate なし、ファイル差分に未解消の残留候補なしです。改訂後の実機出力は採り直してから掲載します。
検査結果を証跡へ保存した後、検査用の一時一覧も削除します。
rm -f /tmp/part4-package-before.tsv /tmp/part4-package-after.tsv sudo rm -f /tmp/part4-manifest-before.tsv /tmp/part4-manifest-after.tsv /var/tmp/part4-chapter-manifest.diff test ! -e /var/tmp/part4-chapter-manifest.diff
つまずきポイント
ハマり #1 · certificate が信頼されない
HTTPS の接続は始まりますが、発行者を信頼できないという検証失敗になります。
確認順序
curlが演習用 CA を--cacertで指定しているか確認する。openssl x509 -issuer -subjectで server certificate の発行者を読む。openssl verify -CAfileで作成物の chain を確認する。openssl s_client -showcertsで入口が実際に提示した certificate を読む。
- 原因: CA を指定していない、別の CA を指定した、入口が意図しない certificate を提示している可能性があります。
- 対処: 信頼を無効化して通すのではなく、指定した CA と提示された chain の対応を確認します。
ハマり #2 · certificate の名前が一致しない
演習用 CA は信頼されていますが、接続名の検証に失敗します。
確認順序
curlの URL に使った名前を記録する。openssl x509 -ext subjectAltNameで SAN を読む。openssl s_client -servernameに渡した名前を確認する。- nginx が提示している certificate を確認する。
- 原因: URL の名前が SAN にない、検証対象とは別の certificate を入口が提示している可能性があります。
- 対処: 接続名と SAN を一致させます。検証自体を無効にして成功扱いにしません。
ハマり #3 · ルール適用後に自分の接続が切れた
現在の SSH が応答しない、または別ターミナルから新しい SSH 接続を作れません。
確認順序
- その先のルール適用を直ちに止める。
- 別ターミナルから対象 VM へ新しい SSH 接続を 1 回だけ試す。
- 接続できなければ、同じ経路から修正できると思い込んで操作を重ねない。
- 原因: 管理接続の許可より先に拒否を適用した、送信元または対象 interface の指定が環境と一致していない可能性があります。
- 対処: 接続できなくなった場合は、演習セッションを終了すると環境が初期状態へ戻ります。戻った後は、直前の 1 ルールと接続確認の結果から見直します。
ハマり #4 · 入口経由は通るのに直接接続も通ってしまう
HTTPS の入口経由の要求は成功しますが、workstation01 から App または DB への直接 probe も成功します。
確認順序
- STEP 7 の表で、どの送信元・宛先のセルが想定外かを 1 つに絞る。
- 対象 VM の待受 port と、probe した port が一致するか確認する。
- 対象 VM に反映された service network 側のルールを読み、送信元と順序を確認する。
- STEP 2 の同じセルと比較し、変更前後で結果が本当に同じか確認する。
- 原因: 拒否より広い許可が先に一致している、送信元や interface の指定が誤っている、別の port を測っている可能性があります。
- 対処: 表の 1 セルに対応する経路だけを追い、待受、送信元、ルール順序のどこで広く許可されたかを特定します。
修了確認
問 1 · TLS の 3 つの確認点を説明する
server certificate を検証するとき、名前・発行者・期限はそれぞれ何を確認しますか。
問 2 · 変更前の到達表が必要な理由を説明する
変更後の表だけでは、サービス経路を絞れた証明として不足するのはなぜですか。
問 3 · 1 ルールごとに管理接続を確認する
複数のルールをまとめて適用し、最後に SSH を確認する手順が危険なのはなぜですか。
問 4 · TLS の失敗と到達性の失敗を切り分ける
HTTPS 要求が失敗したとき、certificate の名前不一致と TCP 到達不可を、どの観測結果から区別しますか。
段階ヒント(問 1〜問 4)
問 1 のヒントと答え
ヒント 1 段 · certificate の欄を見る
接続時に指定したもの、chain の上位、現在時刻と比べる欄を探してください。
ヒント 2 段 · 確認コマンド
openssl x509 -subject -issuer -dates -ext subjectAltName の出力を、接続名と現在時刻に照らします。
答えを見る
名前は接続名が SAN に含まれるか、発行者は信頼する CA へ chain をたどれるか、期限は現在時刻が有効期間内かを確認します。3 点は別々の条件です。
問 2 のヒントと答え
ヒント 1 段 · 変化を証明するには
変更後に DENY だったセルが、変更前にどうだったかを考えてください。
ヒント 2 段 · 同じ測定を使う
STEP 2 と STEP 7 は、同じ 5 台、同じ 5 宛先、同じ順序、同じ probe を使います。
答えを見る
変更後だけでは、ルールで拒否されたのか、もともと到達できなかったのかを区別できません。変更前後を同じ方法で測り、変化したセルを比較することで、絞り込みの効果を示せます。
問 3 のヒントと答え
ヒント 1 段 · 失敗した位置を考える
5 個まとめて適用した後に接続が切れたら、どの変更が原因か分かるでしょうか。
ヒント 2 段 · 新しい接続を作る
現在の SSH が残っていることだけでなく、別ターミナルから対象 VM へ新しい SSH 接続を作れるかを毎回確認します。
答えを見る
途中で管理接続を遮断すると、以後のルールを直せず、どの変更で切れたかも特定しにくくなります。管理接続を先に明示的に許可し、1 ルール適用するごとに新しい SSH 接続を確認してから次へ進みます。
問 4 のヒントと答え
ヒント 1 段 · 失敗した層を見る
TCP 接続が成立する前の失敗と、TLS handshake 中の検証失敗を分けてください。
ヒント 2 段 · 観測場所
TCP probe の終了 status、curl --verbose の検証エラー、openssl s_client が提示する SAN と chain を比較します。
答えを見る
名前不一致では TCP 接続と TLS handshake は進み、クライアントが SAN と接続名の不一致を報告します。到達不可では TLS 検証へ進む前に TCP probe が失敗します。エラー文だけでなく、どの段階まで進んだかで区別します。
部品をそろえ、自分で建てて引き渡す
本章で、入口の信頼と、5 台をまたぐ最小のサービス経路を観測できる形になりました。次章では、これまで用意した部品を自分で建て直し、受入条件を検査して、運用へ引き渡せる状態かを判断します。
Chapter 6: 改札サービスを建て直し、運用へ引き渡す 🎓
- 入口、App、DB を自分で組み立てる
- 正常系と故障系を受入条件で検査する
- 証跡をそろえ、運用へ引き渡せるか判断する
参考資料
- OpenSSL 公式ドキュメント:
openssl-x509、openssl-verify、openssl-s_client - nginx 公式ドキュメント: HTTPS servers
man opensslman curlman ss