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
157 lines
6.1 KiB
PHP
157 lines
6.1 KiB
PHP
<?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");
|
|
}
|