# ============================================================================= # 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@:/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''' 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= -> 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], // -- 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=` -> 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//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}")