POSA_LEAKSMS/tools/make_deploy_bundles.py
cw2007 0966f6d32b feat(sensor): 센서별 표시 라벨(위치·관리주체) 서버측 오버라이드
SENSOR_LABELS(config.local.php) 로 센서별 위치·관리주체를 서버에서 지정하고,
sensor_label() 헬퍼로 SMS·대시보드 표시를 통일한다(Pi 재배포 불필요). SMS 본문
라벨을 '센서'→'관리주체'로 변경. 배포 번들에 3개 센서(서버실 안/문서고/밖) 예시
SENSOR_LABELS 를 주입.
2026-07-11 11:22:40 +09:00

392 lines
18 KiB
Python

# =============================================================================
# make_deploy_bundles.py - per-device turnkey deploy bundle generator
#
# python tools/make_deploy_bundles.py
#
# Creates deploy/ at the repo root and generates, for the 1 server + 3 Pis, a
# folder each that is operable by just "copy -> install.sh -> systemctl enable".
# - API_KEY is auto-generated on first run (stored in deploy/.secrets/api_key.txt,
# reused on later runs) and injected identically into the server and all 3 Pis.
# - The admin password is likewise auto-generated + bcrypt-hashed (needs php) and injected.
# - Text files are normalized to LF (Windows checkout CRLF -> avoids Pi bash errors).
# - deploy/ contains secrets -> it is a .gitignore target. Never commit it.
#
# Remaining manual settings (external secrets that cannot be pre-injected):
# 1) server DB account (cafe24 panel values) 2) SMS account / sender & recipient numbers 3) one-time TOTP enrollment
# =============================================================================
import hashlib
import hmac
import os
import re
import secrets
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "deploy"
SECRETS_DIR = DEPLOY / ".secrets"
# -- deploy defaults ----------------------------------------------------------
# WEB_DIR (obfuscated path folder name) is a secret - it is NOT kept in this
# committed file; it is loaded from deploy/.secrets/web_dir.txt (randomly generated if absent).
# Only edit DOMAIN below.
DOMAIN = "https://pores.kr"
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html", ".css"}
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
BUNDLES = [
# (folder name, source, kind)
("01_server_cafe24", None, "server"),
("02_pi_a_leak_id1", "raspberry_pi4", "leak"),
("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"),
("04_pi_c_leak_id3", "raspberry_pi4", "leak"),
]
PI_META = {
"02_pi_a_leak_id1": {
"device_id": "rpi4-leak-01", "location": "Site 1 (Server Room)",
"sensors": None, # config.py default (sensor_id 1)
"service": "leak-sensor",
},
"03_pi_b_sht30_id2": {
"device_id": "rpi4-sht30-01", "location": "Site 1 (Server Room)",
"sensors": None, # config.py default (sensor_id 2)
"service": "sht30-sensor",
},
"04_pi_c_leak_id3": {
"device_id": "rpi4-leak-02", "location": "Site 3 (New)",
"sensors": '[\n {"id": 3, "gpio": 17, "name": "Sensor 3 (New Site)"},\n]',
"service": "leak-sensor",
},
}
def fail(msg: str) -> None:
print(f"[ERROR] {msg}", file=sys.stderr)
sys.exit(1)
def load_or_create_secret(name: str, generate) -> str:
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
f = SECRETS_DIR / name
if f.exists():
return f.read_text(encoding="utf-8").strip()
value = generate()
f.write_text(value + "\n", encoding="utf-8")
os.chmod(f, 0o600) # on Windows this is a read-only approximation; for POSIX build machines
return value
def bcrypt_hash(password: str) -> str:
"""Generate a bcrypt hash via php (same result as the server setup_hash.php).
The password is passed on stdin, not argv (avoids exposure in the shared build machine's process list)."""
try:
out = subprocess.run(
["php", "-r", "echo password_hash(rtrim(fgets(STDIN), \"\\r\\n\"), PASSWORD_BCRYPT);"],
input=password + "\n",
capture_output=True, text=True, check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as e:
fail(f"php execution failed - cannot create bcrypt hash: {e}")
h = out.stdout.strip()
if not h.startswith("$2y$"):
fail(f"unexpected bcrypt hash format: {h!r}")
return h
def normalize_lf(path: Path) -> None:
"""Normalize bundle text files CRLF -> LF (avoids Pi bash/systemd/Apache errors).
Extension-less config dotfiles (.htaccess/.gitignore etc.) are included regardless of
extension (.htaccess CRLF can inject a CR into Apache Header values)."""
dotfiles = {".htaccess", ".gitignore", ".env.example"}
for p in path.rglob("*"):
if p.is_file() and (p.suffix in TEXT_EXTS or p.name in dotfiles):
data = p.read_bytes()
if b"\r\n" in data:
p.write_bytes(data.replace(b"\r\n", b"\n"))
def copy_tree(src: Path, dst: Path) -> None:
shutil.copytree(
src, dst,
ignore=shutil.ignore_patterns(*EXCLUDE_NAMES, "*.pyc"),
dirs_exist_ok=False,
)
def write_lf(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8", newline="\n")
# -- leak guard first - create the ignore file before writing any secrets ------
# (Double defense together with the root .gitignore's deploy/ entry. Guarantees the
# order so that even if the script fails mid-generation, secrets under deploy/ are
# never staged into git.)
DEPLOY.mkdir(parents=True, exist_ok=True)
write_lf(DEPLOY / ".gitignore", "# Never commit the whole deploy bundle (contains secrets)\n*\n!.gitignore\n")
# -- prepare secrets ----------------------------------------------------------
API_KEY = load_or_create_secret("api_key.txt", lambda: secrets.token_hex(32))
ADMIN_PASSWORD = load_or_create_secret("admin_password.txt", lambda: secrets.token_urlsafe(18))
MFA_SETUP_TOKEN = load_or_create_secret("mfa_setup_token.txt", lambda: secrets.token_hex(16))
ADMIN_HASH = bcrypt_hash(ADMIN_PASSWORD)
WEB_DIR = load_or_create_secret("web_dir.txt", lambda: secrets.token_hex(8))
BASE_URL = f"{DOMAIN}/{WEB_DIR}"
API_URL = f"{BASE_URL}/api/sensor_data.php"
PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php"
# cron HTTP key - matches the server config.php's hash_hmac('sha256','cron',API_KEY).
# Precomputed and embedded in SETUP.md so the operator never has to put API_KEY on a
# shell command line (exposed in history / process list).
CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest()
# -- remove existing bundles then regenerate (.secrets is preserved) ----------
for name, _, _ in BUNDLES:
d = DEPLOY / name
if d.exists():
shutil.rmtree(d)
# -- Pi bundles ---------------------------------------------------------------
for name, src, kind in BUNDLES:
if kind == "server":
continue
dst = DEPLOY / name
copy_tree(ROOT / src, dst)
shutil.copy2(ROOT / "tools" / "harden_pi.sh", dst / "harden_pi.sh")
meta = PI_META[name]
sensors_block = ""
if meta["sensors"]:
sensors_block = (
"\n# Sensor list for this device - overrides the config.py default (sensor_id 1)\n"
f"SENSORS = {meta['sensors']}\n"
)
write_lf(dst / "config_local.py", f'''# =============================================================================
# config_local.py - operational values for {meta["device_id"]} (auto-generated, do not commit)
# =============================================================================
API_URL = "{API_URL}"
{'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (the SHT30 module has no photo upload)'}
API_KEY = "{API_KEY}"
DEVICE_ID = "{meta["device_id"]}"
DEVICE_LOCATION = "{meta["location"]}"
{sensors_block}''')
# -- one-shot install wrapper: install -> register service -> verify -> harden --
# If verification (test_connection.py) fails, set -e stops before hardening ->
# SSH stays alive for debugging. Hardening (SSH lockdown) only runs on success.
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
# =============================================================================
# setup.sh - one-shot install for {meta["device_id"]} (install->service->verify->harden)
# Run as the pi user: bash setup.sh (do NOT run with sudo - sudo is used internally)
# On verification failure it stops before hardening (set -e) -> SSH stays up for debugging.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
if [ "$(id -u)" -eq 0 ]; then
echo "[ERROR] Run as the pi user without sudo: bash setup.sh" >&2
exit 1
fi
echo "== [1/4] Install packages + venv =="
chmod +x install.sh && ./install.sh
echo "== [2/4] Register systemd service =="
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now {meta["service"]}
echo "== [3/4] Server connectivity check (stops here on failure - SSH stays up) =="
# shellcheck disable=SC1091
source venv/bin/activate
python3 test_connection.py
echo "== [4/4] Hardening (last - SSH gets locked down!) =="
sudo bash harden_pi.sh
echo ""
echo "Install, verify and hardening complete. A final reboot is recommended: sudo reboot"
''')
write_lf(dst / "SETUP.md", f'''# {name} install - {meta["device_id"]}
Prerequisites: Raspberry Pi OS **Bookworm 64-bit**, user **pi**, SSH enabled (set in Imager).
`config_local.py` is already filled in (API_KEY/URL/device ID). If the install location changes, edit only DEVICE_LOCATION.
Wiring / pinout: open `WIRING.html` in the same folder in a browser (self-contained - viewable offline).
## One-shot install (recommended)
```bash
# 1. Copy this folder to the Pi
scp -r {name} pi@<PI_IP>:/home/pi/{src}
# 2. All at once: install -> register service -> verify -> harden
cd /home/pi/{src} && bash setup.sh
# (do NOT prefix with sudo - sudo is used internally)
```
`setup.sh` runs 4 stages automatically and **stops before hardening if the verify (stage 3) fails**
(SSH stays alive so you can see the cause). Hardening runs only after verification passes.
Warning: stage 4 hardening **fully disables SSH.** If you are running over SSH, the session
drops right after completion and cannot reconnect - further maintenance needs an on-site monitor + keyboard. Finish with `sudo reboot`.
## Manual (step by step) - if something goes wrong
```bash
cd /home/pi/{src}
chmod +x install.sh && ./install.sh # 1. install
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. service
source venv/bin/activate && python3 test_connection.py # 3. verify (signed 200)
sudo bash harden_pi.sh # 4. harden (SSH lockdown)
```
**Hardening policy** (`harden_pi.sh`): fully disable SSH, block all inbound (nftables),
remove unneeded services (avahi/bluetooth/cups etc.), automatic security patches, `config_local.py` mode 600.
This system is outbound-only, so no inbound is needed. To re-enable, create an empty
`ssh` file on the SD boot partition (physical access required).
''')
# -- server bundle ------------------------------------------------------------
server = DEPLOY / "01_server_cafe24"
copy_tree(ROOT / "php", server / WEB_DIR)
# Remove the example file from the bundle to avoid confusion (the finished config.local.php is used)
(server / WEB_DIR / "config.local.example.php").unlink(missing_ok=True)
# var/ is runtime state (client IPs, audit log, heartbeat, photos) - empty its contents and
# create only an empty skeleton so the build machine's local files do not leak into the bundle.
var_dir = server / WEB_DIR / "var"
if var_dir.exists():
shutil.rmtree(var_dir)
var_dir.mkdir()
write_lf(var_dir / "index.html", "") # directory listing guard
# SQL is a single file only - schema_sht30.sql is the complete new-install schema (includes leak elements).
# migration_*.sql is for retrofitting an existing server, so it is excluded from the new-install bundle.
(server / "sql").mkdir(parents=True)
schema_src = (ROOT / "sql" / "schema_sht30.sql").read_text(encoding="utf-8")
write_lf(server / "sql" / "schema.sql",
"-- schema.sql - single new-install schema (source: sql/schema_sht30.sql)\n"
"-- Includes both leak and temperature/humidity (SHT30) elements - run this one file once.\n"
"-- This single file completes the install (all tables leak_ prefixed - isolated from other tables in the same DB).\n\n"
+ schema_src)
write_lf(server / WEB_DIR / "config.local.php", f'''<?php
// config.local.php - auto-generated operational settings (do not commit/share)
// [CHANGE_ME] Fill in just 3 areas to go live: DB account, SMS account, SMS recipients.
return [
// -- cafe24 MySQL (hosting panel > DB info) -- [CHANGE_ME]
'DB_HOST' => 'localhost',
'DB_PORT' => 3306,
'DB_NAME' => 'CHANGE_ME',
'DB_USER' => 'CHANGE_ME',
'DB_PASS' => 'CHANGE_ME',
// -- shared secret key (auto-injected identically into all 3 Pis - no edit needed) --
'API_KEY' => '{API_KEY}',
'ALLOW_LEGACY_BODY_SIGNATURE' => false,
// -- cafe24 SMS (a pre-registered sender number is required) -- [CHANGE_ME]
'SMS_USER_ID' => 'CHANGE_ME',
'SMS_SECURE' => 'CHANGE_ME',
'SMS_SENDER' => '01000000000',
'SMS_RECIPIENTS' => [
'01000000000', // [CHANGE_ME] alert recipient number
],
// -- admin login (password is in deploy/.secrets/admin_password.txt) --
'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '{ADMIN_HASH}',
// Enroll TOTP once on the server: open setup_mfa.php?token=<token below> -> register in app
// -> put the shown secret into ADMIN_TOTP_SECRET -> clear MFA_SETUP_TOKEN to ''.
'ADMIN_TOTP_SECRET' => '',
'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}',
// -- leak module sensor_id (Site 1 + new Site 3) --
'LEAK_SENSOR_IDS' => [1, 3],
// -- per-sensor display labels (location + managing entity) shown in SMS/dashboard --
'SENSOR_LABELS' => [
1 => ['location' => '서버실(안)', 'name' => '정보기반'],
2 => ['location' => '문서고', 'name' => '조직혁신'],
3 => ['location' => '서버실(밖)', 'name' => '고객센터'],
],
// -- temperature/humidity thresholds (recommended operational values) --
'METRIC_TEMP_HIGH_C' => 27,
'METRIC_TEMP_LOW_C' => 10,
'METRIC_RH_HIGH' => 60,
'METRIC_RH_LOW' => -1, // low-humidity alert disabled
'METRIC_DEWPOINT_MARGIN_C' => 0, // condensation alert disabled
'METRIC_ALERT_COOLDOWN_SEC' => 1800,
'SMS_LOG_RETENTION_DAYS' => 365,
'SENSOR_LOG_RETENTION_DAYS' => 365,
'SENSOR_METRIC_RETENTION_DAYS' => 365,
'ADMIN_AUDIT_RETENTION_DAYS' => 365,
];
''')
write_lf(server / "SETUP.md", f'''# 01_server_cafe24 install
1. **DB schema**: in cafe24 phpMyAdmin, run `sql/schema.sql` **once, that one file only** -> confirm 7 tables
(leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo).
2. **Upload**: upload the entire `{WEB_DIR}/` folder under `public_html/`.
3. **Fill the 3 [CHANGE_ME] areas in config.local.php**: DB account / SMS account / SMS recipient number.
4. **Force HTTPS**: set in the cafe24 hosting panel (required - login fails without it).
5. **Admin login**: `{BASE_URL}/login.php`
- ID `admin` / password is in `deploy/.secrets/admin_password.txt`
- First-time TOTP enrollment: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN value>` ->
register in app -> put the shown secret into `ADMIN_TOTP_SECRET` -> clear `MFA_SETUP_TOKEN`.
6. **cron (1 min)**: in the cafe24 crontab, `php /home/<account>/public_html/{WEB_DIR}/cron_heartbeat.php`
(for the HTTP method, use the URL below as-is - CRON_SECRET is already computed:
`{BASE_URL}/cron_heartbeat.php?key={CRON_SECRET}`)
7. **Verify**: all items in `setup_wizard.php` green + send a test SMS.
''')
# -- top-level README + gitignore guard ---------------------------------------
write_lf(DEPLOY / "README.md", f'''# deploy/ - per-device turnkey deploy bundles (auto-generated: tools/make_deploy_bundles.py)
Warning: **contains API_KEY and admin password - never commit/share.** Secrets: `.secrets/`
Warning: **`.secrets/` must be backed up** - it is lost on `git clean -fdx`, re-clone, or folder deletion. If regenerated,
a new API_KEY is issued and **mismatches every already-deployed server & Pi** (all nodes reject ingestion).
Right after building, copy `.secrets/` to offline storage (USB etc.).
| folder | target | sensor_id | remaining manual setup |
|---|---|---|---|
| 01_server_cafe24 | cafe24 web hosting | - | DB account, SMS account/number, one-time TOTP |
| 02_pi_a_leak_id1 | Pi-A leak + camera | 1 | none |
| 03_pi_b_sht30_id2 | Pi-B SHT30 temp/humidity | 2 | none |
| 04_pi_c_leak_id3 | Pi-C leak + camera (new site) | 3 | none |
## Order
1. `01_server_cafe24/SETUP.md` - server first (Pi verification depends on the server)
2. Common OS for all 3 Pis: Raspberry Pi Imager -> **Bookworm 64-bit**, user `pi`, SSH on, WiFi/wired configured
3. Each Pi folder's `SETUP.md` (copy -> install.sh -> systemctl; can be done in parallel)
## Notes
- Server address: `{BASE_URL}` - the domain is DOMAIN at the top of `tools/make_deploy_bundles.py`; the path folder name is in `.secrets/web_dir.txt`, edit then re-run.
- For offline (air-gapped) install: on one internet-connected Pi run each module's `./vendor.sh`, then
copy the generated `wheelhouse/`+`debs/` into the other Pi bundle folders (install.sh switches to offline automatically).
- Full deployment plan: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
''')
# (deploy/.gitignore was already written before secrets were generated - order guaranteed)
# -- LF normalization ---------------------------------------------------------
normalize_lf(DEPLOY)
print("deploy/ generation complete")
# Avoid characters incompatible with a cp949 console (Windows default) - no em-dashes etc.
print("[NOTE] Back up deploy/.secrets/ to offline storage (lost on git clean -fdx).")
print(" Regenerating issues a new API_KEY that mismatches all already-deployed nodes.")
print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)")
print(f" admin password : deploy/.secrets/admin_password.txt")
print(f" server URL : {BASE_URL}")
for name, _, _ in BUNDLES:
print(f" - deploy/{name}")