# ============================================================================= # 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@:/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''' 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=` → 앱 등록 → 표시된 시크릿을 `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}")