feat: 납품 잔여 3건 해소 (Pi 번들 · MoveNet 폴백 · 자격증명 회전)

Pi 번들 — arm64 빌드 머신이 필요하다고 적어 둔 것은 과한 가정이었다.
--cross 모드에서 pip 이 태그로 aarch64/cp311 휠을 받으므로 네트워크만
있으면 된다. 휠 11개 전부 aarch64. "Pi 번들 미빌드"로 스킵되던 테스트가
이제 실제로 돈다.

MoveNet — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체하고
sha256+size 로 고정했다. 고정 전에 어댑터 계약과 대조했다: 입력
[1,192,192,3] int32 NHWC, 출력 [1,1,17,3]. 레이아웃이 다른 변환본은
onnxruntime 에 그냥 로드되고 좌표만 틀리는 실패라, 파일이 바뀌었는지가
아니라 계약이 맞는지를 테스트로 박았다.

자격증명 회전 — 손으로 적던 SQL 절차를 명령 하나로 바꿨다. DB 에 접속하지
않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로 접속 정보 없이
어느 머신에서나 돌아야 한다). 비밀번호는 STDIN 으로만 받는다 — 명령행은
ps 와 셸 히스토리에 남는다. 테스트가 잠그는 것은 서버 키와 Pi 키의 일치,
그리고 앱이 그 해시를 검증하는지 두 가지다. 회전 실행 자체는 운영 계정
조작이라 저장소에서 수행하지 않는다.

검증: phpunit 276 passed(스킵 0, 신규 7) · pytest 스킵 2(환경)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EBjuR7DW6PNNMows4KnbYU
This commit is contained in:
유창욱 2026-09-21 10:47:06 +09:00
parent 34506e150d
commit 6b6f29c447
8 changed files with 338 additions and 18 deletions

4
.gitignore vendored
View file

@ -11,7 +11,11 @@ dist/
# Edge runtime artifacts # Edge runtime artifacts
*.db *.db
# 모델 가중치는 커밋하지 않는다. 둘 다 고정된 fetch 스크립트로 받는다
# (edge/tools/fetch_hailo_hef.py · fetch_movenet.py) — sha256 가 저장소보다 확실한 보증이고,
# 9~10MB 바이너리를 히스토리에 쌓을 이유가 없다.
*.hef *.hef
*.onnx
# Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed # Root-anchored on purpose. A bare `snapshots/` matches at EVERY depth, which swallowed
# cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory # cloud/storage/snapshots/ — and since git cannot re-include a file whose parent directory
# is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The # is excluded, it silently killed cloud/.gitignore's `!/storage/snapshots/.gitkeep`. The

View file

@ -173,3 +173,21 @@
시스템 구성 · 낙상 시퀀스 · 낙상 상태기계. 시퀀스를 그리다 확인한 사실 — **경광등(3초)과 메일(최대 63초)은 시스템 구성 · 낙상 시퀀스 · 낙상 상태기계. 시퀀스를 그리다 확인한 사실 — **경광등(3초)과 메일(최대 63초)은
같은 "통보"가 아니다.** 상태기계 쪽은 확정 창 3초와 추락 연결 창 3초가 겹쳐 있음을 한 장으로 보여준다 같은 "통보"가 아니다.** 상태기계 쪽은 확정 창 3초와 추락 연결 창 3초가 겹쳐 있음을 한 장으로 보여준다
(4차 감사의 CRITICAL, 시간을 그렸다면 코드보다 먼저 보였을 충돌). HTML 은 gitignore, 소스 JSON 만 커밋. (4차 감사의 CRITICAL, 시간을 그렸다면 코드보다 먼저 보였을 충돌). HTML 은 gitignore, 소스 JSON 만 커밋.
## 2026-09-21 — 납품 정비 (잔여 3건 해소)
- **Pi 번들 재빌드** `move-detect-pi-1.2.0-20260921-aarch64-cp311-hailo8.tar.gz` (82MB).
arm64 빌드 머신이 필요하다고 봤던 것은 과한 가정이었다 — `--cross` 로 pip 이 태그를 보고
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64. 이걸로
"Pi 번들 미빌드" 로 스킵되던 테스트가 실제로 돌아 스킵이 5 → 2로 줄었다.
- **MoveNet CPU 폴백 복구** — P3-3 의 잔여였던 죽은 원본을 유효한 tf2onnx 변환본으로 교체·고정
(sha256 + size). 고정 **전에** 어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC,
출력 `[1,1,17,3]`. 레이아웃이 다른 변환본은 로드는 되고 좌표만 틀리므로 그 계약을 테스트로
박았다. 실사진 2장으로 코<어깨<엉덩이<발목 순서까지 확인. `edge/tools/fetch_movenet.py`
- **자격증명 회전 도구** `server/tools/rotate_credentials.php` — 손으로 적던 SQL 절차를 명령
하나로. DB 에 접속하지 않고 붙여넣을 SQL 만 낸다(운영자의 창구가 phpMyAdmin 이므로).
비밀번호는 STDIN 으로만 받는다 — 명령행은 `ps` 와 셸 히스토리에 남는다. 테스트가 잠그는
것은 "서버 SQL 의 키와 Pi 에 넣으라는 키가 같은가"와 "앱이 그 해시를 검증하는가" 두 가지다.
**교체 실행 자체는 운영 계정 조작이라 사용자 몫으로 남긴다.**
- 검증: pytest 스킵 2(shellcheck 미설치·Windows AF_UNIX) · phpunit 276 passed, 스킵 0

