# ============================================================================= # 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 + Pi nodes + display, # a folder each. Each Pi node is a COMBINED fresh install (초기화 후 새 설치): one folder # bundles every service that node runs (leak / SHT30 / PMS7003 subdirs) behind a single # "copy -> bash setup.sh" that installs, registers, verifies, then hardens once. # - 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"} # -- service catalog ---------------------------------------------------------- # Each sensor service is a self-contained source tree. The service subdir inside a node # bundle is named after its SOURCE dir on purpose: the stock systemd unit files ship with # WorkingDirectory=/home/pi/ and ExecStart=/home/pi//venv/bin/python, so when the # subdir is deployed to /home/pi/ the units work unchanged (no path edits needed). SERVICE_SPECS = { "leak": {"src": "raspberry_pi4", "unit": "leak-sensor", "sub": "raspberry_pi4"}, "sht30": {"src": "raspberry_pi4_sht30", "unit": "sht30-sensor", "sub": "raspberry_pi4_sht30"}, "pms7003": {"src": "raspberry_pi4_pms7003", "unit": "pms7003-sensor", "sub": "raspberry_pi4_pms7003"}, } BUNDLES = [ # (folder name, source, kind) ("01_server_cafe24", None, "server"), ("02_pi_a_node_id1", None, "node"), ("03_pi_b_sht30_id2", None, "node"), ("04_pi_c_node_id3", None, "node"), ("06_pi_zero2w_display", "raspberry_pi_zero2w_display", "display"), ] # Per-node operational metadata. A 'node' runs ONE combined fresh install (초기화 후 새 설치) # covering every service in meta["services"]; each service gets its own subdir + config_local.py. PI_META = { "02_pi_a_node_id1": { "device_id": "rpi4-node-01", "location": "Site 1 (Server Room)", "sensor_id": 1, "report_interval": 600, "services": ["leak", "sht30", "pms7003"], }, "03_pi_b_sht30_id2": { "device_id": "rpi4-sht30-01", "location": "Site 1 (Server Room)", "sensor_id": 2, "report_interval": 3600, "services": ["sht30"], }, "04_pi_c_node_id3": { "device_id": "rpi4-node-03", "location": "Site 3 (New)", "sensor_id": 3, "report_interval": 600, "services": ["leak", "sht30", "pms7003"], }, "06_pi_zero2w_display": { "device_id": "rpi0-display-01", "location": "Office", "service": "display-dashboard", # single-flat special case (read-only, reports nothing) }, } 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") def write_service_config(sub_dir: Path, svc: str, meta: dict) -> None: """Write a per-service config_local.py inside a node's service subdir. All services on a node share DEVICE_ID/DEVICE_LOCATION; each carries the node's sensor_id.""" device_id = meta["device_id"] location = meta["location"] sid = meta["sensor_id"] header = ("# =============================================================================\n" f"# config_local.py - {device_id} / {svc} service (auto-generated, do not commit)\n" "# =============================================================================\n") if svc == "leak": body = (f'API_URL = "{API_URL}"\n' f'PHOTO_UPLOAD_URL = "{PHOTO_UPLOAD_URL}"\n' f'API_KEY = "{API_KEY}"\n' f'DEVICE_ID = "{device_id}"\n' f'DEVICE_LOCATION = "{location}"\n' f'SENSORS = [\n {{"id": {sid}, "gpio": 17, "name": "Sensor {sid} (Leak)"}},\n]\n') elif svc == "sht30": body = (f'API_URL = "{API_URL}"\n' f'API_KEY = "{API_KEY}"\n' f'DEVICE_ID = "{device_id}"\n' f'DEVICE_LOCATION = "{location}"\n' f'SENSOR_ID = {sid}\n' f'SENSOR_NAME = "Sensor {sid} (SHT30)"\n' f'REPORT_INTERVAL = {meta["report_interval"]}\n') elif svc == "pms7003": body = (f'API_URL = "{API_URL}"\n' f'API_KEY = "{API_KEY}"\n' f'DEVICE_ID = "{device_id}"\n' f'DEVICE_LOCATION = "{location}"\n' f'SENSOR_ID = {sid}\n' f'SENSOR_NAME = "Sensor {sid} (PMS7003)"\n' f'REPORT_INTERVAL = {meta["report_interval"]}\n' f'UART_PORT = "/dev/serial0"\n') else: fail(f"unknown service: {svc}") write_lf(sub_dir / "config_local.py", header + body) def build_node_setup_sh(meta: dict) -> str: """One combined setup.sh for a multi-service node: install every service, register + enable all units (single daemon-reload), verify each (test_connection.py), then harden ONCE at the very end. Run as pi (never sudo). set -euo pipefail: any failure stops BEFORE hardening -> SSH stays up.""" specs = [SERVICE_SPECS[s] for s in meta["services"]] subs = " + ".join(sp["sub"] for sp in specs) install_block = "\n".join( f'echo " -> {sp["sub"]}"\n( cd "{sp["sub"]}" && chmod +x install.sh && ./install.sh )' for sp in specs) copy_block = "\n".join( f'sudo cp "{sp["sub"]}/systemd/{sp["unit"]}.service" /etc/systemd/system/' for sp in specs) enable_block = "\n".join(f'sudo systemctl enable --now {sp["unit"]}' for sp in specs) verify_block = "\n".join( f'echo " -> {sp["sub"]}"\n' f'( cd "{sp["sub"]}" && source venv/bin/activate && python3 test_connection.py && deactivate )' for sp in specs) return f'''#!/usr/bin/env bash # ============================================================================= # setup.sh - combined fresh install for {meta["device_id"]} ({subs}) # Run as the pi user from /home/pi: bash setup.sh (do NOT run with sudo - sudo is used internally) # The service subdirs must sit at /home/pi/ (the stock systemd units reference those paths). # Order: install every service -> register + enable all -> verify all -> harden ONCE (SSH lockdown). # On any failure set -e stops BEFORE hardening -> 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 for each service ==" {install_block} echo "== [2/4] Register + enable systemd services (one daemon-reload) ==" {copy_block} sudo systemctl daemon-reload {enable_block} echo "== [3/4] Server connectivity check per service (stops here on failure - SSH stays up) ==" # shellcheck disable=SC1091 {verify_block} echo "== [4/4] Hardening (last - SSH gets locked down!) ==" sudo bash harden_pi.sh echo "" echo "All services installed, verified and hardened." echo "I2C + UART were enabled during install - reboot once to apply: sudo reboot" ''' def build_node_setup_md(name: str, src: str, meta: dict) -> str: """SETUP.md for a combined multi-service node (fresh full install).""" specs = [SERVICE_SPECS[s] for s in meta["services"]] subs = ", ".join(sp["sub"] for sp in specs) svc_list = " + ".join(meta["services"]) wiring = " / ".join(f"`{sp['sub']}/WIRING.html`" for sp in specs) return f'''# {name} install - {meta["device_id"]} (combined fresh install / 초기화 후 새 설치) Fresh full install of a Pi-4 node running **{len(specs)}** service(s): **{svc_list}**. Prerequisites: Raspberry Pi OS **Bookworm 64-bit**, user **pi**, SSH enabled (set in Imager). Each service subfolder's `config_local.py` is already filled in (API_KEY / URL / device id / sensor id). If the install location changes, edit only `DEVICE_LOCATION` in each subfolder's `config_local.py`. Wiring / pinout: open {wiring} in a browser (self-contained - viewable offline). ## One-shot combined install (recommended) The service subfolders must end up at **/home/pi/{subs}** - the stock systemd units reference those exact paths. Flatten the node bundle straight into `/home/pi`: ```bash # 1. Copy the node bundle to the Pi, then flatten it into /home/pi scp -r {name} pi@:/home/pi/ ssh pi@ mv ~/{name}/* ~/ && rmdir ~/{name} # 2. All at once: install every service -> register + enable -> verify -> harden cd ~ && bash setup.sh # (do NOT prefix with sudo - sudo is used internally) ``` `setup.sh` installs every service, registers + enables all units (single daemon-reload), verifies each one, and **stops before hardening if any verify fails** (SSH stays alive so you can see the cause). Hardening (SSH lockdown) runs **once** at the very end, only after all services verify. Warning: the final hardening step **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. I2C (SHT30) and UART (PMS7003) are enabled during install, so finish with a **single reboot**: `sudo reboot`. ''' # -- 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) ---------- # The USB-migration model + the leak-only single-service nodes are retired in favour of combined # per-node fresh installs. Sweep the legacy folder names a previous run may have left (the 05_/07_ # migration bundles and the pre-rename 02_/04_ leak nodes) so deploy/ never ships stale dirs. LEGACY_DIRS = [ "05_migration_leak_add_sht30", "07_migration_leak_add_pms7003", "02_pi_a_leak_id1", "04_pi_c_leak_id3", ] for name in [n for n, _, _ in BUNDLES] + LEGACY_DIRS: d = DEPLOY / name if d.exists(): shutil.rmtree(d) # -- Pi bundles --------------------------------------------------------------- for name, src, kind in BUNDLES: if kind == "server": continue meta = PI_META[name] if kind == "display": # Display node stays a single-flat special case (unchanged): flat copy of its source # tree + a derived STATUS_KEY (never API_KEY) + its own simple install->verify->harden setup.sh. dst = DEPLOY / name copy_tree(ROOT / src, dst) shutil.copy2(ROOT / "tools" / "harden_pi.sh", dst / "harden_pi.sh") # Least privilege: the display gets only the derived STATUS_KEY # (= HMAC(API_KEY,'status')), never API_KEY itself. HMAC is one-way, so a # stolen display (easiest device to grab physically) yields read-only access # and cannot forge measurements or trigger SMS. status_key = hmac.new(API_KEY.encode(), b"status", hashlib.sha256).hexdigest() write_lf(dst / "config_local.py", f'''# ============================================================================= # config_local.py - operational values for {meta["device_id"]} (auto-generated, do not commit) # ============================================================================= STATUS_URL = "{BASE_URL}/api/status_summary.php" STATUS_KEY = "{status_key}" DEVICE_NAME = "{meta["device_id"]}" ''') # -- 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 (STATUS_URL/STATUS_KEY/device name). 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`. ''') continue # -- combined multi-service node (fresh full install / 초기화 후 새 설치) -------- # One node dir gets a subdir per service (named after its source tree so the stock systemd # units resolve /home/pi/ unchanged), plus a single harden_pi.sh at the node root and # one combined setup.sh that installs -> registers -> verifies every service then hardens once. node = DEPLOY / name node.mkdir(parents=True) shutil.copy2(ROOT / "tools" / "harden_pi.sh", node / "harden_pi.sh") for svc in meta["services"]: spec = SERVICE_SPECS[svc] copy_tree(ROOT / spec["src"], node / spec["sub"]) write_service_config(node / spec["sub"], svc, meta) write_lf(node / "setup.sh", build_node_setup_sh(meta)) write_lf(node / "SETUP.md", build_node_setup_md(name, src, meta)) # -- 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], 'METRIC_SENSOR_IDS' => [1, 2, 3], 'PM_SENSOR_IDS' => [1, 3], 'SENSOR_HEARTBEAT_TIMEOUT_SEC' => [1 => 1200, 3 => 1200], // keep leak nodes 1&3 at 20-min offline detection despite being in METRIC_SENSOR_IDS // -- 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=` -> 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.). Each node bundle is a **combined fresh install (초기화 후 새 설치)**: one folder carries every service for that Pi (each in its own `raspberry_pi4[_x]/` subdir) plus a single `setup.sh` + `harden_pi.sh`. | folder | target | sensor_id | remaining manual setup | |---|---|---|---| | 01_server_cafe24 | cafe24 web hosting | - | DB account, SMS account/number, one-time TOTP | | 02_pi_a_node_id1 | Pi-A node: leak + SHT30 + PMS7003 | 1 | none | | 03_pi_b_sht30_id2 | Pi-B SHT30 temp/humidity | 2 | none | | 04_pi_c_node_id3 | Pi-C node: leak + SHT30 + PMS7003 (new site) | 3 | none | | 06_pi_zero2w_display | Pi Zero 2 W + ST7789V mini status display (read-only) | - | wiring per WIRING.html | ## Order 1. `01_server_cafe24/SETUP.md` - server first (Pi verification depends on the server) 2. Common OS for all Pis: Raspberry Pi Imager -> **Bookworm 64-bit**, user `pi`, SSH on, WiFi/wired configured 3. Each Pi folder's `SETUP.md` (nodes: flatten the folder into `/home/pi` -> `bash setup.sh`; 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 service subdir's `./vendor.sh`, then copy the generated `wheelhouse/`+`debs/` into the matching subdir of the other node bundles (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, _, kind in BUNDLES: if kind == "node": print(f" - deploy/{name} (services: {', '.join(PI_META[name]['services'])})") else: print(f" - deploy/{name}")