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