View file

@ -53,7 +53,8 @@
- `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명. - `src/Api/ThresholdCatalog.php`: 편집 가능한 튜너블의 화이트리스트 + 범위 + 한국어 설명.
목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게) 목록에 없는 키는 API 로 저장 불가 (장비가 조용히 무시하는 값을 "설정됨"으로 보이지 않게)
- `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) · - `tools/`: `dispatch_alerts.php`(크론 1분) · `maintain.php`(크론 일 1회, 보존기간) ·
`backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA). `backup.php`(DB 덤프+스냅샷) · `verify_audit_chain.php`(감사 무결성) · `seed_demo.php`(시연·QA) ·
`rotate_credentials.php`(장치 HMAC 키·관리자 비번 회전 — DB 미접속, 붙여넣을 SQL 만 출력).
전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치 전부 CLI 전용(`bootstrap.php` 가 SAPI 검사), 납품 시 `move_detect_app/tools/` 로 www 밖에 배치
- 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` · - 라우트: `POST /api/events`(ingest, HMAC) · `POST /api/heartbeat` · `GET /api/config` ·
`GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) · `GET /api/events` · `GET /api/devices` · `GET /api/heatmap/{camera}`(spatial 집계) ·
@ -98,7 +99,7 @@
## docs/ ## docs/
`delivery-audit.md`(감사 43건, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` · `delivery-audit.md`(감사 43건 + 재감사 4회, 전부 완료 — OFF-HW 실장비 검증만 잔여) · `pilot-runbook.md` ·
`hardware-bringup.md` · `INDEX.md`(작업 기록) `hardware-bringup.md` · `INDEX.md`(작업 기록)
도해 3종. 소스는 `*.archify.json` 이고 HTML 은 생성물이라 커밋하지 않는다 도해 3종. 소스는 `*.archify.json` 이고 HTML 은 생성물이라 커밋하지 않는다

View file

