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
This commit is contained in:
parent
12b0c74806
commit
7a3b571b05
7 changed files with 496 additions and 10 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -12,6 +12,10 @@ firmware/**/secrets.h
|
||||||
# ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개
|
# ── 레거시 백업: 하드코딩된 실 운영 비밀값(DB 암호/API 키/SMS 키) 포함 → 미공개
|
||||||
_backup/
|
_backup/
|
||||||
|
|
||||||
|
# ── 배포 번들: 라이브 API 키·관리자 비밀번호·난독화 경로 포함 (생성기가 내부
|
||||||
|
# .gitignore 도 만들지만, 생성 실패 시에도 새지 않도록 루트에서 이중 차단) ──
|
||||||
|
deploy/
|
||||||
|
|
||||||
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
|
# ── 런타임 상태 (접속 IP·감사로그·브루트포스 카운터·하트비트 throttle) ────
|
||||||
php/var/*
|
php/var/*
|
||||||
!php/var/.gitkeep
|
!php/var/.gitkeep
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# .htaccess - 누수감지 시스템 보안 설정
|
# .htaccess - 누수감지 시스템 보안 설정
|
||||||
# 배포 경로: pores.kr/raspi_leck_detecter/
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
|
# ─── HTTPS 강제 리다이렉트 ─────────────────────────────────
|
||||||
|
|
@ -37,7 +36,11 @@
|
||||||
Header always set X-Content-Type-Options "nosniff"
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
Header always set X-Frame-Options "DENY"
|
Header always set X-Frame-Options "DENY"
|
||||||
Header always set X-XSS-Protection "1; mode=block"
|
Header always set X-XSS-Protection "1; mode=block"
|
||||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
# 내부 관리 시스템 — 검색엔진 색인 금지(HTTP 레벨, 전 응답 공통).
|
||||||
|
# robots.txt 는 쓰지 않는다: 공개 파일이라 난독화 경로를 오히려 광고하게 됨.
|
||||||
|
Header always set X-Robots-Tag "noindex, nofollow, noarchive"
|
||||||
|
# 외부로 Referer 자체를 보내지 않음(도메인 확인 유출 차단). 내부 이동은 영향 없음.
|
||||||
|
Header always set Referrer-Policy "no-referrer"
|
||||||
# TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부
|
# TODO(follow-up): 'unsafe-inline' 제거 — 대시보드 인라인 스크립트/스타일을 외부
|
||||||
# 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다.
|
# 파일로 분리한 후 script-src/style-src 에서 'unsafe-inline' 을 삭제해야 한다.
|
||||||
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'"
|
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'"
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,7 @@
|
||||||
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
|
// [응답] 200 OK application/json {"status":"ok","photo_id":..,"filename":".."}
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
header('Access-Control-Allow-Origin: https://pores.kr');
|
// CORS 헤더 없음 — 기기 서버측 POST 전용(브라우저 미관여), Allow-Origin 은 도메인 지문만 노출.
|
||||||
header('Access-Control-Allow-Methods: POST, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Content-Type, X-Device-Id, X-Signature');
|
|
||||||
|
|
||||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
|
|
@ -101,7 +99,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
|
||||||
return 'unavailable';
|
return 'unavailable';
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$ins = $db->prepare("INSERT INTO ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
|
$ins = $db->prepare("INSERT INTO leak_ingest_nonce (signature, seen_at) VALUES (:sig, NOW())");
|
||||||
$ins->execute([':sig' => substr($sig, 0, 128)]);
|
$ins->execute([':sig' => substr($sig, 0, 128)]);
|
||||||
} catch (PDOException $e) {
|
} catch (PDOException $e) {
|
||||||
$code = $e->getCode();
|
$code = $e->getCode();
|
||||||
|
|
@ -115,7 +113,7 @@ function photo_nonce_claim(PDO $db, string $sig): string {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
$ttl = max(60, INGEST_MAX_CLOCK_SKEW_SEC * 2);
|
||||||
$db->exec("DELETE FROM ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
$db->exec("DELETE FROM leak_ingest_nonce WHERE seen_at < (NOW() - INTERVAL " . (int)$ttl . " SECOND)");
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
/* 정리는 실패해도 무방 */
|
/* 정리는 실패해도 무방 */
|
||||||
}
|
}
|
||||||
|
|
@ -127,7 +125,7 @@ function photo_nonce_release(PDO $db, string $sig): void {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
$db->prepare("DELETE FROM ingest_nonce WHERE signature = :sig")
|
$db->prepare("DELETE FROM leak_ingest_nonce WHERE signature = :sig")
|
||||||
->execute([':sig' => substr($sig, 0, 128)]);
|
->execute([':sig' => substr($sig, 0, 128)]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
|
error_log("[PHOTO] nonce release 오류: " . $e->getMessage());
|
||||||
|
|
@ -248,9 +246,9 @@ $filename = '';
|
||||||
try {
|
try {
|
||||||
$db = get_db();
|
$db = get_db();
|
||||||
|
|
||||||
// log_id 가 sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
|
// log_id 가 leak_sensor_log 에 실제로 존재하는지 + 어느 센서/디바이스의 로그인지 확인한다
|
||||||
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
|
// (임의 log_id 로 사진 누적 방지, 교차 센서 결속). 권위값은 로그 행의 sensor_id/device_id.
|
||||||
$chk = $db->prepare("SELECT sensor_id, device_id FROM sensor_log WHERE id = :id");
|
$chk = $db->prepare("SELECT sensor_id, device_id FROM leak_sensor_log WHERE id = :id");
|
||||||
$chk->execute([':id' => $log_id]);
|
$chk->execute([':id' => $log_id]);
|
||||||
$log_row = $chk->fetch();
|
$log_row = $chk->fetch();
|
||||||
if (!$log_row) {
|
if (!$log_row) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
<?php
|
<?php
|
||||||
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
|
// 모바일 접근 점검용 단순 핑. User-Agent 를 그대로 반사하면 반사형 XSS 가 되므로
|
||||||
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
|
// text/plain 으로 응답하고 출력값도 이스케이프한다(이중 방어).
|
||||||
|
//
|
||||||
|
// 인증 가드(심층 방어): 1차 차단은 .htaccess FilesMatch(403)지만, .htaccess 가
|
||||||
|
// 무시되는 배포(AllowOverride None 등)에서도 비인증 노출되지 않도록 세션을 검사한다.
|
||||||
|
ini_set('session.cookie_httponly', 1);
|
||||||
|
ini_set('session.cookie_secure', 1);
|
||||||
|
ini_set('session.cookie_samesite', 'Strict');
|
||||||
|
ini_set('session.use_strict_mode', 1);
|
||||||
|
session_start();
|
||||||
|
if (empty($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
||||||
|
|
|
||||||
BIN
tools/__pycache__/make_deploy_bundles.cpython-313.pyc
Normal file
BIN
tools/__pycache__/make_deploy_bundles.cpython-313.pyc
Normal file
Binary file not shown.
91
tools/harden_pi.sh
Normal file
91
tools/harden_pi.sh
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# harden_pi.sh — Raspberry Pi OS (Bookworm) 최소 하드닝 (센서 노드 공통)
|
||||||
|
#
|
||||||
|
# 정책 (2026-07 확정):
|
||||||
|
# - SSH 완전 비활성 — 이 시스템은 outbound 전용(Pi → 서버 HTTPS POST).
|
||||||
|
# 유지보수는 현장 물리 접근(모니터+키보드). 재활성이 필요하면 SD 카드의
|
||||||
|
# boot 파티션에 빈 파일 `ssh` 를 만들고 부팅한다(물리 접근 필요 — 의도됨).
|
||||||
|
# - 인바운드 전부 차단(nftables), 아웃바운드는 허용(apt/NTP/서버 POST).
|
||||||
|
# - 자동 보안 패치(unattended-upgrades) 켬, 자동 리부팅은 끔(센서 공백 방지).
|
||||||
|
#
|
||||||
|
# 실행: 모듈 설치(install.sh)와 검증(test_connection.py)이 전부 끝난 "마지막"에.
|
||||||
|
# sudo bash harden_pi.sh
|
||||||
|
# ⚠️ SSH 로 접속해 실행 중이면 완료 즉시 세션이 끊기고 재접속 불가 — 마지막 단계!
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ "$(id -u)" -eq 0 ] || { echo "[ERROR] sudo 로 실행하세요: sudo bash harden_pi.sh"; exit 1; }
|
||||||
|
|
||||||
|
echo "[1/6] SSH 완전 비활성..."
|
||||||
|
systemctl disable --now ssh.service ssh.socket 2>/dev/null || true
|
||||||
|
# boot 파티션 `ssh` 파일에 의한 재활성 경로(sshswitch)는 남겨둔다 — 물리 접근
|
||||||
|
# 없이는 사용할 수 없고, 현장 복구 수단으로 유용하다.
|
||||||
|
|
||||||
|
echo "[2/6] 불필요 리스닝/주변 서비스 비활성..."
|
||||||
|
for svc in avahi-daemon.service avahi-daemon.socket bluetooth.service hciuart.service \
|
||||||
|
triggerhappy.service triggerhappy.socket cups.service cups.socket \
|
||||||
|
cups-browsed.service ModemManager.service; do
|
||||||
|
systemctl disable --now "$svc" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[3/6] 방화벽(nftables) — 인바운드 전부 차단..."
|
||||||
|
apt-get install -y -q nftables >/dev/null
|
||||||
|
cat > /etc/nftables.conf <<'NFT'
|
||||||
|
#!/usr/sbin/nft -f
|
||||||
|
# 센서 노드 방화벽 — outbound 전용 장비. 인바운드는 루프백/응답 트래픽만.
|
||||||
|
flush ruleset
|
||||||
|
table inet filter {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy drop;
|
||||||
|
iif "lo" accept
|
||||||
|
ct state established,related accept
|
||||||
|
# 현장 진단용 ping 허용 (원치 않으면 아래 2줄 삭제)
|
||||||
|
ip protocol icmp icmp type echo-request accept
|
||||||
|
ip6 nexthdr ipv6-icmp accept
|
||||||
|
}
|
||||||
|
chain forward { type filter hook forward priority 0; policy drop; }
|
||||||
|
chain output { type filter hook output priority 0; policy accept; }
|
||||||
|
}
|
||||||
|
NFT
|
||||||
|
systemctl enable --now nftables
|
||||||
|
nft -f /etc/nftables.conf
|
||||||
|
|
||||||
|
echo "[4/6] 자동 보안 패치(unattended-upgrades)..."
|
||||||
|
apt-get install -y -q unattended-upgrades >/dev/null
|
||||||
|
# Raspberry Pi OS 는 기본 Origins 패턴(Debian 전용)에 안 걸린다 — Raspbian/RPF origin 추가.
|
||||||
|
cat > /etc/apt/apt.conf.d/51unattended-upgrades-raspi <<'APT'
|
||||||
|
Unattended-Upgrade::Origins-Pattern {
|
||||||
|
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security";
|
||||||
|
"origin=Raspbian,codename=${distro_codename},label=Raspbian";
|
||||||
|
"origin=Raspberry Pi Foundation,codename=${distro_codename},label=Raspberry Pi Foundation";
|
||||||
|
};
|
||||||
|
// 자동 리부팅 금지 — 커널 패치 후에도 센서 공백을 만들지 않는다(수동 리부팅).
|
||||||
|
Unattended-Upgrade::Automatic-Reboot "false";
|
||||||
|
APT
|
||||||
|
cat > /etc/apt/apt.conf.d/20auto-upgrades <<'APT'
|
||||||
|
APT::Periodic::Update-Package-Lists "1";
|
||||||
|
APT::Periodic::Unattended-Upgrade "1";
|
||||||
|
APT
|
||||||
|
systemctl enable --now unattended-upgrades 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "[5/6] 로컬 권한 조이기..."
|
||||||
|
# 시크릿 파일 소유자 전용 (API_KEY 포함)
|
||||||
|
for f in /home/pi/raspberry_pi4/config_local.py /home/pi/raspberry_pi4_sht30/config_local.py; do
|
||||||
|
[ -f "$f" ] && { chmod 600 "$f"; chown pi:pi "$f"; echo " 600: $f"; }
|
||||||
|
done
|
||||||
|
# 콘솔 자동 로그인 비활성 (물리 콘솔도 로그인 요구)
|
||||||
|
if command -v raspi-config >/dev/null 2>&1; then
|
||||||
|
raspi-config nonint do_boot_behaviour B1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[6/6] 검증 (증적 캡처 대상 — SOURCE_SECURITY_EVIDENCE.md §4)..."
|
||||||
|
echo "-- ssh 상태 (disabled 여야 함):"
|
||||||
|
systemctl is-enabled ssh.service 2>&1 || true
|
||||||
|
echo "-- 리스닝 소켓 (비어 있거나 로컬 전용이어야 함):"
|
||||||
|
ss -lntup || true
|
||||||
|
echo "-- nftables 룰 (input policy drop 확인):"
|
||||||
|
nft list chain inet filter input | head -12
|
||||||
|
echo ""
|
||||||
|
echo "완료. 위 출력을 캡처해 보안 증적으로 보관할 것."
|
||||||
|
echo "리부팅 권장: sudo reboot (이후 ss -lntup 재확인)"
|
||||||
378
tools/make_deploy_bundles.py
Normal file
378
tools/make_deploy_bundles.py
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
# =============================================================================
|
||||||
|
# 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}")
|
||||||
Loading…
Reference in a new issue