POSA_LEAKSMS/tools/make_deploy_bundles.py
cw2007 7a3b571b05 feat(deploy): 기기별 턴키 배포 번들 + Pi OS 하드닝 + 원샷 설치
tools/make_deploy_bundles.py: deploy/ 아래 서버1+Pi3대 폴더 생성 —
API_KEY 1개 자동 생성·4곳 동일 주입, 관리자 bcrypt(비번 stdin 전달), LF 정규화,
단일 schema.sql, var/ 런타임 상태 제외, CRON_SECRET 사전 계산. 난독화 경로
WEB_DIR 은 소스가 아닌 deploy/.secrets/web_dir.txt 에서 로드(커밋 트리 유출 차단).
시크릿 생성 전에 deploy/.gitignore 선작성 + 루트 .gitignore 에 deploy/ 이중 차단.

tools/harden_pi.sh: SSH 완전 비활성(outbound 전용 설계) · 인바운드 전부 차단
(nftables) · 불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치
(unattended-upgrades, 자동 리부팅 끔) · config_local.py 권한 600 · 검증 증적 출력.

setup.sh(번들 생성): 설치→서비스→검증→하드닝 원샷. 검증 실패 시 set -e 로
하드닝(SSH 차단) 전에 중단 → SSH 유지되어 디버깅 가능.

검색 비노출 하드닝: php/.htaccess X-Robots-Tag noindex + Referrer-Policy
no-referrer, api/photo_upload.php CORS 제거, test_mobile.php 세션 인증 가드.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821XP36Q6t9eWKTPSEumnC
2026-07-02 18:51:46 +09:00

378 lines
18 KiB
Python