@ -1,9 +1,10 @@
# Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리) # Delivery Audit — move-detect (납품 전 결함 목록 · 순차 처리)
> **판정 (2026-09-05, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.** > **판정 (2026-09-21, 1.2.0): 적합 — 오프하드웨어 항목 전량 해소.**
> 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 + > 1차 43/43 + 2차 12/12 + 3차(1.1.0 장치 건전성) 14/14 +
> **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소. > **4차 재감사(1.2.0 추락·동시 다발·프리셋 표면) 23/23** 해소.
> 잔여는 실장비(OFF-HW) 검증뿐 — 아래 "OFF-HW 잔여" 절 참조. > 2026-09-21 납품 정비로 Pi 번들 재빌드·MoveNet 원본·자격증명 회전 도구까지 닫혔다.
> 잔여는 실장비(OFF-HW) 검증과 **운영자가 직접 해야 하는 자격증명 교체** 뿐.
Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual Source: 62-agent adversarial audit (2026-07-16) + skill `iot-airgap-delivery` §3 + manual
verification of every blocker against real code. Ordered for sequential fixing. Each item is verification of every blocker against real code. Ordered for sequential fixing. Each item is
@ -163,6 +164,11 @@ misconfig/degradation fail LOUD.
— PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to — PINTO hosts models off-repo), so it was silently saving garbage. No known-good sha256 to
pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet pin until a valid MOVENET_ONNX_URL is set. NON-BLOCKING for the Hailo delivery path (MoveNet
is the optional CPU fallback); flag for whoever needs CPU MoveNet. is the optional CPU fallback); flag for whoever needs CPU MoveNet.
CLOSED 2026-09-21 — 유효한 출처를 찾아 고정했다(tf2onnx 변환본, sha256+size). 고정 전에
어댑터 계약과 대조했다: 입력 `[1,192,192,3] int32` NHWC, 출력 `[1,1,17,3]`. 레이아웃이 다른
변환본은 **로드는 되고 좌표만 틀리는** 실패라, 그 계약을 테스트로 박았다
(`tests/hardware/test_movenet_smoke.py::test_model_signature_is_what_the_adapter_feeds_and_reads`).
실사진 2장으로 코<어깨<엉덩이<발목 세로 순서와 bbox 프레임 내 포함까지 확인.
## Phase 4 — Server hardening (shared hosting) ## Phase 4 — Server hardening (shared hosting)
@ -423,9 +429,13 @@ config 템플릿 2종. `edge/` 는 불변. 4개 관점 병렬 감사 후 각 발
- **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는 - **자격증명 교체** — `secrets/PROVISION_SECRETS.md` 의 대시보드 비밀번호와 장치 HMAC 키는
6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다. 6주간 납품 폴더 안에 있었다. 유출 정황은 없으나(git 미추적·zip 미포함) 교체를 권고한다.
운영 계정 조작이라 이 저장소에서 수행하지 않는다. 운영 계정 조작이라 이 저장소에서 수행하지 않는다.
2026-09-21 — 손으로 적던 절차를 도구로 바꿨다(`server/tools/rotate_credentials.php`).
**실행은 여전히 운영자 몫이다.**
- **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만, - **Pi 번들 재빌드** — 1.1.0 은 서버 전용이라 `edge/` 가 불변이고 내용은 1.0.0 과 동일하지만,
파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드 파일명·MANIFEST 의 버전 표기가 1.0.0 이다. arm64 + 네트워크가 있는 빌드 머신에서만 재빌드
가능하므로 Pi 납품 시 그 머신에서 수행한다. 가능하므로 Pi 납품 시 그 머신에서 수행한다.
RESOLVED 2026-09-21 — `--cross` 모드로 해소했다. arm64 머신은 필요 없었다: pip 이 태그로
aarch64/cp311 휠을 받으므로 네트워크만 있으면 된다. 휠 11개 전부 aarch64.
- **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다 - **매뉴얼에 신규 2종 설명 없음** — `manual.json` 에 `장치 이상`·`감시 성능 저하` 항목이 없다
(1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지 (1.0.0 의 `미확인 경보`도 마찬가지라 이번 회귀는 아니다). 캡처는 실화면이고 UI 골격은 바뀌지
않았다. 다음 매뉴얼 개정에서 함께 넣는다. 않았다. 다음 매뉴얼 개정에서 함께 넣는다.

View file

@ -5,14 +5,13 @@ to a temp file, validated, and only then moved into place — a truncated / HTML
tampered download must never be saved as the model (onnxruntime would then fail to load it, and tampered download must never be saved as the model (onnxruntime would then fail to load it, and
the operator would see a cryptic runtime error instead of a clear fetch failure). the operator would see a cryptic runtime error instead of a clear fetch failure).
STATUS: the historical PINTO_model_zoo raw URL below is DEAD (that repo hosts its models on SOURCE: the historical PINTO_model_zoo raw URL is DEAD (that repo hosts its models on external
external storage, not as repo blobs — the raw path now returns a GitHub HTML 404). So there is storage, not as repo blobs — the raw path returns a GitHub HTML 404). The pinned source below is
no known-good sha256 to pin here yet. Until a working source is set: a tf2onnx export of Google's MoveNet SinglePose Lightning v4, checked to match what
* this tool FAILS LOUD if the download is not a real ONNX (rejects the HTML it currently gets), edge/adapters/movenet_pose.py feeds and reads: input [1,192,192,3] int32 NHWC, output
instead of silently saving garbage; [1,1,17,3] float (y, x, score). A conversion with a different layout would load fine and then
* set MOVENET_ONNX_URL to a valid source and pin PINNED_SHA256/PINNED_SIZE from its bytes. produce silently wrong keypoints, so the digest below is what makes this safe to fetch — not the
For the Hailo delivery path this model is optional (POSE_BACKEND=hailo), so a missing MoveNet is hostname. For the Hailo delivery path this model is optional (POSE_BACKEND=hailo).
non-blocking; the CPU fallback needs this resolved.
""" """
import hashlib import hashlib
import os import os
@ -22,15 +21,16 @@ import urllib.request
URL = os.environ.get( URL = os.environ.get(
"MOVENET_ONNX_URL", "MOVENET_ONNX_URL",
"https://github.com/PINTO0309/PINTO_model_zoo/raw/main/115_MoveNet/resources/" "https://huggingface.co/Xenova/movenet-singlepose-lightning/resolve/main/onnx/model.onnx",
"movenet_singlepose_lightning_4.onnx",
) )
DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx") DEST = os.path.join(os.path.dirname(__file__), "..", "..", "models", "movenet_lightning.onnx")
# Pin from the bytes of a known-good source once MOVENET_ONNX_URL points at one. Empty = unpinned # Pinned from the bytes of the source above. An override must bring its own digest — pointing
# (still validated as a real ONNX below, but not byte-identical-verified). # MOVENET_ONNX_URL elsewhere while keeping this pin just makes the download fail, which is the
PINNED_SHA256 = os.environ.get("MOVENET_ONNX_SHA256", "") # intended direction of failure.
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "0") or "0") PINNED_SHA256 = os.environ.get(
"MOVENET_ONNX_SHA256", "1ad4f8d6c2f776a9967db3993c9ca740bc350104f9d37c151dc183fc29a464ad")
PINNED_SIZE = int(os.environ.get("MOVENET_ONNX_SIZE", "9413268") or "0")
def _looks_like_onnx(head: bytes) -> bool: def _looks_like_onnx(head: bytes) -> bool:

