環境
- CentOS Stream 10
- Python 3.12.12
- nginx-1.26.3-6.el10.x86_64
- FastAPI 0.134.0
- uvicorn 0.41.0
概要設計
全体アーキテクチャ
公開経路
- Internet → nginx (443/HTTPS) → uvicorn/FastAPI (127.0.0.1)
機能の分離
- Web:利用者向けUIのみ(管理画面なし)
- 管理:SSHのみ
認証・認可
- FastAPIログイン(セッション 8時間)
- 権限:admin / user
- 同時ログイン:許可(共用想定)
/docs等:無効化
データ方針
- アップロードはメモリ内完結(サーバ保存しない)
- 設定・ログは shared に固定
ディレクトリ/運用資産
/opt/api-name/current→ 稼働リリース(symlink)/opt/api-name/releases/<version>/→ バージョンごとの app + venv/opt/api-name/shared/→ 運用で不変に近いものconfig.env(環境設定)users.json(ユーザDB)logs/app.log(アプリ論理ログ:保持30日)maintenance.on(メンテフラグ:存在でON)
状態遷移(A/B/C統合)
1) Webサービス状態(A2)
- W0:通常
- user/admin 利用可
- W1:メンテナンス
maintenance.onが存在- adminのみ利用可
- userはログイン不可/API 実行不可(503またはメンテ画面)
遷移
- W0 → W1:
maintenance.on作成 - W1 → W0:
maintenance.on削除
2) SSH管理状態(B1)
- S0:Closed(通常)
- SSHは外部から不可
- S1:Open-Restricted
- 作業端末の現在のグローバルIPのみ許可
- タイムアウトで自動的にS0へ戻る(閉じ忘れ対策)
- 目標タイムアウト:60分(推奨)
遷移
- S0 → S1:コンソールから許可IPを設定し開放
- S1 → S0:手動で閉/またはタイムアウトで自動閉
監視と発動条件(C統合)
ログの所在
- nginxアクセス/エラー:OS標準領域(入口・障害)
- アプリ論理ログ:
shared/logs/app.log(30日保持) - SSH/OS:OS標準ログ
ブルートフォース疑いの運用基準
- 同一IP:ログイン失敗 5回/5分
- 全体:ログイン失敗 50回/10分
- 404急増(スキャン)
緊急時運用プレイブック(2パターン)
「必要時のみ制限」を実際に回す手順です。
パターン1:ブルートフォース疑い(不審アクセス急増)
- ログで状況確認(nginx/app)
- W1へ:メンテナンスモードON(adminのみ)
- 必要なら S1へ:SSHを一時開放(自IPのみ・タイムアウト)
- 追加対策(後で実装の話:レート制限、失敗回数制限など)
- 落ち着いたら W0へ復帰:メンテOFF
- SSHは必ず S0へ(またはタイムアウトに任せる)
パターン2:脆弱性情報(パッチ適用まで遮断)
- W1へ:メンテナンスモードON(adminのみ)
- S1へ:SSH一時開放 → パッチ適用/再起動
- adminで動作確認(Webはadminだけ通る)
- 問題なければ W0へ:メンテOFF
- SSHは S0へ
リリース運用(バージョン切替)
- 新版は
releases/<version>に追加 currentを symlink 切替- systemd は
currentを参照し続ける(固定) - ロールバック:symlinkを前版に戻すだけ
(この運用は、メンテモードと相性が良い:切替中はW1にするのが安全)
仕様の“穴”になりやすいポイント(ここだけ明示)
実装前に「必ず塞ぐ」対象です。
- userがメンテ中に API を叩けないこと(最重要)
- adminがメンテ中でも正常に動作確認できること
maintenance.onの有無が 全プロセスで一貫して見えること(shared固定でOK)- app.log に ログイン失敗が残ること(ブルートフォース判定に必須)
証明書設計
サーバの HTTPS 構成
証明書方式に関わらず、nginx側の形はこれ。
- 外部公開:443のみ(最終形)
- nginx:TLS終端(証明書はnginxが持つ)
- nginx → uvicorn:
127.0.0.1:8000(HTTPのまま) - 80番:
- 運用方針により「閉じる」or「443へリダイレクト」
- Let’s Encryptの方式によって決まる
証明書方式
- 証明書方式:Let’s Encrypt
- 検証方式:DNS-01(80番を常時開けなくて済む)
- 公開ポート:原則 443のみ
- 80番:基本閉(必要なら例外的に使う)
① OS 構築
前提:CentOS Stream 10 を最小構成でインストール後からスタート
STEP 1 OS 更新
- OS更新
dnf -y update- ツールのインストール
dnf -y install \
vim \
curl \
wget \
git \
tar \
unzip \
policycoreutils-python-utils- OS状態確認
cat /etc/centos-release
uname -r
getenforce方針:
- SELinux 有効で運用する
- systemd 管理
- 公開サーバ化
を目指しています。
ここで SELinux が Disabled だったら、設計が変わります。
STEP 2 firewalld 設定
- firewalld 状態確認
systemctl status firewalld- もし inactive なら以下実行、active なら不要
systemctl enable firewalld
systemctl start firewalld- ゾーン確認
firewall-cmd --get-default-zone
firewall-cmd --list-all- 不要な公開サービスを閉じる
firewall-cmd --permanent --remove-service=cockpit
firewall-cmd --permanent --remove-service=ssh ※この先の作業にSSHを使うならまだ閉じず、公開前に閉じる
firewall-cmd --reload- firewalld 内容確認
firewall-cmd --list-all最終的に公開するポートは:
- 443 (HTTPS) → nginx
- 22 (SSH) → 通常閉、必要時のみ許可
今の段階では:
- 22 は 閉じたまま (作業で使用するなら最終的に閉じればOK)
- 80 / 443 も まだ開けない
つまり今は何も開けないのが正解です。
STEP 3 ユーザ・ディレクトリ設定
- アプリ用ユーザ作成 ※rootで実行
- ユーザ名は任意。この記事内では「fguser」としている
useradd -r -s /sbin/nologin fguser- 設定後確認
id fguser- ディレクトリ構造作成
- /opt 配下のディレクトリ名は作成するサービス名。この記事内では「fg-analyzer」としている
mkdir -p /opt/fg-analyzer/releases
mkdir -p /opt/fg-analyzer/shared/logs- 所有権設定
- ユーザ名、ディレクトリ名は上で作成したものにする
chown -R fguser:fguser /opt/fg-analyzer- 設定後確認
ls -ld /opt/fg-analyzer
ls -ld /opt/fg-analyzer/releases
ls -ld /opt/fg-analyzer/shared
ls -ld /opt/fg-analyzer/shared/logsこの段階で
/opt/fg-analyzer/
releases/
shared/
logs/が存在し、
- releases → バージョン格納
- shared → 設定・ログ・maintenance.on
- 実行ユーザーは fguser
という基盤が出来上がります。
STEP 4 Python 実行基盤構築
- インストール状況確認
python3 --version
python3 -m venv -h | head -n 2- もし
venvが無い/動かない場合だけ、次を入れます
dnf -y install python3 python3-pip- リリースディレクトリ(初期版)を作成
- 「fg-analyzer」はこの記事でのサービス名。自身の環境に合わせて変える
mkdir -p /opt/fg-analyzer/releases/0.0.0/app
chown -R fguser:fguser /opt/fg-analyzer/releases/0.0.0- venv を作成(sudo を使い fguserで実行)
sudo -u fguser python3 -m venv /opt/fg-analyzer/releases/0.0.0/venv- current シンボリックリンク作成
ln -sfn /opt/fg-analyzer/releases/0.0.0 /opt/fg-analyzer/current
chown -h fguser:fguser /opt/fg-analyzer/current- 動作確認
readlink -f /opt/fg-analyzer/current
sudo -u fguser /opt/fg-analyzer/current/venv/bin/python -Vここまでの到達点:
- venv が作られた(ただし中身は空)
currentで参照できる- 以後、バージョン切替が可能
STEP 5 nginx 導入
- nginx インストール
dnf -y install nginx- nginx を起動・自動起動有効
systemctl enable nginx
systemctl start nginx
systemctl status nginx --no-pager- 設定テスト(念のため)
nginx -t
想定結果:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful- ローカルから疎通確認
curl -I http://127.0.0.1/
想定結果:
[root@localhost ~]# curl -I http://127.0.0.1/
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Sun, 01 Mar 2026 05:06:24 GMT
Content-Type: text/html
Content-Length: 1415079
Last-Modified: Fri, 12 Dec 2025 19:59:14 GMT
Connection: keep-alive
ETag: "693c7412-1597a7"
Accept-Ranges: bytes- current シンボリックリンク作成
- 「fg-analyzer」はこの記事でのサービス名。自身の環境に合わせて変える
ln -sfn /opt/fg-analyzer/releases/0.0.0 /opt/fg-analyzer/current
chown -h fguser:fguser /opt/fg-analyzer/current- 動作確認
readlink -f /opt/fg-analyzer/current
sudo -u fguser /opt/fg-analyzer/current/venv/bin/python -V- nginx をインストールして起動できる
- firewalld は まだ 80/443 を開けない(後で開ける)
- SELinux Enforcing のままで進める
STEP 6 nginx を整理(HTTPS化・リバプロ化の土台)
- 現状の設定ファイル構造を確認
ls -l /etc/nginx/
ls -l /etc/nginx/conf.d/- デフォルトサーバ設定を作る(仮のトップページ)
- 「fg-analyzer」はこの記事でのサービス名。自身の環境に合わせて変える
- return 200 "fg-analyzer nginx ok\n";
- この行の表示文字列内容は任意
cat > /etc/nginx/conf.d/fg-analyzer.conf <<'EOF'
server {
listen 80;
server_name _;
# 将来HTTPS化したら 80→443 リダイレクトにする予定。
# 現時点は “仮の疎通確認ページ” を返すだけ。
location / {
add_header Content-Type text/plain;
return 200 "fg-analyzer nginx ok\n";
}
}
EOF- 設定テスト→反映
nginx -t
systemctl reload nginx
想定結果(nginx -t):
nginx: [warn] conflicting server name "_" on 0.0.0.0:80, ignored
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful- ローカルから疎通確認
curl -s http://127.0.0.1/
想定結果:
fg-analyzer nginx oknginx -tでの warn を消す
conflicting server name "_" on 0.0.0.0:80, ignoredこれは 80番で別の server ブロックが既に存在していて、server_name _; が衝突している状態です。
今は動いていますが、将来HTTPS化やリバプロ設定を足したときに事故りやすいので、ここで整理します。
CentOS の nginx はしばしば
/etc/nginx/nginx.conf/etc/nginx/default.d/*.conf/usr/share/nginx/htmlのwelcome
の組み合わせで、既定の80番サーバが最初から存在します。conf.d は空だったので、衝突元は多分 nginx.conf 内か default.d 経由です。
どこに server が定義されているか確認
nginx -T 2>/dev/null | grep -nE '^\s*server\s*\{|listen 80|server_name'
想定結果:
38: server {
41: server_name _;
54:# server_name _;
173:server {
174: listen 80;
175: server_name _;
↓
server ブロックが2つあり、両方とも server_name _; を持っています。
1つ目:nginx.conf(38行付近)
2つ目:conf.d/fg-analyzer.conf(173行付近)
CentOS 標準の nginx.conf に最初からデフォルト server が入っているタイプです。デフォルト server を1つに統一
nl -ba /etc/nginx/nginx.conf | sed -n '30,80p'
想定結果:
30 default_type application/octet-stream;
31
32 # Load modular configuration files from the /etc/nginx/conf.d directory.
33 # See http://nginx.org/en/docs/ngx_core_module.html#include
34 # for more information.
35 include /etc/nginx/conf.d/*.conf;
36
37 server {
38 listen 80;
39 listen [::]:80;
40 server_name _;
41 root /usr/share/nginx/html;
42
43 # Load configuration files for the default server block.
44 include /etc/nginx/default.d/*.conf;
45 }
46
47 # Settings for a TLS enabled server.
48 #
49 # server {
50 # listen 443 ssl;
51 # listen [::]:443 ssl;
52 # http2 on;
53 # server_name _;
54 # root /usr/share/nginx/html;
55 #
56 # ssl_certificate "/etc/pki/nginx/server.crt";
57 # ssl_certificate_key "/etc/pki/nginx/private/server.key";
58 # ssl_session_cache shared:SSL:1m;
59 # ssl_session_timeout 10m;
60 # ssl_ciphers PROFILE=SYSTEM;
61 # ssl_prefer_server_ciphers on;
62 #
63 # # Load configuration files for the default server block.
64 # include /etc/nginx/default.d/*.conf;
65 # }
66
67 }
68上記結果より、/etc/nginx/nginx.confに標準の80番 server が入っており、これが衝突元です。
この標準 server を無効化してconf.d/fg-analyzer.conf を唯一の80番サーバにします。
設定ファイルをバックアップして vi で編集します。
cp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak.$(date +%Y%m%d_%H%M%S)
vi /etc/nginx/nginx.conf以下のブロック(37〜45行相当)を 先頭に # を付けてコメントアウト。
# server {
# listen 80;
# listen [::]:80;
# server_name _;
# root /usr/share/nginx/html;
#
# # Load configuration files for the default server block.
# include /etc/nginx/default.d/*.conf;
# }設定テスト→反映
nginx -t
systemctl reload nginx
想定結果:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
※ warn が消えたこと接続テスト
curl -s http://127.0.0.1/
想定結果:
fg-analyzer nginx ok- プロジェクト専用の設定ファイルを用意
- デフォルトの welcome ページを “プロジェクトの仮ページ” に置き換え
conf.dの管理方針を固定(将来の 443 設定はここに置く)- 設定チェックして reload できる状態にする
STEP 7 Python を書く前の土台作り
- サービスファイル作成
- 以下はアプリ用ユーザ=fguser、サービス名=fg-analyzer を前提。自身の環境に合わせて変更
cat > /etc/systemd/system/fg-analyzer.service <<'EOF'
[Unit]
Description=FG Analyzer Service
After=network.target
[Service]
Type=simple
User=fguser
Group=fguser
WorkingDirectory=/opt/fg-analyzer/current/app
EnvironmentFile=-/opt/fg-analyzer/shared/config.env
# 仮のダミーコマンド(後で uvicorn に置き換える)
ExecStart=/opt/fg-analyzer/current/venv/bin/python -c "import time; time.sleep(9999)"
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF- systemd 読み込み
systemctl daemon-reload- サービス起動テスト
systemctl start fg-analyzer
systemctl status fg-analyzer --no-pager
想定結果:
Active: active (running)- 自動起動設定
systemctl enable fg-analyzer
systemctl is-enabled fg-analyzer
想定結果:
enabled- 将来
currentを参照する設計を固定 - アプリ用ユーザでの実行を前提にする
- shared ディレクトリを前提にする
現時点の完成状態(チェックリスト)
- OS:CentOS Stream 10、更新済み
- SELinux:Enforcing
- firewalld:稼働(公開ポート未開放、SSHのみ構築用に開放)
- nginx:稼働、
conf.d管理方式に統一- 標準の80番 server を無効化
/etc/nginx/conf.d/<サービス名>.confで仮ページ応答OK
- ディレクトリ:
/opt/<サービス名>/{releases,current,shared/logs} - Python:venv作成済み(3.12.12)
- systemd:
<サービス名>.service枠作成済み、enabledcurrent参照固定(将来のバージョン切替に対応
② FastAPI/uvicorn 導入
本章の内容のコマンドでは前章で設定した以下を前提としています。
コマンドや設定ファイル内容でこれらの名前を使用している箇所は自身の環境に合わせて変更
- アプリ用ユーザ名:fguser
- サービス名:fg-analyzer
STEP 8 カレントバージョン用 venv 内で FastAPI/uvicorn インストール
- venv 側の pip を最新化
sudo -u fguser /opt/fg-analyzer/current/venv/bin/pip install -U pip- FastAPI / uvicorn をインストール
sudo -u fguser /opt/fg-analyzer/current/venv/bin/pip install fastapi uvicorn- インストール確認
sudo -u fguser /opt/fg-analyzer/current/venv/bin/python -c "import fastapi, uvicorn; print('fastapi', fastapi.__version__); print('uvicorn', uvicorn.__version__)"
想定結果:
fastapi 0.134.0
uvicorn 0.41.0STEP 9 最小ダミー FastAPI アプリ設置
- ダミーサービスを止める
systemctl stop fg-analyzer- 最小アプリファイルを作成
/opt/fg-analyzer/current/appにapp.pyを設置
sudo -u fguser tee /opt/fg-analyzer/current/app/app.py >/dev/null <<'EOF'
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def root():
return {"status": "ok", "service": "fg-analyzer"}
EOF- 確認
sudo -u fguser ls -l /opt/fg-analyzer/current/app/- systemd の ExecStart を uvicorn に差し替え
■/etc/systemd/system/fg-analyzer.service を編集
vi /etc/systemd/system/fg-analyzer.service
↓
■ExecStart を下の形に変更(他はそのまま)
ExecStart=/opt/fg-analyzer/current/venv/bin/uvicorn app:app --app-dir /opt/fg-analyzer/current/app --host 127.0.0.1 --port 8000- systemd反映→起動
systemctl daemon-reload
systemctl start fg-analyzer
systemctl status fg-analyzer --no-pager- ローカル疎通確認
curl -s http://127.0.0.1:8000/
想定結果:
{"status":"ok","service":"fg-analyzer"}STEP 10 nginx → uvicorn のリバースプロキシ化
- nginx 設定を「リバプロ」に置き換える
- /etc/nginx/conf.d/fg-analyzer.conf を差し替え
cat > /etc/nginx/conf.d/fg-analyzer.conf <<'EOF'
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_buffering off;
}
}
EOF- nginx 設定テスト→反映
nginx -t
systemctl reload nginx
想定結果:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful- SELinux の許可を入れる→反映確認
setsebool -P httpd_can_network_connect on
getsebool httpd_can_network_connect
想定結果:
httpd_can_network_connect --> on- ローカル疎通確認(nginx経由)
curl -s http://127.0.0.1/
想定結果:
{"status":"ok","service":"fg-analyzer"}STEP 11 ログ確認
- nginx のアクセスログに / のアクセスが残っているか確認
tail -n 5 /var/log/nginx/access.log
tail -n 20 /var/log/nginx/error.log
想定結果(access.log):
127.0.0.1 - - [01/Mar/2026:15:27:22 +0900] "GET / HTTP/1.1" 200 39 "-" "curl/8.12.1" "-"
想定結果(error.log):
2026/03/01 15:24:11 [crit] 7056#7056: *6 connect() to 127.0.0.1:8000 failed (13: Permission denied) while connecting to upstream, client: 127.0.0.1, server: _, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:8000/", host: "127.0.0.1"- systemd 経由で uvicorn が生きているか最終確認
systemctl is-active fg-analyzer
想定結果:
activeいまの到達点(要点だけ)
- FastAPI/uvicorn が venv に導入済み
- systemd で uvicorn が起動(127.0.0.1:8000)
- nginx がリバプロ(80 → 127.0.0.1:8000)
- SELinux Enforcing のまま接続許可済み
- ログで障害原因が追える
③ 自己署名証明書による HTTPS 化 (検証用)
検証環境では自己署名証明書を使用。
本章の内容のコマンドではOS構築で設定した以下を前提としています。
コマンドや設定ファイル内容でこれらの名前を使用している箇所は自身の環境に合わせて変更
- アプリ用ユーザ名:fguser
- サービス名:fg-analyzer
STEP 12 自己署名証明書の置き場所を作る
- ディレクトリ作成
mkdir -p /opt/fg-analyzer/shared/tls
chown -R fguser:fguser /opt/fg-analyzer/shared/tls
chmod 700 /opt/fg-analyzer/shared/tls- 確認
ls -ld /opt/fg-analyzer/shared/tlsSTEP 13 自己署名証明書を作成
- openssl インストール→バージョン確認
dnf install -y openssl
openssl version
想定結果:
OpenSSL 3.5.5 27 Jan 2026 (Library: OpenSSL 3.5.5 27 Jan 2026)- 証明書と秘密鍵を作成
sudo -u fguser openssl req -x509 -nodes -newkey rsa:2048 \
-keyout /opt/fg-analyzer/shared/tls/server.key \
-out /opt/fg-analyzer/shared/tls/server.crt \
-days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"- 生成物の確認
ls -l /opt/fg-analyzer/shared/tls/
sudo -u fguser openssl x509 -in /opt/fg-analyzer/shared/tls/server.crt -noout -subject -ext subjectAltName
実行結果:
[root@localhost app]# ls -l /opt/fg-analyzer/shared/tls/
合計 8
-rw-r--r--. 1 fguser fguser 1151 3月 1 15:51 server.crt
-rw-------. 1 fguser fguser 1704 3月 1 15:51 server.key
[root@localhost app]# sudo -u fguser openssl x509 -in /opt/fg-analyzer/shared/tls/server.crt -noout -subject -ext subjectAltName
subject=CN=localhost
X509v3 Subject Alternative Name:
DNS:localhost, IP Address:127.0.0.1STEP 14 nginx を HTTPS化:80→443 リダイレクト + 443でリバプロ
- nginx設定を差し替え
- 80:全て 443 へリダイレクト
- 443:TLS終端 + uvicorn(127.0.0.1:8000) へプロキシ
cat > /etc/nginx/conf.d/fg-analyzer.conf <<'EOF'
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate /opt/fg-analyzer/shared/tls/server.crt;
ssl_certificate_key /opt/fg-analyzer/shared/tls/server.key;
# ローカル検証用なので最小構成(本番は後で強化)
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_buffering off;
}
}
EOF- nginx設定テスト→反映
nginx -t
systemctl reload nginx
想定結果:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful- ローカル疎通確認(HTTPS)
curl -sk https://127.0.0.1/
想定結果:
{"status":"ok","service":"fg-analyzer"}- HTTP→HTTPSリダイレクト確認
curl -I http://127.0.0.1/ | head -n 5
想定結果:
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 169 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
HTTP/1.1 301 Moved Permanently
Server: nginx/1.26.3
Date: Sun, 01 Mar 2026 07:00:49 GMT
Content-Type: text/html
Content-Length: 169現状整理
- nginx が TLS 終端(443)
- 80 は 443 へリダイレクト
- nginx → uvicorn は 127.0.0.1:8000(HTTP)
- 証明書は
/opt/fg-analyzer/shared/tls/に固定 - 本番ではこの証明書を Let’s Encrypt(DNS-01)に置き換えるだけで移行できる
④ /docs 無効化
本章の内容のコマンドではOS構築で設定した以下を前提としています。
コマンドや設定ファイル内容でこれらの名前を使用している箇所は自身の環境に合わせて変更
- アプリ用ユーザ名:fguser
- サービス名:fg-analyzer
STEP 15 /docs 無効化
- 現状確認(今 /docs が見えているか)
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/docs
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/redoc
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/openapi.json
想定結果:
200app.pyを編集して docs を無効化/opt/fg-analyzer/current/app/app.pyを次の内容に差し替え
sudo -u fguser tee /opt/fg-analyzer/current/app/app.py >/dev/null <<'EOF'
from fastapi import FastAPI
app = FastAPI(
docs_url=None,
redoc_url=None,
openapi_url=None,
)
@app.get("/")
def root():
return {"status": "ok", "service": "fg-analyzer"}
EOF- サービス再起動
systemctl restart fg-analyzer
systemctl status fg-analyzer --no-pager- HTTPコードで無効化を確認
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/docs
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/redoc
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/openapi.json
想定結果:
/ → 200
/docs /redoc /openapi.json → 404⑤ Cookie ベースの認証機能追加
Web UI ベースの API 利用を想定しているため Cookie ベースの認証を追加。
本章の内容のコマンドではOS構築で設定した以下を前提としています。
コマンドや設定ファイル内容でこれらの名前を使用している箇所は自身の環境に合わせて変更
- アプリ用ユーザ名:fguser
- サービス名:fg-analyzer
STEP 16 認証機能追加
- 認証の土台:必要ライブラリ導入
passlib[bcrypt]:users.jsonのパスワードハッシュ用python-multipart:今後フォームログイン(POST)で使うitsdangerous:署名付きCookie(セッション/トークン用途)
sudo -u fguser /opt/fg-analyzer/current/venv/bin/pip install \
"passlib[bcrypt]" \
python-multipart \
itsdangerous- インストール確認
sudo -u fguser /opt/fg-analyzer/current/venv/bin/python -c "import passlib, bcrypt, itsdangerous; print('ok')"
想定結果:
ok- ハッシュを生成(admin用)
- 任意のパスワードを一旦「仮」で決めて、ハッシュだけ作ります。
- (パスワード文字列はコマンド履歴に残るので、可能なら一時的な仮パスでOK)
- 出力の
$2b$...を控える
sudo -u fguser /opt/fg-analyzer/current/venv/bin/python - <<'PY'
from passlib.context import CryptContext
pwd = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
print(pwd.hash("パスワードを入力"))
PY
想定結果:
$pbkdf2-sha256$...users.jsonに書く- 控えたハッシュを
"password_hash":の場所に貼り付け
- 控えたハッシュを
sudo -u fguser tee /opt/fg-analyzer/shared/users.json >/dev/null <<'EOF'
{
"users": [
{
"username": "admin",
"password_hash": "$pbkdf2-sha256$...",
"role": "admin",
"disabled": false
}
]
}
EOF- 確認
sudo -u fguser cat /opt/fg-analyzer/shared/users.jsonshared/config.envを用意(秘密鍵を外出し)- Cookie署名用の秘密鍵を shared に固定
python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(32))
PY- 出てきた文字列を
SECRET_KEY=に貼る
tee /opt/fg-analyzer/shared/config.env >/dev/null <<'EOF'
SECRET_KEY=<上の秘密鍵文字列を入力>
SESSION_HOURS=8
USERS_JSON=/opt/fg-analyzer/shared/users.json
EOF
chown fguser:fguser /opt/fg-analyzer/shared/config.env
chmod 600 /opt/fg-analyzer/shared/config.env- 確認
sudo -u fguser cat /opt/fg-analyzer/shared/config.envapp.pyを差し替え(ログイン/ログアウト/保護ルート)
sudo -u fguser tee /opt/fg-analyzer/current/app/app.py >/dev/null <<'EOF'
import json
import os
import time
from typing import Optional
from fastapi import FastAPI, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from passlib.context import CryptContext
# /docs を無効化
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
# 設定(systemd EnvironmentFile から読む想定)
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
SESSION_HOURS = int(os.environ.get("SESSION_HOURS", "8"))
USERS_JSON = os.environ.get("USERS_JSON", "/opt/fg-analyzer/shared/users.json")
COOKIE_NAME = "fg_session"
serializer = URLSafeTimedSerializer(SECRET_KEY)
pwdctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def load_users():
with open(USERS_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
users = {}
for u in data.get("users", []):
users[u["username"]] = u
return users
def verify_user(username: str, password: str) -> Optional[dict]:
users = load_users()
u = users.get(username)
if not u or u.get("disabled"):
return None
if not pwdctx.verify(password, u["password_hash"]):
return None
return u
def set_session(resp: RedirectResponse, username: str, role: str):
payload = {"u": username, "r": role}
token = serializer.dumps(payload)
max_age = SESSION_HOURS * 3600
resp.set_cookie(
COOKIE_NAME,
token,
max_age=max_age,
httponly=True,
secure=True, # HTTPS前提
samesite="lax",
path="/",
)
def clear_session(resp: RedirectResponse):
resp.delete_cookie(COOKIE_NAME, path="/")
def get_current_user(request: Request) -> Optional[dict]:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
payload = serializer.loads(token, max_age=SESSION_HOURS * 3600)
return {"username": payload.get("u"), "role": payload.get("r")}
except (BadSignature, SignatureExpired):
return None
def require_login(request: Request):
u = get_current_user(request)
if not u:
# 未ログインはログイン画面へ
raise RedirectToLogin()
return u
class RedirectToLogin(Exception):
pass
@app.exception_handler(RedirectToLogin)
def _redirect_login_handler(request: Request, exc: RedirectToLogin):
return RedirectResponse(url="/login", status_code=302)
@app.get("/", response_class=JSONResponse)
def root():
return {"status": "ok", "service": "fg-analyzer"}
@app.get("/login", response_class=HTMLResponse)
def login_form():
return HTMLResponse(
"""
<html><body>
<h3>fg-analyzer login</h3>
<form method="post" action="/login">
<div>user: <input name="username"/></div>
<div>pass: <input name="password" type="password"/></div>
<button type="submit">login</button>
</form>
</body></html>
"""
)
@app.post("/login")
def login(username: str = Form(...), password: str = Form(...)):
u = verify_user(username, password)
if not u:
return HTMLResponse("<h3>login failed</h3><a href='/login'>back</a>", status_code=401)
resp = RedirectResponse(url="/app", status_code=302)
set_session(resp, u["username"], u["role"])
return resp
@app.get("/logout")
def logout():
resp = RedirectResponse(url="/login", status_code=302)
clear_session(resp)
return resp
@app.get("/app", response_class=HTMLResponse)
def app_home(user=Depends(require_login)):
# role表示だけ最小で
return HTMLResponse(
f"""
<html><body>
<h3>fg-analyzer app</h3>
<div>user: {user["username"]}</div>
<div>role: {user["role"]}</div>
<div><a href="/admin">admin page</a></div>
<div><a href="/logout">logout</a></div>
</body></html>
"""
)
@app.get("/admin", response_class=HTMLResponse)
def admin_only(user=Depends(require_login)):
if user["role"] != "admin":
return HTMLResponse("<h3>forbidden</h3>", status_code=403)
return HTMLResponse("<h3>admin ok</h3><a href='/app'>back</a>")
EOF- サービス再起動(config.env が読まれる前提)
fg-analyzer.serviceは既にEnvironmentFile=-/opt/fg-analyzer/shared/config.envを読んでいるので、そのまま反映
systemctl restart fg-analyzer
systemctl status fg-analyzer --no-pager- 動作確認(① /login が返る)
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/login
想定結果:
200- 動作確認(② 未ログインで /app に行くと /login に飛ぶ(302))
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/app
想定結果:
302 (ログインへ)- 動作確認(③ ログインしてCookie取得→/app確認)
curl -sk -c /tmp/c.txt -d "username=admin&password=ptest" -X POST https://127.0.0.1/login -o /dev/null -w "%{http_code}\n"
curl -sk -b /tmp/c.txt https://127.0.0.1/app | head -n 5
想定結果:
POSTは 302、/app はHTMLが返る- 動作確認(④ Cookieを付けて /app を取得)
curl -sk -b /tmp/c.txt https://127.0.0.1/app | head -n 10
想定結果:
HTMLが返ってきて、user: admin と role: admin が含まれる- adminページも確認(任意だけど一応)
curl -sk -b /tmp/c.txt -o /dev/null -w "%{http_code}\n" https://127.0.0.1/admin
想定結果:
200STEP 17 メンテナンスモード追加
maintenance.onのパスを決めて app.py に反映- 今回の固定パス:/opt/fg-analyzer/shared/maintenance.on
app.pyに以下の設定と判定を追加します(いったん丸ごと差し替えでいきます)
sudo -u fguser tee /opt/fg-analyzer/current/app/app.py >/dev/null <<'EOF'
import json
import os
from typing import Optional
from fastapi import FastAPI, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from passlib.context import CryptContext
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
SESSION_HOURS = int(os.environ.get("SESSION_HOURS", "8"))
USERS_JSON = os.environ.get("USERS_JSON", "/opt/fg-analyzer/shared/users.json")
MAINTENANCE_FLAG = os.environ.get("MAINTENANCE_FLAG", "/opt/fg-analyzer/shared/maintenance.on")
COOKIE_NAME = "fg_session"
serializer = URLSafeTimedSerializer(SECRET_KEY)
pwdctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def is_maintenance_on() -> bool:
return os.path.exists(MAINTENANCE_FLAG)
def load_users():
with open(USERS_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
users = {}
for u in data.get("users", []):
users[u["username"]] = u
return users
def verify_user(username: str, password: str) -> Optional[dict]:
users = load_users()
u = users.get(username)
if not u or u.get("disabled"):
return None
if not pwdctx.verify(password, u["password_hash"]):
return None
return u
def set_session(resp: RedirectResponse, username: str, role: str):
payload = {"u": username, "r": role}
token = serializer.dumps(payload)
max_age = SESSION_HOURS * 3600
resp.set_cookie(
COOKIE_NAME,
token,
max_age=max_age,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
def clear_session(resp: RedirectResponse):
resp.delete_cookie(COOKIE_NAME, path="/")
def get_current_user(request: Request) -> Optional[dict]:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
payload = serializer.loads(token, max_age=SESSION_HOURS * 3600)
return {"username": payload.get("u"), "role": payload.get("r")}
except (BadSignature, SignatureExpired):
return None
class RedirectToLogin(Exception):
pass
class MaintenanceBlocked(Exception):
pass
@app.exception_handler(RedirectToLogin)
def _redirect_login_handler(request: Request, exc: RedirectToLogin):
return RedirectResponse(url="/login", status_code=302)
@app.exception_handler(MaintenanceBlocked)
def _maintenance_handler(request: Request, exc: MaintenanceBlocked):
return HTMLResponse(
"<h3>maintenance</h3><p>admin only</p>",
status_code=503,
)
def require_login(request: Request):
u = get_current_user(request)
if not u:
raise RedirectToLogin()
# maintenance時は admin 以外拒否
if is_maintenance_on() and u["role"] != "admin":
raise MaintenanceBlocked()
return u
@app.get("/", response_class=JSONResponse)
def root():
# maintenance時は / も admin以外はメンテ表示にしたいならここで制御可能
return {"status": "ok", "service": "fg-analyzer"}
@app.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
# maintenance時はログイン画面も admin 以外は意味がないので案内を出す
if is_maintenance_on():
return HTMLResponse("<h3>maintenance</h3><p>admin only</p>", status_code=503)
return HTMLResponse(
"""
<html><body>
<h3>fg-analyzer login</h3>
<form method="post" action="/login">
<div>user: <input name="username"/></div>
<div>pass: <input name="password" type="password"/></div>
<button type="submit">login</button>
</form>
</body></html>
"""
)
@app.post("/login")
def login(username: str = Form(...), password: str = Form(...)):
# maintenance時はログイン自体を止める(admin専用運用のため)
if is_maintenance_on():
return HTMLResponse("<h3>maintenance</h3><p>admin only</p>", status_code=503)
u = verify_user(username, password)
if not u:
return HTMLResponse("<h3>login failed</h3><a href='/login'>back</a>", status_code=401)
resp = RedirectResponse(url="/app", status_code=302)
set_session(resp, u["username"], u["role"])
return resp
@app.get("/logout")
def logout():
resp = RedirectResponse(url="/login", status_code=302)
clear_session(resp)
return resp
@app.get("/app", response_class=HTMLResponse)
def app_home(user=Depends(require_login)):
return HTMLResponse(
f"""
<html><body>
<h3>fg-analyzer app</h3>
<div>user: {user["username"]}</div>
<div>role: {user["role"]}</div>
<div><a href="/admin">admin page</a></div>
<div><a href="/logout">logout</a></div>
</body></html>
"""
)
@app.get("/admin", response_class=HTMLResponse)
def admin_only(user=Depends(require_login)):
if user["role"] != "admin":
return HTMLResponse("<h3>forbidden</h3>", status_code=403)
return HTMLResponse("<h3>admin ok</h3><a href='/app'>back</a>")
EOF- systemd再起動
systemctl restart fg-analyzer- 動作確認(まずはフラグ無し=通常)
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/login
想定結果:
200- maintenance.on を立てて検証
- フラグを作成
touch /opt/fg-analyzer/shared/maintenance.on
chown fguser:fguser /opt/fg-analyzer/shared/maintenance.on
chmod 644 /opt/fg-analyzer/shared/maintenance.on- 確認
ls -l /opt/fg-analyzer/shared/maintenance.on- 未ログインの挙動(/login が 503)
- フラグを作成
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1/login
想定結果:
503- 既存Cookie(admin)が通ることを確認
- 既に
/tmp/c.txtに admin のCookieを持っているため利用
- 既に
curl -sk -b /tmp/c.txt -o /dev/null -w "%{http_code}\n" https://127.0.0.1/app
curl -sk -b /tmp/c.txt -o /dev/null -w "%{http_code}\n" https://127.0.0.1/admin
想定結果:
/app → 200
/admin → 200STEP 18 ユーザロール追加
users.jsonに user を1つ追加して、メンテナンスモード中に user が弾かれることを確認する。
- users.json に user を1つ追加する
- パスワードハッシュを作る(pbkdf2_sha256)
passlibを使って venv で1回だけ生成- 出力された
"$pbkdf2-sha256$..."を控える
cd /opt/fg-analyzer/current
source venv/bin/activate
python - <<'PY'
from passlib.hash import pbkdf2_sha256
plain = "<ユーザパスワードを入力>" # ←ここだけ好きに変更
print(pbkdf2_sha256.hash(plain))
PY
想定結果(例):
$pbkdf2-sha256$29000$hpDS.t/7/58T4vwfY0wphQ$LTU2nujudwUUb00sFBHD1mc9ZjKRqYqWcwVah0XhCZs- /opt/fg-analyzer/shared/users.json を編集
- 既存がどういう構造でも合わせたいので、基本は「既存usersに1件追加」だけやります。例(配列形式の想定):
[
{
"username": "admin",
"password": "$pbkdf2-sha256$....",
"role": "admin",
"disabled": false
},
{
"username": "user01",
"password": "$pbkdf2-sha256$<<<<さっき生成した値>>>>",
"role": "user",
"disabled": false
}
]編集用コマンド:
vi /opt/fg-analyzer/shared/users.json- maintenance.on を置く(メンテON)
touch /opt/fg-analyzer/shared/maintenance.on
ls -l /opt/fg-analyzer/shared/maintenance.on- 実証(curlでHTTPコードまで出す)
- nginxは自己署名証明書なので
-kを付けます。
ログインがフォーム(python-multipart使ってるので form想定)なら-Fでいけます。
- nginxは自己署名証明書なので
user がログインできず弾かれること(503):
# 1) userでログイン(Cookieを保存)
curl -k -i -c /tmp/c_user.txt \
-X POST https://127.0.0.1/login \
-F 'username=user01' \
-F 'password=<ユーザパスワード>'
想定結果:
HTTP/1.1 503 Service Unavailable
Server: nginx/1.26.3
Date: Mon, 09 Mar 2026 02:38:25 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 37
Connection: keep-alive
<h3>maintenance</h3><p>admin only</p>メンテナンスモードを解除後にログイン→メンテナンスモードON→アクセス試行が失敗すること(503):
rm -f /opt/fg-analyzer/shared/maintenance.on
curl -k -i -c /tmp/c_user.txt \
-X POST https://127.0.0.1/login \
-F 'username=user01' \
-F 'password=<ユーザパスワード>'
touch /opt/fg-analyzer/shared/maintenance.on
curl -k -i -b /tmp/c_user.txt https://127.0.0.1/app
想定結果:
HTTP/1.1 503 Service Unavailable
Server: nginx/1.26.3
Date: Mon, 09 Mar 2026 02:50:50 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 37
Connection: keep-alive
<h3>maintenance</h3><p>admin only</p>STEP 19 メンテナンスモードの仕様変更
メンテナンスモードでもログイン画面は表示し admin のみログインできるようにする。
/opt/fg-analyzer/current/app/app.py の内容を以下に変えます。
import json
import os
from typing import Optional
from fastapi import FastAPI, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from passlib.context import CryptContext
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
SESSION_HOURS = int(os.environ.get("SESSION_HOURS", "8"))
USERS_JSON = os.environ.get("USERS_JSON", "/opt/fg-analyzer/shared/users.json")
MAINTENANCE_FLAG = os.environ.get("MAINTENANCE_FLAG", "/opt/fg-analyzer/shared/maintenance.on")
COOKIE_NAME = "fg_session"
serializer = URLSafeTimedSerializer(SECRET_KEY)
pwdctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
def is_maintenance_on() -> bool:
return os.path.exists(MAINTENANCE_FLAG)
def load_users():
with open(USERS_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
users = {}
for u in data.get("users", []):
users[u["username"]] = u
return users
def verify_user(username: str, password: str) -> Optional[dict]:
users = load_users()
u = users.get(username)
if not u or u.get("disabled"):
return None
if not pwdctx.verify(password, u["password_hash"]):
return None
return u
def set_session(resp: RedirectResponse, username: str, role: str):
payload = {"u": username, "r": role}
token = serializer.dumps(payload)
max_age = SESSION_HOURS * 3600
resp.set_cookie(
COOKIE_NAME,
token,
max_age=max_age,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
def clear_session(resp: RedirectResponse):
resp.delete_cookie(COOKIE_NAME, path="/")
def get_current_user(request: Request) -> Optional[dict]:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
payload = serializer.loads(token, max_age=SESSION_HOURS * 3600)
return {"username": payload.get("u"), "role": payload.get("r")}
except (BadSignature, SignatureExpired):
return None
class RedirectToLogin(Exception):
pass
class MaintenanceBlocked(Exception):
pass
@app.exception_handler(RedirectToLogin)
def _redirect_login_handler(request: Request, exc: RedirectToLogin):
return RedirectResponse(url="/login", status_code=302)
@app.exception_handler(MaintenanceBlocked)
def _maintenance_handler(request: Request, exc: MaintenanceBlocked):
return HTMLResponse(
"<h3>maintenance</h3><p>admin only</p>",
status_code=503,
)
def require_login(request: Request):
u = get_current_user(request)
if not u:
raise RedirectToLogin()
if is_maintenance_on() and u["role"] != "admin":
raise MaintenanceBlocked()
return u
@app.get("/", response_class=JSONResponse)
def root():
return {"status": "ok", "service": "fg-analyzer"}
@app.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
current_user = get_current_user(request)
# すでにログイン済みなら /app に返す
if current_user:
if is_maintenance_on() and current_user["role"] != "admin":
raise MaintenanceBlocked()
return RedirectResponse(url="/app", status_code=302)
# 未ログインでもログイン画面は表示する
# maintenance中でも admin が新規ログインできるようにするため
return HTMLResponse(
"""
<html><body>
<h3>fg-analyzer login</h3>
<form method="post" action="/login">
<div>user: <input name="username"/></div>
<div>pass: <input name="password" type="password"/></div>
<button type="submit">login</button>
</form>
</body></html>
"""
)
@app.post("/login")
def login(username: str = Form(...), password: str = Form(...)):
# 先に認証する
u = verify_user(username, password)
if not u:
return HTMLResponse("<h3>login failed</h3><a href='/login'>back</a>", status_code=401)
# 認証成功後に maintenance 判定
if is_maintenance_on() and u["role"] != "admin":
return HTMLResponse("<h3>maintenance</h3><p>admin only</p>", status_code=503)
resp = RedirectResponse(url="/app", status_code=302)
set_session(resp, u["username"], u["role"])
return resp
@app.get("/logout")
def logout():
resp = RedirectResponse(url="/login", status_code=302)
clear_session(resp)
return resp
@app.get("/app", response_class=HTMLResponse)
def app_home(user=Depends(require_login)):
return HTMLResponse(
f"""
<html><body>
<h3>fg-analyzer app</h3>
<div>user: {user["username"]}</div>
<div>role: {user["role"]}</div>
<div><a href="/admin">admin page</a></div>
<div><a href="/logout">logout</a></div>
</body></html>
"""
)
@app.get("/admin", response_class=HTMLResponse)
def admin_only(user=Depends(require_login)):
if user["role"] != "admin":
return HTMLResponse("<h3>forbidden</h3>", status_code=403)
return HTMLResponse("<h3>admin ok</h3><a href='/app'>back</a>")変更後はサービス再起動して反映:
systemctl restart fg-analyzer
systemctl status fg-analyzer --no-pageradmin は通ること(302):
# 1) adminでログイン(Cookieを保存)
curl -k -i -c /tmp/c_admin.txt \
-X POST https://127.0.0.1/login \
-F 'username=admin' \
-F 'password=<<adminの平文パスワード>>'
想定結果:
HTTP/1.1 302 Found
Server: nginx/1.26.3
Date: Mon, 09 Mar 2026 03:07:15 GMT
Content-Length: 0
Connection: keep-alive
location: /app
set-cookie: fg_session=eyJ1IjoiYWRtaW4iLCJyIjoiYWRtaW4ifQ.aa45Yw._VtrkXmEyVw_XWtZAxaK0gMpock; HttpOnly; Max-Age=28800; Path=/; SameSite=lax; Secureadminで /app(200 OK):
# 2) adminで /app
curl -k -i -b /tmp/c_admin.txt https://127.0.0.1/app
想定結果:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Mon, 09 Mar 2026 03:08:55 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 247
Connection: keep-alive
<html><body>
<h3>fg-analyzer app</h3>
<div>user: admin</div>
<div>role: admin</div>
<div><a href="/admin">admin page</a></div>
<div><a href="/logout">logout</a></div>
</body></html>adminで /admin(200 OK):
# 3) adminで /admin
curl -k -i -b /tmp/c_admin.txt https://127.0.0.1/admin
想定結果:
HTTP/1.1 200 OK
Server: nginx/1.26.3
Date: Mon, 09 Mar 2026 03:10:14 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 40
Connection: keep-alive
<h3>admin ok</h3><a href='/app'>back</a>userはメンテ中ログイン不可(503):
# 4) user はメンテ中ログイン不可
curl -k -i -c /tmp/c_user.txt \
-X POST https://127.0.0.1/login \
-F 'username=user01' \
-F 'password=<<userの平文パスワード>>'
想定結果:
HTTP/1.1 503 Service Unavailable
Server: nginx/1.26.3
Date: Mon, 09 Mar 2026 03:11:46 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 37
Connection: keep-alive
<h3>maintenance</h3><p>admin only</p>⑥ login / logout / maintenance block のログ出力機能追加
以下を残せるようにします。
login_successlogin_faillogoutmaintenance_blockforbidden_admin
これだけで、あとから
- 誰が入れたか
- 失敗が増えていないか
- メンテで誰が弾かれたか
が見えるようになります。
ログ保存先:
- /opt/fg-analyzer/shared/logs/app.log
/opt/fg-analyzer/current/app/app.py の内容を以下に変えます。
import json
import os
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from passlib.context import CryptContext
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-change-me")
SESSION_HOURS = int(os.environ.get("SESSION_HOURS", "8"))
USERS_JSON = os.environ.get("USERS_JSON", "/opt/fg-analyzer/shared/users.json")
MAINTENANCE_FLAG = os.environ.get("MAINTENANCE_FLAG", "/opt/fg-analyzer/shared/maintenance.on")
LOG_FILE = os.environ.get(
"LOG_FILE",
"/opt/fg-analyzer/shared/logs/app.log"
)
COOKIE_NAME = "fg_session"
serializer = URLSafeTimedSerializer(SECRET_KEY)
pwdctx = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
# ---------------------------------------------------------
# logging
# ---------------------------------------------------------
def log_event(event: str, request: Request | None = None, **fields):
try:
entry = {
"ts": datetime.utcnow().isoformat(),
"event": event,
}
if request:
entry["ip"] = request.client.host
entry.update(fields)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass
# ---------------------------------------------------------
# helpers
# ---------------------------------------------------------
def is_maintenance_on() -> bool:
return os.path.exists(MAINTENANCE_FLAG)
def load_users():
with open(USERS_JSON, "r", encoding="utf-8") as f:
data = json.load(f)
users = {}
for u in data.get("users", []):
users[u["username"]] = u
return users
def verify_user(username: str, password: str) -> Optional[dict]:
users = load_users()
u = users.get(username)
if not u:
return None
if u.get("disabled"):
return None
if not pwdctx.verify(password, u["password_hash"]):
return None
return u
def set_session(resp: RedirectResponse, username: str, role: str):
payload = {"u": username, "r": role}
token = serializer.dumps(payload)
resp.set_cookie(
COOKIE_NAME,
token,
max_age=SESSION_HOURS * 3600,
httponly=True,
secure=True,
samesite="lax",
path="/",
)
def clear_session(resp: RedirectResponse):
resp.delete_cookie(COOKIE_NAME, path="/")
def get_current_user(request: Request) -> Optional[dict]:
token = request.cookies.get(COOKIE_NAME)
if not token:
return None
try:
payload = serializer.loads(token, max_age=SESSION_HOURS * 3600)
return {
"username": payload.get("u"),
"role": payload.get("r"),
}
except (BadSignature, SignatureExpired):
return None
# ---------------------------------------------------------
# exceptions
# ---------------------------------------------------------
class RedirectToLogin(Exception):
pass
class MaintenanceBlocked(Exception):
pass
@app.exception_handler(RedirectToLogin)
def redirect_login_handler(request: Request, exc: RedirectToLogin):
return RedirectResponse(url="/login", status_code=302)
@app.exception_handler(MaintenanceBlocked)
def maintenance_handler(request: Request, exc: MaintenanceBlocked):
user = get_current_user(request)
log_event(
"maintenance_block",
request,
user=user["username"] if user else None,
role=user["role"] if user else None,
path=request.url.path,
)
return HTMLResponse(
"<h3>maintenance</h3><p>admin only</p>",
status_code=503,
)
# ---------------------------------------------------------
# auth dependency
# ---------------------------------------------------------
def require_login(request: Request):
user = get_current_user(request)
if not user:
raise RedirectToLogin()
if is_maintenance_on() and user["role"] != "admin":
raise MaintenanceBlocked()
return user
# ---------------------------------------------------------
# routes
# ---------------------------------------------------------
@app.get("/", response_class=JSONResponse)
def root():
return {"status": "ok", "service": "fg-analyzer"}
@app.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
current_user = get_current_user(request)
if current_user:
if is_maintenance_on() and current_user["role"] != "admin":
raise MaintenanceBlocked()
return RedirectResponse(url="/app", status_code=302)
notice = ""
if is_maintenance_on():
notice = "<p><b>maintenance mode:</b> admin only</p>"
return HTMLResponse(
f"""
<html><body>
<h3>fg-analyzer login</h3>
{notice}
<form method="post" action="/login">
<div>user: <input name="username"/></div>
<div>pass: <input name="password" type="password"/></div>
<button type="submit">login</button>
</form>
</body></html>
"""
)
@app.post("/login")
def login(request: Request, username: str = Form(...), password: str = Form(...)):
user = verify_user(username, password)
if not user:
log_event(
"login_fail",
request,
username=username
)
return HTMLResponse(
"<h3>login failed</h3><a href='/login'>back</a>",
status_code=401,
)
if is_maintenance_on() and user["role"] != "admin":
log_event(
"maintenance_login_block",
request,
username=username
)
return HTMLResponse(
"<h3>maintenance</h3><p>admin only</p>",
status_code=503,
)
log_event(
"login_success",
request,
username=user["username"],
role=user["role"],
)
resp = RedirectResponse(url="/app", status_code=302)
set_session(resp, user["username"], user["role"])
return resp
@app.get("/logout")
def logout(request: Request):
user = get_current_user(request)
if user:
log_event(
"logout",
request,
username=user["username"],
role=user["role"],
)
resp = RedirectResponse(url="/login", status_code=302)
clear_session(resp)
return resp
@app.get("/app", response_class=HTMLResponse)
def app_home(user=Depends(require_login)):
return HTMLResponse(
f"""
<html><body>
<h3>fg-analyzer app</h3>
<div>user: {user["username"]}</div>
<div>role: {user["role"]}</div>
<div><a href="/admin">admin page</a></div>
<div><a href="/logout">logout</a></div>
</body></html>
"""
)
@app.get("/admin", response_class=HTMLResponse)
def admin_only(request: Request, user=Depends(require_login)):
if user["role"] != "admin":
log_event(
"admin_forbidden",
request,
username=user["username"],
)
return HTMLResponse("<h3>forbidden</h3>", status_code=403)
return HTMLResponse("<h3>admin ok</h3><a href='/app'>back</a>")変更後サービス再起動:
systemctl restart fg-analyzerログイン・アクセス後、以下のようなログ出力を確認:
((venv) ) [root@localhost current]# tail -f /opt/fg-analyzer/shared/logs/app.log
{"ts": "2026-03-09T03:40:55.844077", "event": "login_fail", "ip": "192.168.75.1", "username": "user01"}
{"ts": "2026-03-09T03:41:01.243160", "event": "maintenance_login_block", "ip": "192.168.75.1", "username": "user01"}
{"ts": "2026-03-09T03:41:16.260226", "event": "login_success", "ip": "192.168.75.1", "username": "user01", "role": "user"}
{"ts": "2026-03-09T03:41:17.441825", "event": "admin_forbidden", "ip": "192.168.75.1", "username": "user01"}
{"ts": "2026-03-09T03:41:19.319379", "event": "logout", "ip": "192.168.75.1", "username": "user01", "role": "user"}バージョン変更時の手順
ディレクトリ構成
現状の構成は以下です。
/opt/fg-analyzer/
releases/
0.0.0/
venv/
app/
app.py
requirements.txt
0.0.1/
venv/
app/
app.py
requirements.txt
current -> releases/0.0.1
shared/
logs/
tls/
users.json
config.env運用ルール
requirements.txtは 各 release ごとに保持するrequirements.txtは その release の venv の実体を freeze したものとする- 稼働中 release の
requirements.txtは変更しない - パッケージ追加・更新・削除は 新 release 作成時のみ行う
- 依存更新後は
pip freeze > requirements.txtを必ず実施する - ロールバック時は対象 release の venv と requirements.txt をそのまま使う
バージョン変更時の標準手順
以下は 0.0.0 → 0.0.1 に上げる例です。
1. 新 release ディレクトリ作成
mkdir -p /opt/fg-analyzer/releases/0.0.1
cp -a /opt/fg-analyzer/releases/0.0.0/app /opt/fg-analyzer/releases/0.0.1/
python3 -m venv /opt/fg-analyzer/releases/0.0.1/venv2. 新 release の venv 有効化
cd /opt/fg-analyzer/releases/0.0.1
source venv/bin/activate3. 旧 release の requirements から復元
pip install --upgrade pip
pip install -r app/requirements.txtここで 0.0.0 の app/requirements.txt がコピーされている前提です。
4. 必要な追加・更新を実施
例:
pip install openpyxlあるいは特定バージョン更新:
pip install "fastapi==0.115.12"5. freeze して新 release の requirements を確定
pip freeze > app/requirements.txt6. 動作確認
- systemd の起動確認
/login/app/admin- maintenance 動作
- ログ出力
7. current 切り替え
ln -sfn /opt/fg-analyzer/releases/0.0.1 /opt/fg-analyzer/current
systemctl restart fg-analyzer8. 問題あれば即ロールバック
ln -sfn /opt/fg-analyzer/releases/0.0.0 /opt/fg-analyzer/current
systemctl restart fg-analyzerrequirements 確定手順
現在の venv が
/opt/fg-analyzer/releases/0.0.0/venvで動いている前提で進めます。
1. venv に入る
cd /opt/fg-analyzer/releases/0.0.0
source venv/bin/activateプロンプトに
(venv)が出ていればOKです。
2. freeze
pip freezeまず中身を確認します。
想定される出力(例)
fastapi==0.xx.x
uvicorn==0.xx.x
itsdangerous==2.x.x
passlib==1.x.x
python-multipart==0.x.x
openpyxl==3.x.x
pydantic==2.x.x
starlette==0.xx.xここで 変なパッケージが入っていないかだけ確認します。
3. requirements.txt 作成
pip freeze > app/requirements.txt保存場所は
/opt/fg-analyzer/releases/0.0.0/app/requirements.txtになります。
4. 内容確認
cat app/requirements.txt5. 動作確認(重要)
requirements が壊れていないか、軽く確認します。
systemctl restart fg-analyzer確認
curl -k https://127.0.0.1/
curl -k https://127.0.0.1/login完了後の構造
/opt/fg-analyzer/
releases/
0.0.0/
venv/
app/
app.py
requirements.txt ← NEW
current -> releases/0.0.0
コメント