# =============================================================================
# make_deploy_bundles.py — 기기별 턴키 배포 번들 생성기
#
# python tools/make_deploy_bundles.py
#
# repo 루트에 deploy/ 를 만들고, 서버 1 + Pi 3대 각각 "복사 → install.sh →
# systemctl enable" 만으로 운영 가능한 폴더를 생성한다.
# - API_KEY 는 최초 실행 시 자동 생성(deploy/.secrets/api_key.txt 에 보관,
# 재실행 시 재사용) → 서버/Pi 4곳에 동일 주입.
# - 관리자 비밀번호도 자동 생성 + bcrypt 해시(php 필요) 주입.
# - 텍스트 파일은 LF 로 정규화(Windows 체크아웃 CRLF → Pi bash 오류 방지).
# - deploy/ 는 비밀값 포함 — .gitignore 대상. 절대 커밋 금지.
#
# 남는 수동 설정(외부 비밀이라 사전 주입 불가):
# 1) 서버 DB 계정(cafe24 패널 값) 2) SMS 계정/발신·수신번호 3) TOTP 등록 1회
# =============================================================================
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"
# ── 배포 기본값 ──────────────────────────────────────────────────────────────
# WEB_DIR(난독화 경로 폴더명)은 비밀값 — 커밋되는 이 파일에 두지 않고
# deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정.
DOMAIN = "https://pores.kr"
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example"}
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
BUNDLES = [
# (폴더명, 원본, 종류)
("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": "1번 지점(서버실)",
"sensors": None, # config.py 기본값 (sensor_id 1)
"service": "leak-sensor",
},
"03_pi_b_sht30_id2": {
"device_id": "rpi4-sht30-01", "location": "1번 지점(서버실)",
"sensors": None, # config.py 기본값 (sensor_id 2)
"service": "sht30-sensor",
},
"04_pi_c_leak_id3": {
"device_id": "rpi4-leak-02", "location": "3번 지점(신규)",
"sensors": '[\n {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},\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) # Windows 에선 read-only 근사지만 POSIX 빌드머신 대비
return value
def bcrypt_hash(password: str) -> str:
"""php 로 bcrypt 해시 생성 (서버 setup_hash.php 와 동일 결과).
비밀번호는 argv 가 아니라 stdin 으로 전달한다(공유 빌드머신 프로세스 목록 노출 방지)."""
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 실행 실패 — bcrypt 해시를 만들 수 없습니다: {e}")
h = out.stdout.strip()
if not h.startswith("$2y$"):
fail(f"bcrypt 해시 형식 이상: {h!r}")
return h
def normalize_lf(path: Path) -> None:
"""번들 내 텍스트 파일 CRLF → LF (Pi bash/systemd 오류 방지)."""
for p in path.rglob("*"):
if p.is_file() and p.suffix in TEXT_EXTS:
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")
# ── 유출 방어 가드 먼저 — 비밀값을 쓰기 전에 ignore 파일부터 만든다 ─────────
# (루트 .gitignore 의 deploy/ 항목과 이중 방어. 생성 도중 스크립트가 실패해도
# deploy/ 아래 비밀값이 git 에 스테이징되지 않도록 순서를 보장한다.)
DEPLOY.mkdir(parents=True, exist_ok=True)
write_lf(DEPLOY / ".gitignore", "# 배포 번들 전체 커밋 금지 (비밀값 포함)\n*\n!.gitignore\n")
# ── 비밀값 준비 ──────────────────────────────────────────────────────────────
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 키 — 서버 config.php 의 hash_hmac('sha256','cron',API_KEY) 과 동일.
# 미리 계산해 SETUP.md 에 넣어, 운영자가 API_KEY 를 셸 명령줄(히스토리/프로세스
# 목록에 노출)에 올리지 않아도 되게 한다.
CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest()
# ── 기존 번들 제거 후 재생성 (.secrets 는 보존) ─────────────────────────────
for name, _, _ in BUNDLES:
d = DEPLOY / name
if d.exists():
shutil.rmtree(d)
# ── Pi 번들 ──────────────────────────────────────────────────────────────────
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# 이 기기 전용 센서 목록 — config.py 기본값(sensor_id 1) 오버라이드\n"
f"SENSORS = {meta['sensors']}\n"
)
write_lf(dst / "config_local.py", f'''# =============================================================================
# config_local.py — {meta["device_id"]} 운영값 (자동 생성, 커밋 금지)
# =============================================================================
API_URL = "{API_URL}"
{'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (SHT30 모듈은 사진 업로드 없음)'}
API_KEY = "{API_KEY}"
DEVICE_ID = "{meta["device_id"]}"
DEVICE_LOCATION = "{meta["location"]}"
{sensors_block}''')
# ── 원샷 설치 래퍼: 설치 → 서비스 등록 → 검증 → 하드닝 ──────────────
# 검증(test_connection.py)이 실패하면 set -e 로 하드닝 전에 멈춘다 →
# SSH 가 살아있어 디버깅 가능. 성공했을 때만 하드닝(SSH 차단)까지 간다.
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
# =============================================================================
# setup.sh — {meta["device_id"]} 원샷 설치 (설치→서비스→검증→하드닝)
# pi 사용자로 실행: bash setup.sh (sudo 로 실행하지 말 것 — 내부에서 sudo)
# 검증 실패 시 하드닝 전에 중단(set -e) → SSH 유지되어 디버깅 가능.
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")"
if [ "$(id -u)" -eq 0 ]; then
echo "[ERROR] sudo 없이 pi 사용자로 실행하세요: bash setup.sh" >&2
exit 1
fi
echo "== [1/4] 패키지·venv 설치 =="
chmod +x install.sh && ./install.sh
echo "== [2/4] systemd 서비스 등록 =="
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now {meta["service"]}
echo "== [3/4] 서버 연결 검증 (실패 시 여기서 중단 — SSH 유지됨) =="
# shellcheck disable=SC1091
source venv/bin/activate
python3 test_connection.py
echo "== [4/4] 하드닝 (마지막 — SSH 가 차단된다!) =="
sudo bash harden_pi.sh
echo ""
echo "설치·검증·하드닝 완료. 마무리 리부팅 권장: sudo reboot"
''')
write_lf(dst / "SETUP.md", f'''# {name} 설치 — {meta["device_id"]}
사전: Raspberry Pi OS **Bookworm 64-bit**, 사용자 **pi**, SSH 활성(Imager 설정).
`config_local.py` 는 이미 채워져 있음(API_KEY/URL/기기 ID). 설치 장소가 바뀌면 DEVICE_LOCATION 만 수정.
## 원샷 설치 (권장)
```bash
# 1. 이 폴더를 Pi 로 복사
scp -r {name} pi@<PI_IP>:/home/pi/{src}
# 2. 한 번에: 설치 → 서비스 등록 → 검증 → 하드닝
cd /home/pi/{src} && bash setup.sh
# (sudo 붙이지 말 것 — 내부에서 sudo 사용)
```
`setup.sh` 는 4단계를 자동 수행하고, **검증(3단계)이 실패하면 하드닝 전에 멈춘다**
(SSH 가 살아있어 원인을 볼 수 있음). 검증이 통과해야만 하드닝까지 진행된다.
⚠️ 4단계 하드닝은 **SSH 를 완전히 끈다.** SSH 로 접속해 실행 중이면 완료 직후
세션이 끊기고 재접속 불가 — 이후 유지보수는 현장 모니터+키보드. 마지막에 `sudo reboot`.
## 수동(단계별) — 문제 발생 시
```bash
cd /home/pi/{src}
chmod +x install.sh && ./install.sh # 1. 설치
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. 서비스
source venv/bin/activate && python3 test_connection.py # 3. 검증(서명 200)
sudo bash harden_pi.sh # 4. 하드닝(SSH 차단)
```
**하드닝 정책**(`harden_pi.sh`): SSH 완전 비활성 · 인바운드 전부 차단(nftables) ·
불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치 · `config_local.py` 권한 600.
이 시스템은 outbound 전용이라 인바운드가 필요 없음. 재활성이 필요하면 SD boot 파티션에
빈 `ssh` 파일 생성(물리 접근 필요).
''')
# ── 서버 번들 ────────────────────────────────────────────────────────────────
server = DEPLOY / "01_server_cafe24"
copy_tree(ROOT / "php", server / WEB_DIR)
# 예시 파일은 혼동 방지를 위해 번들에서 제거 (완성본 config.local.php 를 쓴다)
(server / WEB_DIR / "config.local.example.php").unlink(missing_ok=True)
# var/ 는 런타임 상태(접속 IP·감사로그·하트비트·사진) — 빌드 머신의 로컬 파일이
# 번들에 유입되지 않도록 내용물을 비우고 빈 스켈레톤만 만든다.
var_dir = server / WEB_DIR / "var"
if var_dir.exists():
shutil.rmtree(var_dir)
var_dir.mkdir()
write_lf(var_dir / "index.html", "") # 디렉터리 목록 가드
# SQL 은 단일 파일만 — schema_sht30.sql 이 신규 설치 완전 스키마(누수 요소 포함).
# migration_*.sql 은 기존 서버 레트로핏용이라 신규 설치 번들에서 제외한다.
(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 — 신규 설치용 단일 스키마 (원본: sql/schema_sht30.sql)\n"
"-- 누수(leak) + 온습도(SHT30) 요소 전부 포함 — 이 파일 하나만 1회 실행하면 됨.\n"
"-- 이 파일 하나로 설치 완결 (테이블 전체 leak_ 접두사 — 같은 DB 의 다른 테이블과 격리).\n\n"
+ schema_src)
write_lf(server / WEB_DIR / "config.local.php", f'''<?php
// config.local.php — 자동 생성 운영 설정 (커밋/공유 금지)
// [CHANGE_ME] 3곳만 채우면 운영 가능: DB 계정, SMS 계정, SMS 수신자.
return [
// ── cafe24 MySQL (호스팅 패널 > DB 정보) ── [CHANGE_ME]
'DB_HOST' => 'localhost',
'DB_PORT' => 3306,
'DB_NAME' => 'CHANGE_ME',
'DB_USER' => 'CHANGE_ME',
'DB_PASS' => 'CHANGE_ME',
// ── 공유 비밀키 (Pi 3대와 동일 값 자동 주입됨 — 수정 불필요) ──
'API_KEY' => '{API_KEY}',
'ALLOW_LEGACY_BODY_SIGNATURE' => false,
// ── cafe24 SMS (사전 등록된 발신번호 필수) ── [CHANGE_ME]
'SMS_USER_ID' => 'CHANGE_ME',
'SMS_SECURE' => 'CHANGE_ME',
'SMS_SENDER' => '01000000000',
'SMS_RECIPIENTS' => [
'01000000000', // [CHANGE_ME] 경보 수신 번호
],
// ── 관리자 로그인 (비밀번호는 deploy/.secrets/admin_password.txt) ──
'ADMIN_USER' => 'admin',
'ADMIN_PASSWORD_HASH' => '{ADMIN_HASH}',
// TOTP 는 서버에서 1회 등록: setup_mfa.php?token=<아래 토큰> 접속 → 앱 등록
// → 표시된 시크릿을 ADMIN_TOTP_SECRET 에 넣고 → MFA_SETUP_TOKEN 은 '' 로 비울 것.
'ADMIN_TOTP_SECRET' => '',
'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}',
// ── 누수 모듈 sensor_id (1번 지점 + 3번 신규 지점) ──
'LEAK_SENSOR_IDS' => [1, 3],
// ── 온습도 임계 (운영 권장값) ──
'METRIC_TEMP_HIGH_C' => 27,
'METRIC_TEMP_LOW_C' => 10,
'METRIC_RH_HIGH' => 60,
'METRIC_RH_LOW' => -1, // 저습 경보 비활성
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로 경보 비활성
'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 설치
1. **DB 스키마**: cafe24 phpMyAdmin 에서 `sql/schema.sql` **하나만 1회 실행** → 테이블 7개 확인
(leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo).
2. **업로드**: `{WEB_DIR}/` 폴더 전체를 `public_html/` 아래로 업로드.
3. **config.local.php 의 [CHANGE_ME] 3곳** 채우기: DB 계정 / SMS 계정 / SMS 수신번호.
4. **HTTPS 강제**: cafe24 호스팅 패널에서 설정 (필수 — 미설정 시 로그인 불가).
5. **관리자 로그인**: `{BASE_URL}/login.php`
- ID `admin` / 비밀번호는 `deploy/.secrets/admin_password.txt`
- 최초 1회 TOTP 등록: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN 값>` →
앱 등록 → 표시된 시크릿을 `ADMIN_TOTP_SECRET` 에 → `MFA_SETUP_TOKEN` 비우기.
6. **cron (1분)**: cafe24 크론탭에 `php /home/<계정>/public_html/{WEB_DIR}/cron_heartbeat.php`
(HTTP 방식이면 아래 URL 그대로 — CRON_SECRET 은 이미 계산되어 있음:
`{BASE_URL}/cron_heartbeat.php?key={CRON_SECRET}`)
7. **검증**: `setup_wizard.php` 전 항목 녹색 + 테스트 SMS 발송.
''')
# ── 최상위 README + gitignore 방어 ──────────────────────────────────────────
write_lf(DEPLOY / "README.md", f'''# deploy/ — 기기별 턴키 배포 번들 (자동 생성: tools/make_deploy_bundles.py)
⚠️ **API_KEY·관리자 비밀번호 포함 — 절대 커밋/공유 금지.** 비밀값: `.secrets/`
⚠️ **`.secrets/ 백업 필수`** — `git clean -fdx`·재클론·폴더 삭제 시 소멸. 재생성하면
새 API_KEY 가 발급되어 **이미 배포된 서버·Pi 전부와 불일치**(전 노드 수집 거부)하게 됨.
배포 직후 `.secrets/` 를 오프라인 저장소(USB 등)에 복사할 것.
| 폴더 | 대상 | sensor_id | 남은 수동 설정 |
|---|---|---|---|
| 01_server_cafe24 | cafe24 웹호스팅 | — | DB 계정, SMS 계정/번호, TOTP 1회 |
| 02_pi_a_leak_id1 | Pi-A 누수+카메라 | 1 | 없음 |
| 03_pi_b_sht30_id2 | Pi-B SHT30 온습도 | 2 | 없음 |
| 04_pi_c_leak_id3 | Pi-C 누수+카메라(신규 지점) | 3 | 없음 |
## 순서
1. `01_server_cafe24/SETUP.md` — 서버 먼저 (Pi 검증이 서버에 의존)
2. Pi 3대 공통 OS: Raspberry Pi Imager → **Bookworm 64-bit**, 사용자 `pi`, SSH 켬, WiFi/유선 설정
3. 각 Pi 폴더의 `SETUP.md` (복사 → install.sh → systemctl, 병렬 가능)
## 참고
- 서버 주소: `{BASE_URL}` — 도메인은 `tools/make_deploy_bundles.py` 상단 DOMAIN, 경로 폴더명은 `.secrets/web_dir.txt` 수정 후 재실행.
- 오프라인(폐쇄망) 설치가 필요하면: 인터넷 되는 Pi 1대에서 각 모듈 `./vendor.sh` 실행 후
생성된 `wheelhouse/`+`debs/` 를 다른 Pi 번들 폴더에 복사 (install.sh 가 자동 오프라인 전환).
- 전체 배포 계획: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
''')
# (deploy/.gitignore 는 비밀값 생성 전에 이미 작성됨 — 순서 보장)
# ── LF 정규화 ────────────────────────────────────────────────────────────────
normalize_lf(DEPLOY)
print("deploy/ 생성 완료")
# cp949 콘솔(Windows 기본)에서도 죽지 않도록 em-dash 등 비호환 문자 금지.
print("[주의] deploy/.secrets/ 를 오프라인 저장소에 백업할 것 (git clean -fdx 로 소멸).")
print(" 재생성 시 새 API_KEY 가 배포된 전 노드와 불일치하게 됨.")
print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)")
print(f" admin password : deploy/.secrets/admin_password.txt")
print(f" 서버 URL : {BASE_URL}")
for name, _, _ in BUNDLES:
print(f" - deploy/{name}")