View file

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Tests;
use App\Security\Password;
use PHPUnit\Framework\TestCase;
/**
* 자격증명 회전 도구.
*
* 이 도구의 실패는 조용하다. 회전은 사고 없이 몇 달에 한 번 하는 일이라, 출력이 틀려도
* 알아차릴 기회가 없다. 특히 두 가지가 치명적이다:
*
* 서버 SQL 의 키와 Pi 에 넣으라는 키가 다르면 — 그 장치의 이벤트가 전부 거부된다.
* 사람이 쓰러져도 서버까지 오지 않는데, 대시보드는 device_fault 하나만 보여준다.
* 해시가 앱의 검증과 안 맞으면 — 운영자가 대시보드에서 잠긴다.
*
* 둘 다 "실행했고 SQL 도 돌았는데 안 된다" 로 나타나므로, 출력 그대로를 검사한다.
*/
final class RotateCredentialsTest extends TestCase
{
private const TOOL = __DIR__ . '/../tools/rotate_credentials.php';
/** @return array{0:int,1:string,2:string} [exit, stdout, stderr] */
private function tool(array $args, string $stdin = ''): array
{
$cmd = array_merge([PHP_BINARY, self::TOOL], $args);
$spec = [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$p = proc_open($cmd, $spec, $pipes);
$this->assertIsResource($p, 'rotate_credentials.php 를 실행하지 못했습니다');
fwrite($pipes[0], $stdin);
fclose($pipes[0]);
$out = (string)stream_get_contents($pipes[1]);
$err = (string)stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($p), $out, $err];
}
public function test_device_rotation_puts_the_same_key_on_both_sides(): void
{
[$code, $out] = $this->tool(['--device=pi-01']);
$this->assertSame(0, $code);
$this->assertSame(1, preg_match(
"/UPDATE move_devices SET secret = '([0-9a-f]{64})' WHERE device_id = 'pi-01';/", $out, $sql));
$this->assertSame(1, preg_match('/DEVICE_SECRET=([0-9a-f]{64})\|/', $out, $env));
$this->assertSame($sql[1], $env[1],
'서버에 넣는 키와 Pi 에 넣는 키가 다릅니다 — 그 장치의 이벤트는 전부 거부됩니다.');
}
public function test_each_run_mints_a_fresh_key(): void
{
[, $a] = $this->tool(['--device=pi-01']);
[, $b] = $this->tool(['--device=pi-01']);
$this->assertNotSame($a, $b, '회전인데 같은 값이 나오면 회전이 아닙니다.');
}
public function test_device_id_must_be_an_identifier(): void
{
// device_id 는 운영자가 타이핑하는 값이고 SQL 문자열로 들어간다.
foreach (["pi-01'; DROP TABLE move_devices; --", "pi 01", str_repeat('a', 65), ""] as $bad) {
[$code, $out] = $this->tool(["--device={$bad}"]);
$this->assertNotSame(0, $code, "거부해야 할 device_id 를 통과시켰습니다: {$bad}");
$this->assertStringNotContainsString('UPDATE', $out);
}
}
public function test_admin_hash_is_what_the_app_will_verify(): void
{
$plain = 'correct-horse-battery-staple';
[$code, $out] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n");
$this->assertSame(0, $code);
$this->assertSame(1, preg_match(
"/SET password_hash = '([^']+)', failed_attempts = 0, locked_until = 0/", $out, $m));
$this->assertTrue(Password::verify($plain, $m[1]),
'앱이 검증하지 못하는 해시입니다 — 운영자가 대시보드에서 잠깁니다.');
// 잠금 해제까지 같이 하지 않으면, 비번을 잊어 잠긴 계정은 새 비번으로도 못 들어온다.
$this->assertStringContainsString('failed_attempts = 0', $out);
}
public function test_the_plaintext_password_is_never_printed(): void
{
$plain = 'correct-horse-battery-staple';
[, $out, $err] = $this->tool(['--admin=admin'], "{$plain}\n{$plain}\n");
$this->assertStringNotContainsString($plain, $out);
$this->assertStringNotContainsString($plain, $err);
}
public function test_mismatch_and_short_passwords_are_refused(): void
{
foreach ([["aaaaaaaaaaaa\nbbbbbbbbbbbb\n", '다릅니다'], ["short\nshort\n", '미만']] as [$in, $why]) {
[$code, $out, $err] = $this->tool(['--admin=admin'], $in);
$this->assertNotSame(0, $code, "거부해야 할 입력을 통과시켰습니다: {$why}");
$this->assertStringNotContainsString('UPDATE', $out);
$this->assertStringContainsString($why, $err);
}
}
public function test_it_refuses_to_guess_which_credential_to_rotate(): void
{
foreach ([[], ['--device=pi-01', '--admin=admin']] as $args) {
[$code, $out] = $this->tool($args);
$this->assertNotSame(0, $code);
$this->assertStringNotContainsString('UPDATE', $out);
}
}
}

