POSA_LEAKSMS/tools/make_deploy_bundles.py
cw2007 3fda1f2af6 fix(deploy): 노드/디스플레이 setup.sh 위치-독립화 — flatten 불필요
systemd 유닛의 /home/pi/<subdir> 경로를 설치 시점에 setup.sh가 $BASE=$(pwd) 로 sed 치환.
번들 폴더를 이름 그대로 /home/pi 아래 어디에 두든(USB 통째 복사 포함) 동작 → flatten 단계 제거.

- build_node_setup_sh: copy_block 을 sed "s#/home/pi/#$BASE/#g" | sudo tee 로 교체, BASE 추가
- display setup.sh: 동일 방식(+ 기존 유닛 경로 버그 수정 — /home/pi/display 하드코딩이
  실제 설치경로와 불일치해 서비스 기동 실패하던 것을 $BASE 치환으로 해소)
- SETUP.md(노드·디스플레이): flatten 지침 제거, USB '폴더째 복사 후 cd·실행' 로 갱신
2026-07-19 13:37:20 +09:00

570 lines
28 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 + 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/<src> and ExecStart=/home/pi/<src>/venv/bin/python, so when the
# subdir is deployed to /home/pi/<sub> 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)
# Inject THIS bundle's real path ($BASE) into each unit's /home/pi/<sub> paths at install
# time, so the node works from wherever it was copied (no flatten to /home/pi required).
copy_block = "\n".join(
f'sed "s#/home/pi/#${{BASE}}/#g" "{sp["sub"]}/systemd/{sp["unit"]}.service" '
f'| sudo tee "/etc/systemd/system/{sp["unit"]}.service" >/dev/null'
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 (never sudo): bash setup.sh
# Location-independent: the systemd unit paths are auto-set to THIS folder's real path at
# install time, so the bundle can live anywhere under /home/pi (no flatten needed - copy the
# whole folder as-is, keep its name, cd in, run).
# 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")"
BASE="$(pwd)" # actual install path of this bundle -> injected into the unit paths below
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)
**No flatten needed.** `setup.sh` auto-sets the systemd unit paths to wherever this folder
actually sits, so copy the whole folder as-is (keep its name `{name}`) anywhere under `/home/pi`
and run it. Subfolders end up at `/home/pi/{name}/{subs}` and that is fine.
```bash
# 1. Copy this whole folder under /home/pi (USB or scp; keep the folder name)
# USB example: cp -r /media/pi/<USB_LABEL>/{name} /home/pi/
# scp example: scp -r {name} pi@<PI_IP>:/home/pi/
# 2. Enter the folder and run (do NOT prefix with sudo - sudo is used internally)
cd /home/pi/{name} && bash setup.sh
```
`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")"
BASE="$(pwd)" # actual install path -> injected into the unit path below (no fixed /home/pi/display)
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 (path auto-set to this folder) =="
sed "s#/home/pi/display#${{BASE}}#g" systemd/{meta["service"]}.service | sudo tee /etc/systemd/system/{meta["service"]}.service >/dev/null
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)
**No fixed path required.** `setup.sh` auto-sets the systemd unit path to wherever this folder
sits, so copy it as-is (USB or scp) under `/home/pi` and run.
```bash
# 1. Copy this whole folder under /home/pi (USB or scp; keep the folder name)
# USB example: cp -r /media/pi/<USB_LABEL>/{name} /home/pi/
# scp example: scp -r {name} pi@<PI_IP>:/home/pi/
# 2. Enter the folder and run (do NOT prefix with sudo - sudo is used internally)
cd /home/pi/{name} && bash setup.sh
```
`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/<sub> 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'''<?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],
'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=<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.).
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}")