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·실행' 로 갱신
570 lines
28 KiB
Python
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}")
|