View file

@ -0,0 +1,157 @@
<?php
/**
* 자격증명 회전 — 붙여넣을 SQL 을 만들어 준다. DB 에 직접 쓰지 않는다.
*
* php tools/rotate_credentials.php --device=pi-01
* php tools/rotate_credentials.php --admin=admin
*
* DB 에 연결하지 않는 이유: 회전이 필요한 순간은 카페24 공유호스팅이고, 운영자가 실제로 쓰는
* 창구는 phpMyAdmin 이다. 접속 정보 없이 어느 머신에서나 돌려서 SQL 만 받아 붙여넣는 편이
* 폐쇄망·공유호스팅 양쪽에서 확실하다. 손으로 적던 절차(secrets/PROVISION_SECRETS.md "회전
* 방법")를 명령 하나로 바꾸는 것이 이 도구의 전부다.
*
* 비밀번호는 argv 로 받지 않는다 — 명령행은 `ps` 와 셸 히스토리에 남는다. STDIN 으로만 받고,
* 받은 평문은 해시 외에 어디에도 출력하지 않는다.
*/
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
use App\Security\Password;
const MIN_PASSWORD_LEN = 12; // 대시보드는 인터넷에 노출된다. 짧은 비번은 회전의 의미가 없다.
const DEVICE_SECRET_BYTES = 32;
$args = tool_args($argv);
$device = $args['device'] ?? null;
$admin = $args['admin'] ?? null;
if (($device === null) === ($admin === null)) {
fwrite(STDERR, <<<TXT
사용법: --device=<device_id> | --admin=<username> (둘 중 하나)
--device=pi-01 장치 HMAC 키를 새로 만들고, 서버 UPDATE 문과 Pi 쪽 절차를 출력
--admin=admin 새 비밀번호를 STDIN 으로 받아 Argon2id 해시와 UPDATE 문을 출력
출력된 SQL 을 phpMyAdmin 에서 실행하십시오. 이 도구는 DB 를 건드리지 않습니다.
TXT);
exit(2);
}
/** MySQL 문자열 리터럴. 여기서 만드는 값은 hex/해시뿐이지만, 식별자는 운영자 입력이다. */
function sql_quote(string $s): string
{
return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $s) . "'";
}
if ($device !== null) {
if (!is_string($device) || !preg_match('/^[A-Za-z0-9._-]{1,64}$/', $device)) {
fwrite(STDERR, "device_id 형식이 올바르지 않습니다(영숫자 . _ - , 64자 이내): {$device}\n");
exit(1);
}
$secret = bin2hex(random_bytes(DEVICE_SECRET_BYTES));
$secret_q = sql_quote($secret);
$q = sql_quote($device);
echo <<<TXT
=== 1) 서버 (phpMyAdmin 에서 실행) ===
UPDATE move_devices SET secret = {$secret_q} WHERE device_id = {$q};
=== 2) Pi (SSH) ===
sudo sed -i 's|^DEVICE_SECRET=.*|DEVICE_SECRET={$secret}|' /etc/move-detect/edge.env
sudo systemctl restart move-detect-edge
=== 3) 확인 ===
sudo journalctl -u move-detect-edge -n 30 --no-pager
401/403 이 없고 하트비트가 올라오면 성공. 서버만 바꾸고 Pi 를 안 바꾸면 그 장치의
이벤트는 전부 거부되고, 대시보드에는 device_fault 로 나타난다 — 둘을 같이 하십시오.
TXT;
exit(0);
}
// --- admin 비밀번호 ---
// 해시는 App\Security\Password 를 그대로 쓴다 — 여기서 password_hash 를 다시 부르면 앱이
// 알고리즘·옵션을 바꾼 날 조용히 갈라진다. config.php 는 필요 없으므로(DB 미접속) tool_boot
// 대신 오토로더만 올린다. tools/ 는 저장소와 납품 패키지 양쪽에서 vendor/ 와 형제다.
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
if (!is_file($autoload)) {
fwrite(STDERR, "vendor/autoload.php 를 찾지 못했습니다: {$autoload}\n");
exit(1);
}
require_once $autoload;
if (!defined('PASSWORD_ARGON2ID') || !in_array('argon2id', password_algos(), true)) {
fwrite(STDERR, "이 PHP 에는 Argon2id 가 없습니다. 납품 대상과 같은 PHP 에서 실행하십시오"
. " (server/deploy/preflight.php 가 같은 것을 검사합니다).\n");
exit(1);
}
if (!is_string($admin) || $admin === '' || strlen($admin) > 190) {
fwrite(STDERR, "username 이 비었거나 너무 깁니다.\n");
exit(1);
}
fwrite(STDERR, "새 비밀번호 (화면에 표시되지 않음, " . MIN_PASSWORD_LEN . "자 이상): ");
$plain = read_secret_line();
fwrite(STDERR, "\n한 번 더: ");
$again = read_secret_line();
fwrite(STDERR, "\n");
if ($plain !== $again) {
fwrite(STDERR, "두 입력이 다릅니다. 중단합니다.\n");
exit(1);
}
if (strlen($plain) < MIN_PASSWORD_LEN) {
fwrite(STDERR, "비밀번호가 " . MIN_PASSWORD_LEN . "자 미만입니다. 중단합니다.\n");
exit(1);
}
$hash = Password::hash($plain);
$plain = $again = ''; // 이 프로세스가 더 들고 있을 이유가 없다
$q = sql_quote($admin);
$h = sql_quote($hash);
echo <<<TXT
=== phpMyAdmin 에서 실행 ===
UPDATE move_users SET password_hash = {$h}, failed_attempts = 0, locked_until = 0
WHERE username = {$q};
실행 후 "영향받은 행: 1" 인지 확인하십시오. 0 이면 username 이 틀린 것이고, 비밀번호는
바뀌지 않은 채 바뀐 줄 알게 됩니다. 바로 로그아웃 후 새 비밀번호로 로그인해 확인하십시오.
TXT;
exit(0);
/**
* 한 줄 읽는다. 터미널이면 에코를 꺼서 화면에 안 남긴다.
*
* 파이프로 넣은 경우(테스트·스크립트)는 애초에 보여줄 화면이 없으므로 stty 를 부르지 않는다 —
* Windows 에서 stty 를 부르면 셸이 못 찾았다는 잡음만 STDOUT 으로 새어 나온다. 터미널인데도
* 에코를 못 끈 경우에만 경고한다(숨김 실패 ≠ 입력 실패).
*/
function read_secret_line(): string
{
$tty = function_exists('stream_isatty') && @stream_isatty(STDIN)
&& strncasecmp(PHP_OS_FAMILY, 'Windows', 7) !== 0;
$hidden = false;
if ($tty) {
$hidden = trim((string)@shell_exec('stty -echo 2>/dev/null && echo ok')) === 'ok';
if (!$hidden) {
fwrite(STDERR, "\n (경고: 이 터미널에서는 입력을 숨길 수 없었습니다. 화면과 히스토리를 확인하십시오)\n");
}
}
$line = fgets(STDIN);
if ($hidden) {
@shell_exec('stty echo 2>/dev/null');
}
if ($line === false) {
fwrite(STDERR, "입력을 읽지 못했습니다.\n");
exit(1);
}
return rtrim($line, "\r\n");
}

View file

@ -26,3 +26,22 @@ def test_real_movenet_runs():
assert isinstance(persons, list) assert isinstance(persons, list)
for person in persons: for person in persons:
assert len(person.keypoints) == 17 assert len(person.keypoints) == 17
@pytest.mark.skipif(not os.path.exists(MODEL), reason="MoveNet model not bundled")
def test_model_signature_is_what_the_adapter_feeds_and_reads():
"""모델을 갈아끼웠을 때 조용히 틀리는 경우를 막는다.
레이아웃이 다른 변환본(NCHW·float32·[1,17,3])도 onnxruntime 에는 그냥 로드된다. 그러면
어댑터는 예외 없이 돌면서 엉뚱한 좌표를 내놓고, 낙상 판정은 "동작하는데 안 맞는" 상태가
된다. 파일이 바뀌었는지가 아니라 **계약이 맞는지**를 본다.
"""
ort = pytest.importorskip("onnxruntime")
session = ort.InferenceSession(MODEL, providers=["CPUExecutionProvider"])
inp = session.get_inputs()[0]
assert inp.shape == [1, 192, 192, 3], f"어댑터는 192x192 NHWC 를 넣는다: {inp.shape}"
assert inp.type == "tensor(int32)", f"어댑터는 int32 로 넣는다: {inp.type}"
out = session.get_outputs()[0]
assert out.shape == [1, 1, 17, 3], f"어댑터는 [1,1,17,3] (y,x,score) 를 읽는다: {out.shape}"