Compare commits

..
Author SHA1 Message Date
zzabisagent-lab 6bfde82c5e [verified] fix: show HAL risk warning only on action 2026-10-28 16:50:12 +00:00
oz 0a56d4612c [verified] feat: add Webmaker visual editor v0.2.0 2026-10-28 15:07:35 +00:00
oz 4b3714e37c [verified] docs: add v0.1.4 HAL AI development order 2026-10-27 16:16:09 +00:00
HeraandClaude Fable 5.1 12994279a5 운영자 콘솔 부트스트랩 배너 결함 수정 bear hal21 배포 검증 기록: docs/verification/hal21-ops-banner-fix-2026-10-27.md(원인·RED/GREEN·빌드·백업 sha256·stage 검증·current 전환·config/vault 보존·readback·배포 코드 서버 렌더·외부 회귀·되돌리기), VERSION_HISTORY 결과
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:40:06 +00:00
HeraandClaude Fable 5.1 648247f224 운영자 콘솔 부트스트랩 배너 결함 수정: hal/ui.mjs 가 core.selfHealth().bootstrap 의 camelCase 계약(complete·halRunningVersion·firstAgent) 대신 없는 snake_case 열을 읽어 완료 뒤에도 "부트스트랩 미완료(확인 전·미등록)"를 그리던 문제 — 배너·/ops/releases(running=bootstrapState 행) 2곳 수정 + 회귀 테스트 1건(RED→GREEN), VERSION_HISTORY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 14:31:59 +00:00
zzabisagent-labandClaude Fable 5.1 d8e7beeb86 포털·hostinger Node Agent 제어 평면 이전(hal.ariweb.net → hal21.ariweb.net) 실제 적용 기록: docs/verification/portal-hal21-link-2026-10-27.md(리비전·preflight·백업 sha256·link-portal 파이프·HAL21 작업 배포·readback·전후 집계·blocker), RUNBOOK §10, VERSION_HISTORY
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 09:43:36 +00:00
zzabisagent-labandClaude Fable 5.1 dfb708e933 HAL CLI(시스템 신원) 보완: config-set(운영 값 기록·비밀 거부·감사) / module-deploy(운영자 API 와 같은 멱등 규칙의 module.deploy 작업 큐 투입, 실행은 서비스 프로세스) / job(작업·자식 readback) + 테스트 3건
운영자 콘솔 자격 증명이 없는 연동 단계(포털·Node Agent 제어 평면을 hal.ariweb.net → hal21.ariweb.net 으로 옮기는 작업)에서 HAL 데이터 소유자가 감사 기록(actor cli:<by>, reason)을 남기며 쓰는 명령. 직접 SQL 없이 기존 core/jobs 경로만 사용한다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 09:27:23 +00:00
oz 0b9c70e90c docs: verify Webmaker HAL21 control-plane link 2026-10-27 09:06:25 +00:00
oz 12958ab695 feat(hal): inventory existing Webmaker sites 2026-10-27 08:57:21 +00:00
zzabisagent-labandClaude Fable 5.1 4bd698452b 메일 서비스 ↔ HAL(hal21) 연동 실제 적용 기록: docs/verification/mail-hal-link-2026-10-27.md(호스트·인스턴스·백업 sha256·dry-run/apply/readback·Agent heartbeat·회귀·blocker), VERSION_HISTORY 결과 요약
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:35:12 +00:00
zzabisagent-labandClaude Fable 5.1 79b45ca961 기존 AriMail 인스턴스 ↔ HAL 관리형 MailService/Node 가져오기: hal/mailsvc.mjs(등록부·dry-run·트랜잭션 적용·되돌리기·heartbeat 동기화) + scripts/mail-discover.mjs(읽기 전용 discovery) + CLI/운영자 API/화면, 테스트 9건, RUNBOOK §9
- mail_services(도메인 단위 논리 리소스, 불변 id, 출처 권한, 도메인별 active owner 1 — 부분 유일 인덱스)·mail_imports 표 추가(기존 표 변경 없음)
- discovery 문서에 비밀값이 있으면 거부(참조만 저장), 충돌·중복·누락은 전체 거부, 적용은 트랜잭션 + 감사(immutable), 재적용 멱등, 적용 단위 되돌리기
- hal/index.mjs: mail-import · mail-import-rollback · mail-services · server-token(시스템 신원 토큰, 감사) / app.mjs: /ops/api/mail-services* / ui: /ops/mail HAL 관리형 표 + 포털 중계(#portal-mail)
- core.heartbeat → syncFromHeartbeat(Agent 의 arimail 모듈 상태를 last_sync·health 로 반영, 행 없으면 무관)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:25:05 +00:00
zzabisagent-labandClaude Fable 5.1 03cb9c0777 운영 Webmaker ↔ HAL 연동 점검 도구와 dry-run 기록: scripts/webmaker-inventory.mjs(읽기 전용 inventory·안정 식별자 대조·충돌/누락 판정·비밀 차단) + 테스트 5건, RUNBOOK §8, 검증 기록
- hostinger webmaker.ariweb.net 실데이터 결과: 프로젝트 10(연결 일치 3·고유 7), 회원 12(SSO 3·고유 9), Gitea refs 13, HAL 모듈 0.1.1 ok — 충돌 0·누락 0, 적용 변경 없음(교체 불필요)
- 사전 스냅샷·복원 검증·되돌리기 절차·사후 readback 은 docs/verification/webmaker-hal-link-2026-10-27.md (비밀값 없음)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:06:37 +00:00
oz ac072e6f12 Redirect HAL root to operator console 2026-10-27 04:42:45 +00:00
zzabisagent-labandClaude Fable 5.1 83e3604b24 bear hal21.ariweb.net HAL 전용 부트스트랩 기록: RUNBOOK §7(절차·검증·되돌리기), VERSION_HISTORY, 로컬 E2E 보고서 갱신(빌드 ab75b7d)
- docs/RUNBOOK-0.1.1.md §7: 호스트별 certbot(webroot) 인증서, --acme /var/www/html --portal-host none 부트스트랩, 검증 결과, 되돌리기
- VERSION_HISTORY.md: 배포 artifact(main ab75b7d, hal sha256 f574d875…)와 5단계 거짓 경고 결함 기록

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 03:12:18 +00:00
zzabisagent-labandClaude Fable 5.1 ab75b7db4f bootstrap-hal.sh --portal-host none: HAL 전용 호스트(bear hal21.ariweb.net)에서 포털 nginx 접근 경로를 만들지 않음, 로컬 E2E 보고서(2026-10-27) 추가
- deploy/bootstrap-hal.sh: --portal-host none 이면 my.ariweb.net vhost 를 만들지 않는다(기본 동작 유지)
- docs/RUNBOOK-0.1.1.md, VERSION_HISTORY.md: HAL 전용 호스트 부트스트랩 옵션과 호스트별 certbot(webroot) 인증서 경로

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 03:04:43 +00:00
zzabisagent-labandClaude Fable 5.1 d1eefaa15f 실제 환경 배포·검증 기록(0.1.1): ariweb.net 호스트 배포 완료, 34개 흐름 실환경 판정(실제 7·부분 7·미수행 20), RUNBOOK 운영 사실, 로컬 E2E 보고서 갱신
- docs/verification/real-env-0.1.1.md/.json: 배포 경과·결함 수정 요약, 흐름별 단계·증거, 재검증(systemd/nginx/HAL·포털 건강/배포 영수증/모듈 버전) 기록
- docs/RUNBOOK-0.1.1.md: 서버 한 대 운영, 메일 서버 등록 값, 발신 메일, 같은 릴리스 재등록 절차
- VERSION_HISTORY.md 알려진 제한에 실환경 검증 결과 기입. 출시 완료는 선언하지 않음

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 14:35:04 +00:00
zzabisagent-labandClaude Fable 5.1 3951be5836 서버 한 대(control) 운영의 web·backup 역할 대체, 초안 사이트 백업 중지 사유 구분, 알려진 제한 갱신
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 14:27:24 +00:00
zzabisagent-labandClaude Fable 5.1 65f8102b5f 포털 AriMail 설정 Host 시도 되돌림: Node fetch 는 Host 를 바꿀 수 없어 setup_url 이 기본 도메인 이름이어야 함을 문서화
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 14:19:09 +00:00
zzabisagent-labandClaude Fable 5.1 5e74a5a938 agent/index.mjs: 주석이 같은 줄의 코드를 가리던 구문 오류 수정
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 14:13:29 +00:00
zzabisagent-labandClaude Fable 5.1 23189f4dfb 실제 환경 배포 결함 수정 4: AriMail 설정 API Host(setup_host), Agent heartbeat 의 REVISION 버전 보고, 새 유닛 enable
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 14:12:28 +00:00
zzabisagent-labandClaude Fable 5.1 0862f6fae9 실제 환경 배포 결함 수정 3: EnvironmentFile 줄 단위 파싱(공백 값), 사후 절차 실패 시 current 되돌리기, 보존 디렉터리 이름 유일화
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 14:03:43 +00:00
zzabisagent-labandClaude Fable 5.1 3641a703db 실제 환경 배포 결함 수정 2: artifact 모드 정규화, 실행 계정 모드 artifact 읽기 권한·current 경로, 같은 버전 재설치 보존, Webmaker 백업 관대 처리, 부트스트랩 릴리스 기록 행 생성
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:56:42 +00:00
zzabisagent-labandClaude Fable 5.1 7f307602a3 release.mjs: portal artifact 에 catalog 디렉터리 포함(실서버 상품정책 시드 누락 수정)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:45:42 +00:00
zzabisagent-labandClaude Fable 5.1 3b2bb88a81 bootstrap-hal.sh: root 전용 인증서 디렉터리를 sudo 로 확인(실제 부트스트랩 중단 수정), 로컬 E2E 보고서 갱신
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:43:51 +00:00
zzabisagent-labandClaude Fable 5.1 00c42abca1 실제 환경 배포 결함 수정: Agent 실행 계정 모듈 설치(module-as-user.sh), AriMail 마이그레이션 DB 경로, 도입 모듈 REVISION 버전, 기존 유닛 보존, 포털 nginx 접근 경로
- Agent: 유닛 User 가 Agent 계정과 다르면 deploy/module-as-user.sh 로 그 계정이 풀기·전환·DB 백업·마이그레이션 수행(sudoers 최소 규칙)
- AriMail 마이그레이션에 유닛 EnvironmentFile(DB_FILE) 적용
- 도입 모듈 실제 버전은 REVISION.json 의 Suite 버전, 상류 버전은 upstreamVersion
- Webmaker: keepExistingUnit 으로 운영 유닛의 강화 지시문 보존
- bootstrap-hal.sh: my.ariweb.net nginx 접근 경로 설치
- test/agent.test.mjs 회귀 테스트, VERSION_HISTORY·RUNBOOK 갱신, 로컬 E2E 보고서(릴리스 빌드 실행분) 반영

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:38:45 +00:00
zzabisagent-labandClaude Fable 5.1 45b591b18c 실제 환경 배포·검증 상태 기록(부트스트랩 미실행, DNS 미생성, 출시 완료 미선언)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:22:49 +00:00
zzabisagent-labandClaude Fable 5.1 91b39f47b3 Ariweb Suite 0.1.1 릴리스 병합 (release/0.1.1 → main)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:18:48 +00:00
zzabisagent-labandClaude Fable 5.1 f7852e1fdf VERSION_HISTORY: 로컬 E2E 검증 결과를 알려진 제한에 기입
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:18:48 +00:00
zzabisagent-labandClaude Fable 5.1 acb69c7650 Agent 모듈 설치 경로의 루트 재정의 반영, E2E 흐름 30 판정 로직 수정, 검증 기록 갱신
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:18:20 +00:00
zzabisagent-labandClaude Fable 5.1 98f4182bbb E2E 결함 수정: Agent 절대 번들 URL, HAL 작업 재사용(실패 숨김 방지), 도메인 탐침 재개 키, 작업 엔진 동시 실행(부모/자식 교착 해소), 외부 Agent 자리표시자, JobError 4xx, 포털 HAL 이벤트·health 수신, link-portal 공유 비밀
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:13:39 +00:00
zzabisagent-labandClaude Fable 5.1 7662d06009 로컬 E2E 검증 하네스(실제 HAL·포털·Agent 4대 + 가짜 Webmaker·AriMail·메일)와 34개 완료 조건 흐름 기록
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 13:10:31 +00:00
zzabisagent-labandClaude Fable 5.1 1049f7b95c VERSION_HISTORY: 운영자 화면·모니터링 절 기입
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:16:07 +00:00
zzabisagent-labandClaude Fable 5.1 005e0944e4 HAL 운영자 콘솔 화면(운영 현황·고객·주문·상품·사이트·메일·도메인·서버·백업·이전·릴리스·작업·장애·감사·플랫폼 복구)과 포털 고객 지원 조회·대신 변경·일시 접근 (§25~§34)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:15:48 +00:00
zzabisagent-labandClaude Fable 5.1 709d72338e 0.1.1 배포·검증 RUNBOOK
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:07:06 +00:00
zzabisagent-labandClaude Fable 5.1 deae7e31e0 검증 Catalog 의 백업·이전·보존 정책을 생명주기 영역 정책 읽기와 연결, VERSION_HISTORY 초안
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:06:32 +00:00
zzabisagent-labandClaude Fable 5.1 68027b7bde 도입 소스 manifest 갱신(수정 파일 표시)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:02:43 +00:00
zzabisagent-labandClaude Fable 5.1 0df0173eca HAL 스키마: 임대 호스트·PID 마이그레이션 구문 수정
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:02:33 +00:00
zzabisagent-labandClaude Fable 5.1 ce6a98d534 HAL: 같은 호스트의 죽은 인스턴스 임대 이어받기(systemd 재시작), 부트스트랩이 HAL CLI 로 릴리스 등록
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 12:02:07 +00:00
zzabisagent-labandClaude Fable 5.1 6b5a014041 HAL 제어 평면(릴리스·서버·Agent·작업·모듈 배포·운영자 계정·긴급 복구·재해복구·공개 상태), Node Agent, 배포 스크립트·유닛·nginx 템플릿, 릴리스 도구, 포털 HAL 클라이언트 (§28·§29·§32~§34)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:58:15 +00:00
zzabisagent-labandClaude Fable 5.1 403d0fb845 영역 통합 연결: 공통 화면 틀 우선 사용, AI Wallet 계정명 규칙, 상품정책 조회 이름, 주기 정리 작업 등록
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:57:53 +00:00
zzabisagent-labandClaude Fable 5.1 b1c06a0304 사이트 목록·생성·상세, 관리형/외부 배포, 배포 방식 변경, 도메인·HTTPS, Source 옵션과 Webmaker 도입 수정(SSO, 프로젝트↔사이트 구분, 삭제 영향 확인, 동시 편집 충돌, 발행 경로) (§7~§12·§0.2)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:56:38 +00:00
zzabisagent-labandClaude Fable 5.1 a1cde81332 메일 서비스(사이트 대표·독립)·메일함·사용량·보안·전환·보존과 AriMail 도입 수정(KST 월 경계, 발신 키 오류, 도메인별 보장량, 100% 제한, 발송 큐·속도, 운영 상태, SSO) (§15~§18·§0.4)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:47:45 +00:00
zzabisagent-labandClaude Fable 5.1 a014e3cd05 관리형 사이트 백업·복원, 서버 이전, 소유권 이전, 중지·해지·보존 생명주기와 운영 사이트 수 반환 (§14·§19~§23)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:43:21 +00:00
zzabisagent-labandClaude Fable 5.1 c397452acd 상품·주문·수동 결제 승인·권리·청구·축소·해지·AI Wallet 표시·사용량 배정 화면·경고 엔진 (§6·§13·§27)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:26:35 +00:00
zzabisagent-labandClaude Fable 5.1 c90ac27596 포털 인증·계정·사용자 그룹·대시보드·알림 센터·감사 화면·도움말·발송기·스케줄러 (§3·§4·§5·§24)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:26:28 +00:00
zzabisagent-labandClaude Fable 5.1 0f11424224 README 와 VERSION_HISTORY 뼈대
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:12:14 +00:00
zzabisagent-labandClaude Fable 5.1 5cf7799b51 서비스 간 인증 라이브러리와 포털↔HAL 작업 계약 문서
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:05:43 +00:00
zzabisagent-labandClaude Fable 5.1 8607f19e62 포털 중심 데이터 모델(core)과 영역 테스트 도우미
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:04:32 +00:00
zzabisagent-labandClaude Fable 5.1 c7aea8d419 Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-26 11:02:58 +00:00
516 changed files with 120627 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules/
data/
*.sqlite
*.sqlite-*
*.log
*.tmp
.DS_Store
/dist/
/modules/webmaker/data/
/modules/arimail/data/
/test/.tmp/
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
# Ariweb Suite 개발 지시 — 작성자: Neo · v0.2.0
**작성자:** Neo
**작성일:** 2026-10-28
**이전 버전:** v0.1.1(최초 통합 개발 지시, Hera) · v0.1.4(미반영 오류 수정·HAL AI 대시보드·다중 AI 공동 작업창 지시, Neo)
**이번 개정:** 도입 모듈 Webmaker 에 **직접 편집(시각 편집기)** 사용자 기능을 더한다. 새 사용자 기능이므로 가운데 버전 번호를 하나 올린다 — Suite 0.1.x → **0.2.0**, Webmaker 1.7.3 → **1.8.0**. v0.1.1 의 모든 기준과 v0.1.4 의 지시는 그대로 유효하며, 이 문서는 추가 요구만 적는다(v0.1.4 항목은 이번 구현 범위가 아니다).
## 개정 이력
- **v0.1.1 · Hera · 2026-10-26:** 최초 통합 개발 지시, Webmaker·AriMail 재사용 원칙, 사용량 경고 70·80·90·95%.
- **v0.1.4 · Neo · 2026-10-27:** patch 자동 증가 규칙, 미반영 오류 수정 전수 반영, HAL AI 대시보드, 다중 AI 공동 작업창.
- **v0.2.0 · Neo · 2026-10-28:** Webmaker 직접 편집(끌어다 놓기 시각 편집기) — Suite `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영. 엄격한 TDD, 격리 작업 트리, 커밋·push·병합·배포는 Neo 가 별도 검토 뒤 수행.
## 1. 사용자 요구(약화 금지)
Webmaker 가 초안을 만들어 보이는 페이지가 생긴 뒤, **직접 시각 편집 모드**를 더한다. Wix·아임웹 방식의 끌어다 놓기를 적용한다. 변경은 Suite 의 `modules/webmaker` 와 기존 Webmaker 저장소 **양쪽**에 반영한다.
## 2. 필수 동작(계약)
1. 오른쪽 미리보기 도구 줄의 분명한 **직접 편집 전환**. 사이트와 HTML 페이지가 골라져 있을 때만 켜진다. 기존 소스 편집기는 유지한다.
2. 시각 모드: 캔버스에서 보이는 글을 바로 편집, 보이는 요소 선택, 기존 블록 끌어 순서 바꾸기, 팔레트 구성 요소를 올바른 자리에 끌어다 놓기. 구성 요소 최소: 섹션/컨테이너·제목·문단·버튼/링크·이미지·구분선·여백.
3. 선택 요소 조작: 이동·복제·삭제·요소에 맞는 기본 속성(글·링크 주소·이미지 주소/대체 글·정렬·색·배경·여백). Wix 전체 기능을 흉내 내지 않는 일관되고 안전한 첫 릴리스.
4. 저장 상태(저장되지 않음/저장 중/저장됨/오류) 표시, 저장, 실행 취소/다시 실행, 버리기/다시 불러오기. Ctrl/Cmd+S 저장. 페이지·사이트 전환·화면 이동·창 닫기 전 저장하지 않은 작업 보호. 저장은 실제 선택 HTML 파일을 갱신하고 기존 Webmaker git/버전 이력에 남기며 미리보기·소스 편집기를 갱신하고 상대 경로 자원이 계속 동작한다. **직접 편집은 AI 캐시를 쓰지 않고 AI 수정으로 세지 않는다.**
5. 시각 모드에서 **페이지 스크립트는 실행되지 않는다**. 캔버스는 빌더 쿠키·저장소·API 와 격리된다. 보낸 창·출처·세션을 검사하는 **한정된 postMessage 규약**만 쓰고, 임의 메시지나 HTML 속성을 권한으로 믿지 않는다. 저장 전에 편집기 표식을 제거하고 원본 doctype/head/styles/scripts 를 보존하며 편집하지 않은 코드를 조용히 지우지 않는다.
6. 거절(한국어 안내): HTML 아닌 파일, 너무 큰 내용, 경로 탈출, 다른 사용자 사이트, 진행 중 AI 작업, 오래된 동시 저장, 잘못된 payload, `javascript:`/`data:text/html` 링크, 시각 구성 요소로 들어오는 인라인 이벤트 핸들러, 금지 요소/주소. 임의 HTML 실행 API 를 만들지 않는다.
7. 기존 테넌트 격리·CSRF/출처 검사·sandbox·소스 편집기·모바일 미리보기·버전/복원·배포·가져오기·다국어·모듈·SEO·Site Brief 동작 보존. Suite 의 저장본 `rev` 동시 편집 충돌(§9.3)도 직접 편집 저장에 적용한다.
8. 반응형: 팔레트·속성 창이 캔버스를 가리지 않음, 키보드 사용자용 포커스 가능한 조작과 보이는 선택 표시, 끌어다 놓기의 버튼/키보드 대안.
9. 새 모듈·눈에 띄지 않는 함수에 한국어 상세 주석.
## 3. TDD·검증 요구
- 조각마다 실패하는 테스트를 먼저 쓰고 실행해 RED 를 확인한 뒤 최소 구현으로 GREEN. RED→GREEN 증거를 두 저장소의 `docs/verification/` 에 실제 출력으로 남긴다(조작 금지).
- 권한, CSRF, 허용 페이지, 낙관적 동시성/오래된 저장 거절, 안전한 정화/규약, 스크립트 비실행 계약, 저장이 버전 이력을 만드는 것, 실행 취소/다시 실행 핵심, 끌어 옮기기/구성 요소 삽입 핵심, 저장 상태/이동 보호, 필수 접근성 조작을 모두 다룬다. 편집 문서·규약·상태 로직은 순수 모듈로 뽑아 Node 테스트가 동작을 검사하게 한다. 큰 프레임워크·런타임 의존성을 더하지 않는다.
- 두 저장소에서 집중 테스트 뒤 전체 묶음을 실행한다.
## 4. 버전·이력·출처 요구
- Webmaker: package/runtime/릴리스 표기 1.8.0, `CHANGELOG.md`·`ERRORS.md`·`README.md`·`Dev_Order_v1.8.0.md`.
- Suite: 루트 package/runtime/릴리스 표기 0.2.0. `modules/webmaker` 의 도입 버전/upstream 메타데이터는 Webmaker 1.8.0 을 나타내되 Suite 전용 연결 변경을 잃지 않는다. `VERSION_HISTORY.md` 에 작성자 Neo·이전 버전·재사용 함수와 수정 함수·고객/운영자 화면·직접 편집·끌어다 놓기·저장·버전·보안 동작·도입 소스 출처(원본 저장소·불변 기준 커밋·원본/대상 경로·수정 여부·라이선스 검토)·테스트·알려진 제한·미확정 정책값. 릴리스/카탈로그/manifest 는 도구가 요구하는 범위에서 일관되게(`IMPORTED_SOURCES`, `scripts/release.mjs` 는 package.json 버전을 따른다).
- 비밀번호·키·토큰·개인 정보·비밀값은 어디에도 넣지 않는다.
## 5. 저장소 규율
- 독립 Webmaker 와 Suite `modules/webmaker` 의 모든 차이를 확인하고 보존한다. Suite 전용 HAL/포털/연동 변경을 upstream 파일로 통째로 덮어쓰지 않는다.
- 지정된 두 작업 트리 밖(dirty main 체크아웃·`Ariweb-suite-mail-hal`·Hera 세션/작업 트리)은 건드리지 않는다.
- 커밋·push·병합·배포·운영 접근을 하지 않는다. Neo 가 독립 검토 뒤 수행한다.
## 6. 완료 조건(이번 구현의 인도 기준)
두 저장소의 변경 파일 목록, RED·GREEN 명령과 결과, 전체 묶음 결과, 버전, 보안 결정, 알려진 제한, 막힌 항목을 보고한다. 구현 결과와 증거: Webmaker `CHANGELOG.md` 1.8.0 절·`docs/verification/visual-editor-1.8.0-tdd.md`, Suite `VERSION_HISTORY.md` 0.2.0 절·`docs/verification/webmaker-visual-editor-0.2.0.md`.
+44
View File
@@ -0,0 +1,44 @@
# Ariweb Suite 개발 지시 — 작성자: Neo · v0.2.1
**작성자:** Neo
**작성일:** 2026-10-29
**이전 버전:** v0.2.0(Webmaker 직접 편집 — 시각 편집기, Neo, 2026-10-28) · v0.1.4(미반영 오류 수정·HAL AI 대시보드·다중 AI 공동 작업창 지시, Neo) · v0.1.1(최초 통합 개발 지시, Hera)
**이번 개정:** HAL 운영자 콘솔의 **고위험 재인증 안내 위치 수정**. 모든 일반 화면에 늘 떠 있던 전역 배너를 없애고, 그 안내는 backend 가 고위험 요청을 실제로 거부한 바로 그 때 여는 재인증 창 안에만 둔다. 사용자 기능 추가가 아닌 운영자 화면 수정이므로 끝 버전 번호만 올린다 — Suite 0.2.0 → **0.2.1**. v0.1.1·v0.1.4·v0.2.0 의 모든 기준과 지시는 그대로 유효하며, 이 문서는 추가 요구만 적는다(v0.1.4 항목은 이번 구현 범위가 아니다).
## 개정 이력
- **v0.1.1 · Hera · 2026-10-26:** 최초 통합 개발 지시, Webmaker·AriMail 재사용 원칙, 사용량 경고 70·80·90·95%.
- **v0.1.4 · Neo · 2026-10-27:** patch 자동 증가 규칙, 미반영 오류 수정 전수 반영, HAL AI 대시보드, 다중 AI 공동 작업창.
- **v0.2.0 · Neo · 2026-10-28:** Webmaker 직접 편집(끌어다 놓기 시각 편집기) — Suite `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영.
- **v0.2.1 · Neo · 2026-10-29:** HAL 운영자 콘솔 고위험 재인증 안내를 전역 배너에서 거부 시점 재인증 창으로 옮김. 엄격한 TDD, 격리 작업 트리(`feature/hal-risk-banner-v0.2.1`, 기준 origin/main `0a56d4612c86dddfcbe644e775980d3c5b413b2e`), 커밋·push·병합·배포는 Neo 가 별도 검토 뒤 수행.
## 1. 목표
1. HAL 운영자 콘솔의 모든 일반 화면에 항상 보이던 다음 전역 배너를 **제거**한다: `! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증`.
2. 이 안내는 backend 가 고위험 작업 요청을 `reauth_required` 로 거부하여 재인증이 필요한 **바로 그 시점**에만, 재인증 창(modal) 안에서 보인다.
3. backend 보안 경계(`requireRecent`, CSRF, 3단계 확인, 고위험 작업 분류와 거부 동작)는 한 글자도 약화하지 않는다.
4. Suite 버전을 0.2.1 로 올리고 package/runtime/release 표기와 `VERSION_HISTORY.md` 를 일관되게 갱신한다.
## 2. 사용자 요구(약화 금지)
- 운영자는 단순 화면 조회, 로그인 직후, 로그인 10분 경과, 일반 화면 이동 중에 고위험 재인증 안내를 **보지 않는다**. 안내는 실제로 고위험 작업을 시도해 서버가 거부한 순간에만 뜬다.
- 계정 화면의 **수동 계정 재인증** 단추와 동작은 유지한다. 수동 재인증 창에는 고위험 안내를 억지로 붙이지 않는다(비밀번호만 묻는다).
- 다른 전역 배너(접근권 만료 안내·HAL 자체 건강 비정상·긴급 복구 세션 진행·부트스트랩 미완료·비밀번호 변경 필요)는 그대로 둔다 — 배너 영역 자체를 없애는 것이 아니다.
## 3. 보안 불변조건
1. `hal/app.mjs` 의 `requireRecent`(최근 10분 재인증), CSRF 검사, 3단계 확인(`confirmImpact` → `confirmIdentifiers` → `confirmExecute`), 고위험 작업 분류(서버 삭제·격리·역할 변경, 모듈 되돌리기·배포·재조정, 작업 revert, 운영자 계정·접근권 발급, MFA 해제, 설정 변경, 메일 가져오기 적용·되돌리기, 릴리스 승인, 긴급 복구 선언·승인·자격, DR 설정·복원)과 403 `reauth_required` 거부 동작은 **변경 금지**.
2. 재인증 창을 **취소**하거나 재인증이 **실패**하면 원래 고위험 요청은 실행되지 않고, 원래 오류가 호출자에게 그대로 전파된다.
3. 재인증에 **성공**하면 원 요청을 기존처럼 **딱 한 번** 재시도한다. 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지). `reauth_required` 가 아닌 오류는 재인증을 묻지 않는다.
4. 판단은 전부 서버가 한다. 브라우저 쪽 변경은 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"와 안내 문구 위치에 한정한다. 재인증 API(`POST /ops/api/reauth`)의 CSRF 요구도 그대로다.
5. 비밀번호·토큰·키·개인정보를 파일·출력·commit 에 넣지 않는다.
## 4. TDD·검증
- production code 보다 먼저 최소 회귀 테스트를 쓰고 실제로 실행해 RED(예상 이유로 실패)를 확인한 뒤 최소 구현으로 GREEN. 그 다음 targeted HAL UI/auth 테스트, 마지막에 전체 Suite 테스트(`node test/run.mjs`).
- 다뤄야 할 계약: (가) 일반 화면(운영 현황·서버·릴리스·작업·복구·중계 화면·계정)에 로그인 직후·10분 경과·화면 이동 모두 전역 고위험 배너 없음, 계정 화면 수동 재인증 단추 유지·고위험 안내 없음, 다른 배너 유지; (나) backend 경계 그대로 — 10분 경과 뒤 고위험 API 403 `reauth_required`·거부된 요청 미실행·CSRF 없는 재인증 거부·틀린 비밀번호 거부·재인증 뒤 통과·3단계 확인 별도 요구; (다) 재인증 창 문구 — 거부 시점에만 고위험 안내, 수동은 없음; (라) 취소 시 원 요청 미실행·오류 전파, 성공 시 딱 한 번 재시도, 재시도 거부 시 재질문 없음, 다른 오류는 재인증 질문 없음; (마) 정적 계약 — `hal/ui.mjs` 에 배너 코드 없음, `hal/ui/public/ops.js` 가 순수 helper 를 쓰고 안내 문구를 직접 품지 않음.
- 브라우저 스크립트는 DOM 없이 돌지 않으므로, 재인증 흐름·창 문구를 **테스트 가능한 순수 helper**(`hal/ui/public/reauth.mjs`)로 분리하고 ops.js 가 그것을 쓰는지는 정적 계약으로 못 박는다. 큰 dependency 를 더하지 않는다(외부 npm 의존성 0 유지).
- 기존 test runner 가 만드는 `docs/verification/local-e2e-*` 임시 파일은 최종 diff 에 남기지 않는다(이미 커밋된 2026-10-26·27 기록은 그대로).
## 5. 범위·저장소 규율
- 수정 허용: HAL UI(`hal/ui.mjs`, `hal/ui/render.mjs`(공개 사본은 심볼릭 링크), `hal/ui/public/ops.js`, 새 `hal/ui/public/reauth.mjs`), 관련 테스트, Suite 0.2.1 버전/이력(`package.json`·`README.md`·`VERSION_HISTORY.md`·버전을 검사하는 테스트), 이 개발 지시.
- 금지: Webmaker standalone 기능, 보안 경계, 승인 artifact 불변성(0.2.0 검토 수정), 다른 모듈. commit·push·merge·release build·운영 배포 금지 — Neo 가 독립 검토 뒤 수행한다.
- 코딩은 Claude Fable 5.1 로만 수행한다. 기존 v0.2.0 문서는 덮어쓰지 않는다. 파일 이름의 `Suitle` 철자는 식별자이므로 그대로 보존한다.
## 6. 완료 조건(이번 구현의 인도 기준)
변경 파일 목록, RED 명령과 실패 이유, GREEN·targeted·전체 테스트의 실제 결과, 보안 결정, 남은 제한을 정확히 보고하고 프롬프트로 돌아온다. 구현 결과와 증거: `VERSION_HISTORY.md` 0.2.1 절(작성자·이전 버전·요구·수정한 기존 기능·운영자 화면·보안 결정·테스트·알려진 제한·미확정 정책값), 회귀 테스트 `test/hal-reauth-banner.test.mjs`.
File diff suppressed because it is too large Load Diff
+299
View File
@@ -0,0 +1,299 @@
# 도입 소스 기록(IMPORTED_SOURCES)
생성: 2026-10-28T14:53:58.554Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
## webmaker
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
- 고정 커밋(불변 기준): `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`
- 원본 버전(package.json): 1.8.0 · 브랜치: `feature/visual-editor-v1.8.0` · 작업 트리: 깨끗함
- 대상 경로: `modules/webmaker/`
- 파일 수: 184 (수정 8)
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|---|---|---|---|
| README.md | modules/webmaker/README.md | 그대로 | d28788cd6b28ec72 |
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
| deploy/install-gitea.sh | modules/webmaker/deploy/install-gitea.sh | 그대로 | 199631e74ea4f62c |
| deploy/install-services.sh | modules/webmaker/deploy/install-services.sh | 그대로 | f3b507512b501fd8 |
| deploy/install.sh | modules/webmaker/deploy/install.sh | 그대로 | 58ba0c250620b638 |
| deploy/nginx-webhosting.conf | modules/webmaker/deploy/nginx-webhosting.conf | 그대로 | 0058f1dafd622bc5 |
| deploy/nginx-wmstats.conf | modules/webmaker/deploy/nginx-wmstats.conf | 그대로 | cf4069704f65ef73 |
| deploy/remote-install.sh | modules/webmaker/deploy/remote-install.sh | 그대로 | 66457327d0ef1b3c |
| deploy/remote-update.sh | modules/webmaker/deploy/remote-update.sh | 그대로 | fb0cf663fc687542 |
| deploy/webhosting-agent.service | modules/webmaker/deploy/webhosting-agent.service | 그대로 | 77259a206ca5530b |
| deploy/webmaker-setup.service | modules/webmaker/deploy/webmaker-setup.service | 그대로 | 9bbcbb9239d24ef9 |
| docs/ARIMAIL.md | modules/webmaker/docs/ARIMAIL.md | 그대로 | 1e326aa2252621cf |
| docs/CALENDAR.md | modules/webmaker/docs/CALENDAR.md | 그대로 | 1d12cd1720897254 |
| docs/CONFIG.md | modules/webmaker/docs/CONFIG.md | 그대로 | 13c26d45b5002b48 |
| docs/DOMAIN-DNS-PROVIDERS.md | modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md | 그대로 | c5ee35c850c7c728 |
| docs/DOMAIN-SETUP.md | modules/webmaker/docs/DOMAIN-SETUP.md | 그대로 | 83f0e447cd82e3dd |
| docs/FIRST-INSTALL.md | modules/webmaker/docs/FIRST-INSTALL.md | 그대로 | 642ce20eb96cabb9 |
| docs/GITEA-LOCAL.md | modules/webmaker/docs/GITEA-LOCAL.md | 그대로 | 4d7462b52ef34783 |
| docs/SSL-WILDCARD.md | modules/webmaker/docs/SSL-WILDCARD.md | 그대로 | d93102519cb39333 |
| docs/verification/visual-editor-1.8.0-tdd.md | modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md | 그대로 | 7c3757a3731e1a84 |
| hooks/askpass.sh | modules/webmaker/hooks/askpass.sh | 그대로 | c86b42091d07748d |
| hooks/guard.js | modules/webmaker/hooks/guard.js | 그대로 | 4804e4cd337db53c |
| hooks/guard.sh | modules/webmaker/hooks/guard.sh | 그대로 | 29df99b89bc31912 |
| hooks/settings.json | modules/webmaker/hooks/settings.json | 그대로 | 30ebe47478166d24 |
| lib/audit.js | modules/webmaker/lib/audit.js | 그대로 | 5a0b6a90e3281d2f |
| lib/auth.js | modules/webmaker/lib/auth.js | **수정** | 84e3fcda15e46b49 |
| lib/awsv4.js | modules/webmaker/lib/awsv4.js | 그대로 | 8a4bdab7d701c4ec |
| lib/billing.js | modules/webmaker/lib/billing.js | 그대로 | 4e3f88fd1f8ebae1 |
| lib/brief.js | modules/webmaker/lib/brief.js | 그대로 | 9f37eb2e30c3e710 |
| lib/calendar.js | modules/webmaker/lib/calendar.js | 그대로 | c5ee8d9c2777daac |
| lib/claude.js | modules/webmaker/lib/claude.js | 그대로 | dcdebeeeb1d8ea99 |
| lib/config.js | modules/webmaker/lib/config.js | **수정** | aa577cf668b504e7 |
| lib/connectors/arimail.js | modules/webmaker/lib/connectors/arimail.js | 그대로 | f4dea3dc448c155e |
| lib/connectors/gitea.js | modules/webmaker/lib/connectors/gitea.js | 그대로 | e736d764b2cdac6a |
| lib/connectors/index.js | modules/webmaker/lib/connectors/index.js | 그대로 | 1dc6f6a8290b7a7f |
| lib/costcal.js | modules/webmaker/lib/costcal.js | 그대로 | 22b008dccc3318db |
| lib/crawler.js | modules/webmaker/lib/crawler.js | 그대로 | 392e0fd1e9c88a26 |
| lib/credits.js | modules/webmaker/lib/credits.js | 그대로 | 59caba266ed2bcda |
| lib/deploy.js | modules/webmaker/lib/deploy.js | 그대로 | ddc85c8850a243a6 |
| lib/dns/gcloud.js | modules/webmaker/lib/dns/gcloud.js | 그대로 | e006e6ff4cde77c5 |
| lib/dns/openprovider.js | modules/webmaker/lib/dns/openprovider.js | 그대로 | ae8009bedd485e50 |
| lib/dns/porkbun.js | modules/webmaker/lib/dns/porkbun.js | 그대로 | 9f779244aeddc392 |
| lib/dns/route53.js | modules/webmaker/lib/dns/route53.js | 그대로 | b409db36696b9d5e |
| lib/dnszone.js | modules/webmaker/lib/dnszone.js | 그대로 | 62f74f0772fdc4d4 |
| lib/domains.js | modules/webmaker/lib/domains.js | 그대로 | cee7a767487f9a41 |
| lib/engine-codex.js | modules/webmaker/lib/engine-codex.js | 그대로 | a074e8a4b9867d6d |
| lib/engine-openai.js | modules/webmaker/lib/engine-openai.js | 그대로 | 93e4701e165d032a |
| lib/feedback.js | modules/webmaker/lib/feedback.js | 그대로 | c5860a676dd6118a |
| lib/forms.js | modules/webmaker/lib/forms.js | 그대로 | e2f1b1bb21e5bbdd |
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
| lib/logview.js | modules/webmaker/lib/logview.js | 그대로 | 0262fdf9042301f5 |
| lib/lunar.js | modules/webmaker/lib/lunar.js | 그대로 | fc858ad1b9166b8c |
| lib/mailer.js | modules/webmaker/lib/mailer.js | 그대로 | 7f10112c31ca2d7b |
| lib/mailservers.js | modules/webmaker/lib/mailservers.js | 그대로 | 8608b0f16a0ea9b1 |
| lib/mailsys.js | modules/webmaker/lib/mailsys.js | 그대로 | 8f2e419e859f62ea |
| lib/mailusage.js | modules/webmaker/lib/mailusage.js | 그대로 | a3b608fc766ebe06 |
| lib/maintpage.js | modules/webmaker/lib/maintpage.js | 그대로 | f57501de5a9349d8 |
| lib/modules.js | modules/webmaker/lib/modules.js | 그대로 | b0f8b6df731d180e |
| lib/multilang.js | modules/webmaker/lib/multilang.js | 그대로 | 30eaec7a02d17836 |
| lib/netguard.js | modules/webmaker/lib/netguard.js | 그대로 | 7f3f4a1a908b2371 |
| lib/openprovider.js | modules/webmaker/lib/openprovider.js | 그대로 | aa0ff8df9e04b482 |
| lib/passpolicy.js | modules/webmaker/lib/passpolicy.js | 그대로 | 96c30ce7d9f87797 |
| lib/planner.js | modules/webmaker/lib/planner.js | 그대로 | 55fe3f3597bd9278 |
| lib/prepaid.js | modules/webmaker/lib/prepaid.js | 그대로 | f1078822b25d5325 |
| lib/prompt.js | modules/webmaker/lib/prompt.js | 그대로 | 3279a47d1bb1e078 |
| lib/providers.js | modules/webmaker/lib/providers.js | 그대로 | bf01ac2ac60c40d1 |
| lib/ratelimit.js | modules/webmaker/lib/ratelimit.js | 그대로 | 5abfa063c0f78873 |
| lib/recovery.js | modules/webmaker/lib/recovery.js | 그대로 | 42272b277598a7fb |
| lib/refs.js | modules/webmaker/lib/refs.js | 그대로 | 1c530a8241d363dc |
| lib/registrar.js | modules/webmaker/lib/registrar.js | 그대로 | de6c606dcc87d665 |
| lib/registrars/openprovider.js | modules/webmaker/lib/registrars/openprovider.js | 그대로 | 04697456df4cc68e |
| lib/registrars/porkbun.js | modules/webmaker/lib/registrars/porkbun.js | 그대로 | 6bb857595564dcf6 |
| lib/registrars/route53.js | modules/webmaker/lib/registrars/route53.js | 그대로 | b60f17fefad37a4f |
| lib/remote.js | modules/webmaker/lib/remote.js | 그대로 | 965366d84a6ee859 |
| lib/report.js | modules/webmaker/lib/report.js | 그대로 | 3c14814e9a8f424f |
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 그대로 | 3fd30ebd9efa47a6 |
| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
| lib/ssl.js | modules/webmaker/lib/ssl.js | 그대로 | f7a5bb79900d8bdd |
| lib/stats.js | modules/webmaker/lib/stats.js | 그대로 | fc2de57d4be0960a |
| lib/store.js | modules/webmaker/lib/store.js | **수정** | 82156230f6fcb726 |
| lib/templates.js | modules/webmaker/lib/templates.js | 그대로 | bb44e537d0b28b16 |
| lib/uploads.js | modules/webmaker/lib/uploads.js | 그대로 | 5441ea45ed434575 |
| lib/vault.js | modules/webmaker/lib/vault.js | **수정** | 0730f701278d33a1 |
| lib/visual-editor.js | modules/webmaker/lib/visual-editor.js | 그대로 | 5680bdfc5504d968 |
| lib/xlsx.js | modules/webmaker/lib/xlsx.js | 그대로 | dc353dd3b5fd6e10 |
| locales/ko.json | modules/webmaker/locales/ko.json | 그대로 | 20abf531ef8320f6 |
| locales/site/ar.json | modules/webmaker/locales/site/ar.json | 그대로 | 8644e815887e9bb1 |
| locales/site/de.json | modules/webmaker/locales/site/de.json | 그대로 | 60a7b71fd70ae371 |
| locales/site/en.json | modules/webmaker/locales/site/en.json | 그대로 | 0939d2e2eedf1af9 |
| locales/site/es.json | modules/webmaker/locales/site/es.json | 그대로 | 8ba29c21cf2b90fb |
| locales/site/fa.json | modules/webmaker/locales/site/fa.json | 그대로 | 4c2b4917b1639f87 |
| locales/site/fr.json | modules/webmaker/locales/site/fr.json | 그대로 | 55e2e55db288f856 |
| locales/site/he.json | modules/webmaker/locales/site/he.json | 그대로 | ccaf97cc3f1ea1da |
| locales/site/id.json | modules/webmaker/locales/site/id.json | 그대로 | 9dc0512cc5d63255 |
| locales/site/ja.json | modules/webmaker/locales/site/ja.json | 그대로 | e6ecb37062ec5a8b |
| locales/site/ko.json | modules/webmaker/locales/site/ko.json | 그대로 | ea80e5efdb464a7e |
| locales/site/mn.json | modules/webmaker/locales/site/mn.json | 그대로 | bf5ceec52790a369 |
| locales/site/pt.json | modules/webmaker/locales/site/pt.json | 그대로 | 6922cd08078716c5 |
| locales/site/ru.json | modules/webmaker/locales/site/ru.json | 그대로 | 9d850324dda56354 |
| locales/site/th.json | modules/webmaker/locales/site/th.json | 그대로 | 4f22ec5351f5c41d |
| locales/site/tr.json | modules/webmaker/locales/site/tr.json | 그대로 | 597f9c5b952934ab |
| locales/site/vi.json | modules/webmaker/locales/site/vi.json | 그대로 | 886013f8dfbd56fc |
| locales/site/zh-CN.json | modules/webmaker/locales/site/zh-CN.json | 그대로 | 237e520720cb0166 |
| locales/site/zh-TW.json | modules/webmaker/locales/site/zh-TW.json | 그대로 | a7982e11198a617d |
| modules/README.md | modules/webmaker/modules/README.md | 그대로 | f5d15ad9e5fb2d26 |
| modules/blog/widget.html | modules/webmaker/modules/blog/widget.html | 그대로 | 6a2e80e57038f69a |
| modules/board/widget.html | modules/webmaker/modules/board/widget.html | 그대로 | ac119ae767ce5aa5 |
| modules/booking/widget.html | modules/webmaker/modules/booking/widget.html | 그대로 | 40b6e4cefcf0e901 |
| modules/calendar/widget.html | modules/webmaker/modules/calendar/widget.html | 그대로 | 999a4f5558854ddf |
| modules/faq/widget.html | modules/webmaker/modules/faq/widget.html | 그대로 | 791e54b1dfcb8448 |
| modules/gallery/widget.html | modules/webmaker/modules/gallery/widget.html | 그대로 | 70869995fd8fd4f7 |
| modules/inquiry/widget.html | modules/webmaker/modules/inquiry/widget.html | 그대로 | b56c4807dd11a007 |
| modules/library/widget.html | modules/webmaker/modules/library/widget.html | 그대로 | 1f5ff8f6874a741a |
| modules/map/widget.html | modules/webmaker/modules/map/widget.html | 그대로 | 0f68e9b3c48e4678 |
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
| package.json | modules/webmaker/package.json | 그대로 | 7b5c2bf8a18df617 |
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | 6f37dafdf4502684 |
| public/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a |
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
| public/flow-ui.js | modules/webmaker/public/flow-ui.js | 그대로 | e9a92ab29b71840e |
| public/i18n.js | modules/webmaker/public/i18n.js | 그대로 | c6c4039435b77583 |
| public/import.js | modules/webmaker/public/import.js | 그대로 | 3f17de67ac16b9c4 |
| public/index.html | modules/webmaker/public/index.html | **수정** | 400f72c882088d8e |
| public/login.html | modules/webmaker/public/login.html | 그대로 | dd870dfc417adc65 |
| public/manage-ui.js | modules/webmaker/public/manage-ui.js | 그대로 | c57d9d54dea3ab8d |
| public/modules-ui.js | modules/webmaker/public/modules-ui.js | 그대로 | 5f4f54a0abe692c4 |
| public/numfmt.js | modules/webmaker/public/numfmt.js | 그대로 | a11ef5768e547929 |
| public/optimize.js | modules/webmaker/public/optimize.js | 그대로 | 1dc20eb65f0db62f |
| public/pwrule.js | modules/webmaker/public/pwrule.js | 그대로 | 9a1ea70939fa58ce |
| public/setup.html | modules/webmaker/public/setup.html | 그대로 | 5c91302251e5cb7b |
| public/setup.js | modules/webmaker/public/setup.js | 그대로 | 9027313fdabe3be0 |
| public/site-admin.html | modules/webmaker/public/site-admin.html | 그대로 | d598563c7d617bbb |
| public/site-admin.js | modules/webmaker/public/site-admin.js | 그대로 | 762da69b23f1db85 |
| public/site-info.js | modules/webmaker/public/site-info.js | 그대로 | 99060343aaca0ab3 |
| public/style.css | modules/webmaker/public/style.css | **수정** | 3a9d2f8e50e7b00a |
| public/templates-ui.js | modules/webmaker/public/templates-ui.js | 그대로 | 6adc66d8e2204b1f |
| public/visual-editor-core.js | modules/webmaker/public/visual-editor-core.js | 그대로 | e1ee3bb64d63ee7c |
| public/visual-editor-ui.js | modules/webmaker/public/visual-editor-ui.js | 그대로 | a24aaae4b1b28e1e |
| public/webmail.html | modules/webmaker/public/webmail.html | 그대로 | 981fee72b5c72bed |
| public/webmail.js | modules/webmaker/public/webmail.js | 그대로 | 6cb9a50f39c8d025 |
| public/wizard.js | modules/webmaker/public/wizard.js | 그대로 | 2ba97adfbb4f7cc1 |
| public/wm-modules.js | modules/webmaker/public/wm-modules.js | 그대로 | efc7526ba17c338d |
| public/wm-stats.js | modules/webmaker/public/wm-stats.js | 그대로 | 7e72375620ac125d |
| public/wm-ve-canvas.js | modules/webmaker/public/wm-ve-canvas.js | 그대로 | fb32a5abf9d4f33d |
| scripts/acme-dns-hook.js | modules/webmaker/scripts/acme-dns-hook.js | 그대로 | 4401e102dde4c3ec |
| scripts/admin.js | modules/webmaker/scripts/admin.js | 그대로 | 02adaf25dabad0ec |
| scripts/backup-data.js | modules/webmaker/scripts/backup-data.js | 그대로 | 94b331c3e9555664 |
| scripts/collect-backlog.js | modules/webmaker/scripts/collect-backlog.js | 그대로 | d5e4a0c3ca9bf7f0 |
| scripts/config.js | modules/webmaker/scripts/config.js | 그대로 | d4b90ec541922219 |
| scripts/i18n-extract.js | modules/webmaker/scripts/i18n-extract.js | 그대로 | 7b0a2c7d76155b06 |
| scripts/migrate-gitstore.sh | modules/webmaker/scripts/migrate-gitstore.sh | 그대로 | 6b2fda206e3acb80 |
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
| server.js | modules/webmaker/server.js | **수정** | b15f25a0b4f8278b |
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab |
| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
| test/server.test.js | modules/webmaker/test/server.test.js | 그대로 | b931228730a8bd70 |
| test/visual-editor-app.test.js | modules/webmaker/test/visual-editor-app.test.js | 그대로 | d65ab5fb5885da48 |
| test/visual-editor-server.test.js | modules/webmaker/test/visual-editor-server.test.js | 그대로 | b5de130875d5ef36 |
| test/visual-editor.test.js | modules/webmaker/test/visual-editor.test.js | 그대로 | bd42f92d91224b08 |
## arimail
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
- 고정 커밋(불변 기준): `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
- 대상 경로: `modules/arimail/`
- 파일 수: 88 (수정 15)
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|---|---|---|---|
| README.md | modules/arimail/README.md | 그대로 | 942037563d2ec90a |
| deploy/deploy.sh | modules/arimail/deploy/deploy.sh | **수정** | cd7e1a5f26435839 |
| deploy/domain-nginx.sh | modules/arimail/deploy/domain-nginx.sh | 그대로 | c8322d284a055a68 |
| deploy/gateway.env.in | modules/arimail/deploy/gateway.env.in | 그대로 | 3255005cd674a261 |
| deploy/haraka-apply.sh | modules/arimail/deploy/haraka-apply.sh | 그대로 | 4a5700051eef5a3e |
| deploy/letsencrypt-deploy-hook.sh.in | modules/arimail/deploy/letsencrypt-deploy-hook.sh.in | 그대로 | 6d22293ac934c49f |
| deploy/lib.sh | modules/arimail/deploy/lib.sh | 그대로 | 4726339b779386e2 |
| deploy/new-site.sh | modules/arimail/deploy/new-site.sh | 그대로 | 77048380fc977d44 |
| deploy/nginx-apply.sh | modules/arimail/deploy/nginx-apply.sh | 그대로 | 9709a481130916b8 |
| deploy/nginx/mail-path.conf.in | modules/arimail/deploy/nginx/mail-path.conf.in | 그대로 | 893c6a395cfa79fb |
| deploy/nginx/mail.cloudflare.conf.in | modules/arimail/deploy/nginx/mail.cloudflare.conf.in | 그대로 | a6bd91d9ec547aa4 |
| deploy/nginx/mail.conf.in | modules/arimail/deploy/nginx/mail.conf.in | 그대로 | 55b039d39b838ae9 |
| deploy/nginx/mail.http-only.conf.in | modules/arimail/deploy/nginx/mail.http-only.conf.in | 그대로 | 3f327fdaa7c2ce9d |
| deploy/setup.sh | modules/arimail/deploy/setup.sh | 그대로 | 74fb725daf0b4481 |
| deploy/site-mail-path.sh | modules/arimail/deploy/site-mail-path.sh | 그대로 | 50b30b646233902f |
| deploy/systemd/mail-gateway.service.in | modules/arimail/deploy/systemd/mail-gateway.service.in | 그대로 | 1a129db88217c13a |
| deploy/systemd/mail-haraka.service.in | modules/arimail/deploy/systemd/mail-haraka.service.in | 그대로 | 9854efc3e9e6e877 |
| deploy/systemd/mail-tls.service.in | modules/arimail/deploy/systemd/mail-tls.service.in | 그대로 | 445add8875f3d99d |
| deploy/systemd/mail-tls.timer.in | modules/arimail/deploy/systemd/mail-tls.timer.in | 그대로 | 9eebee444b7d22a8 |
| deploy/tls-when-ready.sh | modules/arimail/deploy/tls-when-ready.sh | 그대로 | 8add122491a9c3d5 |
| deploy/webmaker-access.sh | modules/arimail/deploy/webmaker-access.sh | 그대로 | 4390f154d7b48dc4 |
| docs/SETUP.md | modules/arimail/docs/SETUP.md | 그대로 | 1362935eee8bcfb9 |
| docs/USAGE-API.md | modules/arimail/docs/USAGE-API.md | 그대로 | 622b2346d12972cd |
| haraka/README.md | modules/arimail/haraka/README.md | 그대로 | d6ce6e90a1466334 |
| haraka/package.json | modules/arimail/haraka/package.json | 그대로 | b29222d4d10a4246 |
| haraka/plugins/arimail.js | modules/arimail/haraka/plugins/arimail.js | **수정** | c33939420a33d72e |
| haraka/run.mjs | modules/arimail/haraka/run.mjs | 그대로 | c48fa74615815a7e |
| lang/ko.json | modules/arimail/lang/ko.json | 그대로 | 5e6a8354e25662ff |
| migrations/0001_init.sql | modules/arimail/migrations/0001_init.sql | 그대로 | f6b1b10b05cdb85a |
| migrations/0002_imap.sql | modules/arimail/migrations/0002_imap.sql | 그대로 | 3e234e2410a7e152 |
| migrations/0003_accounts.sql | modules/arimail/migrations/0003_accounts.sql | 그대로 | 00e8cf661fe62d5e |
| migrations/0004_no_retention.sql | modules/arimail/migrations/0004_no_retention.sql | 그대로 | 6e45e554cb1af82d |
| migrations/0005_imap_uid_reassign.sql | modules/arimail/migrations/0005_imap_uid_reassign.sql | 그대로 | 9d3d33c65e05b33d |
| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b |
| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe |
| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 |
| package.json | modules/arimail/package.json | **수정** | f986ee70b4841c73 |
| server/api.mjs | modules/arimail/server/api.mjs | **수정** | b51ef607e4c0e65b |
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
| server/config.mjs | modules/arimail/server/config.mjs | **수정** | 9f5cc526198f988d |
| server/db-migrate.mjs | modules/arimail/server/db-migrate.mjs | 그대로 | cbc5540c4a43e134 |
| server/db.mjs | modules/arimail/server/db.mjs | 그대로 | 35f5d6c1fc4c8ff2 |
| server/dns/common.mjs | modules/arimail/server/dns/common.mjs | 그대로 | c6ccc6ceeafa0bcf |
| server/dns/google.mjs | modules/arimail/server/dns/google.mjs | 그대로 | c825d4f04f7921c9 |
| server/dns/index.mjs | modules/arimail/server/dns/index.mjs | 그대로 | e3605c56e29aeb58 |
| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
| server/limits.mjs | modules/arimail/server/limits.mjs | **수정** | 016c3aecef9a6da7 |
| server/main.mjs | modules/arimail/server/main.mjs | **수정** | 90ad71bdc1bca7c0 |
| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | **수정** | 5774cb75b5b1c9d4 |
| server/providers/resend.mjs | modules/arimail/server/providers/resend.mjs | 그대로 | 2b964265e2d0eb8d |
| server/providers/smtp-client.mjs | modules/arimail/server/providers/smtp-client.mjs | 그대로 | 308ef3b4dfc0b9e0 |
| server/providers/smtp.mjs | modules/arimail/server/providers/smtp.mjs | 그대로 | e7b475fc79c65834 |
| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
| server/setup.mjs | modules/arimail/server/setup.mjs | 그대로 | ece096ed19cbb679 |
| server/smtp.mjs | modules/arimail/server/smtp.mjs | **수정** | 61e2e7187c636297 |
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
| server/usage.mjs | modules/arimail/server/usage.mjs | **수정** | 29c97a4b5f627741 |
| server/user-cli.mjs | modules/arimail/server/user-cli.mjs | 그대로 | ba64bccf7f3892dc |
| server/util.mjs | modules/arimail/server/util.mjs | 그대로 | 6daf63506b35fb6c |
| server/webhook.mjs | modules/arimail/server/webhook.mjs | 그대로 | b3246cf3b830c4d8 |
| server/webmaker-snapshot.mjs | modules/arimail/server/webmaker-snapshot.mjs | 그대로 | ef6962db91b5b09a |
| server/webmaker.mjs | modules/arimail/server/webmaker.mjs | 그대로 | 6cde505d6949137e |
| test/_helpers.mjs | modules/arimail/test/_helpers.mjs | 그대로 | 3c8cf13bfc4fee0a |
| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | **수정** | fc5ef627262d950f |
| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | **수정** | f92287ef6e7df130 |
| test/usage.test.mjs | modules/arimail/test/usage.test.mjs | **수정** | 659271c6b54935b2 |
| test/webmaker.test.mjs | modules/arimail/test/webmaker.test.mjs | 그대로 | 2a6818a40b54cfee |
| web/app.js | modules/arimail/web/app.js | 그대로 | cbfb4a748cc100f4 |
| web/favicon.svg | modules/arimail/web/favicon.svg | 그대로 | 0c44f177404b4090 |
| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
| web/setup.js | modules/arimail/web/setup.js | **수정** | 24f72af0d740a559 |
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
+38
View File
@@ -0,0 +1,38 @@
# Ariweb Suite
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
- 버전: **0.2.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치)
## 구성
| 프로세스 | 진입점 | 포트 | 공개 주소 |
|---|---|---|---|
| HAL(제어 평면·운영자 콘솔·공개 상태) | `hal/index.mjs` | 127.0.0.1:8900 | https://hal.ariweb.net |
| 고객 포털 | `portal/index.mjs` | 127.0.0.1:8910 | https://my.ariweb.net |
| Node Agent | `agent/index.mjs` | — | 각 관리 서버 |
| Webmaker(도입) | `modules/webmaker/server.js` | 127.0.0.1:3080 | https://webmaker.ariweb.net |
| AriMail(도입) | `modules/arimail/server/index.mjs` | 127.0.0.1:8790 + 메일 포트 | https://webmail.ariweb.net |
외부 npm 의존성 없음. Node 22.13 이상(`node:sqlite`).
## 개발
```bash
node test/run.mjs # 전체 회귀 테스트 (test/*.test.mjs)
node test/run.mjs lib # 이름에 lib 가 들어간 테스트만
node scripts/import-sources.mjs # 참조 저장소 고정 커밋에서 모듈 소스 갱신 + manifest
node scripts/release.mjs # 릴리스 artifact(dist/<version>/) 생성·검사
ARIWEB_PORTAL_DATA=./data/portal node portal/index.mjs
ARIWEB_HAL_DATA=./data/hal node hal/index.mjs
```
## 배포 순서(§32)
1. `main` 에 병합된 승인 릴리스에서 `node scripts/release.mjs` 로 artifact 생성
2. 최초 설치에서만 `deploy/bootstrap-hal.sh` 로 HAL 설치(HAL 자체 건강·실행 버전 확인, 첫 Node Agent 등록)
3. 이후 모든 모듈(포털·Webmaker·AriMail·Agent·백업·관측)은 HAL 운영자 콘솔의 모듈 배포 작업으로만 설치·갱신·되돌리기
+282
View File
@@ -0,0 +1,282 @@
# VERSION_HISTORY
## 0.2.1 (2026-10-29)
- **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.1.md`, 오즈 승인) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/hal-risk-banner-v0.2.1`, 기준 origin/main `0a56d4612c86dddfcbe644e775980d3c5b413b2e`). 커밋·push·병합·릴리스 빌드·운영 배포는 하지 않았다 — Neo 가 검토 뒤 수행한다.
- **이전 버전**: 0.2.0(2026-10-28, Webmaker 직접 편집 — 아래 절). 0.2.1 은 **끝 버전 번호만 올리는 운영자 화면 수정**이다: 사용자 기능·도입 모듈·보안 경계 변경 없음.
- **구성**: HAL · 고객 포털 · Node Agent · 도입 모듈 Webmaker 1.8.0 · AriMail 1.0.11 모두 0.2.0 그대로(HAL 운영자 콘솔 화면 2곳 + 브라우저 스크립트만 수정). 외부 npm 의존성 없음, Node 22.13+.
### 요구(사용자 요구 — 약화 없이 반영)
HAL 운영자 콘솔의 모든 일반 화면 위에 재인증이 10분을 넘기면 늘 떠 있던 전역 배너 `! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증` 을 없앤다. 그 안내는 backend 가 고위험 요청을 실제로 `reauth_required` 로 거부한 **바로 그 시점**의 재인증 창 안에서만 보인다. 단순 화면 조회·로그인 직후·10분 경과·일반 이동 중에는 보이지 않는다. 계정 화면의 수동 재인증 단추·동작은 유지하되 고위험 안내를 붙이지 않는다. backend 의 `requireRecent`·CSRF·3단계 확인·고위험 분류·거부 동작은 그대로다.
### 수정한 기존 기능
- `hal/ui.mjs` `shell()`: 재인증 만료(`!session.recentlyAuthed`)를 전역 배너로 그리던 한 줄을 제거(이력 주석으로 대체). 다른 전역 배너(기기 귀속 접근권 만료 안내·HAL 자체 건강 비정상·긴급 복구 세션 진행·부트스트랩 미완료·비밀번호 변경 필요)와 `.alerts` 영역은 그대로.
- `hal/ui/public/ops.js`: `call()`(API 호출 + 재인증 재시도)과 `reauth()` 창이 새 순수 helper `hal/ui/public/reauth.mjs` 의 `callWithReauth`·`reauthPrompt` 를 쓴다. 서버가 `reauth_required` 로 거부했을 때 여는 재인증 창에만 고위험 안내(note, HTML 이스케이프)를 붙이고, 계정 화면의 수동 재인증(`data-act="reauth"`)은 `reauth()` 를 인자 없이 불러 안내 없이 비밀번호만 묻는다. 재시도 규칙은 전과 같다(성공 시 딱 한 번, 취소·실패 시 원 요청 미실행).
- `hal/ui/render.mjs` `accountsView`(공개 사본 `hal/ui/public/render.mjs` 는 심볼릭 링크): 계정 화면 "최근 재인증" 상태 배지 문구 `재인증 필요(고위험 작업)` → `만료(10분 지남)`. 조회 화면이 고위험 안내를 품지 않게 하는 중립 표기이며 상태 판정·단추는 같다.
- 새 파일 `hal/ui/public/reauth.mjs`(순수, DOM·fetch 없음, `/ops/ui/reauth.mjs` 로 정적 제공): `REAUTH_REQUIRED`·`HIGH_RISK_NOTICE`(옛 배너 문구 그대로 — 위치만 옮김)·`reauthPrompt({required})`·`callWithReauth(run, reauth)`. 브라우저와 Node 테스트가 같은 파일을 쓴다.
- 버전 표기: `package.json` 0.2.1(HAL 런타임 `hal/deps.mjs VERSION`·`scripts/release.mjs` 는 이 값을 따른다), `README.md` 버전·개발 지시 목록, `test/webmaker-visual-editor.test.mjs` 의 Suite 버전 검사 0.2.1.
### 고객 화면
- 변경 없음.
### 운영자 화면
- 일반 화면(운영 현황·고객·주문·상품·사이트·메일·도메인·서버·백업·이전·릴리스·작업·장애·감사·복구·계정)에서 고위험 재인증 전역 배너가 사라진다. 운영자는 고위험 작업(서버 삭제·격리·역할 변경, 모듈 되돌리기·배포·재조정, 작업 되돌리기, 운영자 추가·접근권 발급, MFA 해제, 설정 변경, 메일 가져오기 적용·되돌리기, 릴리스 승인, 긴급 복구 선언·승인·자격, DR 설정·복원)을 실제로 시도해 서버가 거부한 순간에만 "재인증(최근 10분)" 창 위쪽에서 `고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다` 를 본다.
- 계정 화면: "재인증" 단추 유지(수동 재인증 창은 비밀번호만), "최근 재인증" 행은 `유효(10분)` / `만료(10분 지남)`. 운영자 조치 항목 없음(설정값·보관소·DB 변경 없음). 배포 뒤 브라우저 캐시의 옛 `ops.js` 가 새 `reauth.mjs` 를 못 찾는 경우는 없다(옛 ops.js 는 helper 를 가져오지 않음).
### 보안 결정
- 서버 쪽 판단 코드는 한 줄도 바꾸지 않았다(`hal/app.mjs` `requireRecent`·CSRF·`threeStep`·고위험 경로 분류, `lib/session.mjs` `recentlyAuthed`, `POST /ops/api/reauth`). 변경은 "거부됐을 때 브라우저가 어떻게 묻고 몇 번 다시 보내는가"와 안내 문구 위치뿐이다.
- 재인증 창 취소·재인증 실패 → 원 요청을 다시 보내지 않고 원래 오류를 호출자에게 전파(고위험 작업이 몰래 실행되지 않음). 성공 → 같은 method·path·body 로 **딱 한 번** 재시도(`api` 직접 호출, `call` 재귀 아님). 재시도도 거부되면 다시 묻지 않는다(무한 반복 금지). `reauth_required` 외의 오류는 재인증을 묻지 않는다.
- 안내 문구는 helper 단일 출처이며 `R.esc` 로 이스케이프해 modal 에 넣는다. 비밀번호·토큰·키·개인정보는 파일·테스트·출력에 없다 — 새 테스트 `test/hal-reauth-banner.test.mjs` 의 운영자 비밀번호 fixture 는 정적 literal 없이 `node:crypto` `randomBytes` 로 실행마다 생성하며(정답·오답 값이 서로 다름 보장) 출력에도 찍지 않는다.
### 테스트
- RED(production 변경 전, `node --no-warnings test/hal-reauth-banner.test.mjs`): **통과 1 · 실패 4** — ① `/ops/: 10분 경과 뒤에도 고위험 배너 없음 (실제 false)`(배너가 아직 그려짐) ② ③ `ERR_MODULE_NOT_FOUND hal/ui/public/reauth.mjs`(helper 없음, 문구·재시도 계약 테스트 2건) ④ `hal/ui.mjs 는 고위험 배너 문구를 더 이상 그리지 않음 (실제 false)`(정적 계약). 통과 1건은 backend 경계 보호 테스트(변경 대상이 아니므로 처음부터 통과해야 한다).
- GREEN(최소 구현 뒤): `test/hal-reauth-banner.test.mjs` **5/5**; targeted `test/hal-ui.test.mjs` **7/7**, `test/hal.test.mjs` **16/16**(기존 reauth_required → 재인증 → 통과 흐름 포함). `node --check` 로 `ops.js`·`reauth.mjs` 구문 확인.
- 전체 Suite `node test/run.mjs`: **25개 파일 실패 0**(새 `hal-reauth-banner` 5 포함, 로컬 E2E 11 통과; 러너가 만든 `docs/verification/local-e2e-2026-10-28.*` 임시 파일은 diff 에서 제거). 기존 테스트 변경은 `test/webmaker-visual-editor.test.mjs` 의 Suite 버전 검사 0.2.1 뿐.
- 새 테스트가 못 박는 계약: 일반 화면 6곳 + 계정 화면에 로그인 직후·10분 경과 모두 전역 배너·배너 영역 재인증 링크·`고위험 작업(` 문구 없음, 계정 수동 재인증 단추 유지, 부트스트랩 배너 유지 / backend: 10분 경과 뒤 서버 삭제·격리 403 `reauth_required`(`detail.action=reauth`)·거부된 요청 미실행·CSRF 없는 재인증 403 `csrf`·틀린 비밀번호 거부·재인증 뒤 `recentlyAuthed`·3단계 확인 없이는 여전히 거부 / helper: 수동 창 note 없음·거부 창 note = 옛 배너 문구·취소 시 run 1회·오류 전파·성공 시 run 2회·재시도 거부 시 재질문 없음·다른 오류는 재인증 없음·정상 응답 그대로 / 정적: `hal/ui.mjs` 배너 코드 없음, `ops.js` 가 `./reauth.mjs`·`callWithReauth`·`reauthPrompt` 를 쓰고 `reauth: () => reauth()`(인자 없음)이며 문구를 직접 품지 않음.
### 알려진 제한
- 실제 브라우저(DOM)에서 재인증 modal 이 열리는 모습은 자동 테스트하지 않았다(저장소에 브라우저 러너 없음, 큰 dependency 추가 금지). 흐름·문구는 순수 helper 로, 결선은 정적 계약으로 검사했다. 배포 뒤 운영자가 10분 경과 상태에서 고위험 단추(예: 서버 격리) 하나를 눌러 안내가 붙은 재인증 창이 뜨고 취소 시 실행되지 않음을 한 번 눈으로 확인하는 것을 권한다.
- `hal/ui/render.mjs` `relayError({code:'reauth_required'})` 가 포털 중계 응답에 "재인증 필요" 상태 패널을 그리는 동작은 그대로다(이것도 서버가 거부한 그 시점의 표시이며 고위험 문구는 없다).
- `docs/verification/` 에 0.2.1 전용 검증 문서는 만들지 않았다(지시 범위: HAL UI·테스트·버전/이력·개발 지시). RED/GREEN 증거는 이 절과 인도 보고에 있다.
### 미확정 정책값
- 0.2.0 의 미확정 정책값 그대로. 재인증 유효 시간(10분)·고위험 작업 분류는 바꾸지 않았고 이번 범위 밖이다.
## 0.2.0 (2026-10-28)
- **작성자**: 개발 지시 — Neo(`Ariweb-Suitle_Dev-Order_v0.2.0.md`) · 구현 — Claude Fable 5.1(격리 작업 트리 `feature/visual-editor-v0.2.0`, 기준 main `4b3714e37c543fcfa8862dbc68c76f62a9b0bf97`). 커밋·push·병합·배포·운영 서버 접근은 하지 않았다 — Neo 가 검토 뒤 수행한다.
- **이전 버전**: 0.1.1(2026-10-26, 첫 구현 릴리스 — 아래 절) · 0.1.4 개발 지시(2026-10-27, HAL AI 대시보드·다중 AI 공동 작업창 지시 문서만 추가, `4b3714e`). 0.2.0 은 **가운데 버전 번호를 올리는 기능 릴리스**다: 도입 모듈 Webmaker 에 사용자 기능(직접 편집)이 더해졌고 Webmaker 자체도 1.7.3 → **1.8.0** 으로 올랐다. 0.1.1 의 모든 기준·0.1.4 의 지시는 그대로 유효하다(이번 범위 밖의 0.1.4 항목은 구현하지 않았다).
- **구성**: HAL · 고객 포털 · Node Agent 는 0.1.1 그대로. 도입 모듈 **Webmaker 1.8.0**(직접 편집 포함) · AriMail 1.0.11(변경 없음). 외부 npm 의존성 없음, Node 22.13+.
### 요구(사용자 요구 — 약화 없이 반영)
Webmaker 가 초안을 만들어 보이는 페이지가 생긴 뒤 **직접 시각 편집 모드**를 더한다(Wix·아임웹 방식 끌어다 놓기). 변경은 Suite 의 `modules/webmaker` 와 기존 Webmaker 저장소 양쪽에 반영한다. 필수 동작·보안 규칙은 `Ariweb-Suitle_Dev-Order_v0.2.0.md` 에 계약으로 적었다.
### 재사용한 기존 기능(그대로 도입)
- Webmaker 1.8.0 의 **직접 편집(시각 편집기)** 전부 — 미리보기 도구 줄 "직접 편집" 전환(사이트·HTML 페이지가 골라져 있을 때만), 캔버스 글 바로 고치기(두 번 클릭/Enter), 요소 선택·도구 막대(↑↓·복제·삭제·끌기 손잡이), 기존 블록 끌어 옮기기, 구성 요소 팔레트 7종(섹션/컨테이너·제목·문단·버튼(링크)·이미지·구분선·여백) 끌어다 놓기 + "추가" 버튼, 속성 창(상위 선택·이동·복제·삭제·글·링크 주소·이미지 주소/대체 글·정렬·글자색·배경색·안쪽/바깥 여백), 저장 상태(변경 없음/저장되지 않음/저장 중/저장됨/저장 실패 — aria-live), 저장(Ctrl/Cmd+S), 실행 취소/다시 실행, 버리고 다시 불러오기, 편집 끝내기, 페이지·사이트·화면 전환·새로고침·소스 보기 전환·창 닫기 전 저장하지 않은 변경 보호. 기존 소스 편집기·모바일 미리보기·버전/복원·배포·가져오기·다국어·모듈·SEO·Site Brief 는 그대로.
- 재사용한 함수(Webmaker 기존 코드를 그대로 호출): `resolveTextFile`(경로 검증·링크 거부), `listFiles`, `gitRecord`/`lib/gitstore.recordChange`(버전 기록 — 새 종류 `visual-edit`), `publicView`, `broadcast`, `jobOf`(AI 작업·배포 중 판정), `readBody`, `sessionUser`·`fromTenantPage`·`auth.csrfOk`(출처·CSRF), `siteUrl`, `isSecureReq`, `MAX_TEXT_BYTES`(5MB 상한), `store.save`(Suite `rev` 증가). 화면: `discardOk`·`renderPreview`·`renderGit`·`loadFrame`·`setEditorOpen`·`logLine`·`pushMsg`·`api`(CSRF 헤더)·`T`.
### 수정한 기존 기능
- **Webmaker(`modules/webmaker`, 파일 머리·줄 주석 `v1.8.0`)**: `server.js` — `require('./lib/visual-editor')`, `veRuntime()`·`requestOrigin()`, `GET /files` 응답에 `hash`, 새 경로 `GET/PUT /api/sites/<id>/visual/<페이지>`(아래 "직접 편집·끌어다 놓기·저장·버전·보안"). `public/app.js` — `discardOk`(직접 편집 변경도 묻기), `VER_KIND` 에 `visual-edit`(기존 `manual-edit` 는 "소스 편집" 으로 표기), `renderPreview`(직접 편집 중이면 미리보기 덮어쓰지 않고 `VisualEditor.onSiteUpdated`), `btnToggleSource`(한 번에 하나), `beforeunload`, `resetToNew`, `btnReload`·`fileSelect.onchange`, `openSite` 끝(사이트 바뀌면 편집 종료). `public/index.html` — `#btnVisual`, `#veBar`, `.ve-stage`(`#vePalette`·`#frameWrap`·`#veInspector`), 스크립트 2개. `public/style.css` — `.ve-*`(900px 이하 세로 배치·접기). `public/admin.js` — 버전 종류 표기. `package.json` 1.8.0.
- **Suite 최소 연결 변경(`[Ariweb Suite 변경]`, 0.1.1 의 8개 파일 변경은 그대로 유지)**: `modules/webmaker/server.js` 의 직접 편집 `PUT` 에도 저장본 `rev` 충돌 검사(`revConflict`, §9.3)를 소스 편집기·기본 사항 저장과 같이 적용 — 화면이 보낸 `rev` 가 다르면 409 와 양쪽 요약(`conflict.currentRev` 등). 화면(`visual-editor-ui.js`)은 `GET /visual` 의 `rev` 를 기억해 저장 때 보내고 응답의 `rev` 로 갱신한다(upstream Webmaker 는 `rev` 를 돌려주되 무시한다). 그 밖의 Suite 전용 변경(SSO·내부 API·배너·`lib/ariweb.js`·`store.rev`)은 바뀌지 않았다 — `diff` 로 upstream 과의 차이가 0.1.1 의 Suite 블록 + 1.8.0 공통 변경만임을 확인했다.
- **Suite 도구**: `scripts/import-sources.mjs` 에 `--allow-dirty`(참조 저장소를 나란히 개발 중일 때 고정 커밋 위의 작업 트리 변경·추적되지 않은 새 파일을 가져오고 `upstream{version·branch·headCommit·workingTreeDirty·note}` 를 기록) 와 `--only` 때 다른 소스 기록 유지. `hal/modules/index.mjs`·`agent/jobs/module.mjs` 의 Webmaker 버전 주석, `portal/webmaker/REUSE.md` 갱신. Agent·포털 코드 동작은 바뀌지 않았다(릴리스 artifact 는 `package.json` 0.2.0 으로 `webmaker-0.2.0.tar.gz` 등으로 만들어진다). **`scripts/release.mjs` 와 `hal/core.mjs` 는 바뀌었다** — 도입 소스 출처 fail-closed(아래 ⑪)와 릴리스 승인 세 검사 필수(아래 종결 검토 ③).
- 오류 수정: Webmaker 직접 편집 저장에서 JSON 파싱 실패가 500 으로 나오던 것을 400 한국어 안내로(TDD 중 발견, 직접 편집 경로에만 적용).
- **검토 수정(Neo 독립 검토)** — ① 출시 차단: 직접 편집 PUT 이 본문을 읽기(await) 전에 파일 hash 를 잡아 두어 두 저장이 나란히 들어오면 둘째가 첫째를 조용히 덮어쓸 수 있었다 → 본문을 다 읽은 뒤 사이트 기록(`store.load`)·파일을 다시 읽어 hash(Suite 는 `rev` 도)를 비교하고 검증·쓰기까지 await 없이 이어 간다(둘째는 409 stale, 첫 저장 보존). 결정적 회귀 테스트(chunked PUT 두 개의 본문을 순서대로 완료). ② 안전 강화: 직접 편집 저장을 `lib/visual-editor.writeTextAtomic`(같은 폴더 임시 파일 0644 → rename, 실패 때 정리·오류 전달, 파일 시스템 주입 가능)로 바꿔 쓰기 실패·중단 때 마지막 정상 페이지가 잘리지 않는다(500 + "기존 페이지는 그대로 있습니다", 버전 미생성). 소스 편집기의 기존 쓰기는 바꾸지 않았다.
### 고객 화면
- 포털 사이트 상세 › 제작 탭 › "제작 화면 열기"(SSO) → Webmaker 빌더 오른쪽 미리보기 도구 줄의 **직접 편집**. 포털 자체 화면은 바뀌지 않았다. 직접 편집은 AI Wallet(캐시)을 쓰지 않으므로 포털 AI Wallet 카드에 영향이 없고, 직접 편집 저장본(버전 `visual-edit`)은 포털 배포 화면의 저장본 선택·Source 변경 이력에 그대로 보인다(내부 API `projects/<id>` 의 `versionList`).
- 포털 상단 배너(그룹·운영 사이트)는 직접 편집 중에도 그대로 보인다.
### 운영자 화면
- HAL 운영자 콘솔 변경 없음. Webmaker 관리자 콘솔의 버전·변경 종류 표기에 "직접 편집"(`visual-edit`)이 추가되고 기존 `manual-edit` 는 "소스 편집" 으로 보인다. 운영자 조치 항목 없음(설정값·보관소 변경 없음).
### 직접 편집 · 끌어다 놓기 · 저장 · 버전 · 보안 동작
- **캔버스**: `GET /api/sites/<id>/visual/<페이지>` 가 저장된 HTML 을 편집 캔버스 문서로 바꿔 준다(`lib/visual-editor.prepareCanvas`). 페이지 `<script>`(외부 src 포함)는 `type="text/x-wm-ve-disabled"`, 인라인 이벤트 속성은 `data-wm-ve-on-*`, `javascript:` 링크·iframe/frame/embed src·object data·meta http-equiv·base href 는 `data-wm-ve-*` 보존 속성으로 바뀌어 **페이지 스크립트가 실행되지 않는다**(지우지 않고 보존). `<meta http-equiv="Content-Security-Policy">` 가 nonce 있는 편집 런타임(인라인 `visual-editor-core.js`+`wm-ve-canvas.js`)만 허용하고, `<base href="<사이트 주소>/<폴더>/">` 로 상대 경로 자원이 저장 페이지와 같게 풀린다. 화면은 이를 기존 미리보기 iframe(sandbox, allow-same-origin 없음 → 출처 null, 쿠키·저장소·빌더 API 접근 불가)의 `srcdoc` 에 넣는다. 응답에 32자리 무작위 세션·파일 sha256(`hash`)·`rev`.
- **규약**: 봉투 `{wmve:1, session, type, payload}`. 부모는 iframe `contentWindow` + 출처 `'null'` + 세션 + 허용 종류(ready·selected·changed·error·body·hotkey)만, 캔버스는 `window.parent` + 부모 출처 + 세션 + 허용 종류(init·select·move·duplicate·delete·insert·setProps·applyHtml·setMode·requestBody)만 처리한다(`WMVisualCore.validateMessage`). HTML 속성·메시지 내용은 권한으로 쓰지 않는다. 캔버스는 서버와 통신하지 않는다.
- **저장**: `PUT /api/sites/<id>/visual/<페이지>` `{body, baseHash, rev}` — 원본 doctype·`<head>`·`<body …>`·꼬리는 글자 그대로 두고 **본문만** 바꿔 끼운다. 보존 속성 복원, 편집기 표식 제거. 거절(한국어): 로그인 401 · 다른 사용자 404 · 생성 사이트 출처/CSRF 없음 401 · HTML 아님 400 · 경로 탈출·링크 400 · AI 작업·배포 중 409 · `rev` 불일치 409(+ 양쪽 요약, Suite) · `baseHash` 불일치 409(+ currentHash) · baseHash 없음/형식·본문 비문자열·JSON 파싱 실패 400 · 5MB 초과 413 · 원본에 없던 스크립트(추가·변경)·인라인 이벤트 속성·javascript:/vbscript:/data:text 주소·iframe/object/embed/meta/link/base·스타일 표현식·본문 안 html/head/body 400. 저장 뒤 `manualEdits{kind:'visual'}`, 버전 `visual-edit`("직접 편집: <경로>"), Suite `rev` +1, 미리보기·소스 편집기용 `hash`·`files`·`git`·`versions` 갱신. **캐시 미차감·AI 명령·수정 횟수 미계수.**
- **끌어다 놓기**: 팔레트 → 캔버스는 **포인터 끌기 다리가 주 경로**(부모가 팔레트 항목의 포인터를 추적해 iframe 안쪽일 때만 상대 좌표를 검증된 메시지 `paletteDragAt`/`paletteDropAt`/`paletteDragCancel` 로 보내고 캔버스가 id·좌표·모드를 다시 검사해 스스로 놓기 표시·삽입 — Chromium 이 HTML5 끌기를 출처 'null' sandbox iframe 으로 넘기지 않기 때문), HTML5 끌어다 놓기는 포인터 이벤트가 없는 환경의 **대체 경로**(`text/wm-ve-component` + `text/plain` 이름 공간 값만 받고 구성 요소 HTML 은 `isSafeComponentHtml` 검사). 기존 블록은 포인터 기반 끌어 옮기기(같은 문서 안, 놓을 자리 표시). 버튼(↑↓·추가)·키보드(Alt+↑/↓·Delete·Ctrl+D·Enter·Esc·Ctrl+S·Ctrl+Z/Ctrl+Shift+Z) 대안.
- **반응형·접근성**: 팔레트·속성 창은 캔버스 옆, 900px 이하에서 아래로 내려가며 접힌다(캔버스를 가리지 않음). 전환 버튼 `aria-pressed`, 도구 줄 `role=toolbar`, 상태 `aria-live=polite`, 팔레트·속성 창 `role=region`+`aria-label`, 접기 `aria-expanded`, 캔버스 도구 버튼 `aria-label`·포커스 표시.
### 도입 소스(출처)
- 원본 저장소 `https://source.ibit.kr/OZ-Group/WebHosting_AI_agent`, **고정 커밋 `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`**(Webmaker 1.8.0 — `feature/visual-editor-v1.8.0`·main 원격 ref 검증됨; 0.1.1 의 불변 기준 커밋 `3b10859884ef2fd0f2af11934ab6c16370fd64b8`(1.7.3) 위의 변경). 개발 중에는 커밋 전 작업 트리(참조 저장소 worktree `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`)에서 `node scripts/import-sources.mjs --only webmaker --allow-dirty` 로 가져왔고, 커밋 뒤 `scripts/import-sources.mjs` 의 `pin` 을 `4b8c4d0` 으로 올렸다. 깨끗한 도입 기록(`IMPORTED_SOURCES.json/.md`, 그 pin 으로 `--allow-dirty` 없이 다시 만든 것)의 webmaker 항목: `commit` = `upstream.headCommit` = `4b8c4d0`, `upstream{version:'1.8.0', branch:'feature/visual-editor-v1.8.0', workingTreeDirty:false}`, 파일 184개(0.1.1 의 176 + 신규 8: `lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`, `test/visual-editor.test.js`, `test/visual-editor-server.test.js`, `test/visual-editor-app.test.js`, `docs/verification/visual-editor-1.8.0-tdd.md` — 모두 원본과 동일), 수정 8개(0.1.1 과 같은 `lib/auth.js lib/config.js lib/store.js lib/vault.js public/app.js public/index.html public/style.css server.js` — Suite 블록 때문). 이 작업 트리에 있는 `IMPORTED_SOURCES.json/.md` 는 아직 개발 중 기록(기준 `3b10859`, `workingTreeDirty:true`)이라 Neo 가 올린 pin 으로 다시 만든다(이 작업 트리의 참조 체크아웃은 그 커밋에 있지 않아 여기서는 재생성하지 않았다).
- 원본 경로 → 대상 경로는 `IMPORTED_SOURCES.md` 표(`modules/webmaker/<같은 경로>`). 라이선스 검토: OZ-Group 내부 전용(private, 별도 LICENSE 없음) — 같은 조직의 Suite 재사용 허용, 외부 배포 불가(0.1.1 과 같음). AriMail 은 변경 없음(a07f69f).
- **실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트)** — 직접 편집을 켜도 옛 작업 칸(`.left`)이 남아 1280×633 에서 캔버스 iframe 이 115px 로 줄고 390×844 에서 캔버스가 y≈2228 로 밀리는 출시 차단 결함. `body.ve-on` 의 CSS 배치 규칙이 없던 것이 원인(JS 전환은 정상, 가짜 DOM 실행 테스트로 확인). `modules/webmaker/public/style.css` 에 `body.ve-on .left { display:none }`·`body.ve-on .app { grid-template-columns: var(--rail-w) minmax(0,1fr) }`(1280px 캔버스 568px)·1180px 이하 패널 190px·900px 이하 레일+캔버스 두 행 규칙을 추가(독립 Webmaker 와 동일). 끝내면 원래 세 칸 배치 복원. 회귀: CSS 계약 테스트 2건 + JS 전환 동작 테스트 1건(`test/visual-editor.test.js`).
- **실제 브라우저 시각 검증 수정 2(배치 수정 뒤 게이트)** — 직접 편집을 켜고 요소를 고르기 전인데 어두운 선택 도구 줄(`.wm-ve-tools`)이 이미 페이지 위에 보이는 출시 차단 결함(DOM 은 `hidden`, 속성 창은 "선택한 요소 없음"). 캔버스 런타임의 작성자 규칙 `.wm-ve-tools{display:flex}` 가 UA 의 `[hidden]{display:none}` 을 덮어쓴 것이 원인. `modules/webmaker/public/wm-ve-canvas.js` 런타임 CSS 에 `.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}` 한 줄 추가(독립 Webmaker 와 byte-identical). `renderOverlay()` 가 `hidden=false` 로 바꿀 때만 보이고 선택 해제·미리보기·`applyHtml` 뒤 다시 숨겨진다. 회귀: 런타임 CSS 를 파싱해 `hidden` 요소의 최종 display 를 cascade 모형으로 계산하는 계약 테스트 1건(`test/visual-editor.test.js`, RED actual 'flex' → GREEN).
- **실제 브라우저 게이트 3(Playwright 자동 게이트)** — 실제 Chrome 에서 frameLocator 로 캔버스 iframe 을 직접 조작해 선택·글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동은 통과했지만, 팔레트 → 캔버스 실제 HTML5 끌어다 놓기가 들어가지 않는 출시 차단 결함. 부모는 전용 MIME `text/wm-ve-component` 만 넣는데 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 출처 'null' 문서라 Chromium 이 다른 출처의 전용 MIME 끌기 자료를 넘겨주지 않는 것이 원인. 전용 MIME 은 유지하고 표준 `text/plain` 에 `wm-ve-component:<id>` 를 함께 넣으며, 캔버스는 `core.dragComponentId` 한곳에서 전용 MIME 우선 → 정확히 일치하는 text/plain + 알려진 id 만 받는다(임의 글·모르는 id 는 삽입·표식 없음). `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`(독립 Webmaker 와 byte-identical). sandbox·CSP·메시지 검증·저장 검증·"추가" 버튼 대안 불변. 회귀: 순수 규칙·팔레트 dragstart·캔버스 동작(가짜 DOM 위 실제 런타임 부트) 3건(`test/visual-editor.test.js`, RED → GREEN).
- **실제 브라우저 게이트 4(살균 DnD 진단) — 포인터 끌기 다리** — MIME 보완 뒤에도 끌어다 놓기가 실패해 부모·캔버스 양쪽에 살균 리스너(종류·플래그만)를 붙여 확인: 부모 dragstart 2건(두 종류 모두 유효)인데 캔버스 dragenter·dragover·drop 0건. Chromium 은 HTML5 끌기 자체를 부모 → 출처 'null' sandbox iframe 으로 넘기지 않으므로(제품 결함) sandbox 를 유지한 채 포인터 다리를 주 경로로 넣었다: 부모 팔레트 항목이 포인터(마우스·펜)를 추적해("추가" 버튼·터치·보조 버튼 제외, 포인터 캡처, 4px 문턱) iframe 안쪽일 때만 상대 좌표를 검증된 메시지 `paletteDragAt`/`paletteDropAt`(밖·Escape·pointercancel·끝내기는 `paletteDragCancel`)로 보내고, 캔버스가 메시지 규약 뒤 id(`core.isComponentId`)·좌표(`core.canvasPoint`)·편집 모드를 다시 검사해 스스로 놓기 표시·삽입을 한다(잘못된 입력은 삽입·표식 없음). HTML5 DnD 는 대체 경로로 남김. `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`(독립 Webmaker 와 byte-identical) + `style.css` 2줄(`.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class). 터치 스크롤·"추가" 버튼·키보드·CSP·저장 검증 불변. 회귀: 규약·좌표 순수 규칙 / 부모 포인터 순서 동작 / 캔버스 수신 동작 3건(`test/visual-editor.test.js`, RED → GREEN).
- **독립 검토(읽기 전용) 차단 항목 12건 수정** — 보안 7·규격/회귀 5. 공유 파일은 참조 저장소와 byte-identical 로 반영하고 Suite 블록 파일(`server.js`·`app.js`·`lib/store.js`)은 패치했다(상세·RED 증거: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`). ① 캔버스 연결 규약 — 캔버스가 `MessageChannel` 포트를 첫 'ready' 창 메시지에 동봉하고 그 뒤 모든 명령·이벤트는 포트로만(`core.validatePortMessage`); 부모는 첫 ready 한 번만 연결·연결 뒤 창 메시지 전면 무시·끝내기 때 포트 닫음; 캔버스는 창 메시지를 듣지 않음(iframe 안 문서가 바뀌어도 세션·명령을 이어받을 수 없다). 심층 방어: a/area/제출 클릭·submit 차단(두 모드), `<base target="_blank">`, CSP form-action none, meta refresh 비활성화. ② 주소 스킴 정규화 — 서버·화면·캔버스가 한 구현(`core.urlScheme`)으로 이름·숫자 엔티티(두 겹)·제어·폭 없는 문자를 걷어내고, 스킴 자리의 모르는 엔티티는 거절. ③ `prepareCanvas` 가 구조 스캐너로 진짜 head/html/body 를 찾아 CSP·런타임을 넣고 없으면 진짜 `<head>` 생성. ④ 저장 검증을 완성 문서 전체 ↔ 원본 전체 비교로(머리 요소를 본문에 복사 불가). ⑤ 사이트별 변경 잠금 `withSiteLock` — 소스/직접 편집 PUT·버전 복원은 본문을 읽은 뒤 잠금 안에서 실행 중·배포 중·기록(rev)·hash 재검사 → 쓰기; AI/기획/가져오기/복사/다국어/배포 시작은 `claimJob`; 소스 편집기 PUT 에 선택적 `baseHash`(화면이 보냄, 409 때 다시 불러오기 질문). ⑥ 직접 편집 저장 트랜잭션 — 기록/Git 실패 때 페이지·사이트 기록(rev 포함, `store.save(prev,{restore:true})` 는 rev 를 올리지 않음)을 되돌리고 커밋을 되감아 500(`gitRecord` strict; 종결 검토 ② 에서 원격 푸시 순서·검증된 되돌리기로 재설계). ⑦ **Suite 전용**: 직접 편집 저장은 유한한 숫자 `rev` 필수(없음·문자열·null → 400 `revRequired`, 오래됨·다른 파일 저장으로 오른 rev → 409). ⑧ 전환 보호 — `openSite` 는 전환 전에 묻고 취소하면 현재 사이트 유지, 홈 탭도 묻고, `onSiteUpdated`/`renderPreview` 는 HTML 페이지가 없어져도 변경이 있으면 묻고 캔버스를 남겨 둔다. ⑨ 복제가 자손 id 도 제거. ⑩ 블록 끌기 — 손잡이 포인터 캡처, pointercancel·창 blur·Escape 가 상태를 모두 정리. ⑪ **Suite 전용**: 새 `lib/provenance.mjs` — `scripts/release.mjs build` 는 IMPORTED_SOURCES 의 출처가 깨끗하지 않으면(workingTreeDirty=true·불완전·commit≠headCommit) artifact 를 만들지 않고 종료 1, manifest.json 에 `checks.provenanceClean/provenanceProblems`·`imported[].upstream`; `register --approve` 는 `provenanceClean !== true` 면 승인하지 않고, `hal/core.approveRelease` 는 `provenanceClean === false` 를 `release_checks_failed` 로 거절(종결 검토 ③ 에서 세 검사 모두 명시적 통과 필수로 강화). 개발 중 기록(`workingTreeDirty: true`)으로는 build 가 멈췄다 — 참조 저장소 1.8.0 이 `4b8c4d0` 으로 커밋·검증되어 pin 을 올렸고, 그 pin 으로 다시 만든 깨끗한 기록(`workingTreeDirty:false`, 184 파일·수정 8)으로 build·approve 가 된다. ⑫ 문서 수치를 최종값으로 맞추고 브라우저 게이트 상태를 기록. 도입 기록(`IMPORTED_SOURCES.json`, 생성 manifest 기준): webmaker **184개 파일**(수정 8: lib/auth.js·lib/config.js·lib/store.js·lib/vault.js·public/app.js·public/index.html·public/style.css·server.js), 고정 커밋 `3b10859`, upstream 1.8.0 `feature/visual-editor-v1.8.0` workingTreeDirty=true. upstream 대비 Suite 블록 차이: `server.js` 115줄(107 → 115), `app.js` 4줄, `style.css` 5줄, `index.html` 2줄, `lib/store.js` 4줄.
- **브라우저 게이트 상태** — Neo 의 Playwright 게이트(네이티브 포인터 순서)는 이 검토 **전** 에 37/37 통과. 수정 뒤 Neo 의 첫 재실행은 `edit-text` 에서 멈췄는데 원인은 게이트 멱등성 결함(같은 저장 폴더·같은 고정 제목 → 변경 없음 = clean)이었고 제품 결함이 아니다(로컬 결정적 재현). 같은 조사에서 가운데 클릭(`auxclick`) 틈을 찾아 캔버스 런타임에 차단을 더했다. 고친 게이트(실행마다 다른 제목, 연결 규약·이동 활성화 6종·위조 창 메시지 단언 추가)는 로컬 Chrome 156 에서 61/61(연속 2회), **Neo 호스트의 실제 Chrome 에서도 61/61 통과(2026-10-28)** — 수정 뒤 브라우저 재실행은 완료되었고 남은 재실행은 없다(종결 검토 3건은 브라우저 동작을 바꾸지 않는다).
- **독립 검토 2차(종결 검토) 차단 항목 3건 수정 + 문서 수정** — 참조 저장소와 같은 코드(공유 파일 byte-identical, `server.js` 는 Suite 블록 보존 패치; 상세·RED/GREEN: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`, 변경 이력 §9). ① **SVG SMIL 저장형 XSS 우회(보안)** — `validateVisualSave` 가 `<animate attributeName="href" values="javascript:…">`·`<set …>` 를 받았다(정적 주소만 검사, 위험 요소 목록에 SMIL 없음; Chromium 에서 실행 확인). `lib/visual-editor.js` 가 SMIL 요소 전부(`animate·set·animateMotion·animateTransform·animateColor·discard·mpath`, 대소문자·`svg:` 접두어 포함)를 위험 요소 묶음으로 세어 원본 수까지만 허용(삭제 가능, 추가·변경 거절), 캔버스에서는 `wm-ve-x-<이름>` 비활성 이름으로 바꿨다가 복원 때 표준 이름으로 되돌린다; `core.isSafeComponentHtml` 도 거절. ② **Git 저장 트랜잭션 재설계** — `gitstore.recordChangeStrict`: 스냅샷 → 로컬 커밋·변경 브랜치 → 사이트 기록 저장(rev +1) → 원격이 있으면 main + 변경 브랜치를 `git push --atomic` 한 번으로(마지막 단계, 그 뒤 상태 변경 없음); 어느 단계가 실패해도 `reset --mixed`(커밋 되감기 + 인덱스 비움, 첫 커밋 전이면 브랜치 삭제 + 빈 인덱스)·변경 브랜치 삭제/복원·페이지 원본·`store.save(prev,{restore:true})`(rev 보존) 뒤 **검증**(HEAD·인덱스·브랜치·작업 트리·페이지 바이트·기록) — `rolledBack:true` 는 검증 통과 때만, 아니면 `rolledBack:false`+`rollbackProblems`. 강제 푸시 되돌리기 없음, 원격 없는 저장소는 기존처럼 로컬 커밋. 푸시 **전** 에 저장되는 기록은 원격 반영을 주장하지 않는다(`pushed:false, remote:'pending'`, 응답도 같음) — 별도 조정 `reconcilePushes`(저장 뒤·기동 때·다음 기록 때, 잠금 안)가 `ls-remote` 로 원격 ref 를 확인한 뒤에만 `pushed:true, remote:'verified'` 로 바꾸고, 원격에 없으면(기록 저장과 푸시 사이에 프로세스가 죽은 경우) 로컬 변경 브랜치 + main 을 원자적으로 올린 뒤 확인한다. 조정의 기록 저장은 `{restore:true}` 라 rev 를 올리지 않는다(Suite 동시 편집 충돌 규칙과 무관한 메타데이터). ③ **Suite 전용 — 릴리스 승인 fail-open**: `hal/core.releaseApprovalProblem` 한 규칙으로 `testsPassed === true`·`dirty === false`·`provenanceClean === true` 를 모두 요구(없음·null·문자열 거절) — `approveRelease`·`registerRelease(approve:true)`·운영자 API `POST /ops/api/releases/:v/approve`·`scripts/release.mjs register --approve`(DB 열기 전) 모두 적용, `--skip-tests` 빌드는 등록만 가능. 기존 테스트 픽스처(hal-cli·hal·e2e 가짜 릴리스)는 깨끗한 checks 를 넣도록 고쳤다(옛 관용 정책 없음). ④ 문서 — "재실행 필요/미실행"·"자동화하지 않음"·옛 수치(103/110·182 파일·HTML5 주 경로·release.mjs 변경 없음)를 현재 증거로 고쳤다. 도입 기록 재생성: webmaker 184 파일·수정 8·workingTreeDirty=true(의도 유지 — 참조 저장소 커밋 뒤 재도입).
- **독립 검토 3차(최종 검토) 차단 항목 4건 수정** — ① **Suite 전용 · 승인된 릴리스 불변**: `hal/core.registerRelease` 가 승인된 버전을 다른 내용(artifact·checks·커밋·빌드 시각·메모)으로 재등록하면 `release_immutable` 로 거절하고 아무것도 바꾸지 않는다(같은 내용 재등록은 멱등, approve 요청이어도 그대로); 승인되지 않은 버전의 내용 변경 재등록은 registered 로 두고 승인을 물려받지 않으며 approve 는 세 검사 규칙(`releaseApprovalProblem`)을 지나야 한다. `hal/index.mjs register-release` 는 거부를 종료 1 로 알린다(DB 불변). ② **사이트 변경 잠금 전면 감사**(공유 코드, Suite 블록 보존): `/api/sites/<id>/…` 변경 요청은 본문을 잠금 밖에서 메모 읽기 → 잠금 안에서 기록 재적재 → 짧은 경로는 처리기 전체를 잠금 안에서, 긴 작업 경로는 재적재한 기록으로(claimJob 까지 await 없음); 재진입 잠금(AsyncLocalStorage)·`detached()`; `changeLanguages` 는 claimJob 먼저. Suite 의 rev 충돌 검사는 재적재된 최신 기록으로 이루어진다. ③ **트랜잭션 완결성·모호한 푸시 판정**: `recordChangeStrict` 의 준비 단계(initSite·스냅샷·원격 설정)도 트랜잭션 안(실패하면 onRollback·새로 만든 저장소 삭제·스냅샷 전 실패는 ok:false), 푸시 오류는 원격 ref 를 다시 읽어 성공/원격 불변(되돌림 + 원격 재검증)/모호(pending 유지, 200 + `remoteState:'ambiguous'`, rolledBack 주장 없음)로 판정. ④ 문서 합계 통일. ⑤ **배경 작업 저장 합치기(저장소 불변식, Suite 블록 포함)**: `modules/webmaker/lib/store.js` 가 읽은 객체마다 읽었을 때 상태(`__base`, 열거 안 됨)와 저장 표식 `saveId` 를 기억하고, 디스크 기록이 그 사이 다른 객체로 저장되었으면 이 객체가 바꾼 최상위 항목만 디스크 최신 기록 위에 합쳐 저장한다 — 배경 AI 작업의 종료 저장이 작업 중의 기본 사항·모델 등급 변경을 지우지 않는다(RED: 제목이 "VE site" 로 되돌아감 → GREEN). Suite 의 `rev` 는 합칠 때 디스크 값을 기준으로 올리고, 원격 반영 조정은 `keepRev`(합치되 rev 불변)로 저장한다(`restore` 는 스냅샷 그대로). upstream 대비 `lib/store.js` 차이는 5줄(rev·keepRev). ⑥ **같은 잎 동시 변경 fail-closed**: 합치기를 잎 단위 3자 병합으로 바꾸고(같은 최상위 항목의 다른 잎은 합침), 같은 잎을 둘 다 다르게 바꾼 경우만 `site_save_conflict`(409, fields)로 거절해 디스크의 먼저 쓴 값을 보존한다. 배경 작업의 모든 저장(실패·중단 기록 포함)은 `saveJob` 으로 가서 충돌 때 `store.update` 로 최신 기록을 다시 읽어 작업이 가진 항목만 다시 적용하고 객체를 맞춘다(`store.adopt`); 다시 푸시·배경 조정은 원격 ref 결과를 `store.update` 로 적용. RED: 작업 중 다시 푸시가 쓴 `verifiedAt` 이 작업 종료 저장에 지워지고 배경 재확인이 다른 시각을 씀 → GREEN. 상세·RED/GREEN: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(변경 이력 §10).
- **독립 검토 4차 차단 항목 4건 수정** — ① **Suite 전용 · artifact 설치 불변·fail-closed**: `hal/core.installArtifacts` — 원본 바이트의 sha256·크기를 manifest 와 대조(불일치 `artifact_hash_mismatch`, 아무것도 쓰지 않음), 승인된 버전은 메타데이터·설치 파일 해시가 같을 때만 멱등(복사 없음)·다르면 `release_immutable` 로 파일·DB 불변 거절, 새·미승인 설치는 임시 파일 → 해시 재확인 → rename. `hal/index.mjs register-release`·`scripts/release.mjs register` 모두 설치 → 등록 순서로 이 함수를 쓰고 거부는 종료 1. ② **읽기 요청 부작용 제거**(공유 코드): GET/HEAD/OPTIONS 는 `x-ui-lang` 으로 저장하지 않는다(saveId·rev 불변) — 언어는 변경 요청의 잠금 안 재적재에서만 저장. ③ **폐기 잠금·표식 규약**(공유 코드, Suite 의 포털 영향 확인 블록 보존): 잠금 안 재검사 + 폐기 표식 원자적 claim → 잠금 밖 배포 서버 정리 → 다시 잠금·같은 표식 확인 뒤 삭제; 표식 중 모든 변경 요청·작업 시작 409; 실패는 표식 해제 + `store.update` 로 `deleteFailed`. **포털 연결 보상(잔여 위험 해소)**: 연결 해제(`ariweb.unlink`)를 정리 **전** 에 하던 것을 공유 `destroySite` 의 `beforeIrreversible` 훅으로 옮겨 배포 서버 정리 성공·사이트 폴더/기록을 임시 이름으로 옮긴 **뒤**(되돌릴 수 없는 기록 삭제 직전)에만 하고 readback 으로 확인한다; 정리가 실패하면 연결·파일·기록이 모두 그대로, 연결 해제가 실패하면 옮긴 것을 되돌리고 중단, 기록 삭제가 실패하면 `compensate`(`ariweb.link` 로 되살리기 + readback, `linkedAt` 보존) 뒤 실패로 알린다(되돌리지 못하면 `recoveryRequired` + 감사 `site.destroy.failed`). 뒷정리(폴더 등) 실패는 성공 응답의 `warnings` 와 감사 `site.destroy.cleanup` 으로 운영자에게 보인다. ④ **파서 차이(저장형 XSS)**(공유 코드): 스캐너가 svg/math 안에서는 title·textarea·style·script 를 원문으로 읽지 않아 `<svg><title><script>` 가 거절·비활성화된다. 상세·RED/GREEN: `docs/verification/webmaker-visual-editor-0.2.0.md`, upstream `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(변경 이력 §11).
### 테스트
- 독립 검토 4차 4건 수정 뒤(**현재 증거**): 참조 저장소 `npm test` **115/115**(단위 39 + 서비스 23 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **125/125**(Suite 전용 `test/ariweb.test.js` 10 — 폐기 보상 3 추가), Suite `node test/run.mjs` **24개 파일 실패 0**(`test/release-provenance.test.mjs` 11건), 실제 Chrome 게이트(Neo 호스트) 61/61 그대로. RED: Suite artifact 불변 2 실패(승인된 바이트가 B 로 덮임), 참조 단위 38/39(svg title script 통과)·서비스 21/23(읽기가 기록 파일을 바꿈, 폐기 중 저장 미거절), Suite 모듈 폐기 보상 7/10(정리 실패 뒤 연결이 풀림·연결 해제 실패인데 정리를 먼저 하지 않음·되살리기 없음).
- 최종 검토(3차) 4건 + 잎 단위 저장소 합치기(fail-closed) 수정 뒤: 참조 저장소 `npm test` **112/112**(단위 38 + 서비스 21 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **119/119**, Suite `node test/run.mjs` **24개 파일 실패 0**(`test/release-provenance.test.mjs` 9건), 실제 Chrome 게이트(Neo 호스트) 61/61. RED: Suite 승인 불변 core·CLI 2 실패(재등록이 승인자를 바꿈), 참조 서비스 15/18(잠금 경쟁·모호한 푸시·준비 단계 실패).
- 종결 검토 3건 + 원격 반영 보수 기록 수정 뒤: 참조 저장소 `npm test` **107/107**(단위 38 + 서비스 16 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **114/114**, Suite `node test/run.mjs` **24개 파일 실패 0**(`test/release-provenance.test.mjs` 7건 — 승인 규칙 3건 추가). RED: 참조 저장소 단위 37/38(SMIL 1 실패)·서비스 11/14(Git 실패 되돌리기 검증·원격 트랜잭션 실패), 그 뒤 서비스 13/16(푸시 순간의 기록이 `pushed:true` 를 주장 — 보수 기록 수정 전), Suite provenance 4/7(core 승인·운영자 API·release.mjs 3 실패). 실제 브라우저 게이트(Neo 호스트 실제 Chrome) 61/61.
- 독립 검토(1차) 차단 항목 수정 뒤: 참조 저장소 `npm test` **103/103**(단위 37 + 서비스 13 + 화면 전환 1 + 원본 52), `modules/webmaker` `npm test` **110/110**, Suite `node test/run.mjs` **24개 파일 실패 0**(새 `test/release-provenance.test.mjs` 4건 포함, `test/webmaker-visual-editor.test.mjs` 5건은 rev 필수·교차 파일 409·되돌리기 rev 보존 검사를 더함). RED: 참조 저장소 단위 24/37(13 실패)·서비스 9/13(4 실패)·app 0/1, Suite provenance 0/4, Suite rev(rev 줄을 뺀 임시 서버 복사본) 4/5.
- 포인터 끌기 다리 뒤: 참조 저장소 `npm test` **87/87**(단위 26 + 서비스 9 포함), `modules/webmaker` `npm test` **94/94**, Suite `node test/run.mjs` **23개 파일 실패 0**.
- 끌어다 놓기 수정 뒤: 참조 저장소 `npm test` 84/84(단위 23 + 서비스 9 포함), `modules/webmaker` `npm test` 91/91, Suite `node test/run.mjs` 23개 파일 실패 0.
- 도구 줄 수정 뒤: 참조 저장소 `npm test` 81/81(단위 20 + 서비스 9 포함), `modules/webmaker` `npm test` 88/88, Suite `node test/run.mjs` 23개 파일 실패 0.
- 배치 수정 뒤: 참조 저장소 `npm test` 80/80(단위 19 + 서비스 9 포함), `modules/webmaker` `npm test` 87/87, Suite `node test/run.mjs` 23개 파일 실패 0.
- 검토 수정 뒤: 참조 저장소 `npm test` 77/77(단위 16 + 서비스 9 포함), `modules/webmaker` `npm test` 84/84, Suite `node test/run.mjs` 23개 파일 실패 0. 아래는 첫 인도 때 수치.
- Webmaker 참조 저장소(1.8.0): `test/visual-editor.test.js` 15건(문서 나누기·캔버스 변환·복원·저장 검증 허용/거절·hash·공용 핵심), `test/visual-editor-server.test.js` 7건(권한·출처·CSRF / 허용 페이지 / 캔버스 계약 / 저장·버전·캐시 미차감 / 오래된 저장 409·잘못된 본문 400 / AI 작업 중 409 — 가짜 claude 실행 파일로 작업을 멈춘 두 번째 서버 / 화면 접근성 조작·런타임 서빙). RED(모듈 없음·경로 404·버튼 없음) → GREEN 기록: `docs/verification/visual-editor-1.8.0-tdd.md`. 전체 `npm test` **74/74**(원본 52 + 22).
- Suite: `test/webmaker-visual-editor.test.mjs` 5건(도입 파일·버전·IMPORTED_SOURCES upstream / 순수 모듈 / 공용 핵심 / 화면 접근성 + Suite 배너 보존 / **실제 서버로 rev 충돌 409 → 맞는 rev 저장 200·버전 visual-edit·rev 증가**). RED(0/5) → GREEN(5/5): `docs/verification/webmaker-visual-editor-0.2.0.md`. `modules/webmaker` 전체 `npm test` **81/81**(59 + 22). Suite 전체 `node test/run.mjs` **23개 파일 실패 0**(로컬 E2E 포함, `docs/verification/local-e2e-2026-10-28.md/.json` 생성). 모든 서버 테스트는 `CLAUDE_BIN` 을 즉시 실패하는 가짜 실행 파일로 두고 실행했다(개발 PC 의 진짜 claude 가 사이트 생성 작업으로 실행되지 않게 — 첫 RED 실행에서 한 번 실행되어 즉시 종료시켰고, 테스트를 고쳤다).
### 알려진 제한
- 실제 브라우저 검증은 Neo 의 게이트(시각 게이트 1280×633·390×844 → Playwright 자동 게이트, 실제 Chrome)로 다섯 차례 수행되어 배치·도구 줄·끌어다 놓기 결함을 찾아 고쳤고, 독립 검토 수정 뒤 재실행(게이트 멱등성 수정 뒤)은 **Neo 호스트에서 61/61 통과** — 캔버스 안 실제 포인터 조작(contenteditable 입력·실행 취소/다시 실행·이동·팔레트 포인터 끌어다 놓기·저장·연결 규약·이동 활성화·위조 메시지)은 이 게이트로 자동 검증했다. Node 에서는 문법 검사·`eval` 부재·정적 구조·CSS 배치 계약·가짜 DOM 위 실제 런타임/app.js 실행을 확인. 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면·운영 서버 배포·Chrome 외 브라우저 — 사람 확인 목록은 Webmaker `Dev_Order_v1.8.0.md` 끝.
- 본문은 브라우저 직렬화(innerHTML)로 정규화된다(속성 큰따옴표·`&`→`&amp;`·태그 소문자·void 요소 `/>` 제거) — 의미는 같고 머리·doctype·꼬리는 글자 그대로. `</body>` 뒤 스크립트는 브라우저가 본문으로 옮기므로 저장 때 꼬리의 같은 스크립트를 제거해 중복을 막는다.
- 캔버스는 페이지 스크립트를 실행하지 않으므로 스크립트가 만드는 동적 내용(슬라이더·모듈 위젯 목록)은 빈 틀로 보인다. 구성 요소는 7종의 첫 릴리스, 블록 이동은 같은 문서 안. 다국어 사이트는 페이지마다 따로 편집(자동 재번역 없음 — 기존 다국어 카드 흐름 사용).
- 참조 저장소 1.8.0 은 `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088` 으로 커밋·검증되어 `scripts/import-sources.mjs` 의 webmaker `pin` 을 그 커밋으로 올렸다. 이 작업 트리의 `IMPORTED_SOURCES.json/.md` 는 아직 개발 중에 만든 기록(기준 `3b10859`, `workingTreeDirty:true`)이며, 그 pin 으로 `node scripts/import-sources.mjs --only webmaker`(`--allow-dirty` 없이)를 돌려 다시 만들면 `commit = upstream.headCommit = 4b8c4d0`, `workingTreeDirty:false`, 184 파일·수정 8 의 깨끗한 기록이 되어 build·approve 가 가능하다(여기서는 참조 체크아웃이 그 커밋에 있지 않아 재생성하지 않았다). 0.1.4 지시(HAL AI 대시보드 등)는 이번 범위 밖으로 구현되지 않았다. 운영 서버 배포·검증·점검은 하지 않았다(출시 선언 없음).
### 미확정 정책값
- 0.1.1 의 미확정 정책값(`alias_counting`·`large_attachment`·`ai_wallet_pricing`, 후속 상품정책 값)은 그대로다. 직접 편집은 AI Wallet 을 쓰지 않으므로 과금 정책에 새 항목이 없다. 운영자가 뒤에 정할 수 있는 값: 직접 편집 본문 상한(현재 소스 편집기와 같은 5MB 고정), 실행 취소 기록 수(화면 200개 고정), 구성 요소 목록 확장.
## 0.1.1 (2026-10-26)
- **작성자**: 개발 지시 — Hera(`Ariweb-Suitle_Dev-Order_v0.1.1.md`, 통합 검토: Hera v0.1.0·Neo v0.1.0·1차 개발계획서) · 구현 — Claude Fable 5.1(Ariweb Suite 통합 개발, 커밋 Co-Authored-By 표기)
- **이전 버전**: 0.1.0 — 개발 지시 문서(`Ariweb-Suitle_Dev-Order.md`)만 저장소에 있었고 코드는 없었다. 0.1.1 은 Ariweb Suite 의 첫 구현 릴리스다.
- **정식 저장소**: https://source.ibit.kr/OZ-Group/AriWeb-Suite (브랜치 `release/0.1.1` → `main` 병합)
- **구성**: HAL(제어 평면·운영자 콘솔·공개 상태) · 고객 포털 · Node Agent · 도입 모듈 Webmaker 1.7.3 · AriMail 1.0.11. 외부 npm 의존성 없음, Node 22.13+.
### 경고 구간 변경
- 사용량 경고 구간을 **70% · 80% · 90% · 95%** 네 단계로, 100% 에서 자원별 제한으로 통일했다(v0.1.0 지시의 구간 체계를 대체). 값은 코드에 고정하지 않고 상품정책 버전(`product_versions.warn_levels_json`, 기본 `[70,80,90,95]`)에서 읽는다.
- 단계별 동작(§13.4): 70% Portal 사전 안내 → 80% Portal + 외부 이메일 → 90% 예상 소진 시각과 증설·정리 방법을 강조한 긴급 경고 → 95% 곧 제한될 기능과 즉시 조치를 적은 최종 경고 → 100% 자원별 제한과 유지 기능·해제 방법 표시. 같은 원인의 반복은 하나의 사건(incident key `<그룹>:<자원>:<대상>:<구간>`)으로 묶인다.
- 100% 제한의 자원별 의미: 웹 아웃바운드 → 사이트 대신 제한 안내, 다음 KST 월 또는 추가 용량 뒤 복구 · 웹 디스크 → 조회·삭제·내보내기 유지, 새 업로드·DB 증가·새 배포 제한 · 메일 저장 공간 → 조회·삭제 유지, 새 수신 임시 거부(발신 서버 재시도) · 월간 메일 통수 → 새 발신 차단 + 새 수신 임시 거부 · 메일함 수 → 새 메일함 생성 차단. 고객 데이터는 자동 삭제하지 않는다.
- AriMail 쪽에도 같은 구간을 적용해 `/api/usage` 가 `warnLevel`·`limitedReasons`·`monthReset` 을 돌려준다.
### 재사용한 기존 기능(그대로 도입)
기능별 상세 표는 `portal/webmaker/REUSE.md`(Webmaker, 원본 revision `4b8c4d0` — 1.8.0; 0.1.1 기준 `3b10859`)와 `portal/mail/REUSE.md`(AriMail, 원본 revision `a07f69f`)에 있다. 두 모듈의 원본 회귀 테스트는 도입 뒤에도 모두 통과한다(Webmaker 59/59 — 원본 52 + 추가 7, AriMail 9개 묶음 모두 통과).
- **Webmaker(§0.1)**: `AI에게 맡기기`·`내가 고르기` 제작, Site Brief, Template 과 기존 Module, Page 생성·편집·미리보기·저장, AI 생성·수정과 Wallet/Cache 차감, 저장본 선택, Domain·DNS·TLS 관리(등록 업체·DNS 공급자·와일드카드 인증서), Gitea Source 연결과 변경 이력, 배포 결과 확인과 이전 Version 복구, AriMail Server 연결과 Mail 사용량 표시, 사용자·관리자 화면의 설정·상태·통계. 포털 사이트 상세 › 제작 탭의 "제작 화면 열기"(SSO) 로 진입한다.
- **AriMail(§0.3)**: Multi-domain Mail Service, Webmail(수신함·발신함·작성·회신·전달·검색), SMTP 발신·수신, IMAP·POP3, Resend·Haraka·External SMTP 발송 방식, Mail Domain·Account 관리, Mailbox 용량, 자동 응답·전달, 일반 첨부·링크 방식 대용량 첨부, POP3·IMAP 설정, DNS 설정·도메인 확인, 사용량 API, Webmaker 연동, 초기 설정·관리자·진단. 포털 메일 화면의 "웹메일 열기"(SSO 티켓) 와 외부 프로그램 설정 안내로 진입한다.
- **공통 라이브러리에 옮겨 적은 기존 방식**: Webmaker 의 원자적 JSON 저장·세션 세대·고정 관리자 비밀번호 폐지·scrypt 해시(→ `lib/db`·`lib/session`·`lib/crypto`), AriMail 의 의존성 없는 SMTP 클라이언트와 `node:sqlite` 운영(→ `portal/mailer.mjs`, `lib/db`), Webmaker `deploy/remote-update.sh` 의 갱신 순서(데이터 백업 → 교체 → 재시작 → 유닛 점검)와 AriMail `deploy/deploy.sh` 의 배포 순서(DB 백업 → 릴리스 복사 → 마이그레이션 → 재시작 → health → Haraka)를 HAL 모듈 설명자(`hal/modules/index.mjs`)와 Agent 모듈 설치기(`agent/jobs/module.mjs`)에 그대로 반영.
### 수정한 기존 기능(최소 연결 변경·오류 수정·정책 개선)
- **Webmaker(§0.2, `modules/webmaker`, 파일 머리 `[Ariweb Suite 변경]`)**
- 최소 연결 변경: `GET /api/auth/sso?ticket=`(포털 서명 티켓으로 별도 로그인 없이 진입, 계정 `aw-<그룹8>-<사용자8>` 자동 준비) · 내부 API `/api/internal/ariweb/*`(프로젝트 목록·연결·소스·저장본 export·credits·health, 서비스 서명) · 상단 배너에 현재 사용자 그룹·사이트 표시 · 설정 그룹 `ariweb.*`(포털 주소, 공유 비밀은 보관소) · `lib/ariweb.js`(티켓 검증·연결 등록부 `data/ariweb-links.json`).
- 정책 개선: 프로젝트와 운영 사이트를 구분(연결 등록부, "프로젝트" 표기), 연결된 프로젝트의 발행/재배포는 포털 배포 화면으로 안내(409 + 배포 URL), 프로젝트 삭제 전 포털 영향 조회(`/internal/webmaker/project-impact`)와 확인 필수 — 운영 사이트·배포본·도메인은 자동 삭제하지 않음.
- 오류 수정/개선: 여러 사용자의 동시 편집을 조용히 덮어쓰지 않도록 사이트 저장본 `rev` 를 두고 불일치 시 409 와 양쪽 요약을 돌려준다.
- 포털 쪽 정책: Draft 사이트는 운영 사이트 수를 쓰지 않고 최초 공개 배포 때 `예약됨` → 대표 도메인 확인 뒤 `사용 중`; AI Wallet(Webmaker credits)과 호스팅·메일 사용량을 분리 표시; 외부 자가 배포는 비용·책임·상태를 구분.
- **AriMail(§0.4, `modules/arimail`, 15개 파일, 회귀 테스트 `test/ariweb.test.mjs` 8건 추가)**
- 오류 수정: `test/usage.test.mjs` 의 KST·UTC 월 경계 — 상대 시각(`daysAgo`) 을 KST 정오 기준으로 고정하고 `limits.statsNow`/`monthlyCount(env, at)` 로 고정 시각 검사(2026-10-31T14:59:59Z ↔ 15:00:00Z 경계 테스트) · Haraka/External SMTP 발신 때 `RESEND_API_KEY` 를 잘못 요구하던 `server/smtp.mjs` 451 오류.
- 정책 개선: 도메인 등록부에 `ariweb {kind: site_bound|standalone, siteId, groupId, mailServiceId}` 와 `operating(active|migrating_out|migrating_in|frozen)` · 도메인별 보장량(`LIMIT_ACCOUNTS/TOTAL_BYTES/MAILBOX_BYTES/MONTHLY_MESSAGES` 를 테넌트 config 로) · 월간 통수 = 송신+수신, 최종 수신자 한 명당 한 통 · 저장 공간에 원문·일반 첨부·Inline·대용량 첨부 포함 · 100% 동작(저장 공간 → 수신 임시 거부·조회/삭제 유지, 월간 통수 → 발신 차단 `mail_monthly_limit` + 수신 임시 거부, 다음 KST 월 초기화) · 발송 큐(`outbound_queue` 표, 재시작 복구)와 안전 속도(도메인·서버별 초당 5건, 제출 초당 10건 이상은 제한·경고·악용 확인 `abuse_flags`, 운영자 `/api/admin/rates`) · 이전 중 두 서버 동시 처리 방지(`operating` ≠ active 면 수신 임시 거부·발신 차단) · SSO `GET /api/auth/sso?ticket=`.
- 포털 쪽 정책: Site 대표 Mail 과 독립 MailService 구분, 사이트당 대표 Mail Domain 하나, 독립 MailService 는 운영 사이트 수 미소비, Site-bound → 독립 전환, 고객용 Mail Backup·복원 미제공 명시, 해지 후 30일 읽기·Export 와 삭제 예정일.
- **미사용**: Webmaker 자체의 배포 대상(deploy targets)·되돌리기 기능은 Ariweb 연결 프로젝트에 쓰지 않는다(관리형·외부 배포는 HAL 작업으로 통일). AriMail 별칭·전달 전용 주소 생성은 AriMail 에 기능이 없고 정책이 미확정이라 이번 버전에서 제공하지 않는다(무제한 제공 금지 원칙).
### 고객 화면
- 공통 틀(§2): 상단 그룹 전환·알림·도움말·사용자 메뉴, 왼쪽 메뉴(대시보드·사이트·메일·도메인·백업·상품 및 결제·사용량·사용자 그룹 설정), 제목 아래 현재 위치·주요 작업 버튼, 상단 주의 항목. 모든 화면이 `불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음` 상태를 갖고, 상태는 색과 함께 글자·기호로 표시하며 360px 에서 표가 카드로 접힌다. 파괴적 작업은 대상·영향 재표시 + 입력 확인.
- 작업 상태 어휘(§2.2) 14종을 `lib/status` 로 통일하고, 서비스 상태·작업 상태·외부 건강 상태를 별도 필드로 둔다. 작업 블록은 현재/완료/다음 단계, 예상 완료 또는 산정 불가 사유, 마지막 갱신, 자동 재시도 횟수·다음 시각, 재시도·취소·되돌리기·문의 번호를 보인다.
- 회원가입·로그인·복구(§3): 이메일 확인 전 유료 구매·운영 서비스 생성 불가, 중복 이메일은 존재 여부 비노출, 약관·개인정보·마케팅 동의 분리, 5회 실패 → 15분 잠금(해제 시각 표시), 기기별 세션 목록과 개별/전체 종료, 외부 이메일 재설정 링크(만료·사용 완료 표시), TOTP 다중 인증과 1회 표시 복구 코드.
- 사용자 그룹(§4): 그룹 전환 즉시 반영·다른 그룹 정보 비노출, 구성원·초대(7일, 재발송·취소·만료)·역할·제거·정지(본인 세션과 미실행 작업만, 영향 범위 사전 표시)·탈퇴·소유권 이전, `user-NN` 별칭, 사이트별 권한(조회·콘텐츠 편집·배포·도메인/메일·결제/해지·전체 관리), 직접 주소 접근도 거부와 필요 역할 안내.
- 대시보드(§5): 운영/구매 사이트 수(예약됨·사용 중 구분), 초안·제작 중, 정상·경고·장애, 관리형·외부, 도메인 조치, 메일 서비스·메일함, 백업 사이트, 진행 작업, 이번 달 예상 비용·결제 대기 금액, 중요도순 주의 항목(조치 화면 링크), 사용자/자동 작업을 구분한 최근 활동.
- 알림 센터·감사 기록(§24): 분류·필수 알림·사건 묶음·조치 링크·읽음 처리, 누가·언제·어디서·무엇을·자동 여부·결과 조회, 권한·소유권·배포·해지·복원·이전·결제 승인은 삭제 불가 기록.
### 운영자 화면
- HAL 운영자 콘솔 신설(`hal.ariweb.net/ops/`, §25): 허용 네트워크(사설망/VPN) 또는 기기 귀속 접근권 뒤의 별도 로그인(MFA), 고객 세션 차단, 접근권 만료·재인증 필요·승인 대기·거부 이유 배너, 메뉴 14개(운영 현황·고객과 사용자 그룹·주문과 결제 승인·상품과 정책·사이트와 배포·메일 서비스·도메인과 인증서·서버와 용량·백업과 복원·서버 이전·배포 릴리스·장애와 알림·감사 기록·플랫폼 복구) + 운영자 계정, 고위험 작업(모듈 되돌리기·서버 삭제 등) 3단계 확인, 1회 표시 값(등록 토큰·긴급 자격·접근권·복구 코드).
- 운영 현황(§26.1): HAL·Portal·Webmaker·웹호스팅·AriMail·Source·백업·관측 상태, 등록/비정상 서버, 진행 중 배포·복원·이전, 처리 대기 승인, 용량 경고, 장애·유지보수 — 오래된 상태는 `정보 지연` 과 마지막 확인 시각.
- 고객 조회(§26.2): 검색·그룹 상세(구성원·권리·구독·사이트·메일·도메인·백업·주문·사용량·정지·보존·실패 작업), 대신 변경은 허용 항목만 전후 값·사유·승인자를 기록하고 고객 감사 기록에도 남김, 고객 지원용 일시 접근 기록(대상·이유·만료·승인).
- 주문 승인·상품 관리(§27), 서버 목록·등록 7단계·유지보수·격리·90일 지표(§28), 작업 화면의 대상별 결과(§29), 도메인·메일(원문 미노출, 접근 사유)·백업/복원·이전·Source 운영(§30), 릴리스·긴급 복구·재해복구 화면(§32~§34).
- HAL 백엔드(§25·§28·§29): 운영자 계정은 고객 계정과 분리(별도 로그인 화면·세션 종류 `operator`), 허용 네트워크(사설망/VPN CIDR) 또는 사용자·기기 귀속 단기 접근권 없이는 로그인 화면도 열리지 않음, 고위험 작업은 최근 재인증 + 3단계 확인(영향 범위 → 대상 식별정보 재확인 → 최종 실행과 되돌리기 가능 여부), 접근권 만료·재인증 필요·승인 대기·거부 이유 표시.
- 포털 운영자 API(`/internal/ops/*`, 서비스 키 서명): 주문 승인(대상·금액·권리 변화·현재 사용량, 반복 승인 안전), 상품 버전 관리(판매된 조건은 새 버전), 사용량·제한, 사이트·배포·도메인·Source·메일(원문 미노출, 진단 사유 기록)·백업/복원 승인(고객·그룹·사이트·도메인·백업 시각 최종 확인)·이전·소유권 이전·생명주기 운영.
### 사이트 생성과 배포
- 사이트 목록·필터·생성(§7): 이름·용도·Webmaker 프로젝트(새로/기존)·템플릿·언어·시간대·배포 방식 선택, 초안은 사이트 수 미소비, 생성 뒤 다음 단계 안내.
- 사이트 상세(§8) 10개 탭. 관리형 운영 사이트는 실제 수집값(`site.usage` 작업 → `usage_samples`)만 표시하고, 미배포 사이트는 준비 단계를, 외부 사이트는 Agent 연결·마지막 배포 결과·외부 확인·고객 책임 범위와 `제공되지 않음` 을 표시한다.
- 배포 방식(§10): 관리형(설명·모니터링 범위·한도·백업·이전 가능성·예상 시간·승인 비용, 정적 + 승인된 동적 실행 형태 사전 점검) / 외부(지원 대상 = External Pull Agent 를 설치할 수 있는 Linux 서버, 준비 정보, 건별 비용, 미제공 기능, 장애 분리, 버전이 기록되는 동의 `external-deploy-2026-10-0.1.1`, 외부 메일 배포 미지원).
- 배포(§11): 사전 확인(저장본·사이트 수·배포 위치·용량·도메인·필수 설정·작업 충돌, 통과/차단 구분), 10단계 진행 표시, 화면을 닫아도 계속, 완료 상태 `배포 검증 완료 / 도메인 설정 필요 / 공개 운영 중`, 최초 공개 배포 때 사이트 수 `예약됨` → 대표 도메인 확인 뒤 `사용 중`, 실패 시 단계·안전한 오류·문의 번호·기존 버전 유지·재시도·되돌리기, 중간 결과 `지금 정리하고 반환`. 배포 방식 변경은 새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리.
- 외부 배포: Agent 설치 안내·일회용 연결·연결 확인·배포·재배포·제거·마지막 외부 확인·실패 잔여물 정리.
### 호스팅
- Node Agent 가 관리 서버에서 사이트를 `/var/www/ariweb-sites/<siteId>/releases/<n>` + `current` 로 배치하고 nginx vhost(와일드카드 인증서 재사용 또는 사이트별 certbot, 유지보수 페이지)를 템플릿으로 생성, 내부·외부 확인, 사용량 수집(디스크·DB·업로드, 일/월/누적 인바운드·아웃바운드, 24시간·7일·30일 추이, 오류율·p95).
- 서버 등록(§28.2)은 역할·위치 → 일회용 등록 정보(10분, 1회, 재표시 없음) → Agent 설치 → 신원·기능·용량 확인 → 운영자 승인 → 배치 가능. 유지보수(신규 배치 중지 → 영향 확인 → 이전 → 시작/종료 → 재개). 총 보장량·실제 용량을 넘는 배치 금지.
### 메일
- 메일 서비스 종류·목록·생성 흐름(§15), 메일함 목록·생성·설정(§16, 초기 비밀번호 1회 표시 또는 초대 링크, 정지 메일함도 수·공간 소비 표시, POP3·IMAP 서비스 스위치와 적용 후 접속 확인, 웹메일 SSO 진입, 외부 프로그램 설정값·비밀번호 비표시), 사용량·제한(§17, 월간 송신+수신 합계·최종 수신자 기준·누적·할당·다음 KST 초기화·마지막 계측, 발송 제한 사유·해제 시각·문의, 대용량 첨부 정책 사전 표시), 고객용 메일 백업·복원 미제공 명시(§17.4), 메일 도메인 보안(§18, 정상·권장 개선·오류, 복사 가능한 값).
- 활성화는 결제·승인 권리가 있을 때만, 내부 준비만으로 완료하지 않고 실제 외부 송신·수신·회신 확인 작업을 거친다(운영자가 검증용 수신 주소를 설정하지 않으면 `사용자 확인 필요` 로 정직하게 멈춤).
### 도메인과 인증서
- 도메인 목록·연결 8단계(§12: 입력 → 웹/메일/둘 다 → 소유 확인(TXT) → DNS 값 안내(복사) → 반영 확인(미반영과 잘못된 값 구분) → 인증서 발급 → 외부 접속 확인 → 대표 지정), 한 사이트 여러 도메인도 사이트 수 하나, 대표 도메인 변경 시 이전 주소 이동 처리 선택, 인증서 발급·갱신·만료 임박·실패 표시, 유효하지 않은 인증서를 정상 HTTPS 로 표시하지 않음, 같은 도메인의 다른 고객 연결 차단.
- 등록 업체·DNS 자동화는 Webmaker 의 기존 도메인 탭(SSO) 에서 수행하고 포털은 상태·확인을 담당한다. 도메인 등록 기능이 없는 환경(검증 Catalog)에서는 외부 등록기관 안내만 표시한다.
### 상거래
- 상품 목록(§6.1)은 운영자가 활성화한 최신 상품 버전을 표시(가격·한도를 화면에 고정하지 않음, 미확정 정책은 `미확정 정책` 안내). 주문 8단계(§6.2), 0.1.1 기본 결제는 **수동 결제 확인**(입금 안내·주문 번호·금액·통화·세금·기한 → 운영자 입금 확인 → 승인/거절 → 승인 뒤에만 권리 활성화), 새로 고침·연타 중복 방지(멱등 키), 거절·만료 사유와 재주문, 자동 결제수단 비표시, 갱신 전 청구 예정 안내와 `갱신 결제 대기`, 미납 제한 시점은 정책에서 읽어 고객·운영자 동일 표시, 미납 즉시 삭제 없음.
- 결제 화면(§6.3), 상품 축소·해지(§6.4, 범위 안이면 즉시, 아니면 줄여야 할 항목을 구체적으로 표시하고 보류 → 정리 후 재검증), AI Wallet 분리 표시·자동 초과 사용 기본 꺼짐·초과 허용 시 항목별 상한·기간·철회 결과 확인(§6.5).
- 출시 검증 Catalog(`catalog/release-0.1.1.json`): basic·team 두 기본 상품(사이트 수·웹 디스크·월간 웹 아웃바운드·메일 저장 공간·월간 메일 통수·메일함), 옵션(독립 메일·Source·백업·디스크/아웃바운드/메일함 추가), 가격·통화·세금·수동 승인·권리·한도·백업 일정·보존·복원 비용·생명주기 결과를 모두 채움. 출시 시험용이며 운영자가 콘솔에서 새 버전으로 관리한다.
### 백업과 복원
- 관리형 사이트 단위 유료 옵션(§19): 백업 가능한 관리형 사이트에서만 구매, 외부 사이트 미제공, 사이트당 하나, 구매 화면에 저장 한도·사용량·일정·Daily/Weekly/Monthly 보존 개수·첫 백업 예정·원격 사본 검증 방식·추가 저장 옵션(활성 상품정책에서 읽음). 포함: 현재 배포본·DB·업로드·복원에 필요한 설정. 제외: 메일 원문·소스 저장소·Webmaker 이력·외부 배포 데이터·플랫폼 내부 사본(화면에 명시).
- 백업은 운영 서버와 다른 저장 서버에 두고 생성 후 읽기 검증, 반복 실패 알림과 다음 재시도, 저장 공간 부족 시 최소 보존 세트를 지우지 않고 다음 백업 중지·안내, 중지된 사이트의 정기 백업 중지와 재개 방식 표시, 옵션 만료 후 7일 보관 뒤 삭제.
- 복원(§20): 요청 정보(백업 시각·포함 데이터·영향·예상 중단·비용·되돌리기 범위) → 비용·승인·작업 시간 → 9단계(보호 시점·격리 검증·복원·기능 확인·외부 확인·사용자 확인 또는 자동 완료·정리) → 실패 시 유지/되돌림, 보호 시점 유지 기간·마지막 되돌리기 시각·완료 방식 사전 표시.
### 이전
- 서버 이전(§21): 정적 자동(조건 충족 시)·동적/메일 승인형·고객 요청 유료 구분, 사전 안내(대상·이유·위치·시간·영향·변경 제한·비용·행동), 10단계, 시작 전과 전환 직전 재점검(조건 변화 시 자동 중지·알림), 두 서버 동시 운영 금지, 실패·일시 중지·취소·되돌리는 중·되돌림 완료 구분, 경계 표시(취소 가능 마지막 단계, 쓰기 제한 시각, 목표 중단 시간, 유일 운영 시점, 되돌리기 기간, 이전 위치 보존·삭제, DNS·인증서 대기 처리), 플랫폼 사유 이전 무과금.
- 소유권 이전(§22): 대상/제외 구분(AI Wallet·그룹 미배정 용량·청구 이력 제외, 독립 메일은 명시 선택), 요청 → 수락(받는 쪽 권리 부족 시 보류) → 운영자 심사 → 원자적 완료(일부만 이전된 상태 비노출), 진행 작업 중에는 시작 불가, 만료·취소 시점, 분쟁 동결, 청구 책임 종료·시작 시각.
- 운영 사이트 수 반환(§14) 8단계 점검표와 대표 메일의 독립 전환(주소·메일함·별칭·전달·비밀번호·DNS 유지 항목별 확인, 전환 전후 송수신 확인, 누락 시 반환 거부).
### 모니터링
- 공통 상태 어휘와 `정보 확인 지연`, HAL 응답 불가 시 포털은 신규 증가를 막고 기존 조회·삭제·내보내기를 유지(§13.4·§26.2), Agent heartbeat 30초·90초 침묵 시 오프라인, 서버 지표(CPU·메모리·디스크·inode·I/O·네트워크·시계 차이) 90일 보관, 사건(incident) 묶음, 공개 상태 페이지 `/status`(개인정보·내부 위치 비표시).
- 운영자 장애 화면(§31.2): 같은 원인 경고를 하나의 사건으로 묶고 고객 영향·최근 배포·담당자·고객 공지·실제 사용자 경로 확인 뒤 종료. 고객 화면(§31.1)은 자신의 서비스에 대한 정상·성능 저하·일부 장애·중단과 영향 기능·시각·대응 상태만 보이며 내부 서버 이름·다른 고객 정보는 노출하지 않는다. 공개 상태 페이지(§31.3)는 로그인 없이 주요 서비스 상태와 계획된 유지보수를 보인다.
### HAL/Node Agent 운영
- HAL(§32): 릴리스 등록부(버전·main 커밋·빌드 시각·artifact sha256·검사 결과·승인), 부트스트랩은 최초 설치에서만(`deploy/bootstrap-hal.sh`: 승인 릴리스·고정 artifact 선택, 보관소·인증서·접근 경로 준비, HAL 설치, 첫 Agent 등록 토큰) 이후 수동 설치 잠금, 정상 배포 순서(HAL 먼저 → HAL 자체 건강·실행 버전 확인 → Agent 연결 확인 → 모듈 배포 → 모듈별 건강·실제 버전 → 고객 경로 확인 → 완료/되돌리기), HAL 확인 실패 시 나머지 배포 금지, 대상별 성공·실패·대기 결과(일부 실패를 전체 성공으로 표시하지 않음), 목표 버전과 실제 버전 차이·조정(reconcile).
- Node Agent: 일회용 토큰 등록 → 키·비밀 1회 발급, HMAC 서명 요청, long-poll 작업 수신, 단계 저장과 재시작 후 재개, `sudo -n` 범위 최소화(systemctl·nginx·certbot), sudo 불가 시 `사용자 확인 필요` 로 보고, External Pull Agent 모드(사이트 배포·제거·확인만, 한 사이트에 귀속).
- 긴급 복구(§33): 사건 번호·사유·대상·허용 작업·짧은 유효 시간, 재인증 + 서로 다른 내용의 3단계 승인(한 명도 가능, 단계별 시각 기록), 모든 작업 기록, 자격 1회 표시·정상화 후 만료, 허용 범위(HAL·HAL 저장 정보·비밀정보·인증서 기반·artifact 저장소·최초 Agent 연결) 밖 작업 거부, HAL 정상화 뒤 실제/원하는 상태 비교·조정.
- 플랫폼 재해복구(§34): HAL 운영 정보·진행 작업·로그인·비밀정보·인증서 기반·배치·서버 등록 정보의 복구 사본·외부 보관·검증·훈련 기록, RTO/RPO/주기/보존 설정과 측정값 비교, 복구 시작 별도 승인과 기존 제어 위치 중지 확인, 하나의 HAL 만 운영(임대, 같은 호스트의 죽은 프로세스는 이어받음), 훈련 성공 전 정식 운영 준비 미표시.
### 실제 환경 배포에서 발견한 결함 수정(2026-10-26, ariweb.net 호스트 첫 배포)
- **Agent 실행 계정 모드**(`agent/jobs/module.mjs`, `deploy/module-as-user.sh`, `deploy/sudoers/ariweb-agent`): 도입 모듈 AriMail 은 서버에서 다른 계정(`arihakdang`)으로 운영되어 `/srv/arihakdang-mail` 을 Agent(master)가 쓸 수 없었다. 유닛의 `User=` 가 Agent 계정과 다르면 풀기·REVISION·current 전환·정리·DB 백업·마이그레이션을 `sudo -n -u <계정> bash deploy/module-as-user.sh` 로 그 계정이 수행한다(sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용, 경로는 모듈 디렉터리 안으로 제한). artifact 는 `<srv>/agent/work` 에 받는다.
- **AriMail 마이그레이션 DB 경로**: `server/db-migrate.mjs` 는 `DB_FILE` 환경이 없으면 릴리스 디렉터리 안에 빈 DB 를 만든다. 유닛의 `EnvironmentFile`(`/etc/<유닛>/gateway.env`)을 실행 계정으로 읽어 실행하도록 고쳤다(직접 모드도 같은 파일을 읽는다).
- **도입 모듈 실제 버전**: Webmaker(`package.json` 1.7.3)·AriMail(1.0.11)의 상류 버전이 HAL 의 목표 버전(0.1.1)과 달라 `version_mismatch` 로 배포가 실패했을 것이다. 풀 때 적는 `REVISION.json` 의 Suite 릴리스 버전을 실제 버전으로 읽고 상류 버전은 `upstreamVersion` 으로 함께 보고한다.
- **기존 유닛 보존**(`hal/modules/index.mjs` `keepExistingUnit`): Webmaker 의 운영 유닛은 `ProtectSystem=strict`·`ReadWritePaths` 등 강화 지시문을 갖는데 템플릿이 Environment 줄만 보존하고 나머지를 지웠을 것이다. 유닛이 이미 `<appDir>/current` 를 가리키면 다시 쓰지 않는다.
- **포털 nginx 접근 경로**(`deploy/bootstrap-hal.sh`): `deploy/nginx/ariweb-portal.conf.tmpl` 이 어디서도 적용되지 않아 `my.ariweb.net` 이 열리지 않았다. 부트스트랩 4단계(접근 경로 준비)에서 HAL 과 함께 설치한다.
- **부트스트랩 인증서 확인**(`deploy/bootstrap-hal.sh`): `/etc/letsencrypt/live` 는 root 만 읽을 수 있어 일반 계정의 `[ -f ]` 가 항상 거짓이었다(실제 부트스트랩이 4단계에서 중단). `sudo -n test -f` 로 확인한다.
- **포털 artifact 에 Catalog 누락**(`scripts/release.mjs`): 상품정책 시드 파일 `catalog/release-0.1.1.json` 이 portal artifact 에 들어가지 않아 운영자 시드(`/internal/ops/commerce/catalog/seed`)가 실서버에서 실패했을 것이다. portal 경로에 `catalog` 를 넣었다.
- **artifact 디렉터리 모드**(`scripts/release.mjs`): 개발 PC 의 umask(077)가 artifact 에 0700 디렉터리로 들어가 서버에서 다른 실행 계정이 Agent 의 `deploy/module-as-user.sh` 와 풀린 릴리스를 읽지 못했다. `tar --mode=u=rwX,go=rX --owner=0 --group=0` 로 정규화한다. `deploy/agent-install.sh` 도 호출자의 umask 와 무관하게 코드 트리를 `a+rX` 로 맞춘다.
- **실행 계정 모드 보완**(`agent/jobs/module.mjs`): 내려받은 artifact(0600)를 실행 계정이 읽도록 0644 로 바꾸고, 도우미 스크립트는 sudoers 규칙과 같은 `<srv>/agent/current/deploy/module-as-user.sh` 경로로 부른다(ESM 의 `import.meta.url` 은 `releases/<ver>` 실제 경로로 풀린다).
- **같은 버전 재설치**(`agent/jobs/module.mjs`, `module-as-user.sh rename`): 실행 중인 current 디렉터리를 지우고 다시 풀던 것을, 임시 디렉터리에 풀고 기존 디렉터리를 `<ver>.prev-<시각>` 으로 보존(되돌리기 대상)한 뒤 제자리에 두도록 바꿨다. DB 백업 파일 이름은 같은 초에 겹치지 않게 한다.
- **Webmaker 데이터 백업**(`agent/jobs/module.mjs`): 운영 데이터 디렉터리의 읽을 수 없는 파일(root 가 남긴 마이그레이션 기록)이 tar 를 실패시켜 배포가 막혔다. `--ignore-failed-read` 로 경고만 남기고(결과 `unreadable` 목록) 진행한다. ariweb.net 호스트의 해당 파일(`/data/webmaker/migrations`)은 소유권을 master 로 바로잡았다.
- **부트스트랩 릴리스 기록**(`hal/core.mjs setBootstrap`): HAL 기동 전 CLI `bootstrap-release` 가 쓴 UPDATE 가 행(id=1)이 없어 무효였다(건강 응답의 `releaseVersion` 이 null). 행을 먼저 만든다(`test/hal.test.mjs` 회귀 테스트).
- **AriMail 마이그레이션의 EnvironmentFile 읽기**(`deploy/module-as-user.sh migrate`): systemd 문법(`MAIL_FROM_NAME=Ari Hak dang` 처럼 따옴표 없는 공백 값)을 bash 로 소스하다 실패했다. 줄 단위로 읽어 따옴표를 벗기고 export 한다.
- **사후 절차 실패 시 되돌리기**(`agent/jobs/module.mjs`): current 전환 뒤 마이그레이션이 실패하면 current 가 새 릴리스를 가리킨 채 남았다(다음 재시작 때 마이그레이션 없는 새 코드가 뜬다). 실패하면 이전 릴리스로 되돌리고 재시작한다.
- **AriMail 전체 설정 API 주소**(`portal/mail/arimail.mjs` 주석·운영 안내): AriMail 은 설정 API(`/api/setup/*`)를 기본 도메인 호스트(`req.headers.host`, 예: `mail.arihakdang.com`)에서만 응답하고 다른 테넌트 호스트로는 404 를 준다. Node fetch 는 Host 헤더를 바꿀 수 없으므로(시도했다가 되돌림) 포털의 설정 주소(`setup_url`)는 기본 도메인 이름이어야 하고, 그 이름이 설정 포트(loopback 전용 8791)로 풀려야 한다 — ariweb.net 호스트에서는 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 두었다(알려진 제한에 기록).
- **Agent heartbeat 의 모듈 버전**(`agent/index.mjs`): `current/package.json` 버전(상류 1.7.3·1.0.11)을 보고해 HAL 이 배포 직후 "목표와 다름(drift)" 으로 표시했다. `REVISION.json` 의 Suite 버전을 먼저 읽는다.
- **새 유닛 enable**(`agent/jobs/module.mjs`): 템플릿으로 만든 유닛(포털)을 enable 하지 않아 재부팅 뒤 뜨지 않았을 것이다. 유닛을 쓸 때 `systemctl enable` 한다.
- **서버 한 대 운영의 역할 대체**(`hal/core.mjs pickServer`, `portal/backup/service.mjs pickStoreServer`): 첫 서버는 역할이 `control` 하나뿐이라 사이트 배포(web)·백업 저장(backup) 대상 서버를 고를 수 없었다. 요청 역할의 서버가 없으면 control 서버를 web·backup 으로도 쓴다(전용 서버가 생기면 우선). 같은 서버에 백업을 두는 것은 여전히 운영자 override 가 필요하다.
- **초안 사이트의 백업 중지 사유**(`portal/backup/service.mjs`): 미배포(초안) 사이트의 옵션을 정기 실행이 `site_cancelled`(해지) 로 중지해 표시가 틀렸다. `site_not_deployed`(사이트 미배포) 로 구분한다.
- 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다.
### HAL 전용 호스트 부트스트랩(2026-10-27, bear `hal21.ariweb.net`)
- **`deploy/bootstrap-hal.sh --portal-host none`**: 포털을 다른 호스트에서 운영하는 HAL 전용 호스트에는 `my.ariweb.net` nginx 접근 경로를 만들지 않는다(이전에는 항상 만들어 포털이 없는 호스트에도 죽은 vhost 가 남았다). 기본값(`my.ariweb.net`)의 동작은 그대로다.
- bear(Ubuntu 24.04, nginx 1.24, Node 24.22, certbot nginx 플러그인 운영)에 HAL 만 최초 부트스트랩: 인증서는 호스트별 certbot(webroot `/var/www/html`, ECDSA, 갱신 훅 `systemctl reload nginx`) — `--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html`. 기존 Webmaker·Gitea·Matrix·기본 서버는 건드리지 않았다. 절차·검증·되돌리기는 `docs/RUNBOOK-0.1.1.md` §7. 배포 artifact: main `ab75b7d`, hal sha256 `f574d875…`(deploy/ 변경으로 ariweb.net 호스트의 `e7741899…` 와 다름). 알려진 사소한 결함: 부트스트랩 5단계의 HTTPS 자체 확인이 nginx reload 직후 실행되어 거짓 경고가 날 수 있다(결과에 영향 없음).
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
- **기존 사이트 인벤토리**(`agent/webmaker-inventory.mjs`, HAL 서버 상세): Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조해 모든 디렉터리의 ID·제목·계정 식별자·배포 상태(`deployed`/`unpublished`/`draft`/`unknown`)·서비스 상태·도메인·크기·파일 수를 HAL에 연결한다. 메타데이터가 없는 디렉터리도 `missing`으로 남기며 총 수·수집 수·잘림 여부·스캔 오류를 함께 기록한다. 2 MiB heartbeat 제한과 1,000항목 제한을 넘는 인벤토리는 512 KiB 이하 인증 배치로 전송하고 마지막 배치에서만 원자 반영한다. 페이지 본문·프롬프트·연락처·인증정보는 전송하지 않는다.
### 운영 Webmaker ↔ HAL 연동 점검 도구(2026-10-27, webmaker.ariweb.net)
- **`scripts/webmaker-inventory.mjs`**(+ `test/webmaker-inventory.test.mjs` 5건): 운영 Webmaker 데이터(JSON)·포털 DB·HAL DB 를 읽기 전용으로 안정 식별자(프로젝트 32-hex id, 포털 `s_/g_/u_`, HAL `srv_`)로 대조해 연결 등록부 일치·충돌·중복·누락, SSO 회원(`aw-<g8>-<u8>`) 신원, 도메인, Gitea `sites/<id>/main` ref, HAL 모듈 등록·버전·건강을 판정한다. 비밀로 보이는 키는 출력 전에 거부(`assertNoSecrets`). 충돌·누락이 있으면 종료 코드 2.
- 2026-10-27 hostinger 실데이터 결과: 프로젝트 10(포털 연결 일치 3·Webmaker 고유 7), 회원 12(SSO 3 일치·고유 9), 도메인 2, Gitea refs 13, HAL `module_targets.webmaker` 0.1.1 health ok — 충돌 0·누락 0, **적용 변경 없음**(교체도 불필요). 사전 스냅샷·복원 검증·사후 readback 은 `docs/verification/webmaker-hal-link-2026-10-27.md`, 절차는 RUNBOOK §8.
- **HAL21 제어 평면 전환 완료**: bear HAL을 main `12958ab`로 갱신한 뒤 hostinger Node Agent를 `hal21.ariweb.net`의 기존 서버 `srv_01a17bd4dbcdb55877f2d9fcab`에 재등록·승인했다. Webmaker unit·데이터는 변경하거나 재시작하지 않았다. 명시적 `DATA_ROOT`·`DATA_DIR`·`SITES_ROOT`로 수집한 HAL 인벤토리는 프로젝트 10/10, 절단 없음, 오류 0이며 `module_targets.webmaker`는 0.1.1/health ok다. 사전 체크섬 32,608건 불일치 0, Git 7개 fsck 성공, Gitea ref 13개 동일. 결과와 롤백은 `docs/verification/webmaker-hal21-link-2026-10-27.md`.
### 기존 메일 서비스(AriMail 인스턴스) ↔ HAL 관리형 MailService/Node 가져오기(2026-10-27, mail.arihakdang.com · webmail.ibit.kr → bear `hal21.ariweb.net`)
- **`hal/mailsvc.mjs`**(+ 마이그레이션 `hal_0003_mail_services`, `test/hal-mailimport.test.mjs` 7건): HAL 관리형 메일 서비스 등록부(`mail_services`, 도메인 단위 논리 리소스, 불변 id `mls_…`, 출처 권한 `arimail:<hostname>:<unit>`, 도메인별 active owner 정확히 하나 — 부분 유일 인덱스)와 가져오기 단위(`mail_imports`). 읽기 전용 discovery 문서만 입력으로 받고 **비밀값이 어디에든 있으면 거부**(비밀은 `secretRefs` 의 file:/dir:/vault:/env: 참조만 저장). dry-run 이 기본, 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락이 하나라도 있으면 아무것도 적용하지 않음, 적용은 한 트랜잭션 + 감사(immutable), 같은 출처·서버의 재적용은 update(멱등), 적용 단위 되돌리기(생성 행 삭제·갱신 행 복원). 소유 조직은 증거로 확인되기 전까지 `unassigned_legacy_managed`. Agent heartbeat 의 arimail 모듈 상태가 그 서버의 행에 `last_sync`·`health` 로 비친다(`core.heartbeat`). Data Plane(AriMail 송수신·계정·메일·DNS/MX·TLS·provider)은 바꾸지 않는다 — HAL 이 멈춰도 메일은 그대로다.
- **`scripts/mail-discover.mjs`**(+ `test/mail-discover.test.mjs` 2건): 원본 호스트에서 단독 실행하는 읽기 전용 discovery — 유닛 상태(`systemctl show`)·`current`/REVISION·package.json·AriMail 도메인 등록부(`data/domains.json`)·건강 API·haraka-status·게이트웨이/테넌트 DB **집계**(계정·메일 수·크기·무결성, 주소·내용 미열람)·유닛 PID 소유 리스너·nginx vhost·TLS 주체/만료·공개 DNS(1.1.1.1/8.8.8.8, 호스트 /etc/hosts 오염 방지)·certbot 훅. env/config 는 키 이름과 경로·권한만.
- **HAL CLI**(`hal/index.mjs`): `mail-import --discovery <json> (--server <id> | --create-server --name …) [--apply]`, `mail-import-rollback --import <id>`, `mail-services`(readback JSON), `server-token --server <id>`(운영자 콘솔이 닿지 않는 부트스트랩·연동 단계의 시스템 신원 토큰 발급, 감사 `cli:<by>`). **운영자 API**: `GET /ops/api/mail-services`(+readback: 총수·active·도메인 중복·서버별·비밀 누출 검사), `GET /ops/api/mail-services/:id`(감사 포함), `GET /ops/api/mail-services/imports`, `POST /ops/api/mail-services/import {discovery, serverId, apply}`(적용은 재인증), `POST /ops/api/mail-services/imports/:id/rollback`(재인증). **운영자 화면** `/ops/mail`: HAL 관리형 메일 서비스 표(도메인·공개 주소·서버/인스턴스·리비전·계정/메일함/메일·건강/동기화·TLS/DNS·비밀 참조 수)를 먼저 그리고 포털 메일 서비스는 그 아래 `#portal-mail` 에 중계로 채운다(`ops.js`).
- 2026-10-27 실제 적용: bear HAL 을 커밋 79b45ca 릴리스로 교체(재시작 2.6초, 메일 서비스 프로브 무중단) → 서버 2(pending) · MailService 7(hostinger 인스턴스 6 도메인 + jitsi 1, 모두 `unassigned_legacy_managed`, duplicates 0, secretLeaks 0) · jitsi Node Agent 등록(heartbeat → `last_sync_source=agent`, 승인은 운영자 결정) · hostinger 는 기존 HAL(`hal.ariweb.net`) 제어 평면과의 이중 제어를 피해 Agent 미설치(관측 전용). 원본 메일 서비스 변경 0. 결과·백업 sha256·readback 은 `docs/verification/mail-hal-link-2026-10-27.md`, 절차·되돌리기는 `docs/RUNBOOK-0.1.1.md` §9.
### 포털·hostinger Node Agent 제어 평면 이전 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
- HAL CLI(시스템 신원, 감사 actor `cli:<by>`·reason) 보완: `config-set`(운영 값만 data/config.json 에 기록, 비밀 키 거부, 서비스 재시작 뒤 반영) · `module-deploy`(운영자 API `POST /ops/api/modules/:name/deploy` 와 같은 멱등 키 규칙으로 `module.deploy` 작업을 큐에 넣고, 실행은 serve 프로세스의 작업 엔진이 맡는다) · `job --id`(작업·자식 readback). 운영자 콘솔 자격 증명이 없는 연동 단계용. 테스트 `test/hal-cli.test.mjs` 3건(별도 프로세스 실행·멱등·감사·서비스 측 엔진이 집어 실행).
- 결과(docs/verification/portal-hal21-link-2026-10-27.md): bear HAL21 을 main `dfb708e` 릴리스(artifact 4e419a72…)로 교체·등록·승인, `portalUrl=https://my.ariweb.net`; `link-portal` 값을 파이프로만 포털 vault `service:hal` 에 넣고(구 `service:portal` 제거) 포털 `halUrl=https://hal21.ariweb.net`; HAL21 작업 `j_01a17cb5716c669450412967f7` 로 portal 0.1.1 재배포(이전 릴리스 `.prev-20261027093350` 보존, health ok). 포털→HAL21 인증 읽기(health·servers·jobs 계약) 실측, 포털 DB 집계·Webmaker 인벤토리·AriMail 전후 동일, 외부 URL/TLS 전부 정상. 구 HAL 은 server offline 상태로 잔존(중지는 사람 결정). blocker: hostinger 서버 행 role `mail` → `pickServer(web)` null(운영자가 control 로 변경 필요).
### 운영자 콘솔 부트스트랩 배너 결함 수정 — 완료 뒤에도 "부트스트랩 미완료" 경고(2026-10-27, bear `hal21.ariweb.net`, Hera)
- **증상**: bear HAL21 `/api/health` 는 `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent 있음 · manualInstallLocked=true` 인데, 운영자 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보이고, 배포 릴리스 화면도 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려졌다.
- **원인**: `hal/ui.mjs` 의 공통 틀(shell) 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`)이 아니라 존재하지 않는 snake_case 열 이름(`completed_at`·`hal_running_version`·`first_agent_id`)을 읽어 항상 `undefined` → 늘 "미완료·확인 전·미등록"으로 렌더링. 같은 파일의 `/ops/releases` 라우트는 `releasesView` 가 bootstrap 행(snake_case `hal_running_version`·`manual_install_locked`)을 기대하는데 camelCase `selfHealth().bootstrap` 을 넘겨 같은 증상. 서버 렌더(HTML) 결함이며 `ops.js`/`ops.css` 는 관여하지 않는다(브라우저 캐시 무관).
- **수정(`hal/ui.mjs` 만, 2곳)**: 배너는 `health.bootstrap.complete` / `halRunningVersion` / `firstAgent` 를 쓰고, `/ops/releases` 는 `running: core.bootstrapState(d)` 를 넘긴다. 옛 snake_case fallback 은 두지 않았다(단일 계약).
- **검증(strict TDD)**: `test/hal-ui.test.mjs` 에 회귀 1건 추가 — selfHealth.bootstrap 에 snake_case 필드가 없음을 고정하고, (1) 초기 `확인 전·미등록` (2) `claimLive` 뒤 `0.1.1·미등록` (3) 첫 Agent 등록·승인(완료) 뒤 운영 현황·서버·릴리스 화면에 경고 없음 + 릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`. RED: 수정 전 (2)에서 `확인 전` 으로 실패(완료 상태에서도 경고·릴리스 화면 `확인 전` 잔존을 별도 스크립트로 기록). GREEN: 파일 7건·전체 22파일 150건 통과·실패 0. 배포 결과는 `docs/verification/hal21-ops-banner-fix-2026-10-27.md`.
- **배포 결과(bear hal21, 2026-10-27 14:36Z)**: 커밋 `648247f` 릴리스(hal artifact `ed957a0a…`)를 `/srv/ariweb/hal/releases/0.1.1-648247f22415` 에 stage → 추출된 `hal/ui.mjs` sha·`node --check`·snake_case 참조 0 확인 → register-release(approved, by hera-ops-banner-fix)·bootstrap-release → current 전환·재시작(health 까지 1.08초, NRestarts 0). data/config(opsAllowCidrs 5항목)/vault/DB sha·크기 전후 동일, 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z`. readback: `/api/health` bootstrap complete·mainCommit 648247f, Agent 2대 heartbeat 재개, 배포된 코드만 import 한 in-memory 서버 렌더에서 완료 상태 배너 없음·릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김`; 포털·Webmaker·메일 회귀 이상 없음. 되돌리기: current 를 `0.1.1-dfb708e933e5` 로 되돌리고 재시작.
### 도입 소스
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
- 수정된 파일(원본 대비): Webmaker 8개(`lib/auth.js lib/config.js lib/store.js lib/vault.js public/app.js public/index.html public/style.css server.js` + 신규 `lib/ariweb.js`, `test/ariweb.test.js`), AriMail 15개(`deploy/deploy.sh haraka/plugins/arimail.js package.json server/api.mjs server/config.mjs server/domains.mjs server/limits.mjs server/main.mjs server/providers/index.mjs server/smtp.mjs server/usage.mjs test/gateway.test.mjs test/smtp.test.mjs test/usage.test.mjs web/setup.js` + 신규 `server/sendqueue.mjs`, `migrations/0009_ariweb_queue.sql`, `test/ariweb.test.mjs`). 이유는 위 "수정한 기존 기능" 과 각 파일의 `[Ariweb Suite 변경]` 주석.
- 참조 저장소는 직접 배포하지 않는다. HAL 이 `modules/*` 에서 만든 artifact(`webmaker-0.1.1.tar.gz`, `arimail-0.1.1.tar.gz`)만 배포한다.
### 알려진 제한
- 로컬 E2E 검증(`docs/verification/local-e2e-2026-10-26.md`, 실제 HAL·포털·Node Agent 4대 + 가짜 Webmaker·AriMail·메일): 34개 완료 조건 중 19개 로컬 확인, 14개 부분 확인, 1개(외부 메일 왕복) 로컬 확인 불가, 결함 0. 부분 확인 항목은 실제 DNS·공인 서버·실 AriMail 왕복·시간 경과(30일 보존)가 필요한 부분이며, 실제 환경 확인 결과는 `docs/verification/` 에 별도 기록한다.
- ariweb.net 호스트의 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com` 이 있다: 앞의 둘은 공개 DNS 생성 전 같은 서버 안의 Agent·포털이 HAL 에 닿기 위한 임시 항목이고, `mail.arihakdang.com` 은 AriMail 전체 설정 포트(8791, loopback 전용)에 기본 도메인 이름으로 접속하기 위한 항목이다(cloud-init 이 `/etc/hosts` 를 관리하므로 재부팅 뒤 확인 필요).
- 실제 환경 검증(`docs/verification/real-env-0.1.1.md`, ariweb.net 호스트, 2026-10-26): 34개 중 실제 확인 7(가입·확인 메일, 그룹·초대, 주문·승인·권리, 초안 사이트, 독립 메일 서비스, HAL 우선 배포·HAL 작업 배포, HAL 재시작 유지), 부분 확인 7(Webmaker 진입, 관리형 배포 요청, 사이트 화면, 대표 메일·메일함, 백업 옵션, 모듈 갱신, 감사 일치), 미수행 20(공개 DNS·실 도메인·수신 주소·다중 서버·시간 경과 필요), 결함 0. 출시 완료는 선언하지 않는다.
- DNS: `ariweb.net` 은 hosting.co.kr 네임서버(웹 패널)라 `hal.ariweb.net`·`my.ariweb.net` A 레코드는 운영자가 패널에서 만들어야 한다. 레코드가 생기기 전에는 로컬 `--resolve` 로만 확인된다.
- 실제 외부 메일 왕복 검증은 운영자가 검증용 수신 주소(자동 응답 메일함)를 설정해야 완료된다.
- AI Wallet 은 Webmaker credits 를 읽어 표시만 하며, 초과 사용 과금 라인 생성은 하지 않는다(표시·제한 해제만).
- 메일 서비스 서버 이전은 AriMail `operating` 상태 전환 도우미(setMigrating) 가 포털에 없어 `설정 필요` 로 안내된다.
- 별칭·전달 전용 주소 생성, 대량 .eml 내보내기(IMAP/POP3·원문 다운로드 안내로 대체), Gitea 브랜치 자동 삭제(상태만), 외부 메일 배포, 자동 결제수단은 이번 버전에서 제공하지 않는다.
- 실제 환경(ariweb.net 호스트)은 서버 한 대(역할 control)로 운영된다. 관리형 사이트 배포·백업 저장은 control 서버를 대체 사용하고, 백업을 같은 서버에 두려면 운영자 override 가 필요하다. 서버 간 이전·외부 Agent·다른 호스트 재해복구는 서버가 늘어난 뒤 실환경에서 확인한다.
- Webmaker 의 페이지 제작(AI 생성·저장)은 사람이 제작 화면에서 수행해야 하므로 실환경 검증에서 저장본 없는 초안 사이트까지만 자동 확인했다(관리형 배포·백업 실행·복원·외부 확인은 저장본이 생긴 뒤 수행).
- Agent 의 사용량 수집은 Agent 가 배포한 사이트의 nginx `awstats` 로그만 집계한다(Webmaker 자체 호스팅 사이트는 Webmaker 통계 유지).
### 미확정 정책값
- 활성 상품정책에 `{status: 'deferred'}` 로 표시되어 화면에 `미확정 정책` 으로 안내되는 값: `alias_counting`(별칭·전달 전용 주소 계산 방식 — 확정 전 무제한 제공 금지, 메일함 수에 포함), `large_attachment`(대용량 첨부 허용 크기·다운로드 횟수·보관 기한 — 시스템 안전 상한 우선), `ai_wallet_pricing`(AI 크레딧 단가 — Webmaker 관리자 설정을 따름).
- 운영자가 후속 상품정책에서 정할 값: 도메인 등록·갱신 가격(등록 업체 연동 시), 외부 배포 건별 비용의 최종 금액, 이전 목표 중단 시간·되돌리기 기간·이전 위치 보존 기간·고객 요청 이전 비용의 운영 값, 복원 비용·중단 시간, 보존 기간 재정의(기본 30/30/30/7일), 초과 사용 단가. 검증 Catalog 의 수치는 모두 출시 시험용이다.
+42
View File
@@ -0,0 +1,42 @@
/**
* agent/client.mjs — HAL 호출(서명·시계 차이·artifact 내려받기·전송 올리기/내려받기).
* 모든 요청은 lib/serviceauth 로 서명한다(keyId 'agent:<id>'). 응답의 Date/X-HAL-Time 으로 시계 차이를 재어 heartbeat 에 싣는다.
* 큰 파일 업로드는 본문 해시를 서명에 넣지 않고(빈 본문 서명) X-Transfer-Sha256 헤더로 무결성을 전한다(HAL 이 받은 뒤 다시 계산해 비교).
*/
import { signHeaders } from '../lib/serviceauth.mjs';
import { createHash } from 'node:crypto';
import { createWriteStream, createReadStream, statSync } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
export function createClient({ halUrl, agentId, secret, fetchImpl = fetch }) {
const base = String(halUrl).replace(/\/$/, ''); const keyId = `agent:${agentId}`; let clockSkewMs = 0;
function skew(r) { const t = Number(r.headers.get('x-hal-time') || 0) || Date.parse(r.headers.get('date') || ''); if (t) clockSkewMs = Date.now() - t; }
async function request(method, path, body, { timeoutMs = 30000 } = {}) {
const text = body == null ? undefined : JSON.stringify(body);
const r = await fetchImpl(base + path, { method, headers: signHeaders(keyId, secret, method, path, text), body: text, signal: AbortSignal.timeout(timeoutMs) }); skew(r);
let json = null; try { json = await r.json(); } catch (_) {}
if (!r.ok || (json && json.ok === false)) { const e = new Error(`HAL ${method} ${path} → ${r.status} ${json?.error || ''} ${json?.message || ''}`.trim()); e.status = r.status; e.code = json?.error || `http_${r.status}`; throw e; }
return json;
}
/** 파일 내려받기 + sha256 검증(expected 가 있으면) */
async function download(path, dest, expected = null) {
// [E2E 결함 수정] 포털이 주는 번들 URL 은 절대 주소(토큰으로 보호)다. HAL 상대 경로일 때만 HAL 서명 헤더를 붙인다.
const absolute = /^https?:\/\//i.test(path);
const r = await fetchImpl(absolute ? path : base + path, { headers: absolute ? {} : signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); if (!absolute) skew(r);
if (!r.ok) { const e = new Error(`HAL 내려받기 실패 ${path} → ${r.status}`); e.code = `http_${r.status}`; throw e; }
const hash = createHash('sha256'); const src = Readable.fromWeb(r.body); src.on('data', (c) => hash.update(c)); await pipeline(src, createWriteStream(dest, { mode: 0o600 }));
const sum = hash.digest('hex'); const want = String(expected || r.headers.get('x-sha256') || '').replace(/^sha256:/i, '').toLowerCase(); if (want && want !== sum) throw Object.assign(new Error(`내려받은 파일 해시가 다릅니다(기대 ${want.slice(0, 12)}… 실제 ${sum.slice(0, 12)}…)`), { code: 'checksum_mismatch' });
return { sha256: sum, size: statSync(dest).size };
}
/** 전송 올리기(스트리밍). 반환 { id, sha256, size } */
async function upload(transferId, file, { purpose = '' } = {}) {
const hash = createHash('sha256'); for await (const c of createReadStream(file)) hash.update(c); const sum = hash.digest('hex'); const size = statSync(file).size;
const path = `/agent/v1/transfers/${transferId}${purpose ? `?purpose=${encodeURIComponent(purpose)}` : ''}`;
const r = await fetchImpl(base + path, { method: 'POST', headers: { ...signHeaders(keyId, secret, 'POST', path, ''), 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), 'X-Transfer-Sha256': sum }, body: createReadStream(file), duplex: 'half', signal: AbortSignal.timeout(3600000) }); skew(r);
const json = await r.json().catch(() => null); if (!r.ok) throw Object.assign(new Error(`전송 올리기 실패 ${r.status} ${json?.error || ''}`), { code: json?.error || `http_${r.status}` });
return { id: transferId, sha256: sum, size };
}
const downloadTransfer = (id, dest, expected = null) => download(`/agent/v1/transfers/${id}`, dest, expected);
return { request, download, upload, downloadTransfer, get clockSkewMs() { return clockSkewMs; }, agentId, base };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* agent/config.mjs — Node Agent 설정과 경로.
* 데이터 디렉터리(기본 /srv/ariweb/agent/data)의 agent.json 에 id·HAL 주소·역할·사이트(외부 모드)를 두고, 비밀은 같은 디렉터리의 보관소(lib/vault)에 'agent-secret' 로 둔다.
* 실제 시스템 경로는 환경 변수로 바꿀 수 있어 테스트는 임시 디렉터리에서 돈다.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { openVault } from '../lib/vault.mjs';
export function loadAgentConfig(env = process.env) {
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
const cfg = {
dataDir, file, ...saved,
roots: {
sites: env.ARIWEB_SITES_ROOT || '/var/www/ariweb-sites', // 사이트 파일: <sites>/<siteId>/releases/<n>, current, data(uploads)
srv: env.ARIWEB_SRV_ROOT || '/srv/ariweb', // 모듈 설치: <srv>/<module>/releases/<ver>, current
nginx: env.ARIWEB_NGINX_DIR || '/etc/nginx', // sites-available/sites-enabled
backups: env.ARIWEB_BACKUP_ROOT || '/srv/ariweb/backups', // 백업 저장소
logs: env.ARIWEB_LOG_DIR || '/var/log/nginx', // 사이트별 접속 로그 aw-<siteId>.access.log
acme: env.ARIWEB_ACME_WEBROOT || '/var/www/letsencrypt', // certbot webroot
letsencrypt: env.ARIWEB_LE_DIR || '/etc/letsencrypt/live',
wildcardCert: env.ARIWEB_WILDCARD_CERT || '', // 플랫폼 와일드카드 인증서 디렉터리(예: /etc/letsencrypt/live/wh-wild-ariweb.net)
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
},
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
vault: () => openVault(dataDir),
};
return cfg;
}
+69
View File
@@ -0,0 +1,69 @@
/**
* agent/index.mjs — Node Agent 진입점·CLI(§5·§28.2·§10.2).
*
* node agent/index.mjs register --hal <url> --token <t> [--role web|mail|backup|control] [--external --site <siteId>] [--label <이름>]
* node agent/index.mjs run 서비스 루프: heartbeat(30초) + 작업 long-poll + 재시작 뒤 미완료 작업 재개
* node agent/index.mjs status 등록 상태·HAL 연결 확인
* 비밀(Agent secret)은 데이터 디렉터리의 보관소에만 둔다. 등록 응답의 비밀은 그 자리에서 저장하고 화면에 보이지 않는다.
*/
import { loadAgentConfig } from './config.mjs';
import { createClient } from './client.mjs';
import { createSys } from './sys.mjs';
import { createRunner } from './runner.mjs';
import { KINDS } from './jobs/index.mjs';
import { sample, facts } from './metrics.mjs';
import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { hostname } from 'node:os';
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
const log = (...a) => console.log(new Date().toISOString(), ...a);
const args = process.argv.slice(2); const cmd = args[0] || 'run'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
const cfg = loadAgentConfig(); const sys = createSys({ log });
if (cmd === 'register') {
const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); }
const f = facts(sys); f.role = opt('role', 'web'); f.location = opt('location', '');
const r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }) }); const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(1); }
cfg.vault().set('agent-secret', j.secret, { kind: 'agent-secret' }); cfg.save({ id: j.agentId, halUrl: hal, serverId: j.serverId || null, kind: j.kind, siteId: j.siteId || null, groupId: j.groupId || null, registeredAt: new Date().toISOString(), label: opt('label', hostname()) });
console.log(`등록 완료: agent ${j.agentId} (${j.kind}${j.serverId ? `, 서버 ${j.serverId}` : ''}). 비밀은 ${cfg.dataDir} 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다`);
} else if (cmd === 'status') {
if (!cfg.id) { console.log('등록되지 않음'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
try { const r = await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: {}, clockSkewMs: 0 }); console.log(`연결 정상: agent ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}, 대기 작업 ${r.pendingJobs}, 시계 차이 ${client.clockSkewMs}ms`); } catch (e) { console.error(`HAL 연결 실패: ${e.message}`); process.exit(1); }
} else if (cmd === 'run') {
if (!cfg.id) { console.error('등록되지 않았습니다 — register 먼저'); process.exit(1); }
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
/** 기존 Webmaker 사이트: 운영에 필요한 식별자·상태·도메인·배포 용량만 facts에 싣고 본문·프롬프트·연락처·비밀은 제외한다. */
function inventoryFacts(f) {
return configuredWebmakerInventory(f);
}
/** 2 MiB 요청 한도와 무관하게 전체 인벤토리를 512 KiB 이하 배치로 순차 전송한다. */
async function syncWebmakerInventory(inv) {
if (!inv) return; const batches = webmakerInventoryBatches(inv); let offset = 0;
for (const body of batches) { const r = await client.request('POST', '/agent/v1/inventory/webmaker', body); offset = Number(r.nextOffset); }
if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`);
}
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
async function heartbeat() { try {
if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false };
const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : [];
const legacy = lastFacts.f.webmakerInventory?.sites?.map((x) => x.id) || [];
const sites = new Set([...managed, ...legacy]).size;
if (lastFacts.f.webmakerInventory && !lastFacts.inventorySynced) { try { await syncWebmakerInventory(lastFacts.f.webmakerInventory); lastFacts.inventorySynced = true; } catch (e) { log(`[agent] Webmaker 인벤토리 전송 실패: ${e.message}`); } }
const heartbeatFacts = { ...lastFacts.f }; delete heartbeatFacts.webmakerInventory;
await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: heartbeatFacts, sitesHosted: sites, jobs: { running } });
} catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
await heartbeat(); const hb = setInterval(heartbeat, cfg.heartbeatMs);
process.on('SIGTERM', () => { stopping = true; clearInterval(hb); });
log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}`);
while (!stopping) { try { const r = await client.request('GET', '/agent/v1/jobs/next?wait=25', undefined, { timeoutMs: 40000 }); if (r.job) execute(r.job); } catch (e) { log(`[agent] 작업 조회 실패: ${e.message}`); await new Promise((s) => setTimeout(s, 5000)); } }
process.exit(0);
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
+50
View File
@@ -0,0 +1,50 @@
/**
* agent/jobs/backup.mjs — 관리형 사이트 백업·복원(§19·§20).
*
* 백업 묶음(tar.gz) 구조: files/(현재 배포본) data/(uploads 등 영속 데이터) db/(SQLite 는 VACUUM INTO 일관 사본; 그 밖의 DB 는 payload.db.dumpCommand 가 있을 때만) config/(vhost·버전 정보·ariweb.json) meta.json
* 메일 원문·소스 저장소·Webmaker 이력은 포함하지 않는다(§19.1). 만든 뒤 목록을 읽어 검증하고 sha256 을 돌려준다.
* 저장소: <backups>/<siteId>/<backupId>.tar.gz. 운영 서버와 다른 저장 서버로 보내야 하면 uploadToHal 로 HAL 전송에 올린다(backup.store 가 저장 서버에서 받는다).
* 복원: 보호 시점(현재 상태 백업) → 격리 디렉터리에서 풀어 확인 → 새 릴리스로 배치·데이터 교체(이전 데이터는 .pre-restore 로 보존) → current 전환 → 내부 확인.
*/
import { existsSync, mkdirSync, readdirSync, renameSync, copyFileSync, writeFileSync, statSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { siteDir, assertSiteId, readLink, switchLink, tarDir, untar, tarList, sha256File, rmrf, readJson, writeJson, internalCheck, fmtSum, normSum } from './common.mjs';
import { configureVhost } from './site.mjs';
const stamp = () => new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
const backupDir = (ctx, siteId) => join(ctx.roots.backups, assertSiteId(siteId));
/** SQLite 일관 사본 */
async function copySqlite(src, dest) { const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync(src, { readOnly: true }); try { db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`); } finally { db.close(); } }
/** 현재 상태를 백업 파일로 묶는다 */
export async function makeBackup(ctx, siteId, { backupId, scope = {}, outDir, db = null, tag = null }) {
const sd = siteDir(ctx.roots, siteId); const cur = readLink(join(sd, 'current')); const work = join(sd, 'work', `bk-${backupId}`); rmrf(work); mkdirSync(work, { recursive: true });
const included = {};
if (scope.files !== false && cur && existsSync(cur)) { mkdirSync(join(work, 'files'), { recursive: true }); await ctx.sys.tar(['-cf', join(work, 'files.tar'), '-C', cur, '.']); included.files = true; }
if (scope.uploads !== false && existsSync(join(sd, 'data'))) { await ctx.sys.tar(['-cf', join(work, 'data.tar'), '-C', join(sd, 'data'), '.']); included.data = true; }
if (scope.db !== false) { mkdirSync(join(work, 'db'), { recursive: true }); const dbs = []; const dbDir = join(sd, 'data', 'db'); if (existsSync(dbDir)) for (const f of readdirSync(dbDir).filter((x) => /\.(sqlite|db)$/.test(x))) { await copySqlite(join(dbDir, f), join(work, 'db', f)); dbs.push(f); } if (db?.sqlite && existsSync(db.sqlite)) { await copySqlite(db.sqlite, join(work, 'db', basename(db.sqlite))); dbs.push(basename(db.sqlite)); } included.db = dbs; }
if (scope.config !== false) { mkdirSync(join(work, 'config'), { recursive: true }); const vh = join(ctx.roots.nginx, 'sites-available', `aw-${siteId}.conf`); if (existsSync(vh)) copyFileSync(vh, join(work, 'config', 'vhost.conf')); if (cur) for (const f of ['.ariweb-version.json', 'ariweb.json']) if (existsSync(join(cur, f))) copyFileSync(join(cur, f), join(work, 'config', f)); included.config = true; }
writeJson(join(work, 'meta.json'), { backupId, siteId, createdAt: new Date().toISOString(), version: cur ? cur.split('/').pop() : null, included, tag, excluded: ['mail', 'source_repo', 'webmaker_history', 'external_deploy_data'] });
mkdirSync(outDir, { recursive: true }); const out = join(outDir, `${backupId}.tar.gz`); const t = await tarDir(ctx, out, work); rmrf(work);
const list = await tarList(ctx, out); if (!list.some((x) => x.endsWith('meta.json'))) throw Object.assign(new Error('백업 파일을 읽을 수 없습니다'), { code: 'backup_unreadable' });
return { backupId, path: out, sizeBytes: t.size, checksum: t.sha256, createdAt: new Date().toISOString(), scope: included };
}
export const kinds = {
'backup.create': { title: '백업 생성', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const backupId = p.backupId || `bk-${stamp()}-${siteId.slice(0, 8)}`; const r = await ctx.step('create', () => makeBackup(ctx, siteId, { backupId, scope: p.scope || {}, outDir: backupDir(ctx, siteId), db: p.db || null, tag: p.retentionTag || null }), '백업 생성'); if (p.uploadToHal) { const up = await ctx.step('upload', () => ctx.client.upload(p.transferId || `tr_bk_${backupId}`, r.path, { purpose: 'backup' }), 'HAL 전송에 올리기'); r.transferId = up.id; r.uploaded = true; if (!p.keepLocalCopy) rmrf(r.path); } return r; } },
'backup.store': { title: '백업 저장소에 받기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const out = join(backupDir(ctx, siteId), `${p.backupId}.tar.gz`); mkdirSync(dirname(out), { recursive: true }); const dl = await ctx.step('download', () => ctx.client.downloadTransfer(p.transferId, out, p.sha256 || null), '받기'); const list = await tarList(ctx, out); if (!list.some((x) => x.endsWith('meta.json'))) throw Object.assign(new Error('받은 백업을 읽을 수 없습니다'), { code: 'backup_unreadable' }); return { path: out, sizeBytes: dl.size, checksum: fmtSum(dl.sha256), verified: true }; } },
'backup.verify': { title: '백업 검증', async run(ctx) { const p = ctx.payload; if (!p.location || !existsSync(p.location)) return { ok: false, reason: 'missing' }; const sum = sha256File(p.location); const list = await tarList(ctx, p.location).catch(() => []); const ok = (!p.checksum || normSum(p.checksum) === sum) && list.some((x) => x.endsWith('meta.json')); return { ok, checksum: fmtSum(sum), entries: list.length, reason: ok ? null : p.checksum && normSum(p.checksum) !== sum ? 'checksum_mismatch' : 'unreadable' }; } },
'backup.fetch': { title: '백업 저장소에서 보내기', async run(ctx) { const p = ctx.payload; if (!existsSync(p.location)) throw Object.assign(new Error('백업 파일이 없습니다'), { code: 'backup_missing' }); if (p.checksum && sha256File(p.location) !== normSum(p.checksum)) throw Object.assign(new Error('백업 파일 해시가 다릅니다'), { code: 'checksum_mismatch' }); const up = await ctx.step('upload', () => ctx.client.upload(p.transferId, p.location, { purpose: 'restore' }), '올리기'); return { transferId: up.id, checksum: fmtSum(up.sha256), sizeBytes: up.size }; } },
'backup.delete': { title: '백업 삭제', async run(ctx) { const p = ctx.payload; const ok = existsSync(p.location) && p.location.startsWith(ctx.roots.backups); if (ok) rmrf(p.location); return { deleted: ok, location: p.location }; } },
'backup.restore': { title: '백업 복원', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const work = join(sd, 'work', `rs-${stamp()}`); mkdirSync(work, { recursive: true });
const src = await ctx.step('fetch', async () => { if (p.transferId) { const f = join(work, 'backup.tar.gz'); await ctx.client.downloadTransfer(p.transferId, f, p.checksum || null); return f; } if (!p.location || !existsSync(p.location)) throw Object.assign(new Error('복원할 백업이 없습니다'), { code: 'backup_missing' }); if (p.checksum && sha256File(p.location) !== normSum(p.checksum)) throw Object.assign(new Error('백업 해시가 다릅니다'), { code: 'checksum_mismatch' }); return p.location; }, '백업 가져오기');
const protect = await ctx.step('protect', async () => { if (p.protectPoint === false) return null; const r = await makeBackup(ctx, siteId, { backupId: `protect-${stamp()}`, outDir: join(backupDir(ctx, siteId), 'protect'), tag: 'protect' }); return r.path; }, '현재 상태 보호 시점 만들기');
const iso = await ctx.step('isolated', async () => { const dir = join(work, 'iso'); await untar(ctx, src, dir); const meta = readJson(join(dir, 'meta.json'), null); if (!meta || meta.siteId !== siteId) throw Object.assign(new Error('백업의 사이트 정보가 다릅니다'), { code: 'backup_site_mismatch' }); if (existsSync(join(dir, 'files.tar'))) { mkdirSync(join(dir, 'files'), { recursive: true }); await ctx.sys.tar(['-xf', join(dir, 'files.tar'), '-C', join(dir, 'files')]); } if (existsSync(join(dir, 'data.tar'))) { mkdirSync(join(dir, 'data'), { recursive: true }); await ctx.sys.tar(['-xf', join(dir, 'data.tar'), '-C', join(dir, 'data')]); } const files = existsSync(join(dir, 'files')) ? readdirSync(join(dir, 'files')).length : 0; if (!files) throw Object.assign(new Error('백업에 사이트 파일이 없습니다'), { code: 'backup_empty' }); return { dir, meta, files }; }, '격리된 환경에서 백업 확인');
await ctx.progress(50);
const placed = await ctx.step('restore', async () => { const rel = join(sd, 'releases', `${stamp()}-restore`); mkdirSync(join(sd, 'releases'), { recursive: true }); renameSync(join(iso.dir, 'files'), rel); writeJson(join(rel, '.ariweb-version.json'), { version: rel.split('/').pop(), restoredFrom: iso.meta.backupId, restoredAt: new Date().toISOString(), originalVersion: iso.meta.version }); if (existsSync(join(iso.dir, 'data'))) { const old = join(sd, 'data'); if (existsSync(old)) renameSync(old, join(sd, `data.pre-restore-${stamp()}`)); renameSync(join(iso.dir, 'data'), old); } const dbSrc = join(iso.dir, 'db'); if (existsSync(dbSrc)) { mkdirSync(join(sd, 'data', 'db'), { recursive: true }); for (const f of readdirSync(dbSrc)) { const dest = p.db?.sqlite && basename(p.db.sqlite) === f ? p.db.sqlite : join(sd, 'data', 'db', f); if (existsSync(dest)) copyFileSync(dest, dest + `.pre-restore-${stamp()}`); copyFileSync(join(dbSrc, f), dest); } } const prev = readLink(join(sd, 'current')); switchLink(join(sd, 'current'), rel); writeJson(join(sd, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { rel, prev }; }, '데이터 복원');
const meta = readJson(join(placed.rel, 'ariweb.json'), {}); if (meta.runtime === 'node') await ctx.sys.systemctl('restart', `ariweb-site-${siteId}`, { optional: true }); else await ctx.sys.nginxReload().catch(() => {});
const ic = await ctx.step('check', () => internalCheck(ctx, siteId, (p.domains && p.domains[0]) || `${siteId}.preview.${ctx.roots.platformDomain}`), '사이트 기능 확인');
rmrf(work);
if (!ic.ok) { if (placed.prev && existsSync(placed.prev)) switchLink(join(sd, 'current'), placed.prev); throw Object.assign(new Error('복원 뒤 내부 확인 실패 — 이전 상태로 되돌렸습니다'), { code: 'internal_check_failed', partial: { protectPointLocation: protect, internalCheck: ic } }); }
return { protectPointLocation: protect, restoredVersion: placed.rel.split('/').pop(), isolatedVerify: true, internalCheck: ic, restoredFrom: iso.meta.backupId };
} },
};
+66
View File
@@ -0,0 +1,66 @@
/**
* agent/jobs/common.mjs — 사이트 작업 공통: 경로·템플릿·vhost 생성·내부 확인·tar·체크섬.
* 사이트 레이아웃: <sites>/<siteId>/releases/<버전디렉터리>, current(심링크), data/(uploads·db 등 영속 데이터), .maintenance / .limited 표식 파일.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, symlinkSync, unlinkSync, renameSync, rmSync, lstatSync, readlinkSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const SITE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}$/;
export function assertSiteId(id) { if (!SITE_ID_RE.test(String(id || ''))) throw Object.assign(new Error(`사이트 id 형식이 올바르지 않습니다: ${id}`), { code: 'bad_site_id' }); return id; }
export const siteDir = (roots, siteId) => join(roots.sites, assertSiteId(siteId));
export const tmpl = (name) => readFileSync(join(ROOT, 'deploy', 'nginx', name), 'utf8');
export const render = (t, vars) => t.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? '' : String(vars[k])));
export function sha256File(file) { const h = createHash('sha256'); h.update(readFileSync(file)); return h.digest('hex'); }
/**
* 체크섬 표기: 결과·payload 에 넣는 해시는 'sha256:<hex>' 형식으로 쓴다. 긴 hex 그대로 두면 lib/redact 가 비밀값으로 보고 '[redacted]' 로 가려
* 포털·HAL 이 검증에 쓸 수 없기 때문이다. 비교할 때는 normSum 으로 접두어·대소문자를 벗긴다(두 형식 모두 허용).
*/
export const fmtSum = (hex) => (hex ? `sha256:${String(hex).replace(/^sha256:/i, '').toLowerCase()}` : null);
export const normSum = (v) => (v ? String(v).replace(/^sha256:/i, '').toLowerCase() : null);
export function dirSize(dir) { let n = 0; if (!existsSync(dir)) return 0; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isSymbolicLink()) continue; if (e.isDirectory()) n += dirSize(p); else n += statSync(p).size; } return n; }
/** 심링크 원자 교체 */
export function switchLink(linkPath, target) { const tmp = `${linkPath}.tmp-${process.pid}`; try { unlinkSync(tmp); } catch (_) {} symlinkSync(target, tmp); renameSync(tmp, linkPath); }
export function readLink(linkPath) { try { return lstatSync(linkPath).isSymbolicLink() ? readlinkSync(linkPath) : null; } catch (_) { return null; } }
export const MAINT_HTML = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>점검 중</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>사이트 점검 중입니다</h1><p>잠시 후 다시 접속해 주세요.</p><p style="color:#888;font-size:12px">supported by ariweb.net</p></body></html>';
export const LIMIT_HTML = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>이용 제한</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>월간 전송량 한도에 도달했습니다</h1><p>다음 달(KST 1일) 또는 추가 용량 적용 뒤 다시 제공됩니다.</p><p style="color:#888;font-size:12px">supported by ariweb.net</p></body></html>';
const esc1 = (s) => s.replace(/'/g, '&#39;');
/** 사이트 vhost 설정 문자열 만들기 */
export function renderVhost({ roots, siteId, domains = [], primaryDomain = null, runtime = 'static', port = null, certDir = null, redirectOld = [] }) {
const sd = siteDir(roots, siteId); const names = domains.length ? domains : [`${siteId}.preview.${roots.platformDomain}`];
const body = render(tmpl(runtime === 'node' ? 'site-body-node.tmpl' : 'site-body-static.tmpl'), { ROOT: join(sd, 'current'), SITE_DIR: sd, LOG: join(roots.logs, `aw-${siteId}.access.log`), MAINT_HTML: esc1(MAINT_HTML), LIMIT_HTML: esc1(LIMIT_HTML), PORT: port || 0 });
const https = certDir ? render(tmpl('site-https.tmpl'), { SERVER_NAMES: names.join(' '), CERT_DIR: certDir, BODY: body }) : '';
const httpBody = certDir ? ` location / { return 301 https://${primaryDomain || names[0]}$request_uri; }` : body;
let out = render(tmpl('site.conf.tmpl'), { SITE_ID: siteId, DOMAIN_LIST: names.join(', '), GENERATED_AT: new Date().toISOString(), SERVER_NAMES: names.join(' '), ACME_ROOT: roots.acme, HTTP_BODY: httpBody, HTTPS_SERVER: https });
if (redirectOld.length && primaryDomain) out += '\n' + render(tmpl('redirect.tmpl'), { OLD_NAMES: redirectOld.join(' '), ACME_ROOT: roots.acme, PRIMARY: primaryDomain });
return out;
}
/** 와일드카드 인증서 적용 가능 여부: 모든 도메인이 플랫폼 도메인의 1단계 하위이고 인증서 디렉터리가 있을 때 */
export function wildcardCertFor(roots, domains) { if (!roots.wildcardCert || !domains.length) return null; const pd = roots.platformDomain.toLowerCase(); const ok = domains.every((d) => { const x = String(d).toLowerCase(); return x.endsWith('.' + pd) && !x.slice(0, -pd.length - 1).includes('.'); }); return ok && existsSync(join(roots.wildcardCert, 'fullchain.pem')) ? roots.wildcardCert : null; }
/** 사이트별 인증서 디렉터리(certbot --cert-name aw-<siteId>) */
export function ownCertFor(roots, siteId) { const p = join(roots.letsencrypt, `aw-${siteId}`); return existsSync(join(p, 'fullchain.pem')) ? p : null; }
/** 인증서 만료 읽기(openssl 없이: PEM 의 notAfter 는 파싱이 번거로워 파일 mtime+90일로 추정, 정확한 값은 domain.cert 결과가 준다) */
export function certExpiry(certDir) { try { const st = statSync(join(certDir, 'fullchain.pem')); return new Date(st.mtimeMs + 90 * 86400000).toISOString(); } catch (_) { return null; } }
/** vhost 파일 쓰고 nginx 적용. stub 모드에서는 파일만 쓴다. */
export async function applyVhost(ctx, siteId, content) {
const roots = ctx.roots; const avail = join(roots.nginx, 'sites-available', `aw-${siteId}.conf`); const enabled = join(roots.nginx, 'sites-enabled', `aw-${siteId}.conf`);
await ctx.sys.writeFile(avail, content);
try { mkdirSync(dirname(enabled), { recursive: true }); if (!existsSync(enabled)) symlinkSync(avail, enabled); } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') { await ctx.sys.sudo(['ln', '-sf', avail, enabled]); } else if (e.code !== 'EEXIST') throw e; }
await ctx.sys.nginxReload(); return { file: avail };
}
export async function removeVhost(ctx, siteId) { const roots = ctx.roots; const files = [join(roots.nginx, 'sites-enabled', `aw-${siteId}.conf`), join(roots.nginx, 'sites-available', `aw-${siteId}.conf`)]; let removed = 0; for (const f of files) { try { unlinkSync(f); removed++; } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') { await ctx.sys.sudo(['rm', '-f', f]); removed++; } } } if (removed) await ctx.sys.nginxReload().catch(() => {}); return removed; }
/** 내부 접속 확인: 127.0.0.1:80 에 Host 헤더로. stub 모드면 current/index.html 존재로 대신한다. */
export function internalCheck(ctx, siteId, host, { path = '/', expectMarker = null, port = 80 } = {}) {
if (ctx.sys.stub) { const sd = siteDir(ctx.roots, siteId); const idx = join(sd, 'current', 'index.html'); const ok = existsSync(idx) && (!expectMarker || readFileSync(idx, 'utf8').includes(expectMarker)); return Promise.resolve({ ok, status: ok ? 200 : 404, ms: 0, stubbed: true, host }); }
return new Promise((resolve) => { const t0 = Date.now(); const req = http.get({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 200000) body += c; }); res.on('end', () => resolve({ ok: res.statusCode < 400 && (!expectMarker || body.includes(expectMarker)), status: res.statusCode, ms: Date.now() - t0, host, markerFound: expectMarker ? body.includes(expectMarker) : null })); }); req.setTimeout(15000, () => { req.destroy(new Error('timeout')); }); req.on('error', (e) => resolve({ ok: false, error: e.message, ms: Date.now() - t0, host })); });
}
/** 디렉터리를 tar.gz 로 */
export async function tarDir(ctx, outFile, dir, items = ['.']) { mkdirSync(dirname(outFile), { recursive: true }); await ctx.sys.tar(['-czf', outFile, '-C', dir, ...items]); return { file: outFile, size: statSync(outFile).size, sha256: fmtSum(sha256File(outFile)) }; }
export async function untar(ctx, file, destDir) { mkdirSync(destDir, { recursive: true }); await ctx.sys.tar(['-xzf', file, '-C', destDir]); return destDir; }
export async function tarList(ctx, file) { const r = await ctx.sys.run('tar', ['-tzf', file], { allowStub: false }); if (r.code !== 0) throw new Error(`tar 목록 실패: ${r.stderr.slice(0, 200)}`); return r.stdout.split('\n').filter(Boolean); }
export const rmrf = (p) => rmSync(p, { recursive: true, force: true });
export const readJson = (p, fb = null) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch (_) { return fb; } };
export const writeJson = (p, v) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(v, null, 1)); };
+27
View File
@@ -0,0 +1,27 @@
/**
* agent/jobs/domain.mjs — 도메인 작업: vhost 재구성, HTTPS 인증서 발급(certbot webroot), DNS 확인(공용 리졸버).
* 인증서: 플랫폼 서브 도메인은 와일드카드(*.ariweb.net)를 재사용하고, 고객 도메인은 certbot --webroot 로 사이트별(aw-<siteId>) 인증서를 받는다(Webmaker deploy.js 와 같은 원칙).
* 유효하지 않은 인증서를 정상 HTTPS 로 표시하지 않는다(§12.3): certbot 결과가 성공이고 fullchain.pem 이 있을 때만 'issued'.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { promises as dns } from 'node:dns';
import { assertSiteId, ownCertFor, wildcardCertFor, certExpiry } from './common.mjs';
import { configureVhost } from './site.mjs';
export const kinds = {
'domain.vhost': { title: '도메인 연결 설정', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const r = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime: p.runtime || 'static', port: p.port || null, redirectOld: p.redirectOld ? (p.oldDomains || []) : [] }), 'nginx 설정'); if (p.maintenance !== undefined) { const { kinds: site } = await import('./site.mjs'); await site['site.maintenance'].run({ ...ctx, payload: { siteId, on: !!p.maintenance } }); } return { applied: r.serverNames, certs: r.certs, https: r.https }; } },
'domain.cert': { title: 'HTTPS 인증서 발급', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const domains = (p.domains || []).map((d) => String(d).toLowerCase()); if (!domains.length) throw Object.assign(new Error('도메인이 없습니다'), { code: 'domains_required' });
const wild = wildcardCertFor(ctx.roots, domains); if (wild) { const r = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), '와일드카드 인증서 적용'); return { issued: domains, failed: [], source: 'wildcard', expiresAt: certExpiry(wild), https: r.https }; }
// 1) HTTP vhost 가 먼저 있어야 webroot 인증이 된다
await ctx.step('vhost-http', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), 'HTTP 연결 준비');
const issued = []; const failed = [];
await ctx.step('certbot', async () => { const args = ['certonly', '--webroot', '-w', ctx.roots.acme, '--cert-name', `aw-${siteId}`, '--non-interactive', '--agree-tos', '--keep-until-expiring', '--expand', ...(p.email ? ['-m', p.email] : ['--register-unsafely-without-email'])]; for (const d of domains) args.push('-d', d); const r = await ctx.sys.certbot(args); if (r.code !== 0 && !ctx.sys.stub) { const msg = (r.stderr || r.stdout).trim(); for (const d of domains) failed.push({ domain: d, reason: msg.slice(0, 300) || 'certbot 실패' }); } else { issued.push(...domains); if (ctx.sys.stub && !ownCertFor(ctx.roots, siteId)) { const { mkdirSync, writeFileSync } = await import('node:fs'); const dir = join(ctx.roots.letsencrypt, `aw-${siteId}`); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'fullchain.pem'), 'stub'); writeFileSync(join(dir, 'privkey.pem'), 'stub'); } } return { code: r.code }; }, 'certbot 실행');
if (!issued.length) { const e = new Error(`인증서 발급 실패: ${failed[0]?.reason || '원인 불명'}`); e.code = 'cert_failed'; e.partial = { failed }; throw e; }
const own = ownCertFor(ctx.roots, siteId); if (!own) throw Object.assign(new Error('certbot 은 성공했지만 인증서 파일이 없습니다'), { code: 'cert_missing' });
const r = await ctx.step('vhost-https', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), 'HTTPS 연결');
return { issued, failed, source: 'own', expiresAt: certExpiry(own), https: r.https };
} },
'domain.probe': { title: 'DNS 확인', async run(ctx) { const p = ctx.payload; const name = String(p.name || '').toLowerCase(); if (!name) throw Object.assign(new Error('이름이 없습니다'), { code: 'name_required' }); if (ctx.sys.stub && p._stubAnswer) return { ...p._stubAnswer, resolvedAt: new Date().toISOString() }; const r = new dns.Resolver({ timeout: 5000, tries: 2 }); r.setServers(['1.1.1.1', '8.8.8.8']); const out = { name, a: [], txtOk: null, cname: [], mx: [], resolvedAt: new Date().toISOString(), resolver: 'public' }; try { out.a = await r.resolve4(name); } catch (e) { out.aError = e.code; } try { out.cname = await r.resolveCname(name); } catch (_) {} if (p.expectTxt) { try { const txts = (await r.resolveTxt(p.expectTxt.name || name)).map((x) => x.join('')); out.txtOk = txts.includes(p.expectTxt.value); out.txtFound = txts.length; } catch (e) { out.txtOk = false; out.txtError = e.code; } } if (p.mx) { try { out.mx = await r.resolveMx(name); } catch (_) {} } if (p.expectA) { const want = new Set(p.expectA); out.aOk = out.a.length > 0 && out.a.every((x) => want.has(x)); out.aMismatch = out.a.filter((x) => !want.has(x)); } return out; } },
};
+6
View File
@@ -0,0 +1,6 @@
/** agent/jobs/index.mjs — 작업 종류 등록부. HAL 이 보내는 kind 이름과 같다. External Pull Agent 는 HAL 이 배정을 제한하므로 여기서는 모두 등록한다. */
import { kinds as site } from './site.mjs';
import { kinds as domain } from './domain.mjs';
import { kinds as backup } from './backup.mjs';
import { kinds as mod } from './module.mjs';
export const KINDS = { ...site, ...domain, ...backup, ...mod };
+156
View File
@@ -0,0 +1,156 @@
/**
* agent/jobs/module.mjs — 모듈 설치·갱신·되돌리기·건강 확인(§32·§29).
*
* 레이아웃: <appDir>/releases/<version>/ + current 심링크. 도입 모듈은 서버에 이미 있던 appDir(/srv/webmaker, /srv/arihakdang-mail …)과 유닛 이름을 facts 로 받아 그대로 쓴다.
* 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/<ver> 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기.
* 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다).
* 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다.
*
* [실제 환경 결함 수정 0.1.1] 실행 계정이 다른 도입 모듈: 유닛의 User= 가 Agent 계정과 다르면(예: AriMail 의 arihakdang) 그 디렉터리에 Agent 가 직접 쓸 수 없다.
* 이때는 모든 파일 작업(풀기·REVISION·current 전환·정리·DB 백업·마이그레이션)을 `sudo -n -u <계정> bash deploy/module-as-user.sh …` 로 그 계정이 수행한다
* (sudoers 는 이 스크립트만 root 가 아닌 계정으로 허용). artifact 는 Agent 의 작업 디렉터리(<srv>/agent/work)에 받아 그 계정이 읽게 한다.
* 마이그레이션은 유닛의 EnvironmentFile(DB_FILE 등)을 같은 계정으로 읽어 실행한다 — 없으면 릴리스 디렉터리 안에 빈 DB 를 만드는 잘못된 동작이 된다.
* [실제 환경 결함 수정 0.1.1] 실제 버전: 도입 모듈의 package.json 버전(Webmaker 1.7.3·AriMail 1.0.11 — 0.2.0 부터 Webmaker 1.8.0)은 Suite 릴리스 버전(0.1.1·0.2.0)과 다르다.
* HAL 은 "목표 릴리스 버전 = 실제 버전" 으로 판정하므로, 풀 때 적은 REVISION.json 의 version 을 실제 버전으로 읽고 package.json 버전은 upstreamVersion 으로 함께 보고한다.
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { userInfo } from 'node:os';
import http from 'node:http';
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
/** 내부 건강 확인 URL 호출 */
function httpGet(url, timeoutMs = 8000) { return new Promise((resolve) => { const t0 = Date.now(); const req = http.get(url, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 100000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body, ms: Date.now() - t0 })); }); req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))); req.on('error', (e) => resolve({ status: 0, error: e.message, ms: Date.now() - t0 })); }); }
async function health(ctx, desc) { if (ctx.sys.stub) return { ok: true, stubbed: true }; if (!desc?.health?.url) return { ok: true, skipped: 'no_health_url' }; let last = null; for (let i = 0; i < 20; i++) { const r = await httpGet(desc.health.url); last = r; if (r.status && r.status < 500 && (desc.health.expect !== 'ok' || /"ok"\s*:\s*true|ok/.test(r.body))) return { ok: true, status: r.status, ms: r.ms }; await new Promise((s) => setTimeout(s, 1500)); } return { ok: false, status: last?.status, error: last?.error || `상태 ${last?.status}` }; }
/**
* 실제 버전 읽기. 우선순위: 건강 응답의 version(HAL·포털) → current/REVISION.json 의 Suite 릴리스 버전(도입 모듈) → package.json → 디렉터리 이름.
* 도입 모듈은 package.json 이 상류 버전이라 upstreamVersion 으로 따로 돌려준다(화면·영수증 표시용).
*/
async function detectVersion(ctx, desc, appDir) {
const cur = readLink(join(appDir, 'current')); const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; const rev = cur ? readJson(join(cur, 'REVISION.json'), null) : null;
if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return { version: j[desc.version.field], upstreamVersion: pkg?.version || null }; } catch (_) {} }
if (rev?.version) return { version: rev.version, upstreamVersion: pkg?.version && pkg.version !== rev.version ? pkg.version : null };
return { version: pkg?.version || (cur ? cur.split('/').pop() : null), upstreamVersion: null };
}
/** 유닛에서 실행 계정·EnvironmentFile·node 경로를 읽는다. 계정이 Agent 계정과 다르면 asUser 모드(파일 작업을 그 계정으로). */
async function serviceContext(ctx, unit) {
const txt = unit ? await ctx.sys.unitText(unit) : ''; const me = (() => { try { return userInfo().username; } catch (_) { return process.env.USER || ''; } })();
const user = (/^User=(.+)$/m.exec(txt) || [])[1]?.trim() || null; const envFile = ((/^EnvironmentFile=(.+)$/m.exec(txt) || [])[1] || '').replace(/^-/, '').trim() || null; const node = (/^ExecStart=(\S+)/m.exec(txt) || [])[1] || null;
return { unitText: txt, user, envFile, nodeBin: node && /node$/.test(node) ? node : null, asUser: !!(user && user !== me && user !== 'root'), me };
}
/** 실행 계정으로 파일 작업(deploy/module-as-user.sh). 실패하면 stderr 를 포함한 오류를 던진다. */
async function asUser(ctx, svc, appDir, sub, args, { input = null } = {}) {
// sudoers 규칙은 current 심링크 경로(<srv>/agent/current/deploy/module-as-user.sh)로 허용한다 — ESM 의 import.meta.url 은 실제 경로(releases/<ver>)로 풀리므로 current 경로를 우선 쓴다.
const viaCurrent = join(ctx.roots.srv, 'agent', 'current', 'deploy', 'module-as-user.sh'); const script = existsSync(viaCurrent) ? viaCurrent : join(ROOT, 'deploy', 'module-as-user.sh');
const r = await ctx.sys.run('sudo', ['-n', '-u', svc.user, '/usr/bin/bash', script, sub, '--app-dir', appDir, ...args], { input, timeoutMs: 600000 });
if (r.code !== 0) throw Object.assign(new Error(`실행 계정(${svc.user})으로 ${sub} 실패: ${(r.stderr || r.stdout || '').trim().slice(0, 300) || `코드 ${r.code}`}`), { code: r.code === 1 && /sudo|password/i.test(r.stderr || '') ? 'sudo_denied' : 'as_user_failed', hint: 'deploy/sudoers/ariweb-agent 의 module-as-user.sh 규칙이 설치되어 있어야 합니다' });
return r;
}
/** 파일 쓰기·심링크 전환·삭제를 모드(직접/실행 계정)에 맞게 수행 */
const fsOps = (ctx, svc, appDir) => ({
writeJson: async (p, v) => (svc.asUser ? asUser(ctx, svc, appDir, 'write', ['--file', p], { input: JSON.stringify(v, null, 1) }) : writeJson(p, v)),
switchLink: async (link, target) => (svc.asUser ? asUser(ctx, svc, appDir, 'switch', ['--link', link, '--target', target]) : switchLink(link, target)),
remove: async (dir) => (svc.asUser ? asUser(ctx, svc, appDir, 'remove', ['--dir', dir]) : rmrf(dir)),
rename: async (from, to) => (svc.asUser ? asUser(ctx, svc, appDir, 'rename', ['--dir', from, '--target', to]) : renameSync(from, to)),
});
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
async function writeUnit(ctx, desc, unit, appDir, version, svc) {
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
const old = svc.unitText;
if (desc.keepExistingUnit && new RegExp(`^WorkingDirectory=${appDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/current/?$`, 'm').test(old)) return { skipped: 'existing_unit_kept', user: svc.user };
const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
const user = svc.user || desc.user || process.env.USER || 'master'; const envFile = svc.envFile || ''; const node = svc.nodeBin || process.execPath;
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: node, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); const en = await ctx.sys.systemctl('enable', unit, { optional: true }); /* 새로 만든 유닛은 재부팅 뒤에도 뜨도록 enable(실제 환경 결함 수정: 포털 유닛이 disabled 였다) */ return { written: true, enabled: en.code === 0, preservedEnv: envLines.split('\n').filter(Boolean).length };
}
/** EnvironmentFile(KEY=값 줄) 읽기 — 직접 모드의 마이그레이션에 싣는다. 값은 돌려주되 로그에는 쓰지 않는다. */
function readEnvFile(p) { const out = {}; if (!p || !existsSync(p)) return out; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); if (!m) continue; let v = m[2].trim(); if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1); out[m[1]] = v; } return out; }
/** preStop/postStart 절차 */
async function hook(ctx, name, { appDir, cur, unit, svc }) {
if (name === 'webmaker.backupData') {
const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(svc.unitText) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true });
// [실제 환경 결함 수정] 운영 데이터 디렉터리에는 다른 계정(root)이 남긴 파일이나 백업 중 바뀌는 파일이 있을 수 있다. 읽지 못한 파일은 경고로 남기고 배포를 막지 않는다(tar 종료 코드 2 = 치명적 오류만 실패).
const r = await ctx.sys.run('tar', ['-czf', out, '-C', dataRoot, '--exclude=./backups', '--ignore-failed-read', '--warning=no-file-changed', '--warning=no-file-removed', '.'], { allowStub: false });
if (r.code !== 0 && r.code !== 1) throw new Error(`데이터 백업 실패: ${(r.stderr || '').slice(0, 300)}`);
const warnings = (r.stderr || '').split('\n').filter((l) => /Cannot open|Permission denied|Cannot stat/.test(l)).map((l) => l.replace(/^tar: /, '')).slice(0, 20);
if (warnings.length) ctx.log(`데이터 백업에서 읽지 못한 파일 ${warnings.length}개(경고): ${warnings[0]}`);
const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out, unreadable: warnings.length ? warnings : undefined };
}
if (name === 'arimail.backupDb') {
const env = svc.asUser ? {} : readEnvFile(svc.envFile); const db = env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite'); let out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); for (let n = 1; existsSync(out); n++) out = out.replace(/(-\d+)?\.sqlite$/, `-${n}.sqlite`); // 같은 초에 두 번 설치해도 VACUUM INTO 대상이 겹치지 않게
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'backup-db', ['--db', db, '--out', out, ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_db' } : { backup: out, via: svc.user }; }
if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out.replace(/'/g, "''")}'`); } finally { d.close(); } return { backup: out };
}
if (name === 'arimail.migrate') {
const script = join('server', 'db-migrate.mjs'); if (!ctx.sys.stub && !existsSync(join(cur, script))) return { skipped: 'no_migrate' };
if (svc.asUser) { const r = await asUser(ctx, svc, appDir, 'migrate', ['--cwd', cur, '--script', script, ...(svc.envFile ? ['--env-file', svc.envFile] : []), ...(svc.nodeBin ? ['--node', svc.nodeBin] : [])]); return /skipped/.test(r.stdout) ? { skipped: 'no_migrate' } : { migrated: true, via: svc.user, envFile: svc.envFile || null }; }
const env = readEnvFile(svc.envFile); const r = await ctx.sys.run(svc.nodeBin || process.execPath, [join(cur, script)], { allowStub: false, timeoutMs: 120000, env: { ...process.env, ...env } }).catch((e) => ({ code: 1, stderr: e.message }));
if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db: env.DB_FILE || join(appDir, 'data', 'mail-gateway.sqlite') };
}
if (name === 'arimail.haraka') { const r = await ctx.sys.systemctl('restart', `${unit}-haraka`, { optional: true }); return { haraka: r.code === 0 ? 'restarted' : 'skipped' }; }
if (name === 'webmaker.unitCheck') { const r = await ctx.sys.run(process.execPath, [join(cur, 'scripts', 'unit-check.js')], { allowStub: true, timeoutMs: 60000 }).catch(() => ({ code: 1 })); return { unitCheck: r.code === 0 ? 'ok' : 'warn' }; }
return { skipped: `unknown hook ${name}` };
}
/**
* [E2E 결함 수정] 설치 디렉터리 결정. HAL 이 준 appDir 가 기본 루트(/srv/ariweb/…) 아래이고 이 Agent 의 루트(ARIWEB_SRV_ROOT)가 다른 곳으로
* 재정의되어 있으면(검증 환경·일반 계정) 그 루트 아래로 옮긴다. 서버에서 발견한 기존 경로(/srv/webmaker 등)는 그대로 둔다.
*/
function resolveAppDir(ctx, p) {
const srv = ctx.roots.srv; const given = p.appDir;
if (!given) return join(srv, p.module);
if (srv !== '/srv/ariweb' && String(given).startsWith('/srv/ariweb/')) return join(srv, String(given).slice('/srv/ariweb/'.length));
return given;
}
async function installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }) {
const relDir = join(appDir, 'releases', action === 'rollback' ? (toVersion || '') : version); const prev = readLink(join(appDir, 'current'));
if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; }
// 실행 계정 모드에서는 appDir 에 쓸 수 없으므로 Agent 의 작업 디렉터리에 받는다(그 계정이 읽을 수 있게 0644).
const workDir = svc.asUser ? join(ctx.roots.srv, 'agent', 'work') : join(appDir, 'work'); const tgz = join(workDir, `${module}-${version}.tar.gz`);
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(workDir, { recursive: true }); const r = await ctx.client.download(ctx.payload.artifactUrl, tgz, ctx.payload.sha256 || null); if (svc.asUser) chmodSync(tgz, 0o644); /* 내려받기는 0600 으로 쓰므로 실행 계정이 읽게 한다(artifact 는 비밀이 아니다) */ return { file: tgz, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
// [실제 환경 결함 수정] 같은 버전 재설치: 실행 중인 current 가 가리키는 디렉터리를 지우고 다시 풀면 실행 중인 서비스가 잠깐 코드를 잃는다(지연 require 실패) 데다 되돌릴 곳도 없다.
// 임시 디렉터리에 먼저 풀고, 기존 디렉터리는 <ver>.prev-<시각> 으로 이름을 바꿔 보존한 뒤(이것이 되돌리기 대상) 새 디렉터리를 제자리에 둔다.
let prevDir = prev;
await ctx.step('extract', async () => {
const ops = fsOps(ctx, svc, appDir); const tmp = `${relDir}.tmp`;
if (svc.asUser) await asUser(ctx, svc, appDir, 'extract', ['--tgz', tgz, '--dest', tmp]); else { rmrf(tmp); mkdirSync(tmp, { recursive: true }); await untar(ctx, tgz, tmp); }
if (desc.copyPaths && !existsSync(join(tmp, 'package.json'))) { await ops.remove(tmp).catch(() => {}); throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); }
await ops.writeJson(join(tmp, 'REVISION.json'), { module, version, upstreamVersion: readJson(join(tmp, 'package.json'), {}).version || null, installedAt: new Date().toISOString(), by: svc.asUser ? svc.user : svc.me });
let kept = null;
if (existsSync(relDir)) { if (prev && prev.replace(/\/$/, '') === relDir) { const base = `${relDir}.prev-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`; kept = base; for (let n = 1; existsSync(kept); n++) kept = `${base}.${n}`; await ops.rename(relDir, kept); prevDir = kept; } else await ops.remove(relDir); }
await ops.rename(tmp, relDir);
rmSync(tgz, { force: true }); return { dir: relDir, asUser: svc.asUser ? svc.user : null, replacedRunning: kept };
}, '릴리스 풀기');
return { relDir, prev: prevDir };
}
export const kinds = {
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
};
async function installOrRollback(ctx, action) {
const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = resolveAppDir(ctx, p); const unit = (desc && desc.unit) || `ariweb-${module}`;
const svc = await serviceContext(ctx, unit); const ops = fsOps(ctx, svc, appDir);
if (!svc.asUser) mkdirSync(join(appDir, 'releases'), { recursive: true });
if (svc.asUser) ctx.log(`실행 계정 모드: 유닛 ${unit} 의 User=${svc.user} 로 파일 작업을 수행합니다`);
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, action, toVersion, svc }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
await ctx.progress(40);
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사전 절차 ${h}`);
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version, svc), '실행 유닛 갱신');
await ctx.step('switch', async () => { await ops.switchLink(join(appDir, 'current'), relDir); await ops.writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
const self = module === 'agent'; let healthRes = { ok: true };
const revert = async () => { if (prev && existsSync(prev)) { await ops.switchLink(join(appDir, 'current'), prev).catch(() => {}); await ctx.sys.systemctl('restart', unit, { optional: true }); } };
// 사후 절차(마이그레이션 등)가 실패하면 current 를 이전 릴리스로 되돌린다 — 그대로 두면 다음 재시작 때 마이그레이션 없는 새 코드가 뜬다(실제 환경 결함 수정).
for (const h of desc.postStart || []) { try { await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit, svc }), `사후 절차 ${h}`); } catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'post_hook_failed', partial: { rolledBackTo: prev, hook: h } }); } }
if (!self && desc.layout !== 'agent-feature') {
try { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); for (const u of desc.extraUnits || []) await ctx.sys.systemctl('restart', u, { optional: true }); }
catch (e) { await revert(); throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
healthRes = await ctx.step('health', () => health(ctx, desc), '건강 확인');
if (!healthRes.ok) { await revert(); throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
}
const ver = self ? { version: readJson(join(relDir, 'package.json'), {}).version || version, upstreamVersion: null } : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
// 오래된 릴리스 정리(최근 3개 보존)
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) await ops.remove(d).catch((e) => ctx.log(`오래된 릴리스 정리 실패(${r}): ${e.message}`)); }
return { module, actualVersion: ver.version, upstreamVersion: ver.upstreamVersion, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir, runUser: svc.user || svc.me };
}
+114
View File
@@ -0,0 +1,114 @@
/**
* agent/jobs/site.mjs — 사이트 작업: 배포·되돌리기·제거·확인·사용량, 이전(relocate) 보조 작업.
*
* site.deploy(§11.2): 번들 가져오기(HAL 전송·서명 URL·로컬 경로) → releases/<n> 에 풀기 → 버전 기록 → current 교체 → (동적) 실행 유닛 → vhost → nginx → 내부 확인.
* 실패하면 current 를 바꾸지 않고(기존 버전 유지), 만들어 둔 디렉터리를 leftovers 로 보고해 포털이 "남은 파일" 과 정리를 보일 수 있게 한다.
* site.usage(§13.1): 사이트 디렉터리 크기(du) + nginx awstats 로그 증분 파싱 → 일별 집계(data/usage/<siteId>.json, 400일) → 24h/7d/30d/누적.
* 이전 보조: site.prepare / site.export(전송 올리기) / site.import(받아 staging) / site.stage_check / site.activate / site.deactivate / site.freeze.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync, openSync, readSync, closeSync, fstatSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { siteDir, assertSiteId, switchLink, readLink, renderVhost, applyVhost, removeVhost, internalCheck, wildcardCertFor, ownCertFor, certExpiry, dirSize, tarDir, untar, sha256File, rmrf, readJson, writeJson, render, ROOT, fmtSum, normSum } from './common.mjs';
const stamp = () => new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
/** 번들을 작업 디렉터리의 파일로 가져온다 */
async function fetchBundle(ctx, siteId, dest) {
const p = ctx.payload; mkdirSync(join(dest), { recursive: true }); const file = join(dest, 'bundle.tar.gz');
if (p.transferId) { const r = await ctx.client.downloadTransfer(p.transferId, file, p.checksum || null); return { file, sha256: fmtSum(r.sha256), source: 'transfer' }; }
if (p.bundleUrl) { const r = await ctx.client.download(p.bundleUrl.startsWith('http') ? p.bundleUrl : p.bundleUrl, file, p.checksum || null); return { file, sha256: fmtSum(r.sha256), source: 'url' }; }
if (p.bundlePath && existsSync(p.bundlePath)) { if (statSync(p.bundlePath).isDirectory()) { await tarDir(ctx, file, p.bundlePath); return { file, sha256: fmtSum(sha256File(file)), source: 'dir' }; } copyFileSync(p.bundlePath, file); return { file, sha256: fmtSum(sha256File(file)), source: 'file' }; }
if (p.bundleFromWebmaker?.path && existsSync(p.bundleFromWebmaker.path)) { await tarDir(ctx, file, p.bundleFromWebmaker.path); return { file, sha256: fmtSum(sha256File(file)), source: 'webmaker' }; }
throw Object.assign(new Error('배포할 저장본(transferId / bundleUrl / bundlePath / bundleFromWebmaker)이 없습니다'), { code: 'bundle_missing' });
}
/** 동적(node) 사이트 포트: 지정 없으면 siteId 해시로 20000~29999 */
function sitePort(siteId, p) { if (p.port) return Number(p.port); let h = 0; for (const c of siteId) h = (h * 31 + c.charCodeAt(0)) >>> 0; return 20000 + (h % 10000); }
async function nodeUnit(ctx, siteId, sd, start, port) { const unit = `ariweb-site-${siteId}`; const txt = render(readFileSync(join(ROOT, 'deploy', 'units', 'ariweb-site.service.tmpl'), 'utf8'), { SITE_ID: siteId, SITE_DIR: sd, PORT: port, USER: process.env.USER || 'master', NODE: process.execPath, START: start }); await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); await ctx.sys.systemctl('enable', unit, { optional: true }); await ctx.sys.systemctl('restart', unit); return unit; }
/** 현재 운영 중인 도메인 설정으로 vhost 다시 쓰기 */
export async function configureVhost(ctx, siteId, { domains = [], primaryDomain = null, runtime = 'static', port = null, redirectOld = [] }) {
const own = ownCertFor(ctx.roots, siteId); const wild = wildcardCertFor(ctx.roots, domains); const certDir = own || wild;
const conf = renderVhost({ roots: ctx.roots, siteId, domains, primaryDomain, runtime, port, certDir, redirectOld });
const r = await applyVhost(ctx, siteId, conf);
const certs = Object.fromEntries(domains.map((dm) => [dm, certDir ? { state: 'active', source: own ? 'own' : 'wildcard', expiresAt: certExpiry(certDir) } : { state: 'none' }]));
return { ...r, https: !!certDir, certs, serverNames: domains.length ? domains : [`${siteId}.preview.${ctx.roots.platformDomain}`] };
}
export const kinds = {
'site.deploy': { title: '사이트 배포', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const leftovers = [];
const rel = await ctx.step('prepare', () => { mkdirSync(join(sd, 'releases'), { recursive: true }); mkdirSync(join(sd, 'data', 'uploads'), { recursive: true }); const n = `${stamp()}-${String(p.versionRef || 'v').replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 24)}`; return { dir: join(sd, 'releases', n), name: n }; }, '배포 위치 준비');
leftovers.push(rel.dir);
try {
const b = await ctx.step('fetch', () => fetchBundle(ctx, siteId, join(sd, 'work', rel.name)), '저장본 준비'); leftovers.push(join(sd, 'work', rel.name));
await ctx.step('extract', async () => { await untar(ctx, b.file, rel.dir); writeJson(join(rel.dir, '.ariweb-version.json'), { version: rel.name, versionRef: p.versionRef || null, deployedAt: new Date().toISOString(), sha256: b.sha256, source: b.source }); rmrf(join(sd, 'work', rel.name)); return { files: readdirSync(rel.dir).length }; }, '파일과 설정 적용');
await ctx.step('data', () => { const meta = readJson(join(rel.dir, 'ariweb.json'), {}); if (meta.db?.sqlite) mkdirSync(join(sd, 'data', 'db'), { recursive: true }); return { runtime: p.runtime || meta.runtime || 'static', db: meta.db || null }; }, '데이터베이스와 업로드 데이터 확인');
await ctx.progress(50); ctx.checkCancel();
const prev = readLink(join(sd, 'current'));
const sw = await ctx.step('switch', async () => { switchLink(join(sd, 'current'), rel.dir); writeJson(join(sd, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { previous: prev }; }, '서비스 시작(버전 전환)');
const meta = readJson(join(rel.dir, 'ariweb.json'), {}); const runtime = p.runtime || meta.runtime || 'static'; let port = null;
if (runtime === 'node') { port = sitePort(siteId, p); await ctx.step('unit', () => nodeUnit(ctx, siteId, sd, meta.start || 'server.js', port), '실행 유닛 준비'); }
const vh = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime, port }), '도메인 연결(nginx)');
await ctx.progress(80);
const ic = await ctx.step('internal', () => internalCheck(ctx, siteId, vh.serverNames[0], { expectMarker: p.expectMarker || null }), '내부 확인');
if (!ic.ok) { if (prev && existsSync(prev)) { switchLink(join(sd, 'current'), prev); await ctx.sys.nginxReload().catch(() => {}); } const e = new Error(`내부 확인 실패(${ic.status || ic.error}) — 이전 버전을 유지합니다`); e.code = 'internal_check_failed'; e.partial = { leftovers, previousKept: !!prev, internalCheck: ic }; throw e; }
// 오래된 릴리스 정리(최근 5개 보존 — 되돌리기 대상)
await ctx.step('prune', () => { const rels = readdirSync(join(sd, 'releases')).sort(); const del = rels.slice(0, Math.max(0, rels.length - 5)).filter((r) => join(sd, 'releases', r) !== rel.dir); for (const r of del) rmrf(join(sd, 'releases', r)); return { pruned: del.length }; }, '이전 배포본 정리');
return { version: rel.name, deployedAt: new Date().toISOString(), internalCheck: ic, siteUrl: p.primaryDomain ? `https://${p.primaryDomain}/` : `http://${vh.serverNames[0]}/`, serverNames: vh.serverNames, https: vh.https, certs: vh.certs, previousVersion: sw.previous ? sw.previous.split('/').pop() : null, runtime, port, leftovers: [] };
} catch (e) { if (!e.partial) e.partial = { leftovers: leftovers.filter((x) => existsSync(x)), previousKept: true }; throw e; }
} },
'site.rollback': { title: '이전 버전으로 되돌리기', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId);
const target = await ctx.step('find', () => { const want = p.toVersion ? join(sd, 'releases', p.toVersion) : readJson(join(sd, '.previous.json'), {}).previous; if (!want || !existsSync(want)) throw Object.assign(new Error('되돌릴 이전 버전이 없습니다'), { code: 'no_previous_version' }); return want; }, '되돌릴 버전 확인');
const cur = readLink(join(sd, 'current'));
await ctx.step('switch', () => { switchLink(join(sd, 'current'), target); writeJson(join(sd, '.previous.json'), { previous: cur, at: new Date().toISOString() }); return { from: cur, to: target }; }, '버전 전환');
const unit = `ariweb-site-${siteId}`; if (readJson(join(target, 'ariweb.json'), {}).runtime === 'node') await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '실행 유닛 재시작'); else await ctx.sys.nginxReload().catch(() => {});
const ic = await ctx.step('internal', () => internalCheck(ctx, siteId, (p.domains && p.domains[0]) || `${siteId}.preview.${ctx.roots.platformDomain}`), '내부 확인');
if (!ic.ok) throw Object.assign(new Error('되돌린 버전의 내부 확인에 실패했습니다'), { code: 'internal_check_failed', partial: { internalCheck: ic } });
return { version: target.split('/').pop(), internalCheck: ic };
} },
'site.remove': { title: '배포 제거', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const left = [];
await ctx.step('vhost', () => removeVhost(ctx, siteId), '도메인 연결 해제');
await ctx.step('unit', async () => { if (existsSync(`/etc/systemd/system/ariweb-site-${siteId}.service`) || ctx.sys.stub) { await ctx.sys.systemctl('stop', `ariweb-site-${siteId}`, { optional: true }); await ctx.sys.systemctl('disable', `ariweb-site-${siteId}`, { optional: true }); await ctx.sys.sudo(['rm', '-f', `/etc/systemd/system/ariweb-site-${siteId}.service`]); } return true; }, '실행 유닛 제거');
await ctx.step('files', () => { if (p.keepData) { for (const x of ['releases', 'current', 'work']) rmrf(join(sd, x)); if (existsSync(join(sd, 'data'))) left.push(join(sd, 'data')); } else rmrf(sd); return { removed: true }; }, '파일 정리');
return { removed: true, leftovers: left.filter((x) => existsSync(x)) };
} },
'site.check': { title: '사이트 확인', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const checks = await ctx.step('check', async () => { const out = []; for (const u of p.urls || []) { let host = u; try { host = new URL(u).hostname; } catch (_) {} out.push({ url: u, ...(await internalCheck(ctx, siteId, host, { expectMarker: p.expectMarker || null })) }); } return out; }, '내부 확인'); const sd = siteDir(ctx.roots, siteId); return { checks, currentVersion: (readLink(join(sd, 'current')) || '').split('/').pop() || null, maintenance: existsSync(join(sd, '.maintenance')), limited: existsSync(join(sd, '.limited')), tls: (() => { const c = ownCertFor(ctx.roots, siteId) || wildcardCertFor(ctx.roots, p.domains || []); return { valid: !!c, expiresAt: c ? certExpiry(c) : null }; })() }; } },
'site.usage': { title: '사용량 수집', async run(ctx) { const out = {}; for (const siteId of ctx.payload.siteIds || []) { try { out[siteId] = await ctx.step(`usage:${siteId}`, () => collectUsage(ctx, siteId), `사용량 ${siteId}`); } catch (e) { out[siteId] = { error: e.message }; } } return { sites: out, measuredAt: new Date().toISOString() }; } },
'site.limit': { title: '전송량 제한 표시', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.limited'); if (ctx.payload.limited) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { limited: !!ctx.payload.limited }; } },
'site.maintenance': { title: '유지보수 모드', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.maintenance'); if (ctx.payload.on) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { maintenance: !!ctx.payload.on }; } },
// ---------------- 이전 보조(§21)
'site.prepare': { title: '새 위치 준비', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(join(sd, 'releases'), { recursive: true }); mkdirSync(join(sd, 'staging'), { recursive: true }); mkdirSync(join(sd, 'data'), { recursive: true }); const free = (() => { try { const { statfsSync } = ctx.sys.stub ? { statfsSync: () => ({ bfree: 1e9, bsize: 1 }) } : {}; return null; } catch (_) { return null; } })(); return { prepared: true, dir: sd }; } },
'site.export': { title: '사이트 내보내기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const cur = readLink(join(sd, 'current')); if (!cur) throw Object.assign(new Error('운영 중인 배포본이 없습니다'), { code: 'no_current' }); const work = join(sd, 'work', `export-${stamp()}`); mkdirSync(work, { recursive: true }); const t = await ctx.step('tar', async () => { mkdirSync(join(work, 'pack'), { recursive: true }); await ctx.sys.tar(['-cf', join(work, 'pack', 'files.tar'), '-C', cur, '.']); if (p.includeData !== false && existsSync(join(sd, 'data'))) await ctx.sys.tar(['-cf', join(work, 'pack', 'data.tar'), '-C', join(sd, 'data'), '.']); writeJson(join(work, 'pack', 'meta.json'), { siteId, version: cur.split('/').pop(), exportedAt: new Date().toISOString(), ariweb: readJson(join(cur, 'ariweb.json'), {}) }); return tarDir(ctx, join(work, 'export.tar.gz'), join(work, 'pack')); }, '묶기'); const up = await ctx.step('upload', () => ctx.client.upload(p.transferId, t.file, { purpose: 'relocate' }), 'HAL 로 올리기'); rmrf(work); return { transferId: up.id, checksum: fmtSum(up.sha256), sizeBytes: up.size, version: cur.split('/').pop() }; } },
'site.import': { title: '사이트 받기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const stage = join(sd, 'staging', p.stage || 'pre'); rmrf(stage); mkdirSync(stage, { recursive: true }); const dl = await ctx.step('download', () => ctx.client.downloadTransfer(p.transferId, join(stage, 'export.tar.gz'), p.checksum || null), '받기'); await ctx.step('unpack', async () => { await untar(ctx, join(stage, 'export.tar.gz'), join(stage, 'pack')); mkdirSync(join(stage, 'files'), { recursive: true }); await ctx.sys.tar(['-xf', join(stage, 'pack', 'files.tar'), '-C', join(stage, 'files')]); if (existsSync(join(stage, 'pack', 'data.tar'))) { mkdirSync(join(stage, 'data'), { recursive: true }); await ctx.sys.tar(['-xf', join(stage, 'pack', 'data.tar'), '-C', join(stage, 'data')]); } return true; }, '풀기'); const meta = readJson(join(stage, 'pack', 'meta.json'), {}); writeJson(join(sd, 'staging', 'latest.json'), { stage: p.stage || 'pre', checksum: fmtSum(dl.sha256), meta, at: new Date().toISOString() }); return { ok: dl.sha256 === normSum(p.checksum || dl.sha256), checksum: fmtSum(dl.sha256), sizeBytes: dl.size, version: meta.version || null, files: readdirSync(join(stage, 'files')).length }; } },
'site.stage_check': { title: '새 위치 확인', async run(ctx) { const p = ctx.payload; const sd = siteDir(ctx.roots, assertSiteId(p.siteId)); const latest = readJson(join(sd, 'staging', 'latest.json'), null); const stage = latest ? join(sd, 'staging', latest.stage) : null; const ok = !!(latest && stage && existsSync(join(stage, 'files')) && (!p.expectedChecksum || normSum(latest.checksum) === normSum(p.expectedChecksum)) && readdirSync(join(stage, 'files')).length > 0); return { ok, checksum: latest?.checksum || null, expected: p.expectedChecksum || null, files: ok ? readdirSync(join(stage, 'files')).length : 0, reason: ok ? null : '스테이징 사본이 없거나 체크섬이 다릅니다' }; } },
'site.activate': { title: '새 위치에서 서비스 시작', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const latest = readJson(join(sd, 'staging', 'latest.json'), null); const rel = await ctx.step('place', () => { if (p.reactivate && readLink(join(sd, 'current'))) return { dir: readLink(join(sd, 'current')), reused: true }; if (!latest) throw Object.assign(new Error('스테이징 사본이 없습니다'), { code: 'no_staging' }); const dir = join(sd, 'releases', `${stamp()}-reloc`); renameSync(join(sd, 'staging', latest.stage, 'files'), dir); if (existsSync(join(sd, 'staging', latest.stage, 'data'))) { const old = join(sd, 'data'); if (existsSync(old)) renameSync(old, join(sd, `data.pre-reloc-${stamp()}`)); renameSync(join(sd, 'staging', latest.stage, 'data'), old); } writeJson(join(dir, '.ariweb-version.json'), { version: latest.meta?.version || dir.split('/').pop(), relocatedAt: new Date().toISOString() }); return { dir }; }, '배포본 배치'); switchLink(join(sd, 'current'), rel.dir); rmrf(join(sd, '.frozen')); let port = null; const meta = readJson(join(rel.dir, 'ariweb.json'), {}); if ((p.runtime || meta.runtime) === 'node') { port = sitePort(siteId, p); await ctx.step('unit', () => nodeUnit(ctx, siteId, sd, meta.start || 'server.js', port), '실행 유닛'); } const vh = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime: p.runtime || meta.runtime || 'static', port }), '도메인 연결'); const ic = await internalCheck(ctx, siteId, vh.serverNames[0]); if (!ic.ok) throw Object.assign(new Error('새 위치 내부 확인 실패'), { code: 'internal_check_failed', partial: { internalCheck: ic } }); return { version: rel.dir.split('/').pop(), internalCheck: ic, serverNames: vh.serverNames }; } },
'site.deactivate': { title: '이전 위치 서비스 중지(사본 보존)', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); await ctx.step('vhost', () => removeVhost(ctx, siteId), '도메인 연결 해제'); await ctx.step('unit', () => ctx.sys.systemctl('stop', `ariweb-site-${siteId}`, { optional: true }), '실행 유닛 중지'); const until = new Date(Date.now() + (p.retainDays || 7) * 86400000).toISOString(); writeJson(join(sd, '.retained.json'), { until, reason: 'relocated', at: new Date().toISOString() }); rmrf(join(sd, '.frozen')); return { retainedUntil: until }; } },
'site.freeze': { title: '쓰기 제한', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.frozen'); if (ctx.payload.freeze) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { frozen: !!ctx.payload.freeze, at: new Date().toISOString() }; } },
};
// ---------------- 사용량(§13.1)
const DAY_KEEP = 400;
function usageFile(ctx, siteId) { return join(ctx.dataDir, 'usage', `${siteId}.json`); }
/** awstats 로그 증분 읽기(inode·offset 저장) → 일별 집계 */
export function ingestLog(ctx, siteId, logPath, state) {
if (!existsSync(logPath)) return state;
const fd = openSync(logPath, 'r'); try {
const st = fstatSync(fd); const off = state.offsets?.[logPath] || { inode: null, offset: 0 }; let start = off.inode === st.ino && off.offset <= st.size ? off.offset : 0;
const buf = Buffer.alloc(Math.min(st.size - start, 64 * 1024 * 1024)); const n = readSync(fd, buf, 0, buf.length, start); const text = buf.toString('utf8', 0, n); const lastNl = text.lastIndexOf('\n'); const usable = lastNl >= 0 ? text.slice(0, lastNl + 1) : '';
for (const line of usable.split('\n')) { if (!line) continue; const f = line.split('|'); if (f.length < 7) continue; const day = f[0].slice(0, 10); const d = state.days[day] || (state.days[day] = { req: 0, bytesIn: 0, bytesOut: 0, err: 0, hist: {} }); d.req++; d.bytesIn += Number(f[2]) || 0; d.bytesOut += Number(f[3]) || 0; if (Number(f[4]) >= 500) d.err++; const ms = Math.round((Number(f[5]) || 0) * 1000); const bucket = ms < 100 ? '0' : ms < 300 ? '100' : ms < 1000 ? '300' : ms < 3000 ? '1000' : '3000'; d.hist[bucket] = (d.hist[bucket] || 0) + 1; }
state.offsets = state.offsets || {}; state.offsets[logPath] = { inode: st.ino, offset: start + Buffer.byteLength(usable) };
} finally { closeSync(fd); }
const days = Object.keys(state.days).sort(); for (const k of days.slice(0, Math.max(0, days.length - DAY_KEEP))) delete state.days[k];
return state;
}
function p95(hist) { const order = ['0', '100', '300', '1000', '3000']; const total = order.reduce((a, k) => a + (hist[k] || 0), 0); if (!total) return null; let acc = 0; for (const k of order) { acc += hist[k] || 0; if (acc / total >= 0.95) return Number(k); } return 3000; }
export async function collectUsage(ctx, siteId) {
assertSiteId(siteId); const sd = siteDir(ctx.roots, siteId); const f = usageFile(ctx, siteId); const state = readJson(f, { days: {}, offsets: {} });
ingestLog(ctx, siteId, join(ctx.roots.logs, `aw-${siteId}.access.log`), state); writeJson(f, state);
const today = new Date().toISOString().slice(0, 10); const kst = (d) => new Date(new Date(d).getTime() + 9 * 3600000).toISOString().slice(0, 10); const kToday = kst(Date.now()); const month = kToday.slice(0, 7);
const sum = (filter, key) => Object.entries(state.days).filter(([d]) => filter(d)).reduce((a, [, v]) => a + (v[key] || 0), 0);
const series = (n) => { const out = []; for (let i = n - 1; i >= 0; i--) { const d = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10); const v = state.days[d] || {}; out.push({ date: d, req: v.req || 0, bytesOut: v.bytesOut || 0, bytesIn: v.bytesIn || 0, err: v.err || 0 }); } return out; };
const cur = readLink(join(sd, 'current')); const dbDir = join(sd, 'data', 'db'); const last30 = (d) => Date.now() - Date.parse(d) < 30 * 86400000;
const errs = sum(last30, 'err'), reqs = sum(last30, 'req'); const histAll = {}; for (const [d, v] of Object.entries(state.days)) if (last30(d)) for (const [k, c] of Object.entries(v.hist || {})) histAll[k] = (histAll[k] || 0) + c;
return { diskBytes: cur ? dirSize(cur) : 0, dbBytes: dirSize(dbDir), uploadsBytes: dirSize(join(sd, 'data', 'uploads')), outBytesDay: sum((d) => d === today, 'bytesOut'), outBytesMonth: sum((d) => kst(d + 'T12:00:00Z').startsWith(month), 'bytesOut'), outBytesTotal: sum(() => true, 'bytesOut'), inBytesDay: sum((d) => d === today, 'bytesIn'), inBytesMonth: sum((d) => kst(d + 'T12:00:00Z').startsWith(month), 'bytesIn'), inBytesTotal: sum(() => true, 'bytesIn'), req24h: sum((d) => d === today, 'req'), pv24h: sum((d) => d === today, 'req'), series7d: series(7), series30d: series(30), errorRate: reqs ? Math.round(errs / reqs * 10000) / 100 : 0, p95ms: p95(histAll), measuredAt: new Date().toISOString(), kstMonth: month };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* agent/metrics.mjs — 서버 지표(§28.1): CPU·메모리·디스크·inode·I/O·네트워크·load. /proc 와 df 만 읽는다(외부 도구 없음).
* CPU 는 두 표본 사이의 사용률(%). I/O·네트워크는 누적 바이트(HAL 이 차분으로 속도를 구한다). 읽을 수 없는 항목은 null.
*/
import { readFileSync, statfsSync, existsSync } from 'node:fs';
import { loadavg, hostname, release, totalmem, freemem } from 'node:os';
import { execFileSync } from 'node:child_process';
let lastCpu = null;
function cpuPercent() {
try { const l = readFileSync('/proc/stat', 'utf8').split('\n')[0].trim().split(/\s+/).slice(1).map(Number); const idle = l[3] + (l[4] || 0); const total = l.reduce((a, b) => a + b, 0); const prev = lastCpu; lastCpu = { idle, total }; if (!prev) return null; const dt = total - prev.total; return dt > 0 ? Math.round((1 - (idle - prev.idle) / dt) * 1000) / 10 : null; } catch (_) { return null; }
}
function mem() { try { const m = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').filter(Boolean).map((l) => { const [k, v] = l.split(':'); return [k, parseInt(v, 10) * 1024]; })); return { total: m.MemTotal, used: m.MemTotal - (m.MemAvailable ?? m.MemFree) }; } catch (_) { return { total: totalmem(), used: totalmem() - freemem() }; } }
function disk(path = '/') { try { const s = statfsSync(path); return { total: s.blocks * s.bsize, used: (s.blocks - s.bfree) * s.bsize, inode: { total: s.files, used: s.files - s.ffree } }; } catch (_) { return null; } }
function io() { try { let r = 0, w = 0; for (const l of readFileSync('/proc/diskstats', 'utf8').split('\n')) { const f = l.trim().split(/\s+/); if (f.length < 14 || !/^(sd|vd|nvme|xvd|md|dm)/.test(f[2]) || /\d+p?\d+$/.test(f[2]) && !/^(md|dm)/.test(f[2])) continue; r += Number(f[5]) * 512; w += Number(f[9]) * 512; } return { read: r, write: w }; } catch (_) { return null; } }
function net() { try { let i = 0, o = 0; for (const l of readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2)) { const [name, rest] = l.split(':'); if (!rest || name.trim() === 'lo') continue; const f = rest.trim().split(/\s+/); i += Number(f[0]); o += Number(f[8]); } return { in: i, out: o }; } catch (_) { return null; } }
/** heartbeat 에 실을 지표 묶음 */
export function sample(roots = {}) { const dk = disk('/'); return { cpu: cpuPercent(), mem: mem(), disk: dk ? { total: dk.total, used: dk.used } : null, inode: dk ? dk.inode : null, io: io(), net: net(), load1: loadavg()[0], at: new Date().toISOString() }; }
/** 서버 사실(facts): 등록·heartbeat 때 HAL 에 알린다. 비밀 없음. */
export function facts(sys) {
const f = { hostname: hostname(), kernel: release(), node: process.version, nginx: existsSync('/usr/sbin/nginx') || existsSync('/usr/bin/nginx'), certbot: existsSync('/usr/bin/certbot') || existsSync('/snap/bin/certbot'), units: {}, appDirs: {} };
try { const out = execFileSync('systemctl', ['list-units', '--type=service', '--all', '--no-pager', '--no-legend', '--plain'], { encoding: 'utf8', timeout: 5000 }); const names = out.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter(Boolean).map((n) => n.replace(/\.service$/, ''));
for (const [mod, cands] of Object.entries({ webmaker: ['webmaker'], arimail: ['arimail', 'arihakdang-mail', 'heart-mail', 'mail-gateway'], hal: ['ariweb-hal'], portal: ['ariweb-portal'], agent: ['ariweb-agent'] })) { const u = cands.find((c) => names.includes(c)); if (u) { f.units[mod] = u; try { const txt = execFileSync('systemctl', ['cat', u], { encoding: 'utf8', timeout: 5000 }); const wd = /^WorkingDirectory=(.+)$/m.exec(txt); if (wd) f.appDirs[mod] = wd[1].replace(/\/current\/?$/, ''); const user = /^User=(.+)$/m.exec(txt); if (user) (f.users = f.users || {})[mod] = user[1]; const envf = /^EnvironmentFile=(.+)$/m.exec(txt); if (envf) (f.envFiles = f.envFiles || {})[mod] = envf[1]; const node = /^ExecStart=(\S+)/m.exec(txt); if (node) (f.nodeBins = f.nodeBins || {})[mod] = node[1]; } catch (_) {} } } } catch (_) {}
return f;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* agent/runner.mjs — Agent 작업 실행기(단계 저장·재개·진행 보고).
*
* HAL 의 lib/jobs 와 같은 원칙: 작업은 재진입 가능하고 끝난 단계는 건너뛴다. 단계 상태는 data/jobs/<id>.json 에 저장되어 Agent 재시작 뒤 이어서 실행된다.
* ctx = { job, payload, roots, sys, client, dataDir, step(id, fn, title), log(line), progress(pct), needsUser(message, hint), checkCancel(), steps() }
* 결과 보고: 성공 { status:'done', result } / 실패 { status:'failed', error, errorCode } / 사용자·운영자 확인 { status:'needs_user', userMessage }.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { redact, safeError } from '../lib/redact.mjs';
import { NeedsUser } from './sys.mjs';
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
export function createRunner({ kinds, roots, sys, client, dataDir, log = () => {} }) {
const jobsDir = join(dataDir, 'jobs'); mkdirSync(jobsDir, { recursive: true });
const stateFile = (id) => join(jobsDir, `${id}.json`);
const loadState = (id) => { try { return JSON.parse(readFileSync(stateFile(id), 'utf8')); } catch (_) { return { steps: [], log: [] }; } };
const saveState = (id, st) => { const tmp = stateFile(id) + '.tmp'; writeFileSync(tmp, JSON.stringify(st)); renameSync(tmp, stateFile(id)); };
/** 진행 보고(HAL). 실패해도 작업은 계속한다(다음 보고에서 따라잡음). */
async function report(id, body) { if (!client) return null; try { return await client.request('POST', `/agent/v1/jobs/${id}/progress`, body); } catch (e) { log(`[agent] 진행 보고 실패 ${id}: ${safeError(e)}`); return null; } }
async function execute(job) {
const def = kinds[job.kind]; const st = loadState(job.id); let cancelled = false;
const ctx = {
job, payload: job.payload || {}, roots, sys, client, dataDir, kinds,
async step(id, fn, title) {
let s = st.steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; st.steps.push(s); }
if (s.state === 'done') return s.result;
s.state = 'running'; s.startedAt = new Date().toISOString(); delete s.error; saveState(job.id, st); await report(job.id, { steps: st.steps });
try { const r = await fn(); s.state = 'done'; s.endedAt = new Date().toISOString(); s.result = r === undefined ? null : redact(r); saveState(job.id, st); return r; }
catch (e) { s.state = e instanceof Signal ? 'needs_user' : 'failed'; s.error = safeError(e); s.endedAt = new Date().toISOString(); saveState(job.id, st); throw e; }
},
log(line) { st.log.push({ at: new Date().toISOString(), line: String(line).slice(0, 500) }); st.log = st.log.slice(-200); saveState(job.id, st); log(`[${job.kind} ${job.id}] ${line}`); },
async progress(pct) { st.progress = Math.max(0, Math.min(100, Math.round(pct))); saveState(job.id, st); const r = await report(job.id, { progress: st.progress, steps: st.steps, log: st.log.slice(-20) }); if (r && r.cancelRequested) cancelled = true; },
needsUser(message, hint) { throw new Signal('needs_user', { message, hint }); },
checkCancel() { if (cancelled) throw new Signal('cancel', {}); },
steps: () => st.steps, state: st,
};
let out;
if (!def) out = { status: 'failed', error: `알 수 없는 작업 종류: ${job.kind}`, errorCode: 'unknown_kind' };
else {
try { const result = await def.run(ctx); out = { status: 'done', result: redact(result === undefined ? {} : result), steps: st.steps, log: st.log.slice(-50) }; }
catch (e) {
if (e instanceof Signal && e.signal === 'needs_user') out = { status: 'needs_user', userMessage: `${e.payload.message}${e.payload.hint ? ` (${e.payload.hint})` : ''}`, steps: st.steps, log: st.log.slice(-50) };
else if (e instanceof Signal && e.signal === 'cancel') out = { status: 'failed', error: '취소 요청으로 중단', errorCode: 'cancelled', steps: st.steps, log: st.log.slice(-50) };
else if (e instanceof NeedsUser || e.needsUser) out = { status: 'needs_user', userMessage: `${e.message}${e.hint ? ` — ${e.hint}` : ''}`, steps: st.steps, log: st.log.slice(-50) };
else out = { status: 'failed', error: safeError(e), errorCode: e.code || 'failed', steps: st.steps, log: st.log.slice(-50), result: e.partial ? redact(e.partial) : undefined };
}
}
try { unlinkSync(stateFile(job.id)); } catch (_) {}
if (client) { try { await client.request('POST', `/agent/v1/jobs/${job.id}/result`, out); } catch (e) { log(`[agent] 결과 보고 실패 ${job.id}: ${safeError(e)}`); saveState(job.id, { ...st, pendingResult: out }); } }
return out;
}
/** 재시작 뒤: 보고하지 못한 결과가 남아 있으면 다시 보고 */
async function flushPending() { if (!client) return; const { readdirSync } = await import('node:fs'); for (const f of readdirSync(jobsDir).filter((x) => x.endsWith('.json'))) { const st = loadState(f.slice(0, -5)); if (st.pendingResult) { try { await client.request('POST', `/agent/v1/jobs/${f.slice(0, -5)}/result`, st.pendingResult); unlinkSync(join(jobsDir, f)); } catch (_) {} } } }
return { execute, loadState, flushPending, kinds };
}
+36
View File
@@ -0,0 +1,36 @@
/**
* agent/sys.mjs — 시스템 명령 실행 계층(sudo·systemctl·nginx·certbot·tar) 과 권한 있는 파일 쓰기.
*
* Agent 는 일반 계정(master)으로 돌고 systemctl/nginx/certbot/tee 만 sudo -n(비밀번호 없음)으로 쓴다(deploy/sudoers/ariweb-agent).
* sudo 가 막혀 있으면 조용히 실패하지 않고 NEEDS_USER 로 운영자에게 이유(힌트)를 보인다.
* stub 모드(ARIWEB_AGENT_STUB_CMDS=1, 테스트): 외부 명령을 실행하지 않고 호출만 기록하며 성공으로 돌려준다. tar 는 실제로 실행한다(파일 결과를 검증하기 위해).
*/
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
import { dirname } from 'node:path';
export class NeedsUser extends Error { constructor(message, hint) { super(message); this.needsUser = true; this.hint = hint; } }
export function createSys({ stub = process.env.ARIWEB_AGENT_STUB_CMDS === '1', log = () => {} } = {}) {
const calls = [];
/** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true, env = null } = {}) {
calls.push({ cmd, args });
if (stub && allowStub && cmd !== 'tar' && cmd !== 'node') return Promise.resolve({ code: 0, stdout: '', stderr: '', stubbed: true });
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: env || process.env }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
}
const sudo = (args, o) => run('sudo', ['-n', ...args], o);
async function hasSudo() { if (stub) return true; const r = await run('sudo', ['-n', 'true']); return r.code === 0; }
async function requireSudo(what) { if (!(await hasSudo())) throw new NeedsUser(`${what} 에 sudo 권한이 필요하지만 이 서버에서 비밀번호 없는 sudo 가 허용되지 않았습니다`, 'deploy/sudoers/ariweb-agent 를 /etc/sudoers.d/ 에 설치하세요'); }
/** 권한이 필요한 경로에 파일 쓰기: 직접 쓰기 → 안 되면 sudo tee */
async function writeFile(path, content, mode = '644') {
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); return { via: 'direct' }; } catch (e) { if (e.code !== 'EACCES' && e.code !== 'EPERM') throw e; }
await requireSudo(`${path} 쓰기`); await sudo(['mkdir', '-p', dirname(path)]); const r = await sudo(['tee', path], { input: content }); if (r.code !== 0) throw new Error(`쓰기 실패 ${path}: ${r.stderr}`); await sudo(['chmod', mode, path]); return { via: 'sudo' };
}
async function systemctl(action, unit, { optional = false } = {}) { await requireSudo(`systemctl ${action} ${unit}`); const r = await sudo(['systemctl', action, unit]); if (r.code !== 0 && !optional) throw new Error(`systemctl ${action} ${unit} 실패: ${(r.stderr || r.stdout).trim().slice(0, 300)}`); return r; }
async function isActive(unit) { if (stub) return true; const r = await run('systemctl', ['is-active', unit]); return r.stdout.trim() === 'active'; }
async function unitText(unit) { if (stub) return ''; const r = await run('systemctl', ['cat', unit]); return r.code === 0 ? r.stdout : ''; }
async function nginxReload() { await requireSudo('nginx 설정 적용'); const t = await sudo(['nginx', '-t']); if (t.code !== 0) throw new Error(`nginx -t 실패: ${(t.stderr || t.stdout).trim().slice(0, 400)}`); const r = await sudo(['systemctl', 'reload', 'nginx']); if (r.code !== 0) throw new Error(`nginx reload 실패: ${r.stderr.slice(0, 300)}`); return true; }
async function certbot(args) { await requireSudo('certbot'); return sudo(['certbot', ...args], { timeoutMs: 300000 }); }
async function tar(args, o) { const r = await run('tar', args, { ...o, allowStub: false }); if (r.code !== 0) throw new Error(`tar 실패: ${r.stderr.slice(0, 300)}`); return r; }
return { stub, calls, run, sudo, hasSudo, requireSudo, writeFile, systemctl, isActive, unitText, nginxReload, certbot, tar, readIfExists: (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null) };
}
+116
View File
@@ -0,0 +1,116 @@
/**
* agent/webmaker-inventory.mjs — 기존 Webmaker 사이트 디렉터리를 HAL 서버 인벤토리로 정리한다.
*
* 운영 정보만 보낸다. 페이지 본문, 프롬프트, 사용자 연락처, 인증정보와 비밀값은 결과에
* 넣지 않는다. 사이트 작업 디렉터리도 존재할 수 있으므로 배포 이력을 함께 읽어 상태를
* deployed/unpublished/draft/unknown으로 구분한다. 메타데이터 없는 디렉터리도 누락하지 않는다.
*/
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const SITE_ID = /^[a-f0-9]{32}$/;
const text = (v, max = 240) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max) : null;
function domainsOf(site) {
const src = Array.isArray(site?.domains) ? site.domains : [];
return [...new Set(src.map((d) => typeof d === 'string' ? d : d?.domain || d?.hostname || d?.host || d?.name)
.map((d) => text(d, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100);
}
function treeStat(root, errors, siteId) {
let bytes = 0; let files = 0; let complete = true; const stack = [root];
const note = (e) => { complete = false; const code = e?.code || 'read_failed'; const marker = `site:${siteId}:${code}`; if (!errors.includes(marker)) errors.push(marker); };
while (stack.length) {
const dir = stack.pop(); let entries = [];
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { note(e); continue; }
for (const e of entries) {
const p = join(dir, e.name); try {
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) stack.push(p);
else if (e.isFile()) { bytes += lstatSync(p).size; files += 1; }
} catch (error) { note(error); }
}
}
return { bytes, files, complete };
}
function deploymentState(site) {
if (!site) return 'unknown';
if (site.unpublishedAt) return 'unpublished';
const history = Array.isArray(site.deployments) ? site.deployments : [];
const lastLifecycle = [...history].reverse().find((d) => d && ((d.kind === 'unpublish' && d.ok !== false) || (d.ok && d.kind !== 'unpublish' && (d.ready === true || d.phase === 'ready' || d.phase === 'transferred' || !d.phase))));
if (lastLifecycle?.kind === 'unpublish') return 'unpublished';
if (lastLifecycle || site.deployedAt || site.publishedAt) return 'deployed';
return 'draft';
}
export function scanWebmakerInventory({ dataRoot, dataDir = null, sitesRoot, maxSites = Infinity } = {}) {
const metaRoot = dataDir || join(dataRoot || '', 'sites');
let dirs = []; let metadataIds = []; let complete = true; const errors = [];
try { dirs = readdirSync(sitesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && SITE_ID.test(e.name)).map((e) => e.name); }
catch (e) { complete = false; errors.push(`sites:${e.code || 'read_failed'}`); }
try { metadataIds = readdirSync(metaRoot, { withFileTypes: true }).filter((e) => e.isFile() && SITE_ID.test(e.name.replace(/\.json$/i, '')) && /\.json$/i.test(e.name)).map((e) => e.name.slice(0, -5)); } catch (e) { complete = false; errors.push(`metadata:${e.code || 'read_failed'}`); }
const deployed = new Set(dirs); const ids = [...new Set([...dirs, ...metadataIds])].sort();
const limit = Number.isFinite(maxSites) && maxSites >= 0 ? Math.floor(maxSites) : ids.length;
const selected = ids.slice(0, limit);
const sites = selected.map((id) => {
let site = null; let metadata = 'missing';
const metadataFile = join(metaRoot, `${id}.json`);
try {
site = JSON.parse(readFileSync(metadataFile, 'utf8'));
if (!site || typeof site !== 'object' || Array.isArray(site)) { site = null; metadata = 'invalid'; const marker = `metadata:${id}:invalid_shape`; if (!errors.includes(marker)) errors.push(marker); }
else metadata = 'ok';
} catch (e) {
if (e?.code === 'ENOENT' && !existsSync(metadataFile)) metadata = 'missing';
else { metadata = 'invalid'; const marker = `metadata:${id}:${e?.code || 'invalid_json'}`; if (!errors.includes(marker)) errors.push(marker); }
}
const size = deployed.has(id) ? treeStat(join(sitesRoot, id), errors, id) : { bytes: 0, files: 0, complete: true };
if (!size.complete) complete = false;
return {
id,
title: text(site?.title || site?.name || id, 200),
ownerId: text(site?.ownerId, 128),
status: text(site?.status, 80),
deploymentState: deploymentState(site),
domains: domainsOf(site),
updatedAt: text(site?.updatedAt || site?.deployedAt || site?.publishedAt, 40),
metadata,
bytes: size.bytes,
files: size.files,
};
});
if (sites.length < ids.length) complete = false;
return {
dataRoot: dataRoot || null,
dataDir: metaRoot || null,
sitesRoot: sitesRoot || null,
total: ids.length,
returned: sites.length,
truncated: sites.length < ids.length,
errors,
complete,
scannedAt: new Date().toISOString(),
sites,
};
}
/** 운영 경로는 Agent unit에 명시적으로 주입한다. systemd 설정을 추측·재해석해 다른 데이터 루트를 스캔하지 않는다. */
export function configuredWebmakerInventory(facts, env = process.env) {
if (!facts?.units?.webmaker) return facts;
const dataRoot = env.ARIWEB_WEBMAKER_DATA_ROOT; const dataDir = env.ARIWEB_WEBMAKER_DATA_DIR; const sitesRoot = env.ARIWEB_WEBMAKER_SITES_ROOT;
if (![dataRoot, dataDir, sitesRoot].every((p) => typeof p === 'string' && p.startsWith('/'))) return { ...facts, webmakerInventory: { dataRoot: null, dataDir: null, sitesRoot: null, total: 0, returned: 0, truncated: false, errors: ['configuration:ARIWEB_WEBMAKER_DATA_ROOT_DATA_DIR_SITES_ROOT_required'], complete: false, scannedAt: new Date().toISOString(), sites: [] } };
return { ...facts, webmakerInventory: scanWebmakerInventory({ dataRoot, dataDir, sitesRoot }) };
}
/** HAL JSON 본문 상한과 무관하게 전체 사이트를 byte·항목 수 기준 배치로 나눈다. */
export function webmakerInventoryBatches(inv, maxBytes = 512 * 1024) {
if (!Number.isInteger(maxBytes) || maxBytes < 64 * 1024) throw new Error('인벤토리 배치 한도가 너무 작습니다');
if (inv?.complete === false) throw new Error('inventory_incomplete');
const sites = Array.isArray(inv?.sites) ? inv.sites : []; const rawErrors = Array.isArray(inv?.errors) ? inv.errors : []; const meta = {
dataRoot: String(inv?.dataRoot || '').slice(0, 4096), dataDir: String(inv?.dataDir || '').slice(0, 4096), sitesRoot: String(inv?.sitesRoot || '').slice(0, 4096), total: Number.isFinite(Number(inv?.total)) ? Number(inv.total) : sites.length, truncated: !!inv?.truncated,
errors: rawErrors.slice(0, 1000).map((x) => String(x).slice(0, 500)), errorsTruncated: rawErrors.length > 1000, scannedAt: String(inv?.scannedAt || new Date().toISOString()).slice(0, 80),
}; const scanId = meta.scannedAt || String(Date.now()); const out = []; let offset = 0; let part = [];
const body = (items) => ({ scanId, offset, itemTotal: sites.length, meta, sites: items });
for (const site of sites) {
const candidate = [...part, site]; if (candidate.length > 1000 || Buffer.byteLength(JSON.stringify(body(candidate))) > maxBytes) {
if (!part.length) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다'); out.push(body(part)); offset += part.length; part = [site]; if (Buffer.byteLength(JSON.stringify(body(part))) > maxBytes) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다');
} else part = candidate;
}
if (part.length || !sites.length) { const last = body(part); if (Buffer.byteLength(JSON.stringify(last)) > maxBytes) throw new Error('Webmaker 인벤토리 메타데이터가 배치 한도를 넘습니다'); out.push(last); } return out;
}
+407
View File
@@ -0,0 +1,407 @@
{
"_comment": "Ariweb Suite 0.1.1 출시 검증용 버전 고정 상품 Catalog. 운영 상품정책은 운영자가 화면에서 관리하며 이 파일은 검증 환경 시드일 뿐이다. 금액·한도는 시험용 값이고 비밀값은 없다.",
"catalog_version": "release-0.1.1",
"payment_instructions": {
"method": "manual_transfer",
"bank": "검증용 은행(예시)",
"account": "000-0000-0000-00 (검증 환경 예시 계좌)",
"holder": "Ariweb 검증 환경",
"note": "입금자명에 주문 번호를 적어 주세요. 운영자가 입금을 확인한 뒤 승인하면 권리가 활성화됩니다.",
"deadline_days": 7
},
"products": [
{
"product_id": "basic",
"version": 1,
"name": "베이직",
"audience": "개인·소규모 사업자",
"kind": "base",
"description": "사이트 1개와 대표 메일을 운영하는 기본 상품",
"billing_cycle": "monthly",
"price": 19000,
"currency": "KRW",
"tax_rate": 0.1,
"limits": {
"sites": 1,
"web_disk": 5368709120,
"web_out": 53687091200,
"mail_storage": 5368709120,
"mail_msgs": 3000,
"mailboxes": 5
},
"options": [
{
"id": "standalone_mail",
"name": "독립 메일 서비스",
"price": 9000,
"grants": {
"mail_services": 1,
"mail_storage": 5368709120,
"mail_msgs": 3000,
"mailboxes": 5
},
"per": "group",
"max_qty": 3
},
{
"id": "source",
"name": "Source(Gitea) 옵션",
"price": 5000,
"grants": {
"source": 1
},
"per": "site",
"max_qty": 1
},
{
"id": "backup",
"name": "관리형 사이트 백업 옵션",
"price": 7000,
"grants": {
"backup": 1
},
"per": "site",
"max_qty": 1,
"policy": {
"schedule": "daily",
"schedule_time_kst": "03:30",
"retention": {
"daily": 7,
"weekly": 4,
"monthly": 3
},
"storage_bytes": 21474836480,
"first_backup_within_hours": 24,
"remote_copy_verification": "sha256 + 격리 환경 읽기 확인",
"restore_cost": 0,
"restore_approval": "manual",
"expired_backup_retention_days": 7,
"failure_alert_after": 3,
"extra_storage_option": "extra_backup_storage",
"restore_completion": "user_confirm",
"protect_point_days": 7,
"revert_hours": 72,
"resume_after_pause": "confirm",
"restore_downtime_minutes": 10
}
},
{
"id": "extra_disk",
"name": "웹 디스크 추가 5GB",
"price": 3000,
"grants": {
"web_disk": 5368709120
},
"per": "group",
"max_qty": 10
},
{
"id": "extra_out",
"name": "월간 웹 아웃바운드 추가 50GB",
"price": 3000,
"grants": {
"web_out": 53687091200
},
"per": "group",
"max_qty": 10
},
{
"id": "extra_mailboxes",
"name": "메일함 5개 추가",
"price": 2000,
"grants": {
"mailboxes": 5
},
"per": "group",
"max_qty": 10
},
{
"id": "extra_backup_storage",
"name": "백업 저장 공간 추가 20GB",
"price": 2000,
"grants": {},
"per": "site",
"max_qty": 5
}
],
"includes": [
"관리형 배포(정적·승인된 동적)",
"사이트 대표 메일 도메인 1개",
"도메인 연결·HTTPS 인증서",
"접속·사용량 통계",
"알림 센터"
],
"excludes": [
"외부 배포 서버 운영·백업·SLA",
"메일 원문 백업",
"도메인 등록(외부 등록기관 이용)",
"자동 결제"
],
"policy": {
"approval": "manual",
"payment_deadline_days": 7,
"renewal_notice_days": 7,
"restriction_after_unpaid_days": 14,
"restriction_effect": "신규 배포·설정 변경·새 메일함 생성 제한(기존 서비스·조회·삭제·내보내기 유지)",
"retention_days": {
"site": 30,
"mail": 30,
"source": 30,
"backup_after_expiry": 7
},
"external_deploy_fee": 3000,
"relocation": {
"customer_requested_fee": 10000,
"platform_reason_fee": 0,
"target_downtime_minutes": 15,
"revert_window_hours": 72,
"old_location_retention_hours": 72,
"dns_wait_handling": "전환 뒤 DNS·인증서 반영 대기 중에는 이전 위치가 새 위치로 요청을 넘겨 접속이 끊기지 않게 한다",
"write_freeze": "동적 사이트·메일은 최종 변경분 반영 직전부터 전환 완료까지 쓰기 제한"
},
"overage": {
"default": false,
"unit_prices": {
"web_out": {
"per_bytes": 10737418240,
"price": 1000
},
"mail_msgs": {
"per_units": 1000,
"price": 1000
}
}
},
"lifecycle": {
"pause_keeps_site_count": true,
"cancel_effective": "next_billing_date",
"readonly_retention_days": 30,
"delete_after_days": 30
},
"alias_counting": {
"value": null,
"status": "deferred",
"note": "별칭·전달 전용 주소의 계산 방식은 후속 상품정책에서 정한다. 확정 전에는 무제한으로 제공하지 않으며 메일함 수에 포함해 계산한다."
},
"large_attachment": {
"value": null,
"status": "deferred",
"note": "대용량 첨부의 허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정한다. 시스템 안전 상한이 우선한다."
},
"ai_wallet_pricing": {
"value": null,
"status": "deferred",
"note": "AI 제작 기능의 크레딧 단가는 Webmaker 관리자 설정(등급별)을 따르며 상품정책에 고정하지 않는다."
}
},
"warn_levels": [
70,
80,
90,
95
],
"effective_from": "2026-10-01T00:00:00.000Z",
"published": true
},
{
"product_id": "team",
"version": 1,
"name": "팀",
"audience": "여러 사이트를 함께 운영하는 팀·회사",
"kind": "base",
"description": "사이트 3개와 넉넉한 메일·트래픽",
"billing_cycle": "monthly",
"price": 49000,
"currency": "KRW",
"tax_rate": 0.1,
"limits": {
"sites": 3,
"web_disk": 21474836480,
"web_out": 214748364800,
"mail_storage": 21474836480,
"mail_msgs": 10000,
"mailboxes": 20
},
"options": [
{
"id": "standalone_mail",
"name": "독립 메일 서비스",
"price": 9000,
"grants": {
"mail_services": 1,
"mail_storage": 5368709120,
"mail_msgs": 3000,
"mailboxes": 5
},
"per": "group",
"max_qty": 5
},
{
"id": "source",
"name": "Source(Gitea) 옵션",
"price": 5000,
"grants": {
"source": 1
},
"per": "site",
"max_qty": 1
},
{
"id": "backup",
"name": "관리형 사이트 백업 옵션",
"price": 7000,
"grants": {
"backup": 1
},
"per": "site",
"max_qty": 1,
"policy": {
"schedule": "daily",
"schedule_time_kst": "03:30",
"retention": {
"daily": 7,
"weekly": 4,
"monthly": 3
},
"storage_bytes": 53687091200,
"first_backup_within_hours": 24,
"remote_copy_verification": "sha256 + 격리 환경 읽기 확인",
"restore_cost": 0,
"restore_approval": "manual",
"expired_backup_retention_days": 7,
"failure_alert_after": 3,
"extra_storage_option": "extra_backup_storage",
"restore_completion": "user_confirm",
"protect_point_days": 7,
"revert_hours": 72,
"resume_after_pause": "confirm",
"restore_downtime_minutes": 10
}
},
{
"id": "extra_disk",
"name": "웹 디스크 추가 5GB",
"price": 3000,
"grants": {
"web_disk": 5368709120
},
"per": "group",
"max_qty": 20
},
{
"id": "extra_out",
"name": "월간 웹 아웃바운드 추가 50GB",
"price": 3000,
"grants": {
"web_out": 53687091200
},
"per": "group",
"max_qty": 20
},
{
"id": "extra_mailboxes",
"name": "메일함 5개 추가",
"price": 2000,
"grants": {
"mailboxes": 5
},
"per": "group",
"max_qty": 20
},
{
"id": "extra_backup_storage",
"name": "백업 저장 공간 추가 20GB",
"price": 2000,
"grants": {},
"per": "site",
"max_qty": 5
}
],
"includes": [
"관리형 배포(정적·승인된 동적)",
"사이트마다 대표 메일 도메인 1개",
"도메인 연결·HTTPS 인증서",
"접속·사용량 통계",
"사이트별 구성원 권한"
],
"excludes": [
"외부 배포 서버 운영·백업·SLA",
"메일 원문 백업",
"도메인 등록(외부 등록기관 이용)",
"자동 결제"
],
"policy": {
"approval": "manual",
"payment_deadline_days": 7,
"renewal_notice_days": 7,
"restriction_after_unpaid_days": 14,
"restriction_effect": "신규 배포·설정 변경·새 메일함 생성 제한(기존 서비스·조회·삭제·내보내기 유지)",
"retention_days": {
"site": 30,
"mail": 30,
"source": 30,
"backup_after_expiry": 7
},
"external_deploy_fee": 3000,
"relocation": {
"customer_requested_fee": 10000,
"platform_reason_fee": 0,
"target_downtime_minutes": 15,
"revert_window_hours": 72,
"old_location_retention_hours": 72,
"dns_wait_handling": "전환 뒤 DNS·인증서 반영 대기 중에는 이전 위치가 새 위치로 요청을 넘겨 접속이 끊기지 않게 한다",
"write_freeze": "동적 사이트·메일은 최종 변경분 반영 직전부터 전환 완료까지 쓰기 제한"
},
"overage": {
"default": false,
"unit_prices": {
"web_out": {
"per_bytes": 10737418240,
"price": 1000
},
"mail_msgs": {
"per_units": 1000,
"price": 1000
}
}
},
"lifecycle": {
"pause_keeps_site_count": true,
"cancel_effective": "next_billing_date",
"readonly_retention_days": 30,
"delete_after_days": 30
},
"alias_counting": {
"value": null,
"status": "deferred",
"note": "별칭·전달 전용 주소의 계산 방식은 후속 상품정책에서 정한다. 확정 전에는 무제한으로 제공하지 않으며 메일함 수에 포함해 계산한다."
},
"large_attachment": {
"value": null,
"status": "deferred",
"note": "대용량 첨부의 허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정한다. 시스템 안전 상한이 우선한다."
},
"ai_wallet_pricing": {
"value": null,
"status": "deferred",
"note": "AI 제작 기능의 크레딧 단가는 Webmaker 관리자 설정(등급별)을 따르며 상품정책에 고정하지 않는다."
}
},
"warn_levels": [
70,
80,
90,
95
],
"effective_from": "2026-10-01T00:00:00.000Z",
"published": true
}
],
"settings": {
"domain_registration": {
"enabled": false,
"note": "버전 0.1.1 검증 환경에는 등록기관이 연결되어 있지 않습니다. 도메인은 외부 등록기관에서 등록한 뒤 연결하세요."
},
"usage.stale_minutes": 30
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# deploy/agent-install.sh — 관리 서버 또는 고객 외부 서버에 Node Agent 설치·등록(§28.2·§10.2). HAL 이 /agent/v1/install.sh 로 제공한다.
# curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <일회용 토큰> [--role web|mail|backup|control] [--external --site <siteId>] [--app-dir /srv/ariweb/agent]
# 하는 일: node 22+ 확인(없으면 NodeSource 로 설치), agent artifact 를 HAL 에서 받아 <app-dir>/releases/<ver> 에 풀고 current 연결, 등록(토큰 1회), systemd 유닛 ariweb-agent 설치·시작, sudoers 최소 규칙(관리형만).
# 멱등: 다시 실행하면 이미 등록된 Agent 는 재등록하지 않고 코드·유닛만 맞춘다. 토큰 외의 비밀은 출력하지 않는다.
set -euo pipefail
HAL=""; TOKEN=""; ROLE="web"; EXTERNAL=0; SITE=""; APP_DIR="/srv/ariweb/agent"; LABEL="$(hostname)"
while [ $# -gt 0 ]; do case "$1" in --hal) HAL="$2"; shift 2;; --token) TOKEN="$2"; shift 2;; --role) ROLE="$2"; shift 2;; --external) EXTERNAL=1; shift;; --site) SITE="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --label) LABEL="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
[ -n "$HAL" ] || { echo "--hal 이 필요합니다" >&2; exit 1; }
HAL="${HAL%/}"; USER_NAME="$(id -un)"
if ! command -v node >/dev/null 2>&1 || [ "$(node --version | sed -E 's/^v([0-9]+).*/\1/')" -lt 22 ]; then echo "== node 22 설치"; curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n bash - >/dev/null && sudo -n apt-get install -y -q nodejs >/dev/null; fi
NODE_BIN="$(command -v node)"
echo "== HAL 확인 $HAL"; HEALTH="$(curl -fsS "$HAL/api/health")"; VERSION="$(echo "$HEALTH" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).bootstrap.releaseVersion||JSON.parse(s).version))")"
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$APP_DIR/data" /var/www/ariweb-sites /srv/ariweb/backups; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /var/www/ariweb-sites /srv/ariweb/backups; chmod 700 "$APP_DIR/data"
if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then
echo "== agent artifact $VERSION 내려받기"
# 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로).
if [ -n "$TOKEN" ]; then curl -fsS -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi
fi
# 실행 계정이 다른 도입 모듈 설치(deploy/module-as-user.sh)를 위해 코드 트리는 다른 계정도 읽을 수 있어야 한다 — 호출자의 umask 와 무관하게 맞춘다(실제 환경 결함 수정)
chmod 755 "$APP_DIR" "$APP_DIR/releases"; chmod -R a+rX "$APP_DIR/releases/$VERSION"
ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current"
export ARIWEB_AGENT_DATA="$APP_DIR/data"
if [ ! -f "$APP_DIR/data/agent.json" ]; then [ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"; if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL"; fi; else echo "== 이미 등록됨($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).id))")) — 코드·유닛만 갱신"; fi
echo "== systemd 유닛"
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" -e "s#{{ENV_LINES}}#$([ "$EXTERNAL" = "1" ] && echo "Environment=ARIWEB_AGENT_EXTERNAL=1" || echo "")#" deploy/units/ariweb-agent.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-agent.service >/dev/null
if [ "$EXTERNAL" = "0" ]; then sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent && sudo -n visudo -c >/dev/null; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf 2>/dev/null && (sudo -n nginx -t >/dev/null 2>&1 && sudo -n systemctl reload nginx || true); fi
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-agent >/dev/null; sudo -n systemctl restart ariweb-agent; sleep 2
systemctl is-active --quiet ariweb-agent && echo "== 완료: ariweb-agent 실행 중. HAL 운영자 콘솔에서 서버를 승인하면 배치가 가능합니다" || { echo "ariweb-agent 기동 실패 — journalctl -u ariweb-agent" >&2; exit 1; }
+65
View File
@@ -0,0 +1,65 @@
#!/bin/bash
# deploy/bootstrap-hal.sh — HAL 최초 설치(부트스트랩, Dev-Order §32.2). 최초 설치에서만 허용되는 작업:
# 1) 승인된 main 릴리스의 고정 artifact 선택 2) HAL 저장 기능·비밀정보 보관소·인증서 기반·접근 경로 준비 3) HAL 설치 4) 첫 관리 서버 Node Agent 등록 토큰 발급
# 그 뒤 모든 모듈(포털·Webmaker·AriMail·Agent 갱신 등)은 HAL 작업으로만 설치한다. 이 스크립트는 HAL 데이터에 부트스트랩이 완료된 흔적이 있으면 실행을 거부한다.
#
# 사용(대상 서버에서, sudo 가능한 일반 계정으로):
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net | none] [--app-dir /srv/ariweb/hal]
# --portal-host none : 포털 접근 경로(nginx my.ariweb.net → 127.0.0.1:8910)를 만들지 않는다 — HAL 만 두는 호스트(예: hal21.ariweb.net)용.
# 출력: 운영자 최초 설정 토큰(1회)과 첫 Agent 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
set -euo pipefail
DIST=""; HOST="hal.ariweb.net"; CERT="/etc/letsencrypt/live/wh-wild-ariweb.net"; APP_DIR="/srv/ariweb/hal"; PORTAL_HOST="my.ariweb.net"; ACME="/var/www/letsencrypt"; USER_NAME="$(id -un)"
while [ $# -gt 0 ]; do case "$1" in --dist) DIST="$2"; shift 2;; --host) HOST="$2"; shift 2;; --cert) CERT="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --portal-host) PORTAL_HOST="$2"; shift 2;; --acme) ACME="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
[ -n "$DIST" ] && [ -f "$DIST/manifest.json" ] || { echo "--dist <manifest.json 이 있는 디렉터리> 가 필요합니다" >&2; exit 1; }
NODE_BIN="$(command -v node || true)"; [ -n "$NODE_BIN" ] || { echo "node 22.13+ 가 필요합니다" >&2; exit 1; }
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
sudo -n true 2>/dev/null || { echo "비밀번호 없는 sudo 가 필요합니다" >&2; exit 1; }
VERSION="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).version)")"
COMMIT="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).mainCommit)")"
DIRTY="$($NODE_BIN -e "const m=JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8'));console.log(m.checks&&m.checks.dirty?'1':'0')")"
[ "$DIRTY" = "0" ] || { echo "main 에 병합되지 않은(dirty) 빌드는 부트스트랩에 쓸 수 없습니다" >&2; exit 1; }
ART="$DIST/hal-$VERSION.tar.gz"; [ -f "$ART" ] || { echo "artifact 없음: $ART" >&2; exit 1; }
SUM="$(sha256sum "$ART" | cut -d' ' -f1)"; WANT="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).artifacts.hal.sha256)")"
[ "$SUM" = "$WANT" ] || { echo "artifact sha256 불일치" >&2; exit 1; }
echo "== 1/6 릴리스 $VERSION (main $COMMIT, hal artifact sha256 ${SUM:0:16}…)"
DATA="$APP_DIR/data"
if [ -f "$DATA/hal.sqlite" ] && $NODE_BIN -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('$DATA/hal.sqlite',{readOnly:true});const r=d.prepare('SELECT completed_at FROM bootstrap_state WHERE id=1').get();process.exit(r&&r.completed_at?0:1)" 2>/dev/null; then
echo "이미 부트스트랩이 완료된 HAL 데이터($DATA)가 있습니다 — 이후 설치·갱신은 HAL 작업(운영자 콘솔 › 배포 릴리스)으로만 합니다" >&2; exit 2
fi
echo "== 2/6 디렉터리·보관소·접근 경로 준비"
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$DATA" "$ACME" /srv/ariweb /var/www/ariweb-sites; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /srv/ariweb /var/www/ariweb-sites; chmod 700 "$DATA"
tar -xzf "$ART" -C "$APP_DIR/releases/$VERSION"; ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"
cd "$APP_DIR/current"
export ARIWEB_HAL_DATA="$DATA"; $NODE_BIN -e "import('./lib/vault.mjs').then(m=>{m.openVault('$DATA');console.log(' 보관소 준비(vault.key 0600)')})"
$NODE_BIN hal/index.mjs bootstrap-release --version "$VERSION" --commit "$COMMIT" --sha256 "$SUM"
$NODE_BIN hal/index.mjs register-release --manifest "$DIST/manifest.json" --approve --by bootstrap # hal artifact 에는 scripts/ 가 없으므로 HAL CLI 로 등록·artifact 복사
$NODE_BIN -e "const fs=require('fs');const f='$DATA/config.json';const v=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,'utf8')).values:{};v.publicUrl='https://$HOST';v.portalUrl=v.portalUrl||'http://127.0.0.1:8910';fs.writeFileSync(f,JSON.stringify({version:1,updatedAt:new Date().toISOString(),updatedBy:'bootstrap',values:v},null,1),{mode:0o600})"
echo "== 3/6 systemd 유닛 ariweb-hal"
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null
sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; }
echo "== 4/6 nginx $HOST$([ "$PORTAL_HOST" != none ] && echo " · $PORTAL_HOST") (인증서 $CERT)"
sudo -n test -f "$CERT/fullchain.pem" || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf
# 포털 접근 경로(my.ariweb.net → 127.0.0.1:8910)도 여기서 준비한다 — 포털 자체는 HAL 작업으로 설치되지만 nginx 접근 경로는 "접근 경로 준비"(§32.2 2단계)에 속한다(실제 환경 결함 수정: 이전에는 어디서도 만들지 않았다).
# --portal-host none 이면 포털 접근 경로를 만들지 않는다(HAL 만 두는 호스트 — 포털은 다른 호스트에서 운영).
if [ "$PORTAL_HOST" != "none" ]; then
sed -e "s#{{PORTAL_HOST}}#$PORTAL_HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8910#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-portal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-portal.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
fi
sudo -n nginx -t && sudo -n systemctl reload nginx
echo "== 5/6 HAL 자체 건강 확인"
curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})"
curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요"
echo "== 6/6 최초 운영자 설정 토큰과 첫 Agent"
$NODE_BIN hal/index.mjs setup-token
cat <<MSG
다음 순서:
1) 위 설정 토큰으로 첫 운영자 계정을 만듭니다(POST https://$HOST/ops/api/setup). 운영자 콘솔은 허용 네트워크(사설망/VPN) 또는 접근권(node hal/index.mjs grant --login <id>)이 있어야 열립니다.
2) 운영자 콘솔 › 서버에서 이 서버(역할 control/web)를 만들고 등록 토큰을 발급해 첫 Node Agent 를 설치합니다:
curl -fsSL https://$HOST/agent/v1/install.sh | bash -s -- --hal https://$HOST --token <토큰> --role control
첫 Agent 가 승인되면 부트스트랩이 닫히고 수동 설치가 잠깁니다. 이후 포털·Webmaker·AriMail 은 HAL 작업으로 배포합니다.
3) 포털 연동 키: node hal/index.mjs link-portal (출력 값을 포털 보관소에 넣습니다)
MSG
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# deploy/hal-restore.sh — 플랫폼 재해복구(§34): HAL 복구 사본(dr snapshot tar.gz)으로 HAL 데이터를 복원한다. 운영자 콘솔 › 플랫폼 복구에서 "복원 시작" 승인과 기존 제어 위치 중지 확인 뒤에만 실행한다.
# sudo bash deploy/hal-restore.sh <snapshot.tar.gz> <sha256> [--data /srv/ariweb/hal/data] [--vault-key <hex 32바이트>]
# vault.key 는 사본에 들어 있지 않으므로 운영자가 보관한 키를 --vault-key 또는 ARIWEB_VAULT_KEY 로 준다(없으면 기존 데이터 디렉터리의 키를 유지).
set -euo pipefail
SNAP="${1:?snapshot}"; SUM="${2:?sha256}"; shift 2; DATA="/srv/ariweb/hal/data"; VKEY="${ARIWEB_VAULT_KEY:-}"
while [ $# -gt 0 ]; do case "$1" in --data) DATA="$2"; shift 2;; --vault-key) VKEY="$2"; shift 2;; *) shift;; esac; done
[ "$(sha256sum "$SNAP" | cut -d' ' -f1)" = "$SUM" ] || { echo "복구 사본 sha256 불일치" >&2; exit 1; }
systemctl is-active --quiet ariweb-hal && { echo "ariweb-hal 이 실행 중입니다 — 기존 제어 위치를 먼저 중지하세요(systemctl stop ariweb-hal)" >&2; exit 2; }
STAMP="$(date +%Y%m%d-%H%M%S)"; mkdir -p "$DATA"; [ -f "$DATA/hal.sqlite" ] && cp -a "$DATA" "$DATA.pre-restore-$STAMP" && echo "기존 데이터를 $DATA.pre-restore-$STAMP 로 보존"
WORK="$(mktemp -d)"; tar -xzf "$SNAP" -C "$WORK"; [ -f "$WORK/hal.sqlite" ] || { echo "사본에 hal.sqlite 가 없습니다" >&2; exit 1; }
install -m 600 "$WORK/hal.sqlite" "$DATA/hal.sqlite"; rm -f "$DATA/hal.sqlite-wal" "$DATA/hal.sqlite-shm"; [ -f "$WORK/vault.json" ] && install -m 600 "$WORK/vault.json" "$DATA/vault.json"; [ -f "$WORK/config.json" ] && install -m 600 "$WORK/config.json" "$DATA/config.json"
[ -n "$VKEY" ] && printf '%s' "$VKEY" > "$DATA/vault.key" && chmod 600 "$DATA/vault.key"; [ -f "$DATA/vault.key" ] || echo "경고: vault.key 가 없습니다 — 보관소 값을 읽을 수 없으니 --vault-key 로 넣으세요" >&2
OWNER="$(stat -c %U "$(dirname "$DATA")")"; chown -R "$OWNER:$OWNER" "$DATA"; rm -rf "$WORK"
echo "복원 완료($SNAP). 새 HAL 기동: systemctl start ariweb-hal (다른 인스턴스의 임대가 남아 있으면 기동이 거부됩니다 → 그 HAL 이 정말 중지되었는지 확인한 뒤 node hal/index.mjs --takeover 로 1회 기동)"
echo "기동 뒤 운영자 콘솔 › 플랫폼 복구 › 복원 완료 확인(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)을 기록하세요"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/bash
# deploy/module-as-user.sh — 도입 모듈을 "그 모듈의 실행 계정" 으로 설치하기 위한 최소 파일 작업 도우미(§32).
#
# 왜 — Node Agent 는 master 계정으로 돌지만 도입 모듈(AriMail 등)은 서버에 이미 다른 계정(arihakdang 등)으로 설치되어 있고
# 그 디렉터리(/srv/<모듈>/releases, data)는 그 계정만 쓸 수 있다. Agent 가 직접 쓰면 EACCES 로 끝나므로(실제 환경 검증에서 발견),
# sudoers(deploy/sudoers/ariweb-agent)에 "이 스크립트만 root 가 아닌 임의 계정으로" 허용하고 Agent 가 `sudo -n -u <계정> bash <이 파일> <하위 명령>` 으로 부른다.
# 하위 명령은 모듈 배치에 필요한 것만 두고, 모든 경로 인자는 --app-dir 아래(또는 명시한 artifact)로 제한한다. 비밀값은 다루지 않고 출력하지 않는다.
#
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
# write --file <appDir 아래 파일> 표준 입력을 파일로 쓴다(REVISION.json·.previous.json)
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
# backup-db --db <sqlite 파일> --out <백업 파일> [--node <node>] SQLite 온라인 백업(VACUUM INTO)
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
set -euo pipefail
SUB="${1:-}"; shift || true
APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
while [ $# -gt 0 ]; do case "$1" in
--app-dir) APP_DIR="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
--dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;;
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
APP_DIR="$(cd "$APP_DIR" && pwd -P)"
# 경로가 appDir 아래인지(심링크를 따라간 실제 경로 기준, 아직 없는 경로는 부모 기준) 확인한다 — 다른 디렉터리를 건드리지 못하게.
inside() { local p="$1" d real; d="$(dirname "$p")"; while [ ! -d "$d" ] && [ "$d" != "/" ]; do d="$(dirname "$d")"; done; real="$(cd "$d" && pwd -P)"; case "$real" in "$APP_DIR"|"$APP_DIR"/*) return 0;; esac; echo "경로가 모듈 디렉터리($APP_DIR) 밖입니다: $p" >&2; exit 3; }
[ -n "$NODE_BIN" ] || NODE_BIN="$(command -v node || true)"
case "$SUB" in
extract)
[ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
write)
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"; cat > "$tmp"; mv -f "$tmp" "$FILE"; echo "written $FILE";;
switch)
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
rename)
inside "$DIR"; inside "$TARGET"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; case "$TARGET" in "$APP_DIR"/releases/*) ;; *) echo "--target 은 releases 아래여야 합니다" >&2; exit 3;; esac
mv -T "$DIR" "$TARGET"; echo "renamed $DIR -> $TARGET";;
remove)
inside "$DIR"; case "$DIR" in "$APP_DIR"/releases/*) ;; *) echo "--dir 는 releases 아래여야 합니다" >&2; exit 3;; esac; rm -rf "$DIR"; echo "removed $DIR";;
backup-db)
inside "$OUT"; [ -f "$DB" ] || { echo "skipped no_db"; exit 0; }; mkdir -p "$(dirname "$OUT")"
# node:sqlite 의 VACUUM INTO 로 WAL 포함 일관된 사본을 만든다(원본 deploy.sh 의 python backup 과 같은 목적).
"$NODE_BIN" --no-warnings -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync(process.argv[1],{readOnly:true});try{d.exec(\"VACUUM INTO '\"+process.argv[2].replace(/'/g,\"''\")+\"'\")}finally{d.close()}" "$DB" "$OUT"; echo "backup $OUT";;
migrate)
inside "$CWD"; [ -f "$CWD/$SCRIPT" ] || { echo "skipped no_migrate"; exit 0; }
# systemd 유닛의 EnvironmentFile(DB_FILE·DATA_DIR 등)을 같은 계정으로 읽어 환경에 싣는다 — 없으면 모듈 기본 경로(릴리스 디렉터리 안)를 쓰게 되어 운영 DB 가 아닌 빈 DB 를 만드는 문제를 막는다.
# systemd EnvironmentFile 문법(KEY=값, 값에 공백·따옴표 가능)은 bash 소스가 아니다 — 줄 단위로 읽어 따옴표를 벗기고 export 한다(실제 환경: MAIL_FROM_NAME=Ari Hak dang 에서 소스 실패).
if [ -n "$ENV_FILE" ]; then [ -r "$ENV_FILE" ] || { echo "EnvironmentFile 을 읽을 수 없습니다: $ENV_FILE" >&2; exit 2; }
while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}"; case "$line" in ''|'#'*) continue;; esac; key="${line%%=*}"; val="${line#*=}"; key="${key#export }"; key="${key// /}"
case "$key" in *[!A-Za-z0-9_]*|'') continue;; esac; case "$val" in \"*\") val="${val#\"}"; val="${val%\"}";; \'*\') val="${val#\'}"; val="${val%\'}";; esac; export "$key=$val"; done < "$ENV_FILE"; fi
cd "$CWD"; exec "$NODE_BIN" --no-warnings "$SCRIPT";;
*) echo "알 수 없는 하위 명령: $SUB" >&2; exit 2;;
esac
+31
View File
@@ -0,0 +1,31 @@
# hal.ariweb.net — HAL(제어 평면). 운영자 콘솔 /ops 는 HAL 이 네트워크 관문(허용 CIDR 또는 접근권 쿠키)을 적용한다.
# Agent 는 /agent/v1 로, 포털은 /portal/v1 로 서명된 요청을 보낸다. 큰 전송(/agent/v1/transfers)은 본문 제한을 넉넉히 둔다.
server {
listen 80;
listen [::]:80;
server_name {{HAL_HOST}};
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{HAL_HOST}};
server_tokens off;
client_max_body_size 0;
ssl_certificate {{CERT_DIR}}/fullchain.pem;
ssl_certificate_key {{CERT_DIR}}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://127.0.0.1:{{PORT}};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 120s;
}
}
+29
View File
@@ -0,0 +1,29 @@
# my.ariweb.net — 고객 포털
server {
listen 80;
listen [::]:80;
server_name {{PORTAL_HOST}};
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{PORTAL_HOST}};
server_tokens off;
client_max_body_size 200m;
ssl_certificate {{CERT_DIR}}/fullchain.pem;
ssl_certificate_key {{CERT_DIR}}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:{{PORT}};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
}
}
+3
View File
@@ -0,0 +1,3 @@
# deploy/nginx/awstats.conf — /etc/nginx/conf.d/awstats.conf : Ariweb 관리형 사이트 접속 통계 로그 형식(§13.1).
# Agent(site.usage)가 사이트별 로그(aw-<siteId>.access.log)를 읽어 요청 수·수신/송신 바이트·오류율·응답 시간을 집계한다. IP 는 기록하지 않는다(개인정보 최소화).
log_format awstats '$time_iso8601|$host|$request_length|$bytes_sent|$status|$request_time|$uri';
+7
View File
@@ -0,0 +1,7 @@
server {
listen 80;
listen [::]:80;
server_name {{OLD_NAMES}};
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
return 301 https://{{PRIMARY}}$request_uri;
}
+7
View File
@@ -0,0 +1,7 @@
access_log {{LOG}} awstats;
error_page 503 /__aw_maint;
location = /__aw_maint { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{MAINT_HTML}}'; }
if (-f {{SITE_DIR}}/.maintenance) { return 503; }
if (-f {{SITE_DIR}}/.limited) { rewrite ^ /__aw_limited last; }
location = /__aw_limited { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{LIMIT_HTML}}'; }
location / { proxy_pass http://127.0.0.1:{{PORT}}; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 300s; }
+11
View File
@@ -0,0 +1,11 @@
root {{ROOT}};
index index.html;
access_log {{LOG}} awstats;
error_page 503 /__aw_maint;
location = /__aw_maint { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{MAINT_HTML}}'; }
if (-f {{SITE_DIR}}/.maintenance) { return 503; }
if (-f {{SITE_DIR}}/.limited) { rewrite ^ /__aw_limited last; }
location = /__aw_limited { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{LIMIT_HTML}}'; }
location ~ /\. { return 404; }
location / { try_files $uri $uri/ /index.html =404; add_header Cache-Control "no-cache" always; }
location /uploads/ { alias {{SITE_DIR}}/data/uploads/; }
+12
View File
@@ -0,0 +1,12 @@
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{SERVER_NAMES}};
server_tokens off;
client_max_body_size 64m;
ssl_certificate {{CERT_DIR}}/fullchain.pem;
ssl_certificate_key {{CERT_DIR}}/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
{{BODY}}
}
+11
View File
@@ -0,0 +1,11 @@
# Ariweb 관리형 사이트 vhost — Agent 가 만든다(site.deploy/domain.vhost). 손으로 고치지 마세요(다음 배포 때 덮어씁니다).
# 사이트 {{SITE_ID}} · 도메인 {{DOMAIN_LIST}} · 생성 {{GENERATED_AT}}
server {
listen 80;
listen [::]:80;
server_name {{SERVER_NAMES}};
server_tokens off;
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
{{HTTP_BODY}}
}
{{HTTPS_SERVER}}
+12
View File
@@ -0,0 +1,12 @@
# /etc/sudoers.d/ariweb-agent — Node Agent(계정 master)가 비밀번호 없이 쓸 수 있는 최소 명령(§28·§32). 그 밖의 sudo 는 허용하지 않는다.
# 설치: sudo install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent && sudo visudo -c
Defaults:master !requiretty
master ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload, /usr/bin/systemctl start *, /usr/bin/systemctl stop *, /usr/bin/systemctl restart *, /usr/bin/systemctl reload *, /usr/bin/systemctl enable *, /usr/bin/systemctl disable *, /usr/bin/systemctl is-active *
master ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/certbot *, /snap/bin/certbot *
master ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/aw-*, /usr/bin/tee /etc/nginx/sites-enabled/aw-*, /usr/bin/tee /etc/nginx/conf.d/awstats.conf, /usr/bin/tee /etc/nginx/sites-available/ariweb-*, /usr/bin/tee /etc/systemd/system/ariweb-*, /usr/bin/tee /etc/systemd/system/webmaker.service, /usr/bin/tee /etc/systemd/system/webmaker-setup.service
master ALL=(root) NOPASSWD: /usr/bin/ln -sf /etc/nginx/sites-available/aw-* /etc/nginx/sites-enabled/*, /usr/bin/ln -sf /etc/nginx/sites-available/ariweb-* /etc/nginx/sites-enabled/*, /usr/bin/rm -f /etc/nginx/sites-enabled/aw-*, /usr/bin/rm -f /etc/nginx/sites-available/aw-*, /usr/bin/rm -f /etc/systemd/system/ariweb-site-*
master ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*, /usr/bin/mkdir -p /var/www/*, /usr/bin/chown master\:master /var/www/*, /usr/bin/chmod * /etc/nginx/sites-available/aw-*, /usr/bin/chmod * /etc/systemd/system/ariweb-*, /usr/bin/chmod * /etc/nginx/conf.d/awstats.conf
master ALL=(root) NOPASSWD: /usr/bin/true
# 실행 계정이 다른 도입 모듈(AriMail 의 arihakdang 등) 설치: 이 도우미 스크립트만 root 가 아닌 계정으로 허용(agent/jobs/module.mjs 실행 계정 모드)
Runas_Alias ARIWEB_SVC = ALL, !root
master ALL=(ARIWEB_SVC) NOPASSWD: /usr/bin/bash /srv/ariweb/agent/current/deploy/module-as-user.sh *
+31
View File
@@ -0,0 +1,31 @@
# AriMail(도입 모듈) — 원본 deploy/systemd/mail-gateway.service.in 과 같은 내용. 서버에 이미 있는 유닛(arihakdang-mail/arimail/heart-mail)은 그대로 두고 current 심링크만 바꾸므로 보통 쓰이지 않는다.
[Unit]
Description={{UNIT}} — AriMail gateway ({{VERSION}})
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
EnvironmentFile={{ENV_FILE}}
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=8790
ExecStart={{NODE}} server/index.mjs
Restart=always
RestartSec=3
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{APP_DIR}}/data
StandardOutput=journal
StandardError=journal
SyslogIdentifier={{UNIT}}
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Ariweb Node Agent ({{VERSION}})
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=ARIWEB_AGENT_DATA={{APP_DIR}}/data
Environment=NODE_OPTIONS=--no-warnings
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/snap/bin
{{ENV_LINES}}
ExecStart={{NODE}} agent/index.mjs run
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-agent
[Install]
WantedBy=multi-user.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=Ariweb HAL — 제어 평면 ({{VERSION}})
Documentation=https://source.ibit.kr/OZ-Group/AriWeb-Suite
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=8900
Environment=ARIWEB_HAL_DATA={{APP_DIR}}/data
Environment=NODE_OPTIONS=--no-warnings
ExecStart={{NODE}} hal/index.mjs
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths={{APP_DIR}}
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-hal
[Install]
WantedBy=multi-user.target
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Ariweb Portal — 고객 포털 ({{VERSION}})
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=8910
Environment=ARIWEB_PORTAL_DATA={{APP_DIR}}/data
Environment=NODE_OPTIONS=--no-warnings
{{ENV_LINES}}
ExecStart={{NODE}} portal/index.mjs
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-portal
[Install]
WantedBy=multi-user.target
+25
View File
@@ -0,0 +1,25 @@
# 관리형 동적(Node) 사이트 실행 유닛 — Agent 가 site.deploy(runtime=node) 때 만든다. 사이트 번들의 ariweb.json { "start": "node server.js", "port": N } 을 따른다.
[Unit]
Description=Ariweb site {{SITE_ID}}
After=network-online.target
[Service]
Type=simple
User={{USER}}
WorkingDirectory={{SITE_DIR}}/current
Environment=NODE_ENV=production
Environment=PORT={{PORT}}
Environment=ARIWEB_SITE_DATA={{SITE_DIR}}/data
ExecStart={{NODE}} {{START}}
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths={{SITE_DIR}}/data
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-site-{{SITE_ID}}
[Install]
WantedBy=multi-user.target
+25
View File
@@ -0,0 +1,25 @@
# Webmaker(도입 모듈 WebHosting_AI_agent) — 원본 deploy/webhosting-agent.service 를 바탕으로 HAL 배치 레이아웃(releases/current)에 맞춘 유닛.
# 데이터·포트·경로 환경 변수(DATA_ROOT·DATA_DIR·SITES_ROOT·STATS_LOG·PUBLIC_BASE_URL 등)는 서버에 있던 기존 유닛의 Environment 줄({{ENV_LINES}})을 그대로 보존한다.
[Unit]
Description=AI Webmaker ({{VERSION}}, Ariweb Suite managed)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3080
{{ENV_LINES}}
ExecStart={{NODE}} server.js
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
SyslogIdentifier=webmaker
[Install]
WantedBy=multi-user.target
+121
View File
@@ -0,0 +1,121 @@
# Ariweb Suite v0.1.1 — 아키텍처와 개발 규약
이 문서는 Ariweb Suite 의 모든 구성요소가 따르는 공통 규약이다. 하위 시스템을 나누어 개발할 때 이 문서가 인터페이스 계약이다.
개발 지시 원문은 `Ariweb-Suitle_Dev-Order_v0.1.1.md` 이며, 절 번호(예: §13.3)는 그 문서를 가리킨다.
## 1. 구성요소와 프로세스
| 프로세스 | 진입점 | 포트(127.0.0.1) | 역할 |
|---|---|---|---|
| `ariweb-hal` | `hal/index.mjs` | 8900 | HAL 제어 평면: 릴리스 등록부, 서버·Node Agent 등록부, 내구성 작업 엔진, 모듈 배포·갱신·되돌리기, 운영자 콘솔(`hal.ariweb.net/ops`), 공개 상태 페이지(`/status`), 긴급 복구, 플랫폼 재해복구 |
| `ariweb-agent` | `agent/index.mjs` | (없음, HAL 에 long-poll) | 각 관리 서버의 Node Agent: 일회용 등록 토큰으로 HAL 에 등록, heartbeat·지표(CPU·메모리·디스크·inode·I/O·네트워크·시계 차이), HAL 이 내린 작업(모듈 설치·갱신·되돌리기·건강 확인·사이트 배포·백업·복원·이전) 실행. 외부 고객 서버에서는 **External Pull Agent** 모드(사이트 배포만) |
| `ariweb-portal` | `portal/index.mjs` | 8910 | 고객 포털(`my.ariweb.net`): 회원·사용자 그룹·상품·주문·사이트·배포·도메인·메일·백업·복원·이전·소유권·사용량·알림·감사. Webmaker·AriMail 로 가는 통합 진입점 |
| `webmaker` | `modules/webmaker/server.js` | 3080 | 도입 모듈(WebHosting_AI_agent 1.7.3). 사이트 제작·AI·도메인·DNS·TLS·Gitea·배포. 포털 SSO 로 로그인 없이 진입 |
| `webmaker-setup` | `modules/webmaker/setup-server.js` | 8795 | Webmaker 초기 설정 페이지(기존 그대로) |
| `arimail` | `modules/arimail/server/index.mjs` | 8790 + 25/110/143/465/587/993/995 | 도입 모듈(AriMail 1.0.11). 웹메일·IMAP·POP3·SMTP·사용량 API |
| nginx | — | 80/443 | 모든 공개 진입. vhost 는 HAL/Agent 가 템플릿으로 생성 |
세 신규 프로세스(`hal`·`agent`·`portal`)는 같은 저장소의 `lib/` 를 공유하고 외부 npm 의존성이 없다(Node 22.13+, `node:sqlite`).
도입 모듈(`modules/*`)은 원본 저장소의 구조를 그대로 두고, 변경은 `IMPORTED_SOURCES.md` 에 파일 단위로 기록한다.
## 2. 저장소 레이아웃
```
lib/ 공통 라이브러리(ESM). db·http·session·status·jobs·quota·notify·audit·kst·redact·i18n
hal/ HAL 서버(api, ops 콘솔 UI, 릴리스·서버·작업·복구)
agent/ Node Agent(등록·heartbeat·작업 실행기·모듈 설치기·External Pull 모드)
portal/ 고객 포털(api, 화면, 도메인 로직) — portal/<영역>/*.mjs
modules/webmaker/ 도입: WebHosting_AI_agent @4b8c4d0 (1.8.0, CommonJS; 0.1.1 기준 3b10859)
modules/arimail/ 도입: AriMail @a07f69f (ESM)
catalog/ 출시 검증용 버전 고정 상품 Catalog(JSON) — 운영자가 HAL/포털 화면에서 관리하는 ProductVersion 의 시드
deploy/ 설치 스크립트·systemd 유닛·nginx 템플릿·부트스트랩(HAL 최초 설치)
scripts/ import-sources(도입 소스 복사·manifest), release(릴리스 artifact 생성·검사)
test/ 회귀 테스트(node:test 없이 lib/test.mjs 의 작은 러너). `npm test` 가 전부 실행
docs/ 이 문서, 운영 문서, 검증 기록
VERSION_HISTORY.md, IMPORTED_SOURCES.md, README.md
```
## 3. 공통 라이브러리(lib/) 계약
- `lib/db.mjs` — `openDb(file, migrations[])`: `node:sqlite` 를 열고(WAL, foreign_keys) `migrations` 배열(`{id, sql}`)을 순서대로 적용한다. 반환 `{ db, get(sql, ...p), all(sql, ...p), run(sql, ...p), tx(fn) }`. 모든 저장은 SQLite 이며 JSON 열은 `json_*` 접미사.
- `lib/http.mjs` — `createApp()` → `{ route(method, pattern, handler), static(prefix, dir), listen(port, host) }`. 핸들러 시그니처 `(ctx) => body|Response`. `ctx = { req, res, method, path, params, query, body(json), cookies, ip, user, set(status, headers), json(obj, status), html(str), redirect(url), error(status, code, message) }`. 오류는 `{ ok:false, error:'<code>', message }` JSON. 모든 응답 `Cache-Control: no-store`(정적 제외). 신뢰 프록시(nginx) 의 `X-Real-IP` 만 믿는다.
- `lib/session.mjs` — 서명 쿠키가 아니라 **DB 세션**(기기·IP·UA·발급·만료·마지막 사용, 개별/전체 종료 §3.2). `createSession(db, {userId, kind:'customer'|'operator', ...})`, `resolve(db, token)`, `revoke`, `revokeAll`. CSRF 는 세션에 묶인 HMAC 토큰(`X-CSRF` 헤더).
- `lib/status.mjs` — §2.2 사용자용 작업 상태 어휘와 전이 규칙. `STATUS = { NOT_READY:'준비 전', NEEDS_SETUP:'설정 필요', AWAITING_APPROVAL:'승인 대기', AWAITING_PAYMENT:'결제 대기', QUEUED:'처리 대기', RUNNING:'진행 중', AWAITING_EXTERNAL:'외부 작업 대기', VERIFYING:'검증 중', PAUSED:'일시 중지', DONE:'완료', FAILED:'실패', REVERTING:'되돌리는 중', REVERTED:'되돌림 완료', NEEDS_USER:'사용자 확인 필요' }`. 코드(영문 키)로 저장하고 화면에서 한국어로 바꾼다. 서비스 상태(`SERVICE_STATE`)·작업 상태(`STATUS`)·외부 건강 상태(`HEALTH`)는 **별도 필드**로 둔다(§2.2 "합치지 않는다").
- `lib/jobs.mjs` — 내구성 작업 엔진. 작업은 `jobs` 표에 `{id, kind, subject(json), steps(json: [{id,title,state,startedAt,endedAt,detail}]), status, attempt, maxAttempts, nextRunAt, lockedBy, lockedAt, result(json), error, idempotencyKey, createdBy, groupId}` 로 저장된다. 실행기는 `registerKind(kind, { run(job, ctx), revert?(job, ctx), steps:[...] })` 로 종류를 등록하고, `ctx.step(id, fn)` 으로 단계를 기록한다(단계마다 저장 → 프로세스 재시작 뒤 이어서 실행 §29). 같은 `idempotencyKey` 의 작업은 하나만 만든다(§36). 일시 오류는 `retryable` 표시로 지수 백오프 재시도(횟수·다음 시각 표시 §2.2). `ctx.waitExternal(reason)` 은 `AWAITING_EXTERNAL` 로 둔다.
- `lib/quota.mjs` — §13 보장량·예약. 자원 종류 `RESOURCE = sites | web_disk | web_out | mail_storage | mail_msgs | mailboxes | mail_services | source | backup`. 표 `entitlements`(그룹별 구매 총량, 상품정책 버전 참조), `allocations`(서비스별 배정량), `reservations`(진행 중 작업 예약: `{id, groupId, resource, amount, state: available|reserved|in_use|returning|returned, jobId, expiresAt}`). `reserve()` 는 단일 트랜잭션에서 `구매총량 - (배정 + 예약)` 을 검사해 **동시 요청 중 하나만 성공**(§13.3, 검증 27). `commit()`→in_use, `release()`→returned. `warnLevel(used, limit)` → 0|70|80|90|95|100.
- `lib/kst.mjs` — KST 월 경계(`monthStart(at)`, `nextMonthStart(at)`), 고정 시각 입력 가능(테스트).
- `lib/redact.mjs` — 비밀로 보이는 키·값 가리기. 감사 기록·알림·작업 결과·로그 **모두** 이 함수를 거친다(§37).
- `lib/notify.mjs` — 알림 센터(§24.1): 분류 `category`, 사건 묶음 `incidentKey`(같은 원인은 하나로 갱신), 필수 알림(`mandatory`), 외부 이메일 발송 훅(`mailer`, AriMail/Resend 는 포털 설정). 링크는 조치 화면으로.
- `lib/audit.mjs` — 감사 기록(§24.2): `{at, actorType: user|operator|system, actorId, groupId, subjectType, subjectId, action, outcome, detail(redacted), immutable}`. 권한·소유권·배포·해지·복원·이전·결제 승인은 `immutable=1`(삭제 불가).
- `lib/i18n.mjs` — 한국어 문구 사전(`lang/ko.json`). 화면 문구는 코드에 박지 않고 여기서 읽는다.
- `lib/test.mjs` — 작은 테스트 러너: `test(name, fn)`, `eq`, `ok`, `rejects`, 임시 DB 생성.
## 4. 데이터 모델(포털 SQLite, `portal/schema.mjs`)
핵심 표(열은 요지만, 상세는 스키마 파일):
- `users`(id, email, email_verified_at, display_name, pass_hash(scrypt), pass_salt, mfa_json, failed_logins, lock_until, created_at) · `email_tokens`(verify/reset, 만료·사용 완료) · `sessions`
- `groups`(id, name, kind: personal|team, billing_contact, tech_contact, owner_user_id) · `memberships`(group_id, user_id, role: owner|admin|member|billing|viewer, alias `user-01`, status) · `invites`(email, role, token_hash, expires_at, accepted_at)
- `site_permissions`(site_id, user_id, perms_json: view|edit|deploy|domain_mail|billing|admin)
- `product_versions`(id, product_id, version, name, audience, billing_cycle, price, currency, tax_rate, limits_json, options_json, warn_levels_json [70,80,90,95], approval: manual, effective_from, published) — 코드에 가격을 고정하지 않는다(§6.1, §27.2)
- `orders`(id, group_id, product_version_id, items_json, amount, tax, total, currency, status: awaiting_payment|awaiting_approval|approved|rejected|expired|cancelled, payment_ref, due_at, approved_by, idempotency_key) · `entitlements`(group_id, resource, amount, source_order_id, starts_at, ends_at)
- `sites`(id, group_id, name, purpose, webmaker_project_id, lifecycle: draft|building|deploying|live|paused|cancel_scheduled|readonly_retention|pending_delete|deleted, deploy_mode: managed|external|null, primary_domain, site_count_state: none|reserved|in_use|returning, server_id, current_version, health, settings_json)
- `deployments`(id, site_id, version_ref(저장본), mode, target(server/external agent), status(job), verify_state: verified|needs_domain|public, result_json)
- `domains`(id, group_id, name, purpose: web|mail|both, site_id, mail_service_id, ownership_state, dns_state, cert_state, mail_dns_state, last_checked_at, is_primary)
- `mail_services`(id, group_id, kind: site_bound|standalone, site_id, domain, arimail_server_id, status, allocations…) · `mailboxes`(mail_service_id, address, status, quota_bytes, …) — 실제 계정·메일 데이터는 AriMail 이 가지며 포털은 상태·배정만 둔다
- `backup_options`(site_id, product_version_id, schedule_json, retention_json, status, first_backup_due_at) · `backups`(id, site_id, created_at, scope_json, verify_state, expires_at, location) · `restores`(id, site_id, backup_id, status, cost, approval…)
- `relocations`(id, subject_type, subject_id, from_server, to_server, kind: auto_static|approved|customer_paid, status, boundaries_json)
- `ownership_transfers`(id, from_group, to_group, scope_json, status: requested|accepted|reviewing|approved|completed|rejected|expired|disputed, expires_at)
- `jobs`, `reservations`, `allocations`, `notifications`, `audit_log`, `usage_samples`(site/mail 별 측정값, measured_at)
## 5. HAL ↔ Node Agent 프로토콜(`docs/HAL-AGENT.md` 에 상세)
- 등록: 운영자가 HAL 에서 서버(역할·위치) 생성 → **일회용 등록 토큰**(10분, 1회) 발급 → 서버에서 `ariweb-agent register --hal https://hal.ariweb.net --token …` → Agent 가 키쌍 생성, 공개키 등록, HAL 이 Agent 자격(agent_id + 비밀, 응답에 1회) 발급 → 운영자 승인 후 배치 가능.
- 인증: 모든 Agent 요청은 `Authorization: Bearer <agent secret>` + 요청 서명(HMAC: method|path|ts|body-sha256). 시계 차이 ±5분.
- heartbeat: `POST /agent/v1/heartbeat` 30초마다 `{version, modules:{name:{version,active,health}}, metrics:{cpu,mem,disk,inode,io,net,clockSkewMs}, jobs:{running}}`.
- 작업: Agent 가 `GET /agent/v1/jobs/next?wait=25` 로 long-poll → HAL 이 `agent_jobs` 에서 배정 → Agent 실행 → `POST /agent/v1/jobs/<id>/progress|result`. Agent 재시작 시 미완료 작업은 재개 가능한 단계부터 다시 하거나 `NEEDS_USER` 로 보고한다.
- artifact: `GET /agent/v1/releases/<ver>/artifact` (tar.gz, sha256 검증). 모듈 설치 레이아웃 `/srv/ariweb/<module>/releases/<ver>` + `current` 심링크, 유닛 `ariweb-<module>.service`(도입 모듈은 기존 유닛 이름 `webmaker`·`arimail` 호환).
## 6. 포털 ↔ HAL
포털은 관리형 작업(사이트 배포·백업·복원·이전·사용량 수집)을 HAL API(`/portal/v1/*`, 서비스 키 + HMAC)로 요청하고 작업 ID 를 받는다. 포털 `jobs` 표의 작업이 HAL 작업을 참조(`hal_job_id`)하며 상태는 HAL 이 webhook(`/internal/hal-events`)으로 밀고, 포털도 주기 조회로 맞춘다(둘 중 빠른 쪽). HAL 이 응답하지 않으면 포털은 `정보 확인 지연` 을 표시하고 신규 증가를 허용하지 않는다(§13.4, §26.2).
## 7. 포털 ↔ Webmaker / AriMail
- **Webmaker SSO**(§0.2): 포털이 사용자별 Webmaker 계정(`aw-<groupId 앞 8>-<userId 앞 8>`)을 만들어 두고, "제작 화면 열기" 때 포털 서명 티켓(`/sso/webmaker?ticket=…`)으로 Webmaker 의 `/api/auth/sso` (신규, 포털 공유 비밀로 검증)에서 세션 쿠키를 받아 들어간다. Webmaker 의 `users.json` 비밀번호는 무작위이며 사용자는 모른다(별도 로그인 없음). 현재 사용자 그룹 이름을 Webmaker 상단에 표시한다(`X-Ariweb-Group` 배너).
- **Webmaker Project ↔ Site**: 포털 `sites.webmaker_project_id` ↔ Webmaker `data/sites/<id>.json`. Webmaker 프로젝트 삭제 API 에 포털 영향 확인 훅(`beforeDelete` → 포털 `/internal/webmaker/project-impact`)을 둔다.
- **AriMail**: 관리자 API(계정 CRUD)·사용량 API(`/api/usage`)·전체 설정 API(도메인 추가)를 포털 `portal/mail/arimail.mjs` 어댑터가 호출한다. 비밀 키는 포털 vault(`lib/vault.mjs`, AES-256-GCM, 데이터 디렉터리의 `vault.key`)에만 둔다.
## 8. 보안 규칙(§37)
1. 비밀값(비밀번호·토큰·API 키·개인키·복구 코드·메일 원문)은 소스·문서·로그·테스트·커밋·보고서에 쓰지 않는다. 시험용 값도 `example`·무작위 생성만.
2. 모든 기록 경로(감사·알림·작업 결과·오류 메시지)는 `redact()` 를 거친다.
3. 비밀값은 저장 후 다시 읽을 수 없고 재발급만 제공한다(일회용 표시 뒤 해시만 저장).
4. 그룹 경계: 모든 조회 SQL 은 `group_id` 조건을 포함한다. 검색·자동완성도 같다.
5. 운영자 콘솔은 별도 계정·별도 세션(`kind='operator'`)·제한 경로(`/ops`)·허용 네트워크(사설망/VPN CIDR 또는 기기 귀속 단기 접근권) 없이는 로그인 화면도 열리지 않는다(§25).
6. 고위험 작업은 최근 재인증(10분) + 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부)을 거친다.
## 9. 코딩 규약
- ESM(`.mjs`), 외부 의존성 없음, Node 22.13+.
- 모든 파일 머리에 모듈 목적 주석, 모든 함수·클래스에 목적·매개변수·반환·비자명한 논리 이유를 **한국어**로 적는다.
- 화면 문구는 `lang/ko.json`(포털·HAL) 에 둔다. 상태는 코드로 저장하고 표시 때 번역한다.
- 각 영역은 `portal/<영역>/{model,api,ui}.mjs` 로 나눈다. `api.mjs` 는 `register(app, deps)` 를 내보낸다.
- 테스트는 `test/<영역>.test.mjs`, `npm test` 로 전부 실행. 외부 네트워크·실서버에 닿는 테스트는 `ARIWEB_LIVE=1` 일 때만.
- 날짜·시각은 ISO 8601 UTC 로 저장하고 화면에서 KST 로 표시한다. 월간 사용량 경계는 KST.
## 10. 배포 레이아웃(관리 서버)
```
/srv/ariweb/
hal/ releases/<ver>/ current -> … data/(hal.sqlite, vault.key, artifacts/)
portal/ releases/<ver>/ current data/(portal.sqlite, vault.key)
agent/ releases/<ver>/ current data/(agent.json: id·공개키·HAL 주소; 비밀은 0600)
webmaker/ (도입 모듈) releases/<ver>/ current 데이터는 기존 /data/webmaker 유지
arimail/ (도입 모듈) releases/<ver>/ current 데이터는 기존 경로 유지
backups/ 관리형 사이트 백업 저장소(운영 서버와 다른 서버가 원칙)
systemd: ariweb-hal.service · ariweb-portal.service · ariweb-agent.service · webmaker.service · arimail(또는 기존 유닛명).service
nginx: /etc/nginx/sites-enabled/ariweb-hal.conf (hal.ariweb.net) · ariweb-portal.conf (my.ariweb.net) · wh-<siteId>.conf (사이트)
```
HAL 최초 설치(부트스트랩, §32.2)만 `deploy/bootstrap-hal.sh` 로 수행한다. 그 뒤 모든 모듈은 HAL 작업으로만 설치·갱신한다.
+69
View File
@@ -0,0 +1,69 @@
# HAL ↔ Node Agent 프로토콜·설치·운영 (v0.1.1)
Dev-Order §5(아키텍처)·§28·§29·§32·§33·§34 의 백엔드 구현. 코드: `hal/`, `agent/`, `deploy/`, `scripts/release.mjs`, `portal/hal/client.mjs`.
## 1. 구성
| 구성요소 | 위치 | 역할 |
|---|---|---|
| HAL | `/srv/ariweb/hal/current`, 127.0.0.1:8900, nginx `hal.ariweb.net` | 서버·Agent 등록부, 릴리스 등록부, 내구성 작업 엔진(lib/jobs), 모듈 배포, 포털 API, 운영자 API, 긴급 복구, 재해복구, 공개 상태 |
| Node Agent | `/srv/ariweb/agent/current`, 유닛 `ariweb-agent` | heartbeat(30초, 지표·모듈 버전·시계 차이), 작업 long-poll·실행(단계 저장·재개), 모듈 설치, 사이트 배포·백업·이전 |
| External Pull Agent | 고객 외부 서버, 같은 코드 `--external --site <id>` | `site.deploy / site.remove / site.check / site.rollback` 만, 사이트 하나에 귀속 |
## 2. 인증
- Agent·포털·HAL 간 모든 요청은 `lib/serviceauth` HMAC: `X-Service-Key-Id`(`agent:<id>` / `portal` / `hal`), `X-Service-Ts`(±5분), `X-Service-Sign`.
- Agent 비밀은 등록 응답에서 한 번만 전달되어 Agent 보관소(`data/vault.json`, 키 `agent-secret`)에, HAL 쪽은 `service:agent:<id>` 로 보관. 표에는 해시만.
- 큰 전송(`POST /agent/v1/transfers/:id`)은 빈 본문 서명 + `X-Transfer-Sha256` 헤더, HAL 이 받은 뒤 해시를 다시 계산해 비교.
## 3. Agent 프로토콜(`/agent/v1`)
| 경로 | 설명 |
|---|---|
| `GET install.sh` | 설치 스크립트(deploy/agent-install.sh) |
| `GET bootstrap-artifact?module=agent&version=` + `X-Registration-Token` | 등록 전 Agent 코드 내려받기(유효 토큰 확인, 소비하지 않음) |
| `POST register {token, facts, version}` | 일회용 토큰(10분) → `{agentId, secret(1회), serverId, kind, siteId}` |
| `POST heartbeat {version, metrics, modules, clockSkewMs, facts, sitesHosted}` | 지표 저장(90일+), 서버 last_seen·용량·모듈 실제 버전 갱신. 90초 무응답 → offline |
| `GET jobs/next?wait=25` | long-poll. `{job:{id, kind, payload, halJobId}}` 또는 null |
| `GET jobs/unfinished` | 재시작 뒤 이어서 할 running 작업 목록 |
| `POST jobs/:id/progress {progress, steps, log}` → `{cancelRequested}` / `POST jobs/:id/result {status: done\|failed\|needs_user, result, error, errorCode, userMessage}` | |
| `GET releases/:version/artifact?module=` | tar.gz 스트리밍, `X-Sha256` |
| `POST/GET transfers/:id` | HAL 경유 파일 전송(이전·원격 백업), 24시간 보관 |
## 4. 포털 API(`/portal/v1`, 키 `portal`) — docs/HAL-JOBS.md
부트스트랩이 끝나기 전에는 `health` 외 503 `bootstrap_incomplete`. 작업 상태 변화는 outbox 를 통해 포털 `POST /internal/hal-events` 로 밀린다(실패 시 지수 백오프, 7일 보관).
## 5. 작업 모델
- HAL 작업(lib/jobs) 한 개가 Agent 작업(agent_jobs) 하나 이상을 배정하고 결과를 기다린다(`core.awaitAgentJob`: 진행률 반영, 취소 전달, Agent 10분 무응답 → `사용자 확인 필요`).
- 복합 작업: `backup.create`(생성 → 저장 서버로 복사 → 검증), `backup.restore`(가져오기 → 보호 시점 → 격리 확인 → 복원 → 외부 확인, 되돌리기 지원), `site.relocate`(precheck → prepare → precopy → verify_copy → final_sync → verify_new → switch → external_check → cleanup; `verify_new` 실패 시 전환하지 않음, 되돌리기 지원).
- 운영자 작업: `module.deploy`/`module.rollback`/`module.health`/`module.reconcile` 은 서버마다 자식 작업(`module.deploy.target`)을 만들고 `childrenSummary` 로 대상별 성공·실패·대기를 보인다. 일부 실패는 `partial_failure` 로 끝난다.
- 배포 순서(§32.2): 릴리스 승인 → HAL 자체 건강 → 부트스트랩 완료 → `hal` 모듈이 그 릴리스로 실행 중일 때만 다른 모듈 배포(`hal_first`).
## 6. 설치(부트스트랩)와 첫 Agent
```
# 작업 PC: main 에 병합된 커밋에서
node scripts/release.mjs build # dist/0.1.1/*.tar.gz + manifest.json (테스트 통과 필요)
scp -r dist/0.1.1 hostinger:/tmp/ariweb-dist
# 대상 서버(ariweb.net 호스트, master, sudo):
bash /tmp/ariweb-dist/… (artifact 안의 deploy/bootstrap-hal.sh) --dist /tmp/ariweb-dist --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net
```
부트스트랩은 HAL 설치·보관소·유닛·nginx·릴리스 등록(승인)·운영자 설정 토큰까지만 한다. 이어서 운영자 계정 생성 → 서버 생성·등록 토큰 → `agent-install.sh` → 서버 승인. 첫 Agent 승인 시 `bootstrap_state.completed_at` 이 기록되고 수동 설치가 잠긴다. 그 뒤 `portal`·`webmaker`·`arimail` 은 운영자 API `POST /ops/api/modules/<name>/deploy {version, serverIds}` 로 배포한다.
도입 모듈 배치: Agent facts 가 서버의 기존 유닛(`webmaker`, `arihakdang-mail`/`arimail`/`heart-mail`)과 WorkingDirectory 를 읽어 그 appDir(`/srv/webmaker`, `/srv/arihakdang-mail` …)의 `releases/<ver>` 에 풀고 `current` 를 바꾼다. Webmaker 는 원본 `remote-update.sh` 순서(데이터 백업 → 교체 → 재시작 → unit-check), AriMail 은 원본 `deploy.sh` 순서(DB 백업 → 교체 → db-migrate → 재시작 → health → haraka 재시작)를 Agent 가 수행한다.
## 7. 운영자 접근(§25)
`/ops/*` 는 허용 CIDR(`config.opsAllowCidrs`) 안이거나 접근권 쿠키(`aw_ops_grant`, 사용자·기기 귀속, 최대 72시간)가 있어야 하며 아니면 로그인 화면도 403. 접근권: `node hal/index.mjs grant --login <id> --hours 8` 또는 허용 네트워크 안의 운영자가 `POST /ops/api/access-grants`. 고위험 작업은 재인증(10분)과 3단계 확인(`confirmImpact`, `confirmIdentifiers`, `confirmExecute`).
## 8. 긴급 복구(§33)·재해복구(§34)
- `POST /ops/api/emergency` 선언(HAL 정상이고 열린 critical 사건이 없으면 거부) → `approve` 1·2·3단계(서로 다른 입력) → `credential`(최대 60분, 1회 표시) → `perform`(허용 범위·허용 행동만; 일반 배포 거부) → `verify`(자격 폐기) → `reconcile`(목표·실제 비교) → `close`.
- DR: `PUT /ops/api/dr/config`(RTO·RPO·주기·보존·외부 보관), 사본 `POST /ops/api/dr/snapshots`(VACUUM INTO + vault.json + config.json, vault.key 제외), 복원 요청→승인→시작(기존 제어 중지 확인 문구)→`deploy/hal-restore.sh`→완료 확인(운영자 로그인·HAL 제어·HTTPS·고객 서비스), 훈련 기록과 측정 RTO/RPO. `readiness.ready` 가 true 여야 정식 운영 준비 완료.
- 하나의 HAL: 기동 때 `bootstrap_state.live_instance_id/live_lease_at` 임대(20초 갱신, 60초 유효). 다른 인스턴스의 임대가 살아 있으면 기동 거부(`--takeover` 는 복원 절차에서만).
## 9. 테스트
`node --no-warnings test/hal.test.mjs`, `node --no-warnings test/agent.test.mjs`. Agent 테스트는 `ARIWEB_AGENT_STUB_CMDS=1` 과 임시 루트로 실제 시스템을 건드리지 않는다.
+41
View File
@@ -0,0 +1,41 @@
# 포털 ↔ HAL 작업 계약(HAL-JOBS)
포털 어댑터 `portal/hal/client.mjs`(deps.hal) 와 HAL `/portal/v1/*` 가 공유하는 계약. 인증은 lib/serviceauth(키 id `portal`).
## deps.hal (포털 쪽 인터페이스)
```js
hal.createJob({ kind, target, payload, idempotencyKey, groupId, subjectType, subjectId }) → { halJobId, status }
hal.getJob(halJobId) → { id, kind, status(lib/status JOB_STATUS 코드), steps:[{id,title,state,detail}], progress, result, error, errorCode, updatedAt, children? }
hal.cancelJob(halJobId) / hal.retryJob(halJobId) / hal.revertJob(halJobId) / hal.resumeJob(halJobId)
hal.listServers({ role }) → [{ id, name, role:'web'|'mail'|'backup'|'control', location, status:'ready'|'maintenance'|'isolated'|'offline', acceptsNew, health, lastSeenAt, capacity:{ diskFreeBytes, diskTotalBytes, memFreeBytes, cpuLoad, sitesHosted, reservedBytes }, version:{ agent, modules:{} } }]
hal.pickServer({ role:'web', needBytes }) → { serverId } | null // 신규 배치 허용·여유 있는 서버
hal.issueExternalAgentToken({ groupId, siteId, label }) → { agentId, token(1회), expiresAt, installCommand }
hal.getExternalAgent(agentId) → { id, status:'pending'|'connected'|'offline'|'revoked', lastSeenAt, version, lastDeploy:{...} }
hal.revokeExternalAgent(agentId)
hal.health() → { ok, version, uptime } // 실패 시 code 'service_unreachable'
```
HAL 이 응답하지 않으면 어댑터는 `service_unreachable` 을 던지고 호출자는 `정보 확인 지연` 을 표시하며 신규 증가를 허용하지 않는다.
HAL 은 작업 상태 변화를 포털 `POST /internal/hal-events` (키 id `hal`) 로 민다: `{ halJobId, status, steps, result, error, updatedAt }`. 포털은 5초 주기 조회로도 맞춘다.
## 작업 종류와 payload (target = 관리 서버 id 또는 외부 Agent id)
| kind | payload | result |
|---|---|---|
| `site.deploy` | `{ siteId, versionRef, bundleUrl(포털이 Webmaker 저장본을 tar.gz 로 내놓는 서명 URL) 또는 bundleFromWebmaker:{projectId, versionRef}, runtime:'static'|'node', env:{}, domains:[...], primaryDomain, previousVersion, external:bool }` | `{ version, deployedAt, internalCheck:{ok,status,ms}, externalCheck:{ok,url,status}, siteUrl, leftovers:[] }` |
| `site.rollback` | `{ siteId, toVersion }` | `{ version }` |
| `site.remove` | `{ siteId, keepData:bool }` | `{ removed:true, leftovers:[] }` |
| `site.check` | `{ siteId, urls:[...], expectMarker }` | `{ checks:[{url,status,ok,ms,tls:{valid,expiresAt}}] }` |
| `site.usage` | `{ siteIds:[...] }` | `{ sites:{ [siteId]: { diskBytes, dbBytes, uploadsBytes, outBytesDay, outBytesMonth, outBytesTotal, inBytesDay, inBytesMonth, inBytesTotal, req24h, pv24h, series7d:[...], series30d:[...], errorRate, p95ms, measuredAt } } }` |
| `domain.vhost` | `{ siteId, domains:[...], primaryDomain, redirectOld:bool, maintenance:bool }` | `{ applied:[...], certs:{domain:{state,expiresAt}} }` |
| `domain.cert` | `{ siteId, domains:[...], email }` | `{ issued:[...], failed:[{domain,reason}], expiresAt }` |
| `domain.probe` | `{ name, expectA:[...], expectTxt:{name,value}? }` | `{ a:[...], txtOk, resolvedAt }` |
| `backup.create` | `{ siteId, scope:{files,db,uploads,config}, storeServerId, retentionTag }` | `{ backupId, location, sizeBytes, checksum, verified:true, createdAt }` |
| `backup.verify` | `{ location, checksum }` | `{ ok }` |
| `backup.restore` | `{ siteId, location, checksum, protectPoint:true, isolatedVerify:true }` | `{ protectPointLocation, restoredVersion, externalCheck }` |
| `backup.delete` | `{ location }` | `{ deleted }` |
| `site.relocate` | `{ siteId, fromServerId, toServerId, kind:'static'|'dynamic', writeFreeze:bool, domains, primaryDomain }` | 단계: precheck → prepare → precopy → verify_copy → final_sync → verify_new → switch → external_check → cleanup → done. result `{ newServerId, switchedAt, oldRetainedUntil }` |
| `module.deploy` / `module.update` / `module.rollback` / `module.health` | HAL 내부(운영자 콘솔) | 대상별 자식 작업 |
External Pull Agent(외부 서버)는 `site.deploy`·`site.remove`·`site.check` 만 받는다.
+146
View File
@@ -0,0 +1,146 @@
# 0.1.1 배포·검증 절차(RUNBOOK)
대상: ariweb.net 호스트(`ssh hostinger`, 187.53.133.173, master 계정, sudo, Node 22.23, nginx, 와일드카드 인증서 `*.ariweb.net`).
원칙(§32): HAL 먼저, 그 뒤 모든 모듈은 HAL 작업으로만. 개별 서버에 임의 접속해 일반 배포하지 않는다.
## 0. 사전 조건(운영자 조치)
- DNS(hosting.co.kr 패널): `hal.ariweb.net A 187.53.133.173`, `my.ariweb.net A 187.53.133.173`. 레코드 전에는 `curl --resolve` 로만 확인된다.
- 검증용 메일 수신 주소(자동 응답 메일함) — 메일 활성화의 실제 왕복 확인에 필요(`PUT /internal/ops/mail/policy {verifier}`).
## 1. 릴리스 artifact(개발 PC, main 병합 뒤)
```bash
git checkout main && git pull
node scripts/release.mjs build # 전체 테스트 → dist/0.1.1/{hal,portal,agent,webmaker,arimail}-0.1.1.tar.gz + manifest.json
scp -r dist/0.1.1 hostinger:/tmp/ariweb-dist-0.1.1
```
## 2. HAL 부트스트랩(최초 1회, 서버에서)
```bash
ssh hostinger
tar -xzf /tmp/ariweb-dist-0.1.1/hal-0.1.1.tar.gz -C /tmp/ariweb-hal-src # deploy/ 스크립트를 쓰기 위해 풀어 둔다
bash /tmp/ariweb-hal-src/deploy/bootstrap-hal.sh --dist /tmp/ariweb-dist-0.1.1 --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net
```
출력된 **운영자 설정 토큰(1회)** 으로 첫 운영자를 만든다: `POST https://hal.ariweb.net/ops/api/setup {setupToken, login, password}` (허용 네트워크 밖이면 `node hal/index.mjs grant --login <id>` 로 접근권 URL 발급).
확인: `curl -s https://hal.ariweb.net/api/health` 의 `version`·`db`·`jobs`·`bootstrap.halRunningVersion`.
DNS 레코드가 아직 없으면 같은 서버의 Agent·포털이 `https://hal.ariweb.net` 에 닿도록 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net` 을 임시로 둔다(cloud-init 이 `/etc/hosts` 를 관리하는 호스트는 재부팅 때 지워질 수 있다 — DNS 레코드가 생기면 필요 없다).
부트스트랩은 `my.ariweb.net` nginx 접근 경로(→ 127.0.0.1:8910)도 함께 만든다. HAL 만 두는 호스트(예: bear `hal21.ariweb.net`, 포털은 다른 호스트)는 `--portal-host none` 으로 포털 접근 경로를 만들지 않고, 인증서가 호스트별 certbot(webroot `/var/www/html`)이면 `--cert /etc/letsencrypt/live/<호스트> --acme /var/www/html` 을 준다(§7 참조).
## 3. 첫 Node Agent(같은 서버, 역할 control)
운영자 콘솔 › 서버 › 새 서버(역할 control, 위치 ariweb.net) → 등록 토큰(10분·1회) →
```bash
curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <토큰> --role control
```
운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 모듈 기술서는 서버 역할을 배포 조건으로 두지 않으므로 한 대(control)에 portal·webmaker·arimail 을 모두 둘 수 있다.
포털 연동 키: `node hal/index.mjs link-portal` 의 출력 값을 포털 보관소에 1회 입력한다. 포털 데이터 디렉터리(`/srv/ariweb/portal/data`)는 배포 전에 만들어 보관소를 먼저 채워도 된다(같은 `lib/vault.mjs`). 값을 화면·로그에 남기지 않으려면 서버 안에서 파이프로 바로 넣는다.
## 4. 모듈 배포(HAL 작업)
운영자 콘솔 › 배포 릴리스 › 0.1.1 승인 확인 → 모듈 배포 순서: `portal` → `webmaker` → `arimail` → (`agent` 갱신). 각 작업의 대상별 결과·실제 버전·건강 상태를 확인한다. HAL 자체 건강이 실패하면 나머지를 배포하지 않는다.
- portal: `/srv/ariweb/portal`, 유닛 `ariweb-portal`, nginx `my.ariweb.net`(와일드카드 인증서). 포털 보관소에 `service:hal`(link-portal 출력), `sso:webmaker`, `arimail:*` 키 입력.
- webmaker: 기존 `/srv/webmaker` + `/data/webmaker` 유지(HAL 모듈 설명자가 기존 유닛명·데이터 경로를 발견해 보존), 설정 `ariweb.portalUrl`·`ariweb.secret`.
- arimail: 기존 `/srv/arihakdang-mail` 유지(실행 계정 `arihakdang` — Agent 가 `deploy/module-as-user.sh` 로 그 계정이 파일 작업을 하게 한다), 마이그레이션 `0009_ariweb_queue.sql` 적용(유닛의 `EnvironmentFile` 의 `DB_FILE`). 포털 › 운영자 › 메일 서버 등록(`POST /internal/ops/mail/servers`)에 setup-token(`<appDir>/data/setup-token`)·usage-key(기본 도메인 설정의 `USAGE_API_KEY`)를 넣고, 고객 도메인의 `ARIWEB_SSO_SECRET` 은 포털이 서비스 생성 때 스스로 넣는다.
- 도입 모듈의 실제 버전은 `REVISION.json` 의 Suite 버전(0.1.1)이고 상류 버전(1.7.3·1.0.11)은 `upstreamVersion` 으로 함께 보인다.
## 4-1. 실제 배포(2026-10-26)에서 확인한 운영 사실
- 서버가 한 대(역할 control)면 사이트 배포·백업 저장은 control 서버가 대체한다(`pickServer`). 백업을 같은 서버에 두려면 운영자 override(`/internal/ops/backup/options/:id/override-same-server`)가 필요하다.
- 포털 메일 서버 등록(`POST /ops/api/portal/mail/servers`): `baseUrl https://webmail.ariweb.net`, `internalUrl http://127.0.0.1:8790`, `setupUrl https://mail.arihakdang.com:8791`(AriMail 전체 설정 API 는 기본 도메인 호스트에서만 응답하므로 기본 도메인 이름을 쓰고, 설정 포트가 loopback 전용이라 서버 `/etc/hosts` 에 `127.0.0.1 mail.arihakdang.com` 을 둔다), 비밀은 `setup-token`(`<appDir>/data/setup-token`)·`usage-key`(없으면 `POST /api/setup/usage-key {action:'generate'}` 로 발급, 기본 도메인 Host 로).
- 발신 메일: 포털 보관소 `mailer:resend-key`(Webmaker 보관소의 Resend 키 재사용), 설정 `mailer: resend`, `mailFrom no-reply@ariweb.net`. 검증 때는 `resendBase` 를 로컬 포획 서버로 돌려 링크를 꺼냈고, 끝난 뒤 되돌렸다.
- 수정 artifact 를 같은 릴리스(0.1.1)로 다시 내려면 `node hal/index.mjs register-release --manifest <manifest> --approve` 로 재등록(기존 버전은 artifact·커밋이 갱신된다)한 뒤 운영자 콘솔에서 `agent` → `hal` → 바뀐 모듈 순으로 배포한다. `bootstrap-release --commit` 으로 건강 응답의 main 커밋도 맞춘다.
- 검증 데이터(`*@check.ariweb.net` 계정, `check-*/solo-*.ariweb.net` 도메인·메일 서비스)는 운영자 취소로 정리했고 보존 기간 뒤 삭제된다.
## 5. 검증(Release 0.1.1 완료 조건 34개)
`docs/verification/` 의 로컬 E2E 결과와 실제 환경 확인 기록을 함께 둔다. 각 흐름은 버튼 성공이 아니라 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정한다. 충족하지 못한 흐름은 완료로 표시하지 않는다.
## 6. 되돌리기
- 모듈: 운영자 콘솔 › 모듈 › 되돌리기(이전 릴리스 디렉터리 유지, 3개 보관).
- HAL: `deploy/hal-restore.sh` + 재해복구 절차(§34, 기존 제어 위치 중지 확인 뒤 `--takeover`).
## 7. HAL 전용 호스트 부트스트랩 — bear `hal21.ariweb.net` (2026-10-27)
대상: bear(`ssh bear`, 192.168.20.23 / 공인 121.152.149.45, master 계정, sudo NOPASSWD, Ubuntu 24.04, nginx 1.24, Node v24.22(엔진 요구 ≥22.13 충족), certbot 2.9 nginx 플러그인·`certbot.timer`). 기존 운영(Webmaker `webmaker.ibit.kr`·`hs-company.ariweb.net`, Gitea `source.ibit.kr`, Matrix, board/file/office/xmap, 기본 서버 `wh-default.conf`)은 건드리지 않는다. 이 호스트에는 **HAL 만** 둔다(포털·Webmaker·AriMail·Agent 는 다음 단계).
```bash
# 개발 PC: main 이 깨끗한 상태(미추적 파일 없음)에서 빌드 — 전체 테스트 뒤 dist/0.1.1 (manifest.mainCommit = HEAD, dirty=false)
node scripts/release.mjs build && node scripts/release.mjs verify --dist dist/0.1.1
scp -r dist/0.1.1 bear:ariweb-deploy-0.1.1/dist
# bear: 호스트별 인증서(기존 인증서는 ibit.kr 7개뿐, ariweb.net 와일드카드 없음). 기본 서버가 /.well-known/acme-challenge/ 를 /var/www/html 로 서비스하므로 webroot 로 발급한다(nginx 설정을 건드리지 않음)
sudo certbot certonly --webroot -w /var/www/html -d hal21.ariweb.net --non-interactive --keep-until-expiring
sudo certbot reconfigure --cert-name hal21.ariweb.net --deploy-hook "systemctl reload nginx" # 갱신 뒤 reload(installer 가 없으므로)
# bear: 부트스트랩(출력에 1회 설정 토큰이 있으므로 0600 파일로만 받는다)
cd ~/ariweb-deploy-0.1.1 && mkdir -p -m 700 src && tar -xzf dist/hal-0.1.1.tar.gz -C src
umask 077; bash src/deploy/bootstrap-hal.sh --dist ~/ariweb-deploy-0.1.1/dist --host hal21.ariweb.net \
--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html --portal-host none > bootstrap.log 2>&1
```
- `--acme /var/www/html`: HAL vhost 의 acme 경로를 webroot 발급 경로와 같게 둔다(아니면 vhost 가 생긴 뒤 갱신 실패).
- 만든 것: `/srv/ariweb/hal/{releases/0.1.1,current,data(0700)}`, `/var/www/ariweb-sites`, 유닛 `ariweb-hal`(enabled, 127.0.0.1:8900), nginx `sites-available/ariweb-hal.conf`(+enabled 링크), `conf.d/awstats.conf`(log_format awstats — 기존 wmstats 와 이름이 다름), `/etc/sudoers.d/ariweb-agent`(master 는 이미 NOPASSWD ALL 이라 추가 권한 없음). 포털 vhost 는 만들지 않았다.
- 비밀값: 설정 토큰은 `/srv/ariweb/hal/data/setup.token`(0600)과 `~/ariweb-deploy-0.1.1/bootstrap.log`(0600)에만 있다. 첫 운영자 생성(`POST /ops/api/setup`)은 이 단계에서 하지 않았다 — `/ops` 는 허용 네트워크(기본 사설망 CIDR) 또는 접근권이 필요하다.
- 알려진 사소한 결함: 5단계의 `https://<host>` 자체 확인은 `nginx reload` 직후 바로 실행되어 새 vhost 가 아직 적용되기 전에 "unrecognized name" 경고가 날 수 있다(거짓 경고, 몇 초 뒤 정상). 다음 릴리스에서 reload 뒤 짧은 대기를 넣는다.
검증(2026-10-27 03:08~03:12 UTC, 모두 통과): `systemctl is-active/is-enabled ariweb-hal` active/enabled · 127.0.0.1:8900 리슨(MainPID) · `nginx -t` 성공 후 reload, 기존 nginx 설정 파일 12개 sha256 변화 없음 · `https://hal21.ariweb.net/api/health` 외부(LAN 밖 fetch·LAN NAT 양쪽)에서 200, 인증서 CN=hal21.ariweb.net(Let's Encrypt, 2027-01-25 만료), HTTP→HTTPS 301 · 건강 응답 version 0.1.1·mainCommit ab75b7d·artifactSha256 f574d875…·bootstrap.complete=false(첫 Agent 전) · `systemctl restart ariweb-hal` 뒤 재기동·건강 ok(NRestarts 0, enabled 로 재부팅 지속; 서버 재부팅은 기존 서비스 중단이라 하지 않음) · 기존 서비스 회귀: nginx/webmaker/webmaker-setup/webmaker-gitea-reverse-tunnel/docker/certbot.timer active, 기존 vhost 8개 응답 코드 배포 전과 동일 · `certbot reconfigure` 의 모의 갱신 성공, `renew_hook = systemctl reload nginx`.
되돌리기(HAL 만 제거, 기존 서비스 영향 없음):
```bash
sudo systemctl disable --now ariweb-hal && sudo rm /etc/systemd/system/ariweb-hal.service && sudo systemctl daemon-reload
sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/conf.d/awstats.conf && sudo nginx -t && sudo systemctl reload nginx
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
```
## 8. 운영 Webmaker(webmaker.ariweb.net) ↔ HAL 연동 점검·dry-run (2026-10-27)
사실: `webmaker.ariweb.net` = hostinger, 이미 HAL 작업으로 설치된 Ariweb Suite Webmaker 모듈 0.1.1(상류 1.7.3)이며 hostinger HAL 의 `module_targets.webmaker` 에 등록돼 있다(교체 불필요). HAL 은 사이트·계정을 모델링하지 않으므로 "HAL 리소스" 는 모듈 등록이고, 프로젝트 연결은 포털 ↔ Webmaker 양쪽 등록부로만 이뤄진다. 기록: `docs/verification/webmaker-hal-link-2026-10-27.md`.
```bash
# 1) 사전 스냅샷(hostinger, master, 사설 0700) — /srv/webmaker/backups/pre-hal-link-<UTC>/ : data·sites tar, 원본 32,608 파일 sha256, conf, Gitea refs, HAL·포털 DB VACUUM INTO 사본, MANIFEST.sha256, ROLLBACK.md. 만든 뒤 임시 디렉터리에 풀어 체크섬 전수 대조(RESTORE_TEST_OK)
# 2) 읽기 전용 inventory + dry-run (비밀값 없음, 충돌·누락이 있으면 종료 코드 2)
node --no-warnings scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker --sites-root /var/www/webmaker-sites --app-dir /srv/webmaker/current \
--portal-db /srv/ariweb/portal/data/portal.sqlite --hal-db /srv/ariweb/hal/data/hal.sqlite --nginx-enabled /etc/nginx/sites-enabled \
--gitea-refs <ls-remote --heads 출력> --out <백업 디렉터리>/dry-run.json
# 3) 사후 readback: (cd / && sha256sum --quiet -c <백업>/checksums-source.txt) · git fsck · ls-remote diff · systemctl show webmaker -p NRestarts · nginx -t · 외부 URL
```
판정 뜻: `linked_consistent` 양쪽 등록부 일치 · `unlinked_native` Webmaker 고유 프로젝트(변경 대상 아님) · `link_*`/`duplicate`/`missing_project`/`gitea_ref`/`hal_*` 는 충돌·누락이라 적용 금지. 2026-10-27 결과: 프로젝트 10(일치 3·고유 7), 회원 12(SSO 3 일치·고유 9), 충돌 0·누락 0 → **적용 변경 없음**. 되돌리기는 스냅샷 디렉터리의 `ROLLBACK.md`.
남은 사람 결정: hal21(bear) 운영자 자격 증명으로 서버 등록·Agent 재등록 여부(제어 평면 이전은 §34 "하나의 HAL" 절차), 고유 회원 9명의 포털 계정 전환(사용자 자격 증명·동의 필요).
## 9. 기존 메일 서비스(AriMail) ↔ HAL 관리형 MailService/Node 가져오기 (2026-10-27)
사실(읽기 전용 조사): `https://mail.arihakdang.com/` = hostinger(187.53.133.173, 유닛 `arihakdang-mail`, `/srv/arihakdang-mail`, 실행 계정 arihakdang, Ariweb Suite arimail 모듈 0.1.1 = AriMail 1.0.11, 도메인 6개 멀티테넌트) · `https://webmail.ibit.kr/` = 115.68.194.192(`ssh jitsi`, 유닛 `arimail`, `/srv/arimail`, 실행 계정 arimail, 원본 AriMail 1.0.11 `a07f69f`, 도메인 1개). **서로 다른 호스트·다른 인스턴스**다. 두 호스트 모두 Haraka 비활성(25 포트 없음), 수신 MX 는 Resend(inbound-smtp.ap-northeast-1.amazonaws.com), 제출 587/465·IMAP 143/993·POP 110/995 는 AriMail 프로세스가 직접 연다. hostinger 에는 자체 HAL(`hal.ariweb.net`)과 거기에 등록된 Node Agent 가 이미 있다(bear `hal21` 과 별개 제어 평면).
원칙: 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 그대로. HAL 에는 **등록·감시·통제용 등록부**(`mail_services` 도메인별 논리 리소스 + `servers` 노드)만 만든다. AriMail 재시작·DNS 변경·계정/DB 변형·리스너 교체 금지.
```bash
# 1) 사전 사설 스냅샷(각 원본 호스트, root 0700/0600, git 밖) — <app>/backups/pre-hal-onboard-<UTC>/ : archive.tgz(데이터·설정·유닛·env·nginx·훅, DB 제외) · db.tgz(VACUUM INTO 사본 + integrity_check) · code.tgz(current 릴리스 트리) · meta.tgz · SHA256SUMS · RESTORE.md
# bear HAL 도 같은 모양: /srv/ariweb/backups/hal-pre-mail-onboard-<UTC>/ (hal.sqlite VACUUM 사본·vault·config·유닛·nginx·current 트리)
# 2) 읽기 전용 discovery(원본 호스트, 파일 하나만 복사해 sudo 로 실행 — 데이터 디렉터리가 0700 이라서). 출력에 비밀값·메일 주소 없음
sudo node scripts/mail-discover.mjs --unit arihakdang-mail.service --app-dir /srv/arihakdang-mail --etc /etc/arihakdang-mail \
--vhost /etc/nginx/sites-available/mail.arihakdang.conf --hook /etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh --out discovery.json
# 3) bear(HAL 데이터 소유자 master): 서버(노드) 만들기 + dry-run → 거부 0 이면 --apply. 같은 문서를 다시 적용하면 update(멱등)
cd /srv/ariweb/hal/current && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
node hal/index.mjs mail-import --discovery discovery.json --create-server --name "srv1953374 (mail.arihakdang.com)" --location hostinger --by hera # dry-run, 서버 id 출력
node hal/index.mjs mail-import --discovery discovery.json --server <srv_id> --apply --by hera
node hal/index.mjs mail-services # readback: counts.active = 도메인 수, duplicates [], secretLeaks 0
# 4) (선택) Node Agent — Agent 가 없는 호스트만. 유닛 ariweb-agent(별도 프로세스, HAL 로의 outbound HMAC 만, 메일 포트·유닛 무관)
node hal/index.mjs server-token --server <srv_id> --by hera # 1회 표시 토큰 → 원본 호스트에서 install.sh (--role mail)
# 운영자 콘솔에서 서버 '승인' 은 사람 결정(첫 Agent 승인은 부트스트랩을 닫고 수동 설치를 잠근다 §32.2)
```
되돌리기: `node hal/index.mjs mail-import-rollback --import <mimp_id>`(그 적용이 만든 행 삭제·갱신 행 복원), 서버 행은 운영자 콘솔 서버 삭제(3단계 확인). Agent 는 `systemctl disable --now ariweb-agent` + `/srv/ariweb/agent` 제거(메일 서비스 영향 없음). 원본 메일 데이터 복원은 각 백업의 RESTORE.md(운영자 승인 뒤에만 — 메일 중단 동반).
hostinger 주의: `arihakdang-mail` 은 hostinger HAL(`hal.ariweb.net`)의 `module_targets.arimail` 로도 관리된다. hal21 에 같은 호스트의 두 번째 Agent 를 설치하면 두 제어 평면이 같은 유닛에 배포할 수 있으므로(§34 "하나의 HAL") **설치하지 않고** 서버 행을 pending(Agent 미연결)으로 두었다 — 제어 평면 이전은 사람 결정.
## 10. 포털·Node Agent 제어 평면 이전 — hostinger HAL(hal.ariweb.net) → bear HAL21 (2026-10-27)
전제: 포털은 hostinger 에서 이미 active(신규 초기화·빈 DB 생성 금지), Agent 는 한 HAL 에만 연결(단일 제어 평면), 비밀값은 파이프로만 이동. 기록: `docs/verification/portal-hal21-link-2026-10-27.md`.
```bash
# 0) 읽기 전용 preflight: DNS(공개·권한 resolver)·TLS·login·/api/health, 유닛 PID/NRestarts/cwd/REVISION/리스너, Agent 의 halUrl(agent.json 의 비밀 아닌 값), 포털 config 값·vault 이름, DB integrity/집계, 구 HAL·HAL21 의 servers/agents/jobs/module_targets
# 1) 사설 백업(각 호스트, root 0700/0600, RESTORE.md 먼저): 포털 DB VACUUM 사본(+wal/shm 크기 기록)·vault·config·유닛·nginx·cert 메타·Agent data·구 HAL DB/config, HAL21 DB/config/release — SHA256SUMS·stage 추출 readback·복원 경로 존재 검증
# 2) HAL21(bear, HAL 데이터 소유자): main 릴리스 artifact 등록·승인, 포털 주소 설정(재시작 필요), current 전환
cd /srv/ariweb/hal/releases/0.1.1-<commit12> && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
node hal/index.mjs register-release --manifest <dist>/manifest.json --approve --by <who>
node hal/index.mjs bootstrap-release --version 0.1.1 --commit <sha> --sha256 <hal artifact sha>
node hal/index.mjs config-set --portalUrl https://my.ariweb.net --by <who> --reason '…' # 포털이 다른 호스트일 때
ln -sfn $PWD /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal && curl -s http://127.0.0.1:8900/api/health
# 3) 포털 연동 키: 값을 화면에 두지 않고 host-to-host 파이프로 포털 vault 에 넣는다(service:hal 하나 — 포털은 양방향에 같은 이름을 쓴다)
ssh bear 'cd /srv/ariweb/hal/current && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs link-portal | sed -n "s/^ service:hal = //p"' \
| ssh hostinger 'node -e "let s=\"\";process.stdin.on(\"data\",c=>s+=c).on(\"end\",async()=>{const m=await import(\"/srv/ariweb/portal/current/lib/vault.mjs\");m.openVault(\"/srv/ariweb/portal/data\").set(\"service:hal\",s.trim(),{kind:\"service-key\"})})"'
# 4) 포털 주소(비밀 아님): portal/config.mjs 의 set 으로 config.json 기록(0600, 원자적). 포털은 기동 때 읽으므로 다음 단계의 재배포(재시작)로 반영된다
ARIWEB_PORTAL_DATA=/srv/ariweb/portal/data node -e "import('/srv/ariweb/portal/current/portal/config.mjs').then(m=>m.loadConfig().set({publicUrl:'https://my.ariweb.net',halUrl:'https://hal21.ariweb.net'},'cli:<who>'))"
# 5) HAL21 작업으로 portal 재배포(같은 0.1.1 이면 Agent 가 현재 릴리스를 <ver>.prev-<시각> 으로 보존하고 health 실패 시 자동 복귀). 실행은 ariweb-hal 서비스가 맡는다
node hal/index.mjs module-deploy --module portal --version 0.1.1 --servers <srv_id> --by <who> --reason '…' # → jobId
node hal/index.mjs job --id <j_id> # status done, children[].steps await.result.actualVersion/previousVersion/health
# 6) readback: module_targets.portal target=actual=0.1.1 health ok · 포털의 실제 client+config+vault 로 /portal/v1/health·servers·jobs 계약 · bear nginx access log 의 포털 요청 200 · 포털 DB 집계·Webmaker 인벤토리 전후 비교 · 외부 URL/TLS · 유닛/NRestarts/journal
```
주의: 포털 vault 교체와 포털 재시작 사이(수 초)에는 포털→HAL 호출이 인증 실패한다(화면은 '정보 확인 지연'). 서버 행의 role 이 `mail` 이면 `pickServer(web)` 이 null 이라 포털 사이트 배포 대상이 없다 — 운영자 콘솔에서 role 을 control 로 바꾼다(운영자 API 만 있음). 구 HAL 은 Agent 가 떠나면 server offline 으로 남는다; 중지·폐기는 사람 결정.
+38
View File
@@ -0,0 +1,38 @@
# 검증 기록(docs/verification)
이 디렉터리에는 Ariweb Suite 의 **로컬 종단 간 검증 하네스**(`test/e2e/`)가 만든 기록이 쌓인다. 개발 지시 "Release 0.1.1 완료 조건" 34개 흐름을
실제 HAL·고객 포털·Node Agent 프로세스로 걸어 보고, 흐름마다 다음 판정과 증거(HTTP 상태·최종 화면 상태·작업 단계)를 남긴다.
| 판정 | 뜻 |
|---|---|
| 로컬 확인(verified_local) | 로컬 환경에서 끝까지 확인됨 |
| 부분 확인(partial) | 일부 단계만 확인됨(나머지는 실 인프라가 필요하거나 선행 흐름의 결함으로 막힘) |
| 로컬 확인 불가(not_verifiable_locally) | 실 DNS·메일 왕복·공인 서버 등이 필요 |
| 실패(결함)(failed) | 실제 결함으로 보이는 실패 — 보고서의 "발견한 결함" 에 재현 정보 |
**이 기록은 출시 판정이 아니다.** 개발 지시는 각 흐름을 실제 배포 환경에서 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정하도록 요구한다.
로컬 하네스는 결함을 빨리 찾고, 실서버 검증 전에 어떤 흐름이 어디까지 동작하는지 드러내는 용도다.
## 실행
```bash
node --no-warnings test/e2e.test.mjs # 전체(5분 안에 끝남). docs/verification/local-e2e-<날짜>.md/.json 생성
ARIWEB_E2E_FLOWS=30-sites,80-hal node --no-warnings test/e2e.test.mjs # 흐름 그룹 일부만
ARIWEB_E2E_STRICT=1 node --no-warnings test/e2e.test.mjs # failed 판정이 있으면 종료 코드 1
ARIWEB_E2E_KEEP=1 … # 임시 디렉터리(test/.tmp/e2e-*) 보존
ARIWEB_E2E=0 … # 건너뜀(test/run.mjs 에서 제외하고 싶을 때)
```
## 하네스 구성(test/e2e/)
- `boot.mjs` — 빈 포트·임시 데이터 디렉터리로 가짜 서비스 → HAL(설정 토큰·릴리스 등록·부트스트랩 → 서버 실행 → 운영자 최초 설정·로그인) → Node Agent 4대(control/web/backup/web, `ARIWEB_AGENT_STUB_CMDS=1` 로 systemctl/nginx/certbot 은 기록만, tar 와 파일 배치는 실제) → 고객 포털(Catalog 자동 시드, AriMail 서버 등록) 순으로 띄운다.
- `fake-mail.mjs` — Resend 호환 발신 API 포획(가입 확인·초대·필수 알림 메일에서 토큰·링크를 꺼낸다).
- `fake-webmaker.mjs` — Webmaker 의 포털 연동 면(`/api/internal/ariweb/*` 서명 검증, 프로젝트·저장본·export tar.gz, `/api/auth/sso` 1회용 티켓).
- `fake-arimail.mjs` — AriMail 의 설정·관리자·사용량 API(도메인별 보장량·경고 구간·제한 사유 계산). SMTP/IMAP 은 없다.
- `flows/*.mjs` — 흐름 그룹(번호순 실행). 각 단계는 `steps().run()` 으로 증거를 남기며 결함이 보이면 `report.bug()` 로 재현 정보를 적는다. 결함을 우회하지 않고, 막힌 뒤 흐름은 `partial`(선행 흐름 번호) 로 적는다.
- `report.mjs` — 흐름·결함·실 인프라 필요 항목을 Markdown/JSON 으로 쓴다. 비밀값은 lib/redact 로 가린다.
## 로컬에서 확인할 수 없는 것(실서버·실 DNS 필요)
도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인, 메일 DNS 와 외부 송신·수신·회신 왕복, 실제 서버 간 이전·백업 저장소, 모듈 설치(/srv/ariweb 권한), 재해복구의 실제 다른 호스트 복원.
이 항목들은 하네스가 `not_verifiable_locally` 또는 `partial` 로 적고 "실제 인프라가 필요한 항목" 절에 모은다.
@@ -0,0 +1,53 @@
# 운영자 콘솔 "부트스트랩 미완료" 배너 결함 수정·배포 검증 기록 — 2026-10-27 (bear hal21.ariweb.net, Hera)
비밀값(토큰·비밀번호·Vault 값·운영자 자격)은 이 문서에 없다. 시각은 UTC.
## 1. 증상과 원인
- 오즈 보고: HAL21 운영 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보임.
- 실제 readback(수정 전, 14:2xZ `curl http://127.0.0.1:8900/api/health`): `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent=ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, Agent 2대 active.
- 원인(코드): `hal/ui.mjs` shell 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`, `hal/core.mjs` selfHealth)이 아니라 존재하지 않는 snake_case `completed_at`·`hal_running_version`·`first_agent_id` 를 읽어 항상 `undefined` → "미완료·확인 전·미등록". 같은 파일 `/ops/releases` 라우트는 `releasesView`(`hal/ui/render.mjs`, bootstrap 행의 `hal_running_version`·`manual_install_locked` 기대)에 camelCase 객체를 넘겨 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려짐.
- **서버 렌더(HTML) 결함**이다. `hal/ui/public/ops.js`·`ops.css` 는 bootstrap 필드를 읽지 않으며(grep 0건) 변경하지 않았다 → 브라우저 캐시와 무관.
## 2. RED (수정 전 실패 증명)
- 회귀 테스트 `test/hal-ui.test.mjs` "부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약…": (0) `selfHealth.bootstrap` 에 snake_case 필드가 없음을 고정 (1) 초기 `확인 전·미등록` (2) `core.claimLive` 뒤 `HAL 실행 버전 0.1.1, 첫 Agent 미등록` (3) 첫 Agent 등록(`POST /agent/v1/register`)·`core.approveServer` → 완료 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 에 경고 없음 + 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`.
- 수정 전 실행 결과(`node --no-warnings test/hal-ui.test.mjs`): **통과 6 · 실패 1** — `Error: 실행 버전 0.1.1 표시: 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록) (실제 false)` (test/hal-ui.test.mjs:50).
- 완료 상태 증상도 수정 전 코드로 기록(scratchpad `g7/red-evidence.mjs`): `selfHealth.bootstrap.complete=true` 인데 `/ops/` 배너 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)`, `/ops/releases` `HAL 실행 버전: <b>확인 전</b>`.
## 3. GREEN (수정, `hal/ui.mjs` 만 2곳)
- 배너: `!health.bootstrap.complete` / `health.bootstrap.halRunningVersion` / `health.bootstrap.firstAgent`.
- `/ops/releases`: `running: core.bootstrapState(d)`(렌더러가 기대하는 bootstrap 행). snake_case fallback 없음, 다른 기능 변경 없음.
- 수정 뒤 `test/hal-ui.test.mjs` 통과 7 · 실패 0.
## 4. 리비전·테스트·빌드
| 항목 | 값 |
|---|---|
| 작업 시작 origin/main = HEAD(fetch 뒤 동일, worktree clean — e2e 테스트가 재생성한 `docs/verification/local-e2e-2026-10-27.*` 는 HEAD 로 복원) | `d8e7beeb861ae5edb45fd56d6950fd8a50ba204e` |
| 수정 커밋(hal/ui.mjs + test/hal-ui.test.mjs + VERSION_HISTORY, author `Hera (헤라) <hera@mp.local>`) | `648247f2241579e947d61ab2928176f678f4b141` |
| 전체 테스트 `node test/run.mjs`(수정 뒤, 2m37s) | 22파일 **150건 통과 · 실패 0**(기존 149 + 신규 1) |
| release build(`scripts/release.mjs build`, 커밋 648247f, 테스트 재실행 150건) | 0.1.1, testsPassed=true, dirty=false; verify 5 artifact ✔ |
| hal artifact sha256 | `ed957a0aeb8f065085e3163cf3f367a240e90b55f6221844c93fa4404de8512f` (portal `32c76a60…`, agent `cf2e52fe…`, webmaker `9490b59d…`, arimail `b22868c0…` 는 이전 빌드와 동일) |
| `bash -n deploy/*.sh` | 4개 통과(agent-install·bootstrap-hal·hal-restore·module-as-user); shellcheck 미설치 |
| 소스 `hal/ui.mjs` sha256 | `904059364106888cab17609e3e9f743d96ea239bae87c0af856980a673eed92d` |
## 5. bear 배포 (코드 릴리스만, data/config/DB/Vault 무변경)
- 사전 상태: current `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5`, PID 1792203(14:21:13Z 다른 세션의 opsAllowCidrs 변경·재시작, config.json `updatedBy cli:hera-ops-access` 14:21:12Z, `config.json.pre-ops-cidrs-20261027T142020Z` 존재).
- 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/`(root 0700, 파일 0600, `sha256sum -c` OK, stage 추출 검증 halVault 4·integrity ok·servers 2·configKeys [publicUrl, portalUrl, opsAllowCidrs], 경로 검증 6/6): archive.tgz `5c0e91f8530403061adf108ec1dbcab684c58854ccd7b60eed20e08aa81490c3`(7: config.json·pre-ops-cidrs 사본·vault.json·vault.key·unit·nginx conf), db.tgz `5182f1a65bb99d8dc084590e3993408fc7dd2129998be14bd536ec03e21c7bd0`(hal.sqlite VACUUM 835,584 B, integrity ok, 28표: servers 2·agents 2·releases 1·jobs 4·agent_jobs 2·mail_services 7), code.tgz `0ec00cb9c4698e4e228d31ffa5432ab0a55e6847da1ff8db721700b1e165b73a`(81, 0.1.1-dfb708e933e5), meta.tgz `75d7ff23f1ebeeccb7ad8da8518c5bbe440c39bd4088197a2d3e236a7d466166`, RESTORE.md `78769ba25e4033a310d9b5c348863946603130fc430ed8356713758e3eaaa1f7`, hal-verify.json `2435b0eb87252cb42a589d98a83b55442f3720c898e51b08c3e81ce1e8d4148d`.
- 적용(`~/hera-deploy/bear-apply-g7.sh`, 14:36:34Z): artifact sha 확인 OK → `/srv/ariweb/hal/releases/0.1.1-648247f22415` 추출 → **stage 검증**: 추출된 `hal/ui.mjs` sha256 = 소스(`90405936…`) OK, `node --check` OK, `health.bootstrap.complete` 참조 1, snake_case 참조 0 → `REVISION.json`(by hera-ops-banner-fix) → `register-release --manifest … --approve --by hera-ops-banner-fix`(릴리스 0.1.1 approved, 감사 release.register·release.approve 14:36:34.403Z) → `bootstrap-release --version 0.1.1 --commit 648247f… --sha256 ed957a0a…` → `ln -sfn` current 전환 → `systemctl restart ariweb-hal`. config-set 없음.
- 결과: restart→health **1.08초**, PID 1816086, ActiveState active, NRestarts 0, ActiveEnter 14:36:35Z, 프로세스 cwd `/proc/<pid>/cwd` = `/srv/ariweb/hal/releases/0.1.1-648247f22415`, journal 오류 0, 기동 로그 "부트스트랩 완료".
- 보존 확인(sha256 전=후): config.json `ae5963d29ae899bc77fcb7c03df7d5802bd493b61fc4b2ab2b7a263a525ba0b7`, vault.json `0e2dc9875f2779a0e7a3c1a56c00321dd2a45b0613a3952b62a8c7505be50bb7`, vault.key `345d92e77c3fcd7853b01024d4aaa3f07d27977ea04a30a287070048afe1bf81`; config readback `opsAllowCidrs=["127.0.0.1/32","::1/128","192.168.10.0/24","192.168.20.0/24","119.203.225.42/32"]`, publicUrl `https://hal21.ariweb.net`, portalUrl `https://my.ariweb.net`, updatedAt 14:21:12.623Z 그대로. data 디렉터리 0700, hal.sqlite 860,160 B·wal 4,132,392 B·shm 32,768 B 그대로. 이전 릴리스 디렉터리 전부 보존(0.1.1, -12958ab6952e, -79b45ca96102, -ac072e6f12a9, -dfb708e933e5).
## 6. readback
- `/api/health`(로컬·`https://hal21.ariweb.net`): ok, version 0.1.1, db true, jobs true, bootstrap `complete=true · releaseVersion 0.1.1 · mainCommit 648247f2241579e947d61ab2928176f678f4b141 · artifactSha256 ed957a0a… · halRunningVersion 0.1.1 · firstAgent ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, instanceId `hal_01a17dcaa5d2cb7866a47b3fa3`.
- HAL DB(읽기 전용): bootstrap_state `release_version 0.1.1 · main_commit 648247f… · hal_running_version 0.1.1 · hal_started_at 14:36:35.156Z · first_agent_id ag_01a17bd7… · completed_at 07:21:53.139Z · manual_install_locked 1`; releases 1행 0.1.1 approved by hera-ops-banner-fix(hal_deployed_at null — module.deploy hal 작업이 아닌 CLI bootstrap-release 경로, 이전과 동일); 열린 작업 0; mail_services 7.
- Agent heartbeat: 서버 2대 ready, last_seen 14:36:59.073Z(hostinger ag_01a17c99…)·14:36:59.619Z(jitsi ag_01a17bd7…) — 재시작(14:36:35Z) 뒤 재개, 14:38:10Z 까지 heartbeat 6회(bear nginx log). 재시작 순간 Agent long-poll `GET /agent/v1/jobs/next?wait=25` 502 1회(14:36:35Z, 재시작 창) 외 Agent 요청 비-2xx 없음.
- **배포된 코드 서버 렌더 검증**(운영자 자격 없음 → `~/hera-deploy/deployed-render.mjs` 가 `/srv/ariweb/hal/current` 의 app/deps/core 만 import, in-memory DB·임시 data dir, 운영 데이터·Vault 미접근): 초기 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)` → claimLive 뒤 `(HAL 실행 버전 0.1.1, 첫 Agent 미등록)` → 첫 Agent 등록·승인(complete=true) 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 모두 **배너 없음**, 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김(HAL 전용 운영)`.
- 외부(14:37Z): `https://hal21.ariweb.net/api/health` 200 · `/ops/` 200(세션 없음 → 로그인 화면, 본문에 "부트스트랩 미완료" 0건) · `/ops/login` 200 · `/portal/v1/health` 무인증 401; TLS CN=hal21.ariweb.net 만료 2027-01-25, verify 0.
- 회귀: `https://my.ariweb.net/api/health` 200 ok 0.1.1(로컬 8910 동일), `/` 302→/login, `/login` 200, http→https 301; hostinger 유닛 ariweb-portal 1500968·ariweb-agent 1483523·webmaker 1041361·arihakdang-mail 1046648·nginx 1414934·(구) ariweb-hal 1059540 모두 active NRestarts 0, `nginx -t` 성공, Agent/포털 journal 오류 0(14:36Z 이후); `https://webmaker.ariweb.net/` 302(첫 프로브 4.5~5.0초, 이후 0.29초 — hostinger 측 콜드 응답, HAL 무관); `https://mail.arihakdang.com/` 200·`/api/health` ok 1.0.11 domains 6; `https://webmail.ibit.kr/` 200·ok 1.0.11 domains 1.
## 7. 되돌리기
- 코드만: `ln -sfn /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal` (이전 릴리스 디렉터리 보존됨). 릴리스 행·bootstrap 행의 main_commit/artifact 는 `cd /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs register-release --manifest ~/hera-deploy/dist-dfb708e/manifest.json --approve --by <who> && node hal/index.mjs bootstrap-release --version 0.1.1 --commit dfb708e933e524894822719bce0169008285f4fd --sha256 4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377`.
- 데이터까지: `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/RESTORE.md`(검증 `sudo sha256sum -c SHA256SUMS`).
## 8. blocker / unknown
- 운영자 자격 증명이 없어 실제 운영자 세션으로 콘솔 화면을 열어 보지는 못했다 — 배포된 코드의 서버 렌더 harness(§6)와 회귀 테스트, 배포 파일 sha 일치로 판단. 오즈가 로그인하면 경고가 보이지 않아야 한다(서버 렌더이므로 새로고침만으로 충분, 캐시 삭제 불필요).
- `releases.hal_deployed_at` 은 null(HAL 자체는 module.deploy 작업이 아니라 CLI bootstrap-release + current 전환으로 교체) — 이전 배포와 같은 방식.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
# 기존 메일 서비스 ↔ HAL(bear hal21) 연동 기록 — 2026-10-27 (Hera)
대상: `https://mail.arihakdang.com/` · `https://webmail.ibit.kr/` → bear HAL `https://hal21.ariweb.net` 의 관리형 MailService/Node 등록. 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 바꾸지 않았다. 이 문서에 비밀값은 없다(경로·키 이름·해시만).
## 1. 소스·배포 리비전
| 항목 | 값 |
|---|---|
| 작업 시작 시 origin/main | `ac072e6f12a910ccd60dc7e63a9724363617a46b` (rebase 뒤 `03cb9c0777ddc3dc2003893d3dddd53a01565f2b`) |
| 코드 커밋(배포한 HAL) | `79b45ca96102213d2bd30856ef79b6d732758349` — release.mjs build: testsPassed=true, dirty=false, hal artifact sha256 `bf636f847266e319c888f3ba9f40fc8a22c9f5e6f43775d61d967752742db489` |
| bear HAL 배포 전 | `/srv/ariweb/hal/releases/0.1.1-ac072e6f12a9` (hal.sqlite servers 0 · agents 0 · ops_accounts 1 · mail_services 표 없음) |
| bear HAL 배포 후 | `/srv/ariweb/hal/releases/0.1.1-79b45ca96102` → `current`, `ariweb-hal` 05:28:55Z 재시작(재시작→/api/health 2.6초, NRestarts 0), `/api/health.bootstrap.mainCommit=79b45ca…`, 릴리스 0.1.1 artifact 재등록(approved, by hera-mail-onboard) |
## 2. 실제 호스트·인스턴스(읽기 전용 조사)
| | mail.arihakdang.com | webmail.ibit.kr |
|---|---|---|
| 호스트 | hostinger `srv1953374` (187.53.133.173, Ubuntu, Node v24.14.1 /opt/arihakdang-node) | `jitsi.ibit.kr` (115.68.194.192, Ubuntu 22.04, Node v22.23.3 /opt/arimail-node) |
| 유닛 / 실행 계정 / cwd | `arihakdang-mail.service` (+`-haraka`) / arihakdang / `/srv/arihakdang-mail/current` | `arimail.service` (+`-haraka`) / arimail / `/srv/arimail/current` |
| 리비전 | `releases/0.1.1` REVISION.json: Suite arimail 0.1.1, upstream AriMail 1.0.11, 2026-10-26 설치(hostinger HAL 모듈 배포) | `releases/20261024-204656` REVISION `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`, package 1.0.11 |
| 설정·비밀 참조 | `/etc/arihakdang-mail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arihakdang-mail/tls/` · `data/setup-token` | `/etc/arimail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arimail/tls/` · `data/setup-token` |
| 도메인 등록부 | 6: arihakdang.com(primary, auth www) · ariweb.net(webmaker) · check/solo-mvpcgayc·mvpcu283.ariweb.net(4개, Suite E2E 테넌트, operating=frozen, hostinger 포털 group/mailService 링크) | 1: ibit.kr(primary, auth local) |
| 집계(계정=메일함 / 메일 / 저장) | 10 / 195 / 30.4MB(DB 기준; data 디렉터리 545MB — 과거 pre-* DB 백업 포함) | 3 / 0 / 2.1MB |
| 리스너(유닛 PID 소유) | 110·143·465·587·993·995(0.0.0.0), 8790·8791(127.0.0.1). 25 없음 | 110·143·465·587·993·995, 8790. 25 없음 |
| provider | Resend(inbound/outbound), Haraka 설치됨·비활성 | Resend, Haraka 비활성. journal: `resend._domainkey.ibit.kr` TXT 검증 실패 반복(기존 상태, 변경 없음) |
| DNS(공개 리졸버) | A 187.53.133.173 · MX arihakdang.com→inbound-smtp.ap-northeast-1.amazonaws.com | A 115.68.194.192 · MX ibit.kr→inbound-smtp.ap-northeast-1.amazonaws.com |
| TLS | LE `CN=mail.arihakdang.com`(+SAN webmail.ariweb.net) 만료 2027-01-12 | LE `CN=webmail.ibit.kr` 만료 2027-01-19 |
| nginx / certbot 훅 | `sites-available/mail.arihakdang.conf` → 127.0.0.1:8790, 80→301 https · deploy 훅 `arihakdang-mail.sh`(갱신 시 유닛 재시작) | `webmail.ibit.kr.conf` → 127.0.0.1:8790, 80→301 · 훅 `arimail.sh` |
| 기존 제어 평면 | hostinger HAL(`hal.ariweb.net`, 부트스트랩 완료) `module_targets.arimail` 0.1.1 ok, Agent `ag_01a178829f…` 등록 | 없음 |
두 URL 은 **서로 다른 호스트·다른 AriMail 인스턴스**다(같은 멀티도메인 리스너가 아님).
## 3. 사전 스냅샷(비밀 제외 sha256, 모두 root 0700/0600, git 밖, `sha256sum -c` 통과, SQLite `integrity_check=ok`)
| 위치 | archive.tgz | db.tgz | code.tgz |
|---|---|---|---|
| hostinger `/srv/arihakdang-mail/backups/pre-hal-onboard-20261027T051350Z/` (RESTORE.md 포함) | `abb731f26e6a06c4…` (32 entries: data·config·domains.json·gateway.env·유닛·nginx·훅) | `1ba2eb9c2ba6c8ad…` (VACUUM INTO 사본 6개: 게이트웨이 + 테넌트 5) | `releases/0.1.1` 트리 106 entries |
| jitsi `/srv/arimail/backups/pre-hal-onboard-20261027T051352Z/` | `7e36bcbff719dc36…` (20 entries) | `26035be7a4afdc71…` (사본 1) | `releases/20261024-204656` 66 entries |
| bear `/srv/ariweb/backups/hal-pre-mail-onboard-20261027T051353Z/` | `534c9eb1228c011e…` (vault·config·유닛·nginx) | `da848bc05c84ef7d…` (hal.sqlite 사본, servers 0/agents 0/ops_accounts 1) | `releases/0.1.1-ac072e6f12a9` 79 entries |
복원 순서는 각 디렉터리의 `RESTORE.md`(메일 복원은 서비스 중단 동반 → 운영자 승인 뒤에만). HAL 되돌리기: `mail-import-rollback --import <id>` 또는 RESTORE.md(hal.sqlite 사본 + current 심링크 `0.1.1-ac072e6f12a9`).
## 4. discovery → dry-run → apply → readback (bear, HAL CLI, 시스템 신원 `cli:hera`)
- discovery 문서: `scripts/mail-discover.mjs`(sudo, 읽기 전용) — sha256 `1cfb2042138d1de3…`(hostinger, 05:22:06Z) · `18cda7182ac68dd7…`(jitsi, 05:22:07Z). `validateDiscovery` ok(비밀값 0, 메일 주소 0).
- 서버(노드) 생성: `srv_01a17bd4dbcdb55877f2d9fcab` "srv1953374 (mail.arihakdang.com)" role mail · `srv_01a17bd4dc2a2bc3050054fee0` "jitsi.ibit.kr (webmail.ibit.kr)" role mail — 둘 다 pending.
- dry-run: `mimp_01a17bd4dbd076ebd300d5a681`(create 6·reject 0) · `mimp_01a17bd4dc2e21a185a4cefc09`(create 1·reject 0). DB 변경 없음(mail_services 0).
- apply: `mimp_01a17bd5f2685f8929d7638a28` → 생성 6(`mls_01a17bd5f26934dd18266db83a` arihakdang.com, `…f26a6e17e69ff81adb` ariweb.net, `…f26afc33723ce9c622` check-mvpcgayc, `…f26a1478f1bafcdca7` solo-mvpcgayc, `…f26aa7eab73a48883e` check-mvpcu283, `…f26bbb6aa9e045b23b` solo-mvpcu283) · `mimp_01a17bd5f2eaf5667cbc127dac` → 생성 1(`mls_01a17bd5f2eaf032215343d7cb` ibit.kr).
- 멱등·충돌 검증: 같은 문서 재적용 `mimp_01a17bd5f35c29928b0609ec45` = update 6·create 0(id 유지) · jitsi 문서를 hostinger 서버에 적용 시도 `mimp_01a17bd5f3c482c2797e178e46` = rejected(ibit.kr collision) — 부분 적용 없음.
- readback(`hal/index.mjs mail-services`, 05:33Z): total 7 · active 7 · 도메인 7(각 1) · duplicates [] · secretLeaks 0 · perServer {hostinger 6, jitsi 1} · 모두 kind legacy_managed · ownership **unassigned_legacy_managed**(ariweb.net 테넌트의 hostinger 포털 group 링크는 domain_json 증거로만 보관, 매핑하지 않음). 각 행: 서버·유닛·appDir·리비전·집계·리스너·TLS 만료·DNS MX·nginx·secretRefs(참조 4건, 값 없음).
- 감사: `server.create` 2 · `mail_service.import_dry_run` 2 · `mail_service.import` 13(immutable) · `mail_service.import_rejected` 1 · `release.register/approve`. hal.sqlite `integrity_check=ok`, 마이그레이션 `hal_0003_mail_services` 적용(기존 표 변경 없음).
## 5. Node Agent / heartbeat
- jitsi: 토큰 `hal/index.mjs server-token --server srv_01a17bd4dc2a2…`(1회, 화면·로그 미기록) → `https://hal21.ariweb.net/agent/v1/install.sh --role mail --label jitsi.ibit.kr`. 결과 Agent `ag_01a17bd7201c9a73c157e29968`(managed, 05:30:58Z), 유닛 `ariweb-agent`(master, `/srv/ariweb/agent/current` 0.1.1) active NRestarts 0, 비밀은 `/srv/ariweb/agent/data` 보관소만. 설치가 만든 것: `/srv/ariweb/agent`, `/srv/ariweb/backups`, `/var/www/ariweb-sites`, `/etc/systemd/system/ariweb-agent.service`, `/etc/sudoers.d/ariweb-agent`, `/etc/nginx/conf.d/awstats.conf`(log_format, `nginx -t` 성공·graceful reload). **arimail 유닛·PID·리스너 변화 없음**(PID 3304850 / 3304871 그대로).
- heartbeat readback(05:32:59Z): servers.last_seen 갱신, agent_version 0.1.1, metrics_samples 5건, module_targets `arimail actual 1.0.11 health ok`(REVISION.json 없음 → package.json), 시계 차이 25,053ms(경고 기준 60초 미만 — 호스트 시각 확인 권장). `mail_services`(ibit.kr) `last_sync_source=agent`, health ok, observedVersion 1.0.11.
- hostinger: hal21용 Agent **설치하지 않음** — 기존 Agent 가 hostinger HAL 에 등록되어 있고 같은 유닛을 두 제어 평면이 배포하면 §34 "하나의 HAL" 위반. 서버 행은 pending(Agent 미연결), 메일 서비스 6건은 `last_sync_source=import`(관측 전용). 제어 평면 이전은 사람 결정.
- 서버 승인은 하지 않았다(첫 Agent 승인이 부트스트랩을 닫고 수동 설치를 잠근다 — 운영자 결정).
## 6. Data Plane 독립·회귀(적용 뒤)
- HAL 재시작 구간(05:28:5x) 동안 외부 프로브: `mail.arihakdang.com /api/health` 200 · `webmail.ibit.kr /api/health` 200 · IMAPS 993 `* OK` 연속 유지(12회, 실패 0). AriMail 유닛은 HAL 을 참조하지 않는다(EnvironmentFile·유닛 파일 sha256 변화 없음).
- 두 호스트 사후 점검: 유닛 active/enabled, NRestarts 0, 시작 시각·PID 변화 없음(hostinger 1046648/1046634, jitsi 3304850/3304871) · 리스너 동일 · `/api/health` {ok, 1.0.11, domains 6 / 1} · DB 계정/메일 수 동일(2+2+2+1+2+1 / 189+6, 3 / 0) · gateway.env·config.json·domains.json·유닛·nginx vhost·fullchain.pem sha256 변화 없음 · `nginx -t` 성공 · journal 60분 오류 0(hostinger) / 1(jitsi, 기존 Resend DKIM TXT 검증 실패 반복) · outbound_queue 0 · Haraka 비활성 그대로.
- 외부 프로토콜(05:33Z): 두 호스트 모두 http→https 301, https 200, 587 STARTTLS·465·993·995 인증서 일치(verify 0), IMAPS `* OK`·POP3S `+OK`·SMTPS `220` greeting, 25 닫힘, MX/A 변화 없음.
- hostinger HAL 제어 평면 변화 없음(`module_targets.arimail` 0.1.1 ok, Agent 1051076 active).
- HAL 외부: `/api/health` 200, `/status` 200, `/` → `/ops/` 302, `/ops/mail` 200(로그인 화면), `/ops/api/mail-services` 401(운영자 세션 필요), 인증서 `hal21.ariweb.net` 2027-01-25.
## 7. 테스트
`node test/run.mjs`: 21 파일 144 건 통과·실패 0(신규 `hal-mailimport` 7, `mail-discover` 2). `bash -n deploy/*.sh` 통과(shellcheck 미설치). `scripts/release.mjs build/verify` 통과.
## 8. 바꾸지 않은 것 · blocker · 미확인
- 바꾸지 않음: DNS/A/MX/TXT, 인증서, AriMail 코드·유닛·env·config·domains.json·DB·메일·계정, 리스너, Haraka 상태, hostinger HAL/포털/Agent, bear nginx. 테스트 메일 발송·임시 계정 생성 없음.
- blocker(사용자 조작 필요): (1) hal21 운영자 자격 증명 없음 → 운영자 API/콘솔 readback·서버 승인·접근권 발급은 수행하지 않았다(readback 은 HAL DB/CLI 로 수행). (2) hostinger 의 제어 평면(hal.ariweb.net ↔ hal21) 결정 전에는 hostinger Agent 를 hal21 에 등록하지 않는다. (3) 4개 E2E 테넌트(frozen)와 ariweb.net 의 소유 조직 매핑은 포털 그룹 대조 뒤 운영자가 지정.
- 미확인: jitsi 시계 차이 25초(원인 미조사), ibit.kr Resend DKIM TXT 미검증(기존).
@@ -0,0 +1,57 @@
# 포털·hostinger Node Agent 제어 평면 이전 기록 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
대상: hostinger(`srv1953374`, 187.53.133.173)의 Ariweb Portal 0.1.1(`https://my.ariweb.net`)과 Node Agent 의 제어 평면을 hostinger 구 HAL(`hal.ariweb.net`)에서 bear HAL21(`https://hal21.ariweb.net`)로 옮기고 실제 readback 으로 증명한 기록. Webmaker·포털 데이터·고객 계정·사이트·세션·DB·Vault·DNS·TLS·메일은 변경하지 않았다. 비밀값은 어디에도 출력하지 않았다(파이프로만 이동).
## 1. 리비전
| 항목 | 값 |
|---|---|
| 작업 시작 origin/main(rebase 뒤) | `0b9c70e90c7278e895de54e75e3d07f706c703bf` (Neo 의 Webmaker 연동 커밋 `12958ab`·`0b9c70e` 포함 확인, worktree clean) |
| 코드 커밋 = 배포한 HAL / 등록 릴리스 | `dfb708e933e524894822719bce0169008285f4fd` — HAL CLI `config-set`·`module-deploy`·`job` + 테스트 3건 |
| 릴리스 manifest | version 0.1.1, mainCommit `dfb708e…`, testsPassed=true, dirty=false. artifact sha256: hal `4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377` · portal `32c76a6047b2a7d74de0929c993984040cb1263044af2593abd399ed7daff04e`(05:28Z 등록본과 동일) · agent `cf2e52fe23496b126248b65d3bb7e94106db573559392bd20f365f1b970e315f` · webmaker `9490b59dc1aca3d953f907bb7e5b447901a4558a7275913ffb1cb03e2c944caa` · arimail `b22868c08d2551e7d10891cd4858ee0525ac6529e6ece965881d601c20bde983` |
| 포털 코드 | 운영 포털(2026-10-26 14:30:42Z 설치, 빌드 3951be5)과 HEAD 의 portal/lib/lang/catalog 경로 diff 없음 → 같은 0.1.1 재배포(제어 평면 이전 목적) |
| HAL21 이전 | `/srv/ariweb/hal/releases/0.1.1-12958ab6952e`(Neo 09:02Z 수동 설치, PID 1314165), bootstrap mainCommit `12958ab…`, releases 행 main_commit `79b45ca…`(등록부와 실행본 불일치) |
| HAL21 이후 | `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5` → `current`, `ariweb-hal` 09:31:52Z 재시작(재시작→/api/health 1.11초, PID 1360348, NRestarts 0, instanceId `hal_01a17cb3add236d2c112f3df1a`), bootstrap mainCommit `dfb708e…`/artifact `4e419a72…`, releases 행 0.1.1 approved(by hera-portal-link 09:31:51.753Z), config `portalUrl` `http://127.0.0.1:8910` → `https://my.ariweb.net`(감사 config.update, actor cli:hera-portal-link) |
| hostinger 포털 | `/srv/ariweb/portal/releases/0.1.1`(REVISION installedAt 2026-10-27T09:33:50.021Z, by master) → `current`; 이전 릴리스 `0.1.1.prev-20261027093350` 보존(.previous.json), 유닛 `ariweb-portal` PID 1500968(09:33:50Z), 127.0.0.1:8910 |
| hostinger Agent | `/srv/ariweb/agent/releases/0.1.1-12958ab6952e`(Neo 09:03Z) 그대로, `ag_01a17c99f0102ce24409dc258a` → `https://hal21.ariweb.net`, PID 1483523, NRestarts 0 |
## 2. 읽기 전용 preflight(09:13~09:18Z)
- DNS `my.ariweb.net` A 187.53.133.173 — 1.1.1.1·8.8.8.8·권한 서버 ns1/ns2.hosting.co.kr(TCP) 일치. `hal21.ariweb.net` 121.152.149.45. `hal.ariweb.net` 공개 A 없음(hostinger `/etc/hosts` 127.0.0.1 매핑만). TLS my: CN=`*.ariweb.net` 만료 2027-01-13T07:57:11Z verify 0; hal21: CN=hal21.ariweb.net 만료 2027-01-25T02:03:16Z.
- hostinger 유닛(모두 active/enabled, NRestarts 0): ariweb-hal PID 1059540(10-26 14:33:18Z) · ariweb-portal PID 1059582(14:33:19Z, cwd `/srv/ariweb/portal/current`) · ariweb-agent PID 1483523(10-27 09:04:12Z) · webmaker PID 1041361(14:00:23Z) · arihakdang-mail PID 1046648(14:09:30Z). 리스너 127.0.0.1:8900/8910/8790/3080, nginx 80/443. `/etc/hosts`: `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com`.
- Agent(hostinger) 연결 HAL: 이미 hal21(Neo 09:03:45.729Z 재등록, label `srv1953374 (mail+webmaker)`, drop-in `90-hal21-webmaker.conf`). hal21 의 jobs/agent_jobs 0건. 구 HAL: server `srv_01a178829d5848f93a15f93a17` status offline(last_seen 09:03:14.274Z), agent `ag_01a178829f1c3d88381d2c0c75` active(heartbeat 없음), 열린 jobs 0·agent_jobs 0, module_targets 7(목표 0.1.1), 운영자 ariweb-ops 1, vault 3(service:agent, service:portal, service:hal).
- 포털 config.json 값(비밀 아님): mailer·mailFrom·operatorEmail 만 → halUrl 은 코드 기본값 `https://hal.ariweb.net`(=/etc/hosts → 구 HAL). 포털 vault 이름 11: service:hal, service:portal, sso:webmaker, mailer:resend-key, arimail:* 7.
- 포털 DB(portal.sqlite, wal): integrity ok, 표 53, users 7(active), groups 10, memberships 13, sites 3, domains 6, mail_services 4(cancelled), mail_servers 1, mailboxes 6, jobs 4(failed 2 backup.scheduled, needs_user 2 mail.activate), sessions 7(live 7), audit_log 71, mail_usage_log 899.
- HAL21 DB: integrity ok; 서버 2 모두 ready(hostinger 승인 09:04:02.227Z by `cli:neo-webmaker-link`, jitsi 07:21:53.138Z by `op_01a17bae6c0b70acd18b16f029`), bootstrap complete 07:21:53.139Z(firstAgent `ag_01a17bd7201c9a73c157e29968`, manual_install_locked 1); agents 2 active; module_targets 12(hostinger: agent·arimail·backup·hal·observability·portal·webmaker 모두 actual 0.1.1 ok); 운영자 admin-hal 1(세션 live 1, 마지막 09:08:59Z — 다른 세션 소유); mail_services 7.
- Webmaker baseline(`scripts/webmaker-inventory.mjs`, 읽기 전용): 회원 12(고유 9·SSO 3), 프로젝트 10(배포 3), 도메인 2, 연결 3; 포털 users 7·groups 10·sites 3(linked 3)·domains 6·mailServices 4; 판정 linked_consistent 3·unlinked_native 7, 충돌 0·누락 0. 공개 사이트 디렉터리 10.
## 3. 백업(Gate 2, root 0700/0600, git 밖, `sha256sum -c` OK, stage 추출·readback 검증)
**hostinger `/srv/ariweb/backups/pre-hal21-migrate-20261027T092204Z/`** (RESTORE.md 먼저 작성, 경로 존재 검증 12/12 ok)
- archive.tgz `dca689d8c0fcb5d38455351b5db102e31a5d846b4f4735992627edfa7751bf63` (21 entries: portal/agent/hal data 의 config·vault.json·vault.key·agent.json·jobs, 유닛 3+drop-in, nginx ariweb-portal.conf·ariweb-hal.conf, /etc/hosts)
- db.tgz `d36000015028b6cea72173a8365cbd388f3b17c8b81e7daecb942dd0ac038e9c` (VACUUM INTO 일관 사본: portal.sqlite 1,028,096 B integrity ok 53표 — 원본 db 1,032,192 / wal 4,140,632 / shm 32,768 B; hal.sqlite 888,832 B ok 25표)
- code.tgz `7a9c56e39a63ad79706eaa046869e122c135bb0aa85f7420d74d3536b2f7c08e` (296 entries: portal·agent·hal current 실경로 트리), meta.tgz `7be814eb0030d3ccfc4b1fe1e4d778f70e3a56868158ed5ce66805a4ded2b76c`, RESTORE.md `3954ce32299bc51c86c6ccb6e3a157730d4de11ec862ea10c12e404d578b1300`, portal-verify.json `aa212574b7d3dc1c7266c456379a89b0a71e3e1bf2eb00cfe92f248aae953788`, hal-verify.json `91c33cc8b0d6155bd481b02ca7258f4f589884bb841b4de07e6ed067106eee55`
- stage 검증: portalVault 11 · agentVault 1 · halVault 3 · agent.json id `ag_01a17c99…` · 사본 integrity ok/ok.
**bear `/srv/ariweb/backups/hal-pre-portal-link-20261027T092207Z/`**
- archive.tgz `97b880370d4a94a64d712ba63d8b5ea072a847f893bf977435a1160d6dbcc9ed` (6), db.tgz `1deb62fdca1a77792c3367cb27668d297a4ec5d83e41c0d6c256d7c3b9425a32` (hal.sqlite 548,864 B ok 28표), code.tgz `e543244242541faadb067e66e48db32c128fcecfe23d3b1a15c830abb1f6d9ba` (80), meta.tgz `4c1078937387148e69ed602be66a528de14187eb04040d2908a7744a3c8cbe5c`, RESTORE.md `b1245a65fc82ad8a38866211a3cb261069bd77ef928725fc4921838dcc313ec6`, hal-verify.json `aebb63337c569abc821a9b88750b48d9cd0df45acedb80466c2d97f74135961f`; stage 검증 halVault 2 · servers 2 · configKeys publicUrl,portalUrl.
**source**: origin/main `0b9c70e…` git bundle(작업 PC scratchpad, 0600) sha256 `d55c924495b08337efb6fb1a3385bf5648a07b97231284f4d123bba2080e67eb` + clone.
구 HAL 등록 Agent 데이터는 Neo 백업 `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`(RESTORE.md 의 B 절차가 가리킴).
## 4. 적용(모두 지원 경로, 직접 SQL 없음)
1. 09:31:51Z bear: `register-release --manifest … --approve --by hera-portal-link`(artifact 5개 복사·등록부 갱신) → `bootstrap-release --version 0.1.1 --commit dfb708e… --sha256 4e419a72…` → `config-set --portalUrl https://my.ariweb.net`(감사) → current 전환·`ariweb-hal` 재시작(1.11초). 재시작 구간 외부 프로브 8회: my=200·mail=200·hal21=200·sample=200 전부, webmaker 302×6/000×2(프로브 4초 타임아웃; 직후 5회 재확인 302, 0.29초 — hostinger 호스트라 HAL 재시작과 무관). 두 Agent heartbeat 재개(09:32:29Z·09:32:45Z).
2. 09:33:44Z `link-portal`(bear vault service:portal·service:hal 09:33:45.756Z 갱신) → stdout 을 `sed` 로 값만 추려 ssh 파이프로 hostinger `lib/vault.mjs` 에 전달 → 포털 vault `service:hal` 09:33:46.334Z 교체(replacedExisting true), 구 연동의 미사용 이름 `service:portal` 제거. 값은 화면·로그·셸 변수에 없음(두 node 프로세스 사이 파이프만).
3. 09:33:46Z 포털 `config.set({publicUrl:'https://my.ariweb.net', halUrl:'https://hal21.ariweb.net'}, 'cli:hera-portal-link')`(config.json 198→292 B, 0600; webmakerUrl·arimailUrl 기본값 유지).
4. 09:33:47Z bear `module-deploy --module portal --version 0.1.1 --servers srv_01a17bd4dbcdb55877f2d9fcab --by hera-portal-link --reason …` → HAL 작업 `j_01a17cb5716c669450412967f7`(module.deploy, created_by cli:hera-portal-link, actor operator) → 자식 `j_01a17cb5719cc5385804317f08`(module.deploy.target) → Agent 작업 `aj_01a17cb573916795ab7bc67a54`(module.install, 09:33:49.009→09:33:52.775Z). 결과: actualVersion 0.1.1, previousVersion `0.1.1.prev-20261027093350`, health ok(200, 10 ms), releaseDir `/srv/ariweb/portal/releases/0.1.1`, runUser master; 부모 done 09:33:53.325Z(precheck→fanout→collect, overall done 1/1). 감사 `deploy.module.request`·`deploy.module`. 포털 재시작 09:33:50Z(vault 교체 뒤 4초 — 그 사이 포털→HAL 호출 없음, 포털 journal 오류 0).
## 5. readback
- HAL21 `module_targets.portal`(hostinger): target 0.1.1 · actual 0.1.1 · health ok · last_job `j_01a17cb5719cc5385804317f08` · last_check 09:34:45Z(heartbeat 로 계속 갱신). agent_jobs 1(done). jobs 2(done). outbox 0(운영자 작업은 포털 이벤트를 만들지 않음).
- **Portal→HAL21 인증 읽기(09:35:09Z, 포털의 실제 `portal/hal/client.mjs` + 실제 config.json·vault 로)**: `/portal/v1/health` ok(version 0.1.1, instanceId `hal_01a17cb3…`, bootstrap complete, mainCommit dfb708e…) · `/portal/v1/servers` 2건(둘 다 ready/ok, hostinger modules webmaker·arimail·hal·portal·agent·observability·backup = 0.1.1/ok) · `POST /portal/v1/servers/pick {role:web}` → null(§7 blocker) · `GET /portal/v1/jobs/j_01a17cb5716c…` 200 · `GET /portal/v1/jobs/j_none` → `job_not_found` 404(작업 계약). bear nginx access log: 187.53.133.173 → 위 5요청 200/200/200/200/404, Agent heartbeat 67회/5분. HAL21 journal 오류 0(재시작 이후).
- 바이트 보존(sha256, 배포 전=후): 유닛 `ef800cf0…1e59`(Agent 가 템플릿으로 다시 썼으나 내용 동일, diff 없음) · nginx ariweb-portal.conf `d7e8e1c7…7167` · fullchain `6650b903…b780` · portal vault.key `6a6af7d6…0094` · agent.json `335cc9e2…b524` · agent vault.json `393fe23d…4d64` · /etc/hosts `eafc0116…6c02`. config.json `a703ce78…`→`66fcb558…`, vault.json `8b3598bc…`→`a7e9e8ae…` 는 3·2단계의 의도된 변경(배포 자체는 data 디렉터리를 건드리지 않음). DB 원본 크기 동일(1,032,192/4,140,632/32,768).
- 포털 DB 전후: integrity ok/ok, 표 53/53, 차이는 `mail_usage_log` 899→915(사용량 수집 주기 작업)뿐; users 7·groups 10·memberships 13·sites 3·domains 6·mail_services 4·mailboxes 6·jobs 4·sessions 7(live 7) 동일. vault 이름 11→10(service:portal 제거·service:hal 교체), config 키 +publicUrl·+halUrl.
- Webmaker 전후 동일(회원 12·프로젝트 10·배포 3·연결 3, linked_consistent 3·unlinked_native 7, 충돌 0·누락 0, apply no-op), webmaker PID 1041361·NRestarts 0 유지, 공개 사이트 10. AriMail health ok 1.0.11 domains 6, webmail.ibit.kr ok domains 1.
- 외부(09:35Z): my http→301 https, https / →302 /login, /login 200 `로그인 · Ariweb 고객 포털`, /api/health ok 0.1.1, TLS verify 0; hal21 /api/health ok·/ops/ 200·/portal/v1/health(무인증) 401; webmaker 302, sample 200, guide 200, mail.arihakdang.com 200, webmail.ibit.kr 200. 유닛 6개 active/enabled NRestarts 0, nginx -t 성공, journal 오류: 포털 0, Agent 1(09:31:52 HAL 재시작 중 502 1회), 구 HAL 0, webmaker 0, mail 0.
- 구 HAL(hostinger): 계속 실행 중(PID 1059540)이나 서버 offline(last_seen 09:03:14Z, Agent heartbeat 없음), 열린 작업 0 — hostinger Agent 의 active owner 아님.
## 6. 테스트·빌드
`node test/run.mjs` 22파일 149건 통과·실패 0(코드 커밋 뒤 release build 안에서 1회, 문서 커밋 뒤 재실행). 신규 `test/hal-cli.test.mjs` 3건. `scripts/release.mjs build/verify` 통과(artifact 5개 ✔). `bash -n deploy/*.sh` 통과, shellcheck 미설치.
## 7. 되돌리기·blocker
- 되돌리기: hostinger `RESTORE.md`(A 포털 연결만 → B Agent 구 HAL 복귀(Neo 백업) → C 구 HAL 데이터 → D 유닛·nginx·hosts), bear `RESTORE.md`(vault·config·DB 사본·current `0.1.1-12958ab6952e`), 포털 코드 `ln -sfn /srv/ariweb/portal/releases/0.1.1.prev-20261027093350 /srv/ariweb/portal/current && systemctl restart ariweb-portal`, HAL 작업 되돌리기는 운영자 콘솔 모듈 되돌리기.
- blocker/unknown: (1) hal21 운영자 자격 증명 없음 — 서버 승인·역할 변경·콘솔 readback 은 수행하지 않음(승인·부트스트랩은 이미 완료 상태를 readback). (2) **hostinger 서버 행 role 이 `mail`**(메일 가져오기 때 생성) → `pickServer(web)` null → 포털 관리형 사이트 신규 배포·백업 저장 대상이 없다. 운영자가 `PATCH /ops/api/servers/srv_01a17bd4dbcdb55877f2d9fcab {role:'control'}`(재인증 필요)로 바꿔야 한다. (3) 구 HAL `ariweb-hal`(hostinger)은 중지하지 않았다(폐기·보존은 사람 결정). `/etc/hosts` 의 `hal.ariweb.net` 매핑도 그대로. (4) Agent 의 3개 보존 정책으로 코드 디렉터리 `0.1.1.prev-20261026141643` 가 정리됨(데이터 아님). (5) HAL→포털 이벤트 경로(`/internal/hal-events`)는 포털 발 작업이 없어 실측하지 못함(portalUrl 은 설정됨). (6) jitsi 서버의 `module_targets.webmaker` actual null(Neo 작업 범위).
File diff suppressed because it is too large Load Diff
+810
View File
@@ -0,0 +1,810 @@
# 0.1.1 실제 환경 배포·검증 기록 (ariweb.net 호스트, 2026-10-26)
생성: 2026-10-26T14:33:19.592Z · 검증기 `real-env-check.mjs`(서버에서 실행, 실제 HAL·포털·Node Agent·Webmaker·AriMail 대상) · 배포 기준 main `3951be5`
## 상태 요약
- 배포: HAL 부트스트랩(`deploy/bootstrap-hal.sh`) → 첫 운영자 → 첫 Node Agent(역할 control, 승인 → 부트스트랩 완료·수동 설치 잠김) → `link-portal` → HAL 작업으로 portal → webmaker → arimail 배포, 이후 수정 artifact 를 같은 릴리스 0.1.1 로 재등록해 HAL 작업으로 agent·hal·portal 갱신. 모든 모듈의 실제 버전 0.1.1·건강 ok(아래 재검증).
- 공개 DNS: `hal.ariweb.net`·`my.ariweb.net` A 레코드는 아직 없다(이번 작업은 공개 DNS 를 바꾸지 않았다). 서버 `/etc/hosts` 임시 항목으로 같은 서버 안 통신만 확인했고, 외부 주소 접속(흐름 8 등)은 레코드 생성 뒤 확인한다.
- 실제 외부 발신 1건: 포털 mailer(Resend, `no-reply@ariweb.net`)로 `icqmomo@gmail.com` 에 확인 메일 발송 — Resend 수락 id `01a17899-8c6e-7574-b85b-3c1015fe94b5`(14:24 UTC). 수신함 도착은 사람이 확인한다.
- 흐름 판정(34개): 실제 확인 7 · 부분 확인 7 · 미수행 20 · 실패 0. **0.1.1 출시 완료는 선언하지 않는다** — 지시서 기준(최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전) 중 외부 접속·메일 왕복·Webmaker 페이지 제작이 남아 있다.
## 사람이 해야 남는 일
1. hosting.co.kr 패널에 `hal.ariweb.net`·`my.ariweb.net` A 레코드(187.53.133.173) 생성 → 흐름 8·외부 접속 확인.
2. 메일 왕복 검증용 수신 주소(자동 응답 메일함) 설정(`PUT /internal/ops/mail/policy {verifier}`) → 흐름 11.
3. Webmaker 제작 화면에서 검증 사이트의 페이지를 만들어 저장 → 흐름 5·6·13·14 의 나머지(관리형 배포·백업 실행·복원·외부 확인).
## 배포 경과와 발견한 결함(모두 코드로 수정해 main 에 반영, VERSION_HISTORY "실제 환경 배포에서 발견한 결함 수정")
1. 부트스트랩 4단계 중단 — `/etc/letsencrypt/live` 는 root 전용이라 일반 계정의 인증서 존재 확인이 거짓 → `sudo -n test -f`.
2. 포털 artifact 에 상품정책 시드(`catalog/`) 누락 → 릴리스 빌더 경로 추가.
3. AriMail 은 다른 계정(arihakdang)으로 운영 → Agent 실행 계정 모드(`deploy/module-as-user.sh`, sudoers 최소 규칙). 내려받은 artifact 0600·현재 경로 심링크·umask 077 로 만들어진 0700 디렉터리(릴리스 빌더 모드 정규화) 등 권한 문제 4건.
4. AriMail 마이그레이션이 `DB_FILE` 없이 릴리스 디렉터리에 빈 DB 를 만들 뻔함 → 유닛 EnvironmentFile 을 실행 계정으로 읽어 실행(공백 값 파싱 포함). 사후 절차 실패 시 current 되돌리기.
5. 도입 모듈 실제 버전이 상류 버전(1.7.3·1.0.11)으로 보고돼 HAL 판정·drift 표시 오류 → REVISION.json 의 Suite 버전 사용(설치 결과·heartbeat 모두).
6. Webmaker 운영 유닛의 강화 지시문이 템플릿에 지워질 뻔함 → keepExistingUnit. 데이터 백업이 root 소유 파일(이전 마이그레이션 기록)에 막힘 → 읽기 실패 관대 처리(+서버의 해당 파일 소유권 정정).
7. 같은 버전 재설치가 실행 중인 디렉터리를 지움 → 임시 풀기·`<ver>.prev-<시각>` 보존. 새 유닛(포털) enable 누락. 부트스트랩 릴리스 기록 행 없음(UPDATE 무효).
8. my.ariweb.net nginx 접근 경로가 어디서도 만들어지지 않음 → 부트스트랩이 설치.
9. 서버 한 대(control)라 web·backup 역할 서버를 고를 수 없음 → control 대체. AriMail 설정 API 는 기본 도메인 호스트에서만 응답(Node fetch 는 Host 변경 불가) → 설정 주소를 기본 도메인 이름으로, 서버 hosts 항목.
## Release 0.1.1 완료 조건 34개 — 실제 환경 판정
| # | 흐름 | 판정 | 비고 |
|---|---|---|---|
| 1 | 신규 고객 가입과 이메일 확인 | 실제 확인 | 실제 포털·실제 mailer 경로(포획 서버) — 외부 수신함 도착은 별도 1건으로 확인 |
| 2 | 개인 사용자 그룹 생성과 다른 사용자 초대 | 실제 확인 | |
| 3 | 상품 주문, 결제 확인, 권리 활성화 | 실제 확인 | |
| 4 | 초안 사이트 생성 | 실제 확인 | 실제 Webmaker 에 프로젝트 생성(내부 API 서명 호출) |
| 5 | Webmaker에서 페이지 제작 | 부분 확인 | 제작 화면 진입(SSO)은 실제 Webmaker 로 확인 · 페이지 제작(AI 생성·저장)은 사람이 Webmaker 화면에서 수행해야 하므로 미수행 |
| 6 | 관리형 배포 | 부분 확인 | 배포 요청이 거부됨: precheck_failed — 배포 전 확인에서 차단된 항목이 있습니다 (저장본이 없어 배포할 내용이 없음; 사람이 Webmaker 에서 페이지를 저장한 뒤 수행) |
| 7 | 도메인 연결과 HTTPS 발급 | 미수행 | 실제 소유 도메인과 공개 DNS(TXT·A) 변경이 필요 — 이번 작업은 공개 DNS 를 바꾸지 않음 |
| 8 | 외부 주소에서 사이트 확인 | 미수행 | 공개 DNS(my/hal/사이트 도메인) 생성 전 — 외부 주소 접속은 레코드 생성 뒤 확인 |
| 9 | 사이트 정보와 접속·사용량 통계 확인 | 부분 확인 | 화면 데이터 제공 확인 · 실제 접속 통계(awstats 로그 집계)는 공개 DNS 로 외부 방문이 생긴 뒤 확인 가능 |
| 10 | 사이트 대표 메일과 메일함 생성 | 부분 확인 | 실제 AriMail 테넌트·메일함 생성 확인 · 활성화는 도메인 DNS(MX/SPF/DKIM) 반영이 필요해 외부 대기 |
| 11 | 외부 송신, 수신, 회신 확인 | 미수행 | 검증용 수신 주소(자동 응답 메일함)와 고객 도메인 MX 반영 필요 |
| 12 | 독립 메일 서비스 생성 | 실제 확인 | |
| 13 | 백업 옵션 구매와 첫 백업 확인 | 부분 확인 | 백업 옵션 주문·승인·활성화는 실제 확인. 수동 백업 실행은 사이트가 초안(미배포)이라 정기 실행기가 옵션을 site_not_deployed 로 중지해 Agent 백업까지 가지 않음(설계대로) — Webmaker 에서 페이지를 저장·배포한 뒤 다시 수행. 판정은 검증기의 시간 초과를 운영자가 DB 상태(옵션 paused: site_not_deployed)로 확인해 부분 확인으로 정정 |
| 14 | 백업 복원과 외부 사이트 확인 | 미수행 | 복원 승인(3단계)·외부 사이트 확인은 공개 DNS 와 복원 대상 배포본 필요(흐름 6 선행) |
| 15 | 관리형 사이트 서버 이전과 외부 확인 | 미수행 | 관리 서버가 1대뿐이라 서버 간 이전 미수행 |
| 16 | 외부 배포 사이트 생성과 배포 | 미수행 | 외부 Linux 서버 없음 |
| 17 | 사용량 경고와 한도 동작 확인 | 미수행 | 실제 사용량이 한도에 이르려면 운영 트래픽·시간 경과 필요 |
| 18 | 상품 축소 보류와 정리 후 적용 | 미수행 | 축소 보류는 실제 사용 중 리소스가 필요(로컬 E2E 확인) |
| 19 | 사이트 수 반환과 대표 메일의 독립 전환 | 미수행 | 사이트 수 반환·독립 전환은 흐름 10 활성화(외부 DNS) 뒤 가능 |
| 20 | 사이트 소유권 이전 | 미수행 | 두 번째 소유자 그룹의 심사 절차를 포함해 로컬 E2E 로 확인, 실환경 미수행 |
| 21 | 중지, 해지, 보존 예정일 표시 | 미수행 | 보존 예정일 표시는 시간 경과 필요(로컬 E2E 확인) |
| 22 | HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 | 실제 확인 | HAL 부트스트랩 → 첫 Agent → HAL 작업으로 portal·webmaker·arimail·agent 배포, 실제 버전 0.1.1·건강 ok |
| 23 | 실패 작업의 재시도와 되돌리기 | 미수행 | 실패 작업 재시도·되돌리기는 배포 중 실제로 2회 발생(백업 실패→재시도 성공, 마이그레이션 실패→되돌리기) — 기록은 VERSION_HISTORY·이 문서의 배포 경과 참고 |
| 24 | 운영자 승인, 감사 기록, 고객 알림의 일치 | 부분 확인 | |
| 25 | HAL, 로그인, 비밀정보 관리, 인증서 기반의 재해복구 훈련 | 미수행 | 재해복구 훈련은 다른 호스트 복원이 필요(로컬 E2E 부분 확인) |
| 26 | 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 | 실제 확인 | |
| 27 | 마지막 사이트 수·용량·메일함 동시 요청 시 하나만 성공 | 미수행 | 동시 요청 경합은 로컬 E2E 로 확인, 실환경 미수행 |
| 28 | 외부 Linux 서버 Agent 연결·배포·재배포·제거·실패 정리 | 미수행 | 외부 Linux 서버 없음 |
| 29 | HAL 장애 중 기존 서비스 유지와 신규 변경 제한 표시 | 미수행 | HAL 장애 상태 표시는 로컬 E2E 로 확인(흐름 26 에서 재시작 중 복구만 실환경 확인) |
| 30 | 모듈 업데이트, 일부 서버 실패, 대상별 되돌리기와 상태 조정 | 부분 확인 | 모듈 갱신(수정 artifact 재등록 → HAL 작업으로 agent·hal 갱신) 4건 완료 확인 · 일부 서버 실패·대상별 되돌리기는 서버 1대뿐이라 미수행 |
| 31 | 긴급 복구의 승인 부족·만료 자격·허용 범위 밖 작업 거부와 정상 복구 | 미수행 | 긴급 복구 절차는 로컬 E2E 로 확인 |
| 32 | Source 옵션 만료 후 30일 read-only, clone·export와 삭제 완료 | 미수행 | Source 옵션 30일 만료는 시간 경과 필요 |
| 33 | 메일 서비스의 사이트 대표→독립 전환 후 항목 유지 | 미수행 | 흐름 19 와 같은 외부 DNS 선행 조건 |
| 34 | 일시 중지·해지 예약·보존·삭제 단계의 표시 일치 | 미수행 | 시간 경과 필요(로컬 E2E 확인) |
## 흐름별 단계와 증거
### 1. 신규 고객 가입과 이메일 확인 — 실제 확인
실제 포털·실제 mailer 경로(포획 서버) — 외부 수신함 도착은 별도 1건으로 확인
- ✔ 가입 → 세션 발급(실제 포털 https://my.ariweb.net) — {"status":200}
- ✔ 확인 전 그룹 생성 차단(403 email_unverified) — "email_unverified"
- ✔ 확인 메일이 실제 발신 경로(mailer resend → 포획 서버)로 나감 · 토큰 추출 — {"subject":"[Ariweb] 이메일 주소를 확인해 주세요","from":"no-reply@ariweb.net","linkHost":"https://my.ariweb.net"}
- ✔ 이메일 확인(1회용) → 재사용 거부 — {"first":200,"second":"token_used"}
- ✔ 내 정보: 확인됨 · 개인 그룹 · owner — {"verifiedAt":"2026-10-26T14:31:15.078Z","group":"personal","role":"owner"}
- ✔ 중복 가입은 존재를 드러내지 않는 안내(409) — "signup_unavailable"
- ✔ 로그인 화면(로그인 상태면 302, 아니면 200)·기기(세션) 목록 — {"loggedIn":302,"anonymous":200,"sessions":"[redacted]"}
```json
{
"verifiedAt": "2026-10-26T14:31:15.078Z",
"group": "personal",
"role": "owner"
}
```
### 2. 개인 사용자 그룹 생성과 다른 사용자 초대 — 실제 확인
- ✔ 팀 그룹 만들기 → 현재 그룹 전환 — "g_01a1789f67b12a216acd1955fa"
- ✔ 이메일 초대 → 초대 메일(포획)에서 링크 — "upaCVcL43bFzzZaGTtGZi_tU2J64oJEA"
- ✔ 초대 수락(1회용) → member · 재사용 거부 — {"reuse":"invite_invalid"}
- ✔ 권한 없는 구성원의 변경 API → 403 — {"requiredRole":["owner","admin"],"yourRole":"member"}
### 3. 상품 주문, 결제 확인, 권리 활성화 — 실제 확인
- ✔ 상품 목록(공개 버전·가격·한도) — {"count":2,"id":"pv_01a178834fac072b5600ce1e60","name":"팀","price":49000,"sites":3}
- ✔ 주문 제출(수동 결제, 독립 메일 옵션 포함) → 결제 대기 — {"id":"o_01a1789f68c3298aec8ad337e8","group_id":"g_01a1789f67b12a216acd1955fa","order_no":"AW-20261026-0003","kind":"new","product_version_id":"pv_01a178834fac072b5600ce1e60","items_json":"[{\"type\":\"product\",\"id\":\"team\",\"name\":\"팀\",\"qty\":1,\"unit_price\":49000,\"amount\":49000,\"tax\":4900,\"target_type\":\"group\",\"target_id\":null,\"grants\":{\"sites\":3,\"web_disk\":21474836480,\"
- ✔ 입금 확인 전 승인 거부(409) — "payment_not_confirmed"
- ✔ 운영자 입금 확인 → 승인 → 권리 활성화 — {"paid":"awaiting_approval","approved":"approved","granted":10}
- ✔ 고객 결제 화면: 활성 상품·권리·다음 청구일 — {"subs":1,"nextBilling":"2026-11-26T14:31:15.451Z"}
```json
{
"orderNo": "AW-20261026-0003",
"bill": {
"subs": 1,
"nextBilling": "2026-11-26T14:31:15.451Z"
}
}
```
### 4. 초안 사이트 생성 — 실제 확인
실제 Webmaker 에 프로젝트 생성(내부 API 서명 호출)
- ✔ 사이트 생성(새 Webmaker 프로젝트 · 관리형) → 초안 — {"id":"s_01a1789f690cb9115f0330d2e8","group_id":"g_01a1789f67b12a216acd1955fa","name":"검증 사이트 mvpcu283","purpose":"실제 환경 검증","webmaker_project_id":"85e1859a8638022beb761f1ec4ccfc93","template":null,"lang":"ko","timezone":"Asia/Seoul","lifecycle":"draft","deploy_mode":"managed","primary_domain":null,"site_count_state":"none","site_count_reservation_id":null,"server_id":null,"external_agent_id":null
- ✔ 초안 목록·사용량(사이트 수 미소비) — {"drafts":1,"lifecycle":"draft","projectId":null}
### 5. Webmaker에서 페이지 제작 — 부분 확인
제작 화면 진입(SSO)은 실제 Webmaker 로 확인 · 페이지 제작(AI 생성·저장)은 사람이 Webmaker 화면에서 수행해야 하므로 미수행
- ✔ 제작 화면 열기: 포털 SSO 티켓 → 실제 Webmaker /api/auth/sso 302(1회용) — {"portal":302,"webmaker":302,"webmakerLocation":"/?site=85e1859a8638022beb761f1ec4ccfc93","reuse":403}
- ✔ 저장본 목록·배포 전 확인(실제 Webmaker 프로젝트) — {"versionsStatus":200,"versions":0,"precheck":true}
```json
{
"sso": {
"portal": 302,
"webmaker": 302,
"webmakerLocation": "/?site=85e1859a8638022beb761f1ec4ccfc93",
"reuse": 403
},
"ver": {
"versionsStatus": 200,
"versions": 0,
"precheck": true
}
}
```
### 6. 관리형 배포 — 부분 확인
배포 요청이 거부됨: precheck_failed — 배포 전 확인에서 차단된 항목이 있습니다 (저장본이 없어 배포할 내용이 없음; 사람이 Webmaker 에서 페이지를 저장한 뒤 수행)
- ✔ 배포 요청(관리형) → HAL 작업·사이트 수 예약 — {"status":409,"error":"precheck_failed","message":"배포 전 확인에서 차단된 항목이 있습니다"}
### 9. 사이트 정보와 접속·사용량 통계 확인 — 부분 확인
화면 데이터 제공 확인 · 실제 접속 통계(awstats 로그 집계)는 공개 DNS 로 외부 방문이 생긴 뒤 확인 가능
- ✔ 사이트 정보·배포 이력·사용량 화면 — {"overviewKeys":["ok","overview","refreshJob"],"usageKeys":["ok","overview","allocations","samples"],"deployments":0}
```json
{
"overviewKeys": [
"ok",
"overview",
"refreshJob"
],
"usageKeys": [
"ok",
"overview",
"allocations",
"samples"
],
"deployments": 0
}
```
### 10. 사이트 대표 메일과 메일함 생성 — 부분 확인
실제 AriMail 테넌트·메일함 생성 확인 · 활성화는 도메인 DNS(MX/SPF/DKIM) 반영이 필요해 외부 대기
- ✔ 사이트 연결 도메인 등록(검증 전용 이름, 소유 확인은 외부 대기) — {"id":"d_01a1789f6a07e57873e2f0c05f"}
- ✔ 사이트 대표 메일 생성 → 실제 AriMail 테넌트 준비 — {"id":"msv_01a1789f6a0d5f702a1581f2fd","status":"needs_setup","kind":"site_bound"}
- ✔ 메일함 생성(초기 비밀번호 1회 표시) → 재조회에 비밀번호 없음 · 실제 AriMail 계정 — {"id":"mb_01a1789f6e723ade68602baaf9","address":"kim@check-mvpcu283.ariweb.net","shownOnce":true,"leakedOnReload":false}
- ✔ DNS 확인(미반영 구분) → 활성화 작업은 외부 대기 — {"dnsOk":true,"items":[],"activate":200,"job":"queued","waitReason":""}
```json
{
"d": {
"id": "d_01a1789f6a07e57873e2f0c05f"
},
"svc": {
"id": "msv_01a1789f6a0d5f702a1581f2fd",
"status": "needs_setup",
"kind": "site_bound"
},
"mb": {
"id": "mb_01a1789f6e723ade68602baaf9",
"address": "kim@check-mvpcu283.ariweb.net",
"shownOnce": true,
"leakedOnReload": false
},
"dns": {
"dnsOk": true,
"items": [],
"activate": 200,
"job": "queued",
"waitReason": ""
}
}
```
### 12. 독립 메일 서비스 생성 — 실제 확인
- ✔ 메일 전용 도메인 등록(purpose mail) — "d_01a1789f6e94cf197bdcb0cb81"
- ✔ 독립 메일 서비스 생성(독립 옵션 소비·사이트 수 미소비) — {"id":"msv_01a1789f6e9604bd5f49bb08e1","kind":"standalone","status":"needs_setup","usage":{}}
```json
{
"id": "msv_01a1789f6e9604bd5f49bb08e1",
"kind": "standalone",
"status": "needs_setup",
"usage": {}
}
```
### 13. 백업 옵션 구매와 첫 백업 확인 — 부분 확인
백업 옵션 주문·승인·활성화는 실제 확인. 수동 백업 실행은 사이트가 초안(미배포)이라 정기 실행기가 옵션을 site_not_deployed 로 중지해 Agent 백업까지 가지 않음(설계대로) — Webmaker 에서 페이지를 저장·배포한 뒤 다시 수행. 판정은 검증기의 시간 초과를 운영자가 DB 상태(옵션 paused: site_not_deployed)로 확인해 부분 확인으로 정정
- ✔ 백업 옵션 주문(대상 사이트 지정) → 입금 확인 → 승인 — {"orderNo":"AW-20261026-0004","approved":"approved"}
- ✔ 백업 화면(옵션 보유 여부) — {}
- ✔ 옵션 활성화(확인 필수) → 첫 백업 예정 — {"status":"active"}
- ✘ 지금 백업 실행 → 실제 Agent 가 사이트 파일·데이터를 묶어 저장(완료 대기 120초) — 백업 완료 대기 시간 초과
```json
{
"optionState": {
"status": "paused",
"paused_reason": "site_not_deployed",
"updated_at": "2026-10-26T14:32:11.943Z"
},
"siteLifecycle": "draft",
"lastJob": {
"status": "failed",
"error": "대상 서버 또는 Agent 가 필요합니다"
}
}
```
### 22. HAL 우선 배포 후 HAL을 통한 전체 모듈 배포 — 실제 확인
HAL 부트스트랩 → 첫 Agent → HAL 작업으로 portal·webmaker·arimail·agent 배포, 실제 버전 0.1.1·건강 ok
- ✔ HAL 릴리스·모듈 목표/실제 버전(배포 영수증) — {"running":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"[redacted]","artifactSha256":"[redacted]","halRunningVersion":"0.1.1","firstAgent":"[redacted]","manualInstallLocked":true},"targets":[{"module":"agent","target":"0.1.1","actual":"0.1.1","health":"ok"},{"module":"arimail","target":"0.1.1","actual":"0.1.1","health":"ok"},{"module":"backup","target":null,"actual"
- ✔ HAL 을 통한 모듈 배포 작업(module.deploy) 결과 — [{"label":"portal 0.1.1 배포","status":"done"},{"label":"hal 0.1.1 배포","status":"done"},{"label":"portal 0.1.1 배포","status":"done"},{"label":"portal 0.1.1 배포","status":"done"},{"label":"agent 0.1.1 배포","status":"done"},{"label":"arimail 0.1.1 배포","status":"done"},{"label":"hal 0.1.1 배포","status":"done"},{"label":"agent 0.1.1 배포","status":"done"},{"label":"arimail 0.1.1 배포","status":"failed"},{"label
```json
{
"rel": {
"running": {
"complete": true,
"firstInstall": false,
"releaseVersion": "0.1.1",
"mainCommit": "[redacted]",
"artifactSha256": "[redacted]",
"halRunningVersion": "0.1.1",
"firstAgent": "[redacted]",
"manualInstallLocked": true
},
"targets": [
{
"module": "agent",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok"
},
{
"module": "arimail",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok"
},
{
"module": "backup",
"target": null,
"actual": "0.1.1",
"health": "ok"
},
{
"module": "hal",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok"
},
{
"module": "observability",
"target": null,
"actual": "0.1.1",
"health": "ok"
},
{
"module": "portal",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok"
},
{
"module": "webmaker",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok"
}
]
},
"jobs": [
{
"label": "portal 0.1.1 배포",
"status": "done"
},
{
"label": "hal 0.1.1 배포",
"status": "done"
},
{
"label": "portal 0.1.1 배포",
"status": "done"
},
{
"label": "portal 0.1.1 배포",
"status": "done"
},
{
"label": "agent 0.1.1 배포",
"status": "done"
},
{
"label": "arimail 0.1.1 배포",
"status": "done"
},
{
"label": "hal 0.1.1 배포",
"status": "done"
},
{
"label": "agent 0.1.1 배포",
"status": "done"
},
{
"label": "arimail 0.1.1 배포",
"status": "failed"
},
{
"label": "webmaker 0.1.1 배포",
"status": "done"
},
{
"label": "portal 0.1.1 배포",
"status": "done"
}
]
}
```
### 24. 운영자 승인, 감사 기록, 고객 알림의 일치 — 부분 확인
- ✔ 고객 감사 기록(order.approve) · 알림 · 운영자 주문 상태 일치 — {"auditEntries":0,"notifications":6,"status":"approved"}
- ✔ HAL 감사 기록(deploy.module) — 10
```json
{
"a": {
"auditEntries": 0,
"notifications": 6,
"status": "approved"
},
"halDeployAudit": 10
}
```
### 26. 새로 고침, 재로그인, HAL 재시작 후 진행 작업 상태 유지 — 실제 확인
- ✔ HAL 재시작 전: 운영자 세션·작업 수·서버 등록 — {"jobs":23,"servers":1}
- ✔ systemctl restart ariweb-hal → 건강 회복 → 같은 세션으로 작업 수·서버 유지 — {"uptimeSec":1,"sessionKept":"[redacted]","jobs":23,"servers":1,"agentOnline":true}
- ✔ Agent 가 재접속(heartbeat) 해 서버 ready 유지 — [{"name":"srv1953374 (ariweb.net)","status":"ready","lastSeen":"2026-10-26T14:33:13.381Z"}]
```json
{
"before": {
"jobs": 23,
"servers": 1
},
"after": {
"uptimeSec": 1,
"sessionKept": "[redacted]",
"jobs": 23,
"servers": 1,
"agentOnline": true
},
"ag": [
{
"name": "srv1953374 (ariweb.net)",
"status": "ready",
"lastSeen": "2026-10-26T14:33:13.381Z"
}
]
}
```
## 검증에 쓴 테스트 데이터(정리 대상)
- 고객 계정 `alice-mvpcu283@check.ariweb.net`(및 bob-*, 이전 실행분 alice-*/bob-*), 사이트 `s_01a1789f690cb9115f0330d2e8`, 도메인 `check-mvpcu283.ariweb.net`·`solo-*.ariweb.net`(AriMail 테넌트는 운영자 취소 → 보존 기간 뒤 삭제), 주문 `o_01a1789f68c3298aec8ad337e8`. 모두 `@check.ariweb.net` 주소의 검증 전용 데이터다.
## 성공 선언 전 재검증(서버에서 읽은 값, `90-verify.sh`)
```
### systemd
ariweb-hal active / enabled=enabled
ariweb-agent active / enabled=enabled
ariweb-portal active / enabled=enabled
webmaker active / enabled=enabled
webmaker-setup active / enabled=enabled
arihakdang-mail active / enabled=enabled
arihakdang-mail-haraka active / enabled=enabled
nginx active / enabled=enabled
### nginx
nginx: configuration file /etc/nginx/nginx.conf test is successful
ariweb-hal.conf
ariweb-portal.conf
webmail.ariweb.net.conf
webmaker.ariweb.net.conf
webmaker.ariweb.net.conf.bak-1.5.27
### HAL health (127.0.0.1:8900)
{"ok":true,"version":"0.1.1","uptimeSec":5,"db":true,"jobs":true,"bootstrap":{"complete":true,"firstInstall":false,"releaseVersion":"0.1.1","mainCommit":"3951be5836a753e60f11d6b363025059fcf6d448","artifactSha256":"e7741899cd7387b0155c9d3bf8ff14cc4026880db999c9d59347744390415e84","halRunningVersion":"0.1.1","firstAgent":"ag_01a178829f1c3d88381d2c0c75","manualInstallLocked":true},"instanceId":"hal_01a178a14a3deef408f7ddfa91"}
### HAL health via nginx https (resolve 127.0.0.1)
{"ok":true,"version":"0.1.1","db":true,"bootstrap":true}
### portal health
{"ok":true,"version":"0.1.1","db":true,"jobs":17,"time":"2026-10-26T14:33:23.567Z"}
{"ok":true,"version":"0.1.1"}
my.ariweb.net /login → 200
### ops console
/ops/ (127.0.0.1) → 200
/ops/ via nginx (외부 IP 흉내 X-Real-IP 8.8.8.8) → 200
### modules (HAL 기록)
{
"running": {
"complete": true,
"firstInstall": false,
"releaseVersion": "0.1.1",
"mainCommit": "3951be5836a753e60f11d6b363025059fcf6d448",
"artifactSha256": "e7741899cd7387b0155c9d3bf8ff14cc4026880db999c9d59347744390415e84",
"halRunningVersion": "0.1.1",
"firstAgent": "ag_01a178829f1c3d88381d2c0c75",
"manualInstallLocked": true
},
"releases": [
{
"v": "0.1.1",
"status": "approved",
"halDeployedAt": "2026-10-26T14:30:34.618Z"
}
],
"targets": [
{
"module": "agent",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
},
{
"module": "arimail",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
},
{
"module": "backup",
"target": null,
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
},
{
"module": "hal",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
},
{
"module": "observability",
"target": null,
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
},
{
"module": "portal",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
},
{
"module": "webmaker",
"target": "0.1.1",
"actual": "0.1.1",
"health": "ok",
"active": 1,
"checkedAt": "2026-10-26T14:33:13.381Z",
"err": null
}
]
}
### modules (디스크)
/srv/ariweb/hal current → /srv/ariweb/hal/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
/srv/ariweb/portal current → /srv/ariweb/portal/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
/srv/ariweb/agent current → /srv/ariweb/agent/releases/0.1.1 REVISION=0.1.1(상류 0.1.1) pkg=0.1.1
/srv/webmaker current → /srv/webmaker/releases/0.1.1 REVISION=0.1.1(상류 1.7.3) pkg=1.7.3
/srv/arihakdang-mail current → /srv/arihakdang-mail/releases/0.1.1 REVISION=0.1.1(상류 1.0.11) pkg=1.0.11
### 서비스 자체 보고 버전
{"ok":true,"version":"1.0.11","domains":6}
webmaker csrf → 401
webmaker.ariweb.net → 302
webmail.ariweb.net → 200
### 배포 영수증(감사 기록 deploy.*)
[
{
"at": "2026-10-26T14:30:45.633Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "portal",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:30:35.618Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "hal",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:23:37.819Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "portal",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:16:46.605Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "portal",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:16:41.094Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "agent",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:09:32.947Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "arimail",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:09:24.433Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "hal",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:08:47.250Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "agent",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:00:26.918Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "webmaker",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
},
{
"at": "2026-10-26T14:00:17.400Z",
"actor": "op_01a178829b90905c15a3191caa",
"subject": "portal",
"detail": {
"version": "0.1.1",
"serverIds": [
"srv_01a178829d5848f93a15f93a17"
],
"overall": "done"
}
}
]
### 배포 작업
[
{
"id": "j_01a1789ee309d1bc1102a6d949",
"label": "portal 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:30:45.633Z"
},
{
"id": "j_01a1789eba1aac59f8518c92b3",
"label": "hal 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:30:35.618Z"
},
{
"id": "j_01a178985cf9fa2f2e76fa7735",
"label": "portal 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:23:37.819Z"
},
{
"id": "j_01a17892166e8460c04de6a7d6",
"label": "portal 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:16:46.605Z"
},
{
"id": "j_01a17892094ddcff01ba7b8002",
"label": "agent 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:16:41.094Z"
},
{
"id": "j_01a1788b794d0d3545ca8bedd0",
"label": "arimail 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:09:32.947Z"
},
{
"id": "j_01a1788b53ed98904ed1127166",
"label": "hal 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:09:24.433Z"
},
{
"id": "j_01a1788acd103d85194bbb8bba",
"label": "agent 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:08:47.250Z"
},
{
"id": "j_01a17883416842d8acc7909136",
"label": "arimail 0.1.1 배포",
"status": "failed",
"ended": "2026-10-26T14:00:33.432Z"
},
{
"id": "j_01a178831b657e19aaed13d7d8",
"label": "webmaker 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:00:26.918Z"
},
{
"id": "j_01a178830202223b4852e6cb6a",
"label": "portal 0.1.1 배포",
"status": "done",
"ended": "2026-10-26T14:00:17.401Z"
}
]
### 서버·Agent
[
{
"id": "srv_01a178829d5848f93a15f93a17",
"name": "srv1953374 (ariweb.net)",
"role": "control",
"status": "ready",
"agent": "ag_01a178829f1c3d88381d2c0c75",
"agentVersion": "0.1.1",
"lastSeen": "2026-10-26T14:33:13.381Z",
"modules": {
"webmaker": {
"target": "0.1.1",
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
},
"arimail": {
"target": "0.1.1",
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
},
"hal": {
"target": "0.1.1",
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
},
"agent": {
"target": "0.1.1",
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
},
"observability": {
"target": null,
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
},
"backup": {
"target": null,
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
},
"portal": {
"target": "0.1.1",
"actual": "0.1.1",
"drift": false,
"health": "ok",
"active": true,
"lastCheckAt": "2026-10-26T14:33:13.381Z",
"lastError": null
}
}
}
]
### 공개 DNS(참고)
hal.ariweb.net →
my.ariweb.net →
```
@@ -0,0 +1,71 @@
# webmaker.ariweb.net ↔ HAL 연동 점검·dry-run 기록 (2026-10-27)
작업 승인: 오즈. 실행: Claude Fable 5.1(세션 ariweb-suite-8a). 저장소 기준 main `ac072e6` 에서 도구 추가(`scripts/webmaker-inventory.mjs`, `test/webmaker-inventory.test.mjs`). 비밀값(해시·토큰·키·세션)은 어디에도 출력·기록하지 않았다.
## 1. 실제 호스트 (DNS·운영 위치, 개발 저장소로 추정하지 않음)
| 항목 | 실제 값 |
|---|---|
| `webmaker.ariweb.net` A | 187.53.133.173 = hostinger(`srv1953374`, Ubuntu 24.04.4, Node v22.23.2, nginx 1.24.0) |
| 서비스 | systemd `webmaker.service`(User master, ExecStart `node server.js`, 127.0.0.1:3080) + `webmaker-setup.service` |
| 코드 | `/srv/webmaker/current → /srv/webmaker/releases/0.1.1`(프로세스 cwd 동일), `REVISION.json` = 모듈 0.1.1 / 상류 1.7.3, HAL 작업으로 2026-10-26 14:00:21Z 설치 — 즉 **이미 Ariweb Suite Webmaker(도입 모듈)** 이며 교체 대상이 아니다 |
| 데이터 | `DATA_ROOT=/data/webmaker`(0700, 691M) · `DATA_DIR=/data/webmaker/sites` · `SITES_ROOT=/var/www/webmaker-sites`(969M) · `STATS_LOG=/var/log/nginx/webmaker-sites.log` · 설정 `/data/webmaker/config.json`(값은 보관소) |
| 저장 구조 | JSON 파일(원자적 저장 + `.bak`): `users.json`(회원·scrypt 해시) · `sites/<id>.json`(프로젝트·배포 이력·git 변경) · `domains.json` · `deploy-targets.json` · `ariweb-links.json`(포털 연결 등록부) · `mail-servers.json` · `credits.json` 등. 로컬 git `/data/webmaker/git/<id>`(7개) → Gitea `127.0.0.1:3030`(hostinger 로컬, 저장소 `webmaker/sites`, 브랜치 `sites/<id>/main`) |
| HAL | hostinger 자체 HAL(`ariweb-hal`, 127.0.0.1:8900, nginx `hal.ariweb.net` — 공개 DNS 없음) 부트스트랩 완료, 서버 `srv_01a178829d5848f93a15f93a17`(control, ariweb.net) + Agent `ag_01a178829f1c3d88381d2c0c75` 활성, `module_targets.webmaker` 0.1.1/0.1.1 health ok |
| bear HAL(`hal21.ariweb.net`) | 운영자 1·서버 0·Agent 0·부트스트랩 미완료. 운영자 로그인 정보는 이 세션에 없다(피어 세션 ariweb-suite-36 도 만들지 않았다고 답함) |
## 2. inventory (원본, 안정 식별자)
| 자원 | 수 | 식별자·비고 |
|---|---|---|
| Webmaker 회원 | 12 | 고유 9(`oz-admin`, `admin`, `guest01`, `icqmomo`, `keytron`, `thewings`, `mingks01`, `visionplus`, `yunmi7266`) + 포털 SSO 3(`aw-01a17894-01a17894`, `aw-01a17895-01a17895`, `aw-01a1789f-01a1789f`) |
| 프로젝트(사이트) | 10 | 32-hex id. 배포 이력 있음 3(`291da329…` sample.ariweb.net 14회, `41ca22fd…` guide.ariweb.net 3회, `572a1d94…` hs-company 1회), 로컬 git 7, 포털 연결 3(검증 사이트) |
| 도메인(Webmaker 등록부) | 2 | sample.ariweb.net, guide.ariweb.net(모두 icqmomo, active). `hs-company.ariweb.net` 은 프로젝트 도메인 목록에만 있고 DNS 는 bear(121.152.149.45)를 가리킴 |
| 배포 대상 | 1 | `a7ae448c0593` 기본서버-자체호스팅(local, `/var/www/webmaker-sites`, `https://webmaker.ariweb.net`) |
| 메일 서버 | 0 | `mail-servers.json` items 비어 있음(메일 연동은 `config.mail.provider=arimail`, `https://webmail.ariweb.net`) |
| Gitea refs | 13 heads | `sites/<id>/main` 7개 + `changes/*` 5개 + `main` |
| 사이트 디렉터리 | 10 | 파일 수: 291da 13 · 41ca 16 · 4cbe 61 · 572a 31,780(961M) · 7e10 8 · b354 4 · cf7c 4 · 검증 3개 0 |
| nginx | 3 | `webmaker.ariweb.net.conf`, `wh-291da…conf`, `wh-41ca…conf` (+`wh-default.conf`) |
| 포털 DB | users 7 · groups 10 · sites 3 · domains 6 · mail_services 4 · deployments 0 | 사이트 3개 모두 `webmaker_project_id` 로 검증 프로젝트에 연결 |
## 3. 사전 스냅샷 (hostinger, 사설, 비밀 미출력)
위치 `/srv/webmaker/backups/pre-hal-link-20261027T045715Z/`(master 0700, 파일 0600). 내용: `data-webmaker.tar.gz`(708,460,310B) · `sites-root.tar.gz`(76,467,803B) · `checksums-source.txt`(원본 32,608 파일 sha256) · `conf/`(webmaker·webmaker-setup 유닛, nginx 3+1 파일, sites-enabled 목록, 유닛 상태) · `gitea-refs.txt`(13) · `local-git-heads.txt` · `hal.sqlite`·`portal.sqlite`(VACUUM INTO 일관 사본) · `MANIFEST.sha256` · `ROLLBACK.md` · `dry-run.json`.
MANIFEST.sha256 자체 sha256 앞 16자리 `b3d7d97e66ace583`, data tar `29d13e5846871a63`, sites tar `8690f9de6e1246fd`, checksums `8d01acf5721ac7ec`.
복원 가능성 검증: 두 tar 를 임시 디렉터리에 풀어 `checksums-source.txt` 32,608건 전부 대조 → `RESTORE_TEST_OK 32608 files`(04:58:32Z), 임시 디렉터리 삭제. 되돌리기 절차는 `ROLLBACK.md`(정지 → 현재 보존 → tar 복원 → 설정 복원 → 기동 → 체크섬·refs 대조).
## 4. 연동 경로 조사 (코드·테스트)
- HAL 은 사이트·계정을 모델링하지 않는다(테이블: servers·agents·modules·module_targets·releases·jobs…). Webmaker 의 "HAL 리소스" 는 모듈 등록(`module_targets`)뿐이며 이미 등록·건강 ok.
- 포털 ↔ Webmaker 연결은 양쪽 등록부(`ariweb-links.json` ↔ `sites.webmaker_project_id`)로만 이뤄지고, 기존 프로젝트 연결은 포털 사용자 세션의 `사이트 생성(projectMode=existing)` 경로뿐이다(`portal/sites/service.mjs`). Webmaker 내부 API `/api/internal/ariweb/*` 는 포털 서비스 서명 필요. "콘텐츠 가져오기" 는 이번 버전 미지원(`portal/sites/ui.mjs`).
- 비밀 입력 없이 쓸 수 있는 경로: 읽기 전용 inventory/dry-run(`scripts/webmaker-inventory.mjs`)뿐. 고유 회원 9명을 포털 계정으로 만드는 일은 사용자 자격 증명·동의가 필요해 하지 않았다(해시·세션 재사용 금지).
## 5. dry-run 결과 (hostinger 실데이터, 읽기 전용, `dry-run.json` sha256 `e94116e71a5c1a81…`)
| 판정 | 수 |
|---|---|
| linked_consistent(양쪽 등록부 일치) | 3 (`85e1859a…`→`s_01a1789f…`, `899f5bbe…`→`s_01a17895…`, `ad015867…`→`s_01a17894…`) |
| unlinked_native(Webmaker 고유, 변경 대상 아님) | 7 |
| 충돌(link_mismatch·duplicate·한쪽만·SSO 신원 누락·도메인 불일치·HAL 버전/건강) | **0** |
| 누락(missing_project·Gitea ref·HAL 등록) | **0** (Gitea `sites/<id>/main` 7/7, 마지막 푸시 커밋 일치) |
| SSO 회원 ↔ 포털 그룹·사용자 | 3/3 일치, 고유 회원 9 = 포털 신원 없음(keep) |
| HAL 모듈 | registered, server `srv_01a178829d…`, target 0.1.1 = actual 0.1.1 = 설치 0.1.1(상류 1.7.3), health ok, Agent online |
| applyPlan | **no-op — 적용할 변경 없음** |
## 6. 적용 (5단계)
적용한 변경 **없음**. 교체 불필요(이미 Ariweb Suite Webmaker 모듈 0.1.1). 운영 Webmaker 의 회원·프로젝트·도메인·배포·메일 연결·Gitea·설정·세션은 바이트 단위로 보존(아래 7).
## 7. 사후 검증 (05:01~05:03Z, 모두 readback)
- 콘텐츠 체크섬: `/data/webmaker` + `/var/www/webmaker-sites` 32,608 파일 `sha256sum -c` 불일치 0.
- git fsck 로컬 7개 저장소 오류 0 · Gitea `ls-remote --heads` 13건 스냅샷과 동일.
- `webmaker.service` active, MainPID 1041361, NRestarts 0, ActiveEnter 2026-10-26 14:00:23Z(스냅샷 전후 동일), 최근 2시간 err 저널 0. ariweb-hal/portal/agent, arihakdang-mail, nginx, webmaker-setup 모두 active·재시작 0.
- nginx `-t` 성공, 유닛·vhost 4개 sha256 스냅샷과 동일. TLS: webmaker.ariweb.net CN 전용(2027-01-12), sample/guide `*.ariweb.net`(2027-01-13).
- 외부 HTTP: `https://webmaker.ariweb.net/` 302→`/login`(200, 제목 "로그인 — AI Webmaker" — 화면·세션 호환, 자격 증명 미사용) · sample.ariweb.net `/`,`/en/`,`/ja/` 200 · guide.ariweb.net `/`,`/en/` 200 · webmaker.ariweb.net 미리보기 경로 3개(`/291da…/`, `/41ca…/`, `/572a…/`) 200.
- HAL readback: `module_targets.webmaker` 0.1.1/0.1.1 health ok last_check 05:01:44Z. `session.secret`·`users.json` mtime 변동 없음.
## 8. 미해결 blocker (사람 결정 필요)
1. bear HAL(`hal21.ariweb.net`)에 hostinger 를 서버로 등록하거나 Agent 를 재등록하려면 hal21 운영자 로그인(등록 토큰 발급)이 필요하다 — 이 세션에는 자격 증명이 없고, 운영자 1건은 누가 만들었는지 확인되지 않았다. 또한 hostinger Agent 는 hostinger HAL 에 등록돼 있어 제어 평면을 hal21 로 옮기는 것은 별도 결정·절차(§34 "하나의 HAL")가 필요하다.
2. Webmaker 고유 회원 9명·고유 프로젝트 7개를 포털 자원(계정·그룹·사이트)으로 옮기는 기능은 없고, 만들려면 각 사용자의 자격 증명·동의가 필요하다.
@@ -0,0 +1,79 @@
# webmaker.ariweb.net → HAL21 운영 연동 검증
**작성자:** Neo
**작성일:** 2026-10-27 UTC
**목적:** 운영 Webmaker의 데이터·계정·배포 사이트를 변경하지 않고 `hal21.ariweb.net`의 HAL 제어 평면에 연결한 결과와 롤백 근거를 기록한다.
**요약:** 운영 Webmaker는 이미 Ariweb Suite Webmaker 0.1.1(상류 1.7.3)이므로 애플리케이션 교체는 하지 않았다. 검증된 사전 백업을 유지한 채 Node Agent만 HAL21로 재등록했고, HAL21은 프로젝트 10개를 누락·절단·오류 없이 읽었다. 회원 12명(고유 9, SSO 3), 데이터 32,608개 파일, Gitea ref 13개와 서비스 프로세스는 보존됐다. 비밀값은 출력·문서화하지 않았다.
## 개정 이력
- v1.0 — 2026-10-27 — HAL21 연동, 인벤토리, 무결성, 회귀 및 롤백 검증 기록.
## 1. 범위와 결정
- 대상: `webmaker.ariweb.net` / `srv1953374` (`187.53.133.173`).
- 제어 평면: `https://hal21.ariweb.net` / bear.
- Webmaker 실행 버전은 이미 Ariweb Suite 모듈 `0.1.1`, 상류 Webmaker `1.7.3`이었다. 동일 코드를 다시 덮어쓰면 위험만 늘어나므로 Webmaker 코드·DB·데이터는 교체하지 않았다.
- 기존 Webmaker 고유 계정의 비밀번호 해시나 세션을 Portal 계정으로 복제하지 않았다. 고유 회원 9명은 Webmaker 인증 정본으로 유지하고, 기존 SSO 회원 3명과 Portal 연결 3건도 유지했다.
- 기존 hostinger HAL에 등록돼 있던 Node Agent를 HAL21로 재등록했다. Webmaker Data Plane은 작업 중 계속 실행됐다.
## 2. 배포 정본
- AriWeb Suite main: `12958ab6952e78159f4de22771ddf46a51441a34`.
- HAL artifact SHA-256: `01c70d098d614fbd38195bde21c383c1dcaefc2891a4b907d0d268fc08f8f28f`.
- HAL 설치: `/srv/ariweb/hal/releases/0.1.1-12958ab6952e` → `/srv/ariweb/hal/current`.
- Agent 설치: `/srv/ariweb/agent/releases/0.1.1-12958ab6952e` → `/srv/ariweb/agent/current`.
- HAL 백업: `bear:/srv/ariweb/backups/hal-pre-12958ab-20261027T090222Z`.
- Agent 전환 백업: `srv1953374:/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`.
- Webmaker 사전 백업·복원 시험: `/srv/webmaker/backups/pre-hal-link-20261027T045715Z` (`RESTORE_TEST_OK 32608 files`).
## 3. HAL21 연결
- HAL21 서버: `srv_01a17bd4dbcdb55877f2d9fcab`, 상태 `ready`.
- HAL21 Agent: `ag_01a17c99f0102ce24409dc258a`, 상태 active.
- Agent의 Webmaker 경로는 systemd drop-in으로 명시했다. 경로를 추측하거나 기존 Webmaker unit을 재작성하지 않았다.
- `DATA_ROOT=/data/webmaker`
- `DATA_DIR=/data/webmaker/sites`
- `SITES_ROOT=/var/www/webmaker-sites`
- HAL21 모듈 readback: Webmaker actual `0.1.1`, active, health `ok`, drift 없음.
- HAL21 인벤토리 readback: total `10`, returned `10`, truncated `false`, errors `[]`.
- 인벤토리에 포함된 도메인: `sample.ariweb.net`, `guide.ariweb.net`, `hs-company.ariweb.net`.
- 사이트 본문, 생성 프롬프트, 연락처, 비밀번호·해시·토큰·키·세션은 HAL로 보내지 않는다.
## 4. 데이터 보존 검증
- `/data/webmaker`와 `/var/www/webmaker-sites` 사전 체크섬 32,608건 재검증: exit 0, 불일치 0.
- 로컬 Git 저장소 7개: `git fsck` 성공 7, 실패 0.
- Gitea heads: 사전 스냅샷과 동일한 13개 ref.
- Webmaker 프로세스는 재시작하지 않았다.
- MainPID `1041361`
- NRestarts `0`
- ActiveEnter `2026-10-26 14:00:23 UTC`
- Webmaker 회원 12명(고유 9, SSO 3), 프로젝트 10개, Portal 연결 3개, 배포 이력 및 도메인 등록부는 변경하지 않았다.
## 5. 회귀 검증
- `webmaker.service`, `ariweb-agent`, `nginx`, `ariweb-hal`: active.
- nginx 설정 검사: 성공. 기존 `server_name`/protocol 중복 경고는 남아 있으나 이번 작업에서 nginx 파일을 변경하지 않았다.
- 외부 HTTP:
- `https://webmaker.ariweb.net/` → 로그인 화면 HTTP 200.
- `https://sample.ariweb.net/` → HTTP 200.
- `https://guide.ariweb.net/` → HTTP 200.
- Webmaker의 `572a1d…` 미리보기 경로 → HTTP 200.
- `https://hal21.ariweb.net/api/health` → HTTP 200.
- `https://hal21.ariweb.net/ops/` → HTTP 200.
- 알려진 기존 상태: `https://hs-company.ariweb.net/`은 공개 TLS SNI 오류가 있으며 이번 HAL 연결 작업으로 새로 생긴 문제가 아니다. 프로젝트와 파일은 Webmaker 인벤토리 및 미리보기에서 보존됨을 확인했다.
## 6. 롤백
1. hostinger에서 `ariweb-agent`를 중지한다.
2. `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z/data`를 `/srv/ariweb/agent/data`로 복원한다.
3. `/srv/ariweb/agent/current`를 백업의 `current-target.txt` 값으로 되돌리고 `90-hal21-webmaker.conf`를 제거한다.
4. daemon-reload 후 `ariweb-agent`를 시작하면 기존 hostinger HAL 등록으로 복귀한다.
5. HAL21의 신규 Agent는 폐기 후 revoke하고, 서버 상태를 점검한다.
6. HAL 자체 롤백이 필요하면 bear의 HAL 백업에서 data를 복원하고 `current-target.txt`의 이전 release로 되돌린 뒤 `ariweb-hal`을 기동한다.
Webmaker Data Plane은 HAL과 분리돼 있으므로 위 롤백 중에도 기존 공개 사이트와 Webmaker 자체 인증 데이터는 그대로 유지된다.
@@ -0,0 +1,186 @@
# Webmaker 1.8.0 직접 편집(시각 편집기) 도입 — Ariweb Suite 0.2.0 TDD RED→GREEN 기록
- 작업 트리: `/home/master/work/Ariweb-suite-visual-editor-v020`, 브랜치 `feature/visual-editor-v0.2.0`, 기준 `origin/main 4b3714e37c543fcfa8862dbc68c76f62a9b0bf97`
- 참조 저장소 작업 트리: `/home/master/work/WebHoting_AI_Agent-visual-editor-v180`(`feature/visual-editor-v1.8.0`, 기준 `3b10859`) — 그쪽 RED→GREEN 은 `modules/webmaker/../docs/verification/visual-editor-1.8.0-tdd.md`(참조 저장소 문서)에 있다.
- 날짜: 2026-10-28 · Node v24.22.1 · 커밋·push·병합·배포·운영 접근 없음(Neo 검토 전 인도). 출력은 실제 실행 결과를 그대로 옮겼다(스택 줄만 줄임).
- 서버를 띄우는 테스트는 모두 `CLAUDE_BIN` 을 즉시 실패하는 가짜 실행 파일로 두고 실행했다(사이트 생성이 AI 작업을 시작하므로 개발 PC 의 진짜 claude 가 실행되지 않게). 첫 Suite 테스트 실행에서는 그 설정이 빠져 진짜 claude 프로세스가 하나 시작되어 즉시 종료시켰고, 테스트를 고쳤다.
## RED — `node --no-warnings test/webmaker-visual-editor.test.mjs` (도입 전: 모듈·화면·경로 없음)
```
✘ 도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다
Error: lib/visual-editor.js (실제 false)
✘ lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다
Error: Cannot find module '/home/master/work/Ariweb-suite-visual-editor-v020/modules/webmaker/lib/visual-editor.js'
✘ visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다
Error: Cannot find module '/home/master/work/Ariweb-suite-visual-editor-v020/modules/webmaker/public/visual-editor-core.js'
✘ 화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다
Error: 직접 편집 전환 버튼 (실제 false)
✘ server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버)
Error: 기대 200 실제 404
✘ 통과 0 · 실패 5
```
## 도입 작업
1. 참조 저장소에서 1.8.0 을 TDD 로 구현(위 참조 문서). 새 파일 6개는 `cp` 로 그대로 가져옴(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`, `test/visual-editor.test.js`, `test/visual-editor-server.test.js`), `public/admin.js` 는 Suite 변경이 없어 upstream 그대로.
2. Suite 블록(`[Ariweb Suite 변경]`)이 있는 `server.js`·`public/app.js`·`public/index.html`·`public/style.css` 는 **같은 변경을 손으로 적용**(덮어쓰지 않음) + `server.js` 직접 편집 PUT 에 `revConflict` 추가. `diff` 로 upstream 과의 차이를 확인: server.js 107줄(모두 0.1.1 Suite 블록 + rev 검사 1줄), app.js 4줄, index.html 2줄, style.css 5줄(배너) — 1.8.0 공통 변경은 양쪽 동일.
3. 중간 실행 1 — 4/5: rev 충돌 테스트가 "AI 작업 실행 중" 409 를 받음(진짜 claude 가 생성 작업으로 실행 중) → 테스트에 가짜 `CLAUDE_BIN`·작업 종료 대기 추가.
4. `WEBMAKER_SRC=<참조 작업 트리> node scripts/import-sources.mjs --only webmaker --allow-dirty` 로 `IMPORTED_SOURCES.json/.md` 재생성(처음 182 파일 → 최종 184 파일: 0.1.1 의 176 + 신규 8, 수정 8 = 0.1.1 과 같은 Suite 블록 파일, upstream 1.8.0·workingTreeDirty).
## GREEN — `node --no-warnings test/webmaker-visual-editor.test.mjs`
```
✔ 도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.2.0 이다 (1ms)
✔ lib/visual-editor: 캔버스 변환은 스크립트를 실행하지 않게 하고, 저장 검증은 새 스크립트·핸들러를 거절한다 (13ms)
✔ visual-editor-core: 실행 취소/다시 실행·저장 상태·메시지 검증·구성 요소가 Suite 체크아웃에서도 같다 (2ms)
✔ 화면(index.html)에 직접 편집 전환·저장·실행 취소·다시 실행·버리기·팔레트·속성 조작이 접근성 속성과 함께 있고 Suite 배너는 남아 있다 (3ms)
✔ server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버) (1323ms)
✔ 통과 5 · 실패 0
```
## 전체 묶음
- `modules/webmaker`: `CLAUDE_BIN=<가짜 exit 1> npm test` → `ℹ tests 81 · pass 81 · fail 0` (0.1.1 의 59 + 새 22)
- Suite: `CLAUDE_BIN=<가짜 exit 1> node test/run.mjs` → **`✔ 테스트 파일 23개 중 실패 0개`**(agent 9·auth 7·backup 8·commerce 11·dashboard 4·domains 3·e2e 11·groups 5·hal-cli 3·hal-mailimport 7·hal-ui 7·hal 16·lib 10·lifecycle 6·mail-discover 2·mail 9·relocation 6·sites 11·transfer 4·usage 4·webmaker-adapter 2·webmaker-inventory 5·webmaker-visual-editor 5). 로컬 E2E 하네스가 `docs/verification/local-e2e-2026-10-28.md/.json` 을 새로 썼다(결함 0).
## 요구 ↔ Suite 테스트 대응
| 요구 | 테스트 |
|---|---|
| 도입 파일·버전(1.8.0/0.2.0)·IMPORTED_SOURCES 불변 기준 커밋·upstream 1.8.0 | 도입 모듈 파일·버전 |
| 스크립트 비실행 변환·저장 검증(새 스크립트 거절·머리 보존) | lib/visual-editor |
| 실행 취소/다시 실행·저장 상태·메시지 규약(세션 불일치 거절)·구성 요소 안전 | visual-editor-core |
| 필수 접근성 조작 + Suite 배너·SSO 변경 보존 | 화면(index.html) |
| Suite 전용 rev 충돌 409 → 맞는 rev 저장 200·버전 visual-edit·rev 증가(실제 서버) | server.js [Ariweb Suite 변경] |
| 권한·CSRF·허용 페이지·오래된 저장·AI 작업 중·캐시 미차감 | `modules/webmaker/test/visual-editor-server.test.js`(81개 묶음 안) |
## 검토 수정(Neo 독립 검토, 2026-10-28) — 동시 저장 경쟁(출시 차단) · 원자적 저장(안전 강화)
참조 저장소에서 RED→GREEN 으로 고친 뒤(참조 문서 "검토 수정" 절: 경쟁 테스트 actual 200/expected 409, 원자성 테스트 actual 200/expected 500, helper 없음 TypeError → 모두 GREEN) Suite 에 반영했다.
- 그대로 가져옴(`cp`, diff 동일): `modules/webmaker/lib/visual-editor.js`(`writeTextAtomic` 추가), `test/visual-editor.test.js`(16건), `test/visual-editor-server.test.js`(9건 — 경쟁·원자성 추가).
- 손으로 반영: `modules/webmaker/server.js` 직접 편집 PUT — 본문을 읽은 뒤 `store.load` 로 사이트 기록과 파일을 다시 읽고(**Suite 의 `revConflict` 도 다시 읽은 최신 rev 로 비교**), hash 검사·검증·`writeTextAtomic` 까지 await 없이 이어 간다. 쓰기 실패 500 한국어 안내. upstream 과의 차이는 여전히 Suite 블록만(107줄).
- Suite 전용 추가 테스트는 두지 않았다: 공유 테스트 파일(`modules/webmaker/test/visual-editor-server.test.js`)이 Suite 모듈 서버로도 실행되어 경쟁·원자성을 검증하고, `test/webmaker-visual-editor.test.mjs` 의 rev 충돌 테스트가 다시 읽은 rev 경로를 그대로 지나기 때문이다.
GREEN(Suite 체크아웃)
```
cd modules/webmaker && node --test test/visual-editor-server.test.js test/visual-editor.test.js
ℹ tests 25
ℹ pass 25
ℹ fail 0
node --no-warnings test/webmaker-visual-editor.test.mjs
✔ server.js [Ariweb Suite 변경]: 직접 편집 저장도 동시 편집 rev 충돌(409)을 돌려주고, 맞는 rev 면 저장된다 (실제 서버) (1213ms)
✔ 통과 5 · 실패 0
```
전체 묶음(수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`):
- 참조 저장소 `npm test` → `ℹ tests 77 · pass 77 · fail 0`
- Suite `modules/webmaker` `npm test` → `ℹ tests 84 · pass 84 · fail 0`
- Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함, 로컬 E2E 가 `local-e2e-2026-10-28.md/.json` 을 다시 씀, 결함 0)
## 실제 브라우저 시각 검증 수정(Neo 의 브라우저 게이트, 2026-10-28) — 직접 편집 중 배치(출시 차단)
- 발견: 독립 Webmaker 코드의 실제 브라우저 검증(1280×633·390×844)에서 직접 편집을 켜도 옛 작업 칸(`.left` 460px)이 남아 캔버스 iframe 이 114.6px, 390px 에서는 캔버스가 y≈2228 로 밀림. 원인은 `body.ve-on` 의 CSS 배치 규칙 부재(JS 전환은 정상).
- 참조 저장소에서 RED(`test/visual-editor.test.js` +3: CSS 계약 2건 실패 `직접 편집 중 옛 작업 칸 숨김`·`좁은 화면 body.ve-on .app 규칙`, JS 전환 동작 1건은 통과 — 진단 확인) → `public/style.css` 수정 → GREEN 19/19. Suite 에 **동일하게** 반영: `modules/webmaker/public/style.css` 같은 블록을 손으로 추가(upstream 과의 차이는 여전히 배너 5줄), 테스트 파일은 `cp`(diff 동일). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 19 · pass 19 · fail 0`.
- 전체 묶음(배치 수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 80 · pass 80 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 87 · pass 87 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 이번 작업의 산출물이 아니라 보존하지 않았다.
- 문법·차이 확인: `node --check public/visual-editor-ui.js`(두 저장소) 통과, `style.css` upstream 대비 차이 5줄(포털 배너)만, `test/visual-editor.test.js` 두 저장소 동일.
## 실제 브라우저 시각 검증 수정 2(배치 수정 뒤 게이트, 2026-10-28) — 선택 전 도구 줄 노출(출시 차단)
- 발견: 직접 편집을 켜고 아무 요소도 고르지 않았는데 어두운 선택 도구 줄 `.wm-ve-tools` 가 페이지 위에 이미 그려짐(DOM 은 `tools.hidden = true`, 속성 창은 "선택한 요소 없음"). 원인은 캔버스 런타임(`wm-ve-canvas.js`)의 작성자 규칙 `.wm-ve-tools{…display:flex…}` 가 UA 규칙 `[hidden]{display:none}` 을 덮어쓰는 것.
- 참조 저장소에서 RED(`test/visual-editor.test.js` +1: 런타임 소스의 CSS 블록을 파싱해 `hidden` 인 `.wm-ve-tools`·`.wm-ve-drop` 의 최종 display 를 cascade 모형으로 계산 — `AssertionError: hidden 인 .wm-ve-tools 의 최종 display`, actual 'flex' / expected 'none', 20건 중 19 통과 1 실패) → `public/wm-ve-canvas.js` 런타임 CSS 에 한 줄 추가 `.wm-ve-tools[hidden],.wm-ve-drop[hidden]{display:none !important}` → GREEN 20/20. Suite 에는 `modules/webmaker/public/wm-ve-canvas.js`·`test/visual-editor.test.js` 를 `cp`(두 파일 모두 upstream 과 byte-identical, `cmp` 확인). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 20 · pass 20 · fail 0`. 다른 동작 변경 없음(JS 의 hidden 전환·보일 때 flex 배치 그대로, 테스트가 함께 확인).
- 전체 묶음(도구 줄 수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 81 · pass 81 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 88 · pass 88 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 보존하지 않았다.
- 문법·차이 확인: `node --check public/wm-ve-canvas.js`(두 저장소) 통과, `wm-ve-canvas.js`·`test/visual-editor.test.js` 두 저장소 동일, `style.css` upstream 대비 차이는 여전히 배너 5줄.
## 실제 브라우저 게이트 3(Playwright 자동 게이트, 2026-10-28) — 팔레트 → 캔버스 끌어다 놓기 실패(출시 차단)
- 발견: 실제 Chrome 에서 frameLocator 로 캔버스 iframe 을 직접 조작한 게이트가 선택·contenteditable 글 편집·실행 취소/다시 실행·Alt+↑/↓ 이동·팔레트 표시(19/19)를 통과시킨 뒤, 부모 팔레트 "문단" 의 실제 HTML5 끌어다 놓기에서 실패(`끌어다 놓기 뒤 요소 수 증가` 없음). 원인: 부모가 전용 MIME `text/wm-ve-component` 만 넣는데 캔버스는 `sandbox="allow-scripts"`(allow-same-origin 없음) 출처 'null' 문서라 Chromium 이 다른 출처의 전용 MIME 끌기 자료를 넘겨주지 않음. 제품 결함.
- 참조 저장소에서 RED(`test/visual-editor.test.js` +3 — 순수 추출 규칙 `core.dragComponentId` 미존재, 팔레트 dragstart 에 text/plain 없음, **동작 테스트**: 가짜 DOM 위에서 실제 `wm-ve-canvas.js` 를 부트하고 전용 MIME 이 없는 DataTransfer 로 dragover/drop → `dragover preventDefault` actual false; 23건 중 20 통과 3 실패) → 수정 → GREEN 23/23. 수정: `public/visual-editor-core.js` 에 `COMPONENT_MIME`·`componentDragText`·`isComponentDrag`·`dragComponentId`(전용 MIME 우선, 없으면 `wm-ve-component:<id>` 정확 일치 + 알려진 id 만), `public/visual-editor-ui.js` dragstart 가 text/plain 도 넣음, `public/wm-ve-canvas.js` dragover/drop 이 종류로 인식하고 drop 때 검증된 id 만 삽입(아니면 삽입·표식 없음, 기본 drop 차단). sandbox·CSP·메시지 검증·저장 검증·"추가" 버튼 대안 불변.
- Suite 에는 `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor.test.js` 를 `cp`(네 파일 모두 upstream 과 byte-identical, `cmp` 확인). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 23 · pass 23 · fail 0`.
- 전체 묶음(끌어다 놓기 수정 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 84 · pass 84 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 91 · pass 91 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 보존하지 않았다.
- 문법·차이 확인: `node --check` 세 런타임 파일(두 저장소) 통과, 공유 파일 두 저장소 동일, `style.css` upstream 대비 차이는 여전히 배너 5줄, `server.js`·`app.js`·`index.html` 의 Suite 블록 차이 불변.
## 실제 브라우저 게이트 4(살균 DnD 진단, 2026-10-28) — HTML5 끌기가 sandbox iframe 으로 넘어가지 않음 → 포인터 끌기 다리(출시 차단)
- 발견: MIME 보완 뒤 같은 게이트 재실행도 끌어다 놓기 단계에서 실패. 부모·캔버스 양쪽 살균 리스너(종류·플래그만) 진단: 부모 dragstart 2건(text/plain·전용 MIME 모두 유효), **캔버스 dragenter·dragover·drop 0건**. Chromium 은 HTML5 끌기 자체를 부모 → 출처 'null' sandbox iframe 으로 넘기지 않는다. 제품 결함.
- 참조 저장소에서 RED(`test/visual-editor.test.js` +3 — 규약·좌표 순수 규칙 `paletteDragAt 는 부모→캔버스 종류`, 부모 포인터 순서 동작 `pointerdown/move/up 처리기`, 캔버스 수신 동작 `놓기 표시 보임`; 26건 중 23 통과 3 실패) → 수정 → GREEN 26/26. 수정(sandbox 그대로, allow-same-origin 없음): `public/visual-editor-core.js` 에 toCanvas 종류 `paletteDragAt`·`paletteDragCancel`·`paletteDropAt` + `isComponentId`·`POINTER_DRAG_THRESHOLD`(4px)·`pointInFrame`(테두리·스크롤바 보정 상대 좌표)·`canvasPoint`(유한 숫자·뷰포트 안); `public/visual-editor-ui.js` 팔레트 항목 포인터 다리(pointerdown — "추가" 버튼·터치·보조 버튼 제외 → 포인터 캡처 → 문턱 → iframe 안에서만 상대 좌표 메시지, 밖·Escape·pointercancel·끝내기 → 취소, 포인터 끌기 중 네이티브 dragstart 차단, HTML5 DnD 는 대체 경로); `public/wm-ve-canvas.js` 가 메시지 규약 뒤 id·좌표·모드를 다시 검사해 스스로 `dropTargetAt` → 놓기 표시 / `insertComponent`(잘못된 입력은 삽입·표식 없이 표시만 숨김); `public/style.css` 에 `.ve-comp { user-select:none; touch-action:pan-y }`·끌기 중 class 2줄.
- Suite 에는 `modules/webmaker/public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor.test.js` 를 `cp`(네 파일 모두 upstream 과 byte-identical, `cmp` 확인), `modules/webmaker/public/style.css` 에는 같은 2줄을 손으로 추가(upstream 대비 차이 여전히 배너 5줄). Suite 모듈에서 `node --test test/visual-editor.test.js` → `ℹ tests 26 · pass 26 · fail 0`.
- 전체 묶음(포인터 다리 뒤, 모두 `CLAUDE_BIN=<가짜 exit 1>`): 참조 저장소 `npm test` → `ℹ tests 87 · pass 87 · fail 0` · Suite `modules/webmaker` `npm test` → `ℹ tests 94 · pass 94 · fail 0` · Suite `node test/run.mjs` → `✔ 테스트 파일 23개 중 실패 0개`(webmaker-visual-editor 5/5 포함). 하네스가 다시 쓴 `local-e2e-2026-10-28.*` 보고서 파일은 보존하지 않았다.
- 문법·차이 확인: `node --check` 세 런타임 파일(두 저장소) 통과, 공유 파일 두 저장소 동일, `server.js`·`app.js`·`index.html` 의 Suite 블록 차이 불변.
## 독립 검토(읽기 전용) 차단 항목 12건 수정(2026-10-28) — Suite 쪽 기록
참조 저장소 쪽 상세(항목 1~6·8~10 의 결함·수정·RED 증거 표)는 `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(upstream 과 동일). 여기에는 Suite 반영과 Suite 전용 항목(7·11)을 적는다.
- **공유 파일 반영**: `lib/visual-editor.js`·`lib/gitstore.js`·`public/visual-editor-core.js`·`visual-editor-ui.js`·`wm-ve-canvas.js`·`test/visual-editor.test.js`·`test/visual-editor-server.test.js`·새 `test/visual-editor-app.test.js` 를 `cp`(모두 upstream 과 byte-identical, `cmp`). Suite 블록이 있는 `server.js`·`public/app.js`·`lib/store.js` 는 참조 저장소의 수정 전/후 diff 를 `patch` 로 적용하고(server.js 의 소스/직접 편집 PUT 두 hunk 는 Suite rev 줄 때문에 손으로 반영) Suite 블록을 보존했다. upstream 대비 차이: `server.js` 115줄(107 → 115: 항목 7 의 rev 필수 2줄·잠금 안으로 옮긴 `revConflict` 줄·주석), `app.js` 4줄·`style.css` 5줄·`index.html` 2줄 불변, `lib/store.js` 4줄(rev 증가 + restore 때 rev 보존).
- **항목 7(Suite 전용) — 직접 편집 저장은 유한한 숫자 `rev` 필수**: `PUT /api/sites/<id>/visual/<페이지>` 는 `typeof rev === 'number' && Number.isFinite(rev)` 가 아니면 400 `{ revRequired: true }`(없음·문자열 "3"·null·"NaN"·""). 잠금 안에서 다시 읽은 사이트 기록의 rev 와 다르면(다른 파일 저장으로 오른 rev 포함) 409 `conflict{ yourRev, currentRev }`. 소스 편집기 PUT 의 옛 화면 호환 규칙(rev 없으면 통과)은 이 경로에 적용하지 않는다(fail-closed). 공유 서버 테스트는 GET 이 준 rev 를 모든 PUT 에 함께 보낸다(단독 Webmaker 는 0 을 주고 무시) — 처음 Suite 에서 `npm test` 를 돌리자 rev 없는 PUT 8건이 400 으로 떨어져 이를 드러냈고, 뒤처진 저장의 409 는 Suite 에서 hash(stale) 가 아니라 rev(conflict) 로 먼저 잡히므로 테스트가 둘 중 하나를 받는다(`expectStale`).
- **항목 6 의 Suite 부분 — 되돌리기 저장은 rev 를 올리지 않는다**: `lib/store.js save(site, { restore: true })` 는 rev 를 그대로 둔다. 실패한 직접 편집 저장(사이트 기록 폴더 읽기 전용 → store 실패)은 500 뒤 페이지·rev 가 저장 전 값 그대로이고 같은 rev·baseHash 로 재시도하면 200.
- **항목 11(Suite 전용) — 도입 소스 출처 fail-closed**: 새 `lib/provenance.mjs checkProvenance(manifest)`: 소스마다 40자리 `commit`·비어 있지 않은 파일 목록(from·to·origHash), `upstream` 이 있으면 version·branch·headCommit(= commit)·`workingTreeDirty === false` 를 요구하고, 모르는 형태·빈 목록은 불완전으로 거절. `upstream` 칸이 없는 0.1.x 기록(AriMail)은 깨끗한 고정 커밋만 받던 스크립트가 만든 것이라 legacy 로 받아들인다. `scripts/release.mjs build` 는 검사를 테스트보다 먼저 하고 깨끗하지 않으면 artifact 를 만들지 않고 종료 1(이유 출력), manifest.json 에 `checks.provenanceClean`·`provenanceProblems`·`imported[].upstream/legacy` 를 남긴다. `register --approve` 는 `checks.provenanceClean !== true`(false·옛 빌드의 없음)면 DB 를 열기 전에 종료 1. `hal/core.approveRelease` 는 `checks.provenanceClean === false` 면 `release_checks_failed`. 테스트용 `ARIWEB_IMPORTED_SOURCES` 로 manifest 경로를 바꿀 수 있다. 개발 중 기록(`workingTreeDirty: true`, --allow-dirty)으로는 build 가 멈췄다(의도). 참조 저장소 1.8.0 이 `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088` 으로 커밋·검증된 뒤 `scripts/import-sources.mjs` 의 pin 을 그 커밋으로 올렸고, 그 pin 으로 `--allow-dirty` 없이 다시 만든 기록(`commit = headCommit = 4b8c4d0`, `workingTreeDirty:false`, 184 파일·수정 8)이면 build·approve 가 된다 — 이 작업 트리의 `IMPORTED_SOURCES` 파일은 아직 개발 중 기록이라 Neo 가 다시 만든다.
- **항목 12** — 이 문서·VERSION_HISTORY·upstream 문서의 수치를 최종값으로, 가져온 파일 수는 생성된 manifest(`IMPORTED_SOURCES.json`)에서 읽어 적었다(VERSION_HISTORY).
- **수정 뒤 브라우저 게이트 재실행 조사(2026-10-28)**: Neo 의 재실행이 `edit-text` 에서 "저장 상태 dirty" 시간 초과. 원인은 게이트 멱등성 결함(같은 저장 폴더에 이전 실행의 같은 제목이 이미 있어 변경 없음 = clean, 제품 규칙은 옳음)이며 로컬에서 결정적으로 재현·수정(실행마다 다른 제목 + 사전 단언). 같은 로컬 실행에서 실제 틈 하나(가운데 클릭 `auxclick` 이 sandbox 에서도 새 페이지를 만듦)를 찾아 `wm-ve-canvas.js` 에 auxclick 차단을 더했다(단위 RED→GREEN, upstream 과 byte-identical 로 반영). 로컬 Chrome 156 게이트 61/61(연속 2회), 이어서 **Neo 호스트의 실제 Chrome 재실행 61/61 통과**(2026-10-28). 상세: `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`.
## 독립 검토 2차(종결 검토) 차단 항목 3건 + 문서 수정(2026-10-28) — Suite 쪽 기록
참조 저장소 쪽 상세(항목 1·2 의 결함·수정·RED 증거)는 `modules/webmaker/docs/verification/visual-editor-1.8.0-tdd.md`(upstream 과 동일). 여기에는 Suite 반영과 Suite 전용 항목 3 을 적는다.
- **공유 파일 반영**: `lib/visual-editor.js`·`lib/gitstore.js`·`public/visual-editor-core.js`·`test/visual-editor.test.js`·`test/visual-editor-server.test.js`·`docs/verification/visual-editor-1.8.0-tdd.md`·`README.md` 를 `cp`(모두 upstream 과 byte-identical, `cmp`). `server.js` 는 참조 저장소의 수정 전/후 diff 를 `patch` 로 적용하되 직접 편집 PUT 의 트랜잭션 hunk 는 Suite rev 주석 때문에 손으로 반영(되돌리기의 `store.save(prev,{restore:true})` 줄에 `[Ariweb Suite 변경]` 주석 — rev 보존). upstream 대비 차이: `server.js` 115줄(변함없음), `app.js` 4줄·`style.css` 5줄·`index.html` 2줄·`lib/store.js` 4줄 불변. 도입 기록 재생성: 184 파일·수정 8·workingTreeDirty=true(의도).
- **항목 1 — SVG SMIL 저장형 XSS(공유 코드)**: Suite 는 같은 `lib/visual-editor.js` 를 쓰므로 그대로 적용된다 — 새 SMIL 요소(animate·set·animateMotion·animateTransform·animateColor·discard·mpath, 대소문자·`svg:` 접두어 포함)는 400, 원본에 있던 것은 그 수까지만(삭제 허용), 캔버스는 `wm-ve-x-` 비활성 이름. Suite 모듈 단위 테스트 38/38.
- **항목 2 — Git 저장 트랜잭션(공유 코드 + Suite rev)**: `gitstore.recordChangeStrict` 가 로컬 커밋 → 사이트 기록(Suite 는 여기서 rev +1 한 번) → 원격 `push --atomic`(main + 변경 브랜치) 순서로 끝내고, 실패하면 `reset --mixed`·브랜치 삭제/복원·페이지 원본·`store.save(prev,{restore:true})`(rev 보존) 뒤 검증한 결과로만 `rolledBack` 을 준다. 1차 수정 경로가 하던 두 번의 `store.save`(manualEdits 저장 + gitRecord 안 저장)가 한 번으로 줄어 rev 는 성공한 저장마다 정확히 1 오른다(기존 `test/webmaker-visual-editor.test.mjs` 의 `currentRev >= …` 검사 그대로 통과). 푸시 전에 저장되는 기록은 `pushed:false, remote:'pending'` 이고(원격 반영 미주장 — 훅이 푸시 순간에 복사한 기록으로 확인), 트랜잭션 밖 `reconcileSite`(잠금 안, 저장 뒤·기동 때)가 `ls-remote` 로 확인한 뒤에만 `verified` 로 바꾸며 그 저장은 `{restore:true}` 라 rev 를 올리지 않는다(재기동 조정 테스트가 `saved.rev === site.rev` 를 확인). 공유 서비스 테스트(bare 원격 + 훅 실패 주입, 첫 커밋 전 되감기, 기동 조정, 모호한 푸시 판정, 잠금 경쟁)가 Suite 모듈에서도 그대로 돈다(18/18).
- **항목 3(Suite 전용) — 릴리스 승인 fail-open**: `hal/core.approveRelease` 가 `checks.testsPassed === false`·`provenanceClean === false` 만 거절해 checks 가 없거나(옛 빌드·수동 등록) `testsPassed: null`(--skip-tests)·`dirty` 미기록인 릴리스를 승인했고, `registerRelease(approve:true)`·운영자 API `POST /ops/api/releases/:version/approve` 도 같은 함수를 지나 같은 구멍이 있었다. 새 `hal/core.releaseApprovalProblem(checks)` 한 규칙: `testsPassed === true`·`dirty === false`·`provenanceClean === true` 가 모두 있어야 하며(없음·null·문자열 "true"·1·"false" 모두 거절) 이유를 `release_checks_failed` 메시지로 돌려준다. `scripts/release.mjs register --approve` 도 같은 함수로 DB 를 열기 전에 종료 1(이유 출력). `--skip-tests` 빌드(testsPassed null)는 등록만 되고 승인은 불가. 기존 테스트 픽스처(hal-cli 2곳·hal 1곳 `registerRelease`, e2e `buildFakeRelease` manifest)는 깨끗한 checks 를 넣도록 고쳤다 — 현재 요구사항에 관용 정책이 없어 옛 정책은 남기지 않았다.
- **문서 수정** — 이 문서·VERSION_HISTORY·upstream 문서의 "재실행 필요/미실행"·"자동화하지 않음"·옛 수치(103/110·182 파일·HTML5 주 경로·release.mjs 변경 없음)를 현재 증거(Neo 호스트 실제 Chrome 61/61, 106/113/24, 184 파일·수정 8, 포인터 다리 주 경로 + HTML5 대체, release.mjs·hal/core 변경됨)로 고쳤다.
## 독립 검토 3차(최종 검토) 차단 항목 4건(2026-10-28) — Suite 쪽 기록
- **항목 1(Suite 전용) — 승인된 릴리스 불변**: `hal/core.registerRelease` 는 기존 행이 approved 면 같은 내용(main 커밋·빌드 시각·artifact·checks 를 키 순서 무관하게 비교, 메모는 준 경우)의 재등록만 멱등하게 돌려주고(approve:true 여도 승인자·시각 그대로), 다르면 `release_immutable` 로 거절하며 UPDATE 를 실행하지 않는다. 기존 행이 승인되지 않았으면 내용을 갱신하되 `status='registered', approved_by=NULL, approved_at=NULL` 로 두고, approve 는 `releaseApprovalProblem` 규칙을 지나야 한다. `hal/index.mjs register-release` 는 거부를 `릴리스 등록 거부 (release_immutable): …` 로 출력하고 종료 1(DB 불변). 운영자 API 의 승인 경로(`POST /ops/api/releases/:v/approve`)는 같은 규칙(앞 절 테스트). RED → GREEN: `test/release-provenance.test.mjs` +2 — core(8가지 변경 × approve true/false 모두 거절·readback 불변, 미승인 버전의 승인 미승계) · CLI(같은 manifest 재등록 멱등, 불량 checks·다른 artifact 재등록 종료 1 + DB readback 불변).
- **항목 2·3(공유 코드)**: `modules/webmaker/server.js` 에 라우터 수준 잠금·재적재(본문 메모 읽기 → 잠금 안 재적재 → 짧은 경로 전체 잠금 / 긴 경로 재적재)와 재진입 잠금, `changeLanguages` 순서, 직접 편집 PUT 의 `remoteState:'ambiguous'` 응답을 upstream 과 같은 블록으로 반영(Suite 블록 보존). `lib/gitstore.js` 는 byte-identical. Suite 의 직접 편집 rev 충돌 검사는 잠금 안에서 재적재된 최신 기록으로 이루어진다.
- **항목 4**: 이 문서·VERSION_HISTORY 의 합계(107/114 등)를 현재 증거로 바꿨다.
- **항목 5(공유 코드 + Suite 블록) — 배경 작업 저장 합치기**: `modules/webmaker/lib/store.js` 의 `load` 가 읽은 상태를 `__base`(열거 안 됨)로 기억하고 `save` 가 디스크 `saveId` 변화를 보고 이 객체가 바꾼 최상위 항목만 합친다. Suite 블록: 합칠 때 `rev` 는 디스크 값을 기준으로 올리고, `keepRev`(원격 반영 조정)는 합치되 rev 를 올리지 않는다(`restore` 는 스냅샷 그대로, rev 불변). upstream 대비 차이 5줄. 되돌리기 검증(`server.js`)은 `updatedAt`·`saveId` 만 제외하고 기록을 비교한다. 공유 서비스 테스트 +1(작업 중 PATCH 제목·PUT 등급 → 중단 → 유지)이 Suite 모듈에서도 그대로 돈다(19/19).
- **항목 6(공유 코드 + Suite 블록) — 같은 잎 동시 변경 fail-closed**: `store.js` 합치기를 잎 단위 3자 병합으로(일반 객체는 안으로, 배열·기본값은 잎), 같은 잎을 둘 다 다르게 바꾼 경우만 `SaveConflict`(code `site_save_conflict`, status 409, `fields`)로 거절·디스크 보존. `store.update(id, mutate, opts)`·`store.adopt(site, fresh)` 추가(Suite: `update` 도 `keepRev` 를 받아 조정 저장은 rev 불변). `server.js`: 배경 작업 저장 전부 `saveJob(site, reapply)`(충돌이면 최신 기록 위에 작업 항목만 재적용 + adopt), `gitRecord` 충돌 때 버전만 추가·옛 실패분 재시도는 잠금 안 조정으로, `/git/retry`·`reconcileSite` 는 `applyPushState` 를 `store.update` 로 적용. 그 밖의 화면 경로의 충돌은 409 로 드러난다(조용한 손실 없음). RED → GREEN: 공유 서비스 테스트 +2(저장소 규칙 6가지 / 실제 서버 — git 래퍼로 commit 2초 지연 → 작업 종료의 버전 기록 창에 다시 푸시 → 다시 푸시의 verifiedAt 그대로·작업의 새 버전도 기록); 중간 RED 에서 충돌이 다시 푸시 경로에 409 로 드러난 것을 확인한 뒤 그 경로를 update 로 옮겼다. Suite 모듈 21/21.
## 독립 검토 4차 차단 항목 4건(2026-10-28) — Suite 쪽 기록
- **항목 1(Suite 전용) — artifact 설치 불변·fail-closed**: `hal/core.installArtifacts(d, {version, mainCommit, builtAt, checks, artifacts:{mod:{file, sha256, size, src}}})` — ① 원본 바이트 sha256·크기를 선언값과 대조(불일치 → `artifact_hash_mismatch`, 아무것도 쓰지 않음) ② 승인된 버전이면 `releaseImmutableProblem`(메타데이터) + 설치된 파일 해시 모두 같을 때만 멱등(복사 없음), 아니면 `release_immutable`(기존 파일·DB 불변) ③ 새·미승인 설치는 `.<file>.tmp-…` 에 쓰고 해시를 다시 확인한 뒤 rename. `hal/index.mjs register-release`·`scripts/release.mjs register` 는 설치 → `registerRelease` 순서로 바뀌었고 거부는 종료 1(release.mjs 는 DB 를 닫고 종료). RED → GREEN: `test/release-provenance.test.mjs` +2 — HAL CLI(A 승인 → B 유효 해시·B 바이트에 A 해시 거짓 선언 × approve 유무 → 종료 1, 대상 바이트/해시/DB 가 A, 임시 파일 없음, A 재등록 멱등, 새 버전 해시 불일치 미설치) · release.mjs(같은 4 경우 + 멱등).
- **항목 2·3·4(공유 코드)**: `modules/webmaker/server.js` — 읽기 요청 부작용 제거(언어 저장은 잠금 안 재적재에서만), 폐기 규약(`destroySite(siteId, by, {ownerId, confirm})`: 잠금 안 재검사·표식 claim → 바깥 정리 → 같은 표식 확인 뒤 삭제; 라우터 재적재·`claimJob` 의 폐기 중 409; 실패는 `store.update` 로 deleteFailed) — 사용자 경로는 Suite 의 포털 영향 확인 블록(`ariweb.impactFromPortal`·`acknowledgeImpact`·`unlink`)을 그대로 두고 마지막 호출만 바꿨다(upstream 대비 118줄 차이 — 기존 Suite 블록 + 폐기 전 검사 2줄). `lib/visual-editor.js` 스캐너의 외래 콘텐츠 깊이는 byte-identical.
- **잔여 위험 해소 — 폐기 때 포털 연결 보상(Suite 블록 + 공유 훅)**: 포털 연결 등록부(`lib/ariweb.js` 의 `ariweb-links.json`, 로컬 파일)의 해제(`unlink`)는 되돌릴 수 있고(`link` 로 되살림, `linkedAt` 보존 인자 추가), 포털 자체에는 영향 조회(`impactFromPortal`, 읽기)만 한다. 공유 `destroySite` 의 최종 잠금 단계를 "임시 이름으로 옮김(rename) → `beforeIrreversible` → 기록 unlink(되돌릴 수 없는 한 단계) → 최선의 뒷정리" 로 바꾸고, Suite 사용자 경로는 `beforeIrreversible` 에서 `ariweb.unlink` + readback(남아 있으면 502), `compensate` 에서 `ariweb.link(원래 연결)` + readback 을 한다. 순서상 정리 실패·연결 해제 실패에는 되돌릴 수 없는 변경이 하나도 없고, 기록 삭제 실패에는 되살리기가 있으며 되살리지 못하면 `recoveryRequired`(응답·`deleteFailed`·감사 `site.destroy.failed`)로 운영자에게 보인다. 뒷정리 실패는 성공 응답의 `warnings` + 감사 `site.destroy.cleanup`(남은 `.deleting-…` 폴더). RED → GREEN: `modules/webmaker/test/ariweb.test.js` +3(가짜 ssh·가짜 배포 대상·가짜 포털) — ① 정리 실패(SSH_FAIL): 연결·파일·기록 그대로 + deleteFailed, 정리가 되면 폐기 성공·연결 없음 ② 정리 성공 뒤 연결 등록부 쓰기 실패(DATA_ROOT 읽기 전용): ssh 호출 있음, 연결·파일·기록 그대로(임시 폴더 없음), 쓸 수 있게 되면 성공 ③ 되살리기 readback + 뒷정리 실패(지울 수 없는 하위 폴더): 200 + warnings, 연결·기록·원래 폴더 없음, 남은 폴더 1개 확인 뒤 정리.
### RED — Suite(4차 검토)
- `node test/release-provenance.test.mjs`(수정 전 CLI): `✘ 통과 9 · 실패 2` — `B 유효 해시 + approve: 대상 바이트 A 그대로: 기대 "artifact-A-bytes" 실제 "artifact-B-bytes-different"`, release.mjs `기대 "rel-A" 실제 "rel-B-other"`(승인된 artifact 바이트가 덮임).
- 공유 테스트(참조 저장소, 수정 전): 단위 38/39(`거절되어야 함: …<svg><title><script>alert(1)</script></title></svg>`), 서비스 21/23(`읽기 요청은 기록 파일을 바꾸지 않는다`, 폐기 중 저장이 "폐기" 로 거절되지 않음).
- Suite 모듈 `test/ariweb.test.js`(수정 전 순서 — unlink 먼저): `ℹ tests 10 · pass 7 · fail 3` — `포털 연결이 풀리지 않았다(연결 해제는 정리 성공 뒤에만): null`, `정리는 먼저 수행됐다`(연결 해제 실패가 정리 전에 남), `연결이 되살아났다(readback): null`.
### GREEN — Suite(4차 검토 뒤, 현재 증거)
```
node test/release-provenance.test.mjs ✔ 통과 11 · 실패 0
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 125 · pass 125 · fail 0 (단위 39 · 서비스 23 · 화면 전환 1 · 원본 59 + ariweb 폐기 보상 3)
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
```
참조 저장소: `npm test` 115/115(단위 39 + 서비스 23 + 화면 전환 1 + 원본 52). 실제 Chrome 게이트(Neo 호스트) 61/61 그대로(게이트가 보는 화면 동작 불변).
### RED — Suite(최종 검토)
- `node test/release-provenance.test.mjs`(수정 전 `hal/core.mjs`): `✘ 통과 7 · 실패 2` — `Error: 승인자·시각도 그대로: 기대 {"status":"approved","by":"op",…} 실제 {"status":"approved","by":"op2",…}`(승인된 행이 재등록으로 바뀜), CLI 재등록 뒤 `approved_by` 가 "test" → "test2".
- 공유 서비스 테스트(수정 전 코드, 참조 저장소): `ℹ tests 18 · pass 15 · fail 3` — 잠금 경쟁(`설정 저장이 직접 편집 버전을 덮어쓰지 않는다: ["1:create"]`), 모호한 푸시(`원격이 받았으면 성공: {…"rolledBack":true,"stage":"push"}`), 준비 단계 실패(`stage`·`rollback` 없음, 페이지 미복원). 항목 5(수정 전 store.js): `ℹ tests 19 · pass 18 · fail 1` — `작업의 종료 저장이 화면의 제목 변경을 덮어쓰지 않는다: {"title":"VE site","status":"idle"}`.
### GREEN — Suite(최종 검토 뒤)
```
node test/release-provenance.test.mjs ✔ 통과 9 · 실패 0
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 119 · pass 119 · fail 0 (단위 38 · 서비스 21 · 화면 전환 1 · 원본 59)
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
```
참조 저장소(그때): `npm test` 112/112(단위 38 + 서비스 21 + 화면 전환 1 + 원본 52). 현재 증거는 위 "4차 검토 뒤" 절.
### RED — Suite(종결 검토)
- `node test/release-provenance.test.mjs`(수정 전 `hal/core.mjs`·`scripts/release.mjs`): `✘ 통과 4 · 실패 3` — `Error: checks 없음 (approveRelease)`(승인됨), `Error: 7.7.0: 거절 200 {"ok":true,"release":{…}}`(운영자 API 가 checks 없는 릴리스를 승인), `Error: tests-null: HAL 릴리스 등록: 0.2.0 (approved) … 기대 1 실제 0`(`register --approve` 가 testsPassed null 을 승인).
- 공유 서비스 테스트(수정 전 코드, 참조 저장소에서 실행): `ℹ tests 14 · pass 11 · fail 3` — Git 실패 되돌리기 `rolledBack:false`(옛 boolean 은 상태를 검증하지 않음), 원격 트랜잭션(옛 코드는 pre-receive 거부를 200 + `rec.error` 로 삼킴). 단위: SMIL `거절되어야 함: …<animate attributeName="href" values="javascript:alert(1)"…`.
### GREEN — Suite(종결 검토 뒤)
```
node test/release-provenance.test.mjs ✔ 통과 7 · 실패 0 (checks 13가지 불량 × approveRelease/registerRelease 거절, 운영자 API 거절/승인, register --approve 4가지 거절 + 깨끗한 manifest 승인)
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 114 · pass 114 · fail 0 (단위 38 · 서비스 16 · 화면 전환 1 · 원본 59)
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
```
참조 저장소(그때): `npm test` 107/107(단위 38 + 서비스 16 + 화면 전환 1 + 원본 52). 현재 증거는 위 "최종 검토 뒤" 절.
### RED — Suite(1차 검토)
- `node --no-warnings test/release-provenance.test.mjs`(구현 전): `✘ 통과 0 · 실패 4`(`lib/provenance.mjs` 없음 → 네 테스트 모두 실패).
- `node --no-warnings test/webmaker-visual-editor.test.mjs` 를 **rev 필수 줄과 `{ restore: true }` 를 뺀 임시 서버 복사본** 으로 실행: `✘ 통과 4 · 실패 1` — `Error: rev 없음: {"path":"index.html",…,"rev":11,…}`(rev 없는 저장이 200 으로 파일을 바꿈). 실행 뒤 복사본은 수정본으로 되돌렸다(`cmp` 확인).
- Suite `modules/webmaker` `npm test`(공유 테스트가 rev 를 보내기 전): `ℹ tests 110 · pass 102 · fail 8` — 모두 `{"error":"저장본 개정 번호(rev)가 없거나 올바르지 않습니다…","revRequired":true}`(항목 7 이 동작함을 보임) → 공유 테스트에 rev 추가 → `pass 108 · fail 2`(409 가 stale 대신 conflict) → `expectStale` → 110/110.
### GREEN — Suite(1차 검토 뒤)
```
node --no-warnings test/release-provenance.test.mjs ✔ 통과 4 · 실패 0
node --no-warnings test/webmaker-visual-editor.test.mjs ✔ 통과 5 · 실패 0 (rev 없음/문자열/null/NaN/빈 → 400, 교차 파일 rev → 409, store 실패 → 500·rev 보존·재시도 200)
modules/webmaker: CLAUDE_BIN=<가짜 exit 1> npm test ℹ tests 110 · pass 110 · fail 0
CLAUDE_BIN=<가짜 exit 1> node test/run.mjs ✔ 테스트 파일 24개 중 실패 0개 (release-provenance 포함; 생성된 local-e2e-2026-10-28.{md,json} 은 삭제)
```
참조 저장소: `npm test` 103/103(단위 37 + 서비스 13 + 화면 전환 1 + 원본 52).
## 브라우저 검증 상태 · 자동화하지 않은 것
- 실제 브라우저 검증(Neo 의 게이트, 실제 Chrome)은 다섯 차례 수행되었다: 배치 결함 → 선택 전 도구 줄 노출 → 끌어다 놓기 실패 → 살균 진단으로 "HTML5 끌기가 sandbox iframe 에 도달하지 않음" 확정(3·4차는 선택·글 편집·실행 취소/다시 실행·키보드 이동 통과) → 포인터 다리 뒤 네이티브 포인터 순서 게이트 **37/37**. 독립 검토 수정 뒤 재실행은 게이트 멱등성 결함을 고친 뒤 **Neo 호스트에서 61/61 통과**(연결 규약·이동 활성화 6종·위조 창 메시지 단언 포함) — 캔버스 안 실제 포인터 조작(글 입력·실행 취소/다시 실행·이동·팔레트 끌어다 놓기·저장·저장 파일·390px)은 이 게이트로 자동 검증했다. Suite 쪽 화면은 독립 Webmaker 와 같은 `style.css`·`visual-editor-ui.js`·`wm-ve-canvas.js` 를 쓰므로 같은 수정이 적용된다(style.css 포털 배너 5줄만 추가). 종결 검토 3건은 브라우저 동작을 바꾸지 않아 추가 재실행은 필요 없다.
- 자동화하지 않은 것: 포털 SSO 로 들어간 실제 화면 확인, 운영 서버 배포, Chrome 외 브라우저. 사람 확인 목록은 Webmaker `Dev_Order_v1.8.0.md` 끝.
+198
View File
@@ -0,0 +1,198 @@
/**
* hal/app.mjs — HAL HTTP 라우트.
*
* /api/health 공개 자체 건강(버전·DB·작업 엔진·부트스트랩 상태). 비밀 없음.
* /status, /api/status 공개 상태 페이지 데이터(§31.3): 구성요소 상태·계획 유지보수·공개 사건. 내부 서버 이름·개인정보 없음.
* /agent/v1/* Node Agent 프로토콜(§5). install.sh·register 외에는 Agent HMAC 인증.
* /portal/v1/* 포털 전용 API(docs/HAL-JOBS.md). 서비스 키 'portal'. 부트스트랩 미완료면 503.
* /ops/api/* 운영자 API(§25): 네트워크 관문 → 운영자 세션. 서버·릴리스·모듈 배포·작업·사건·긴급 복구·재해복구·계정. 화면(UI)은 별도 영역(hal/ui, 후속)에서 같은 API 를 쓴다.
*/
import { createApp, HttpError, cookie, esc } from '../lib/http.mjs';
import { verifyCtx } from '../lib/serviceauth.mjs';
import * as session from '../lib/session.mjs';
import { describe, JobError } from '../lib/jobs.mjs';
import { listAudit } from '../lib/audit.mjs';
import { listNotifications } from '../lib/notify.mjs';
import { redact } from '../lib/redact.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { newId, sha256 } from '../lib/crypto.mjs';
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream, statSync, readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as core from './core.mjs';
import * as ops from './ops.mjs';
import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import * as mailsvc from './mailsvc.mjs';
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
const wrap = (fn) => async (ctx) => { try { return await fn(ctx); } catch (e) { bad(e); } };
export function buildApp(d) {
const app = createApp({ log: d.log });
const uiDir = join(ROOT, 'hal', 'ui', 'public'); if (existsSync(uiDir)) app.static('/ops/ui', uiDir);
// ---------------- 공개
/** 기본 주소는 운영자가 실제로 사용하는 콘솔 경로로 안내한다. 접근 통제는 이동 후 /ops 미들웨어가 동일하게 적용한다. */
app.get('/', (ctx) => ctx.redirect('/ops/'));
app.get('/api/health', () => core.selfHealth(d));
app.get('/api/status', () => publicStatus(d));
app.get('/status', (ctx) => ctx.html(statusPage(d)));
app.get('/agent/v1/install.sh', (ctx) => { const f = join(ROOT, 'deploy', 'agent-install.sh'); return ctx.text(existsSync(f) ? readFileSync(f, 'utf8') : 'echo "install script missing"; exit 1'); });
// ---------------- Agent 프로토콜
app.post('/agent/v1/register', wrap(async (ctx) => { const b = await ctx.body(); const r = core.registerAgent(d, { token: b.token, facts: b.facts || {}, version: b.version || '' }); return { ok: true, ...r, halUrl: d.config.publicUrl, serverTime: nowIso() }; }));
/** Agent 인증 미들웨어: /agent/v1/* (register·install.sh 제외) */
const agentAuth = async (ctx) => {
const streaming = ctx.method === 'POST' && /^\/agent\/v1\/transfers\//.test(ctx.path);
const r = streaming ? verifyStreaming(ctx) : await verifyCtx(ctx, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null);
if (!r.ok) throw new HttpError(401, 'agent_auth_failed', `Agent 인증 실패(${r.reason})`);
const agent = core.getAgent(d, r.keyId.slice(6)); if (!agent || agent.status !== 'active') throw new HttpError(401, 'agent_revoked', 'Agent 가 해지되었습니다');
ctx.agent = agent; ctx.outHeaders['X-HAL-Time'] = String(Date.now());
};
function verifyStreaming(ctx) { const { verifyRequest } = d.serviceauth; return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: '' }, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null); }
/** 등록 전 Agent 코드 내려받기: 아직 서명을 만들 수 없으므로 유효한(미사용·미만료) 등록 토큰을 헤더로 확인한다. 토큰은 소비하지 않는다(등록 때 1회 소비). */
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact'].includes(ctx.path)) await agentAuth(ctx); });
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
app.get('/agent/v1/releases/:version/artifact', wrap((ctx) => { const a = core.artifactPath(d, ctx.params.version, ctx.query.module); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
app.post('/agent/v1/transfers/:id', wrap((ctx) => new Promise((resolve, reject) => {
const id = ctx.params.id; if (!/^tr_[A-Za-z0-9_.-]{2,80}$/.test(id)) return reject(new HttpError(400, 'bad_transfer_id', '전송 id 형식이 올바르지 않습니다'));
const expected = String(ctx.headers['x-transfer-sha256'] || ''); const path = join(core.transferDir(d), id + '.bin'); const hash = createHash('sha256'); let size = 0; const limit = 20 * 1024 ** 3;
const out = createWriteStream(path, { mode: 0o600 }); ctx.req.on('data', (c) => { size += c.length; hash.update(c); if (size > limit) ctx.req.destroy(new Error('too large')); }); ctx.req.on('error', (e) => { out.destroy(); reject(new HttpError(413, 'transfer_too_large', e.message)); });
ctx.req.pipe(out); out.on('finish', () => { const sum = hash.digest('hex'); if (expected && expected !== sum) return reject(new HttpError(400, 'checksum_mismatch', '전송 파일 해시가 다릅니다')); core.createTransfer(d, { id, agentId: ctx.agent.id, sha256: sum, size, purpose: String(ctx.query.purpose || '') }); resolve({ ok: true, id, sha256: sum, size }); });
})));
app.get('/agent/v1/transfers/:id', wrap((ctx) => { const t = core.getTransfer(d, ctx.params.id); if (!t) throw new HttpError(404, 'transfer_not_found', '전송 파일이 없거나 만료되었습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': statSync(t.path).size, 'X-Sha256': t.sha256, 'Cache-Control': 'no-store' }); createReadStream(t.path).pipe(ctx.res); }));
// ---------------- 포털 API
app.use(async (ctx) => { if (!ctx.path.startsWith('/portal/v1/')) return; const r = await verifyCtx(ctx, (k) => k === 'portal' ? d.vault.get('service:portal') : null); if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${r.reason})`); if (ctx.path !== '/portal/v1/health' && !core.bootstrapComplete(d)) throw new HttpError(503, 'bootstrap_incomplete', 'HAL 부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다'); });
app.get('/portal/v1/health', () => ({ ok: true, ...core.selfHealth(d) }));
app.post('/portal/v1/jobs', wrap(async (ctx) => { const b = await ctx.body(); if (!d.jobs.kinds().includes(b.kind) || b.kind.startsWith('module.')) throw new HttpError(400, 'bad_kind', `포털이 요청할 수 없는 작업 종류: ${b.kind}`); const { job, created } = d.jobs.enqueue({ kind: b.kind, subject: { target: b.target, payload: b.payload || {}, _portal: true }, idempotencyKey: b.idempotencyKey || null, createdBy: 'portal', actorType: 'system', groupId: b.groupId || null, subjectType: b.subjectType || null, subjectId: b.subjectId || null, label: b.label || null }); return { ok: true, halJobId: job.id, status: job.status, created }; }));
const jobView = (j) => { if (!j) throw new HttpError(404, 'job_not_found', '작업이 없습니다'); const dsc = describe(j); return { ...dsc, result: j.result, children: j.kind.startsWith('module.') ? d.jobs.childrenSummary(j.id) : undefined }; };
app.get('/portal/v1/jobs/:id', wrap((ctx) => ({ ok: true, ...jobView(d.jobs.get(ctx.params.id)) })));
for (const act of ['cancel', 'retry', 'resume', 'revert']) app.post(`/portal/v1/jobs/:id/${act}`, wrap(async (ctx) => { const j = await d.jobs[act](ctx.params.id, 'portal'); return { ok: true, ...jobView(j) }; }));
app.get('/portal/v1/servers', wrap((ctx) => ({ ok: true, servers: core.listServers(d, { role: ctx.query.role || null }).map(stripFacts) })));
app.post('/portal/v1/servers/pick', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, pick: core.pickServer(d, { role: b.role || 'web', needBytes: Number(b.needBytes || 0) }) }; }));
app.post('/portal/v1/external-agents/token', wrap(async (ctx) => { const b = await ctx.body(); if (!b.siteId || !b.groupId) throw new HttpError(400, 'site_required', 'siteId·groupId 가 필요합니다'); const t = core.issueRegistrationToken(d, { kind: 'external', groupId: b.groupId, siteId: b.siteId, label: b.label || '', by: null }); return { ok: true, agentId: null, token: t.token, expiresAt: t.expiresAt, installCommand: t.installCommand }; }));
app.get('/portal/v1/external-agents/by-site/:siteId', wrap((ctx) => { const a = d.store.get('SELECT id FROM agents WHERE kind=\'external\' AND site_id=? AND status=\'active\' ORDER BY registered_at DESC LIMIT 1', ctx.params.siteId); return { ok: true, agent: a ? core.externalAgentStatus(d, a.id) : null }; }));
app.get('/portal/v1/external-agents/:id', wrap((ctx) => { const s = core.externalAgentStatus(d, ctx.params.id); if (!s) throw new HttpError(404, 'agent_not_found', '외부 Agent 가 없습니다'); return { ok: true, agent: s }; }));
app.post('/portal/v1/external-agents/:id/revoke', wrap((ctx) => { core.revokeAgent(d, ctx.params.id, 'portal'); return { ok: true }; }));
// ---------------- 운영자 API(§25)
app.use(async (ctx) => {
if (!ctx.path.startsWith('/ops')) return;
const gate = ops.networkGate(d, ctx);
if (ctx.path === '/ops/access') { const code = ctx.query.code; try { const g = ops.bindGrant(d, code, { ua: ctx.headers['user-agent'], ip: ctx.ip }); ctx.setCookie(ops.grantCookie(g.cookie, ctx.secure)); return ctx.redirect('/ops/'); } catch (e) { throw new HttpError(403, 'access_denied', '접근할 수 없습니다'); } }
if (!gate.ok) throw new HttpError(403, 'access_denied', '접근할 수 없습니다'); // 로그인 화면도 주지 않는다(§25)
ctx.opsVia = gate.via;
const tok = ctx.cookies[session.cookieName('operator')]; ctx.session = session.resolveSession(d.store, tok, 'operator'); ctx.operator = ctx.session ? ops.publicAccount(d.store.get('SELECT * FROM ops_accounts WHERE id=?', ctx.session.user_id)) : null;
if (ctx.path.startsWith('/ops/api/') && !['/ops/api/login', '/ops/api/setup', '/ops/api/me'].includes(ctx.path)) { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (!['GET', 'HEAD'].includes(ctx.method) && !session.csrfOk(ctx.session, ctx.headers['x-csrf'])) throw new HttpError(403, 'csrf', '보안 토큰이 맞지 않습니다'); }
});
const requireRecent = (ctx) => { if (!session.recentlyAuthed(ctx.session)) throw new HttpError(403, 'reauth_required', '고위험 작업은 최근 10분 안의 재인증이 필요합니다', { action: 'reauth' }); };
/** 고위험 작업 3단계 확인(§25): 영향 범위 확인 → 대상 식별정보 재입력 → 최종 실행 */
const threeStep = (b, expectIds) => { if (b.confirmImpact !== true) throw new HttpError(400, 'confirm_impact_required', '1단계: 영향 범위를 확인해야 합니다'); const ids = [].concat(b.confirmIdentifiers || []).sort(); if (JSON.stringify(ids) !== JSON.stringify([].concat(expectIds).sort())) throw new HttpError(400, 'identifiers_mismatch', '2단계: 대상 식별정보가 다릅니다'); if (b.confirmExecute !== true) throw new HttpError(400, 'confirm_execute_required', '3단계: 최종 실행을 확인해야 합니다'); };
app.post('/ops/api/setup', wrap(async (ctx) => { const b = await ctx.body(); const st = core.bootstrapState(d); const tokFile = join(d.config.dataDir, 'setup.token'); const expected = existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : ''; if (core.ensureOpsAccountExists(d) || !expected || b.setupToken !== expected) throw new HttpError(403, 'setup_closed', '최초 설정은 설정 토큰으로 한 번만 할 수 있습니다'); const a = ops.createAccount(d, { login: b.login, password: b.password, displayName: b.displayName, by: null }); try { (await import('node:fs')).unlinkSync(tokFile); } catch (_) {} return { ok: true, account: a, bootstrap: st }; }));
app.post('/ops/api/login', wrap(async (ctx) => { const b = await ctx.body(); const r = ops.login(d, { login: b.login, password: b.password, totp: b.totp, ip: ctx.ip, ua: ctx.headers['user-agent'] }); if (r.mfaRequired) return { ok: true, mfaRequired: true, message: r.message }; ctx.setCookie(cookie(session.cookieName('operator'), r.session.token, { maxAge: 86400, path: '/ops', secure: ctx.secure })); return { ok: true, account: r.account, csrf: r.session.csrf, via: ctx.opsVia }; }));
app.get('/ops/api/me', (ctx) => ({ ok: true, operator: ctx.operator, csrf: ctx.session?.csrf || null, via: ctx.opsVia, recentlyAuthed: session.recentlyAuthed(ctx.session), bootstrap: core.selfHealth(d).bootstrap, setupNeeded: !core.ensureOpsAccountExists(d) }));
app.post('/ops/api/logout', (ctx) => { session.revokeSession(d.store, ctx.session.id, ctx.session.user_id); ctx.setCookie(cookie(session.cookieName('operator'), '', { path: '/ops' })); return { ok: true }; });
app.post('/ops/api/reauth', wrap(async (ctx) => { const b = await ctx.body(); ops.reauth(d, ctx.operator.id, b.password, ctx.session.id); return { ok: true }; }));
app.post('/ops/api/password', wrap(async (ctx) => { const b = await ctx.body(); ops.changePassword(d, ctx.operator.id, b.current, b.next, ctx.session.id); return { ok: true }; }));
app.post('/ops/api/mfa/begin', wrap((ctx) => ({ ok: true, ...ops.mfaBegin(d, ctx.operator.id) })));
app.post('/ops/api/mfa/confirm', wrap(async (ctx) => ({ ok: true, ...ops.mfaConfirm(d, ctx.operator.id, (await ctx.body()).code) })));
app.post('/ops/api/mfa/disable', wrap(async (ctx) => { requireRecent(ctx); ops.mfaDisable(d, ctx.operator.id, (await ctx.body()).code); return { ok: true }; }));
app.get('/ops/api/sessions', (ctx) => ({ ok: true, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })) }));
app.post('/ops/api/sessions/revoke', wrap(async (ctx) => { const b = await ctx.body(); const n = b.all ? session.revokeOtherSessions(d.store, ctx.operator.id, ctx.session.id, 'operator') : session.revokeSession(d.store, b.id, ctx.operator.id); return { ok: true, revoked: n }; }));
app.get('/ops/api/accounts', (ctx) => ({ ok: true, accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }));
app.post('/ops/api/accounts', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, account: ops.createAccount(d, { ...b, by: ctx.operator.id, mustChange: true }) }; }));
app.post('/ops/api/access-grants', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, grant: ops.issueGrant(d, { accountId: b.accountId || ctx.operator.id, hours: b.hours || 8, by: ctx.operator.id, reason: b.reason || '' }) }; }));
app.get('/ops/api/access-grants', (ctx) => ({ ok: true, grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 200') }));
app.post('/ops/api/access-grants/:id/revoke', wrap((ctx) => { ops.revokeGrant(d, ctx.params.id, ctx.operator.id); return { ok: true }; }));
// 운영 현황(§26.1)
app.get('/ops/api/overview', (ctx) => overview(d));
app.get('/ops/api/config', () => ({ ok: true, config: { publicUrl: d.config.publicUrl, portalUrl: d.config.portalUrl, opsAllowCidrs: d.config.opsAllowCidrs, metricsRetentionDays: d.config.metricsRetentionDays, offlineAfterMs: d.config.offlineAfterMs, emergencyTtlMinutes: d.config.emergencyTtlMinutes } }));
app.put('/ops/api/config', wrap(async (ctx) => { requireRecent(ctx); d.config.set(await ctx.body(), ctx.operator.id); return { ok: true }; }));
// 서버(§28)
app.get('/ops/api/servers', () => ({ ok: true, servers: core.listServers(d) }));
app.post('/ops/api/servers', wrap(async (ctx) => { const b = await ctx.body(); const s = core.createServer(d, { ...b, by: ctx.operator.id }); return { ok: true, server: core.publicServer(d, s) }; }));
app.get('/ops/api/servers/:id', wrap((ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); return { ok: true, server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe), agentJobs: d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '') }; }));
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
// HAL 관리형 메일 서비스(legacy 가져오기, hal/mailsvc.mjs). 응답에 비밀 없음(참조만 저장).
app.get('/ops/api/mail-services', () => { const rb = mailsvc.readback(d); return { ok: true, services: rb.services, readback: { counts: rb.counts, duplicates: rb.duplicates, perServer: rb.perServer, secretLeaks: rb.secretLeaks, generatedAt: rb.generatedAt } }; });
app.get('/ops/api/mail-services/imports', () => ({ ok: true, imports: mailsvc.listImports(d) }));
app.post('/ops/api/mail-services/import', wrap(async (ctx) => { const b = await ctx.body(); if (b.apply) requireRecent(ctx); const v = mailsvc.validateDiscovery(b.discovery); if (!v.ok) throw new HttpError(400, 'bad_discovery', 'discovery 문서 검증 실패', { errors: v.errors }); return { ok: true, result: mailsvc.applyImport(d, b.discovery, { serverId: b.serverId, by: ctx.operator.id, dryRun: !b.apply }) }; }));
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
// 릴리스·모듈 배포(§32)
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: ctx.params.name, version: b.version, serverIds: b.serverIds || [] }, idempotencyKey: b.idempotencyKey || `module.deploy:${ctx.params.name}:${b.version}:${(b.serverIds || []).slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created }; }));
app.post('/ops/api/modules/:name/rollback', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, b.serverIds || []); const r = d.jobs.enqueue({ kind: 'module.rollback', subject: { module: ctx.params.name, toVersion: b.toVersion || null, serverIds: b.serverIds || [] }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 되돌리기` }); return { ok: true, job: describe(r.job) }; }));
app.post('/ops/api/modules/:name/health', wrap(async (ctx) => { const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.health', subject: { module: ctx.params.name, serverIds: b.serverIds || null }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 건강 확인` }); return { ok: true, job: describe(r.job) }; }));
app.post('/ops/api/modules/reconcile', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: b.module || null }, createdBy: ctx.operator.id, actorType: 'operator', label: '목표·실제 상태 맞추기' }); return { ok: true, job: describe(r.job) }; }));
// 작업(§29)
app.get('/ops/api/jobs', (ctx) => ({ ok: true, jobs: d.jobs.list({ status: ctx.query.status ? ctx.query.status.split(',') : null, kind: ctx.query.kind || null, limit: Number(ctx.query.limit || 200) }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id })) }));
app.get('/ops/api/jobs/:id', wrap((ctx) => { const j = d.jobs.get(ctx.params.id); if (!j) throw new HttpError(404, 'job_not_found', '작업이 없습니다'); return { ok: true, job: { ...describe(j), subject: redact(j.subject), result: j.result, log: j.log, createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }, children: d.jobs.childrenSummary(j.id), agentJobs: d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id) }; }));
for (const act of ['cancel', 'retry', 'resume', 'revert', 'pause']) app.post(`/ops/api/jobs/:id/${act}`, wrap(async (ctx) => { if (act === 'revert') requireRecent(ctx); const j = await d.jobs[act](ctx.params.id, ctx.operator.id); return { ok: true, job: describe(j) }; }));
// 사건·알림(§31)
app.get('/ops/api/incidents', () => ({ ok: true, incidents: d.store.all('SELECT * FROM incidents ORDER BY started_at DESC LIMIT 200'), notifications: listNotifications(d.store, { groupId: null, limit: 200 }) }));
app.post('/ops/api/incidents', wrap(async (ctx) => { const b = await ctx.body(); const open = d.store.get('SELECT id FROM incidents WHERE key=? AND resolved_at IS NULL', b.key); if (open) { d.store.run('UPDATE incidents SET severity=?, title=?, customer_impact=?, updated_at=? WHERE id=?', b.severity || 'warning', b.title, b.customerImpact || null, nowIso(), open.id); return { ok: true, id: open.id, merged: true }; } const id = newId('inc_'); d.store.run('INSERT INTO incidents(id, key, severity, title, component, customer_impact, started_at, assignee, public, public_note, notes_json, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,\'[]\',?)', id, b.key, b.severity || 'warning', b.title, b.component || null, b.customerImpact || null, nowIso(), ctx.operator.id, b.public ? 1 : 0, b.publicNote || null, nowIso()); return { ok: true, id }; }));
app.patch('/ops/api/incidents/:id', wrap(async (ctx) => { const b = await ctx.body(); const inc = d.store.get('SELECT * FROM incidents WHERE id=?', ctx.params.id); if (!inc) throw new HttpError(404, 'not_found', '사건이 없습니다'); if (b.resolve && !b.verifiedUserPath) throw new HttpError(400, 'verify_required', '실제 사용자 경로 확인 뒤에 종료할 수 있습니다(§31.2)'); const notes = parseJson(inc.notes_json, []); if (b.note) notes.push({ at: nowIso(), by: ctx.operator.id, note: b.note }); d.store.run('UPDATE incidents SET severity=COALESCE(?, severity), assignee=COALESCE(?, assignee), public=COALESCE(?, public), public_note=COALESCE(?, public_note), notes_json=?, resolved_at=CASE WHEN ? THEN ? ELSE resolved_at END, verified_at=CASE WHEN ? THEN ? ELSE verified_at END, updated_at=? WHERE id=?', b.severity || null, b.assignee || null, b.public == null ? null : (b.public ? 1 : 0), b.publicNote || null, toJson(notes), b.resolve ? 1 : 0, nowIso(), b.resolve ? 1 : 0, nowIso(), nowIso(), inc.id); return { ok: true }; }));
app.get('/ops/api/audit', (ctx) => ({ ok: true, entries: listAudit(d.store, { action: ctx.query.action, subjectType: ctx.query.subjectType, subjectId: ctx.query.subjectId, since: ctx.query.since, limit: Number(ctx.query.limit || 200) }) }));
// 긴급 복구(§33)
app.get('/ops/api/emergency', () => ({ ok: true, sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS }));
app.post('/ops/api/emergency', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, session: emergency.declare(d, { ...b, by: ctx.operator.id }) }; }));
app.post('/ops/api/emergency/:id/approve', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, session: emergency.approve(d, ctx.params.id, { ...b, by: ctx.operator.id }) }; }));
app.post('/ops/api/emergency/:id/credential', wrap((ctx) => { requireRecent(ctx); return { ok: true, ...emergency.issueCredential(d, ctx.params.id, ctx.operator.id) }; }));
app.post('/ops/api/emergency/:id/perform', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, result: await emergency.perform(d, ctx.params.id, { ...b, by: ctx.operator.id }) }; }));
app.post('/ops/api/emergency/:id/verify', wrap(async (ctx) => ({ ok: true, session: emergency.verifyAndRevoke(d, ctx.params.id, { by: ctx.operator.id, verify: (await ctx.body()).verify }) })));
app.post('/ops/api/emergency/:id/reconcile', wrap((ctx) => ({ ok: true, result: emergency.reconcile(d, ctx.params.id, ctx.operator.id) })));
app.post('/ops/api/emergency/:id/close', wrap(async (ctx) => ({ ok: true, session: emergency.close(d, ctx.params.id, ctx.operator.id, (await ctx.body()).note) })));
// 재해복구(§34)
app.get('/ops/api/dr', () => ({ ok: true, readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50') }));
app.put('/ops/api/dr/config', wrap(async (ctx) => { requireRecent(ctx); return { ok: true, config: dr.setConfig(d, await ctx.body(), ctx.operator.id) }; }));
app.post('/ops/api/dr/snapshots', wrap((ctx) => ({ ok: true, snapshot: dr.snapshot(d, { kind: 'manual', by: ctx.operator.id }) })));
app.post('/ops/api/dr/restores', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, restore: dr.requestRestore(d, { snapshotId: b.snapshotId, by: ctx.operator.id, note: b.note }) }; }));
app.post('/ops/api/dr/restores/:id/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, restore: dr.approveRestore(d, ctx.params.id, ctx.operator.id) }; }));
app.post('/ops/api/dr/restores/:id/start', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...dr.startRestore(d, ctx.params.id, { by: ctx.operator.id, oldControlStopped: b.oldControlStopped, confirmText: b.confirmText }) }; }));
app.post('/ops/api/dr/restores/:id/finish', wrap(async (ctx) => ({ ok: true, restore: dr.finishRestore(d, ctx.params.id, { by: ctx.operator.id, checks: (await ctx.body()).checks }) })));
app.post('/ops/api/dr/drills', wrap(async (ctx) => ({ ok: true, drill: dr.startDrill(d, { by: ctx.operator.id, snapshotId: (await ctx.body()).snapshotId }) })));
app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) })));
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; }));
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `<!doctype html><html lang="ko"><meta charset="utf-8"><title>Ariweb HAL</title><body style="font-family:sans-serif;padding:40px"><h1>Ariweb HAL 운영자 콘솔</h1><p>화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me</p></body></html>`); });
return app;
}
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
/** 운영 현황(§26.1) */
function overview(d) {
const servers = core.listServers(d); const health = core.selfHealth(d);
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
}
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
function publicStatus(d) {
const o = overview(d);
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', observability: '모니터링' };
return { ok: true, generatedAt: o.generatedAt, components: o.components.map((c) => ({ name: label[c.name] || c.name, status: c.status === 'ok' ? 'ok' : c.status === 'down' ? 'down' : 'unknown', label: c.status === 'ok' ? '정상' : c.status === 'down' ? '장애' : '확인 중' })), incidents: o.incidents.filter((i) => d.store.get('SELECT public FROM incidents WHERE id=?', i.id)?.public).map((i) => ({ severity: i.severity, title: i.title, startedAt: i.started_at, note: d.store.get('SELECT public_note FROM incidents WHERE id=?', i.id)?.public_note || '' })), maintenance: d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s) => ({ note: s.maintenance_note || '계획된 유지보수' })) };
}
function statusPage(d) { const s = publicStatus(d); return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c) => `<li><b class="${c.status}">${c.status === 'ok' ? '●' : c.status === 'down' ? '✘' : '◔'} ${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length ? `<ul>${s.incidents.map((i) => `<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>` : '<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length ? `<ul>${s.maintenance.map((m) => `<li>${esc(m.note)}</li>`).join('')}</ul>` : '<p>예정된 유지보수가 없습니다.</p>'}</body></html>`; }
+21
View File
@@ -0,0 +1,21 @@
/** hal/cidr.mjs — IP 허용 목록(CIDR) 검사(§25 운영자 콘솔 네트워크 제한). IPv4·IPv6·::ffff: 로 감싼 IPv4 를 다룬다. */
import { isIP } from 'node:net';
function toBytes(ip) {
let s = String(ip || '').trim(); if (s.startsWith('::ffff:') && isIP(s.slice(7)) === 4) s = s.slice(7);
if (isIP(s) === 4) return { v: 4, b: s.split('.').map(Number) };
if (isIP(s) !== 6) return null;
const [head, tail] = s.split('::'); const h = head ? head.split(':') : []; const t = tail ? tail.split(':') : []; const parts = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
const b = []; for (const p of parts) { const n = parseInt(p || '0', 16); b.push(n >> 8, n & 255); } return { v: 6, b };
}
/** ip 가 cidr 목록 중 하나에 속하면 true. 목록이 비면 false(허용 네트워크 없음). */
export function ipAllowed(ip, cidrs) {
const a = toBytes(ip); if (!a) return false;
for (const c of cidrs || []) {
const [net, lenS] = String(c).split('/'); const n = toBytes(net); if (!n || n.v !== a.v) continue;
const len = lenS === undefined ? (n.v === 4 ? 32 : 128) : Number(lenS); if (!(len >= 0 && len <= (n.v === 4 ? 32 : 128))) continue;
let ok = true; for (let i = 0; i < len; i++) { const byte = i >> 3, bit = 7 - (i & 7); if (((a.b[byte] >> bit) & 1) !== ((n.b[byte] >> bit) & 1)) { ok = false; break; } }
if (ok) return true;
}
return false;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* hal/config.mjs — HAL 실행 설정.
*
* 배포 때 정해지는 값(포트·데이터 디렉터리)은 환경 변수로, 운영 중 바꾸는 값(공개 주소·포털 주소·허용 네트워크·메트릭 보존 일수)은
* data/config.json 에 둔다. 비밀값(서비스 키·Agent 비밀)은 설정이 아니라 보관소(lib/vault)에만 둔다.
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
export function loadConfig() {
const dataDir = process.env.ARIWEB_HAL_DATA || process.env.DATA_DIR || join(process.cwd(), 'data');
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
const file = join(dataDir, 'config.json');
let values = {}; try { values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')).values || {} : {}; } catch (_) { values = {}; }
const cfg = {
dataDir, file,
port: Number(process.env.PORT || 8900), host: process.env.HOST || '127.0.0.1',
/** HAL 공개 주소(Agent 가 접속하는 주소) */
publicUrl: values.publicUrl || process.env.HAL_PUBLIC_URL || 'https://hal.ariweb.net',
/** 포털 내부 주소(작업 이벤트를 미는 곳) */
portalUrl: values.portalUrl || process.env.PORTAL_URL || 'http://127.0.0.1:8910',
/** 운영자 콘솔 접근 허용 네트워크(CIDR 목록, §25). 비어 있으면 접근권(grant) 쿠키만 허용 */
opsAllowCidrs: values.opsAllowCidrs || (process.env.OPS_ALLOW_CIDRS ? process.env.OPS_ALLOW_CIDRS.split(',').map((s) => s.trim()).filter(Boolean) : ['127.0.0.1/32', '::1/128', '10.0.0.0/8', '192.168.0.0/16']),
/** 메트릭 보존 일수(최소 90, §28.1) */
metricsRetentionDays: Math.max(90, Number(values.metricsRetentionDays || 90)),
/** heartbeat 가 이만큼 없으면 서버를 offline 으로 본다 */
offlineAfterMs: Number(values.offlineAfterMs || 90 * 1000),
/** 전송(transfer) 파일 보관 시간 */
transferTtlMs: Number(values.transferTtlMs || 24 * 3600 * 1000),
/** 긴급 자격 최대 유효 시간(분) */
emergencyTtlMinutes: Math.min(60, Number(values.emergencyTtlMinutes || 30)),
values,
/** 설정 저장(운영자 화면·CLI). 비밀값은 거부한다. */
set(patch, by = 'ops') {
for (const k of Object.keys(patch)) if (/key|secret|token|pass/i.test(k)) throw new Error(`비밀값(${k})은 설정 파일에 둘 수 없습니다 — 보관소를 쓰세요`);
Object.assign(values, patch); const tmp = file + '.tmp';
writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), updatedBy: by, values }, null, 1), { mode: 0o600 }); renameSync(tmp, file); Object.assign(cfg, patch);
},
};
return cfg;
}
+372
View File
@@ -0,0 +1,372 @@
/**
* hal/core.mjs — HAL 핵심 서비스: 서버·Agent 등록부, Agent 작업 배정·대기, 릴리스 등록부, 부트스트랩 상태, 지표, 포털 이벤트 outbox.
* app.mjs(라우트)와 jobs.mjs(작업 종류)가 함께 쓴다. 모든 함수는 deps(store·vault·config·log·jobs) 를 받는다.
*/
import { hostname } from 'node:os';
import { createHash } from 'node:crypto';
import { newId, randomToken, sha256, hashPassword } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { notify } from '../lib/notify.mjs';
import { redact, safeError } from '../lib/redact.mjs';
import { serviceFetch } from '../lib/serviceauth.mjs';
import { JobError } from '../lib/jobs.mjs';
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
import { join, basename } from 'node:path';
import { syncFromHeartbeat } from './mailsvc.mjs';
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
// ------------------------------------------------------------------ 부트스트랩 상태(§32.2)
export function bootstrapState(d) { let r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); if (!r) { d.store.run('INSERT INTO bootstrap_state(id, updated_at) VALUES(1, ?)', nowIso()); r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); } return r; }
/** [실제 환경 결함 수정] 행(id=1)이 아직 없으면(HAL 기동 전 CLI bootstrap-release) UPDATE 가 아무 행도 바꾸지 않아 릴리스 정보가 비었다 — 먼저 행을 만든다. */
export function setBootstrap(d, patch) { bootstrapState(d); const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); }
/** 부트스트랩 완료 여부: HAL 실행 버전 기록 + 첫 Agent 등록·승인 */
export function bootstrapComplete(d) { const b = bootstrapState(d); return !!(b.hal_running_version && b.first_agent_id && b.completed_at); }
/** HAL 기동 때: 실행 버전·인스턴스 임대 기록. 다른 인스턴스의 최근 임대가 있으면 거부(§34 "하나의 HAL 만 운영"). */
export function claimLive(d, { instanceId, version, takeover = false, now = Date.now() }) {
const b = bootstrapState(d);
// [통합 보완] 같은 호스트에서 이전 프로세스가 죽은 뒤(systemd 재시작·비정상 종료) 60초 임대가 남아 있으면 기동이 막힌다.
// 임대에 호스트·PID 를 함께 기록해 두고, 같은 호스트이며 그 PID 가 더 이상 살아 있지 않으면 같은 제어 위치의 재시작으로 보아 이어받는다.
// 다른 호스트(재해복구로 띄운 두 번째 HAL)는 여전히 거부된다(§34 "하나의 HAL 만 운영").
const sameHostDead = b.live_host && b.live_host === hostname() && b.live_pid && !pidAlive(Number(b.live_pid));
if (b.live_instance_id && b.live_instance_id !== instanceId && b.live_lease_at && now - Date.parse(b.live_lease_at) < 60000 && !takeover && !sameHostDead) {
throw Object.assign(new Error(`다른 HAL 인스턴스(${b.live_instance_id})가 ${b.live_lease_at} 에 운영 중입니다. 기존 제어 위치를 먼저 중지하세요`), { code: 'another_hal_live' });
}
return setBootstrap(d, { live_instance_id: instanceId, live_lease_at: new Date(now).toISOString(), live_host: hostname(), live_pid: String(process.pid), hal_running_version: version, hal_started_at: nowIso() });
}
/** PID 가 살아 있는지(신호 0). 권한 오류(EPERM)는 살아 있는 것으로 본다. */
function pidAlive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
export function renewLease(d, instanceId) { d.store.run('UPDATE bootstrap_state SET live_lease_at=? WHERE id=1 AND live_instance_id=?', nowIso(), instanceId); }
/** 첫 Agent 가 승인되면 부트스트랩을 닫고 수동 설치를 잠근다 */
export function maybeCompleteBootstrap(d, agentId) {
const b = bootstrapState(d); if (b.completed_at) return b;
if (!b.first_agent_id) setBootstrap(d, { first_agent_id: agentId, first_agent_registered_at: nowIso() });
if (bootstrapState(d).hal_running_version) { const r = setBootstrap(d, { completed_at: nowIso(), manual_install_locked: 1, first_install: 0 }); audit(d.store, { actorType: 'system', action: 'bootstrap.complete', subjectType: 'agent', subjectId: agentId, immutable: true }); return r; }
return bootstrapState(d);
}
/** HAL 자체 건강(§32.2 4단계) */
export function selfHealth(d) {
const b = bootstrapState(d); let dbOk = true; try { d.store.get('SELECT 1 AS x'); } catch (_) { dbOk = false; }
return { ok: dbOk && !!d.jobsRunning, version: d.version, uptimeSec: Math.round(process.uptime()), db: dbOk, jobs: !!d.jobsRunning, bootstrap: { complete: bootstrapComplete(d), firstInstall: !!b.first_install, releaseVersion: b.release_version, mainCommit: b.main_commit, artifactSha256: b.artifact_sha256, halRunningVersion: b.hal_running_version, firstAgent: b.first_agent_id, manualInstallLocked: !!b.manual_install_locked }, instanceId: d.instanceId };
}
// ------------------------------------------------------------------ 서버·Agent
export function createServer(d, { name, role, location = '', by = null }) {
if (!SERVER_ROLES.includes(role)) throw new JobError('bad_role', `서버 역할은 ${SERVER_ROLES.join('/')} 중 하나입니다`);
const id = newId('srv_'); const now = nowIso();
d.store.run('INSERT INTO servers(id, name, role, location, status, created_at, updated_at) VALUES(?,?,?,?,\'pending\',?,?)', id, String(name).slice(0, 80), role, String(location).slice(0, 120), now, now);
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.create', subjectType: 'server', subjectId: id, detail: { name, role, location } });
return getServer(d, id);
}
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}) } : null; }
export function listServers(d, { role = null } = {}) { return d.store.all(`SELECT * FROM servers ${role ? 'WHERE role=?' : ''} ORDER BY created_at`, ...(role ? [role] : [])).map((s) => publicServer(d, s)); }
/** 화면·포털용 서버 요약(비밀 없음). offline 판정은 last_seen 기준(§28.1 heartbeat). */
export function publicServer(d, s) {
const seen = s.last_seen_at ? Date.now() - Date.parse(s.last_seen_at) : Infinity; const offline = seen > d.config.offlineAfterMs;
const status = s.status === 'pending' ? 'pending' : offline ? 'offline' : s.status;
const mods = d.store.all('SELECT module, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets WHERE server_id=?', s.id);
const capacity = parseJson(s.capacity_json, {}) || {}; const metrics = parseJson(s.metrics_json, null);
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms,
health: status === 'offline' ? 'down' : mods.some((m) => m.health === 'down') ? 'partial_outage' : mods.some((m) => m.health === 'degraded') ? 'degraded' : status === 'ready' ? 'ok' : 'unknown',
metrics, capacity: { diskFreeBytes: capacity.diskFreeBytes || 0, diskTotalBytes: capacity.diskTotalBytes || 0, memFreeBytes: capacity.memFreeBytes || 0, cpuLoad: capacity.cpuLoad || 0, sitesHosted: capacity.sitesHosted || 0, reservedBytes: capacity.reservedBytes || 0, safetyMarginBytes: capacity.safetyMarginBytes || Math.round((capacity.diskTotalBytes || 0) * 0.1), exhaustEta: capacity.exhaustEta || null },
modules: Object.fromEntries(mods.map((m) => [m.module, { target: m.target_version, actual: m.actual_version, drift: !!(m.target_version && m.actual_version && m.target_version !== m.actual_version), health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error }])),
maintenanceNote: s.maintenance_note, isolatedReason: s.isolated_reason, facts: parseJson(s.facts_json, {}) };
}
export function updateServer(d, id, patch, by = null) {
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role'];
const keys = Object.keys(patch).filter((k) => allowed.includes(k)); if (!keys.length) return getServer(d, id);
d.store.run(`UPDATE servers SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...keys.map((k) => patch[k]), nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.update', subjectType: 'server', subjectId: id, detail: patch });
return getServer(d, id);
}
/** 운영자 승인(§28.2 6단계): pending → ready, 신규 배치 허용 */
export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) throw new JobError('server_not_found', '서버가 없습니다'); if (!s.agent_id) throw new JobError('agent_missing', 'Agent 가 아직 연결되지 않았습니다'); updateServer(d, id, { status: 'ready', accepts_new: 1 }, by); d.store.run('UPDATE servers SET approved_at=?, approved_by=? WHERE id=?', nowIso(), by, id); maybeCompleteBootstrap(d, s.agent_id); return getServer(d, id); }
/**
* 신규 배치 가능 서버 고르기(§28.3): ready·accepts_new·여유 용량(안전 여유 제외).
* [실제 환경 결함 수정] 서버 한 대(역할 control)로 시작하는 운영에서는 web·backup 역할 서버가 없어 사이트 배포·백업 저장을 고를 수 없었다.
* 요청 역할의 서버가 없으면 control 서버(nginx 가 있는 관리 서버)를 web·backup 역할로도 쓴다 — 전용 서버가 생기면 그쪽이 우선한다.
*/
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
const fits = (s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes;
let cands = listServers(d, { role }).filter(fits);
if (!cands.length && ['web', 'backup'].includes(role)) cands = listServers(d, { role: 'control' }).filter((s) => fits(s) && (s.facts?.nginx !== false));
cands.sort((a, b) => (b.capacity.diskFreeBytes - b.capacity.reservedBytes) - (a.capacity.diskFreeBytes - a.capacity.reservedBytes));
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
}
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
const token = randomToken(32); const id = newId('rt_'); const expiresAt = new Date(Date.now() + ttlMs).toISOString();
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label } });
return { id, token, expiresAt, installCommand: `curl -fsSL ${d.config.publicUrl}/agent/v1/install.sh | bash -s -- --hal ${d.config.publicUrl} --token ${token}${kind === 'external' ? ` --external --site ${siteId}` : ''}` };
}
/**
* Agent 등록(토큰 1회 사용). 비밀은 보관소에 두고 응답에서 한 번만 돌려준다.
* @returns {{agentId, secret, serverId, kind}}
*/
export function registerAgent(d, { token, facts = {}, version = '' }) {
const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(String(token || '')));
if (!t) throw new JobError('bad_token', '등록 토큰이 올바르지 않습니다');
if (t.used_at) throw new JobError('token_used', '이미 사용한 등록 토큰입니다 — 새 토큰을 발급받으세요');
if (Date.parse(t.expires_at) < Date.now()) throw new JobError('token_expired', '등록 토큰이 만료되었습니다 — 새 토큰을 발급받으세요');
const agentId = newId('ag_'); const secret = randomToken(32); const now = nowIso();
return d.store.tx(() => {
const used = d.store.run('UPDATE registration_tokens SET used_at=?, used_by_agent=? WHERE id=? AND used_at IS NULL', now, agentId, t.id).changes;
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다');
let serverId = t.server_id;
if (t.kind === 'managed' && !serverId) { serverId = createServer(d, { name: facts.hostname || agentId, role: facts.role || 'web', location: facts.location || '' }).id; }
d.store.run('INSERT INTO agents(id, server_id, kind, secret_hash, status, label, group_id, site_id, version, registered_at, last_seen_at, facts_json) VALUES(?,?,?,?,\'active\',?,?,?,?,?,?,?)', agentId, serverId, t.kind, sha256(secret), t.label || facts.hostname || '', t.group_id, t.site_id, version, now, now, toJson(redact(facts)));
d.vault.set(`service:agent:${agentId}`, secret, { kind: 'agent-secret' });
if (serverId) d.store.run('UPDATE servers SET agent_id=?, last_seen_at=?, agent_version=?, facts_json=?, updated_at=? WHERE id=?', agentId, now, version, toJson(redact(facts)), now, serverId);
audit(d.store, { actorType: 'agent', actorId: agentId, action: 'agent.register', subjectType: t.kind === 'external' ? 'site' : 'server', subjectId: t.site_id || serverId, detail: { kind: t.kind, version, hostname: facts.hostname } });
return { agentId, secret, serverId, kind: t.kind, siteId: t.site_id, groupId: t.group_id };
});
}
export function getAgent(d, id) { return d.store.get('SELECT * FROM agents WHERE id=?', id) || null; }
export function revokeAgent(d, id, by = null) { d.store.run('UPDATE agents SET status=\'revoked\', revoked_at=? WHERE id=?', nowIso(), id); d.vault.remove(`service:agent:${id}`); d.store.run('UPDATE agent_jobs SET status=\'cancelled\', ended_at=? WHERE agent_id=? AND status IN (\'queued\',\'running\')', nowIso(), id); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.revoke', subjectType: 'agent', subjectId: id, immutable: true }); }
/** 외부 Agent 상태(포털 화면용, §8.1) */
export function externalAgentStatus(d, id) {
const a = getAgent(d, id); if (!a || a.kind !== 'external') return null;
const seen = a.last_seen_at ? Date.now() - Date.parse(a.last_seen_at) : Infinity;
const last = d.store.get('SELECT * FROM agent_jobs WHERE agent_id=? AND kind=\'site.deploy\' ORDER BY created_at DESC LIMIT 1', id);
const lastCheck = d.store.get('SELECT * FROM agent_jobs WHERE agent_id=? AND kind=\'site.check\' AND status=\'done\' ORDER BY created_at DESC LIMIT 1', id);
return { id: a.id, status: a.status === 'revoked' ? 'revoked' : !a.last_seen_at ? 'pending' : seen > d.config.offlineAfterMs ? 'offline' : 'connected', lastSeenAt: a.last_seen_at, version: a.version, siteId: a.site_id, facts: parseJson(a.facts_json, {}),
lastDeploy: last ? { id: last.id, status: last.status, at: last.ended_at || last.started_at || last.created_at, result: parseJson(last.result_json, null), error: last.error } : null,
lastCheck: lastCheck ? { at: lastCheck.ended_at, result: parseJson(lastCheck.result_json, null) } : null };
}
/** Webmaker 인벤토리를 2 MiB HTTP 한도 아래의 순차 배치로 받아 마지막 배치에서만 공개 facts에 원자 반영한다. */
export function webmakerInventoryBatch(d, agent, body = {}) {
if (!agent.server_id || agent.kind !== 'managed') throw new JobError('inventory_not_allowed', '관리 서버 Agent만 인벤토리를 제출할 수 있습니다');
const scanId = String(body.scanId || ''); const offset = Number(body.offset); const itemTotal = Number(body.itemTotal); const sites = body.sites;
if (!scanId || scanId.length > 100 || !Number.isInteger(offset) || offset < 0 || !Number.isInteger(itemTotal) || itemTotal < 0 || itemTotal > 10000 || !Array.isArray(sites) || sites.length > 1000) throw new JobError('bad_inventory_batch', '인벤토리 배치 형식이 올바르지 않습니다');
const safeText = (v, n) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) : null;
const safeCount = (v) => Number.isSafeInteger(Number(v)) && Number(v) >= 0 ? Number(v) : 0;
const states = new Set(['deployed', 'unpublished', 'draft', 'unknown']); const metadataStates = new Set(['ok', 'missing', 'invalid']);
const cleanSites = sites.map((site) => {
if (!site || typeof site !== 'object' || Array.isArray(site) || !/^[a-f0-9]{32}$/.test(String(site.id || ''))) throw new JobError('bad_inventory_batch', '사이트 항목 형식이 올바르지 않습니다');
const deploymentState = states.has(site.deploymentState) ? site.deploymentState : 'unknown'; const metadata = metadataStates.has(site.metadata) ? site.metadata : 'missing';
const domains = Array.isArray(site.domains) ? [...new Set(site.domains.map((x) => safeText(x, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100) : [];
return { id: site.id, title: safeText(site.title, 200), ownerId: safeText(site.ownerId, 128), status: safeText(site.status, 80), deploymentState, domains, updatedAt: safeText(site.updatedAt, 40), metadata, bytes: safeCount(site.bytes), files: safeCount(site.files) };
});
const metaIn = body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : {}; const safeTotal = Number(metaIn.total); const meta = { dataRoot: safeText(metaIn.dataRoot, 4096), dataDir: safeText(metaIn.dataDir, 4096), sitesRoot: safeText(metaIn.sitesRoot, 4096), total: Number.isSafeInteger(safeTotal) && safeTotal >= 0 && safeTotal <= 10000 ? safeTotal : itemTotal, truncated: !!metaIn.truncated, errors: Array.isArray(metaIn.errors) ? metaIn.errors.slice(0, 1000).map((x) => safeText(x, 500)).filter(Boolean) : [], errorsTruncated: !!metaIn.errorsTruncated, scannedAt: safeText(metaIn.scannedAt, 100) };
if (meta.total !== itemTotal || meta.truncated) throw new JobError('inventory_incomplete', '완전한 Webmaker 인벤토리만 반영할 수 있습니다');
return d.store.tx(() => {
d.store.run('DELETE FROM agent_inventory_staging WHERE updated_at < ?', new Date(Date.now() - 24 * 3600 * 1000).toISOString());
let row = d.store.get('SELECT * FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker');
if (offset === 0) { d.store.run('INSERT INTO agent_inventory_staging(agent_id, kind, scan_id, total, next_offset, meta_json, items_json, updated_at) VALUES(?,?,?,?,0,?,?,?) ON CONFLICT(agent_id, kind) DO UPDATE SET scan_id=excluded.scan_id,total=excluded.total,next_offset=0,meta_json=excluded.meta_json,items_json=\'[]\',updated_at=excluded.updated_at', agent.id, 'webmaker', scanId, itemTotal, toJson(meta), '[]', nowIso()); row = d.store.get('SELECT * FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker'); }
if (!row || row.scan_id !== scanId || row.total !== itemTotal || row.next_offset !== offset) throw new JobError('inventory_batch_order', '인벤토리 배치 순서가 맞지 않습니다 — 처음부터 다시 보내세요');
const items = parseJson(row.items_json, []); items.push(...cleanSites); if (items.length > itemTotal || offset + cleanSites.length !== items.length) throw new JobError('bad_inventory_batch', '인벤토리 항목 수가 선언값과 다릅니다'); const itemsJson = toJson(items); if (Buffer.byteLength(itemsJson) > 16 * 1024 * 1024) throw new JobError('inventory_too_large', '인벤토리 전체 크기가 안전 한도를 넘습니다');
const complete = items.length === itemTotal; d.store.run('UPDATE agent_inventory_staging SET next_offset=?, items_json=?, updated_at=? WHERE agent_id=? AND kind=?', items.length, itemsJson, nowIso(), agent.id, 'webmaker');
if (!complete) return { ok: true, complete: false, nextOffset: items.length };
const inventory = { ...parseJson(row.meta_json, {}), returned: items.length, sites: items }; const aFacts = parseJson(d.store.get('SELECT facts_json FROM agents WHERE id=?', agent.id)?.facts_json, {}) || {}; aFacts.webmakerInventory = inventory;
const sFacts = parseJson(d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id)?.facts_json, {}) || {}; sFacts.webmakerInventory = inventory;
d.store.run('UPDATE agents SET facts_json=? WHERE id=?', toJson(aFacts), agent.id); d.store.run('UPDATE servers SET facts_json=?, updated_at=? WHERE id=?', toJson(sFacts), nowIso(), agent.server_id); d.store.run('DELETE FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker');
return { ok: true, complete: true, nextOffset: items.length };
});
}
/** heartbeat 처리: 지표 저장, 서버 last_seen·시계 차이·모듈 실제 버전·용량 갱신 */
export function heartbeat(d, agent, body) {
const now = nowIso(); const m = body.metrics || {}; const skew = Number.isFinite(body.clockSkewMs) ? Math.round(body.clockSkewMs) : null;
const mergeFacts = (currentText) => { if (!body.facts) return null; const current = parseJson(currentText, {}) || {}; const incoming = redact(body.facts); if (!Object.hasOwn(incoming, 'webmakerInventory') && current.webmakerInventory) incoming.webmakerInventory = current.webmakerInventory; return toJson(incoming); };
const agentFacts = mergeFacts(agent.facts_json); d.store.run('UPDATE agents SET last_seen_at=?, version=?, facts_json=COALESCE(?, facts_json) WHERE id=?', now, body.version || agent.version, agentFacts, agent.id);
if (!agent.server_id) return { ok: true };
const capacity = { diskFreeBytes: m.disk ? m.disk.total - m.disk.used : 0, diskTotalBytes: m.disk?.total || 0, memFreeBytes: m.mem ? m.mem.total - m.mem.used : 0, cpuLoad: m.load1 || 0, sitesHosted: body.sitesHosted || 0, reservedBytes: body.reservedBytes || 0, exhaustEta: body.exhaustEta || null };
const serverRow = d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id); const serverFacts = mergeFacts(serverRow?.facts_json); d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), serverFacts, now, agent.server_id);
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
for (const [name, mod] of Object.entries(body.modules || {})) {
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
}
// [legacy 메일 서비스] Agent 가 보고한 arimail 모듈 상태를 그 서버의 HAL 관리형 메일 서비스 행에 비춘다(행이 없으면 무관)
try { syncFromHeartbeat(d, agent.server_id, body.modules || {}); } catch (e) { d.log && d.log('[hal] mail_services sync 실패', e.message); }
// 시계 차이·디스크 고갈 경고(§28.1)
if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` });
else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} }
if (m.disk && m.disk.total && m.disk.used / m.disk.total >= 0.9) notify(d.store, { category: 'ops', severity: m.disk.used / m.disk.total >= 0.95 ? 'critical' : 'warning', incidentKey: `server:${agent.server_id}:disk`, title: `서버 디스크 ${Math.round(m.disk.used / m.disk.total * 100)}% 사용`, link: `/ops/servers/${agent.server_id}` });
return { ok: true, serverTime: now, pendingJobs: d.store.get('SELECT COUNT(*) AS n FROM agent_jobs WHERE agent_id=? AND status=\'queued\'', agent.id).n };
}
/** 오래 heartbeat 가 없는 서버를 offline 으로 표시(주기 작업). 변경된 서버 목록 반환 */
export function markOffline(d) {
const cut = new Date(Date.now() - d.config.offlineAfterMs).toISOString();
const rows = d.store.all('SELECT id, name FROM servers WHERE status IN (\'ready\',\'maintenance\') AND agent_id IS NOT NULL AND (last_seen_at IS NULL OR last_seen_at<?)', cut);
for (const r of rows) { d.store.run('UPDATE servers SET status=\'offline\', updated_at=? WHERE id=?', nowIso(), r.id); notify(d.store, { category: 'incident', severity: 'critical', mandatory: false, incidentKey: `server:${r.id}:offline`, title: `서버 ${r.name} 응답 없음(Node Agent heartbeat 중단)`, link: `/ops/servers/${r.id}` }); }
return rows;
}
export function purgeMetrics(d) { const cut = new Date(Date.now() - d.config.metricsRetentionDays * 86400000).toISOString(); return d.store.run('DELETE FROM metrics_samples WHERE at<?', cut).changes; }
export function metrics(d, serverId, { since = null, until = null, limit = 5000 } = {}) { return d.store.all('SELECT * FROM metrics_samples WHERE server_id=? AND at>=? AND at<=? ORDER BY at DESC LIMIT ?', serverId, since || new Date(Date.now() - 90 * 86400000).toISOString(), until || nowIso(), Math.min(limit, 50000)); }
// ------------------------------------------------------------------ Agent 작업
/**
* 재진입 가능한 배정: 'dispatch' 단계에 저장된 Agent 작업이 실패·취소로 끝났으면(재시도) 새 Agent 작업을 만든다.
* 호출자는 ctx.step('dispatch', …) 대신 이 함수를 쓴다.
*/
export async function ensureAgentJob(d, ctx, job, { stepId = 'dispatch', agentId, kind, payload, title = '대상 Agent 에 작업 배정' }) {
const prev = ctx.steps().find((x) => x.id === stepId);
if (prev && prev.state === 'done' && prev.result?.agentJobId) { const aj = getAgentJob(d, prev.result.agentJobId); if (aj && ['failed', 'cancelled'].includes(aj.status)) { prev.state = 'pending'; delete prev.result; } }
return ctx.step(stepId, () => ({ agentId, agentJobId: dispatchAgentJob(d, { halJobId: job.id, agentId, kind, payload, dedupeKey: `${job.id}:${kind}:${agentId}:${job.attempt}` }) }), title);
}
/** Agent 작업 만들기(HAL 작업의 자식). 같은 hal_job_id+kind+agent 의 미완료 작업이 있으면 재사용(재진입 멱등). */
export function dispatchAgentJob(d, { halJobId, agentId, kind, payload, dedupeKey = null }) {
const agent = getAgent(d, agentId); if (!agent || agent.status !== 'active') throw new JobError('agent_unavailable', 'Agent 가 없거나 해지되었습니다');
if (agent.kind === 'external' && !EXTERNAL_KINDS.has(kind)) throw new JobError('kind_not_allowed_external', `외부 Agent 는 ${kind} 작업을 받을 수 없습니다`);
if (agent.kind === 'external' && payload?.siteId && agent.site_id && payload.siteId !== agent.site_id) throw new JobError('site_mismatch', '이 외부 Agent 는 다른 사이트에 귀속되어 있습니다');
const key = dedupeKey || `${halJobId}:${kind}:${agentId}`;
const ex = d.store.get('SELECT * FROM agent_jobs WHERE hal_job_id=? AND kind=? AND agent_id=? AND json_extract(payload_json, \'$._dedupe\')=?', halJobId, kind, agentId, key);
if (ex) return ex.id;
const id = newId('aj_');
d.store.run('INSERT INTO agent_jobs(id, hal_job_id, agent_id, kind, payload_json, status, created_at, steps_json, log_json) VALUES(?,?,?,?,?,\'queued\',?,\'[]\',\'[]\')', id, halJobId, agentId, kind, toJson({ ...payload, _dedupe: key }), nowIso());
return id;
}
export function getAgentJob(d, id) { const r = d.store.get('SELECT * FROM agent_jobs WHERE id=?', id); return r ? { ...r, payload: parseJson(r.payload_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), log: parseJson(r.log_json, []) } : null; }
/** Agent 가 다음 작업을 가져간다(long-poll 은 라우트가 반복 호출). queued 중 가장 오래된 것 하나 → running */
export function nextAgentJob(d, agentId) {
const r = d.store.get('SELECT id FROM agent_jobs WHERE agent_id=? AND status=\'queued\' ORDER BY created_at LIMIT 1', agentId); if (!r) return null;
const got = d.store.run('UPDATE agent_jobs SET status=\'running\', assigned_at=?, started_at=?, heartbeat_at=? WHERE id=? AND status=\'queued\'', nowIso(), nowIso(), nowIso(), r.id).changes;
if (!got) return null; const j = getAgentJob(d, r.id); delete j.payload._dedupe; return { id: j.id, kind: j.kind, payload: j.payload, halJobId: j.hal_job_id };
}
/** Agent 재시작 뒤 이어서 할 작업 목록(running 으로 남은 것) */
export function unfinishedAgentJobs(d, agentId) { return d.store.all('SELECT id, kind, payload_json, steps_json FROM agent_jobs WHERE agent_id=? AND status=\'running\'', agentId).map((r) => ({ id: r.id, kind: r.kind, payload: (() => { const p = parseJson(r.payload_json, {}); delete p._dedupe; return p; })(), steps: parseJson(r.steps_json, []) })); }
export function agentJobProgress(d, id, agentId, { progress, steps, log }) { const r = d.store.run('UPDATE agent_jobs SET progress=COALESCE(?, progress), steps_json=COALESCE(?, steps_json), log_json=COALESCE(?, log_json), heartbeat_at=? WHERE id=? AND agent_id=? AND status=\'running\'', progress ?? null, steps ? toJson(redact(steps)) : null, log ? toJson(redact(log)) : null, nowIso(), id, agentId).changes; return !!r; }
export function agentJobResult(d, id, agentId, { status, result, error, errorCode, userMessage, steps, log }) {
if (!['done', 'failed', 'needs_user'].includes(status)) throw new JobError('bad_status', 'Agent 결과 상태는 done/failed/needs_user 중 하나입니다');
const ch = d.store.run('UPDATE agent_jobs SET status=?, result_json=?, error=?, error_code=?, user_message=?, steps_json=COALESCE(?, steps_json), log_json=COALESCE(?, log_json), progress=CASE WHEN ?=\'done\' THEN 100 ELSE progress END, ended_at=? WHERE id=? AND agent_id=? AND status=\'running\'',
status, toJson(redact(result ?? null)), error ? safeError({ message: error }) : null, errorCode || null, userMessage || null, steps ? toJson(redact(steps)) : null, log ? toJson(redact(log)) : null, status, nowIso(), id, agentId).changes;
return !!ch;
}
export const isAgentJobCancelled = (d, id) => !!d.store.get('SELECT cancel_requested FROM agent_jobs WHERE id=?', id)?.cancel_requested;
/**
* HAL 작업(lib/jobs run) 안에서 Agent 작업이 끝날 때까지 기다린다. 진행률·단계를 HAL 작업에 비춘다.
* Agent 가 오래 응답하지 않으면(heartbeat 5분) 계속 기다리되 사유를 기록하고, 서버가 offline 이면 needs_user 로 멈춘다(운영자가 서버를 살리고 '이어서 진행').
*/
export async function awaitAgentJob(d, ctx, agentJobId, { timeoutMs = d.config.agentWaitTimeoutMs || 6 * 3600 * 1000, pollMs = d.config.agentPollMs || 1000 } = {}) {
const t0 = Date.now(); let lastProgress = -1;
while (true) {
const j = getAgentJob(d, agentJobId); if (!j) throw new JobError('agent_job_missing', 'Agent 작업 기록이 사라졌습니다');
if (j.status === 'done') return j.result;
if (j.status === 'failed') throw new JobError(j.error_code || 'agent_failed', j.error || 'Agent 작업 실패', { retryable: j.error_code === 'transient' });
if (j.status === 'needs_user') ctx.needsUser(j.user_message || 'Agent 가 사용자/운영자 확인을 요청합니다', [{ id: 'resume', label: '확인 후 이어서 진행' }]);
if (j.status === 'cancelled') throw new JobError('agent_job_cancelled', 'Agent 작업이 취소되었습니다');
try { ctx.checkCancel(); } catch (e) { d.store.run('UPDATE agent_jobs SET cancel_requested=1 WHERE id=?', agentJobId); throw e; }
if (j.progress !== lastProgress) { lastProgress = j.progress; ctx.progress(j.progress); }
const agent = getAgent(d, j.agent_id); const srv = agent?.server_id ? getServer(d, agent.server_id) : null;
const silent = agent?.last_seen_at ? Date.now() - Date.parse(agent.last_seen_at) : Infinity;
if (silent > 10 * 60 * 1000) ctx.needsUser(`대상 서버(${srv?.name || agent?.label || j.agent_id})의 Agent 가 10분 넘게 응답하지 않습니다. 서버 상태를 확인한 뒤 이어서 진행하세요`, [{ id: 'resume', label: 'Agent 복구 후 이어서 진행' }]);
if (Date.now() - t0 > timeoutMs) throw new JobError('agent_timeout', 'Agent 작업이 제한 시간을 넘겼습니다');
await new Promise((r) => setTimeout(r, pollMs));
}
}
// ------------------------------------------------------------------ 릴리스(§32.1)
/** 키 순서에 무관한 비교용 JSON(릴리스 재등록의 "같은 내용" 판정) */
function canonJson(v) { if (Array.isArray(v)) return '[' + v.map(canonJson).join(',') + ']'; if (v && typeof v === 'object') return '{' + Object.keys(v).sort().map((k) => JSON.stringify(k) + ':' + canonJson(v[k])).join(',') + '}'; return JSON.stringify(v === undefined ? null : v); }
/**
* 릴리스 등록(upsert). 0.2.0 검토 수정 3차 — **승인된 버전은 불변**: 같은 내용(main 커밋·빌드 시각·artifact·checks, 메모는 준 경우에만)의 재등록은 멱등하게 그대로 돌려주고(approve 요청이어도 이미 승인),
* 어느 하나라도 다른 재등록은 approve 여부와 무관하게 `release_immutable` 로 거절하며 아무것도 바꾸지 않는다(새 내용은 새 버전 번호로). 승인되지 않은 버전의 내용 변경 재등록은 내용을 갱신하되
* 상태를 registered 로 두고(승인자·시각 비움) 승인을 물려받지 않는다 — approve 가 참이면 releaseApprovalProblem 규칙을 지나야만 승인된다. CLI(hal/index.mjs register-release·scripts/release.mjs register)도 이 함수를 쓴다.
*/
/** 승인된 릴리스를 다른 내용으로 바꾸려는 등록인지(같은 내용이면 null). registerRelease·installArtifacts 가 같은 규칙을 쓴다 */
export function releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks = null, notes = null }) {
if (!ex || ex.status !== 'approved') return null;
const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes);
return same ? null : `승인된 릴리스 ${ex.version} 은 바꿀 수 없습니다 — artifact·검사 기록·main 커밋·빌드 시각·메모가 다른 빌드는 새 버전 번호로 등록하세요`;
}
/**
* artifact 설치(0.2.0 검토 수정 4차 — 불변·fail-closed). 등록(registerRelease) **전** 에 부른다.
* 1) 원본 파일마다 바이트의 sha256 과 크기를 manifest 의 선언값과 대조한다 — 다르면 `artifact_hash_mismatch` 로 거절(아무것도 쓰지 않는다).
* 2) 같은 버전이 이미 승인되어 있으면 메타데이터(releaseImmutableProblem)와 설치된 파일의 해시까지 같을 때만 멱등(복사하지 않음); 하나라도 다르면 `release_immutable` 로 거절하고 기존 파일·DB 를 건드리지 않는다.
* 3) 새 버전·미승인 버전은 대상 폴더의 임시 파일(.tmp-…)에 쓰고 해시를 다시 확인한 뒤 rename 으로 승격한다(원자적) — 승인된 파일은 어떤 경로로도 잘리거나 덮이지 않는다.
* @param {object} d HAL deps
* @param {{version:string, mainCommit:string, builtAt:string, checks?:object, notes?:string, artifacts:Record<string,{file:string, sha256:string, size?:number, src:string}>}} m src = 원본 파일 경로
* @returns {Record<string,{file:string, sha256:string, size:number}>} 등록에 넘길 artifact 목록(파일 이름만)
*/
export function installArtifacts(d, m) {
const entries = Object.entries(m.artifacts || {});
const declared = {}; const verified = {};
for (const [mod, a] of entries) {
if (!a || !a.file || !a.src) throw new JobError('artifact_missing', `${mod}: artifact 파일 정보가 없습니다`);
if (!existsSync(a.src)) throw new JobError('artifact_missing', `${mod}: artifact 파일이 없습니다 (${basename(a.src)})`);
const bytes = readFileSync(a.src); const sha = createHash('sha256').update(bytes).digest('hex');
if (String(a.sha256 || '').toLowerCase() !== sha) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 해시 불일치 — manifest ${String(a.sha256 || '').slice(0, 12)}… / 파일 ${sha.slice(0, 12)}… (설치·등록하지 않습니다)`);
if (a.size !== undefined && a.size !== null && Number(a.size) !== bytes.length) throw new JobError('artifact_hash_mismatch', `${mod}: artifact 크기 불일치 — manifest ${a.size} / 파일 ${bytes.length}`);
declared[mod] = { file: basename(a.file), sha256: sha, size: bytes.length }; verified[mod] = { bytes, ...declared[mod], src: a.src };
}
const ex = getRelease(d, m.version);
const dir = join(artifactDir(d), m.version);
if (ex && ex.status === 'approved') {
const why = releaseImmutableProblem(ex, { mainCommit: m.mainCommit, builtAt: m.builtAt, artifacts: declared, checks: m.checks ?? null, notes: m.notes ?? null });
if (why) throw new JobError('release_immutable', why);
for (const [mod, v] of Object.entries(verified)) { // 설치된 바이트도 같아야 멱등 — 아니면 승인된 파일을 건드리지 않고 거절
const p = join(dir, v.file); if (!existsSync(p)) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 파일이 없습니다 — 설치 상태를 운영자가 확인하세요`);
const cur = createHash('sha256').update(readFileSync(p)).digest('hex'); if (cur !== v.sha256) throw new JobError('release_immutable', `승인된 릴리스 ${m.version} 의 ${mod} artifact 는 바꿀 수 없습니다(설치된 파일과 바이트가 다름)`);
}
return declared; // 멱등 — 아무것도 쓰지 않는다
}
mkdirSync(dir, { recursive: true });
for (const [mod, v] of Object.entries(verified)) {
const target = join(dir, v.file);
if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') === v.sha256) continue; // 이미 같은 바이트
const tmp = join(dir, `.${v.file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`);
try { writeFileSync(tmp, v.bytes, { mode: 0o644 }); const again = createHash('sha256').update(readFileSync(tmp)).digest('hex'); if (again !== v.sha256) throw new JobError('artifact_hash_mismatch', `${mod}: 임시 파일 해시 불일치`); renameSync(tmp, target); }
catch (e) { try { unlinkSync(tmp); } catch (_) {} throw e; }
}
return declared;
}
export function registerRelease(d, { version, mainCommit, builtAt, artifacts, checks = null, notes = null, approve = false, by = null }) {
const now = nowIso(); const ex = getRelease(d, version);
if (ex) {
const same = ex.main_commit === mainCommit && ex.built_at === builtAt && canonJson(ex.artifacts) === canonJson(artifacts || {}) && canonJson(ex.checks) === canonJson(checks) && (notes == null || notes === ex.notes);
if (ex.status === 'approved') {
const why = releaseImmutableProblem(ex, { mainCommit, builtAt, artifacts, checks, notes }); if (why) throw new JobError('release_immutable', why);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}), idempotent: true, approved: true } });
return ex;
}
if (!same) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes), status=\'registered\', approved_by=NULL, approved_at=NULL WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version);
} else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now);
if (approve) approveRelease(d, version, by);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}) } });
return getRelease(d, version);
}
/**
* 릴리스 검사 기록이 승인 가능한지(0.2.0 검토 수정 2차 — fail-closed). 세 값이 **정확히** 있어야 한다: testsPassed === true(--skip-tests 빌드의 null·없음은 불가), dirty === false(작업 트리 깨끗함이 기록됨),
* provenanceClean === true(도입 소스 출처가 깨끗하다고 확인됨). 기록이 없거나(옛 빌드) 명시적 false 가 아닌 "모름" 도 승인하지 않는다. 승인 경로(approveRelease·registerRelease approve:true·운영자 API·release.mjs register --approve)가 모두 이 한 규칙을 쓴다.
* @returns {string|null} 승인할 수 없는 이유(없으면 null)
*/
export function releaseApprovalProblem(checks) {
const c = checks && typeof checks === 'object' ? checks : null;
if (!c) return '검사 기록(checks)이 없는 릴리스는 승인할 수 없습니다 — 테스트·작업 트리·도입 소스 출처가 확인된 빌드로 다시 등록하세요';
if (c.testsPassed !== true) return `테스트 통과가 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.testsPassed=${JSON.stringify(c.testsPassed ?? null)} — --skip-tests 빌드는 등록만 되고 승인은 불가)`;
if (c.dirty !== false) return `작업 트리가 깨끗하다고 기록되지 않은 릴리스는 승인할 수 없습니다 (checks.dirty=${JSON.stringify(c.dirty ?? null)} — main 에 병합된 커밋에서 빌드하세요)`;
if (c.provenanceClean !== true) return `도입 소스 출처(provenance)가 깨끗하다고 확인되지 않은 릴리스는 승인할 수 없습니다 (checks.provenanceClean=${JSON.stringify(c.provenanceClean ?? null)}${(c.provenanceProblems || []).length ? ': ' + c.provenanceProblems.join(' / ') : ''})`;
return null;
}
export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const why = releaseApprovalProblem(r.checks); if (why) throw new JobError('release_checks_failed', why); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); }
export function getRelease(d, version) { const r = d.store.get('SELECT * FROM releases WHERE version=?', version); return r ? { ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) } : null; }
export function listReleases(d) { return d.store.all('SELECT * FROM releases ORDER BY registered_at DESC').map((r) => ({ ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) })); }
export const artifactDir = (d) => join(d.config.dataDir, 'artifacts');
/** artifact 파일 경로·존재 확인 */
export function artifactPath(d, version, module) { const r = getRelease(d, version); const a = r && r.artifacts[module]; if (!a) return null; const p = join(artifactDir(d), version, a.file); return existsSync(p) ? { path: p, sha256: a.sha256, size: statSync(p).size } : null; }
// ------------------------------------------------------------------ 전송(transfer)
export function transferDir(d) { const p = join(d.config.dataDir, 'transfers'); mkdirSync(p, { recursive: true, mode: 0o700 }); return p; }
export function createTransfer(d, { id = newId('tr_'), agentId, sha256: sum, size, purpose = '', meta = null }) { const path = join(transferDir(d), id + '.bin'); d.store.run('INSERT OR REPLACE INTO transfers(id, created_by_agent, path, size_bytes, sha256, created_at, expires_at, purpose, meta_json) VALUES(?,?,?,?,?,?,?,?,?)', id, agentId, path, size, sum, nowIso(), new Date(Date.now() + d.config.transferTtlMs).toISOString(), purpose, toJson(meta)); return { id, path }; }
export function getTransfer(d, id) { const t = d.store.get('SELECT * FROM transfers WHERE id=? AND deleted_at IS NULL', id); return t && existsSync(t.path) ? t : null; }
// ------------------------------------------------------------------ 포털 이벤트 outbox(§6)
export function queuePortalEvent(d, payload) { d.store.run('INSERT INTO portal_events_outbox(id, payload_json, created_at, next_at) VALUES(?,?,?,?)', newId('ev_'), toJson(redact(payload)), nowIso(), nowIso()); }
/** outbox 전송(주기). 실패는 지수 백오프로 재시도하고 치명적으로 다루지 않는다. */
export async function flushPortalEvents(d, { fetchImpl = fetch } = {}) {
const secret = d.vault.get('service:hal'); if (!secret) return 0;
const rows = d.store.all('SELECT * FROM portal_events_outbox WHERE delivered_at IS NULL AND next_at<=? ORDER BY created_at LIMIT 50', nowIso()); let n = 0;
for (const r of rows) {
try { const res = await serviceFetch(d.config.portalUrl, 'hal', secret, 'POST', '/internal/hal-events', parseJson(r.payload_json, {}), { timeoutMs: 8000, fetchImpl }); if (!res.ok) throw new Error(`portal ${res.status} ${res.json?.error || ''}`); d.store.run('UPDATE portal_events_outbox SET delivered_at=? WHERE id=?', nowIso(), r.id); n++; }
catch (e) { const attempts = r.attempts + 1; d.store.run('UPDATE portal_events_outbox SET attempts=?, next_at=?, last_error=? WHERE id=?', attempts, new Date(Date.now() + Math.min(600000, 5000 * 2 ** attempts)).toISOString(), safeError(e), r.id); }
}
d.store.run('DELETE FROM portal_events_outbox WHERE delivered_at IS NOT NULL AND delivered_at<?', new Date(Date.now() - 7 * 86400000).toISOString());
return n;
}
export function ensureOpsAccountExists(d) { return d.store.get('SELECT COUNT(*) AS n FROM ops_accounts').n > 0; }
export { hashPassword };
+24
View File
@@ -0,0 +1,24 @@
/**
* hal/deps.mjs — HAL 의존성 묶음(deps) 만들기. index.mjs(운영)와 테스트가 같은 함수를 쓴다.
* deps = { store, vault, config, jobs, log, version, instanceId, serviceauth, notifyLib, fetch, emergencyRunner, jobsRunning, stopping }
*/
import { openDb } from '../lib/db.mjs';
import { openVault } from '../lib/vault.mjs';
import { createJobEngine } from '../lib/jobs.mjs';
import * as serviceauth from '../lib/serviceauth.mjs';
import * as notifyLib from '../lib/notify.mjs';
import { newId } from '../lib/crypto.mjs';
import { MIGRATIONS } from './schema.mjs';
import { registerKinds } from './jobs.mjs';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
export function createHalDeps({ config, dbFile = null, log = () => {}, fetchImpl = null, version = VERSION, concurrency = 64 } = {}) {
const store = openDb(dbFile || join(config.dataDir, 'hal.sqlite'), MIGRATIONS);
const vault = openVault(config.dataDir);
const d = { store, vault, config, log, version, instanceId: newId('hal_'), serviceauth, notifyLib, fetch: fetchImpl, emergencyRunner: null, jobsRunning: false, stopping: false };
d.jobs = createJobEngine(store, { log, workerId: `hal@${process.pid}`, concurrency, pollMs: 500 });
registerKinds(d.jobs, d);
return d;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* hal/dr.mjs — 플랫폼 재해복구(§34).
*
* 복구 대상: HAL 운영 정보(hal.sqlite: 서버·Agent·릴리스·작업·모듈 배치·운영자 계정·접근 설정), 진행 중 작업, 보관소(vault.json; 키 파일은 **포함하지 않는다** — 운영자가 따로 보관), config.json, artifact 목록.
* 메일 원문·고객 사이트 데이터는 대상이 아니다(고객용 백업·메일 정책 별도).
* - 설정(dr_config): RTO·RPO·복구 사본 주기·보존 개수·외부 보관 위치. 출시 검증에서 비어 있으면 안 된다(readiness).
* - 복구 사본(snapshot): `VACUUM INTO` 로 일관된 DB 사본 + vault.json + config.json 을 tar.gz 로 묶고 sha256·읽기 검증. 외부 보관 위치로 복사(지정 시).
* - 복원(restore): 별도 승인 + "기존 제어 위치 중지" 확인이 있어야 시작. 실제 파일 복원은 deploy/hal-restore.sh 가 하고, 새 HAL 기동 때 claimLive 가 "하나의 HAL" 을 보장한다.
* 복원 뒤 checks(운영자 로그인·HAL 제어·HTTPS·고객 서비스 1개 외부 확인)와 실제 상태 비교(diff) 를 기록한다.
* - 훈련(drill): 실제 측정 RTO/RPO 와 단계 결과. 마지막 훈련이 성공해야 '정식 운영 준비 완료'.
*/
import { newId } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { JobError } from '../lib/jobs.mjs';
import { createHash } from 'node:crypto';
import { mkdirSync, existsSync, readFileSync, writeFileSync, statSync, copyFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import * as core from './core.mjs';
export function getConfig(d) { return d.store.get('SELECT * FROM dr_config WHERE id=1') || { rto_minutes: null, rpo_minutes: null, snapshot_interval_minutes: null, retention_count: null, external_store: null }; }
export function setConfig(d, patch, by) {
const c = { ...getConfig(d), ...Object.fromEntries(Object.entries(patch).filter(([k]) => ['rto_minutes', 'rpo_minutes', 'snapshot_interval_minutes', 'retention_count', 'external_store'].includes(k))) };
for (const k of ['rto_minutes', 'rpo_minutes', 'snapshot_interval_minutes', 'retention_count']) if (c[k] != null && !(Number(c[k]) > 0)) throw new JobError('bad_value', `${k} 은(는) 0보다 커야 합니다`);
d.store.run('INSERT INTO dr_config(id, rto_minutes, rpo_minutes, snapshot_interval_minutes, retention_count, external_store, updated_at, updated_by) VALUES(1,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET rto_minutes=excluded.rto_minutes, rpo_minutes=excluded.rpo_minutes, snapshot_interval_minutes=excluded.snapshot_interval_minutes, retention_count=excluded.retention_count, external_store=excluded.external_store, updated_at=excluded.updated_at, updated_by=excluded.updated_by', c.rto_minutes, c.rpo_minutes, c.snapshot_interval_minutes, c.retention_count, c.external_store, nowIso(), by);
audit(d.store, { actorType: 'operator', actorId: by, action: 'dr.config', detail: c }); return getConfig(d);
}
export const snapshotDir = (d) => { const p = join(d.config.dataDir, 'dr'); mkdirSync(p, { recursive: true, mode: 0o700 }); return p; };
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
/** 복구 사본 만들기(동기, 수 초). DB 는 VACUUM INTO 로 일관 사본. */
export function snapshot(d, { kind = 'scheduled', by = null } = {}) {
const id = newId('drs_'); const dir = snapshotDir(d); const work = join(dir, id); mkdirSync(work, { recursive: true });
try {
const dbCopy = join(work, 'hal.sqlite'); d.store.exec(`VACUUM INTO '${dbCopy.replace(/'/g, "''")}'`);
for (const f of ['vault.json', 'config.json']) { const p = join(d.config.dataDir, f); if (existsSync(p)) copyFileSync(p, join(work, f)); }
const rel = core.listReleases(d).map((r) => ({ version: r.version, main_commit: r.main_commit, artifacts: r.artifacts, status: r.status })); const meta = { id, createdAt: nowIso(), halVersion: d.version, instanceId: d.instanceId, releases: rel, note: 'vault.key 는 포함하지 않음 — 운영자가 따로 보관' };
writeFileSync(join(work, 'meta.json'), JSON.stringify(meta, null, 1));
const tar = join(dir, `${id}.tar.gz`); execFileSync('tar', ['-czf', tar, '-C', work, '.']); rmSync(work, { recursive: true, force: true });
const sum = sha(tar); const size = statSync(tar).size;
// 읽기 검증: 목록을 읽을 수 있고 meta.json 이 들어 있어야 한다
const list = execFileSync('tar', ['-tzf', tar]).toString(); const ok = list.includes('meta.json') && list.includes('hal.sqlite');
d.store.run('INSERT INTO dr_snapshots(id, created_at, location, size_bytes, sha256, verify_state, verified_at, kind) VALUES(?,?,?,?,?,?,?,?)', id, nowIso(), tar, size, sum, ok ? 'ok' : 'failed', nowIso(), kind);
const cfg = getConfig(d);
if (ok && cfg.external_store) { try { mkdirSync(cfg.external_store, { recursive: true }); const ext = join(cfg.external_store, `${id}.tar.gz`); copyFileSync(tar, ext); if (sha(ext) !== sum) throw new Error('외부 사본 해시 불일치'); d.store.run('UPDATE dr_snapshots SET external_copied_at=?, external_location=? WHERE id=?', nowIso(), ext, id); } catch (e) { d.store.run('UPDATE dr_snapshots SET error=? WHERE id=?', `외부 보관 실패: ${e.message}`, id); } }
prune(d); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'dr.snapshot', subjectType: 'dr_snapshot', subjectId: id, detail: { size, ok, kind }, automatic: !by });
return d.store.get('SELECT * FROM dr_snapshots WHERE id=?', id);
} catch (e) { rmSync(work, { recursive: true, force: true }); d.store.run('INSERT INTO dr_snapshots(id, created_at, location, verify_state, kind, error) VALUES(?,?,?,\'failed\',?,?)', id, nowIso(), '', kind, e.message); throw e; }
}
/** 보존 개수 초과 사본 삭제(외부 사본은 유지) */
export function prune(d) { const n = getConfig(d).retention_count || 14; const rows = d.store.all('SELECT id, location FROM dr_snapshots WHERE deleted_at IS NULL AND verify_state=\'ok\' ORDER BY created_at DESC, rowid DESC'); let k = 0; for (const r of rows.slice(n)) { try { rmSync(r.location, { force: true }); } catch (_) {} d.store.run('UPDATE dr_snapshots SET deleted_at=? WHERE id=?', nowIso(), r.id); k++; } return k; }
export function listSnapshots(d) { return d.store.all('SELECT * FROM dr_snapshots ORDER BY created_at DESC, rowid DESC LIMIT 100'); }
/** 복원 요청·승인·시작. 시작에는 "기존 제어 위치 중지" 확인이 필요하다. */
export function requestRestore(d, { snapshotId, by, note = '' }) { const s = d.store.get('SELECT * FROM dr_snapshots WHERE id=? AND deleted_at IS NULL', snapshotId); if (!s || s.verify_state !== 'ok') throw new JobError('snapshot_unusable', '검증된 복구 사본이 아닙니다'); const id = newId('drr_'); d.store.run('INSERT INTO dr_restores(id, snapshot_id, requested_by, status, note) VALUES(?,?,?,\'requested\',?)', id, snapshotId, by, note); audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_request', subjectType: 'dr_restore', subjectId: id, immutable: true }); return d.store.get('SELECT * FROM dr_restores WHERE id=?', id); }
export function approveRestore(d, id, by) { d.store.run('UPDATE dr_restores SET approved_by=?, approved_at=?, status=\'approved\' WHERE id=? AND status=\'requested\'', by, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_approve', subjectType: 'dr_restore', subjectId: id, immutable: true }); return d.store.get('SELECT * FROM dr_restores WHERE id=?', id); }
export function startRestore(d, id, { by, oldControlStopped, confirmText }) {
const r = d.store.get('SELECT * FROM dr_restores WHERE id=?', id); if (!r || r.status !== 'approved') throw new JobError('restore_not_approved', '복원은 별도 승인 뒤에만 시작할 수 있습니다');
if (oldControlStopped !== true || confirmText !== '기존 제어 위치를 중지했습니다') throw new JobError('old_control_not_confirmed', '기존 제어 위치(HAL)의 중지 확인이 필요합니다');
const s = d.store.get('SELECT * FROM dr_snapshots WHERE id=?', r.snapshot_id);
d.store.run('UPDATE dr_restores SET old_control_stopped_confirmed_at=?, started_at=?, status=\'started\' WHERE id=?', nowIso(), nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_start', subjectType: 'dr_restore', subjectId: id, immutable: true });
return { restore: d.store.get('SELECT * FROM dr_restores WHERE id=?', id), command: `sudo bash /srv/ariweb/hal/current/deploy/hal-restore.sh ${s.location} ${s.sha256}`, note: '새 HAL 기동 때 다른 인스턴스의 최근 임대가 있으면 기동을 거부합니다(하나의 HAL 만 운영)' };
}
/** 복원 뒤 확인·차이 기록(§34: 운영자 로그인·HAL 제어·HTTPS·고객 서비스 1개 외부 확인 + 실제 상태 비교) */
export function finishRestore(d, id, { by, checks }) {
const req = ['operatorLogin', 'halControl', 'https', 'customerService']; const missing = req.filter((k) => !checks || checks[k] == null);
if (missing.length) throw new JobError('checks_incomplete', `필수 확인 항목이 비었습니다: ${missing.join(', ')}`);
const dup = d.store.get('SELECT COUNT(*) AS n FROM jobs WHERE status=\'running\'').n; const drift = d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)');
const ok = req.every((k) => checks[k] === true) && dup === 0; const diff = { runningJobsAtFinish: dup, drift, offline: core.markOffline(d).map((x) => x.id) };
d.store.run('UPDATE dr_restores SET finished_at=?, status=?, checks_json=?, diff_json=? WHERE id=?', nowIso(), ok ? 'completed' : 'completed_with_findings', toJson(checks), toJson(diff), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_finish', subjectType: 'dr_restore', subjectId: id, detail: { ok, diff }, immutable: true });
return d.store.get('SELECT * FROM dr_restores WHERE id=?', id);
}
/** 훈련 기록 */
export function startDrill(d, { by, snapshotId = null }) { const id = newId('drd_'); d.store.run('INSERT INTO dr_drills(id, started_at, snapshot_id, performed_by, steps_json, result) VALUES(?,?,?,?,\'[]\',\'running\')', id, nowIso(), snapshotId, by); return d.store.get('SELECT * FROM dr_drills WHERE id=?', id); }
export function finishDrill(d, id, { by, steps, measuredRtoMinutes, measuredRpoMinutes, findings = [] }) {
const req = ['hal', 'operatorLogin', 'vault', 'certBase', 'httpsCustomer']; const ok = req.every((k) => steps && steps[k] === true); const cfg = getConfig(d);
const within = cfg.rto_minutes && cfg.rpo_minutes ? measuredRtoMinutes <= cfg.rto_minutes && measuredRpoMinutes <= cfg.rpo_minutes : false;
const result = ok && within ? 'success' : 'failed'; const next = new Date(Date.now() + 90 * 86400000).toISOString();
d.store.run('UPDATE dr_drills SET finished_at=?, steps_json=?, measured_rto_minutes=?, measured_rpo_minutes=?, result=?, findings_json=?, next_due_at=? WHERE id=?', nowIso(), toJson(steps), measuredRtoMinutes, measuredRpoMinutes, result, toJson([...(findings || []), ...(within ? [] : ['측정값이 승인된 RTO/RPO 를 넘었거나 목표가 설정되지 않음'])]), next, id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.drill', subjectType: 'dr_drill', subjectId: id, detail: { result, measuredRtoMinutes, measuredRpoMinutes }, immutable: true });
return d.store.get('SELECT * FROM dr_drills WHERE id=?', id);
}
/** 정식 운영 준비 상태(§34): 설정 값이 모두 있고 마지막 훈련이 성공 */
export function readiness(d) {
const cfg = getConfig(d); const missing = ['rto_minutes', 'rpo_minutes', 'snapshot_interval_minutes', 'retention_count'].filter((k) => !(Number(cfg[k]) > 0));
const lastDrill = d.store.get('SELECT * FROM dr_drills WHERE result<>\'running\' ORDER BY started_at DESC, rowid DESC LIMIT 1'); const lastSnap = d.store.get('SELECT * FROM dr_snapshots WHERE verify_state=\'ok\' AND deleted_at IS NULL ORDER BY created_at DESC, rowid DESC LIMIT 1');
const ready = !missing.length && !!lastDrill && lastDrill.result === 'success' && !!lastSnap;
return { ready, missingConfig: missing, lastDrill: lastDrill ? { id: lastDrill.id, result: lastDrill.result, at: lastDrill.finished_at, measuredRtoMinutes: lastDrill.measured_rto_minutes, measuredRpoMinutes: lastDrill.measured_rpo_minutes, nextDueAt: lastDrill.next_due_at } : null, lastSnapshot: lastSnap ? { id: lastSnap.id, at: lastSnap.created_at, external: !!lastSnap.external_copied_at } : null, config: cfg, label: ready ? '정식 운영 준비 완료' : '정식 운영 준비 전(복구 훈련 또는 설정 필요)' };
}
+72
View File
@@ -0,0 +1,72 @@
/**
* hal/emergency.mjs — 긴급 복구(§33).
*
* HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용한다: 선언(incident key + 사유) 때 HAL 자체 건강이 정상이고 열린 critical 사건이 없으면 거부한다.
* 단계: declared → approved(서로 다른 내용을 확인하는 3단계: 1 영향 범위, 2 대상 식별정보 재입력, 3 최종 실행·되돌리기 가능 여부) → credential_issued(짧은 유효 시간, 1회 표시) →
* performing(허용 범위 안의 행동만: hal.restart / hal.restore_snapshot / vault.verify / cert.reissue_internal / artifact.restore / agent.reconnect) → verified → credential_revoked → reconciled → closed.
* 운영자가 한 명이어도 되지만 세 단계의 내용과 시각이 모두 기록되어야 한다. 일반 고객 서비스 배포(module.deploy 등)는 어떤 경우에도 거부한다.
*/
import { newId, randomToken, sha256, safeEqual } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { JobError } from '../lib/jobs.mjs';
import * as core from './core.mjs';
export const SCOPES = ['hal', 'hal_data', 'vault', 'cert_base', 'artifact_store', 'first_agent'];
export const ACTIONS = { 'hal.restart': 'hal', 'hal.restore_snapshot': 'hal_data', 'vault.verify': 'vault', 'cert.reissue_internal': 'cert_base', 'artifact.restore': 'artifact_store', 'agent.reconnect': 'first_agent' };
const STEP_TITLES = ['영향 범위 확인', '변경되지 않는 대상 식별정보 재확인', '최종 실행과 되돌리기 가능 여부 확인'];
function get(d, id) { const r = d.store.get('SELECT * FROM emergency_sessions WHERE id=?', id); if (!r) throw new JobError('emergency_not_found', '긴급 복구 세션이 없습니다'); return { ...r, scope: parseJson(r.scope_json, []), approvals: parseJson(r.approvals_json, []), actions: parseJson(r.actions_json, []), verify: parseJson(r.verify_json, null), reconcile: parseJson(r.reconcile_json, null) }; }
export const getSession = get;
export function listSessions(d) { return d.store.all('SELECT * FROM emergency_sessions ORDER BY declared_at DESC LIMIT 50').map((r) => get(d, r.id)); }
export function declare(d, { incidentKey, reason, scope, by, force = false }) {
if (!incidentKey || !reason) throw new JobError('declare_incomplete', '사건 번호와 복구 이유가 필요합니다');
const bad = (scope || []).filter((s) => !SCOPES.includes(s)); if (bad.length || !scope?.length) throw new JobError('bad_scope', `허용 대상은 ${SCOPES.join(', ')} 입니다`);
const health = core.selfHealth(d); const openCritical = d.store.get('SELECT id FROM incidents WHERE severity=\'critical\' AND resolved_at IS NULL');
if (health.ok && !openCritical && !force) throw new JobError('hal_healthy', 'HAL 정상 작업 기능을 쓸 수 있는 상태입니다 — 긴급 복구가 아니라 일반 작업을 쓰세요. 실제 장애면 사건을 먼저 등록하세요');
const id = newId('em_'); const now = nowIso();
d.store.run('INSERT INTO emergency_sessions(id, incident_key, reason, scope_json, status, declared_by, declared_at, hal_healthy_at_declare, updated_at) VALUES(?,?,?,?,\'declared\',?,?,?,?)', id, incidentKey, reason, toJson(scope), by, now, health.ok ? 1 : 0, now);
audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.declare', subjectType: 'emergency', subjectId: id, detail: { incidentKey, reason, scope, forced: force && health.ok }, immutable: true });
return get(d, id);
}
/** 3단계 승인. 각 단계는 서로 다른 입력을 요구한다(같은 문구 반복 불가). */
export function approve(d, id, { step, by, impactText, identifiers, finalConfirm, revertible }) {
const s = get(d, id); if (!['declared', 'approving'].includes(s.status)) throw new JobError('bad_state', `지금 상태(${s.status})에서는 승인할 수 없습니다`);
const n = s.approvals.length + 1; if (step !== n) throw new JobError('wrong_step', `지금은 ${n}단계(${STEP_TITLES[n - 1]})를 진행해야 합니다`);
let record;
if (n === 1) { if (!impactText || String(impactText).length < 10) throw new JobError('impact_required', '영향 범위를 구체적으로 적어야 합니다(10자 이상)'); record = { step: 1, title: STEP_TITLES[0], impactText, by, at: nowIso() }; }
else if (n === 2) { const need = s.scope.slice().sort(); const got = (identifiers || []).slice().sort(); if (JSON.stringify(need) !== JSON.stringify(got)) throw new JobError('identifiers_mismatch', `대상 식별정보가 선언한 범위(${need.join(', ')})와 다릅니다`); record = { step: 2, title: STEP_TITLES[1], identifiers: got, by, at: nowIso() }; }
else { if (finalConfirm !== `복구 실행 ${s.incident_key}`) throw new JobError('final_confirm_mismatch', `최종 확인 문구는 "복구 실행 ${s.incident_key}" 이어야 합니다`); if (typeof revertible !== 'boolean') throw new JobError('revertible_required', '되돌리기 가능 여부를 명시해야 합니다'); record = { step: 3, title: STEP_TITLES[2], finalConfirm: true, revertible, by, at: nowIso() }; }
const approvals = [...s.approvals, record]; const status = approvals.length === 3 ? 'approved' : 'approving';
d.store.run('UPDATE emergency_sessions SET approvals_json=?, status=?, updated_at=? WHERE id=?', toJson(approvals), status, nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: `emergency.approve_${n}`, subjectType: 'emergency', subjectId: id, detail: record, immutable: true });
return get(d, id);
}
/** 자격 발급(승인 3단계 완료 뒤, 1회 표시, 짧은 유효 시간) */
export function issueCredential(d, id, by) {
const s = get(d, id); if (s.status !== 'approved' || s.approvals.length < 3) throw new JobError('approvals_insufficient', `승인이 부족합니다(${s.approvals.length}/3)`);
const cred = randomToken(32); const exp = new Date(Date.now() + d.config.emergencyTtlMinutes * 60000).toISOString();
d.store.run('UPDATE emergency_sessions SET credential_hash=?, credential_issued_at=?, credential_expires_at=?, status=\'credential_issued\', updated_at=? WHERE id=?', sha256(cred), nowIso(), exp, nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.credential_issue', subjectType: 'emergency', subjectId: id, detail: { expiresAt: exp }, immutable: true });
return { credential: cred, expiresAt: exp };
}
function checkCredential(s, credential) { if (!s.credential_hash) throw new JobError('no_credential', '자격이 발급되지 않았습니다'); if (s.credential_revoked_at) throw new JobError('credential_revoked', '자격이 폐기되었습니다'); if (Date.parse(s.credential_expires_at) < Date.now()) throw new JobError('credential_expired', '긴급 자격이 만료되었습니다 — 새로 승인·발급하세요'); if (!safeEqual(sha256(String(credential || '')), s.credential_hash)) throw new JobError('bad_credential', '자격이 올바르지 않습니다'); }
/** 행동 수행. 허용 범위 밖·일반 배포는 거부. 실행기(d.emergencyRunner)가 실제 동작을 하고 결과를 돌려준다(테스트는 가짜). */
export async function perform(d, id, { credential, action, target = null, by }) {
const s = get(d, id); checkCredential(s, credential);
if (!['credential_issued', 'performing'].includes(s.status)) throw new JobError('bad_state', `지금 상태(${s.status})에서는 수행할 수 없습니다`);
const scopeOf = ACTIONS[action];
if (!scopeOf) { log(d, id, { action, target, by, result: 'rejected', reason: 'not_an_emergency_action' }); throw new JobError('action_forbidden', `긴급 복구에서 허용되지 않는 행동입니다: ${action} (일반 배포는 HAL 정상화 뒤 HAL 작업으로만)`); }
if (!s.scope.includes(scopeOf)) { log(d, id, { action, target, by, result: 'rejected', reason: 'out_of_scope' }); throw new JobError('out_of_scope', `선언한 허용 대상(${s.scope.join(', ')}) 밖의 행동입니다: ${action}`); }
let result; try { result = d.emergencyRunner ? await d.emergencyRunner(action, target, s) : { ok: true, simulated: true }; } catch (e) { log(d, id, { action, target, by, result: 'failed', error: e.message }); throw e; }
log(d, id, { action, target, by, result: 'ok', detail: result }); d.store.run('UPDATE emergency_sessions SET status=\'performing\', updated_at=? WHERE id=?', nowIso(), id);
return result;
}
function log(d, id, entry) { const s = get(d, id); const actions = [...s.actions, { at: nowIso(), ...entry }]; d.store.run('UPDATE emergency_sessions SET actions_json=?, updated_at=? WHERE id=?', toJson(actions), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: entry.by, action: 'emergency.action', subjectType: 'emergency', subjectId: id, outcome: entry.result === 'ok' ? 'ok' : entry.result === 'rejected' ? 'denied' : 'failed', detail: entry, immutable: true }); }
/** 실제 결과 확인 기록 → 자격 폐기 */
export function verifyAndRevoke(d, id, { by, verify }) { const s = get(d, id); if (!['performing', 'credential_issued'].includes(s.status)) throw new JobError('bad_state', '수행 단계가 아닙니다'); const health = core.selfHealth(d); const v = { ...(verify || {}), halHealth: health.ok, at: nowIso() }; d.store.run('UPDATE emergency_sessions SET verify_json=?, credential_revoked_at=?, status=\'verified\', updated_at=? WHERE id=?', toJson(v), nowIso(), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.verify_revoke', subjectType: 'emergency', subjectId: id, detail: v, immutable: true }); return get(d, id); }
/** HAL 정상화 뒤 실제 상태와 원하는 상태 비교(모듈 목표·실제 버전 차이, 오프라인 서버) → 차이 목록 */
export function reconcile(d, id, by) { const s = get(d, id); if (s.status !== 'verified') throw new JobError('bad_state', '검증·자격 폐기 뒤에 조정할 수 있습니다'); const drift = d.store.all('SELECT module, server_id, target_version, actual_version, health FROM module_targets WHERE (target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)) OR health=\'down\''); const offline = core.markOffline(d); const r = { at: nowIso(), drift, offlineServers: offline.map((x) => x.id), halHealth: core.selfHealth(d).ok }; d.store.run('UPDATE emergency_sessions SET reconcile_json=?, status=\'reconciled\', updated_at=? WHERE id=?', toJson(r), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.reconcile', subjectType: 'emergency', subjectId: id, detail: r, immutable: true }); return r; }
export function close(d, id, by, note = '') { const s = get(d, id); if (!['reconciled', 'verified', 'declared', 'approving', 'approved'].includes(s.status)) throw new JobError('bad_state', '지금 상태에서는 종료할 수 없습니다'); if (s.credential_hash && !s.credential_revoked_at) d.store.run('UPDATE emergency_sessions SET credential_revoked_at=? WHERE id=?', nowIso(), id); d.store.run('UPDATE emergency_sessions SET status=\'closed\', closed_at=?, closed_by=?, updated_at=? WHERE id=?', nowIso(), by, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.close', subjectType: 'emergency', subjectId: id, detail: { note }, immutable: true }); return get(d, id); }
/** 만료 자격 자동 폐기(주기) */
export function expireCredentials(d) { return d.store.run('UPDATE emergency_sessions SET credential_revoked_at=?, updated_at=? WHERE credential_hash IS NOT NULL AND credential_revoked_at IS NULL AND credential_expires_at<?', nowIso(), nowIso(), nowIso()).changes; }
+110
View File
@@ -0,0 +1,110 @@
/**
* hal/index.mjs — HAL 프로세스 진입점과 CLI.
*
* node hal/index.mjs 서버 실행(기본). 기동 때 "하나의 HAL" 임대를 잡고(§34) 실행 버전을 기록한다(§32).
* node hal/index.mjs setup-token 최초 운영자 계정을 만들 설정 토큰을 발급(1회 표시, data/setup.token). 부트스트랩 스크립트가 부른다.
* node hal/index.mjs grant --login <id> [--hours 8] 허용 네트워크 밖 기기용 접근권 URL 발급(§25)
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
* node hal/index.mjs server-token --server <srv_id> [--by <who>] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록)
* node hal/index.mjs mail-import --discovery <json> (--server <srv_id> | --create-server --name <n> [--location <l>]) [--apply] [--by <who>]
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
* node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음)
* node hal/index.mjs config-set [--publicUrl <u>] [--portalUrl <u>] [--offlineAfterMs <n>] [--metricsRetentionDays <n>] [--by <who>] [--reason <text>]
* 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다
* node hal/index.mjs module-deploy --module <m> --version <v> --servers <srv_id[,srv_id]> [--by <who>] [--reason <text>] [--nonce <n>]
* 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다
* node hal/index.mjs job --id <j_id> 작업·자식 작업 readback(JSON)
*/
import { loadConfig } from './config.mjs';
import { createHalDeps, VERSION } from './deps.mjs';
import { buildApp } from './app.mjs';
import * as core from './core.mjs';
import * as ops from './ops.mjs';
import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import * as mailsvc from './mailsvc.mjs';
import { purgeSessions } from '../lib/session.mjs';
import { audit } from '../lib/audit.mjs';
import { describe } from '../lib/jobs.mjs';
import { descriptor } from './modules/index.mjs';
import { randomToken } from '../lib/crypto.mjs';
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
const log = (...a) => console.log(new Date().toISOString(), ...a);
const cfg = loadConfig(); const args = process.argv.slice(2); const cmd = args[0] && !args[0].startsWith('--') ? args[0] : 'serve';
const opt = (name, def = null) => { const i = args.indexOf('--' + name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def; };
const d = createHalDeps({ config: cfg, log });
if (cmd === 'setup-token') {
if (core.ensureOpsAccountExists(d)) { console.error('운영자 계정이 이미 있습니다 — 설정 토큰은 최초 1회만 쓸 수 있습니다'); process.exit(1); }
const t = randomToken(24); writeFileSync(join(cfg.dataDir, 'setup.token'), t, { mode: 0o600 });
console.log(`설정 토큰(1회 표시, 공유 금지): ${t}\n운영자 콘솔 ${cfg.publicUrl}/ops/ 의 최초 설정 또는 curl -X POST ${cfg.publicUrl}/ops/api/setup -d '{"setupToken":"…","login":"…","password":"…","displayName":"…"}' 로 첫 운영자 계정을 만드세요`);
} else if (cmd === 'grant') {
const g = ops.issueGrant(d, { accountId: opt('login'), hours: Number(opt('hours', 8)), by: null, reason: opt('reason', 'cli') }); console.log(`접근권 URL(1회, ${g.expiresAt} 까지): ${g.url}`);
} else if (cmd === 'register-release') {
const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8'));
// 0.2.0 검토 수정 4차: artifact 설치는 불변·fail-closed — 원본 바이트를 manifest 해시·크기와 대조하고, 승인된 버전은 같은 바이트·메타데이터일 때만 멱등(복사 없음), 다르면 기존 파일·DB 를 건드리지 않고 거부. 새 설치는 임시 파일 → rename
let r; try {
const artifacts = core.installArtifacts(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, checks: mf.checks || null, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size, src: join(dirname(opt('manifest')), a.file) }])) });
r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') });
} catch (e) { console.error(`릴리스 등록 거부 (${e.code || 'error'}): ${e.message}`); process.exit(1); } // 해시 불일치(artifact_hash_mismatch)·승인된 버전 불변(release_immutable)·승인 규칙(release_checks_failed) — 종료 1, 파일·DB 불변
console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`);
} else if (cmd === 'link-portal') {
// [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다.
const shared = randomToken(32); d.vault.set('service:portal', shared, { kind: 'service-key' }); d.vault.set('service:hal', shared, { kind: 'service-key' });
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
} else if (cmd === 'bootstrap-release') {
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
} else if (cmd === 'server-token') {
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` });
console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`);
} else if (cmd === 'mail-import') {
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
let serverId = opt('server'); const by = `cli:${opt('by', 'cli')}`;
if (!serverId && opt('create-server')) { const s = core.createServer(d, { name: opt('name', doc.source.hostname), role: 'mail', location: opt('location', ''), by }); serverId = s.id; console.log(`서버 생성(pending, Agent 미연결): ${s.id} ${s.name}`); }
if (!serverId) { console.error('--server <srv_id> 또는 --create-server 가 필요합니다'); process.exit(1); }
try { const r = mailsvc.applyImport(d, doc, { serverId, by, dryRun: !opt('apply') }); console.log(JSON.stringify({ importId: r.importId, mode: r.mode, serverId, authority: r.plan.authority, summary: r.plan.summary, items: r.plan.items, missingInSource: r.plan.missingInSource, created: r.created, updated: r.updated }, null, 1)); if (r.mode === 'rejected') process.exit(3); }
catch (e) { console.error(`가져오기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
} else if (cmd === 'mail-import-rollback') {
try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
} else if (cmd === 'mail-services') {
console.log(JSON.stringify(mailsvc.readback(d), null, 1));
} else if (cmd === 'config-set') {
// 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:<by>.
const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) { const v = opt(k); if (v != null && v !== true) patch[k] = String(v); }
if (!Object.keys(patch).length) { console.error('바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상'); process.exit(1); }
const by = `cli:${opt('by', 'cli')}`; cfg.set(patch, by);
audit(d.store, { actorType: 'operator', actorId: by, action: 'config.update', subjectType: 'config', subjectId: 'hal', detail: { keys: Object.keys(patch), values: patch, reason: opt('reason', '') } });
console.log(JSON.stringify({ updated: Object.keys(patch), file: cfg.file, note: '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' }));
} else if (cmd === 'module-deploy') {
// 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다.
const module = opt('module'); const version = opt('version'); const serverIds = String(opt('servers', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const by = `cli:${opt('by', 'cli')}`;
if (!module || module === true || !version || version === true || !serverIds.length) { console.error('--module <name> --version <v> --servers <srv_id[,srv_id]> 가 필요합니다'); process.exit(1); }
try { descriptor(module); } catch (e) { console.error(e.message); process.exit(1); }
if (!core.getRelease(d, version)) { console.error(`릴리스 ${version} 이(가) 등록되어 있지 않습니다`); process.exit(1); }
const missing = serverIds.filter((sid) => !core.getServer(d, sid)); if (missing.length) { console.error(`없는 서버: ${missing.join(', ')}`); process.exit(1); }
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds }, idempotencyKey: `module.deploy:${module}:${version}:${serverIds.slice().sort().join(',')}:${opt('nonce', '') === true ? '' : opt('nonce', '')}`, createdBy: by, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${version} 배포` });
audit(d.store, { actorType: 'operator', actorId: by, action: 'deploy.module.request', subjectType: 'module', subjectId: module, detail: { version, serverIds, reason: opt('reason', ''), jobId: r.job.id, created: r.created } });
console.log(JSON.stringify({ jobId: r.job.id, created: r.created, status: r.job.status, kind: r.job.kind, label: r.job.label, note: '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' }));
} else if (cmd === 'job') {
const j = opt('id') && opt('id') !== true ? d.jobs.get(opt('id')) : null; if (!j) { console.error('--id <j_id> 가 필요합니다(없는 작업)'); process.exit(1); }
console.log(JSON.stringify({ job: describe(j), children: d.jobs.list({ parentId: j.id, limit: 100 }).map(describe) }, null, 1));
} else if (cmd === 'serve') {
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
const app = buildApp(d); d.jobs.start(); d.jobsRunning = true;
const every = (ms, fn) => setInterval(() => { try { const r = fn(); if (r && r.catch) r.catch((e) => log('[hal] 주기 작업 오류', e.message)); } catch (e) { log('[hal] 주기 작업 오류', e.message); } }, ms).unref();
every(20000, () => core.renewLease(d, d.instanceId)); every(30000, () => core.markOffline(d)); every(3000, () => core.flushPortalEvents(d)); every(60000, () => emergency.expireCredentials(d));
every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); });
every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); });
await app.listen(cfg.port, cfg.host);
log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`);
process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); });
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
+167
View File
@@ -0,0 +1,167 @@
/**
* hal/jobs.mjs — HAL 작업 종류(lib/jobs 엔진에 등록).
*
* 두 부류가 있다.
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
*
* 대상(target) 해석: 서버 id(srv_…) → 그 서버의 Agent, Agent id(ag_…) → 그 Agent(외부 Agent 포함).
*/
import { JobError } from '../lib/jobs.mjs';
import { nowIso, toJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { notify } from '../lib/notify.mjs';
import { safeError } from '../lib/redact.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as core from './core.mjs';
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES } from './modules/index.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** 대상 → Agent id */
function agentFor(d, target) {
if (!target) throw new JobError('target_required', '대상 서버 또는 Agent 가 필요합니다');
if (String(target).startsWith('ag_')) { const a = core.getAgent(d, target); if (!a || a.status !== 'active') throw new JobError('agent_unavailable', 'Agent 가 없거나 해지되었습니다'); return a.id; }
const s = core.getServer(d, target); if (!s) throw new JobError('server_not_found', `서버가 없습니다: ${target}`);
if (!s.agent_id) throw new JobError('agent_missing', `서버 ${s.name} 에 Agent 가 없습니다`);
if (s.status === 'isolated') throw new JobError('server_isolated', `서버 ${s.name} 은(는) 격리 상태입니다`);
return s.agent_id;
}
/** 외부 접속 확인(§11.2 8단계). 실제 응답을 본다. */
async function externalCheck(d, url, { expectMarker = null } = {}) {
if (!url) return { ok: false, skipped: 'no_url' };
const t0 = Date.now();
try { const r = await (d.fetch || fetch)(url, { redirect: 'follow', signal: AbortSignal.timeout(15000), headers: { 'User-Agent': 'Ariweb-HAL-check/0.1' } }); const text = expectMarker ? await r.text() : ''; return { ok: r.ok && (!expectMarker || text.includes(expectMarker)), status: r.status, ms: Date.now() - t0, url, markerFound: expectMarker ? text.includes(expectMarker) : null }; }
catch (e) { return { ok: false, error: safeError(e), ms: Date.now() - t0, url }; }
}
/** 단일 Agent 작업으로 끝나는 종류를 등록하는 도우미 */
function single(engine, d, kind, { title, agentKind = kind, steps = [], build = (s) => s.payload || {}, after = null, maxAttempts = 1, target = (s) => s.target }) {
engine.register(kind, { title, maxAttempts, steps: [{ id: 'dispatch', title: '대상 Agent 에 작업 배정' }, { id: 'await', title: '대상 서버에서 실행' }, ...steps],
async run(job, ctx) {
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, target(job.subject)), kind: agentKind, payload: build(job.subject, d) });
const result = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
return after ? after(job, ctx, result) : result;
},
retryable: (e) => !!e.retryable,
});
}
export function registerKinds(engine, d) {
// ---------------- 사이트(§11)
single(engine, d, 'site.deploy', { title: '사이트 배포', steps: [{ id: 'external', title: '외부 접속 확인' }], build: (s) => ({ ...s.payload, siteId: s.payload.siteId }),
async after(job, ctx, result) {
const ext = await ctx.step('external', () => externalCheck(d, job.subject.payload.externalCheckUrl || result?.siteUrl, { expectMarker: job.subject.payload.expectMarker }));
return { ...result, externalCheck: ext };
} });
single(engine, d, 'site.rollback', { title: '이전 버전으로 되돌리기' });
single(engine, d, 'site.remove', { title: '배포 제거' });
single(engine, d, 'site.check', { title: '사이트 확인', async after(job, ctx, result) { const urls = job.subject.payload?.urls || []; const ext = await ctx.step('external', async () => { const out = []; for (const u of urls) out.push(await externalCheck(d, u, { expectMarker: job.subject.payload?.expectMarker })); return out; }, '외부 확인'); return { ...result, external: ext }; } });
single(engine, d, 'site.usage', { title: '사용량 수집', maxAttempts: 3 });
single(engine, d, 'domain.vhost', { title: '도메인 연결 설정' });
single(engine, d, 'domain.cert', { title: 'HTTPS 인증서 발급', maxAttempts: 2 });
single(engine, d, 'domain.probe', { title: 'DNS 확인', maxAttempts: 3 });
// ---------------- 백업(§19·§20). 저장 서버가 운영 서버와 다르면 HAL 전송을 거쳐 옮긴다.
engine.register('backup.create', { title: '백업 만들기', steps: [{ id: 'create', title: '운영 서버에서 백업 생성' }, { id: 'store', title: '백업 저장소로 복사' }, { id: 'verify', title: '읽을 수 있는지 확인' }],
async run(job, ctx) {
const s = job.subject; const p = s.payload || {}; const siteAgent = agentFor(d, s.target); const storeServer = p.storeServerId && p.storeServerId !== s.target ? p.storeServerId : null;
const created = await ctx.step('create', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: siteAgent, kind: 'backup.create', payload: { ...p, uploadToHal: !!storeServer, transferId: `tr_bk_${job.id}` } }); return core.awaitAgentJob(d, ctx, id); });
let location = `${s.target}:${created.path}`; let stored = created;
if (storeServer) stored = await ctx.step('store', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, storeServer), kind: 'backup.store', payload: { siteId: p.siteId, backupId: created.backupId, transferId: created.transferId, sha256: created.checksum, sizeBytes: created.sizeBytes } }); const r = await core.awaitAgentJob(d, ctx, id); location = `${storeServer}:${r.path}`; return r; });
else await ctx.step('store', () => ({ skipped: '같은 서버에 보관(저장 서버 미지정)', warning: '운영 서버와 분리된 저장소를 권장합니다(§19.2)' }));
const verified = await ctx.step('verify', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, storeServer || s.target), kind: 'backup.verify', payload: { location: stored.path, checksum: created.checksum } }); return core.awaitAgentJob(d, ctx, id); });
if (!verified?.ok) throw new JobError('backup_unreadable', '생성한 백업을 읽을 수 없습니다');
return { backupId: created.backupId, location, sizeBytes: created.sizeBytes, checksum: created.checksum, verified: true, createdAt: created.createdAt, scope: created.scope };
} });
const splitLoc = (loc) => { const i = String(loc).indexOf(':'); return i > 0 ? { serverId: loc.slice(0, i), path: loc.slice(i + 1) } : { serverId: null, path: loc }; };
single(engine, d, 'backup.verify', { title: '백업 검증', target: (s) => splitLoc(s.payload.location).serverId || s.target, build: (s) => ({ location: splitLoc(s.payload.location).path, checksum: s.payload.checksum }) });
single(engine, d, 'backup.delete', { title: '백업 삭제', target: (s) => splitLoc(s.payload.location).serverId || s.target, build: (s) => ({ location: splitLoc(s.payload.location).path }) });
engine.register('backup.restore', { title: '사이트 복원', steps: [{ id: 'fetch', title: '백업 저장소에서 가져오기' }, { id: 'protect', title: '현재 상태 보호' }, { id: 'isolated', title: '격리된 환경에서 백업 확인' }, { id: 'restore', title: '데이터 복원' }, { id: 'external', title: '외부 접속 확인' }],
async run(job, ctx) {
const s = job.subject; const p = s.payload || {}; const loc = splitLoc(p.location); const siteAgent = agentFor(d, s.target); const remote = loc.serverId && loc.serverId !== s.target;
const fetched = await ctx.step('fetch', async () => { if (!remote) return { path: loc.path }; const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, loc.serverId), kind: 'backup.fetch', payload: { location: loc.path, transferId: `tr_rs_${job.id}`, checksum: p.checksum } }); return core.awaitAgentJob(d, ctx, id); });
const r = await ctx.step('restore', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: siteAgent, kind: 'backup.restore', payload: { ...p, location: remote ? null : fetched.path, transferId: remote ? fetched.transferId : null } }); return core.awaitAgentJob(d, ctx, id); });
await ctx.step('protect', () => ({ protectPointLocation: r.protectPointLocation })); await ctx.step('isolated', () => ({ ok: r.isolatedVerify }));
const ext = await ctx.step('external', () => externalCheck(d, p.externalCheckUrl, { expectMarker: p.expectMarker }));
if (p.externalCheckUrl && !ext.ok) throw new JobError('restore_external_check_failed', '복원 뒤 외부 접속 확인에 실패했습니다 — 보호 시점으로 되돌릴 수 있습니다', { detail: ext });
return { ...r, externalCheck: ext };
},
async revert(job, ctx) { const s = job.subject; const r = job.steps.find((x) => x.id === 'restore')?.result; if (!r?.protectPointLocation) throw new JobError('no_protect_point', '보호 시점이 없어 되돌릴 수 없습니다'); await ctx.step('revert', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, s.target), kind: 'backup.restore', payload: { siteId: s.payload.siteId, location: r.protectPointLocation, protectPoint: false, isolatedVerify: true }, dedupeKey: `${job.id}:revert` }); return core.awaitAgentJob(d, ctx, id); }, '보호 시점으로 되돌리기'); } });
// ---------------- 서버 이전(§21). 두 서버에 동시에 운영 상태가 되지 않도록 verify_new 뒤에만 switch 한다.
engine.register('site.relocate', { title: '서버 이전', steps: ['precheck', 'prepare', 'precopy', 'verify_copy', 'final_sync', 'verify_new', 'switch', 'external_check', 'cleanup'].map((id) => ({ id, title: { precheck: '조건 확인', prepare: '새 위치 준비', precopy: '데이터 사전 복사', verify_copy: '복사 결과 확인', final_sync: '최종 변경분 반영', verify_new: '새 위치 확인', switch: '연결 전환', external_check: '외부 접속 확인', cleanup: '이전 위치 정리' }[id] })),
async run(job, ctx) {
const p = job.subject.payload || {}; const from = agentFor(d, p.fromServerId); const to = agentFor(d, p.toServerId); const dyn = p.kind === 'dynamic';
await ctx.step('precheck', () => { const t = core.getServer(d, p.toServerId); const f = core.getServer(d, p.fromServerId); if (!t.accepts_new || t.status !== 'ready') throw new JobError('target_not_ready', `도착 서버 ${t.name} 이(가) 신규 배치를 받을 수 없습니다`); if (f.status === 'offline') throw new JobError('source_offline', '출발 서버가 응답하지 않습니다'); const conflict = d.store.get('SELECT id FROM jobs WHERE subject_id=? AND id<>? AND kind IN (\'site.deploy\',\'backup.restore\',\'site.relocate\') AND status IN (\'queued\',\'running\',\'awaiting_external\',\'needs_user\')', p.siteId, job.id); if (conflict) throw new JobError('conflict', `같은 사이트의 다른 작업(${conflict.id})이 진행 중입니다`); return { from: f.name, to: t.name }; });
await ctx.step('prepare', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.prepare', payload: { siteId: p.siteId } })));
const pre = await ctx.step('precopy', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.export', payload: { siteId: p.siteId, transferId: `tr_rl1_${job.id}`, includeData: true } })));
await ctx.step('verify_copy', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.import', payload: { siteId: p.siteId, transferId: pre.transferId, checksum: pre.checksum, stage: 'pre' } })));
const fin = await ctx.step('final_sync', async () => { if (dyn || p.writeFreeze) await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.freeze', payload: { siteId: p.siteId, freeze: true } })); const ex = await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.export', payload: { siteId: p.siteId, transferId: `tr_rl2_${job.id}`, includeData: true }, dedupeKey: `${job.id}:final` })); return core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.import', payload: { siteId: p.siteId, transferId: ex.transferId, checksum: ex.checksum, stage: 'final' }, dedupeKey: `${job.id}:final-import` })); });
const vn = await ctx.step('verify_new', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.stage_check', payload: { siteId: p.siteId, expectedChecksum: fin.checksum } })));
if (!vn?.ok) throw new JobError('verify_new_failed', '새 위치 확인에 실패해 전환하지 않습니다', { detail: vn });
await ctx.step('switch', async () => { ctx.checkCancel(); const a = await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.activate', payload: { siteId: p.siteId, domains: p.domains || [], primaryDomain: p.primaryDomain, runtime: p.runtime || 'static' } })); await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.deactivate', payload: { siteId: p.siteId, retainDays: p.retainDays || 7 } })); return { switchedAt: nowIso(), activate: a }; });
const ext = await ctx.step('external_check', () => externalCheck(d, p.externalCheckUrl, { expectMarker: p.expectMarker }));
if (p.externalCheckUrl && !ext.ok) throw new JobError('relocate_external_check_failed', '전환 뒤 외부 접속 확인에 실패했습니다 — 되돌리기를 검토하세요', { detail: ext });
const cl = await ctx.step('cleanup', () => ({ oldRetainedUntil: new Date(Date.now() + (p.retainDays || 7) * 86400000).toISOString(), note: '이전 위치 사본은 보존 기간 뒤 삭제 작업(site.remove)으로 정리' }));
return { newServerId: p.toServerId, switchedAt: job.steps?.find((x) => x.id === 'switch')?.result?.switchedAt || nowIso(), oldRetainedUntil: cl.oldRetainedUntil, externalCheck: ext };
},
async revert(job, ctx) { const p = job.subject.payload || {}; const sw = (job.steps || []).find((x) => x.id === 'switch'); if (!sw || sw.state !== 'done') { await ctx.step('revert_unfreeze', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, p.fromServerId), kind: 'site.freeze', payload: { siteId: p.siteId, freeze: false }, dedupeKey: `${job.id}:unfreeze` }))); return; }
await ctx.step('revert_switch', async () => { await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, p.fromServerId), kind: 'site.activate', payload: { siteId: p.siteId, domains: p.domains || [], primaryDomain: p.primaryDomain, runtime: p.runtime || 'static', reactivate: true }, dedupeKey: `${job.id}:revert-act` })); await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, p.toServerId), kind: 'site.deactivate', payload: { siteId: p.siteId, retainDays: 1 }, dedupeKey: `${job.id}:revert-deact` })); }, '이전 위치로 연결 되돌리기'); } });
// ---------------- 모듈 배포(§32)
const unitTemplate = (name) => { const f = name ? join(ROOT, 'deploy', 'units', name) : null; return f && existsSync(f) ? readFileSync(f, 'utf8') : null; };
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
function precheckModule(module, version) {
const rel = core.getRelease(d, version); if (!rel) throw new JobError('release_not_found', `릴리스 ${version} 이(가) 없습니다`);
if (rel.status !== 'approved') throw new JobError('release_not_approved', `릴리스 ${version} 은(는) 승인되지 않았습니다`);
if (ARTIFACT_MODULES.includes(module) && !core.artifactPath(d, version, module)) throw new JobError('artifact_missing', `릴리스 ${version} 의 ${module} artifact 가 없습니다`);
const health = core.selfHealth(d); if (!health.ok) throw new JobError('hal_unhealthy', 'HAL 자체 건강 확인에 실패해 배포하지 않습니다');
if (!core.bootstrapComplete(d)) throw new JobError('bootstrap_incomplete', '부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다');
if (module !== 'hal' && health.bootstrap.halRunningVersion !== version) throw new JobError('hal_first', `HAL 이 아직 ${version} 으로 실행되고 있지 않습니다(현재 ${health.bootstrap.halRunningVersion}). HAL 을 먼저 배포하세요`);
return rel;
}
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
async run(job, ctx) {
const { module, version, serverId, action = 'install' } = job.subject; const desc = descriptor(module); const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
const art = core.artifactPath(d, version, module);
d.store.run('INSERT INTO module_targets(module, server_id, target_version, last_job_id, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET target_version=excluded.target_version, last_job_id=excluded.last_job_id, updated_at=excluded.updated_at', module, serverId, version, job.id, nowIso());
const facts = srv.facts || {}; const unitName = (facts.units && facts.units[module]) || desc.unit;
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: action === 'rollback' ? 'module.rollback' : 'module.install', title: 'Agent 에 설치 지시', payload: { module, version, action, artifactUrl: art ? `/agent/v1/releases/${version}/artifact?module=${module}` : null, sha256: art?.sha256, size: art?.size, descriptor: { ...desc, unit: unitName }, unitTemplate: unitTemplate(desc.unitTemplate), appDir: (facts.appDirs && facts.appDirs[module]) || (desc.baseDir || `/srv/ariweb/${module}`), toVersion: job.subject.toVersion || null } });
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
const v = await ctx.step('verify', () => {
const expected = action === 'rollback' ? (job.subject.toVersion || r.actualVersion) : version; const ok = r.actualVersion === expected && r.health?.ok !== false;
d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=?, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', r.actualVersion || null, r.health?.ok === false ? 'down' : 'ok', r.active ? 1 : 0, nowIso(), ok ? null : `기대 ${expected} 실제 ${r.actualVersion}`, nowIso(), module, serverId);
if (!ok) throw new JobError('version_mismatch', `${srv.name}: 설치 뒤 실제 버전(${r.actualVersion})·건강(${r.health?.ok})이 목표와 다릅니다`, { detail: r });
if (module === 'hal') { d.store.run('UPDATE releases SET hal_deployed_at=? WHERE version=?', nowIso(), version); core.setBootstrap(d, { hal_running_version: expected, release_version: expected }); }
return { actualVersion: r.actualVersion, health: r.health, previousVersion: r.previousVersion };
});
return v;
} });
/** 부모: 대상 서버마다 자식 작업 → 대상별 결과 요약. 일부 실패는 실패로 끝낸다. */
async function fanout(job, ctx, { kind, mk, serverIds }) {
const ids = await ctx.step('fanout', () => serverIds.map((serverId) => engine.enqueue({ kind, subject: mk(serverId), parentId: job.id, groupId: null, subjectType: 'server', subjectId: serverId, idempotencyKey: `${job.id}:${serverId}`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — ${core.getServer(d, serverId)?.name || serverId}` }).job.id), '대상별 작업 만들기');
const t0 = Date.now(); while (true) { if (Date.now() - t0 > (d.config.fanoutTimeoutMs || 12 * 3600 * 1000)) throw new JobError('fanout_timeout', '대상별 작업이 제한 시간 안에 끝나지 않았습니다'); const sum = engine.childrenSummary(job.id); ctx.progress(Math.round(100 * (sum.byStatus.done || 0) / Math.max(1, sum.total))); if (['done', 'partial', 'failed'].includes(sum.overall) && !sum.children.some((c) => ['queued', 'running', 'reverting'].includes(c.status))) { if (sum.overall !== 'done') throw new JobError(sum.overall === 'partial' ? 'partial_failure' : 'all_failed', `${sum.total}대 중 ${sum.children.filter((c) => c.status !== 'done').length}대 실패·대기 — 대상별 결과를 확인하세요`, { detail: sum }); return sum; } ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); }
}
engine.register('module.deploy', { title: '모듈 배포', steps: [{ id: 'precheck', title: '릴리스·HAL·순서 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
async run(job, ctx) {
const { module, version, serverIds } = job.subject; await ctx.step('precheck', () => { precheckModule(module, version); if (!serverIds?.length) throw new JobError('no_targets', '대상 서버가 없습니다'); return { order: DEPLOY_ORDER.indexOf(module) }; });
d.store.run('INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?) ON CONFLICT(name) DO UPDATE SET target_version=excluded.target_version, updated_at=excluded.updated_at', module, version, nowIso());
const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds, mk: (serverId) => ({ module, version, serverId, action: 'install' }) }));
audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.module', subjectType: 'module', subjectId: module, detail: { version, serverIds, overall: sum.overall } });
return sum;
} });
engine.register('module.rollback', { title: '모듈 되돌리기', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
async run(job, ctx) { const { module, toVersion, serverIds } = job.subject; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds, mk: (serverId) => ({ module, version: toVersion || core.getRelease(d, d.store.get('SELECT target_version FROM module_targets WHERE module=? AND server_id=?', module, serverId)?.target_version)?.version, serverId, action: 'rollback', toVersion }) })); audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.module_rollback', subjectType: 'module', subjectId: module, detail: { toVersion, serverIds, overall: sum.overall } }); return sum; } });
single(engine, d, 'module.health.target', { title: '모듈 건강 확인(대상)', agentKind: 'module.health', target: (s) => s.serverId, build: (s) => ({ module: s.module, descriptor: descriptor(s.module), unit: (core.getServer(d, s.serverId)?.facts?.units || {})[s.module] || descriptor(s.module).unit }),
after(job, ctx, r) { d.store.run('UPDATE module_targets SET actual_version=COALESCE(?, actual_version), health=?, active=?, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', r.actualVersion || null, r.health?.ok ? 'ok' : 'down', r.active ? 1 : 0, nowIso(), r.health?.ok ? null : (r.health?.error || '건강 확인 실패'), nowIso(), job.subject.module, job.subject.serverId); return r; } });
engine.register('module.health', { title: '모듈 건강 확인', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }], async run(job, ctx) { const { module, serverIds } = job.subject; return ctx.step('collect', () => fanout(job, ctx, { kind: 'module.health.target', serverIds: serverIds || d.store.all('SELECT server_id FROM module_targets WHERE module=?', module).map((r) => r.server_id), mk: (serverId) => ({ module, serverId }) })); } });
/** 다시 맞추기(§29·§34): 목표 버전과 실제 버전이 다른 대상만 다시 설치 */
engine.register('module.reconcile', { title: '목표·실제 상태 맞추기', steps: [{ id: 'diff', title: '차이 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
async run(job, ctx) { const diff = await ctx.step('diff', () => d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)' + (job.subject.module ? ' AND module=?' : ''), ...(job.subject.module ? [job.subject.module] : []))); if (!diff.length) return { drift: [], overall: 'done' }; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds: diff.map((x) => x.server_id), mk: (serverId) => { const x = diff.find((y) => y.server_id === serverId); return { module: x.module, version: x.target_version, serverId, action: 'install' }; } })); return { drift: diff, ...sum }; } });
// ---------------- 포털 이벤트: 포털이 만든 작업(subject._portal)의 상태 변화를 outbox 에 넣는다
engine.onChange((job) => { if (job.subject && job.subject._portal) core.queuePortalEvent(d, { halJobId: job.id, kind: job.kind, status: job.status, steps: job.steps, progress: job.progress, result: job.result, error: job.error, errorCode: job.error_code, userMessage: job.user_message, updatedAt: job.updated_at, subjectType: job.subject_type, subjectId: job.subject_id }); });
}
+197
View File
@@ -0,0 +1,197 @@
/**
* hal/mailsvc.mjs — HAL 관리형 메일 서비스 등록부와 기존(legacy) AriMail 인스턴스 가져오기(importer).
*
* 왜 있나: 이미 운영 중인 AriMail(mail.arihakdang.com · webmail.ibit.kr 같은)을 교체·중단·재구성하지 않고, HAL 이 **등록·감시·통제**할 수 있는
* 논리 리소스(MailService = 도메인 단위)와 노드(servers 행)로 가져온다. Data Plane(AriMail 의 송수신·계정·메일·DNS/MX·TLS·provider)은 그대로이며
* HAL 이 멈춰도 메일 서비스는 계속 동작한다(HAL 은 제어 평면, 이 표는 등록부일 뿐이다).
*
* 원칙
* - 읽기 전용 discovery: 원본 호스트에서 `scripts/mail-discover.mjs` 가 만든 문서(JSON)만 입력으로 받는다. 비밀값(토큰·키·비밀번호·해시)은 문서에 있으면 거부하고,
* 비밀은 `secretRefs`(file:/dir:/vault:/env: 참조)로만 둔다. HAL DB 에는 참조만 저장한다.
* - 불변 id(`mls_…`), 출처 권한(authority = `arimail:<hostname>:<unit>`), 도메인별 active owner 정확히 하나(부분 유일 인덱스).
* - dry-run 이 기본. 계획(plan)은 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락을 거부하며, 거부가 하나라도 있으면 아무것도 적용하지 않는다(전체 거부).
* - 적용은 한 트랜잭션, 모든 변화는 감사 기록(immutable), 적용 단위(mail_imports)로 되돌리기(생성 행 삭제·갱신 행 복원) 가능.
* - 같은 authority·서버의 재적용은 update(집계·증거 갱신, id 유지) — 멱등.
* - 소유 조직이 증거로 확인되지 않으면 임의 매핑하지 않고 `unassigned_legacy_managed` 로 둔다(AriMail 등록부 `ariweb.groupId` 가 있어도 포털 그룹과 대조하기 전에는 추정하지 않는다).
* - Agent heartbeat 가 arimail 모듈 상태를 보고하면 last_sync·health 가 갱신된다(core.heartbeat → syncFromHeartbeat). Agent 가 없는 서버의 행은 import 시각만 가진다.
*/
import { newId } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { looksSecret } from '../lib/redact.mjs';
import { JobError } from '../lib/jobs.mjs';
import * as core from './core.mjs';
export const DISCOVERY_FORMAT = 1;
export const MAIL_SERVER_ROLES = ['mail', 'control'];
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
const SECRET_KEY_RE = /password|passwd|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|^(pass|key)$/i;
const REF_KEY_RE = /(ref|refs|path|file|files|dir|keys|names|at|mode|owner|hint|exists|count|url)$/i;
const REF_RE = /^(file|dir|vault|env):\S+$/;
/** git 커밋·artifact 해시처럼 "비밀처럼 보이지만 공개 식별자" 인 값: 키 이름이 commit/sha256 류이고 값이 40/64 hex 일 때만 허용 */
const isPublicDigest = (k, v) => /^(commit|main_?commit|sha256|artifact_?sha256|digest)$/i.test(String(k)) && /^([a-f0-9]{40}|[a-f0-9]{64})$/i.test(String(v));
export const MIGRATIONS = [{ id: 'hal_0003_mail_services', sql: `
-- HAL 관리형 메일 서비스(도메인 단위 논리 리소스). 비밀은 secret_refs_json 의 참조만. 도메인별 active 는 정확히 하나.
CREATE TABLE IF NOT EXISTS mail_services (
id TEXT PRIMARY KEY, domain TEXT NOT NULL, server_id TEXT NOT NULL, authority TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'legacy_managed',
ownership TEXT NOT NULL DEFAULT 'unassigned_legacy_managed', owner_group_id TEXT, active INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'managed',
public_host TEXT, web_url TEXT, instance_json TEXT NOT NULL, domain_json TEXT, evidence_json TEXT, aggregates_json TEXT, secret_refs_json TEXT, health TEXT,
import_id TEXT, imported_at TEXT NOT NULL, imported_by TEXT, last_sync_at TEXT, last_sync_source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS mail_services_active_domain ON mail_services(domain) WHERE active=1;
CREATE INDEX IF NOT EXISTS mail_services_server ON mail_services(server_id);
-- 가져오기 단위(dry-run 도 기록). status: planned(dry-run) | rejected | applied | rolled_back
CREATE TABLE IF NOT EXISTS mail_imports (
id TEXT PRIMARY KEY, mode TEXT NOT NULL, status TEXT NOT NULL, actor TEXT, server_id TEXT, authority TEXT NOT NULL, plan_json TEXT NOT NULL,
created_ids_json TEXT, updated_json TEXT, created_at TEXT NOT NULL, applied_at TEXT, rolled_back_at TEXT, rolled_back_by TEXT
);` }];
// ------------------------------------------------------------------ 검증
/** discovery 문서 검증. 비밀값이 어디에든 있으면 거부한다. */
export function validateDiscovery(doc) {
const errors = [];
if (!doc || typeof doc !== 'object') return { ok: false, errors: ['discovery 문서가 객체가 아닙니다'] };
if (doc.format !== DISCOVERY_FORMAT) errors.push(`format 은 ${DISCOVERY_FORMAT} 이어야 합니다`);
if (!doc.collectedAt || isNaN(Date.parse(doc.collectedAt))) errors.push('collectedAt(ISO 시각)이 필요합니다');
const s = doc.source;
if (!s || typeof s !== 'object') errors.push('source 가 필요합니다');
else { for (const k of ['hostname', 'unit', 'appDir', 'user']) if (!s[k] || typeof s[k] !== 'string') errors.push(`source.${k} 가 필요합니다`); if (!s.revision || typeof s.revision !== 'object') errors.push('source.revision 이 필요합니다'); if (!s.health || typeof s.health !== 'object') errors.push('source.health 가 필요합니다'); }
if (!Array.isArray(doc.domains) || !doc.domains.length) errors.push('domains 배열이 필요합니다');
else {
const seen = new Set();
doc.domains.forEach((x, i) => {
if (!x || typeof x !== 'object') { errors.push(`domains[${i}] 가 객체가 아닙니다`); return; }
const dn = String(x.domain || '').toLowerCase(); if (!DOMAIN_RE.test(dn)) errors.push(`domains[${i}] 도메인 형식 오류: ${x.domain}`);
if (seen.has(dn)) errors.push(`도메인 중복: ${dn}`); seen.add(dn);
if (!Array.isArray(x.hosts)) errors.push(`domains[${i}].hosts 배열이 필요합니다`);
});
}
if (!Array.isArray(doc.listeners)) errors.push('listeners 배열이 필요합니다');
if (!doc.aggregates || typeof doc.aggregates !== 'object') errors.push('aggregates 가 필요합니다');
if (doc.secretRefs != null) {
if (!Array.isArray(doc.secretRefs)) errors.push('secretRefs 는 배열이어야 합니다');
else doc.secretRefs.forEach((r, i) => { if (!r || typeof r !== 'object') { errors.push(`secretRefs[${i}] 형식 오류`); return; } if ('value' in r || 'secret' in r) errors.push(`secretRefs[${i}] 에 비밀값을 넣을 수 없습니다 — 참조만`); if (!REF_RE.test(String(r.ref || ''))) errors.push(`secretRefs[${i}].ref 는 file:/dir:/vault:/env: 형식이어야 합니다`); });
}
// 비밀값 탐지(이름·형태). 참조 키(…Ref/Path/File/Dir/Keys)는 허용.
const walk = (v, path, depth) => {
if (depth > 8 || v == null) return;
if (typeof v === 'string') { if (looksSecret(v)) errors.push(`비밀 형태의 값: ${path}`); return; }
if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, depth + 1)); return; }
if (typeof v === 'object') for (const [k, val] of Object.entries(v)) { if (isPublicDigest(k, val)) continue; if (SECRET_KEY_RE.test(k) && !REF_KEY_RE.test(k) && typeof val === 'string' && val !== '') errors.push(`비밀 이름의 키에 값이 있습니다: ${path}.${k}`); walk(val, `${path}.${k}`, depth + 1); }
};
walk(doc, 'doc', 0);
return { ok: errors.length === 0, errors };
}
export const authorityOf = (doc) => `arimail:${doc.source.hostname}:${doc.source.unit}`;
// ------------------------------------------------------------------ 조회
function rowToPublic(d, r) {
if (!r) return null;
const srv = d.store.get('SELECT name, agent_id, status, last_seen_at FROM servers WHERE id=?', r.server_id);
return { id: r.id, domain: r.domain, serverId: r.server_id, serverName: srv?.name || null, agentId: srv?.agent_id || null, serverStatus: srv?.status || null, authority: r.authority, kind: r.kind, ownership: r.ownership, ownerGroupId: r.owner_group_id, active: !!r.active, status: r.status,
publicHost: r.public_host, webUrl: r.web_url, instance: parseJson(r.instance_json, {}), domainInfo: parseJson(r.domain_json, {}), evidence: parseJson(r.evidence_json, {}), aggregates: parseJson(r.aggregates_json, {}), secretRefs: parseJson(r.secret_refs_json, []), health: r.health,
importId: r.import_id, importedAt: r.imported_at, importedBy: r.imported_by, lastSyncAt: r.last_sync_at, lastSyncSource: r.last_sync_source, createdAt: r.created_at, updatedAt: r.updated_at };
}
export function listMailServices(d, { serverId = null } = {}) { return d.store.all(`SELECT * FROM mail_services ${serverId ? 'WHERE server_id=?' : ''} ORDER BY domain, created_at`, ...(serverId ? [serverId] : [])).map((r) => rowToPublic(d, r)); }
export function getMailService(d, id) { return rowToPublic(d, d.store.get('SELECT * FROM mail_services WHERE id=?', id)); }
export function listImports(d, { limit = 50 } = {}) { return d.store.all('SELECT id, mode, status, actor, server_id, authority, created_at, applied_at, rolled_back_at, rolled_back_by, plan_json, created_ids_json FROM mail_imports ORDER BY created_at DESC LIMIT ?', limit).map((r) => ({ id: r.id, mode: r.mode, status: r.status, actor: r.actor, serverId: r.server_id, authority: r.authority, createdAt: r.created_at, appliedAt: r.applied_at, rolledBackAt: r.rolled_back_at, rolledBackBy: r.rolled_back_by, summary: parseJson(r.plan_json, {}).summary || null, createdIds: parseJson(r.created_ids_json, []) })); }
/** 적용 뒤 정확성 readback: 총수·active·도메인별 중복·서버별·비밀 누출(형태 검사). */
export function readback(d) {
const rows = d.store.all('SELECT * FROM mail_services ORDER BY domain'); const services = rows.map((r) => rowToPublic(d, r));
const byDomain = {}; for (const r of rows) if (r.active) byDomain[r.domain] = (byDomain[r.domain] || 0) + 1;
const duplicates = Object.entries(byDomain).filter(([, n]) => n > 1).map(([dn]) => dn);
const perServer = {}; for (const r of rows) perServer[r.server_id] = (perServer[r.server_id] || 0) + 1;
let secretLeaks = 0; const scan = (v, depth = 0) => { if (depth > 8 || v == null) return; if (typeof v === 'string') { if (looksSecret(v)) secretLeaks++; return; } if (Array.isArray(v)) v.forEach((x) => scan(x, depth + 1)); else if (typeof v === 'object') Object.entries(v).forEach(([k, x]) => { if (!isPublicDigest(k, x)) scan(x, depth + 1); }); };
for (const r of rows) scan([parseJson(r.instance_json, null), parseJson(r.evidence_json, null), parseJson(r.aggregates_json, null), parseJson(r.secret_refs_json, null), parseJson(r.domain_json, null)]);
return { counts: { total: rows.length, active: rows.filter((r) => r.active).length, domains: Object.keys(byDomain).length, byDomain }, duplicates, perServer, secretLeaks, services, generatedAt: nowIso() };
}
// ------------------------------------------------------------------ 계획(dry-run)
/** 계획 세우기. 거부(reject)가 하나라도 있으면 plan.ok=false. 서버 없음·역할·authority 불일치는 예외로 던진다(문서 전체가 대상이 아님). */
export function planImport(d, doc, { serverId }) {
const v = validateDiscovery(doc); if (!v.ok) throw new JobError('bad_discovery', `discovery 문서 검증 실패: ${v.errors.join(' · ')}`, { errors: v.errors });
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
if (!MAIL_SERVER_ROLES.includes(srv.role)) throw new JobError('bad_role', `메일 서비스를 둘 서버 역할은 ${MAIL_SERVER_ROLES.join('/')} 여야 합니다(현재 ${srv.role})`);
const facts = srv.facts || {}; if (facts.hostname && facts.hostname !== doc.source.hostname) throw new JobError('authority_mismatch', `서버 facts 의 hostname(${facts.hostname})과 discovery 의 hostname(${doc.source.hostname})이 다릅니다`);
if (facts.units && facts.units.arimail && facts.units.arimail !== doc.source.unit.replace(/\.service$/, '')) throw new JobError('authority_mismatch', `서버 facts 의 arimail 유닛(${facts.units.arimail})과 discovery 의 유닛(${doc.source.unit})이 다릅니다`);
const authority = authorityOf(doc); const items = [];
for (const x of doc.domains) {
const domain = String(x.domain).toLowerCase();
if (x.enabled === false) { items.push({ domain, action: 'skip', reason: 'disabled_in_source' }); continue; }
const ex = d.store.get('SELECT * FROM mail_services WHERE domain=? AND active=1', domain);
if (!ex) items.push({ domain, action: 'create' });
else if (ex.authority === authority && ex.server_id === serverId) items.push({ domain, action: 'update', existingId: ex.id });
else items.push({ domain, action: 'reject', reason: `collision: 도메인 ${domain} 의 active owner 는 이미 ${ex.id}(${ex.authority}, 서버 ${ex.server_id}) 입니다`, existingId: ex.id });
}
const present = new Set(doc.domains.map((x) => String(x.domain).toLowerCase()));
const missingInSource = d.store.all('SELECT domain FROM mail_services WHERE authority=? AND server_id=? AND active=1', authority, serverId).map((r) => r.domain).filter((dn) => !present.has(dn));
const summary = { create: 0, update: 0, reject: 0, skip: 0, missingInSource: missingInSource.length }; for (const i of items) summary[i.action]++;
return { ok: summary.reject === 0, authority, serverId, serverName: srv.name, items, missingInSource, summary, collectedAt: doc.collectedAt };
}
const domainView = (x) => ({ domain: String(x.domain).toLowerCase(), enabled: x.enabled !== false, primary: !!x.primary, hosts: (x.hosts || []).map(String), authMode: x.authMode || null, operating: x.operating || null, ariweb: x.ariweb || null, note: x.note || null, tenantDb: x.tenantDb || null, createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
function buildRow(doc, x, serverId, authority) {
const dv = domainView(x); const host = dv.hosts[0] ? String(dv.hosts[0]).split('/')[0] : null; const agg = (doc.aggregates.byDomain || {})[dv.domain] || null;
const s = doc.source;
const instance = { hostname: s.hostname, unit: s.unit, appDir: s.appDir, currentTarget: s.currentTarget || null, user: s.user, nodeBin: s.nodeBin || null, nodeVersion: s.nodeVersion || null, revision: s.revision || {}, packageVersion: s.packageVersion || null, healthUrl: s.healthUrl || null, health: s.health || null, unitState: s.unitState || null, harakaUnit: s.harakaUnit || null, dataDir: s.dataDir || null, listeners: doc.listeners, provider: doc.provider || null, collector: doc.collector || null, collectedAt: doc.collectedAt };
return { domain: dv.domain, server_id: serverId, authority, public_host: host, web_url: host ? `https://${host}/` : null, instance_json: toJson(instance), domain_json: toJson(dv), evidence_json: toJson(doc.evidence || {}), aggregates_json: toJson(agg), secret_refs_json: toJson(doc.secretRefs || []), health: s.health && s.health.ok === false ? 'down' : s.unitState && s.unitState.active && s.unitState.active !== 'active' ? 'down' : 'ok' };
}
// ------------------------------------------------------------------ 적용
/**
* dry-run(기본) 또는 적용. 결과 { importId, mode: 'dry_run'|'rejected'|'apply', plan, created[], updated[] }.
* 거부가 있으면 아무것도 적용하지 않고 mode='rejected' 로 기록만 남긴다.
*/
export function applyImport(d, doc, { serverId, by = null, dryRun = true }) {
const plan = planImport(d, doc, { serverId }); const authority = plan.authority; const importId = newId('mimp_'); const now = nowIso();
const actorType = by ? 'operator' : 'system';
if (dryRun || !plan.ok) {
const mode = plan.ok ? 'dry_run' : 'rejected';
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_at) VALUES(?,?,?,?,?,?,?,?)', importId, mode, plan.ok ? 'planned' : 'rejected', by, serverId, authority, toJson(plan), now);
audit(d.store, { actorType, actorId: by, action: plan.ok ? 'mail_service.import_dry_run' : 'mail_service.import_rejected', subjectType: 'server', subjectId: serverId, detail: { importId, authority, summary: plan.summary, rejected: plan.items.filter((i) => i.action === 'reject').map((i) => i.domain) } });
return { importId, mode, plan, created: [], updated: [] };
}
return d.store.tx(() => {
const created = []; const updated = [];
for (const item of plan.items) {
if (item.action === 'skip') continue;
const x = doc.domains.find((y) => String(y.domain).toLowerCase() === item.domain); const row = buildRow(doc, x, serverId, authority);
if (item.action === 'create') {
const id = newId('mls_');
d.store.run('INSERT INTO mail_services(id, domain, server_id, authority, kind, ownership, owner_group_id, active, status, public_host, web_url, instance_json, domain_json, evidence_json, aggregates_json, secret_refs_json, health, import_id, imported_at, imported_by, last_sync_at, last_sync_source, created_at, updated_at) VALUES(?,?,?,?,\'legacy_managed\',\'unassigned_legacy_managed\',NULL,1,\'managed\',?,?,?,?,?,?,?,?,?,?,?,?,\'import\',?,?)',
id, row.domain, row.server_id, row.authority, row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, importId, now, by, now, now, now);
created.push(id); item.createdId = id;
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: id, immutable: true, detail: { importId, domain: row.domain, serverId, authority, publicHost: row.public_host, aggregates: parseJson(row.aggregates_json, null) } });
} else if (item.action === 'update') {
const before = d.store.get('SELECT * FROM mail_services WHERE id=?', item.existingId);
d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=\'import\', updated_at=? WHERE id=?', row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, now, now, item.existingId);
updated.push({ id: item.existingId, before });
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: item.existingId, immutable: true, detail: { importId, domain: row.domain, serverId, authority, update: true } });
}
}
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_ids_json, updated_json, created_at, applied_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', importId, 'apply', 'applied', by, serverId, authority, toJson(plan), toJson(created), toJson(updated), now, now);
return { importId, mode: 'apply', plan, created, updated: updated.map((u) => u.id) };
});
}
/** 되돌리기: 그 적용이 만든 행 삭제, 갱신한 행은 이전 값으로 복원. */
export function rollbackImport(d, importId, by = null) {
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', importId); if (!imp) throw new JobError('import_not_found', '가져오기 기록이 없습니다');
if (imp.status === 'rolled_back') throw new JobError('already_rolled_back', '이미 되돌린 가져오기입니다');
if (imp.status !== 'applied') throw new JobError('not_applied', `적용된 가져오기만 되돌릴 수 있습니다(상태 ${imp.status})`);
return d.store.tx(() => {
let deleted = 0, restored = 0;
for (const id of parseJson(imp.created_ids_json, [])) deleted += d.store.run('DELETE FROM mail_services WHERE id=? AND import_id=?', id, importId).changes;
for (const u of parseJson(imp.updated_json, [])) { const b = u.before; if (!b) continue; restored += d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=?, updated_at=? WHERE id=?', b.public_host, b.web_url, b.instance_json, b.domain_json, b.evidence_json, b.aggregates_json, b.secret_refs_json, b.health, b.last_sync_at, b.last_sync_source, nowIso(), u.id).changes; }
d.store.run('UPDATE mail_imports SET status=\'rolled_back\', rolled_back_at=?, rolled_back_by=? WHERE id=?', nowIso(), by, importId);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'mail_service.import_rollback', subjectType: 'server', subjectId: imp.server_id, immutable: true, detail: { importId, deleted, restored } });
return { importId, deleted, restored };
});
}
/** heartbeat 연동(core.heartbeat 가 부른다): 그 서버의 메일 서비스 행에 Agent 관측 상태를 비춘다. 행이 없으면 아무것도 하지 않는다. */
export function syncFromHeartbeat(d, serverId, modules = {}) {
const m = modules && modules.arimail; if (!m) return 0;
const rows = d.store.all('SELECT id, instance_json FROM mail_services WHERE server_id=?', serverId); if (!rows.length) return 0; const now = nowIso();
for (const r of rows) { const inst = parseJson(r.instance_json, {}); inst.observedVersion = m.version || null; inst.observedActive = !!m.active; inst.observedAt = now; d.store.run('UPDATE mail_services SET health=?, last_sync_at=?, last_sync_source=\'agent\', instance_json=?, updated_at=? WHERE id=?', m.health || 'unknown', now, toJson(inst), now, r.id); }
return rows.length;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32).
*
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
*
* 기술서 모양
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
* roles 설치할 수 있는 서버 역할
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다
* unit systemd 유닛 이름(도입 모듈은 서버에 이미 있는 이름을 쓸 수 있어 server.facts.units 로 덮어쓴다)
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
* health { url, expect } 내부 건강 확인(127.0.0.1)
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
* dataDirs 절대 건드리지 않는 데이터 경로(검증·백업 대상 안내용)
*/
export const MODULES = {
hal: { name: 'hal', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true },
portal: { name: 'portal', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] },
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.8.0 — 0.2.0 에서 직접 편집(시각 편집기) 포함; 0.1.1 은 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
// 유닛은 서버에 이미 있고 <appDir>/current 를 가리키면 그대로 둔다(keepExistingUnit — 운영 서버의 강화 지시문·ReadWritePaths 보존, 실제 환경 결함 수정). 없을 때만 deploy/units/webmaker.service.tmpl 로 만든다.
// 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다. 실제 버전은 REVISION.json 의 Suite 릴리스 버전(package.json 1.8.0 은 upstreamVersion).
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, user: 'master', port: 3080, baseDir: '/srv/webmaker',
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
// 실행 계정이 Agent 와 다르면(ariweb.net 호스트의 arihakdang) Agent 가 deploy/module-as-user.sh 로 그 계정이 파일 작업을 하게 한다(실제 환경 결함 수정). 마이그레이션은 유닛의 EnvironmentFile(DB_FILE)을 같은 계정으로 읽어 실행한다.
arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['<appDir>/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] },
backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
observability: { name: 'observability', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [] },
};
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다) */
export const ARTIFACT_MODULES = Object.values(MODULES).filter((m) => m.layout === 'releases').map((m) => m.name);
/** HAL 이 가장 먼저이고, 그다음 agent → portal → 도입 모듈 순으로 배포한다(§32.2) */
export const DEPLOY_ORDER = ['hal', 'agent', 'portal', 'webmaker', 'arimail', 'backup', 'observability'];
export function descriptor(name) { const d = MODULES[name]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
+69
View File
@@ -0,0 +1,69 @@
/**
* hal/ops.mjs — 운영자 계정·로그인·다중 인증·접근권(§25, §3.2, §3.3).
*
* - 운영자 계정은 고객 계정과 완전히 분리된 ops_accounts 이며 세션 종류는 'operator'(lib/session). 고객 세션으로는 /ops 를 열 수 없다.
* - 네트워크 제한: /ops/* 는 허용 CIDR(config.opsAllowCidrs) 안이거나, 유효한 접근권 쿠키(aw_ops_grant: 사용자·기기(UA 해시) 귀속, 짧은 수명)가 있어야 로그인 화면도 열린다.
* 접근권은 (1) 서버에서 CLI `node hal/index.mjs grant --login <id> --hours 8` 로, (2) 허용 네트워크 안에서 로그인한 운영자가 API 로 만든다. 일회용 코드 URL(/ops/access?code=…)을 열면 그 기기에 쿠키가 묶인다.
* - 반복 실패 잠금(5회 → 15분), 비밀번호 변경 시 세션 세대 증가 대신 다른 세션 종료, TOTP(선택) 등록·확인·해제, 복구 코드 1회 표시.
* - 고위험 작업은 recentlyAuthed(10분) 를 요구한다. 보안 수준 표시: 'password' | 'mfa'.
*/
import { newId, randomToken, sha256, hashPassword, verifyPassword, safeEqual } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { JobError } from '../lib/jobs.mjs';
import * as session from '../lib/session.mjs';
import { cookie } from '../lib/http.mjs';
import { ipAllowed } from './cidr.mjs';
import { newSecret, verifyTotp, otpauthUrl } from './totp.mjs';
export const GRANT_COOKIE = 'aw_ops_grant';
const MAX_FAILS = 5, LOCK_MIN = 15;
const uaHash = (ua) => sha256(String(ua || '').slice(0, 300)).slice(0, 32);
export function createAccount(d, { login, password, displayName, by = null, mustChange = false }) {
login = String(login || '').trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9._-]{2,31}$/.test(login)) throw new JobError('bad_login', '아이디는 영문 소문자·숫자로 시작하는 3~32자여야 합니다');
if (String(password || '').length < 10) throw new JobError('weak_password', '운영자 비밀번호는 10자 이상이어야 합니다');
if (d.store.get('SELECT id FROM ops_accounts WHERE login=?', login)) throw new JobError('login_taken', '이미 있는 아이디입니다');
const { salt, hash } = hashPassword(password); const id = newId('op_'); const now = nowIso();
d.store.run('INSERT INTO ops_accounts(id, login, display_name, pass_hash, pass_salt, created_at, updated_at, must_change_password) VALUES(?,?,?,?,?,?,?,?)', id, login, String(displayName || login).slice(0, 80), hash, salt, now, now, mustChange ? 1 : 0);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'ops.account_create', subjectType: 'ops_account', subjectId: id, detail: { login } });
return publicAccount(d.store.get('SELECT * FROM ops_accounts WHERE id=?', id));
}
export function publicAccount(a) { if (!a) return null; const mfa = parseJson(a.mfa_json, {}) || {}; return { id: a.id, login: a.login, displayName: a.display_name, status: a.status, lastLoginAt: a.last_login_at, mfaEnabled: !!mfa.enabledAt, securityLevel: mfa.enabledAt ? 'mfa' : 'password', securityHint: mfa.enabledAt ? '다중 인증 사용 중' : '다중 인증을 켜는 것을 권장합니다(§3.3)', mustChangePassword: !!a.must_change_password, lockedUntil: a.lock_until && Date.parse(a.lock_until) > Date.now() ? a.lock_until : null }; }
/** 1단계 로그인(비밀번호). MFA 가 켜져 있으면 세션을 만들지 않고 mfaRequired 와 임시 토큰을 돌려준다. */
export function login(d, { login, password, totp, ip, ua }) {
const a = d.store.get('SELECT * FROM ops_accounts WHERE login=?', String(login || '').trim().toLowerCase());
const fail = () => { if (a) { const n = a.failed_logins + 1; d.store.run('UPDATE ops_accounts SET failed_logins=?, lock_until=? WHERE id=?', n, n >= MAX_FAILS ? new Date(Date.now() + LOCK_MIN * 60000).toISOString() : null, a.id); } audit(d.store, { actorType: 'operator', action: 'ops.login', outcome: 'failed', ip, detail: { login } }); throw new JobError('bad_credentials', '아이디 또는 비밀번호가 올바르지 않습니다'); };
if (!a || a.status !== 'active') fail();
if (a.lock_until && Date.parse(a.lock_until) > Date.now()) throw Object.assign(new JobError('locked', `로그인 시도가 반복 실패해 ${LOCK_MIN}분 동안 잠겼습니다. 해제 시각: ${a.lock_until}`), { detail: { unlockAt: a.lock_until } });
if (!verifyPassword(password, a.pass_salt, a.pass_hash)) fail();
const mfa = parseJson(a.mfa_json, {}) || {};
if (mfa.enabledAt) {
const secret = d.vault.get(`ops-totp:${a.id}`); const codes = mfa.recoveryHashes || [];
const codeOk = totp && (verifyTotp(secret, totp) || codes.includes(sha256(String(totp).replace(/\s|-/g, ''))));
if (!totp) return { mfaRequired: true, message: '인증 앱의 6자리 코드를 입력하세요' };
if (!codeOk) fail();
if (!verifyTotp(secret, totp)) { mfa.recoveryHashes = codes.filter((h) => h !== sha256(String(totp).replace(/\s|-/g, ''))); d.store.run('UPDATE ops_accounts SET mfa_json=? WHERE id=?', toJson(mfa), a.id); } // 복구 코드는 1회용
}
d.store.run('UPDATE ops_accounts SET failed_logins=0, lock_until=NULL, last_login_at=? WHERE id=?', nowIso(), a.id);
const s = session.createSession(d.store, { userId: a.id, kind: 'operator', ip, ua, days: 1 });
audit(d.store, { actorType: 'operator', actorId: a.id, action: 'ops.login', ip });
return { session: s, account: publicAccount(a) };
}
export function reauth(d, accountId, password, sessionId) { const a = d.store.get('SELECT * FROM ops_accounts WHERE id=?', accountId); if (!a || !verifyPassword(password, a.pass_salt, a.pass_hash)) throw new JobError('bad_credentials', '비밀번호가 올바르지 않습니다'); session.markReauth(d.store, sessionId); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.reauth' }); return true; }
export function changePassword(d, accountId, current, next, keepSessionId) { const a = d.store.get('SELECT * FROM ops_accounts WHERE id=?', accountId); if (!verifyPassword(current, a.pass_salt, a.pass_hash)) throw new JobError('bad_credentials', '현재 비밀번호가 올바르지 않습니다'); if (String(next).length < 10) throw new JobError('weak_password', '10자 이상이어야 합니다'); const { salt, hash } = hashPassword(next); d.store.run('UPDATE ops_accounts SET pass_hash=?, pass_salt=?, must_change_password=0, updated_at=? WHERE id=?', hash, salt, nowIso(), accountId); session.revokeOtherSessions(d.store, accountId, keepSessionId, 'operator'); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.password_change' }); return true; }
/** TOTP 등록 시작: 비밀을 보관소에 임시로 두고 otpauth URL 을 돌려준다(확인 전까지 enabledAt 없음) */
export function mfaBegin(d, accountId) { const a = d.store.get('SELECT * FROM ops_accounts WHERE id=?', accountId); const secret = newSecret(); d.vault.set(`ops-totp:${accountId}`, secret, { kind: 'totp' }); const mfa = { ...(parseJson(a.mfa_json, {}) || {}), pendingAt: nowIso(), enabledAt: null }; d.store.run('UPDATE ops_accounts SET mfa_json=? WHERE id=?', toJson(mfa), accountId); return { otpauth: otpauthUrl(secret, a.login), secret }; }
/** 코드 확인 → 켜기. 복구 코드 8개를 한 번만 돌려준다. */
export function mfaConfirm(d, accountId, code) { const secret = d.vault.get(`ops-totp:${accountId}`); if (!secret || !verifyTotp(secret, code)) throw new JobError('bad_totp', '인증 코드가 맞지 않습니다'); const codes = Array.from({ length: 8 }, () => randomToken(6).replace(/[-_]/g, 'x').slice(0, 10)); const mfa = { enabledAt: nowIso(), recoveryHashes: codes.map((c) => sha256(c)) }; d.store.run('UPDATE ops_accounts SET mfa_json=? WHERE id=?', toJson(mfa), accountId); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.mfa_enable' }); return { recoveryCodes: codes }; }
export function mfaDisable(d, accountId, code) { const secret = d.vault.get(`ops-totp:${accountId}`); if (!secret || !verifyTotp(secret, code)) throw new JobError('bad_totp', '인증 코드가 맞지 않습니다'); d.vault.remove(`ops-totp:${accountId}`); d.store.run('UPDATE ops_accounts SET mfa_json=NULL WHERE id=?', accountId); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.mfa_disable' }); return true; }
/** 접근권 발급: 일회용 코드(URL)로 기기에 묶는다 */
export function issueGrant(d, { accountId, hours = 8, by = null, reason = '' }) { const a = d.store.get('SELECT id FROM ops_accounts WHERE id=? OR login=?', accountId, accountId); if (!a) throw new JobError('account_not_found', '운영자 계정이 없습니다'); const code = randomToken(24); const id = newId('gr_'); d.store.run('INSERT INTO ops_access_grants(id, account_id, code_hash, created_by, created_at, expires_at, reason) VALUES(?,?,?,?,?,?,?)', id, a.id, sha256(code), by, nowIso(), new Date(Date.now() + Math.min(72, Math.max(1, hours)) * 3600000).toISOString(), reason); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'ops.grant_issue', subjectType: 'ops_account', subjectId: a.id, detail: { hours, reason } }); return { id, url: `${d.config.publicUrl}/ops/access?code=${code}`, expiresAt: new Date(Date.now() + hours * 3600000).toISOString() }; }
/** 코드로 기기 묶기 → 쿠키 값(원문). 코드는 1회용. */
export function bindGrant(d, code, { ua, ip }) { const g = d.store.get('SELECT * FROM ops_access_grants WHERE code_hash=? AND revoked_at IS NULL', sha256(String(code || ''))); if (!g || g.bound_at || Date.parse(g.expires_at) < Date.now()) throw new JobError('bad_grant', '접근 코드가 올바르지 않거나 만료·사용되었습니다'); const cookieVal = randomToken(32); d.store.run('UPDATE ops_access_grants SET bound_at=?, cookie_hash=?, ua_hash=?, ip=? WHERE id=?', nowIso(), sha256(cookieVal), uaHash(ua), ip, g.id); return { cookie: cookieVal, expiresAt: g.expires_at }; }
export function grantValid(d, cookieVal, ua) { if (!cookieVal) return null; const g = d.store.get('SELECT * FROM ops_access_grants WHERE cookie_hash=? AND revoked_at IS NULL', sha256(cookieVal)); if (!g || Date.parse(g.expires_at) < Date.now()) return null; if (!safeEqual(g.ua_hash, uaHash(ua))) return null; return g; }
export function revokeGrant(d, id, by) { d.store.run('UPDATE ops_access_grants SET revoked_at=? WHERE id=?', nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'ops.grant_revoke', subjectType: 'grant', subjectId: id }); }
/** 네트워크 관문(§25): 허용 CIDR 또는 접근권 쿠키. 아니면 '접근 불가' — 로그인 화면도 주지 않는다. */
export function networkGate(d, ctx) { if (ipAllowed(ctx.ip, d.config.opsAllowCidrs)) return { ok: true, via: 'network' }; const g = grantValid(d, ctx.cookies[GRANT_COOKIE], ctx.headers['user-agent']); return g ? { ok: true, via: 'grant', grant: g } : { ok: false, reason: 'network_not_allowed' }; }
export const grantCookie = (val, secure) => cookie(GRANT_COOKIE, val, { maxAge: 72 * 3600, path: '/ops', secure });
export { ipAllowed };
+107
View File
@@ -0,0 +1,107 @@
/**
* hal/schema.mjs — HAL DB 마이그레이션(hal.sqlite).
* lib 공통 표(sessions·jobs·notifications·audit) 뒤에 HAL 고유 표를 둔다. docs/ARCHITECTURE.md §5·§10, Dev-Order §28·§32·§33·§34.
*/
import * as session from '../lib/session.mjs';
import * as jobs from '../lib/jobs.mjs';
import * as notify from '../lib/notify.mjs';
import * as audit from '../lib/audit.mjs';
import { MIGRATIONS as MAIL_MIGRATIONS } from './mailsvc.mjs';
export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: `
-- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다.
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL, location TEXT, status TEXT NOT NULL DEFAULT 'pending', accepts_new INTEGER NOT NULL DEFAULT 0,
approved_at TEXT, approved_by TEXT, agent_id TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, agent_version TEXT, metrics_json TEXT, capacity_json TEXT, facts_json TEXT,
maintenance_note TEXT, isolated_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
-- Node Agent(관리형) 또는 External Pull Agent(고객 외부 서버, 사이트 하나에 귀속). 비밀은 보관소('service:agent:<id>')에, 여기엔 해시만.
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, server_id TEXT, kind TEXT NOT NULL DEFAULT 'managed', secret_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', label TEXT,
group_id TEXT, site_id TEXT, version TEXT, registered_at TEXT NOT NULL, last_seen_at TEXT, revoked_at TEXT, facts_json TEXT
);
-- 일회용 등록 토큰(10분, 1회). 외부 Agent 토큰은 group_id/site_id 가 있다.
CREATE TABLE IF NOT EXISTS registration_tokens (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, kind TEXT NOT NULL DEFAULT 'managed', server_id TEXT, group_id TEXT, site_id TEXT, label TEXT,
created_by TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT, used_by_agent TEXT
);
-- 릴리스 등록부(§32.1). artifacts_json = { 모듈: { file, sha256, size } }
CREATE TABLE IF NOT EXISTS releases (
version TEXT PRIMARY KEY, main_commit TEXT NOT NULL, built_at TEXT NOT NULL, artifacts_json TEXT NOT NULL, checks_json TEXT, status TEXT NOT NULL DEFAULT 'registered',
approved_by TEXT, approved_at TEXT, hal_deployed_at TEXT, notes TEXT, registered_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS modules (name TEXT PRIMARY KEY, target_version TEXT, updated_at TEXT NOT NULL);
-- 모듈 × 서버 배치 상태(목표 버전·실제 버전·건강·마지막 확인)
CREATE TABLE IF NOT EXISTS module_targets (
module TEXT NOT NULL, server_id TEXT NOT NULL, target_version TEXT, actual_version TEXT, health TEXT NOT NULL DEFAULT 'unknown', active INTEGER, last_check_at TEXT, last_error TEXT,
last_job_id TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(module, server_id)
);
-- Agent 에게 배정된 작업(HAL 작업의 자식). 상태: queued → running → done|failed|needs_user|cancelled
CREATE TABLE IF NOT EXISTS agent_jobs (
id TEXT PRIMARY KEY, hal_job_id TEXT, agent_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT, status TEXT NOT NULL DEFAULT 'queued', progress INTEGER NOT NULL DEFAULT 0,
steps_json TEXT, log_json TEXT, result_json TEXT, error TEXT, error_code TEXT, user_message TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, assigned_at TEXT, started_at TEXT, ended_at TEXT, heartbeat_at TEXT
);
CREATE INDEX IF NOT EXISTS agent_jobs_agent ON agent_jobs(agent_id, status, created_at);
CREATE INDEX IF NOT EXISTS agent_jobs_hal ON agent_jobs(hal_job_id);
-- 서버 지표(90일 이상 보존, §28.1)
CREATE TABLE IF NOT EXISTS metrics_samples (
server_id TEXT NOT NULL, at TEXT NOT NULL, cpu REAL, mem_used REAL, mem_total REAL, disk_used REAL, disk_total REAL, inode_used REAL, inode_total REAL, io_read REAL, io_write REAL, net_in REAL, net_out REAL, clock_skew_ms INTEGER, load1 REAL,
PRIMARY KEY(server_id, at)
);
-- 사건(§31): 같은 원인은 key 로 묶는다
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY, key TEXT NOT NULL, severity TEXT NOT NULL, title TEXT NOT NULL, component TEXT, customer_impact TEXT, started_at TEXT NOT NULL, resolved_at TEXT, verified_at TEXT,
assignee TEXT, public INTEGER NOT NULL DEFAULT 0, public_note TEXT, notes_json TEXT, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS incidents_key ON incidents(key, resolved_at);
-- 운영자 계정(§25). 고객 계정과 완전히 분리. mfa_json = { totp: { secret(보관소 참조 id), enabledAt } }
CREATE TABLE IF NOT EXISTS ops_accounts (
id TEXT PRIMARY KEY, login TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, pass_hash TEXT NOT NULL, pass_salt TEXT NOT NULL, mfa_json TEXT, status TEXT NOT NULL DEFAULT 'active',
failed_logins INTEGER NOT NULL DEFAULT 0, lock_until TEXT, last_login_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, must_change_password INTEGER NOT NULL DEFAULT 0
);
-- 짧은 수명의 사용자·기기 귀속 접근권(§25: 허용 네트워크 밖에서는 이것 없이 로그인 화면도 열리지 않음)
CREATE TABLE IF NOT EXISTS ops_access_grants (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL, code_hash TEXT UNIQUE, cookie_hash TEXT UNIQUE, ua_hash TEXT, ip TEXT, created_by TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, bound_at TEXT, revoked_at TEXT, reason TEXT
);
-- 긴급 복구 세션(§33)
CREATE TABLE IF NOT EXISTS emergency_sessions (
id TEXT PRIMARY KEY, incident_key TEXT NOT NULL, reason TEXT NOT NULL, scope_json TEXT NOT NULL, status TEXT NOT NULL, declared_by TEXT NOT NULL, declared_at TEXT NOT NULL,
approvals_json TEXT NOT NULL DEFAULT '[]', credential_hash TEXT, credential_issued_at TEXT, credential_expires_at TEXT, credential_revoked_at TEXT,
actions_json TEXT NOT NULL DEFAULT '[]', verify_json TEXT, reconcile_json TEXT, closed_at TEXT, closed_by TEXT, hal_healthy_at_declare INTEGER, updated_at TEXT NOT NULL
);
-- 재해복구(§34)
CREATE TABLE IF NOT EXISTS dr_config (id INTEGER PRIMARY KEY CHECK(id=1), rto_minutes INTEGER, rpo_minutes INTEGER, snapshot_interval_minutes INTEGER, retention_count INTEGER, external_store TEXT, updated_at TEXT, updated_by TEXT);
CREATE TABLE IF NOT EXISTS dr_snapshots (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, location TEXT NOT NULL, size_bytes INTEGER, sha256 TEXT, verify_state TEXT NOT NULL DEFAULT 'pending', verified_at TEXT, external_copied_at TEXT, external_location TEXT, kind TEXT NOT NULL DEFAULT 'scheduled', error TEXT, deleted_at TEXT);
CREATE TABLE IF NOT EXISTS dr_restores (id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, requested_by TEXT NOT NULL, approved_by TEXT, approved_at TEXT, old_control_stopped_confirmed_at TEXT, started_at TEXT, finished_at TEXT, status TEXT NOT NULL, checks_json TEXT, diff_json TEXT, note TEXT);
CREATE TABLE IF NOT EXISTS dr_drills (id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT, snapshot_id TEXT, performed_by TEXT, steps_json TEXT NOT NULL, measured_rto_minutes REAL, measured_rpo_minutes REAL, result TEXT NOT NULL DEFAULT 'running', findings_json TEXT, next_due_at TEXT);
-- 부트스트랩·실행 상태(§32.2). 행 하나(id=1).
CREATE TABLE IF NOT EXISTS bootstrap_state (
id INTEGER PRIMARY KEY CHECK(id=1), first_install INTEGER NOT NULL DEFAULT 1, release_version TEXT, main_commit TEXT, artifact_sha256 TEXT, hal_running_version TEXT, hal_started_at TEXT,
first_agent_id TEXT, first_agent_registered_at TEXT, manual_install_locked INTEGER NOT NULL DEFAULT 0, live_instance_id TEXT, live_lease_at TEXT, completed_at TEXT, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transfers (id TEXT PRIMARY KEY, created_by_agent TEXT, path TEXT NOT NULL, size_bytes INTEGER, sha256 TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, purpose TEXT, meta_json TEXT, deleted_at TEXT);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT NOT NULL, updated_by TEXT);
CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_at TEXT NOT NULL, delivered_at TEXT, last_error TEXT);
` },
// [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID
{ id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` },
// [legacy 메일 서비스 가져오기] HAL 관리형 메일 서비스 등록부·가져오기 단위(hal/mailsvc.mjs). 표 추가만(기존 표 변경 없음).
...MAIL_MIGRATIONS,
// Webmaker 전체 인벤토리는 heartbeat 2 MiB 한도를 넘을 수 있어 인증된 배치로 원자적으로 조립한다.
{ id: 'hal_0004_webmaker_inventory_stage', sql: `
CREATE TABLE IF NOT EXISTS agent_inventory_staging (
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, kind TEXT NOT NULL, scan_id TEXT NOT NULL, total INTEGER NOT NULL, next_offset INTEGER NOT NULL DEFAULT 0,
meta_json TEXT NOT NULL, items_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL,
PRIMARY KEY(agent_id, kind)
);
` },
// 사전 릴리스에서 FK 없이 같은 staging 표를 만든 경우에도 고아 row를 정리하고 이후 삭제를 보장한다.
{ id: 'hal_0005_webmaker_inventory_cleanup', sql: `
DELETE FROM agent_inventory_staging WHERE agent_id NOT IN (SELECT id FROM agents);
CREATE TRIGGER IF NOT EXISTS agent_inventory_staging_agent_cleanup
AFTER DELETE ON agents BEGIN
DELETE FROM agent_inventory_staging WHERE agent_id=OLD.id;
END;
` },
];
+26
View File
@@ -0,0 +1,26 @@
/**
* hal/totp.mjs — RFC 6238 TOTP(운영자 다중 인증, §3.3·§25). 외부 의존성 없이 node:crypto 만 쓴다.
* 비밀은 base32 로 표시(인증 앱 등록용 otpauth URL)하고 저장은 보관소에만 한다.
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buf) { let bits = 0, value = 0, out = ''; for (const b of buf) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32[(value << (5 - bits)) & 31]; return out; }
export function base32Decode(str) { const s = String(str).toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const out = []; for (const c of s) { value = (value << 5) | B32.indexOf(c); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); }
/** 새 비밀(base32 32자) */
export const newSecret = () => base32Encode(randomBytes(20));
/** 코드 계산. step 30초, 6자리 */
export function totpCode(secretB32, at = Date.now(), step = 30) {
const counter = Math.floor(at / 1000 / step); const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac('sha1', base32Decode(secretB32)).update(msg).digest(); const off = h[h.length - 1] & 0xf;
const code = ((h[off] & 0x7f) << 24 | (h[off + 1] & 0xff) << 16 | (h[off + 2] & 0xff) << 8 | (h[off + 3] & 0xff)) % 1000000;
return String(code).padStart(6, '0');
}
/** 검증(앞뒤 1단계 허용, 상수 시간 비교) */
export function verifyTotp(secretB32, code, at = Date.now()) {
const c = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(c)) return false;
for (const d of [-1, 0, 1]) { const exp = Buffer.from(totpCode(secretB32, at + d * 30000)); const got = Buffer.from(c); if (exp.length === got.length && timingSafeEqual(exp, got)) return true; }
return false;
}
/** 인증 앱 등록 URL */
export const otpauthUrl = (secretB32, login, issuer = 'Ariweb HAL') => `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(login)}?secret=${secretB32}&issuer=${encodeURIComponent(issuer)}&digits=6&period=30`;
+132
View File
@@ -0,0 +1,132 @@
/**
* hal/ui.mjs — 운영자 콘솔 화면(§25~§34) 라우트. hal/app.mjs 가 registerUi(app, d) 로 부른다.
*
* 구조
* - 모든 화면은 /ops/* 아래에 있고 app.mjs 의 운영자 미들웨어(네트워크 관문 → 운영자 세션)를 먼저 거친다. 허용 네트워크·접근권이 없으면 로그인 화면도 열리지 않는다(403).
* - 운영자 세션이 없으면(고객 세션 쿠키가 있어도) 로그인 화면만 보인다. 고객 세션으로는 어떤 운영자 화면도 열 수 없다.
* - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는
* 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음).
* - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css).
* - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs).
*/
import { esc } from '../lib/http.mjs';
import * as session from '../lib/session.mjs';
import { describe } from '../lib/jobs.mjs';
import { listAudit } from '../lib/audit.mjs';
import { listNotifications } from '../lib/notify.mjs';
import { redact } from '../lib/redact.mjs';
import { nowIso } from '../lib/db.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as core from './core.mjs';
import * as ops from './ops.mjs';
import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import * as mailsvc from './mailsvc.mjs';
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
import * as R from './ui/render.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** 메뉴(§25) */
export const MENU = [
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['account', '/ops/account', '운영자 계정'],
];
export function registerUi(app, d) {
/** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */
function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) {
if (!ctx.operator) return ctx.html(loginPage(ctx, d));
const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n;
const banner = [];
// [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다.
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
<div class="shell"><aside class="side" id="side"><nav aria-label="운영자 메뉴">${MENU.map(([k, href, label]) => `<a class="nav${nav === k ? ' on' : ''}" href="${href}">${esc(label)}</a>`).join('')}</nav></aside>
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div></main></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
<script type="module" src="/ops/ui/ops.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
}
// ---------------- 로그인·최초 설정
app.get('/ops/login', (ctx) => ctx.operator ? ctx.redirect('/ops/') : ctx.html(loginPage(ctx, d)));
app.get('/ops/setup', (ctx) => ctx.html(loginPage(ctx, d, { setup: true })));
// ---------------- 운영 현황(§26.1)
app.get('/ops/', (ctx) => shell(ctx, { title: '운영 현황', nav: 'overview', page: 'overview', body: R.overviewView(overviewLocal(d)) }));
// ---------------- 포털 중계 화면(브라우저가 /ops/api/portal/* 로 채운다)
const relayPage = (path, nav, title, page) => app.get(path, (ctx) => shell(ctx, { title, nav, page, id: ctx.params.id || ctx.params.groupId || '', body: R.statePanel('loading', '포털에서 불러오는 중') }));
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
// 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다).
app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `<h2>HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)</h2>${mailServicesView(mailsvc.readback(d))}<h2>포털 메일 서비스</h2><div id="portal-mail">${R.statePanel('loading', '포털에서 불러오는 중')}</div>` }));
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
// ---------------- 서버(§28)
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
app.get('/ops/servers/:id', (ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) return shell(ctx, { title: '서버', nav: 'servers', page: 'server', body: R.statePanel('failed', '서버가 없습니다') }); const ps = core.publicServer(d, s); const jobs = d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe); const agentJobs = d.store.all('SELECT id, agent_id, kind, status, progress, created_at, ended_at, error, user_message FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '');
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
// ---------------- 릴리스(§32.1)
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
// ---------------- 작업(§29)
app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }));
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
return shell(ctx, { title: '운영 작업', nav: 'jobs', page: 'jobs', body: filters + R.table([{ l: '작업 번호', f: (j) => `<a href="/ops/jobs/${esc(j.id)}" class="mono">${esc(j.id)}</a><br><b>${esc(j.label)}</b>` }, { l: '종류', k: 'kind' }, { l: '고객·대상', f: (j) => `${esc(j.groupId || '—')}<br><span class="muted">${esc((j.subject && (j.subject.target || j.subject.module || j.subject.payload?.siteId)) || '')}</span>` }, { l: '시작 주체', f: (j) => esc(j.actorType === 'operator' ? `운영자 ${j.createdBy || ''}` : j.actorType === 'system' ? '자동' : `사용자 ${j.createdBy || ''}`) }, { l: '상태', f: (j) => R.jobBadge(j.status) + (j.stale ? ' ' + R.badge('stale', '갱신 지연') : '') }, { l: '단계·진행', f: (j) => `${esc(j.currentStep ? j.currentStep.title : '—')} · ${j.progress || 0}%` }, { l: '재시도·대기', f: (j) => `${j.maxAttempts > 1 ? `${j.attempt}/${j.maxAttempts}` : '—'}${j.waitReason ? `<br><span class="muted">${esc(j.waitReason)}</span>` : ''}` }, { l: '갱신', f: (j) => R.fmt(j.updatedAt) }, { l: '오류', f: (j) => `<span class="err">${esc(j.error || '')}</span>` }], jobs, { empty: '작업이 없습니다' }) }); });
app.get('/ops/jobs/:id', (ctx) => { const j = d.jobs.get(ctx.params.id); if (!j) return shell(ctx, { title: '작업', nav: 'jobs', page: 'job', body: R.statePanel('failed', '작업이 없습니다') }); const v = { ...describe(j), subject: redact(j.subject), result: j.result, log: j.log, createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id };
return shell(ctx, { title: `작업 · ${j.label || j.kind}`, nav: 'jobs', page: 'job', id: j.id, body: `${R.jobBlock(v)}${R.childrenBlock(d.jobs.childrenSummary(j.id))}<h3>Agent 작업</h3>${R.agentJobsTable(d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id))}${j.result ? `<h3>결과</h3><pre class="pre">${esc(JSON.stringify(redact(j.result), null, 1))}</pre>` : ''}<h3>기록</h3><pre class="pre">${esc((j.log || []).map((l) => `${l.at} ${l.line}`).join('\n') || '없음')}</pre>` }); });
// ---------------- 사건·알림(§31.2)
app.get('/ops/incidents', (ctx) => shell(ctx, { title: '장애와 알림', nav: 'incidents', page: 'incidents', body: R.incidentsView(d.store.all('SELECT * FROM incidents ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200'), listNotifications(d.store, { groupId: null, limit: 100 })) + `<p class="muted">공개 상태 페이지: <a href="/status" target="_blank" rel="noopener">/status</a> — 개인정보·내부 위치 없음, 고객 영향 없는 내부 경고는 올리지 않음, 복구 완료는 실제 외부 확인 뒤 게시(§31.3).</p>` }));
// ---------------- 감사(§24.2·§25)
app.get('/ops/audit', (ctx) => shell(ctx, { title: '감사 기록', nav: 'audit', page: 'audit', body: `<form class="row" method="get" action="/ops/audit"><input name="action" placeholder="행동(예: ops., release., emergency.)" value="${esc(ctx.query.action || '')}"><input name="subjectId" placeholder="대상 id" value="${esc(ctx.query.subjectId || '')}"><input type="date" name="since" value="${esc(ctx.query.since || '')}"><button class="btn">조회</button></form>` + R.auditTable(listAudit(d.store, { action: ctx.query.action || undefined, subjectId: ctx.query.subjectId || undefined, since: ctx.query.since || undefined, limit: 300 })) }));
// ---------------- 플랫폼 복구(§33·§34)
app.get('/ops/recovery', (ctx) => shell(ctx, { title: '플랫폼 복구', nav: 'recovery', page: 'recovery', body: `<div class="tabs"><a href="#emergency" class="on" data-tab="emergency">긴급 복구(§33)</a><a href="#dr" data-tab="dr">재해복구(§34)</a></div><section id="emergency">${R.emergencyView({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS })}</section><section id="dr" hidden>${R.drView({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50'), hal: core.selfHealth(d) })}</section>` }));
// ---------------- 운영자 계정(§25·§3.3)
app.get('/ops/account', (ctx) => shell(ctx, { title: '운영자 계정', nav: 'account', page: 'account', body: R.accountsView({ me: ctx.operator, recentlyAuthed: session.recentlyAuthed(ctx.session), via: ctx.opsVia, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })), grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 100'), accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }) }));
// [ops UI 추가] 서버 화면용 읽기 전용: 릴리스 변경 이력(VERSION_HISTORY.md 절)
app.get('/ops/api/changelog', () => ({ ok: true, changelog: changelog() }));
}
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
function loginPage(ctx, d, { setup = false } = {}) {
const needSetup = !core.ensureOpsAccountExists(d); const via = ctx.opsVia === 'grant' ? '기기 귀속 접근권' : '허용 네트워크';
const body = needSetup || setup ? `<h1>HAL 최초 설정</h1><p class="muted">bootstrap-hal.sh 가 한 번만 출력한 설정 토큰으로 첫 운영자 계정을 만듭니다. 토큰은 사용 후 폐기되며 다시 쓸 수 없습니다.</p><form id="setup-form"><label>설정 토큰<input name="setupToken" required autocomplete="off"></label><label>아이디<input name="login" required pattern="[a-z0-9][a-z0-9._-]{2,31}"></label><label>표시 이름<input name="displayName"></label><label>비밀번호(10자 이상)<input name="password" type="password" required minlength="10"></label><button class="btn primary">첫 운영자 만들기</button><p class="err" id="msg"></p></form>` : `<h1>운영자 로그인</h1><p class="muted">고객 계정으로는 로그인할 수 없습니다. 접속 경로: ${esc(via)}.</p><form id="login-form"><label>아이디<input name="login" required autocomplete="username"></label><label>비밀번호<input name="password" type="password" required autocomplete="current-password"></label><label id="totp-row" hidden>인증 코드(또는 복구 코드)<input name="totp" autocomplete="one-time-code"></label><button class="btn primary">로그인</button><p class="err" id="msg" role="alert"></p></form>`;
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"></head><body class="login"><main class="login-box" data-page="${needSetup || setup ? 'setup' : 'login'}">${body}<p class="muted small">Ariweb HAL v${esc(d.version)} · 반복 실패 시 15분 잠금 · 세션 24시간</p></main><script type="module" src="/ops/ui/ops.js"></script></body></html>`;
}
/** 운영 현황 데이터(hal/app.mjs overview 와 같은 규칙으로 HAL 자체 데이터만 모은다) */
export function overviewLocal(d) {
const servers = core.listServers(d); const health = core.selfHealth(d);
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
}
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */
function mailServicesView(rb) {
const warn = []; if (rb.duplicates.length) warn.push(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`); if (rb.secretLeaks) warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
const t = R.table([
{ l: '도메인', f: (s) => `<b>${esc(s.domain)}</b><br><span class="muted">${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}</span>` },
{ l: '공개 주소', f: (s) => s.webUrl ? `<a href="${esc(s.webUrl)}" rel="noopener" target="_blank">${esc(s.publicHost)}</a>` : '—' },
{ l: '서버·인스턴스', f: (s) => `<a href="/ops/servers/${esc(s.serverId)}">${esc(s.serverName || s.serverId)}</a><br><span class="muted mono">${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}</span>` },
{ l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} <span class="muted">(AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})</span>${s.instance.observedVersion ? `<br><span class="muted">Agent 관측 ${esc(s.instance.observedVersion)}</span>` : ''}` },
{ l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' },
{ l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}<br><span class="muted">${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}</span>` },
{ l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}</span>` },
{ l: '비밀', f: (s) => `<span class="muted">참조 ${(s.secretRefs || []).length}건(값 없음)</span>` },
], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' });
return warn.join('') + t + `<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;
}
+27
View File
@@ -0,0 +1,27 @@
/* hal/ui/public/ops.css — 운영자 콘솔 스타일. 상태는 글자+기호로 구분(색은 보조). 760px 이하에서 메뉴가 접히고 표가 카드로 바뀐다(360px 조회·승인·중지·재시도 가능). */
:root{--bg:#f3f5f8;--fg:#1b2430;--mut:#5b6672;--line:#d9dee6;--pri:#1f4fb0;--ok:#1a7f4b;--warn:#b26a00;--bad:#b3261e;--card:#fff;--rad:10px;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-size:14px;line-height:1.5}a{color:var(--pri)}
.skip{position:absolute;left:-999px}.skip:focus{left:8px;top:8px;background:#fff;padding:6px;z-index:99}
.top{display:flex;align-items:center;gap:12px;padding:8px 14px;background:#101826;color:#fff;position:sticky;top:0;z-index:20}.top a{color:#fff}.brand{font-weight:700;text-decoration:none}.ver{font-size:12px;opacity:.9}.menu-btn{display:none;font-size:20px;background:none;border:0;color:#fff}
.top-right{margin-left:auto;display:flex;gap:12px;align-items:center}.top-right .muted{color:#c7d0dc}
.shell{display:flex;min-height:calc(100vh - 48px)}.side{width:210px;background:#fff;border-right:1px solid var(--line);padding:10px 8px}.side .nav{display:block;padding:8px 10px;border-radius:8px;color:var(--fg);text-decoration:none}.side .nav.on{background:#e6edfb;color:var(--pri);font-weight:600}
.main{flex:1;padding:14px 18px;max-width:1400px;min-width:0}h1{font-size:20px;margin:4px 0 10px}h2{font-size:16px;margin:18px 0 8px}h3{font-size:15px;margin:14px 0 6px}
.alerts{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}.alert{display:block;padding:9px 12px;border-radius:8px;border:1px solid;text-decoration:none;color:var(--fg)}.alert.warning{background:#fff7e6;border-color:#f0c36d}.alert.critical{background:#fdecea;border-color:#f3a09a}.alert.info{background:#e8effc;border-color:#b9cdf2}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--rad);padding:14px;margin-bottom:12px;min-width:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.card h2{margin-top:0}.comp{display:flex;flex-direction:column;gap:4px}
.kv{display:grid;grid-template-columns:max-content 1fr;gap:5px 12px;margin:8px 0}.kv dt{color:var(--mut)}.kv dd{margin:0;word-break:break-word}
.btn{display:inline-block;padding:7px 12px;border:1px solid var(--line);background:#fff;border-radius:8px;cursor:pointer;font-size:13px;color:var(--fg);text-decoration:none}.btn.primary{background:var(--pri);color:#fff;border-color:var(--pri)}.btn.danger{color:var(--bad);border-color:var(--bad)}.btn.small{padding:4px 8px;font-size:12px}.btn.on{background:#e6edfb;border-color:var(--pri)}.btn:disabled{opacity:.5;cursor:not-allowed}.actions{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.badge{display:inline-block;padding:1px 8px;border-radius:12px;font-size:12px;border:1px solid var(--line);background:#fff;white-space:nowrap}.badge.ok,.badge.done{border-color:var(--ok);color:var(--ok)}.badge.failed{border-color:var(--bad);color:var(--bad)}.badge.warn,.badge.stale{border-color:var(--warn);color:var(--warn)}.badge.running,.badge.waiting,.badge.info{border-color:var(--pri);color:var(--pri)}
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:var(--rad);overflow:hidden;margin-bottom:10px}th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;font-size:13px}th{background:#f7f9fc;font-weight:600}tr:last-child td{border-bottom:0}
.aw-state{padding:20px;text-align:center;color:var(--mut);background:#fff;border:1px dashed var(--line);border-radius:var(--rad);margin:8px 0}.aw-state.failed,.aw-state.forbidden{border-color:var(--bad);color:var(--bad)}.aw-state.stale,.aw-state.partial{border-color:var(--warn);color:var(--warn)}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}input,select,textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:8px;width:100%;max-width:420px}label{display:block;font-weight:600;margin:8px 0 2px}.row label{margin:0;font-weight:400}
.steps{list-style:none;padding:0;margin:6px 0}.steps li{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line)}.steps .s{width:20px;text-align:center}.steps .done .s{color:var(--ok)}.steps .running .s{color:var(--pri)}.steps .failed .s{color:var(--bad)}.steps .waiting .s,.steps .needs_user .s{color:var(--warn)}
.flow{display:flex;flex-wrap:wrap;gap:4px;padding:0;list-style:none;counter-reset:f}.flow li{padding:3px 8px;border:1px solid var(--line);border-radius:8px;font-size:12px;color:var(--mut)}.flow li.done{border-color:var(--ok);color:var(--ok)}.flow li.now{border-color:var(--pri);color:var(--pri);font-weight:600}
.pre{background:#f7f9fc;border:1px solid var(--line);border-radius:8px;padding:8px;white-space:pre-wrap;word-break:break-word;font-size:12px;max-height:320px;overflow:auto}.muted{color:var(--mut)}.err{color:var(--bad)}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px}.small{font-size:12px}
.chart{width:100%;height:auto;background:#fff;border:1px solid var(--line);border-radius:8px}.chart .line{stroke:var(--pri);stroke-width:1.5}.chart .axis{stroke:var(--line)}.chart .lbl{font-size:11px;fill:var(--mut)}
.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:#101826;color:#fff;padding:10px 16px;border-radius:8px;display:none;z-index:50;max-width:92vw}.toast.show{display:block}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:40;padding:14px}.modal[hidden]{display:none}.modal .box{background:#fff;border-radius:12px;padding:18px;max-width:600px;width:100%;max-height:92vh;overflow:auto}.modal .box h2{margin-top:0}.once{background:#fff7e6;border:1px dashed var(--warn);padding:10px;border-radius:8px;word-break:break-all}
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:10px}.tabs a{padding:8px 12px;text-decoration:none;color:var(--fg);border-bottom:2px solid transparent}.tabs a.on{border-bottom-color:var(--pri);color:var(--pri);font-weight:600}
.pending{list-style:none;padding:0;display:flex;gap:14px;flex-wrap:wrap}.pending li{background:#fff;border:1px solid var(--line);border-radius:8px;padding:8px 12px}
body.login{display:grid;place-items:center;min-height:100vh}.login-box{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px;width:min(420px,94vw)}
@media (max-width:760px){.menu-btn{display:block}.side{position:fixed;left:-230px;top:48px;bottom:0;transition:left .2s;z-index:30;overflow:auto}.side.open{left:0}.main{padding:10px 12px}.top-right .muted{display:none}
table.r{display:block;border:0;background:none}table.r thead{display:none}table.r tr{display:block;background:#fff;border:1px solid var(--line);border-radius:var(--rad);margin-bottom:8px;padding:6px 10px}table.r td{display:flex;justify-content:space-between;gap:8px;border:0;padding:5px 0;flex-wrap:wrap}table.r td::before{content:attr(data-l);color:var(--mut);font-size:12px;min-width:70px}.kv{grid-template-columns:1fr}}
+147
View File
@@ -0,0 +1,147 @@
/* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트.
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
*/
import * as R from './render.mjs';
import { callWithReauth, reauthPrompt } from './reauth.mjs';
const $ = (s, el = document) => el.querySelector(s);
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' });
let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
return j;
}
/** 서버가 reauth_required 로 거부하면 고위험 안내가 붙은 재인증 창을 열고, 성공했을 때만 딱 한 번 다시 시도(취소·실패 시 원 요청 미실행 — hal/ui/public/reauth.mjs 계약) */
const call = (method, path, body) => callWithReauth(() => api(method, path, body), reauth);
const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body);
let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); }
function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; }
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
return new Promise((resolve) => {
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
$('#askc', m.el).onclick = () => { m.close(); resolve(null); };
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : fd.get(f.name); m.close(); resolve(out); };
});
}
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
function showOnce(title, value, note = '') { const m = modal(`<h2>${R.esc(title)}</h2><p class="err">이 값은 지금 한 번만 표시되며 다시 볼 수 없습니다. 안전한 곳에 바로 옮기세요.</p>${note ? `<p class="muted">${R.esc(note)}</p>` : ''}<pre class="once" id="once">${R.esc(value)}</pre><div class="actions"><button class="btn" id="cp">복사</button><button class="btn primary" id="ok">닫기(확인했습니다)</button></div>`); $('#cp', m.el).onclick = () => { navigator.clipboard?.writeText(value); toast('복사했습니다'); }; $('#ok', m.el).onclick = m.close; }
/** 고위험 작업 3단계 확인(§25): 영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부 */
function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌리기 가능 여부를 확인하세요' }) {
return new Promise((resolve) => {
let step = 1; const m = modal('');
const render = () => { m.el.innerHTML = `<div class="box"><h2>${R.esc(title)} — ${step}/3단계</h2>${step === 1 ? `<p><b>1단계 영향 범위 확인</b></p><ul>${impacts.map((i) => `<li>${R.esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="c1"> 위 영향 범위를 확인했습니다</label>` : step === 2 ? `<p><b>2단계 대상 식별정보 재확인</b> — 아래 식별정보를 정확히 입력하세요: <code>${identifiers.map(R.esc).join(', ')}</code></p><input id="c2" placeholder="식별정보(쉼표로 구분)" autocomplete="off">` : `<p><b>3단계 최종 실행</b></p><p>${R.esc(revertible)}</p><label><input type="checkbox" id="c3"> 최종 실행을 확인합니다</label>`}<div class="actions"><button class="btn" id="cancel">취소</button><button class="btn ${step === 3 ? 'danger' : 'primary'}" id="next">${step === 3 ? '실행' : '다음'}</button></div></div>`;
$('#cancel', m.el).onclick = () => { m.close(); resolve(null); };
$('#next', m.el).onclick = () => { if (step === 1 && !$('#c1', m.el).checked) return toast('영향 범위를 확인해야 합니다'); if (step === 2) { const got = $('#c2', m.el).value.split(',').map((s) => s.trim()).filter(Boolean).sort(); if (JSON.stringify(got) !== JSON.stringify(identifiers.slice().sort())) return toast('대상 식별정보가 다릅니다'); } if (step === 3 && !$('#c3', m.el).checked) return toast('최종 실행을 확인해야 합니다'); if (step < 3) { step++; render(); } else { m.close(); resolve({ confirmImpact: true, confirmIdentifiers: identifiers, confirmExecute: true }); } }; };
render();
});
}
/** 재인증 창. required=true(서버 거부 직후)일 때만 고위험 안내를 보이고, 계정 화면의 수동 재인증은 안내 없이 비밀번호만 묻는다 */
async function reauth(opts = {}) { const p = reauthPrompt(opts); const v = await ask(p.title, [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: p.okLabel, note: p.note ? R.esc(p.note) : '' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
const reload = () => location.reload();
async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } }
// ---------------- 페이지별 데이터 로드(포털 중계 포함)
const body = () => $('#body');
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
const PAGES = {
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); body().innerHTML = R.overviewView(o); } catch (e) { body().innerHTML = R.relayError(e); return; }
const el = $('#pending-approvals'); if (!el) return; const partial = []; const count = async (p, pick) => { try { const r = await relay(p); return pick(r); } catch (e) { partial.push(p); return 0; } };
const [orders, restores, relocations, transfers] = await Promise.all([count('/commerce/orders?status=awaiting_approval', (r) => (r.orders || []).length), count('/backup/restores', (r) => (r.restores || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/relocation', (r) => (r.relocations || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/transfer', (r) => (r.transfers || []).filter((x) => ['reviewing', 'accepted'].includes(x.status)).length)]);
el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); },
customers: async () => { const q = new URLSearchParams(location.search).get('q') || ''; try { body().innerHTML = R.customersSearch(await relay('/customers?q=' + encodeURIComponent(q))); } catch (e) { body().innerHTML = R.relayError(e); } },
customer: async (id) => { try { body().innerHTML = R.customerView(await relay('/customers/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
orders: async () => { try { body().innerHTML = R.ordersView(await relay('/commerce/orders')); } catch (e) { body().innerHTML = R.relayError(e); } },
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
mail: async () => { const el = document.getElementById('portal-mail') || body(); try { el.innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { el.innerHTML = R.relayError(e); } },
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); b.innerHTML = (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'; } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); },
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
};
// ---------------- 동작(data-act)
const ACT = {
'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(),
job: async (b) => { await call('POST', `/ops/api/jobs/${b.id}/${b.op}`); toast('요청했습니다'); reload(); },
'release-approve': async (b) => { if (!(await ask('릴리스 승인', [{ name: 'ok', label: `릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`, type: 'checkbox' }]))) return; await call('POST', `/ops/api/releases/${b.v}/approve`); toast('승인했습니다'); reload(); },
'module-deploy': async (b) => { const sv = await api('GET', '/ops/api/servers'); const v = await ask(`${b.m} ${b.v} 배포`, [{ name: 'serverIds', label: '대상 서버 id(쉼표, 비우면 해당 역할 전체)', hint: sv.servers.map((s) => `${s.id}=${s.name}(${s.role},${s.status})`).join(' ') }], { note: 'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.' }); if (!v) return; const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds ? v.serverIds.split(',').map((s) => s.trim()).filter(Boolean) : [], nonce: Date.now() }); location.href = '/ops/jobs/' + r.job.id; },
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); },
'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); },
'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); },
'server-accepts': async (b) => { await call('PATCH', `/ops/api/servers/${b.id}`, { accepts_new: Number(b.v) }); reload(); },
'server-isolate': async (b) => { const v = await ask('서버 격리', [{ name: 'reason', label: '격리 이유', required: true }], { danger: true }); if (!v) return; await call('PATCH', `/ops/api/servers/${b.id}`, { status: 'isolated', isolated_reason: v.reason }); reload(); },
'server-delete': async (b) => { const c = await threeStep({ title: '서버 삭제', impacts: [`서버 ${b.id} 와 그 Agent 를 해지합니다`, `배치된 서비스 ${b.hosted}개가 있으면 먼저 이전해야 합니다`, '지표·작업 기록은 남습니다'], identifiers: [b.id], revertible: '삭제 후 복구할 수 없습니다(다시 등록해야 함)' }); if (!c) return; await call('POST', `/ops/api/servers/${b.id}/delete`, c); location.href = '/ops/servers'; },
'incident-new': async () => { const v = await ask('사건 등록', [{ name: 'key', label: '사건 키(같은 원인은 같은 키로 묶임)', required: true }, { name: 'title', label: '제목', required: true }, { name: 'severity', label: '심각도', type: 'select', options: ['info', 'warning', 'critical'] }, { name: 'component', label: '구성요소' }, { name: 'customerImpact', label: '고객 영향(없으면 비움 — 내부 경고는 공개하지 않음)' }, { name: 'public', label: '공개 상태 페이지에 표시', type: 'checkbox' }, { name: 'publicNote', label: '고객 공지 문구' }]); if (!v) return; await call('POST', '/ops/api/incidents', v); reload(); },
'incident-note': async (b) => { const v = await ask('대응 메모', [{ name: 'note', label: '메모', type: 'textarea', required: true }, { name: 'assignee', label: '담당자' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); },
'incident-public': async (b) => { const v = await ask('고객 공지', [{ name: 'publicNote', label: '공개 문구(개인정보·내부 위치 없이)', type: 'textarea', required: true }, { name: 'public', label: '공개', type: 'checkbox', value: true }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); },
'incident-resolve': async (b) => { const v = await ask('사건 종료', [{ name: 'verifiedUserPath', label: '실제 사용자 경로(외부 접속·메일 왕복 등)로 복구를 확인했습니다', type: 'checkbox' }, { name: 'note', label: '복구 확인 내용', type: 'textarea' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, { ...v, resolve: true }); reload(); },
'em-declare': async () => { const v = await ask('긴급 복구 선언', [{ name: 'incidentKey', label: '사건 번호(키)', required: true }, { name: 'reason', label: '복구 이유', type: 'textarea', required: true }, { name: 'scope', label: '허용 대상(쉼표: hal,hal_data,vault,cert_base,artifact_store,first_agent)', required: true }], { danger: true, note: 'HAL 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. HAL 이 정상이면 거부됩니다.' }); if (!v) return; await call('POST', '/ops/api/emergency', { ...v, scope: v.scope.split(',').map((s) => s.trim()).filter(Boolean) }); reload(); },
'em-approve': async (b) => { const step = Number(b.step); const fields = step === 1 ? [{ name: 'impactText', label: '1단계 — 영향 범위를 적으세요', type: 'textarea', required: true }] : step === 2 ? [{ name: 'identifiers', label: '2단계 — 변경되지 않는 대상 식별정보(쉼표)', required: true }] : [{ name: 'finalConfirm', label: '3단계 — 최종 실행을 확인합니다', type: 'checkbox' }, { name: 'revertible', label: '되돌리기 가능', type: 'checkbox' }]; const v = await ask(`승인 ${step}단계`, fields, { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/approve`, { step, ...v, identifiers: v.identifiers ? v.identifiers.split(',').map((s) => s.trim()) : undefined }); reload(); },
'em-credential': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/credential`); showOnce('긴급 자격(짧은 유효 시간)', r.credential, `만료 ${R.fmt(r.expiresAt)} · 정상화 후 폐기됩니다`); setTimeout(reload, 500); },
'em-perform': async (b) => { const v = await ask('긴급 작업 수행', [{ name: 'credential', label: '긴급 자격', type: 'password', required: true }, { name: 'action', label: '작업', type: 'select', options: ['hal.restart', 'hal.restore_snapshot', 'vault.verify', 'cert.reissue_internal', 'artifact.restore', 'agent.reconnect'] }, { name: 'target', label: '대상(선택)' }], { danger: true, note: '허용 범위 밖 작업·만료 자격·일반 배포는 거부됩니다.' }); if (!v) return; try { const r = await call('POST', `/ops/api/emergency/${b.id}/perform`, v); toast('수행 결과: ' + JSON.stringify(r.result).slice(0, 200)); } catch (e) { toast('거부: ' + e.message); } reload(); },
'em-verify': async (b) => { const v = await ask('실제 결과 확인·자격 폐기', [{ name: 'note', label: '실제 확인 내용(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)', type: 'textarea', required: true }]); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/verify`, { verify: { note: v.note } }); reload(); },
'em-reconcile': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/reconcile`); toast(`차이 ${(r.result.drift || []).length}건`); reload(); },
'em-close': async (b) => { const v = await ask('사건 종료', [{ name: 'note', label: '종료 메모', type: 'textarea' }], { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/close`, v); reload(); },
'dr-config': async () => { const v = await ask('재해복구 설정', [{ name: 'rto_minutes', label: '복구 목표 시간 RTO(분)', type: 'number', required: true }, { name: 'rpo_minutes', label: '허용 데이터 시점 RPO(분)', type: 'number', required: true }, { name: 'snapshot_interval_minutes', label: '복구 사본 생성 주기(분)', type: 'number', required: true }, { name: 'retention_count', label: '보존 개수', type: 'number', required: true }, { name: 'external_store', label: '외부 보관 위치' }]); if (!v) return; await call('PUT', '/ops/api/dr/config', Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === 'external_store' ? x : Number(x)]))); reload(); },
'dr-snapshot': async () => { await call('POST', '/ops/api/dr/snapshots'); toast('복구 사본을 만들었습니다'); reload(); },
'dr-drill': async (b) => { await call('POST', '/ops/api/dr/drills', { snapshotId: b.id || null }); toast('훈련을 시작했습니다 — 실제 복구 뒤 결과를 입력하세요'); reload(); },
'dr-drill-finish': async (b) => { const v = await ask('훈련 결과', [{ name: 'result', label: '결과', type: 'select', options: ['success', 'failed'] }, { name: 'measuredRtoMinutes', label: '측정 RTO(분)', type: 'number', required: true }, { name: 'measuredRpoMinutes', label: '측정 RPO(분)', type: 'number', required: true }, { name: 'findings', label: '실패·조치 항목(줄바꿈)', type: 'textarea' }, { name: 'steps', label: '수행 단계(쉼표)', value: '운영자 로그인,HAL 제어,HTTPS,고객 서비스 외부 확인' }]); if (!v) return; await call('POST', `/ops/api/dr/drills/${b.id}/finish`, { result: v.result, measuredRtoMinutes: Number(v.measuredRtoMinutes), measuredRpoMinutes: Number(v.measuredRpoMinutes), findings: v.findings.split('\n').filter(Boolean), steps: v.steps.split(',').map((s) => ({ id: s.trim(), ok: v.result === 'success' })) }); reload(); },
'dr-restore': async (b) => { const v = await ask('복구 요청', [{ name: 'note', label: '이유', type: 'textarea', required: true }], { danger: true, note: '복구 시작에는 별도 승인과 기존 제어 위치 중지 확인이 필요합니다. 복구 후 HAL 하나만 운영 상태여야 합니다(§34).' }); if (!v) return; await call('POST', '/ops/api/dr/restores', { snapshotId: b.id, note: v.note }); reload(); },
'dr-approve': async (b) => { await call('POST', `/ops/api/dr/restores/${b.id}/approve`); reload(); },
'dr-start': async (b) => { const v = await ask('복구 시작', [{ name: 'oldControlStopped', label: '기존 제어 위치(이전 HAL)를 중지했음을 확인합니다', type: 'checkbox' }, { name: 'confirmText', label: '확인 문구 입력: 복구 시작', required: true }], { danger: true }); if (!v) return; const r = await call('POST', `/ops/api/dr/restores/${b.id}/start`, v); toast(r.message || '복구를 시작했습니다'); reload(); },
'dr-finish': async (b) => { const v = await ask('복구 완료 — 실제 확인 입력', [{ name: 'login', label: '실제 운영자 로그인 확인', type: 'checkbox' }, { name: 'control', label: 'HAL 제어 확인', type: 'checkbox' }, { name: 'https', label: 'HTTPS 확인', type: 'checkbox' }, { name: 'customer', label: '고객 서비스 한 개 이상 외부 확인', type: 'checkbox' }]); if (!v) return; await call('POST', `/ops/api/dr/restores/${b.id}/finish`, { checks: v }); reload(); },
password: async () => { const v = await ask('비밀번호 변경', [{ name: 'current', label: '현재 비밀번호', type: 'password', required: true }, { name: 'next', label: '새 비밀번호(10자 이상)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/password', v); toast('바꿨습니다(다른 세션 종료)'); reload(); },
'mfa-begin': async () => { const r = await call('POST', '/ops/api/mfa/begin'); const v = await ask('MFA 등록', [{ name: 'code', label: `인증 앱에 추가(otpauth): ${r.otpauth} — 수동 키: ${r.secret}. 앱의 6자리 코드를 입력하세요`, required: true }]); if (!v) return; const c = await call('POST', '/ops/api/mfa/confirm', { code: v.code }); showOnce('복구 코드(1회만 표시)', c.recoveryCodes.join('\n'), '각 코드는 한 번만 쓸 수 있습니다.'); setTimeout(reload, 300); },
'mfa-disable': async () => { const v = await ask('MFA 해제', [{ name: 'code', label: '인증 코드', required: true }], { danger: true }); if (!v) return; await call('POST', '/ops/api/mfa/disable', v); reload(); },
'session-revoke': async (b) => { await call('POST', '/ops/api/sessions/revoke', { id: b.id }); reload(); }, 'session-revoke-all': async () => { await call('POST', '/ops/api/sessions/revoke', { all: true }); reload(); },
'grant-issue': async () => { const v = await ask('접근권 발급', [{ name: 'accountId', label: '운영자 id(비우면 나)' }, { name: 'hours', label: '유효 시간(1~72시간)', type: 'number', value: 8 }, { name: 'reason', label: '사유', required: true }]); if (!v) return; const r = await call('POST', '/ops/api/access-grants', { accountId: v.accountId || undefined, hours: Number(v.hours), reason: v.reason }); showOnce('접근권 URL(1회)', r.grant.url, `허용 네트워크 밖 기기에서 이 URL 을 열면 그 기기에 묶입니다. 만료 ${R.fmt(r.grant.expiresAt)}`); },
'grant-revoke': async (b) => { await call('POST', `/ops/api/access-grants/${b.id}/revoke`); reload(); },
'account-new': async () => { const v = await ask('운영자 추가', [{ name: 'login', label: '아이디', required: true }, { name: 'displayName', label: '표시 이름' }, { name: 'password', label: '초기 비밀번호(첫 로그인 뒤 변경 강제)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/accounts', v); reload(); },
// ---- 포털 중계 동작(§26.2·§27·§30)
'cust-change': async (b) => { const v = await ask('고객 대신 변경', [{ name: 'field', label: '항목', type: 'select', options: ['group.name', 'group.contact_email', 'group.contact_phone', 'site.name', 'site.maintenance', 'site.search_visible'] }, { name: 'subjectId', label: '사이트 id(사이트 항목일 때)' }, { name: 'value', label: '변경 후 값(참/거짓 항목은 1/0)', required: true }, { name: 'reason', label: '사유', required: true }, { name: 'approver', label: '승인자' }]); if (!v) return; const r = await portal('POST', `/customers/${b.g}/change`, { ...v, value: /\.(maintenance|search_visible)$/.test(v.field) ? v.value === '1' : v.value }); toast(`변경: ${JSON.stringify(r.change.before)} → ${JSON.stringify(r.change.after)}`); reload(); },
'support-issue': async (b) => { const v = await ask('고객 지원용 일시 접근', [{ name: 'targetType', label: '대상 종류', type: 'select', options: ['group', 'site', 'mail_service'] }, { name: 'targetId', label: '대상 id' }, { name: 'reason', label: '이유', required: true }, { name: 'hours', label: '만료(시간, 최대 24)', type: 'number', value: 4 }, { name: 'scope', label: '허용 범위(쉼표: view,change)', value: 'view' }, { name: 'approver', label: '승인자' }]); if (!v) return; await portal('POST', '/support-access', { ...v, groupId: b.g, hours: Number(v.hours), scope: v.scope.split(',').map((s) => s.trim()) }); reload(); },
'support-revoke': async (b) => { await portal('POST', `/support-access/${b.id}/revoke`, {}); reload(); },
'order-paid': async (b) => { const v = await ask('입금 확인', [{ name: 'ref', label: '입금 확인 참조(입금자·시각 등)', required: true }]); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/paid`, v); reload(); },
'order-approve': async (b) => { const o = await portal('GET', `/commerce/orders/${b.id}`); const od = o.order || {}; const v = await ask('주문 승인', [{ name: 'ok', label: `대상 ${od.group?.name || od.group_id} · 금액 ${od.total} ${od.currency} · 권리 변화 ${(od.entitlementDelta || []).map((e) => e.resource + '+' + e.amount).join(', ')} — 확인했습니다`, type: 'checkbox' }]); if (!v || !v.ok) return; const r = await portal('POST', `/commerce/orders/${b.id}/approve`, {}); toast(r.already ? '이미 승인된 주문입니다(권리 중복 생성 없음)' : `승인 — 권리 ${r.granted}건 활성화`); reload(); },
'order-reject': async (b) => { const v = await ask('주문 거절', [{ name: 'reason', label: '사유(고객에게 표시)', required: true }], { danger: true }); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/reject`, v); reload(); },
'order-refund': async (b) => { const v = await ask('환불·크레딧', [{ name: 'kind', label: '종류', type: 'select', options: ['refund', 'credit', 'adjust'] }, { name: 'amount', label: '금액', type: 'number', required: true }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', '/commerce/credits', { groupId: b.g, orderId: b.id, kind: v.kind, amount: Number(v.amount), reason: v.reason }); reload(); },
'product-new-version': async (b) => { const v = await ask('새 상품 버전', [{ name: 'patch', label: '변경 내용(JSON, 예 {"price": 12000})', type: 'textarea', required: true }], { note: '판매된 버전은 바꾸지 않고 새 버전을 만듭니다. 기존 고객은 자기 버전을 유지합니다.' }); if (!v) return; let patch; try { patch = JSON.parse(v.patch); } catch (_) { return toast('JSON 형식이 아닙니다'); } await portal('POST', '/commerce/products', { productId: b.p, baseVersionId: b.id, patch }); reload(); },
'product-publish': async (b) => { await portal('POST', `/commerce/products/${b.id}/publish`, { published: b.pub === '1' }); reload(); },
'catalog-seed': async () => { await portal('POST', '/commerce/catalog/seed', {}); reload(); },
'site-refresh': async (b) => { await portal('POST', `/sites/${b.id}/refresh`, {}); reload(); },
'mail-rates': async (b) => { const v = await ask('발송 제한·제출량 조회', [{ name: 'domain', label: '도메인(선택)' }, { name: 'reason', label: '접근 사유(기록됨)', required: true }]); if (!v) return; const r = await portal('GET', `/mail/servers/${b.id}/rates?domain=${encodeURIComponent(v.domain || '')}`); modal(`<h2>발송 제한·제출량</h2><pre class="pre">${R.esc(JSON.stringify(r, null, 1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`); },
'mail-test': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/test`, {}); toast('외부 송수신 시험을 시작했습니다' + (r.job ? ' — 작업 ' + r.job.id : '')); },
'mail-security': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/security`, {}); modal(`<h2>DNS 보안 확인</h2><pre class="pre">${R.esc(JSON.stringify(r, null, 1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`); },
'mail-operating': async (b) => { const v = await ask('운영 상태(이전 중 두 서버 동시 처리 방지)', [{ name: 'operating', label: '상태', type: 'select', options: ['active', 'migrating_out', 'migrating_in', 'frozen'] }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', `/mail/services/${b.id}/operating`, v); reload(); },
'mail-cancel': async (b) => { const v = await ask('메일 서비스 정지·해지', [{ name: 'reason', label: '사유', required: true }, { name: 'ok', label: '송수신이 즉시 중지되고 30일 읽기·내보내기 뒤 삭제됩니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/mail/services/${b.id}/cancel`, { reason: v.reason }); reload(); },
'domains-sweep': async () => { await portal('POST', '/domains/sweep', {}); await portal('POST', '/source/sweep', {}).catch(() => {}); reload(); },
'backup-retry': async (b) => { await portal('POST', `/backup/options/${b.id}/retry`, {}); reload(); }, 'backup-override': async (b) => { const v = await ask('같은 서버 저장 허용(예외)', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/options/${b.id}/override-same-server`, v); reload(); },
'restore-approve': async (b) => { const d = await portal('GET', `/backup/restores/${b.id}`); const r = d.restore || d; const fc = d.finalConfirmation || {}; const v = await ask('복원 승인 — 최종 확인', [{ name: 'ok', label: `고객 그룹 ${fc.groupName || r.group_name} · 사이트 ${fc.siteName || r.site_name} · 도메인 ${fc.primaryDomain || r.primary_domain || '—'} · 백업 시각 ${R.fmt(fc.backupAt || r.backup_created_at)} — 위 내용을 최종 확인합니다`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/backup/restores/${b.id}/approve`, { confirm: fc }); reload(); },
'restore-reject': async (b) => { const v = await ask('복원 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/restores/${b.id}/reject`, v); reload(); }, 'restore-retry': async (b) => { await portal('POST', `/backup/restores/${b.id}/retry`, {}); reload(); },
'reloc-approve': async (b) => { const d = await portal('GET', `/relocation/${b.id}`); const fc = d.finalConfirmation || { subjectId: d.relocation?.subject_id, toServerId: d.relocation?.to_server_id }; const v = await ask('이전 승인 — 최종 확인', [{ name: 'scheduledAt', label: '작업 시간(ISO, 비우면 즉시)' }, { name: 'ok', label: `대상 ${fc.subjectName || fc.subjectId} → 서버 ${fc.toServerId} · 전환 직전 조건이 달라지면 자동 중지됩니다 — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/approve`, { scheduledAt: v.scheduledAt || null, confirm: fc }); reload(); },
'reloc-reject': async (b) => { const v = await ask('이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/relocation/${b.id}/reject`, v); reload(); },
'reloc-retry': async (b) => { await portal('POST', `/relocation/${b.id}/retry`, {}); reload(); }, 'reloc-resume': async (b) => { await portal('POST', `/relocation/${b.id}/resume`, {}); reload(); }, 'reloc-revert': async (b) => { const v = await ask('이전 되돌리기', [{ name: 'ok', label: '새 위치를 운영 위치로 확정하지 않고 이전 위치로 돌립니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/revert`, {}); reload(); },
'reloc-new': async () => { const v = await ask('플랫폼 사유 이전', [{ name: 'subjectType', label: '대상 종류', type: 'select', options: ['site', 'mail'] }, { name: 'subjectId', label: '대상 id', required: true }, { name: 'toServerId', label: '도착 서버 id(비우면 자동 선택)' }, { name: 'reason', label: '이유(장애 회피·용량 조정 — 고객 과금 없음)', required: true }]); if (!v) return; await portal('POST', '/relocation', v); reload(); },
'transfer-approve': async (b) => { const d = await portal('GET', `/transfer/${b.id}`); const fc = d.finalConfirmation || {}; const v = await ask('소유권 이전 승인', [{ name: 'proof', label: '소유 증빙 확인 내용', required: true }, { name: 'domains', label: '도메인 확인 결과', required: true }, { name: 'ok', label: `${fc.fromGroupId} → ${fc.toGroupId} 전체가 한 번에 이전됩니다(일부만 이전되지 않음) — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/transfer/${b.id}/approve`, { review: { proof: v.proof, domains: v.domains }, confirm: fc }); reload(); },
'transfer-reject': async (b) => { const v = await ask('소유권 이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/transfer/${b.id}/reject`, v); reload(); },
'transfer-resolve': async (b) => { const v = await ask('분쟁 해결', [{ name: 'resolution', label: '결정', type: 'select', options: ['continue', 'cancel'] }, { name: 'note', label: '근거', required: true }]); if (!v) return; await portal('POST', `/transfer/${b.id}/resolve-dispute`, v); reload(); },
};
document.addEventListener('click', (e) => { const el = e.target.closest('[data-act]'); if (!el) return; e.preventDefault(); const fn = ACT[el.dataset.act]; if (fn) guarded(() => fn(el.dataset)); });
document.addEventListener('submit', (e) => { const f = e.target.closest('[data-form=customer-search]'); if (f) { e.preventDefault(); location.href = '/ops/customers?q=' + encodeURIComponent(new FormData(f).get('q') || ''); } });
// ---------------- 부팅
const main = document.querySelector('[data-page]'); const page = main?.dataset.page; const id = main?.dataset.id;
if (page && PAGES[page]) { guarded(() => PAGES[page](id)); if (['overview', 'jobs', 'job', 'servers'].includes(page)) setInterval(() => { if (!document.hidden) guarded(() => PAGES[page](id)); }, 15000); }
+39
View File
@@ -0,0 +1,39 @@
/**
* hal/ui/public/reauth.mjs — 운영자 콘솔 재인증 흐름의 순수 계약(0.2.1, 개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`).
*
* 브라우저(ops.js)와 Node 테스트가 같은 파일을 쓴다. DOM·fetch 를 직접 만지지 않고, 호출자가 넘긴 함수만 부른다.
*
* 왜 따로 두나
* - 0.2.0 까지는 재인증이 10분을 넘기면 모든 일반 화면 위에 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 늘 떠 있었다.
* 단순 조회·화면 이동·로그인 10분 뒤에도 보여 운영자가 경고에 무뎌진다. 0.2.1 은 이 안내를 backend 가 실제로 고위험 요청을
* `reauth_required` 로 거부한 **바로 그 때 여는 재인증 창 안**으로만 옮긴다. 수동 재인증(계정 화면 단추)에는 붙이지 않는다.
* - 보안 경계는 그대로다: 거부·재인증 판단은 전부 서버(hal/app.mjs requireRecent·CSRF·3단계 확인)가 하고, 여기는 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"만 정한다.
*/
/** 서버가 고위험 요청을 거부할 때 쓰는 오류 코드(hal/app.mjs requireRecent 와 같은 값) */
export const REAUTH_REQUIRED = 'reauth_required';
/** 거부 시점 재인증 창에만 보이는 고위험 안내(옛 전역 배너 문구 그대로 — 위치만 옮겼다) */
export const HIGH_RISK_NOTICE = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다';
/**
* 재인증 창의 문구. required=true(서버 거부 직후)일 때만 고위험 안내(note)를 붙이고, 수동 재인증(required 없음)은 안내 없이 비밀번호만 묻는다.
* @returns {{ title: string, okLabel: string, note: string }}
*/
export function reauthPrompt({ required = false } = {}) {
return { title: '재인증(최근 10분)', okLabel: '재인증', note: required ? HIGH_RISK_NOTICE : '' };
}
/**
* 요청을 보내고, 서버가 `reauth_required` 로 거부하면 재인증을 묻고(required=true) 성공했을 때만 **딱 한 번** 다시 보낸다.
* - 재인증 창을 취소하거나 재인증이 실패하면 원 요청을 다시 보내지 않고 원래 오류를 그대로 올린다(고위험 작업이 몰래 실행되지 않음).
* - 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지).
* - `reauth_required` 가 아닌 오류(권한 없음·검증 실패·네트워크)는 재인증을 묻지 않는다.
* @param {() => Promise<any>} run 실제 요청(같은 method·path·body 로 다시 부를 수 있어야 한다)
* @param {(opts: { required: true }) => Promise<boolean>} reauth 재인증 창을 열어 성공 여부를 돌려주는 함수
*/
export async function callWithReauth(run, reauth) {
try { return await run(); } catch (e) {
if (e && e.code === REAUTH_REQUIRED && await reauth({ required: true })) return run();
throw e;
}
}
+1
View File
@@ -0,0 +1 @@
../render.mjs
+178
View File
@@ -0,0 +1,178 @@
/**
* hal/ui/render.mjs — 운영자 콘솔 화면 조각 렌더러(순수 함수, Node·브라우저 공용).
*
* 서버(hal/ui.mjs)는 HAL 자체 데이터로 첫 화면을 그리고, 브라우저(ops.js)는 같은 함수로 /ops/api 응답을 다시 그린다.
* 그래서 이 파일은 node 모듈을 import 하지 않는다(브라우저가 /ops/ui/render.mjs 로 그대로 받는다).
* 규칙(§2.2·§2.3): 상태는 글자+기호로(색은 보조), 오래된 정보는 '정보 지연', 일부 실패는 '부분 실패'로 드러내고 전체 성공처럼 보이지 않는다.
*/
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 시각을 KST 로 */
export function fmt(iso) { if (!iso) return '—'; const t = Date.parse(iso); if (isNaN(t)) return esc(iso); const k = new Date(t + 9 * 3600 * 1000); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; }
export const ago = (iso) => { if (!iso) return '확인 전'; const s = Math.round((Date.now() - Date.parse(iso)) / 1000); return s < 60 ? `${s}초 전` : s < 3600 ? `${Math.round(s / 60)}분 전` : s < 86400 ? `${Math.round(s / 3600)}시간 전` : `${Math.round(s / 86400)}일 전`; };
export const bytes = (n) => { n = Number(n || 0); if (!n) return '0'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; };
export const pct = (a, b) => (b > 0 ? Math.round((a / b) * 100) : null);
const JOB_LABEL = { not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중', awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료', needs_user: '사용자 확인 필요', cancelled: '취소됨' };
const HEALTH_LABEL = { ok: '정상', degraded: '성능 저하', partial_outage: '일부 장애', down: '중단', unknown: '확인 전', stale: '정보 확인 지연', not_provided: '제공되지 않음' };
const SERVER_LABEL = { ready: '배치 가능', pending: '등록 대기', maintenance: '유지보수', isolated: '격리', offline: '응답 없음' };
export const jobLabel = (s) => JOB_LABEL[s] || s || '';
/** 상태 배지(글자+기호) */
export function badge(kind, label) { const sym = { ok: '●', done: '✔', failed: '✘', warn: '!', info: 'ⓘ', running: '◌', waiting: '◔', paused: '⏸', neutral: '○', stale: '⧖' }[kind] || '○'; return `<span class="badge ${esc(kind)}"><span aria-hidden="true">${sym}</span> ${esc(label)}</span>`; }
export const jobBadge = (status) => badge(status === 'done' ? 'done' : status === 'failed' ? 'failed' : ['running', 'verifying', 'reverting'].includes(status) ? 'running' : ['awaiting_external', 'needs_user', 'awaiting_approval', 'awaiting_payment', 'queued'].includes(status) ? 'waiting' : status === 'paused' ? 'paused' : 'neutral', jobLabel(status));
export const healthBadge = (h) => badge(h === 'ok' ? 'ok' : h === 'down' ? 'failed' : h === 'stale' || h === 'unknown' ? 'stale' : 'warn', HEALTH_LABEL[h] || h);
/** 공통 상태 패널 */
export function statePanel(kind, text, { href, label } = {}) { const L = { loading: '불러오는 중', empty: '비어 있음', stale: '정보 지연', partial: '부분 실패', failed: '전체 실패', forbidden: '권한 없음' }; return `<div class="aw-state ${esc(kind)}" role="status"><b>${esc(L[kind] || kind)}</b>${text ? `<p>${esc(text)}</p>` : ''}${href ? `<a class="btn" href="${esc(href)}">${esc(label || '이동')}</a>` : ''}</div>`; }
/** 중계(포털) 호출 실패 → 정보 지연. 포털 데이터로 정상 판정하지 않는다. */
export function relayError(e) { const code = e && (e.code || e.error) || ''; if (code === 'portal_unreachable' || code === 'portal_not_linked' || code === 'service_unreachable') return statePanel('stale', `포털에서 정보를 받지 못했습니다(${code}). 마지막으로 확인한 정보가 없어 상태를 판정할 수 없습니다 — 연결 복구 후 다시 확인하세요`); if (code === 'unauthenticated' || code === 'access_denied') return statePanel('forbidden', e.message || '권한이 없습니다'); if (code === 'reauth_required') return statePanel('forbidden', '최근 재인증이 필요합니다(10분). 상단의 "재인증"을 누르세요'); return statePanel('failed', (e && e.message) || '오류'); }
/** 표. cols: [{k, l, f?(row)}]. 360px 에서는 table.r 로 카드처럼 접힌다. */
export function table(cols, rows, { empty = '항목이 없습니다', cls = '' } = {}) { if (!rows || !rows.length) return statePanel('empty', empty); return `<table class="r ${esc(cls)}"><thead><tr>${cols.map((c) => `<th>${esc(c.l)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${cols.map((c) => `<td data-l="${esc(c.l)}">${c.f ? c.f(r) : esc(r[c.k])}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
export function kv(pairs) { return `<dl class="kv">${pairs.filter((p) => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v == null || v === '' ? '—' : v}</dd>`).join('')}</dl>`; }
const stepSym = { done: '✔', running: '◌', failed: '✘', waiting: '◔', needs_user: '!', pending: '○' };
/** 작업 상세 블록(§29): 번호·종류·대상·시작 주체·현재 단계·진행률·시각·외부 대기·재시도·되돌리기·오류 */
export function jobBlock(j, { actions = true } = {}) {
if (!j) return statePanel('empty', '작업이 없습니다');
const subj = j.subject || {}; const target = subj.target || subj.module || (subj.payload && (subj.payload.siteId || subj.payload.domain)) || j.subjectId || '';
return `<div class="job" data-job="${esc(j.id)}"><div class="row">${jobBadge(j.status)} <b>${esc(j.label || j.kind)}</b> <span class="mono muted">${esc(j.id)}</span>${j.stale ? ' ' + badge('stale', '정보 확인 지연') : ''}</div>
${kv([['종류', esc(j.kind)], ['대상', esc(target)], ['시작 주체', esc(j.actorType === 'operator' ? `운영자 ${j.createdBy || ''}` : j.actorType === 'system' ? `자동(${j.createdBy || 'system'})` : `사용자 ${j.createdBy || ''}`)], ['고객 그룹', esc(j.groupId || '—')], ['현재 단계', j.currentStep ? esc(j.currentStep.title) + (j.currentStep.detail ? ` <span class="muted">— ${esc(j.currentStep.detail)}</span>` : '') : '—'], ['진행률', `${j.progress || 0}%`], ['시작·갱신', `${fmt(j.startedAt)} · ${fmt(j.updatedAt)}`], ['예상 완료', j.etaAt ? fmt(j.etaAt) : esc(j.etaUnknownReason || '산정할 수 없음')], j.waitReason ? ['외부 대기', esc(j.waitReason)] : null, j.maxAttempts > 1 ? ['자동 재시도', `${j.attempt}/${j.maxAttempts}회${j.nextRunAt && j.status === 'queued' ? ' · 다음 ' + fmt(j.nextRunAt) : ''}`] : null, j.userMessage ? ['필요한 조치', esc(j.userMessage)] : null, j.error ? ['오류 요약', `<span class="err">${esc(j.error)}</span> <span class="muted">(${esc(j.errorCode || '')})</span>`] : null])}
<ul class="steps">${(j.steps || []).map((s) => `<li class="${esc(s.state)}"><span class="s" aria-hidden="true">${stepSym[s.state] || '○'}</span><span>${esc(s.title)}${s.detail ? ` <span class="muted">— ${esc(s.detail)}</span>` : ''}${s.error ? ` <span class="err">— ${esc(s.error)}</span>` : ''}</span></li>`).join('')}</ul>
${actions ? `<div class="actions">${j.canRetry ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="retry">다시 시도</button>` : ''}${j.canResume ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="resume">이어서 진행</button>` : ''}${j.canCancel ? `<button class="btn danger" data-act="job" data-id="${esc(j.id)}" data-op="cancel">취소</button>` : ''}${['done', 'failed', 'needs_user'].includes(j.status) ? `<button class="btn danger" data-act="job" data-id="${esc(j.id)}" data-op="revert">되돌리기</button>` : ''}${['queued', 'awaiting_external'].includes(j.status) ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="pause">일시 중지</button>` : ''}</div>` : ''}</div>`;
}
/** 자식(대상별) 결과: 일부 실패는 '부분 실패' */
export function childrenBlock(c) { if (!c || !c.total) return ''; const overall = c.overall === 'done' ? badge('done', '모든 대상 완료') : c.overall === 'partial' ? badge('warn', '부분 실패 — 대상별 확인 필요') : c.overall === 'failed' ? badge('failed', '모든 대상 실패') : badge('running', '대상별 진행 중'); return `<h3>대상별 결과 ${overall}</h3>${table([{ l: '대상', f: (k) => `<a href="/ops/jobs/${esc(k.id)}">${esc(k.label || k.id)}</a><br><span class="muted mono">${esc((k.subject && (k.subject.serverId || k.subject.target)) || '')}</span>` }, { l: '상태', f: (k) => jobBadge(k.status) }, { l: '시도', f: (k) => `${k.attempt || 0}${k.next_run_at ? ' · 다음 ' + fmt(k.next_run_at) : ''}` }, { l: '오류', f: (k) => `<span class="err">${esc(k.error || '')}</span>` }], c.children)}`; }
export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r) => `<span class="mono">${esc(r.id)}</span><br>${esc(r.kind)}` }, { l: 'Agent', f: (r) => `<span class="mono">${esc(r.agent_id || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) }, { l: '진행', f: (r) => `${r.progress || 0}%` }, { l: '오류·조치', f: (r) => `<span class="err">${esc(r.error || '')}</span> ${esc(r.user_message || '')}` }, { l: '시각', f: (r) => `${fmt(r.created_at)} → ${fmt(r.ended_at)}` }], rows, { empty: 'Agent 작업이 없습니다' }); }
/** 서버 표(§28.1) */
export function serversTable(servers) {
return table([
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)} · ${esc(s.location || '위치 미입력')}</span>` },
{ l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}<br><span class="muted">마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}</span>` },
{ l: '건강', f: (s) => healthBadge(s.stale ? 'stale' : s.health) },
{ l: 'CPU·메모리', f: (s) => s.metrics ? `${esc(String(s.metrics.cpu ?? '—'))}% · ${esc(String(s.metrics.memPct ?? pct(s.metrics.memUsed, s.metrics.memTotal) ?? '—'))}%` : '정보 없음' },
{ l: '디스크·inode', f: (s) => s.capacity.diskTotalBytes ? `${pct(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes, s.capacity.diskTotalBytes)}% (${bytes(s.capacity.diskFreeBytes)} 여유)${s.metrics && s.metrics.inodePct != null ? ` · inode ${s.metrics.inodePct}%` : ''}${s.capacity.exhaustEta ? `<br><span class="err">고갈 예상 ${fmt(s.capacity.exhaustEta)}</span>` : ''}` : '정보 없음' },
{ l: 'I/O·네트워크', f: (s) => s.metrics ? `${bytes(s.metrics.ioRead)}/${bytes(s.metrics.ioWrite)} · ↓${bytes(s.metrics.netIn)} ↑${bytes(s.metrics.netOut)}` : '정보 없음' },
{ l: '배치·예약', f: (s) => `${s.capacity.sitesHosted || 0}개 · 예약 ${bytes(s.capacity.reservedBytes)} · 안전 여유 ${bytes(s.capacity.safetyMarginBytes)}` },
{ l: '신규 배치', f: (s) => s.acceptsNew ? badge('ok', '허용') : badge('warn', '중지') + (s.rawStatus === 'maintenance' ? `<br><span class="muted">${esc(s.maintenanceNote || '유지보수')}</span>` : s.rawStatus === 'isolated' ? `<br><span class="err">격리: ${esc(s.isolatedReason || '')}</span>` : '') },
{ l: '시계·인증서', f: (s) => `${s.clockSkewMs == null ? '—' : Math.abs(s.clockSkewMs) > 60000 ? `<span class="err">${s.clockSkewMs}ms</span>` : `${s.clockSkewMs}ms`}<br>${s.facts && s.facts.certExpiresAt ? '인증서 ' + fmt(s.facts.certExpiresAt) : '인증서 정보 없음'}` },
{ l: '모듈 버전', f: (s) => Object.entries(s.modules || {}).map(([m, v]) => `${esc(m)} ${v.drift ? `<span class="err">${esc(v.actual || '없음')}→${esc(v.target)}</span>` : esc(v.actual || v.target || '—')} ${healthBadge(v.health)}`).join('<br>') || '<span class="muted">배치 없음</span>' },
], servers, { empty: '등록된 서버가 없습니다' });
}
export function serverDetail(s, { jobs = [], agentJobs = [] } = {}) {
const webmakerData = s.facts?.webmakerInventory || null; const webmakerSites = Array.isArray(webmakerData?.sites) ? webmakerData.sites.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) : []; const inventoryErrors = Array.isArray(webmakerData?.errors) ? webmakerData.errors : [];
const webmakerInventory = webmakerData ? `<h2>Webmaker 사이트 디렉터리 인벤토리</h2><p class="muted">Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조한 운영 인벤토리입니다. 전체 ${Number(webmakerData.total || 0).toLocaleString('ko-KR')}개 · 수집 ${Number(webmakerData.returned || 0).toLocaleString('ko-KR')}개${webmakerData.truncated ? ' · 일부만 표시됨' : ''}. 페이지 본문·프롬프트·연락처·인증정보는 HAL로 보내지 않습니다.</p>${inventoryErrors.length || webmakerData.errorsTruncated ? statePanel('failed', `인벤토리 스캔 오류 ${inventoryErrors.length}건${webmakerData.errorsTruncated ? ' 이상(전송 한도에 따라 일부 오류만 표시)' : ''}: ${inventoryErrors.join(', ')}`) : ''}${table([
{ l: '사이트', f: (x) => `<span class="mono">${esc(x.id)}</span><br><b>${esc(x.title || '제목 정보 없음')}</b>` },
{ l: '계정 연결', f: (x) => esc(x.ownerId || '메타데이터 없음') },
{ l: '배포 상태', f: (x) => badge(x.deploymentState === 'deployed' ? 'ok' : x.deploymentState === 'unknown' ? 'warn' : 'neutral', x.deploymentState || 'unknown') },
{ l: '서비스 상태', f: (x) => esc(x.status || '—') },
{ l: '도메인', f: (x) => (Array.isArray(x.domains) ? x.domains : []).map(esc).join('<br>') || '—' },
{ l: '크기·파일', f: (x) => `${bytes(x.bytes || 0)} · ${Number(x.files || 0).toLocaleString('ko-KR')}개` },
{ l: '최근 변경', f: (x) => fmt(x.updatedAt) },
{ l: '메타데이터', f: (x) => badge(x.metadata === 'ok' ? 'ok' : 'warn', x.metadata || 'unknown') },
], webmakerSites, { empty: '사이트 디렉터리가 없습니다' })}` : '';
return `<div class="cards"><div class="card"><h2>연결·역할</h2>${kv([['역할·위치', `${esc(s.role)} · ${esc(s.location || '—')}`], ['연결 상태', badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : 'warn', SERVER_LABEL[s.status] || s.status)], ['마지막 heartbeat', `${fmt(s.lastSeenAt)} (${esc(ago(s.lastSeenAt))})`], ['Agent', `<span class="mono">${esc(s.agentId || '미연결')}</span> ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}</div>
<div class="card"><h2>용량</h2>${kv([['디스크', s.capacity.diskTotalBytes ? `${bytes(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes)} / ${bytes(s.capacity.diskTotalBytes)} (여유 ${bytes(s.capacity.diskFreeBytes)})` : '정보 없음'], ['예약량', bytes(s.capacity.reservedBytes)], ['안전 여유', bytes(s.capacity.safetyMarginBytes)], ['배치 서비스', `${s.capacity.sitesHosted || 0}개`], ['고갈 예상', s.capacity.exhaustEta ? fmt(s.capacity.exhaustEta) : '예상 없음'], ['백업 저장소', s.facts && s.facts.backupStore ? `${bytes(s.facts.backupStore.usedBytes)} / ${bytes(s.facts.backupStore.totalBytes)} · 최근 검증 ${fmt(s.facts.backupStore.lastVerifiedAt)}` : '해당 없음']])}</div>
<div class="card"><h2>모듈(목표 vs 실제)</h2>${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `<span class="err">${esc(m.actual || '없음')}</span>` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `<span class="err">${esc(m.lastError || '')}</span>` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}</div></div>
${webmakerInventory}<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`;
}
/** 90일 지표 SVG(§28.1). samples 는 최신순. key: 'cpu'|'disk'|'mem'|'net'. */
export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
const rows = (samples || []).slice().reverse(); if (!rows.length) return statePanel('empty', '지표가 아직 없습니다');
const val = (r) => key === 'cpu' ? Number(r.cpu || 0) : key === 'mem' ? (r.mem_total ? (r.mem_used / r.mem_total) * 100 : 0) : key === 'disk' ? (r.disk_total ? (r.disk_used / r.disk_total) * 100 : 0) : key === 'inode' ? (r.inode_total ? (r.inode_used / r.inode_total) * 100 : 0) : Number(r.net_out || 0);
const vals = rows.map(val); const max = key === 'net' ? Math.max(1, ...vals) : 100; const t0 = Date.parse(rows[0].at); const t1 = Date.parse(rows[rows.length - 1].at) || t0 + 1;
const pts = rows.map((r, i) => `${(((Date.parse(r.at) - t0) / Math.max(1, t1 - t0)) * (w - 40) + 30).toFixed(1)},${(h - 20 - (vals[i] / max) * (h - 40)).toFixed(1)}`).join(' ');
const label = { cpu: 'CPU %', mem: '메모리 %', disk: '디스크 %', inode: 'inode %', net: '송신 바이트' }[key] || key;
return `<svg viewBox="0 0 ${w} ${h}" class="chart" role="img" aria-label="${esc(label)} 추이 ${rows.length}개 표본"><text x="4" y="12" class="lbl">${esc(label)}</text><line x1="30" y1="${h - 20}" x2="${w - 10}" y2="${h - 20}" class="axis"/><line x1="30" y1="20" x2="30" y2="${h - 20}" class="axis"/><text x="2" y="24" class="lbl">${key === 'net' ? bytes(max) : '100%'}</text><polyline points="${pts}" fill="none" class="line"/><text x="30" y="${h - 4}" class="lbl">${fmt(rows[0].at)}</text><text x="${w - 150}" y="${h - 4}" class="lbl">${fmt(rows[rows.length - 1].at)}</text></svg>`;
}
/** 릴리스 화면(§32.1) */
export function releasesView(data, changelog = {}) {
const running = data.running || {}; const targets = data.targets || [];
const byModule = (v) => (data.modules || []).map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === v).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">배치 없음</span>'}`; }).join(' · ');
return `<p class="muted">배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — <b>HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2)</b>. HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'}</p>
${table([
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 중') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
{ l: '검사', f: (r) => r.checks ? (r.checks.testsPassed === false ? badge('failed', '테스트 실패') : r.checks.testsPassed ? badge('ok', '테스트 통과') : badge('neutral', '검사 없음')) + (r.checks.dirty ? ' ' + badge('warn', '수정본(dirty)') : '') : badge('neutral', '검사 없음') },
{ l: '승인', f: (r) => r.status === 'approved' ? badge('ok', `승인 ${r.approved_by || ''}`) + `<br><span class="muted">${fmt(r.approved_at)}</span>` : badge('waiting', '승인 대기') + ` <button class="btn" data-act="release-approve" data-v="${esc(r.version)}">승인</button>` },
{ l: 'HAL 배포', f: (r) => r.hal_deployed_at ? badge('done', fmt(r.hal_deployed_at)) : badge('neutral', '아직') },
{ l: '모듈별 배치', f: (r) => byModule(r.version) },
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">VERSION_HISTORY 항목 없음</span>' },
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}" class="mono">${esc(t.server_id)}</a>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>`;
}
export function incidentsView(incidents, notifications = []) {
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
<h2>운영자 알림</h2>${table([{ l: '분류', k: 'category' }, { l: '내용', f: (n) => `<b>${esc(n.title)}</b>${n.count > 1 ? ` <span class="muted">×${n.count}</span>` : ''}<br>${esc(n.body || '')}` }, { l: '심각도', f: (n) => badge(n.severity === 'critical' ? 'failed' : n.severity === 'warning' ? 'warn' : 'info', n.severity) }, { l: '최근', f: (n) => fmt(n.last_at) }, { l: '상태', f: (n) => n.state === 'open' ? badge('running', '열림') : badge('done', '해결') }], notifications, { empty: '운영자 알림이 없습니다' })}`;
}
export function auditTable(entries) { return table([{ l: '시각', f: (a) => fmt(a.at) }, { l: '행위자', f: (a) => `${esc(a.actor_type)} ${esc(a.actor_id || '')}${a.automatic ? ' (자동)' : ''}` }, { l: '행동', f: (a) => `<b>${esc(a.action)}</b>${a.immutable ? ' <span class="muted">(삭제 불가)</span>' : ''}` }, { l: '대상', f: (a) => `${esc(a.subject_type || '')} <span class="mono">${esc(a.subject_id || '')}</span>` }, { l: '결과', f: (a) => a.outcome === 'ok' ? badge('ok', '성공') : badge('failed', a.outcome) }, { l: '상세', f: (a) => a.detail ? `<details><summary>보기</summary><pre class="pre">${esc(JSON.stringify(a.detail, null, 1))}</pre></details>` : '' }], entries, { empty: '기록이 없습니다' }); }
/** 긴급 복구(§33) */
export function emergencyView(data) {
const steps = ['declared', 'approving', 'approved', 'credential_issued', 'performing', 'verified', 'reconciled', 'closed'];
const L = { declared: '선언', approving: '승인 중', approved: '승인 완료', credential_issued: '자격 발급', performing: '수행 중', verified: '결과 확인·자격 폐기', reconciled: 'HAL 상태 조정', closed: '사건 종료' };
const sess = (s) => { const idx = steps.indexOf(s.status); const approvals = s.approvals || []; return `<div class="card emergency"><div class="row"><b>${esc(s.id)}</b> ${badge(s.status === 'closed' ? 'done' : 'warn', L[s.status] || s.status)} <span class="muted">사건 ${esc(s.incident_key)} · 선언 ${fmt(s.declared_at)} (${esc(s.declared_by)})</span></div>
<ol class="flow">${steps.map((st, i) => `<li class="${i < idx ? 'done' : i === idx ? 'now' : ''}">${esc(L[st])}</li>`).join('')}</ol>
${kv([['이유', esc(s.reason)], ['허용 대상', (s.scope || []).map(esc).join(', ')], ['승인 단계', approvals.length ? approvals.map((a) => `${a.step}단계 ${esc(a.by)} ${fmt(a.at)} — ${esc(a.impactText || a.finalConfirm != null ? (a.impactText || (a.revertible ? '되돌리기 가능' : '되돌리기 불가')) : (a.identifiers || []).join(','))}`).join('<br>') : '없음 (3단계 모두 필요)'], ['자격', s.credential_issued_at ? `발급 ${fmt(s.credential_issued_at)} · 만료 ${fmt(s.credential_expires_at)} · ${s.credential_revoked_at ? '폐기 ' + fmt(s.credential_revoked_at) : '<span class="err">유효</span>'}` : '미발급(한 번만 표시되며 다시 볼 수 없음)'], ['수행 기록', (s.actions || []).length ? (s.actions || []).map((a) => `${fmt(a.at)} ${esc(a.action)} ${esc(a.target || '')} → ${a.ok ? '성공' : `<span class="err">거부/실패: ${esc(a.error || '')}</span>`}`).join('<br>') : '없음'], ['결과 확인', s.verify ? `HAL 건강 ${s.verify.halHealth ? '정상' : '<span class="err">비정상</span>'} ${fmt(s.verify.at)}` : '—'], ['상태 조정', s.reconcile ? `차이 ${(s.reconcile.drift || []).length}건 · 응답 없는 서버 ${(s.reconcile.offlineServers || []).length}대` : '—']])}
<div class="actions">${s.status === 'declared' || s.status === 'approving' ? `<button class="btn primary" data-act="em-approve" data-id="${esc(s.id)}" data-step="${approvals.length + 1}">${approvals.length + 1}단계 승인</button>` : ''}${s.status === 'approved' ? `<button class="btn primary" data-act="em-credential" data-id="${esc(s.id)}">자격 발급(1회 표시)</button>` : ''}${['credential_issued', 'performing'].includes(s.status) ? `<button class="btn" data-act="em-perform" data-id="${esc(s.id)}">작업 수행</button> <button class="btn" data-act="em-verify" data-id="${esc(s.id)}">결과 확인·자격 폐기</button>` : ''}${s.status === 'verified' ? `<button class="btn" data-act="em-reconcile" data-id="${esc(s.id)}">HAL 상태 조정</button>` : ''}${s.status !== 'closed' ? `<button class="btn danger" data-act="em-close" data-id="${esc(s.id)}">사건 종료</button>` : ''}</div></div>`; };
return `<p class="muted">HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. 허용 대상: ${(data.scopes || []).map(esc).join(', ')} · 허용 작업: ${Object.keys(data.actions || {}).map(esc).join(', ')} — 일반 고객 서비스 배포는 금지(거부됨).</p><div class="actions"><button class="btn danger" data-act="em-declare">긴급 복구 선언</button></div>${(data.sessions || []).length ? data.sessions.map(sess).join('') : statePanel('empty', '긴급 복구 세션이 없습니다')}`;
}
/** 재해복구(§34) */
export function drView(data) {
const r = data.readiness || {}; const cfg = r.config || {};
return `<div class="cards"><div class="card"><h2>준비 상태</h2>${r.ready ? badge('ok', r.label) : badge('warn', r.label)}${kv([['목표 복구 시간(RTO)', cfg.rto_minutes ? `${cfg.rto_minutes}분` : '<span class="err">미설정</span>'], ['허용 데이터 시점(RPO)', cfg.rpo_minutes ? `${cfg.rpo_minutes}분` : '<span class="err">미설정</span>'], ['복구 사본 주기·보존', cfg.snapshot_interval_minutes ? `${cfg.snapshot_interval_minutes}분 · ${cfg.retention_count}개` : '<span class="err">미설정</span>'], ['외부 보관', esc(cfg.external_store || '미설정')], ['최근 훈련', r.lastDrill ? `${r.lastDrill.result === 'success' ? badge('ok', '성공') : badge('failed', r.lastDrill.result)} ${fmt(r.lastDrill.at)} · 측정 RTO ${r.lastDrill.measuredRtoMinutes ?? '—'}분 / RPO ${r.lastDrill.measuredRpoMinutes ?? '—'}분 (목표 ${cfg.rto_minutes ?? '—'}/${cfg.rpo_minutes ?? '—'})` : '<span class="err">실제 복구 훈련 전 — 정식 운영 준비 완료로 표시하지 않음</span>'], ['다음 훈련', r.lastDrill && r.lastDrill.nextDueAt ? fmt(r.lastDrill.nextDueAt) : '예정 없음'], ['운영 중 HAL', data.hal && data.hal.bootstrap ? `<span class="mono">${esc(data.hal.bootstrap.live_instance_id || '')}</span> ${badge('ok', 'HAL 하나만 운영 상태')}` : '—']])}<div class="actions"><button class="btn" data-act="dr-config">설정 변경</button> <button class="btn" data-act="dr-snapshot">지금 복구 사본 만들기</button> <button class="btn" data-act="dr-drill">복구 훈련 시작</button></div></div></div>
<h2>복구 사본</h2>${table([{ l: '사본', f: (s) => `<span class="mono">${esc(s.id)}</span><br>${esc(s.kind)}` }, { l: '생성', f: (s) => fmt(s.created_at) }, { l: '크기', f: (s) => bytes(s.size_bytes) }, { l: '검증', f: (s) => s.verify_state === 'ok' ? badge('ok', `검증 ${fmt(s.verified_at)}`) : badge('failed', s.verify_state) }, { l: '외부 보관', f: (s) => s.external_copied_at ? badge('ok', fmt(s.external_copied_at)) : badge('warn', '외부 사본 없음') }, { l: '', f: (s) => s.verify_state === 'ok' && !s.deleted_at ? `<button class="btn small danger" data-act="dr-restore" data-id="${esc(s.id)}">복구 요청</button> <button class="btn small" data-act="dr-drill" data-id="${esc(s.id)}">이 사본으로 훈련</button>` : '' }], data.snapshots || [], { empty: '복구 사본이 없습니다' })}
<h2>복구 요청(별도 승인 + 기존 제어 위치 중지 확인)</h2>${table([{ l: '요청', f: (x) => `<span class="mono">${esc(x.id)}</span><br>사본 ${esc(x.snapshot_id)}` }, { l: '상태', f: (x) => jobBadge(x.status === 'requested' ? 'awaiting_approval' : x.status === 'approved' ? 'queued' : x.status === 'running' ? 'running' : x.status) }, { l: '승인', f: (x) => x.approved_at ? `${esc(x.approved_by)} ${fmt(x.approved_at)}` : '대기' }, { l: '기존 제어 중지 확인', f: (x) => x.old_control_stopped_confirmed_at ? fmt(x.old_control_stopped_confirmed_at) : '<span class="err">미확인</span>' }, { l: '확인·차이', f: (x) => x.checks_json ? `<pre class="pre">${esc(x.checks_json)}</pre>` : '—' }, { l: '', f: (x) => x.status === 'requested' ? `<button class="btn small" data-act="dr-approve" data-id="${esc(x.id)}">승인</button>` : x.status === 'approved' ? `<button class="btn small danger" data-act="dr-start" data-id="${esc(x.id)}">복구 시작</button>` : x.status === 'running' ? `<button class="btn small" data-act="dr-finish" data-id="${esc(x.id)}">실제 확인 입력·완료</button>` : '' }], data.restores || [], { empty: '복구 요청이 없습니다' })}
<h2>복구 훈련</h2>${table([{ l: '훈련', f: (x) => `<span class="mono">${esc(x.id)}</span>` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` <button class="btn small" data-act="dr-drill-finish" data-id="${esc(x.id)}">결과 입력</button>` : x.result === 'success' ? badge('ok', '성공') : badge('failed', x.result) }, { l: '측정 RTO/RPO', f: (x) => `${x.measured_rto_minutes ?? '—'} / ${x.measured_rpo_minutes ?? '—'}분` }, { l: '실패·조치 항목', f: (x) => x.findings_json ? `<pre class="pre">${esc(x.findings_json)}</pre>` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`;
}
export function accountsView(data) {
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '만료(10분 지남)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
<div class="card"><h2>로그인 세션</h2>${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' <b>(현재)</b>' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `<button class="btn small" data-act="session-revoke" data-id="${esc(s.id)}">종료</button>` }], data.sessions || [])}<div class="actions"><button class="btn" data-act="session-revoke-all">다른 모든 세션 종료</button></div></div></div>
<h2>접근권(허용 네트워크 밖 기기)</h2>${table([{ l: '계정', k: 'account_id' }, { l: '발급', f: (g) => `${esc(g.created_by || '')} ${fmt(g.created_at)}` }, { l: '만료', f: (g) => fmt(g.expires_at) }, { l: '기기 묶임', f: (g) => g.bound_at ? `${fmt(g.bound_at)} (${esc(g.ip || '')})` : '아직' }, { l: '상태', f: (g) => g.revoked_at ? badge('neutral', '회수') : Date.parse(g.expires_at) < Date.now() ? badge('neutral', '만료') : badge('ok', '유효') }, { l: '사유', k: 'reason' }, { l: '', f: (g) => g.revoked_at ? '' : `<button class="btn small" data-act="grant-revoke" data-id="${esc(g.id)}">회수</button>` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}<div class="actions"><button class="btn" data-act="grant-issue">접근권 발급(URL 1회 표시)</button></div>
<h2>운영자 계정</h2>${table([{ l: '아이디', f: (a) => `<b>${esc(a.login)}</b> ${esc(a.displayName)}` }, { l: '보안', f: (a) => a.mfaEnabled ? badge('ok', 'MFA') : badge('warn', '비밀번호만') }, { l: '상태', f: (a) => a.lockedUntil ? badge('warn', `잠김 ${fmt(a.lockedUntil)}`) : badge('ok', a.status) }, { l: '마지막 로그인', f: (a) => fmt(a.lastLoginAt) }], data.accounts || [])}<div class="actions"><button class="btn" data-act="account-new">운영자 추가</button></div>`;
}
/** 운영 현황(§26.1) */
export function overviewView(o) {
const comp = (c) => `<div class="card comp"><b>${esc(c.name)}</b> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
const jobs = o.jobs || {}; const srv = o.servers || {};
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
<h2>장애·유지보수</h2>${(o.incidents || []).length ? table([{ l: '사건', f: (i) => `<a href="/ops/incidents">${esc(i.title)}</a>` }, { l: '심각도', k: 'severity' }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음') }, { l: '시작', f: (i) => fmt(i.started_at) }], o.incidents) : badge('ok', '진행 중인 사건 없음')}
<h2>처리 대기 승인(포털)</h2><div id="pending-approvals">${statePanel('loading', '포털에서 승인 대기 항목을 불러오는 중')}</div><p class="muted">HAL 자체 상태 ${fmt(o.generatedAt)} 기준. 오래된 정보는 정상으로 표시하지 않습니다.</p>`;
}
/** 처리 대기 승인 요약(포털 중계 결과) */
export function pendingApprovals(p) { const row = (label, n, href) => `<li><a href="${href}">${esc(label)}</a> <b>${n}</b>건</li>`; return `<ul class="pending">${row('주문 승인', p.orders, '/ops/orders')}${row('복원 승인', p.restores, '/ops/backup')}${row('서버 이전 승인', p.relocations, '/ops/relocation')}${row('소유권 이전 심사', p.transfers, '/ops/relocation?tab=transfer')}</ul>${p.partial ? statePanel('partial', `일부 항목을 받지 못했습니다: ${p.partial.join(', ')}`) : ''}`; }
// ---------------- 포털 중계 화면(§26.2·§27·§30)
export function customersSearch(d) { return `<form class="row" data-form="customer-search"><input name="q" placeholder="이메일·이름·그룹명·id" value="${esc(d.q || '')}"><button class="btn">검색</button></form><h2>사용자 그룹</h2>${table([{ l: '그룹', f: (g) => `<a href="/ops/customers/${esc(g.id)}"><b>${esc(g.name)}</b></a> <span class="muted">${g.kind === 'personal' ? '개인' : '팀'}</span>` }, { l: '구성원', k: 'members' }, { l: '연락처', f: (g) => esc(g.contactEmail || '—') }, { l: '상태', f: (g) => badge(g.status === 'active' ? 'ok' : 'warn', g.status) }, { l: '가입', f: (g) => fmt(g.createdAt) }], d.groups || [], { empty: '검색 결과가 없습니다' })}<h2>사용자</h2>${table([{ l: '이메일', f: (u) => `${esc(u.email)}<br><span class="muted">${esc(u.displayName)}</span>` }, { l: '확인', f: (u) => u.verified ? badge('ok', '이메일 확인') : badge('warn', '미확인') }, { l: 'MFA', f: (u) => u.mfa ? '사용' : '—' }, { l: '잠김', f: (u) => u.locked ? badge('warn', '잠김') : '—' }, { l: '마지막 로그인', f: (u) => fmt(u.lastLoginAt) }], d.users || [], { empty: '검색 결과가 없습니다' })}`; }
export function customerView(c) {
const g = c.group || {}; const SL = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' };
return `<div class="cards"><div class="card"><h2>${esc(g.name)} <span class="muted">(${g.kind === 'personal' ? '개인' : '팀'})</span></h2>${kv([['id', `<span class="mono">${esc(g.id)}</span>`], ['연락처', `${esc(g.contactEmail || '—')} ${esc(g.contactPhone || '')}`], ['상태', badge(g.status === 'active' ? 'ok' : 'warn', g.status)], ['조회 기준', fmt(c.generatedAt)]])}<div class="actions"><button class="btn" data-act="cust-change" data-g="${esc(g.id)}">고객 대신 변경</button> <button class="btn" data-act="support-issue" data-g="${esc(g.id)}">일시 접근 발급</button></div></div>
<div class="card"><h2>활성 상품·권리</h2>${table([{ l: '자원', k: 'resource' }, { l: '양', k: 'amount' }, { l: '출처', f: (e) => `${esc(e.source_type || '')} ${esc(e.source_id || '')}` }, { l: '기간', f: (e) => `${fmt(e.starts_at)} ~ ${e.ends_at ? fmt(e.ends_at) : '무기한'}` }, { l: '', f: (e) => e.active ? badge('ok', '활성') : badge('neutral', '비활성') }], c.entitlements || [], { empty: '활성 상품이 없습니다' })}</div></div>
<h2>구성원</h2>${table([{ l: '이메일', f: (m) => `${esc(m.email)}<br><span class="muted">${esc(m.displayName)} · ${esc(m.alias || '')}</span>` }, { l: '역할', k: 'role' }, { l: '상태', f: (m) => m.status === 'suspended' ? badge('warn', `정지 ${fmt(m.suspendedAt)}`) : badge('ok', m.status) }, { l: '확인·MFA', f: (m) => `${m.verified ? '이메일 확인' : '<span class="err">미확인</span>'}${m.locked ? ' · 잠김' : ''}` }, { l: '마지막 로그인', f: (m) => fmt(m.lastLoginAt) }], c.members || [])}
<h2>사이트</h2>${table([{ l: '사이트', f: (s) => `<a href="/ops/sites/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.primary_domain || '대표 도메인 없음')}</span>` }, { l: '상태', f: (s) => badge(s.lifecycle === 'live' ? 'ok' : 'neutral', SL[s.lifecycle] || s.lifecycle) }, { l: '배포', f: (s) => `${esc(s.deploy_mode || '미선택')} · ${esc(s.current_version || '—')}` }, { l: '사이트 수', k: 'site_count_state' }, { l: '건강', f: (s) => healthBadge(s.health) }, { l: '보존·해지', f: (s) => s.cancel_at || s.retention_until || s.delete_after ? `${s.cancel_at ? '해지 ' + fmt(s.cancel_at) : ''} ${s.retention_until ? '보존 ' + fmt(s.retention_until) : ''} ${s.delete_after ? '삭제 ' + fmt(s.delete_after) : ''}` : '—' }], c.sites || [], { empty: '사이트가 없습니다' })}
<div class="cards"><div class="card"><h2>메일</h2>${table([{ l: '서비스', f: (m) => `<b>${esc(m.name)}</b><br><span class="muted">${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'}</span>` }, { l: '상태', f: (m) => jobBadge(m.status) }, { l: 'DNS·송수신', f: (m) => `${esc(m.dns_state || '—')} · ${esc(m.send_state || '—')}/${esc(m.receive_state || '—')}` }, { l: '보존', f: (m) => m.retention_until ? fmt(m.retention_until) : '—' }], c.mail || [], { empty: '메일 서비스 없음' })}</div>
<div class="card"><h2>도메인</h2>${table([{ l: '도메인', f: (d) => `<b>${esc(d.name)}</b>${d.is_primary ? ' (대표)' : ''}` }, { l: '소유·DNS', f: (d) => `${esc(d.ownership_state)} · ${esc(d.dns_state)}` }, { l: '인증서', f: (d) => `${esc(d.cert_state)} ${d.cert_expires_at ? fmt(d.cert_expires_at) : ''}` }, { l: '메일 보안', f: (d) => esc(d.mail_dns_state || '—') }], c.domains || [], { empty: '도메인 없음' })}</div>
<div class="card"><h2>백업</h2>${table([{ l: '사이트', k: 'site_id' }, { l: '상태', k: 'status' }, { l: '최근 성공·실패', f: (b) => `${fmt(b.last_success_at)} / ${fmt(b.last_failure_at)} (연속 실패 ${b.consecutive_failures || 0})` }, { l: '다음', f: (b) => fmt(b.next_run_at) }], c.backups || [], { empty: '백업 옵션 없음' })}</div>
<div class="card"><h2>사용량·제한</h2>${table([{ l: '자원', k: 'label' }, { l: '구매', k: 'purchased' }, { l: '배정', k: 'allocated' }, { l: '예약', k: 'reserved' }, { l: '사용', k: 'used' }, { l: '미배정', k: 'unallocated' }], c.usage || [])}${(c.limits || []).length ? `<p class="err">제한 적용: ${c.limits.map((l) => `${esc(l.resource)} ${l.level}%`).join(', ')}</p>` : ''}</div></div>
<h2>주문·결제</h2>${table([{ l: '주문', f: (o) => `<b>${esc(o.order_no)}</b> <span class="muted">${esc(o.kind)}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) }, { l: '금액', f: (o) => `${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)}` }, { l: '기한·입금·승인', f: (o) => `${fmt(o.due_at)} / ${fmt(o.paid_at)} / ${fmt(o.approved_at)}` }, { l: '거절 사유', k: 'rejected_reason' }], c.orders || [], { empty: '주문 없음' })}
<h2>정지·보존</h2>${kv([['정지된 구성원', `${(c.suspensions && c.suspensions.members.length) || 0}명`], ['일시 중지·유지보수 사이트', `${(c.suspensions && c.suspensions.sites.length) || 0}개`], ['보존 중 사이트·메일·백업', `${(c.retention && c.retention.sites.length) || 0} · ${(c.retention && c.retention.mail.length) || 0} · ${(c.retention && c.retention.backups.length) || 0}`]])}
<h2>고객 화면 오류·실패 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '오류', f: (j) => `<span class="err">${esc(j.error || j.userMessage || '')}</span>` }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], c.failedJobs || [], { empty: '실패한 작업 없음' })}${auditTable(c.errors || [])}
<h2>일시 접근 기록</h2>${table([{ l: '대상', f: (a) => `${esc(a.target_type)} ${esc(a.target_id || '')}` }, { l: '이유', k: 'reason' }, { l: '범위', f: (a) => (a.scope || []).map(esc).join(', ') }, { l: '요청·승인', f: (a) => `${esc(a.requested_by)} / ${esc(a.approved_by || '대기')}` }, { l: '만료', f: (a) => fmt(a.expires_at) }, { l: '상태', f: (a) => a.state === 'active' ? badge('ok', '유효') : badge('neutral', a.state) }, { l: '', f: (a) => a.state === 'active' ? `<button class="btn small" data-act="support-revoke" data-id="${esc(a.id)}">회수</button>` : '' }], c.supportAccess || [], { empty: '일시 접근 기록 없음' })}
<h2>운영자 대신 변경 기록</h2>${table([{ l: '시각', f: (x) => fmt(x.created_at) }, { l: '항목', f: (x) => `${esc(x.field)} <span class="mono muted">${esc(x.subject_id)}</span>` }, { l: '변경 전 → 후', f: (x) => `${esc(JSON.stringify(x.before))} → ${esc(JSON.stringify(x.after))}` }, { l: '사유', k: 'reason' }, { l: '운영자·승인자', f: (x) => `${esc(x.operator)} / ${esc(x.approver || '—')}` }], c.changes || [], { empty: '변경 기록 없음' })}`;
}
export function ordersView(d) { return table([{ l: '주문', f: (o) => `<b>${esc(o.order_no)}</b><br><span class="muted">${esc(o.group && o.group.name)} · ${esc(o.product && o.product.name)} v${esc(o.product && o.product.version)}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.statusLabel ? `<br><span class="muted">${esc(o.statusLabel)}</span>` : '') }, { l: '대상·금액', f: (o) => `${esc(o.target_type || '그룹')} ${esc(o.target_id || '')}<br><b>${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)}</b> <span class="muted">(세금 ${Number(o.tax).toLocaleString('ko-KR')})</span>` }, { l: '권리 변화', f: (o) => (o.entitlementDelta || []).map((e) => `${esc(e.label || e.resource)} +${e.amount}${e.target ? ' → ' + esc(e.target) : ''}`).join('<br>') || '—' }, { l: '현재 사용량', f: (o) => (o.currentUsage || []).filter((u) => u.purchased || u.used).map((u) => `${esc(u.label)} ${u.used}/${u.purchased}`).join('<br>') || '—' }, { l: '기한·입금', f: (o) => `${fmt(o.due_at)}<br>${o.paid_at ? '입금 ' + fmt(o.paid_at) + ' ' + esc(o.paid_ref || '') : '<span class="err">입금 미확인</span>'}` }, { l: '', f: (o) => o.status === 'awaiting_payment' ? `<button class="btn small" data-act="order-paid" data-id="${esc(o.id)}">입금 확인</button> <button class="btn small danger" data-act="order-reject" data-id="${esc(o.id)}">거절</button>` : o.status === 'awaiting_approval' ? `<button class="btn small primary" data-act="order-approve" data-id="${esc(o.id)}">승인</button> <button class="btn small danger" data-act="order-reject" data-id="${esc(o.id)}">거절</button>` : o.status === 'approved' ? `<button class="btn small" data-act="order-refund" data-id="${esc(o.id)}" data-g="${esc(o.group_id)}">환불·크레딧</button>` : '' }], d.orders || [], { empty: '주문이 없습니다' }); }
export function productsView(d) { return `${table([{ l: '상품', f: (p) => `<b>${esc(p.name)}</b> <span class="muted">${esc(p.product_id)} v${p.version}</span>` }, { l: '공개', f: (p) => p.published ? badge('ok', '공개') : badge('neutral', '비공개') }, { l: '적용 기간', f: (p) => `${fmt(p.effective_from)} ~ ${p.effective_to ? fmt(p.effective_to) : ''}` }, { l: '가격', f: (p) => `${Number(p.price).toLocaleString('ko-KR')} ${esc(p.currency)} / ${esc(p.billing_cycle)}` }, { l: '가입자', k: 'subscribers' }, { l: '미확정 정책', f: (p) => (p.deferred || []).length ? `<span class="err">${p.deferred.map((x) => esc(x.key || x)).join(', ')}</span>` : '없음' }, { l: '', f: (p) => `<button class="btn small" data-act="product-new-version" data-id="${esc(p.id)}" data-p="${esc(p.product_id)}">새 버전 만들기</button> <button class="btn small" data-act="product-publish" data-id="${esc(p.id)}" data-pub="${p.published ? 0 : 1}">${p.published ? '비공개' : '공개'}</button>` }], d.products || [], { empty: '상품이 없습니다 — "검증 Catalog 넣기"로 시작' })}<p class="muted">이미 판매된 조건은 바꾸지 않고 새 버전을 만듭니다(§27.2). 기존 고객은 자기 버전을 유지합니다.</p><div class="actions"><button class="btn" data-act="catalog-seed">검증 Catalog 넣기</button></div>`; }
export function sitesView(d) { return table([{ l: '사이트', f: (s) => `<a href="/ops/sites/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.primaryDomain || s.primary_domain || '—')} · 그룹 ${esc(s.groupId || '')}</span>` }, { l: '상태', f: (s) => jobBadge(s.lifecycle || s.status) }, { l: '배포', f: (s) => `${esc(s.deployMode || s.deploy_mode || '미선택')} · ${esc(s.currentVersion || s.current_version || '—')}` }, { l: '건강', f: (s) => healthBadge(s.health) }, { l: '서버', f: (s) => esc(s.serverId || '—') }, { l: '조치 필요', f: (s) => s.actionNeeded ? `<span class="err">${esc(s.actionNeeded)}</span>` : '—' }], d.sites || [], { empty: '사이트가 없습니다' }); }
export function siteDetailView(d) { const s = d.site || {}; return `<div class="cards"><div class="card"><h2>${esc(s.name)}</h2>${kv([['그룹', `<a href="/ops/customers/${esc(s.group_id)}">${esc(s.group_id)}</a>`], ['상태', jobBadge(s.lifecycle)], ['배포', `${esc(s.deploy_mode || '미선택')} · ${esc(s.current_version || '—')} · ${esc(s.deploy_verify || '')}`], ['서버', esc(s.server_id || '—')], ['대표 도메인', esc(s.primary_domain || '—')], ['사이트 수', esc(s.site_count_state)], ['건강', healthBadge(s.health) + ' ' + fmt(s.health_checked_at)]])}<div class="actions"><button class="btn" data-act="site-refresh" data-id="${esc(s.id)}">상태 다시 확인</button></div></div>
<div class="card"><h2>외부 Agent</h2>${d.externalAgent ? kv([['상태', esc(d.externalAgent.status)], ['마지막 확인', fmt(d.externalAgent.lastSeenAt)], ['버전', esc(d.externalAgent.version || '')]]) : '<span class="muted">관리형 또는 미연결</span>'}</div></div>
<h2>배포 이력</h2>${table([{ l: '배포', f: (x) => `<span class="mono">${esc(x.id)}</span><br>${esc(x.version_label || x.version_ref || '')}` }, { l: '상태', f: (x) => jobBadge(x.status) + (x.verify_state ? ` <span class="muted">${esc(x.verify_state)}</span>` : '') }, { l: '시각', f: (x) => `${fmt(x.created_at)} → ${fmt(x.ended_at)}` }], d.deployments || [], { empty: '배포 이력 없음' })}
<h2>도메인</h2>${table([{ l: '도메인', f: (x) => `${esc(x.name)}${x.is_primary ? ' (대표)' : ''}` }, { l: '소유·DNS·인증서', f: (x) => `${esc(x.ownership_state)} · ${esc(x.dns_state)} · ${esc(x.cert_state)}` }, { l: '마지막 확인', f: (x) => fmt(x.last_checked_at) }], d.domains || [], { empty: '도메인 없음' })}
<h2>작업</h2>${(d.jobs || []).map((j) => jobBlock(j, { actions: false })).join('') || statePanel('empty', '작업 없음')}<h2>감사 기록</h2>${auditTable(d.audit || [])}`; }
export function mailView(d) { return `<h2>메일 서버</h2>${table([{ l: '서버', f: (s) => `<b>${esc(s.name || s.id)}</b><br><span class="muted">${esc(s.baseUrl || s.base_url || '')}</span>` }, { l: '연결', f: (s) => s.ok === false || s.error ? badge('failed', '연결 실패') + ` <span class="err">${esc(s.error || '')}</span>` : s.ok ? badge('ok', `정상 v${esc(s.version || '')}`) : badge('stale', '확인 전') }, { l: '도메인·계정', f: (s) => s.totals ? `${s.totals.domains}개 · ${s.totals.accounts}계정 · ${bytes(s.totals.storageBytes)}` : '—' }, { l: '', f: (s) => `<button class="btn small" data-act="mail-rates" data-id="${esc(s.id)}">발송 제한·제출량</button>` }], d.servers || [], { empty: '등록된 메일 서버가 없습니다' })}
<h2>메일 서비스</h2>${table([{ l: '서비스', f: (m) => `<b>${esc(m.name)}</b><br><span class="muted">${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'} · 그룹 ${esc(m.group_id)}</span>` }, { l: '상태', f: (m) => jobBadge(m.status) + (m.migrating ? ' ' + badge('warn', '이전 중') : '') }, { l: 'DNS·송신·수신', f: (m) => `${esc(m.dns_state || '—')} · ${esc(m.send_state || '—')} · ${esc(m.receive_state || '—')}` }, { l: '메일함·공간·통수', f: (m) => m.figures ? Object.values(m.figures).map((f) => `${esc(f.label || f.resource)} ${f.used ?? 0}/${f.allocated ?? 0}`).join('<br>') : '—' }, { l: '보존', f: (m) => m.retention_until ? `${fmt(m.retention_until)} · 삭제 ${fmt(m.delete_after)}` : '—' }, { l: '', f: (m) => `<button class="btn small" data-act="mail-test" data-id="${esc(m.id)}">외부 송수신 시험</button> <button class="btn small" data-act="mail-security" data-id="${esc(m.id)}">DNS 보안 확인</button> <button class="btn small" data-act="mail-operating" data-id="${esc(m.id)}">운영 상태</button> <button class="btn small danger" data-act="mail-cancel" data-id="${esc(m.id)}">정지·해지</button>` }], d.services || [], { empty: '메일 서비스가 없습니다' })}<p class="muted">메일 원문은 운영 화면에서 열람하지 않습니다. 진단 조회는 접근 사유를 기록합니다(§30.2).</p>`; }
export function domainsView(d, src) { return `<h2>도메인·인증서(§30.1)</h2>${table([{ l: '도메인', f: (x) => `<b>${esc(x.name)}</b>${x.is_primary ? ' (대표)' : ''}<br><span class="muted">${esc(x.purpose)} · 그룹 ${esc(x.group_id)} · 사이트 ${esc(x.site_id || '—')}</span>` }, { l: '소유 확인', f: (x) => x.ownership_state === 'verified' ? badge('ok', '확인') : badge('waiting', x.ownership_state) }, { l: 'DNS', f: (x) => x.dns_state === 'ok' ? badge('ok', '반영') : badge('warn', x.dns_state) }, { l: '인증서', f: (x) => (x.cert_state === 'ok' || x.cert_state === 'issued' ? badge('ok', '유효') : x.cert_state === 'failed' ? badge('failed', '실패') : badge('neutral', x.cert_state)) + (x.cert_expires_at ? `<br><span class="${Date.parse(x.cert_expires_at) - Date.now() < 14 * 86400000 ? 'err' : 'muted'}">만료 ${fmt(x.cert_expires_at)}</span>` : '') }, { l: '메일 보안', f: (x) => esc(x.mail_dns_state || '—') }, { l: '마지막 외부 확인', f: (x) => fmt(x.last_checked_at) }, { l: '반복 실패', f: (x) => x.failures ? `<span class="err">${x.failures}회</span>` : '—' }], (d.domains || []), { empty: '도메인이 없습니다' })}${d.duplicates && d.duplicates.length ? `<p class="err">여러 고객에게 연결된 도메인(활성화 차단): ${d.duplicates.map(esc).join(', ')}</p>` : ''}<div class="actions"><button class="btn" data-act="domains-sweep">전체 다시 확인</button></div>
<h2>Source/Gitea(§30.5)</h2>${table([{ l: '사이트', f: (o) => `<b>${esc(o.site_name || o.site_id)}</b> <span class="mono muted">${esc(o.site_id)}</span>` }, { l: '상태', f: (o) => jobBadge(o.state || o.status) }, { l: '저장소', f: (o) => esc(o.repo || o.remote || '—') }, { l: '사용량·한도', f: (o) => o.usage ? `${bytes(o.usage.bytes)} / ${bytes(o.usage.limit)}` : '—' }, { l: '만료·삭제 예정', f: (o) => `${fmt(o.expires_at || o.readonly_until)} · ${fmt(o.delete_after)}` }, { l: '최근 배포 revision', f: (o) => `<span class="mono">${esc(String(o.last_deployed_revision || '').slice(0, 10))}</span>` }], (src && src.options) || [], { empty: 'Source 옵션이 없습니다' })}`; }
export function backupView(d) { return `<h2>복원 승인(§30.3)</h2>${table([{ l: '요청', f: (r) => `<span class="mono">${esc(r.id)}</span><br><b>${esc(r.site_name)}</b> ${esc(r.primary_domain || '')}<br><span class="muted">${esc(r.group_name)} · 백업 ${fmt(r.backup_created_at || r.backupAt)}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">${esc(r.approval_state)}</span>` : '') }, { l: '비용·결제', f: (r) => `${Number(r.cost || 0).toLocaleString('ko-KR')}원 · ${esc(r.payment_state || r.order_id || '—')}` }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%` : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="restore-approve" data-id="${esc(r.id)}">승인(최종 확인)</button> <button class="btn small danger" data-act="restore-reject" data-id="${esc(r.id)}">거절</button>` : r.status === 'failed' ? `<button class="btn small" data-act="restore-retry" data-id="${esc(r.id)}">재시도</button>` : '' }], d.restores || [], { empty: '복원 요청이 없습니다' })}
<h2>백업 옵션</h2>${table([{ l: '사이트', f: (o) => `<b>${esc(o.site_name || o.site_id)}</b> <span class="muted">${esc(o.group_name || '')}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.paused_reason ? ` <span class="err">${esc(o.paused_reason)}</span>` : '') }, { l: '최근 성공·실패', f: (o) => `${fmt(o.last_success_at)}<br>${o.consecutive_failures ? `<span class="err">연속 실패 ${o.consecutive_failures}</span>` : '실패 없음'}` }, { l: '다음 예정', f: (o) => fmt(o.next_run_at) }, { l: '만료·삭제', f: (o) => `${fmt(o.ends_at)} · ${fmt(o.delete_after)}` }, { l: '', f: (o) => `<button class="btn small" data-act="backup-retry" data-id="${esc(o.id)}">재시도</button> <button class="btn small" data-act="backup-override" data-id="${esc(o.id)}">같은 서버 허용</button>` }], d.options || [], { empty: '활성 백업 옵션이 없습니다' })}
<h2>백업 목록</h2>${table([{ l: '백업', f: (b) => `<span class="mono">${esc(b.id)}</span><br>${esc(b.site_name || b.site_id)}` }, { l: '상태', f: (b) => jobBadge(b.status) }, { l: '검증', f: (b) => b.verify_state === 'ok' || b.verify_state === 'verified' ? badge('ok', '검증') : badge('warn', b.verify_state) }, { l: '생성·만료', f: (b) => `${fmt(b.created_at)} · ${fmt(b.expires_at)}` }, { l: '크기·위치', f: (b) => `${bytes(b.size_bytes)} <span class="muted">${esc(b.location || '')}</span>` }], d.backups || [], { empty: '백업이 없습니다' })}`; }
export function relocationView(d, tr) { return `<h2>서버 이전(§30.4)</h2>${table([{ l: '대상', f: (r) => `<b>${esc(r.subjectName || r.subject_id)}</b> <span class="muted">${esc(r.subject_type)} · ${esc(r.group_name || '')}</span><br>${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}<br><span class="muted">${esc(r.reason || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">승인 ${esc(r.approval_state)}</span>` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `<br><span class="muted">${esc(r.job.waitReason)}</span>` : ''}` : '—' }, { l: '전환 직전 재점검', f: (r) => r.precheckChanged || (r.boundaries && r.boundaries.autoStopped) ? badge('warn', '조건 변경 — 자동 중지') : r.boundaries && r.boundaries.switchAt ? `전환 ${fmt(r.boundaries.switchAt)}` : '—' }, { l: '고객 통지', f: (r) => r.notifiedAt || (r.boundaries && r.boundaries.notifiedAt) ? fmt(r.notifiedAt || r.boundaries.notifiedAt) : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="reloc-approve" data-id="${esc(r.id)}">승인</button> <button class="btn small danger" data-act="reloc-reject" data-id="${esc(r.id)}">거절</button>` : ['failed', 'paused'].includes(r.status) ? `<button class="btn small" data-act="reloc-retry" data-id="${esc(r.id)}">재시도</button> <button class="btn small" data-act="reloc-resume" data-id="${esc(r.id)}">이어서</button> <button class="btn small danger" data-act="reloc-revert" data-id="${esc(r.id)}">되돌리기</button>` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}<div class="actions"><button class="btn" data-act="reloc-new">플랫폼 사유 이전 만들기</button></div>
<h2>소유권 이전 심사(§22)</h2>${table([{ l: '요청', f: (t) => `<span class="mono">${esc(t.id)}</span><br>${esc(t.fromGroupName || t.from_group_id)} → ${esc(t.toGroupName || t.to_group_id || t.to_email || '')}` }, { l: '상태', f: (t) => jobBadge(t.status) }, { l: '범위', f: (t) => t.scope ? Object.entries(t.scope).map(([k, v]) => `${esc(k)}: ${Array.isArray(v) ? v.length : esc(String(v))}`).join(', ') : '—' }, { l: '만료', f: (t) => fmt(t.expires_at) }, { l: '', f: (t) => ['reviewing', 'accepted'].includes(t.status) ? `<button class="btn small primary" data-act="transfer-approve" data-id="${esc(t.id)}">승인</button> <button class="btn small danger" data-act="transfer-reject" data-id="${esc(t.id)}">거절</button>` : t.status === 'disputed' ? `<button class="btn small" data-act="transfer-resolve" data-id="${esc(t.id)}">분쟁 해결</button>` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }
+12
View File
@@ -0,0 +1,12 @@
{
"auth": {
"login": { "title": "로그인", "email": "이메일", "password": "비밀번호", "submit": "로그인", "failed": "이메일 또는 비밀번호가 올바르지 않습니다", "locked": "로그인 시도가 너무 많습니다. {until} 이후에 다시 시도하세요", "noAccount": "계정이 없으신가요?", "forgot": "비밀번호를 잊으셨나요?", "operatorNote": "운영자는 별도의 운영자 콘솔(hal.ariweb.net)에서 로그인합니다. 이 화면은 고객 전용입니다.", "mfaTitle": "인증 코드 입력", "mfaHint": "인증 앱의 6자리 코드 또는 복구 코드를 입력하세요", "mfaExpired": "인증 단계가 만료되었습니다. 처음부터 다시 로그인하세요" },
"signup": { "title": "회원가입", "displayName": "표시 이름", "submit": "가입하기", "passwordHint": "8자 이상. 다른 서비스와 같은 비밀번호는 피하세요", "terms": "이용약관에 동의합니다 (필수)", "privacy": "개인정보 처리방침에 동의합니다 (필수)", "marketing": "소식·혜택 안내 수신에 동의합니다 (선택)", "dupe": "이 이메일로는 가입을 진행할 수 없습니다. 이미 계정이 있다면 로그인하거나 비밀번호 재설정을 이용하세요", "emailSent": "확인 메일을 보냈습니다. 받은 편지함의 링크를 눌러 이메일을 확인하세요", "consentRequired": "필수 약관에 동의해야 가입할 수 있습니다", "policyNote": "가격·SLA·환불·개인정보·백업·AUP·정지 및 해지 정책은 승인된 문구만 표시됩니다. 미확정 정책은 '확정 전' 으로 표시됩니다" },
"verify": { "title": "이메일 확인", "ok": "이메일이 확인되었습니다", "invalid": "확인 링크가 유효하지 않거나 만료되었습니다. 다시 보내기를 눌러 새 링크를 받으세요", "used": "이미 사용한 링크입니다", "resend": "확인 메일 다시 보내기", "banner": "이메일 확인 전에는 유료 상품 구매와 운영 서비스 생성을 할 수 없습니다" },
"reset": { "title": "비밀번호 재설정", "request": "재설정 링크 보내기", "requested": "계정이 있다면 재설정 안내 메일을 보냈습니다. 받은 편지함을 확인하세요", "newPassword": "새 비밀번호", "submit": "비밀번호 바꾸기", "done": "비밀번호를 바꿨습니다. 다른 기기의 로그인은 모두 종료되었습니다", "expired": "재설정 링크가 만료되었습니다 (유효 시간 1시간)", "used": "이미 사용한 재설정 링크입니다", "state": "링크 상태" },
"password": { "tooShort": "비밀번호는 8자 이상이어야 합니다", "tooLong": "비밀번호는 128자 이하여야 합니다", "same": "새 비밀번호가 현재 비밀번호와 같습니다", "wrongCurrent": "현재 비밀번호가 올바르지 않습니다" },
"mfa": { "title": "다중 인증(MFA)", "enabled": "사용 중", "disabled": "사용 안 함", "setup": "인증 앱 등록", "manualKey": "수동 입력 키", "enable": "코드 확인 후 켜기", "disable": "끄기", "disableNeeds": "끄려면 비밀번호와 현재 코드가 필요합니다", "badCode": "인증 코드가 올바르지 않습니다", "recovery": "복구 코드", "recoveryOnce": "복구 코드는 지금 한 번만 표시됩니다. 안전한 곳에 보관하세요. 각 코드는 한 번만 쓸 수 있습니다", "regenerate": "복구 코드 다시 만들기", "level": "현재 보안 수준", "levelMfa": "비밀번호 + 인증 앱", "levelPw": "비밀번호만 (인증 앱 등록을 권장합니다)" },
"sessions": { "title": "로그인 기기", "current": "현재 세션", "revoke": "종료", "revokeOthers": "다른 모든 세션 종료", "lastSeen": "마지막 사용", "created": "로그인 시각", "device": "기기" },
"account": { "title": "내 계정", "profile": "프로필", "security": "보안", "changePassword": "비밀번호 변경", "currentPassword": "현재 비밀번호", "saved": "저장했습니다" }
}
}
+1
View File
@@ -0,0 +1 @@
{ "backup": { "title": "백업", "restore": { "title": "복원 요청" }, "option": { "active": "활성", "paused": "중지됨", "ended": "종료됨" }, "notProvidedExternal": "외부 배포 사이트에는 백업 옵션을 제공하지 않습니다", "mailExcluded": "메일 원문은 고객용 백업·복원에 포함되지 않습니다" } }
+11
View File
@@ -0,0 +1,11 @@
{
"commerce": {
"orderStatus": { "awaiting_payment": "결제 대기", "awaiting_approval": "승인 대기", "approved": "승인 완료", "rejected": "거절됨", "expired": "기한 만료", "cancelled": "취소됨" },
"subStatus": { "active": "사용 중", "awaiting_renewal_payment": "갱신 결제 대기", "restricted": "미납 제한", "cancel_scheduled": "해지 예약", "cancelled": "해지됨" },
"manualOnly": "버전 0.1.1 은 수동 결제 확인(계좌 이체)만 지원합니다. 자동 결제수단은 아직 제공하지 않습니다.",
"deferred": "미확정 정책",
"domainExternal": "도메인 등록 기능이 연결되어 있지 않습니다. 외부 등록기관에서 등록한 뒤 도메인 화면에서 연결하세요.",
"walletSeparate": "AI Wallet 은 제작(AI) 기능에만 쓰이며 호스팅 디스크·트래픽·메일 사용량과 별도입니다.",
"overageOff": "자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다."
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"app": { "name": "Ariweb Suite", "portal": "Ariweb 고객 포털", "ops": "Ariweb 운영자 콘솔", "hal": "HAL" },
"nav": { "dashboard": "대시보드", "sites": "사이트", "mail": "메일", "domains": "도메인", "backup": "백업", "billing": "상품 및 결제", "usage": "사용량", "group": "사용자 그룹 설정", "notifications": "알림", "help": "도움말", "account": "내 계정", "logout": "로그아웃" },
"state": { "loading": "불러오는 중", "empty": "비어 있음", "stale": "정보 확인 지연", "partial": "부분 실패", "failed": "전체 실패", "forbidden": "권한 없음", "notProvided": "제공되지 않음" },
"common": { "save": "저장", "cancel": "취소", "confirm": "확인", "retry": "다시 시도", "back": "뒤로", "next": "다음", "close": "닫기", "delete": "삭제", "edit": "수정", "create": "만들기", "refresh": "새로 고침", "lastUpdated": "마지막 갱신", "updatedAt": "갱신 시각", "actions": "작업", "status": "상태", "name": "이름", "more": "더 보기", "none": "없음", "yes": "예", "no": "아니요", "required": "필수", "optional": "선택", "help": "도움말", "copy": "복사", "copied": "복사했습니다" },
"error": { "generic": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요", "forbidden": "이 작업을 할 권한이 없습니다. 필요한 역할: {role}", "notFound": "요청한 항목을 찾을 수 없습니다", "csrf": "보안 토큰이 맞지 않습니다. 화면을 새로 고친 뒤 다시 시도하세요", "unauthenticated": "로그인이 필요합니다", "groupRequired": "사용자 그룹을 먼저 선택하세요" }
}
+9
View File
@@ -0,0 +1,9 @@
{
"dashboard": {
"title": "대시보드", "sites": "운영 사이트", "sitesOf": "구매 {purchased}개 중", "reserved": "예약됨", "inUse": "사용 중", "drafts": "초안·제작 중", "health": "사이트 상태", "healthOk": "정상", "healthWarn": "경고", "healthBad": "장애", "deploy": "배포 방식", "managed": "관리형", "external": "외부", "domains": "도메인", "domainsAction": "조치 필요", "mail": "메일 서비스", "mailboxes": "메일함", "backup": "백업 옵션 사이트", "jobs": "진행 중 작업", "cost": "이번 달 예상 비용", "awaitingPayment": "결제 대기 금액",
"attention": "주의 항목", "recent": "최근 활동", "auto": "자동", "user": "사용자", "noAttention": "지금 조치할 항목이 없습니다", "noActivity": "최근 활동이 없습니다"
},
"notifications": { "title": "알림", "all": "전체", "unread": "읽지 않음", "markRead": "읽음으로 표시", "mandatory": "필수", "count": "{n}회 반복", "resolved": "해결됨", "categories": { "billing": "결제와 주문", "site": "사이트와 배포", "domain": "도메인과 인증서", "mail": "메일", "usage": "사용량", "backup": "백업과 복원", "relocation": "서버 이전", "security": "보안", "incident": "장애와 유지보수", "membership": "구성원", "ops": "운영" } },
"activity": { "title": "활동 기록", "who": "누가", "when": "언제", "what": "무엇을", "target": "대상", "result": "결과", "auto": "자동 작업", "manual": "사용자 작업", "immutable": "삭제 불가 기록" },
"help": { "title": "도움말" }
}
+4
View File
@@ -0,0 +1,4 @@
{ "domains": { "title": "도메인", "connect": "도메인 연결", "detail": "도메인 상세", "empty": "연결된 도메인이 없습니다. 도메인을 연결하면 소유 확인 → DNS → HTTPS → 외부 확인 순서로 진행합니다",
"purpose": { "web": "웹", "mail": "메일", "both": "웹 + 메일" },
"ownership": { "pending": "확인 전", "verified": "확인됨" }, "dns": { "pending": "미반영", "wrong": "잘못된 값", "ok": "정상", "customer": "고객 관리", "n/a": "해당 없음" },
"cert": { "none": "없음", "issuing": "발급 중", "valid": "정상", "expiring_soon": "만료 임박", "failed": "실패", "customer_managed": "고객 관리" } } }
+10
View File
@@ -0,0 +1,10 @@
{
"groups": {
"title": "사용자 그룹 설정", "name": "그룹 이름", "contact": "연락처", "billingContact": "결제 담당자", "techContact": "기술 담당자", "members": "구성원", "invites": "초대 대기", "invite": "이메일로 초대", "resend": "재발송", "cancelInvite": "초대 취소", "expired": "만료됨", "role": "역할", "alias": "표시 별칭", "aliasHint": "화면 표시용 별칭입니다. 로그인과 권한의 기준이 아닙니다",
"roles": { "owner": "소유자", "admin": "관리자", "billing": "결제 담당", "member": "구성원", "viewer": "조회 전용" },
"suspend": "정지", "unsuspend": "정지 해제", "remove": "제거", "leave": "그룹 탈퇴", "transferOwner": "소유권 이전", "suspendImpact": "정지하면 이 구성원의 로그인 세션과 아직 실행되지 않은 작업만 중지됩니다. 그룹의 상품·사이트·도메인과 다른 구성원의 작업은 유지됩니다",
"ownerCannotLeave": "소유자는 먼저 소유권을 다른 구성원에게 이전해야 탈퇴할 수 있습니다", "lastOwner": "소유자는 그룹마다 한 명이며 제거할 수 없습니다", "switched": "사용자 그룹을 바꿨습니다",
"sitePerms": { "title": "사이트별 권한", "view": "조회", "edit": "콘텐츠 편집", "deploy": "배포", "domain_mail": "도메인과 메일 설정", "billing": "결제와 해지", "admin": "전체 관리" },
"inviteAccepted": "그룹에 참여했습니다", "inviteInvalid": "초대 링크가 유효하지 않거나 만료되었습니다", "inviteNeedsLogin": "초대를 수락하려면 초대받은 이메일로 로그인하거나 가입하세요"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"hal": {
"name": "HAL",
"menu": { "overview": "운영 현황", "customers": "고객과 사용자 그룹", "orders": "주문과 결제 승인", "products": "상품과 정책", "sites": "사이트와 배포", "mail": "메일 서비스", "domains": "도메인과 인증서", "servers": "서버와 용량", "backup": "백업과 복원", "relocation": "서버 이전", "releases": "배포 릴리스", "incidents": "장애와 알림", "audit": "감사 기록", "recovery": "플랫폼 복구", "emergency": "긴급 복구" },
"server": { "status": { "pending": "등록 대기", "ready": "배치 가능", "maintenance": "유지보수", "isolated": "격리", "offline": "응답 없음" }, "role": { "web": "웹 호스팅", "mail": "메일", "backup": "백업 저장소", "control": "제어(HAL·포털)" }, "acceptsNew": "신규 배치 허용", "clockSkew": "서버 시계 차이", "lastSeen": "마지막 확인", "drift": "목표 버전과 실제 버전이 다릅니다" },
"bootstrap": { "incomplete": "부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다", "locked": "첫 Agent 등록이 끝나 일반 모듈의 수동 설치는 잠겼습니다 — HAL 작업으로만 배포합니다", "halFirst": "HAL 을 먼저 배포하고 건강 확인이 끝난 뒤 나머지 모듈을 배포합니다" },
"release": { "registered": "등록됨", "approved": "승인됨", "halDeployed": "HAL 배포 완료", "checksFailed": "검사 실패" },
"job": { "partial": "일부 대상 실패 — 대상별 결과를 확인하세요", "allFailed": "모든 대상 실패", "agentSilent": "대상 서버의 Agent 가 응답하지 않습니다" },
"emergency": { "status": { "declared": "선언됨", "approving": "승인 진행 중", "approved": "승인 완료", "credential_issued": "자격 발급됨", "performing": "수행 중", "verified": "결과 확인·자격 폐기", "reconciled": "상태 조정 완료", "closed": "종료" }, "steps": ["영향 범위 확인", "변경되지 않는 대상 식별정보 재확인", "최종 실행과 되돌리기 가능 여부 확인"], "forbidden": "긴급 복구에서는 일반 고객 서비스 배포를 할 수 없습니다" },
"dr": { "ready": "정식 운영 준비 완료", "notReady": "정식 운영 준비 전(복구 훈련 또는 설정 필요)", "rto": "복구 목표 시간(분)", "rpo": "허용 가능한 데이터 시점(분)", "interval": "복구 사본 생성 주기(분)", "retention": "보존 개수", "oldControlConfirm": "기존 제어 위치를 중지했습니다" },
"access": { "denied": "접근할 수 없습니다", "reauth": "고위험 작업은 최근 10분 안의 재인증이 필요합니다" }
}
}
+1
View File
@@ -0,0 +1 @@
{ "lifecycle": { "title": "설정·생명주기", "relocation": "서버 이전", "transfer": "소유권 이전", "state": { "draft": "초안", "live": "운영 중", "paused": "일시 중지", "cancel_scheduled": "해지 예약", "readonly_retention": "읽기 전용 보존", "pending_delete": "삭제 대기", "deleted": "삭제 완료", "delete_hold": "삭제 보류" }, "siteCountReturn": "운영 사이트 수 반환" } }
+15
View File
@@ -0,0 +1,15 @@
{
"mail": {
"title": "메일", "list": "메일 서비스", "new": "메일 서비스 만들기", "kind": { "site_bound": "사이트 대표 메일", "standalone": "독립 메일 서비스" },
"status": { "needs_setup": "설정 필요", "activating": "활성화 중", "active": "운영 중", "cancelled": "읽기 전용 보존", "deleted": "삭제 완료", "suspended": "일시 중지" },
"tabs": { "overview": "개요", "mailboxes": "메일함", "usage": "사용량", "security": "도메인 보안", "settings": "설정", "convert": "독립 전환", "lifecycle": "생명주기" },
"noBackup": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다.",
"retention": "해지 뒤 30일 동안 기존 메일 읽기·내보내기가 가능하며 그 뒤 삭제됩니다.",
"counting": "월간 통수는 송신과 수신의 합계이며 최종 수신자 한 명당 한 통으로 셉니다.",
"limit100": { "storage": "저장 공간 100%: 기존 메일 조회·삭제는 유지되고 새 수신은 임시 실패로 응답해 발신 서버가 재시도합니다.", "monthly": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", "mailboxes": "메일함 수 100%: 기존 메일함은 유지되고 새 메일함 생성이 막힙니다." },
"sendLimit": "초당 10건 이상의 SMTP 제출은 자동 제한·경고·악용 확인 대상이며, 실제 외부 전달은 도메인과 서버 각각 초당 5건을 넘지 않습니다. 정상적인 대량 발송 상품이 아닙니다.",
"policyDeferred": "후속 상품정책에서 정합니다", "suspendedCounts": "정지된 메일함도 삭제 전까지 메일함 수와 저장 공간을 소비합니다.",
"initialPasswordOnce": "초기 비밀번호는 지금 한 번만 보입니다. 첫 로그인에서 비밀번호를 바꿔야 합니다.",
"protocolImpact": "POP3 또는 IMAP 을 끄면 그 방식으로 연결된 메일 프로그램(Outlook·Thunderbird·휴대전화 메일 등)이 즉시 접속에 실패합니다. 웹메일은 영향이 없습니다."
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ops": {
"title": "Ariweb HAL 운영자 콘솔",
"menu": { "overview": "운영 현황", "customers": "고객과 사용자 그룹", "orders": "주문과 결제 승인", "products": "상품과 정책", "sites": "사이트와 배포", "mail": "메일 서비스", "domains": "도메인과 인증서", "servers": "서버와 용량", "backup": "백업과 복원", "relocation": "서버 이전", "releases": "배포 릴리스", "jobs": "운영 작업", "incidents": "장애와 알림", "audit": "감사 기록", "recovery": "플랫폼 복구", "account": "운영자 계정" },
"gate": { "denied": "허용 네트워크(VPN·사설망) 또는 기기 귀속 접근권 없이는 로그인 화면에도 접근할 수 없습니다", "grantExpires": "접근권 만료 {at}", "reauth": "고위험 작업은 최근 10분 안의 재인증이 필요합니다", "customerSession": "고객 세션으로는 운영자 화면을 열 수 없습니다" },
"threeStep": { "impact": "1단계 — 영향 범위 확인", "identifiers": "2단계 — 변경되지 않는 대상 식별정보 재입력", "execute": "3단계 — 최종 실행과 되돌리기 가능 여부 확인" },
"state": { "stale": "정보 지연 — 포털 또는 Agent 에서 최신 정보를 받지 못했습니다. 정상으로 판정하지 않습니다", "partial": "부분 실패 — 대상별 결과를 확인하세요" }
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"sites": {
"title": "사이트", "new": "새 사이트", "detail": "사이트 상세", "empty": "아직 사이트가 없습니다. 새 사이트를 만들어 Webmaker 에서 제작을 시작하세요",
"filters": { "all": "전체", "draft": "초안", "building": "제작 중", "deploying": "배포 중", "live": "운영 중", "paused": "중지", "cancel_pending": "해지 대기" },
"tabs": { "overview": "개요", "build": "제작", "source": "Source", "deploy": "배포", "domains": "도메인", "mail": "메일", "backup": "백업", "usage": "사용량 및 통계", "activity": "활동 기록", "settings": "설정" },
"create": { "name": "사이트 이름", "purpose": "용도", "project": "Webmaker 프로젝트", "projectNew": "새 프로젝트 만들기", "projectExisting": "기존 프로젝트 연결", "projectNone": "나중에 연결", "template": "시작 템플릿", "lang": "기본 언어", "timezone": "시간대", "deployMode": "배포 방식", "modeLater": "나중에 선택", "noSiteCount": "초안 사이트를 만드는 것만으로는 운영 사이트 수를 쓰지 않습니다. 최초 공개 배포 직전에 사이트 수를 확보합니다" },
"deploy": { "title": "배포", "start": "배포 시작", "precheck": "배포 전 확인", "version": "배포할 저장본", "current": "현재 저장 상태", "rollback": "이전 버전으로 되돌리기", "cleanup": "지금 정리하고 반환", "verified": "배포 검증 완료", "needs_domain": "도메인 설정 필요", "public": "공개 운영 중", "rollbackImpact": "되돌리면 지금 배포된 파일이 이전 배포본으로 바뀝니다. 업로드 데이터와 데이터베이스는 그대로 유지되며, 되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다" },
"mode": { "managed": "관리형 배포", "external": "외부 배포", "managedDesc": "Ariweb 가 서버 배치와 운영을 관리합니다", "externalDesc": "고객이 준비한 Linux 서버에 External Pull Agent 를 설치해 배포합니다" },
"external": { "title": "외부 서버 배포", "issue": "연결 토큰 발급", "connected": "연결됨", "pending": "연결 대기", "offline": "응답 없음", "revoked": "해제됨" }
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"usage": {
"allocState": { "available": "사용 가능", "reserved": "예약됨", "in_use": "사용 중", "returning": "반환 중", "returned": "반환됨" },
"warn": { "70": "사전 안내", "80": "경고", "90": "긴급 경고", "95": "최종 경고", "100": "제한 적용" },
"stale": "사용량 정보가 오래되어 배정을 늘릴 수 없습니다. 계측이 갱신된 뒤 다시 시도하세요.",
"belowUsage": "현재 사용량보다 작게 줄일 수 없습니다.",
"monthReset": "월간 항목은 다음 KST 월 시작에 초기화됩니다."
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* lib/audit.mjs — 감사 기록(§24.2).
*
* 누가(actor)·언제·어떤 그룹과 대상(subject)에서·무엇을(action)·자동/사용자 작업인지·결과(outcome)를 남긴다.
* 비밀값은 redact() 로 거른다. 권한·소유권·배포·해지·복원·이전·결제 승인은 immutable=1 로 두어 삭제 API 가 거부한다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_audit_0001', sql: `
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, at TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, actor_label TEXT, group_id TEXT,
subject_type TEXT, subject_id TEXT, action TEXT NOT NULL, outcome TEXT NOT NULL, automatic INTEGER NOT NULL DEFAULT 0,
immutable INTEGER NOT NULL DEFAULT 0, detail_json TEXT, ip TEXT
);
CREATE INDEX IF NOT EXISTS audit_group ON audit_log(group_id, at);
CREATE INDEX IF NOT EXISTS audit_subject ON audit_log(subject_type, subject_id, at);` }];
/** 삭제할 수 없는 기록으로 남기는 행동 접두어 */
const IMMUTABLE_PREFIX = /^(permission|ownership|deploy|cancel|terminate|restore|relocate|order\.approve|order\.reject|payment|refund|member\.role|site_count|emergency|recovery)/;
/**
* 기록 남기기.
* @param {object} store lib/db
* @param {object} e { actorType: 'user'|'operator'|'system'|'agent', actorId, actorLabel, groupId, subjectType, subjectId, action, outcome: 'ok'|'failed'|'cancelled'|'denied', automatic, detail, ip }
*/
export function audit(store, e) {
const id = newId('a_');
store.run('INSERT INTO audit_log(id, at, actor_type, actor_id, actor_label, group_id, subject_type, subject_id, action, outcome, automatic, immutable, detail_json, ip) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, nowIso(), e.actorType || 'system', e.actorId || null, e.actorLabel || null, e.groupId || null, e.subjectType || null, e.subjectId || null, e.action, e.outcome || 'ok',
e.automatic ? 1 : 0, e.immutable || IMMUTABLE_PREFIX.test(e.action) ? 1 : 0, toJson(redact(e.detail || null)), e.ip || null);
return id;
}
/** 조회(그룹·대상·행동·기간 필터) */
export function listAudit(store, { groupId, subjectType, subjectId, action, since, until, limit = 200 } = {}) {
const w = []; const p = [];
if (groupId) { w.push('group_id=?'); p.push(groupId); }
if (subjectType) { w.push('subject_type=?'); p.push(subjectType); }
if (subjectId) { w.push('subject_id=?'); p.push(subjectId); }
if (action) { w.push('action LIKE ?'); p.push(action + '%'); }
if (since) { w.push('at>=?'); p.push(since); }
if (until) { w.push('at<=?'); p.push(until); }
return store.all(`SELECT * FROM audit_log ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
+29
View File
@@ -0,0 +1,29 @@
/**
* lib/crypto.mjs — 비밀번호 해시·토큰·HMAC 공통.
*
* - 비밀번호: scrypt(N=2^15) + 16바이트 salt. Webmaker 와 같은 계열의 알고리즘이라 SSO 계정 동기화 때 혼동이 없다.
* - 토큰: 32바이트 무작위 base64url. 저장은 항상 sha256 해시만(원문은 발급 응답에 한 번).
* - HMAC: Agent 요청 서명·SSO 티켓 서명. 비교는 항상 timingSafeEqual.
*/
import { scryptSync, randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
/** 비밀번호 해시. @returns {{salt:string, hash:string}} */
export function hashPassword(password) { const salt = randomBytes(16).toString('hex'); return { salt, hash: scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex') }; }
/** 비밀번호 검증(상수 시간). */
export function verifyPassword(password, salt, hash) {
if (!salt || !hash) return false;
const a = scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); const b = Buffer.from(hash, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
/** 무작위 토큰(원문). bytes 기본 32. */
export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url');
/** 짧은 식별자(ULID 대용): 시각 정렬 가능한 16진 26자 — 앞 10자는 ms 시각. */
export function newId(prefix = '') { const t = Date.now().toString(16).padStart(12, '0'); return prefix + t + randomBytes(7).toString('hex'); }
/** 토큰 저장용 해시 */
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
/** HMAC-SHA256 (base64url) */
export const hmac = (secret, data) => createHmac('sha256', secret).update(String(data)).digest('base64url');
/** 문자열 상수 시간 비교 */
export function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); }
/** 6자리 숫자 코드(이메일 확인·MFA 복구 등) */
export const numericCode = (digits = 6) => String(parseInt(randomBytes(4).toString('hex'), 16) % 10 ** digits).padStart(digits, '0');
+74
View File
@@ -0,0 +1,74 @@
/**
* lib/db.mjs — SQLite 저장소 공통 계층 (node:sqlite, 외부 의존성 없음).
*
* 목적
* HAL·포털·Agent 가 모두 같은 방식으로 DB 를 열고 마이그레이션을 적용하게 한다.
* - WAL 모드: 읽기와 쓰기가 서로 막지 않아 화면 조회 중 작업 엔진이 기록해도 된다.
* - foreign_keys ON: 그룹·사이트·작업 사이의 참조 무결성을 DB 가 지킨다.
* - busy_timeout: 짧은 잠금 경쟁(두 요청이 동시에 예약을 시도)은 기다렸다가 처리한다(§13.3 동시 요청).
*
* 사용
* const store = openDb('/srv/ariweb/portal/data/portal.sqlite', [{ id: '0001_init', sql: '...' }]);
* store.get('SELECT * FROM users WHERE id=?', id) → 한 행 또는 undefined
* store.all(...) → 행 배열
* store.run(...) → { changes, lastInsertRowid }
* store.tx(() => { ... }) → BEGIN IMMEDIATE … COMMIT (예외 시 ROLLBACK). 중첩되면 바깥 트랜잭션에 합류한다.
*
* 마이그레이션
* `_migrations` 표에 적용한 id 를 남기고, 아직 없는 것만 순서대로 적용한다. 각 모듈(lib/session·jobs·quota·notify·audit)은
* 자기 표의 마이그레이션을 `MIGRATIONS` 로 내보내고, 각 프로세스의 schema 파일이 그것을 모아 openDb 에 넘긴다.
*/
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/**
* DB 를 열고 마이그레이션을 적용한다.
* @param {string} file 파일 경로. ':memory:' 면 메모리 DB(테스트).
* @param {Array<{id:string, sql:string}>} migrations 적용할 마이그레이션(순서대로)
* @returns {{db: DatabaseSync, get: Function, all: Function, run: Function, exec: Function, tx: Function, close: Function, file: string}}
*/
export function openDb(file, migrations = []) {
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file);
// 운영 안정성 설정. WAL 은 파일 DB 에서만 의미가 있다.
db.exec('PRAGMA foreign_keys = ON');
if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); }
db.exec('PRAGMA busy_timeout = 5000');
db.exec('CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
const applied = new Set(db.prepare('SELECT id FROM _migrations').all().map((r) => r.id));
for (const m of migrations) {
if (applied.has(m.id)) continue;
// 한 마이그레이션은 한 트랜잭션: 중간에 실패하면 전부 되돌려 반쪽 스키마가 남지 않게 한다.
db.exec('BEGIN');
try { db.exec(m.sql); db.prepare('INSERT INTO _migrations(id, applied_at) VALUES(?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); }
catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 (${m.id}): ${e.message}`); }
}
let depth = 0; // 중첩 tx 깊이 — 안쪽 tx 는 BEGIN 을 다시 하지 않는다
const store = {
db, file,
get: (sql, ...p) => db.prepare(sql).get(...p),
all: (sql, ...p) => db.prepare(sql).all(...p),
run: (sql, ...p) => db.prepare(sql).run(...p),
exec: (sql) => db.exec(sql),
/**
* 즉시 잠금 트랜잭션. 쓰기 경쟁(예약·주문 중복)을 DB 수준에서 직렬화한다.
* 동기 함수만 받는다(node:sqlite 는 동기 API 라 await 사이에 잠금을 쥐고 있으면 안 된다).
*/
tx(fn) {
if (depth > 0) return fn(store);
db.exec('BEGIN IMMEDIATE'); depth++;
try { const r = fn(store); db.exec('COMMIT'); depth--; return r; }
catch (e) { depth--; try { db.exec('ROLLBACK'); } catch (_) {} throw e; }
},
close: () => db.close(),
};
return store;
}
/** JSON 열 읽기 도우미: 깨진 값은 fallback(화면이 깨지지 않게). */
export function parseJson(text, fallback = null) { if (text == null || text === '') return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } }
/** JSON 열 쓰기 도우미 */
export const toJson = (v) => (v === undefined ? null : JSON.stringify(v));
/** 현재 시각(ISO, UTC) */
export const nowIso = () => new Date().toISOString();
+157
View File
@@ -0,0 +1,157 @@
/**
* lib/http.mjs — 의존성 없는 작은 HTTP 앱 프레임워크(라우터·JSON·쿠키·정적 파일·오류 형식).
*
* 왜 직접 만드나: 형제 제품(Webmaker·AriMail)과 같이 외부 npm 의존성 없이 운영하기 위해서다. 필요한 기능은
* 경로 매개변수(`/api/sites/:id`), JSON 본문, 쿠키, 신뢰 프록시 IP, 정적 파일, 일관된 오류 응답뿐이다.
*
* 핸들러: async (ctx) => 반환값
* - 객체를 돌려주면 JSON(200). `ctx.json(obj, status)`·`ctx.html(str)`·`ctx.redirect(url)` 로 다른 응답.
* - 예외: `ctx.error(status, code, message)` 가 만든 HttpError → { ok:false, error, message, detail? }. 그 밖의 예외는 500 + 기록.
* 미들웨어: app.use(async (ctx) => { ... }) — 모든 라우트 전에 실행(세션 해석 등). 반환값이 있으면 그것으로 응답(차단).
* 보안 기본값: 모든 동적 응답 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`.
* 접속 IP: nginx 가 붙인 `X-Real-IP` 를 소켓 주소가 루프백일 때만 믿는다(클라이언트가 넣은 헤더를 믿지 않음).
*/
import http from 'node:http';
import { readFileSync, statSync, createReadStream, existsSync } from 'node:fs';
import { join, normalize, extname } from 'node:path';
export class HttpError extends Error {
/** @param {number} status @param {string} code 기계용 오류 코드 @param {string} message 사용자용 설명 @param {object} [detail] 화면이 쓸 추가 정보(비밀 없음) */
constructor(status, code, message, detail) { super(message || code); this.status = status; this.code = code; this.detail = detail; }
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' };
const MAX_BODY = 2 * 1024 * 1024; // JSON 본문 상한(2MB). 파일 업로드는 별도 스트리밍 라우트가 맡는다.
/** 쿠키 헤더 파싱 */
export function parseCookies(header) { const out = {}; for (const part of String(header || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; }
/** Set-Cookie 문자열 만들기 */
export function cookie(name, value, { maxAge = 0, path = '/', secure = true, sameSite = 'Lax', httpOnly = true } = {}) {
return `${name}=${encodeURIComponent(value)}; Path=${path}; ${httpOnly ? 'HttpOnly; ' : ''}SameSite=${sameSite}${secure ? '; Secure' : ''}${maxAge ? `; Max-Age=${maxAge}` : '; Max-Age=0'}`;
}
/** 경로 패턴(`/a/:id/b`)을 정규식으로 */
function compile(pattern) {
const keys = []; const re = new RegExp('^' + pattern.replace(/\/+$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.*)').replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '/?$');
return { re, keys };
}
/**
* 앱 만들기.
* @param {{ log?: Function, trustProxy?: boolean, onError?: Function }} opts
*/
export function createApp(opts = {}) {
const routes = []; const middlewares = []; const statics = [];
const log = opts.log || ((...a) => console.log(...a));
const app = {
/** 라우트 등록. method 는 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'*' */
route(method, pattern, handler) { routes.push({ method: method.toUpperCase(), ...compile(pattern), pattern, handler }); return app; },
get: (p, h) => app.route('GET', p, h), post: (p, h) => app.route('POST', p, h), put: (p, h) => app.route('PUT', p, h), patch: (p, h) => app.route('PATCH', p, h), delete: (p, h) => app.route('DELETE', p, h),
use(fn) { middlewares.push(fn); return app; },
/** 정적 파일: prefix 아래 요청을 dir 에서 낸다(경로 탈출 차단). */
static(prefix, dir) { statics.push({ prefix: prefix.replace(/\/$/, ''), dir }); return app; },
/** 요청 하나 처리(테스트에서도 직접 부른다). */
async handle(req, res) {
const started = Date.now();
const url = new URL(req.url, 'http://localhost');
const ctx = makeCtx(req, res, url, opts);
try {
for (const s of statics) {
if (ctx.method === 'GET' && (url.pathname === s.prefix || url.pathname.startsWith(s.prefix + '/'))) {
const rel = normalize(url.pathname.slice(s.prefix.length) || '/').replace(/^(\.\.(\/|\\|$))+/, '');
const file = join(s.dir, rel);
if (!file.startsWith(s.dir)) throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
if (existsSync(file) && statSync(file).isFile()) return sendFile(res, file);
}
}
for (const m of middlewares) { const r = await m(ctx); if (r !== undefined) return finish(ctx, r); if (ctx.ended) return; }
for (const r of routes) {
if (r.method !== '*' && r.method !== ctx.method) continue;
const m = r.re.exec(url.pathname); if (!m) continue;
r.keys.forEach((k, i) => { ctx.params[k] = decodeURIComponent(m[i + 1]); });
const out = await r.handler(ctx);
return finish(ctx, out);
}
throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
} catch (e) { return sendError(ctx, e, log, opts); }
finally { if (opts.access) log(`${ctx.method} ${url.pathname} ${res.statusCode} ${Date.now() - started}ms`); }
},
/** 서버 시작 */
listen(port, host = '127.0.0.1') { const server = http.createServer((req, res) => app.handle(req, res)); server.keepAliveTimeout = 65000; return new Promise((resolve) => server.listen(port, host, () => resolve(server))); },
routes,
};
return app;
}
/** 요청 컨텍스트 */
function makeCtx(req, res, url, opts) {
const sock = req.socket?.remoteAddress || '';
const loop = sock === '127.0.0.1' || sock === '::1' || sock === '::ffff:127.0.0.1';
const ip = (opts.trustProxy !== false && loop && req.headers['x-real-ip']) ? String(req.headers['x-real-ip']) : sock.replace(/^::ffff:/, '');
let bodyCache;
const ctx = {
req, res, method: req.method.toUpperCase(), path: url.pathname, url, params: {}, query: Object.fromEntries(url.searchParams), cookies: parseCookies(req.headers.cookie), ip, headers: req.headers,
user: null, session: null, ended: false, status: 200, outHeaders: {},
secure: req.headers['x-forwarded-proto'] === 'https' || !!req.socket?.encrypted,
/** JSON 본문(한 번만 읽고 캐시). 비어 있으면 {} */
async body() {
if (bodyCache !== undefined) return bodyCache;
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > MAX_BODY) throw new HttpError(413, 'body_too_large', '요청 본문이 너무 큽니다'); chunks.push(c); }
const text = Buffer.concat(chunks).toString('utf8');
if (!text) return (bodyCache = {});
const ct = String(req.headers['content-type'] || '');
if (ct.includes('application/x-www-form-urlencoded')) return (bodyCache = Object.fromEntries(new URLSearchParams(text)));
try { return (bodyCache = JSON.parse(text)); } catch (_) { throw new HttpError(400, 'bad_json', '요청 본문이 올바른 JSON 이 아닙니다'); }
},
set(status, headers = {}) { ctx.status = status; Object.assign(ctx.outHeaders, headers); },
setCookie(str) { const prev = ctx.outHeaders['Set-Cookie']; ctx.outHeaders['Set-Cookie'] = prev ? [].concat(prev, str) : str; },
json(obj, status = 200) { ctx.status = status; return { __json: obj }; },
html(str, status = 200) { ctx.status = status; return { __html: str }; },
text(str, status = 200) { ctx.status = status; return { __text: str }; },
redirect(url, status = 302) { ctx.status = status; ctx.outHeaders.Location = url; return { __text: '' }; },
error(status, code, message, detail) { return new HttpError(status, code, message, detail); },
};
return ctx;
}
function baseHeaders(ctx) { return { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...ctx.outHeaders }; }
function finish(ctx, out) {
if (ctx.ended) return; ctx.ended = true;
const h = baseHeaders(ctx);
if (out && out.__html !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/html; charset=utf-8', ...h }); return ctx.res.end(out.__html); }
if (out && out.__text !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/plain; charset=utf-8', ...h }); return ctx.res.end(out.__text); }
const body = out && out.__json !== undefined ? out.__json : out;
ctx.res.writeHead(ctx.status, { 'Content-Type': 'application/json; charset=utf-8', ...h });
ctx.res.end(JSON.stringify(body === undefined ? { ok: true } : body));
}
function sendError(ctx, e, log, opts) {
if (ctx.ended) return;
const status = e instanceof HttpError ? e.status : (e && e.status) || 500;
const code = e instanceof HttpError ? e.code : (e && e.code) || 'server_error';
if (status >= 500) { log(`[http] ${ctx.method} ${ctx.path} 500: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); if (opts.onError) try { opts.onError(e, ctx); } catch (_) {} }
ctx.ended = true;
ctx.res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...baseHeaders(ctx) });
ctx.res.end(JSON.stringify({ ok: false, error: code, message: status >= 500 ? '일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요' : (e.message || code), ...(e.detail ? { detail: e.detail } : {}) }));
}
function sendFile(res, file) {
const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
createReadStream(file).pipe(res);
}
/** 간단한 HTML 이스케이프 */
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 테스트용: 앱에 가짜 요청을 보내고 { status, headers, json, text } 를 돌려준다(소켓 없이). */
export async function invoke(app, method, path, { body, headers = {}, cookies = {}, ip = '127.0.0.1' } = {}) {
const { PassThrough } = await import('node:stream');
const req = new PassThrough(); req.method = method; req.url = path;
req.headers = { 'content-type': 'application/json', ...headers };
if (Object.keys(cookies).length) req.headers.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
req.socket = { remoteAddress: ip };
if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); else req.end();
const out = { status: 0, headers: {}, chunks: [] };
const res = { writeHead(s, h) { out.status = s; out.headers = h || {}; }, end(d) { if (d) out.chunks.push(Buffer.from(d)); out.done = true; resolve(); }, write(d) { out.chunks.push(Buffer.from(d)); }, statusCode: 200, on() {}, once() {}, emit() {} };
let resolve; const p = new Promise((r) => { resolve = r; });
await app.handle(req, res); await p;
const text = Buffer.concat(out.chunks).toString('utf8');
let json = null; try { json = JSON.parse(text); } catch (_) {}
const setCookie = [].concat(out.headers['Set-Cookie'] || []);
return { status: out.status, headers: out.headers, text, json, cookies: Object.fromEntries(setCookie.map((c) => { const [kv] = c.split(';'); const i = kv.indexOf('='); return [kv.slice(0, i), decodeURIComponent(kv.slice(i + 1))]; })) };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* lib/i18n.mjs — 화면 문구 사전. 문구를 코드에 박지 않고 lang/ko.json 에서 읽는다(§9 규약).
* t('auth.login.failed') → 문자열. {name} 자리표시자는 vars 로 치환. 없는 키는 키 그대로 돌려줘 누락이 화면에 드러난다.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
let dict = null; let loadedAt = 0;
/** lang/ko/*.json 을 모두 읽어 깊게 합친다(영역마다 파일을 나눠 충돌 없이 관리). 운영 중 수정은 5초 안에 반영. */
function load() {
if (dict && Date.now() - loadedAt < 5000) return dict;
const dir = join(ROOT, 'lang', 'ko'); const merged = {};
const deepMerge = (a, b) => { for (const [k, v] of Object.entries(b)) { if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') deepMerge(a[k], v); else a[k] = v; } return a; };
try { for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) { try { deepMerge(merged, JSON.parse(readFileSync(join(dir, f), 'utf8'))); } catch (e) { console.error(`[i18n] ${f} 읽기 실패: ${e.message}`); } } } catch (_) {}
dict = merged; loadedAt = Date.now(); return dict;
}
/** 점(.)으로 중첩 키를 찾는다 */
function lookup(obj, key) { return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); }
export function t(key, vars = {}) {
const v = lookup(load(), key);
const s = typeof v === 'string' ? v : key;
return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? String(vars[k]) : `{${k}}`));
}
/** 사전 전체(화면 JS 에 내려보낼 때) */
export function all() { return load(); }
+214
View File
@@ -0,0 +1,214 @@
/**
* lib/jobs.mjs — 내구성 작업 엔진(§2.2·§29·§36).
*
* 생각의 틀
* 배포·복원·이전·백업·주문 처리처럼 "오래 걸리고 중간에 프로세스가 죽을 수 있는" 일은 모두 작업(job)이다.
* 작업은 DB 행이며 단계(step)마다 결과를 저장한다. 프로세스가 재시작되면 끝난 단계는 건너뛰고 다음 단계부터 이어서 실행한다.
* 완료되지 않은 작업을 실패로 숨기지 않고, 재시도 횟수·다음 시각·외부 대기 사유·사용자 확인 필요를 상태로 드러낸다.
*
* 핵심 규약
* - register(kind, def): def = { title, steps:[{id,title}], run(job, ctx), revert?(job, ctx), maxAttempts?, retryable?(err) }
* - run() 은 **재진입 가능**해야 한다: 같은 작업으로 여러 번 불릴 수 있으므로 부수 효과가 있는 일은 ctx.step(id, fn) 안에서 한다.
* ctx.step 은 이미 done 인 단계면 저장된 결과를 돌려주고 fn 을 실행하지 않는다.
* - 같은 idempotencyKey 로 enqueue 하면 새 작업을 만들지 않고 기존 작업을 돌려준다(버튼 연타·새로 고침 §36).
* - 일시 오류(err.retryable=true 또는 def.retryable(err)) 는 지수 백오프로 재시도하고 attempt·next_run_at 을 보인다.
* - ctx.waitExternal(reason, {resumeAt}) → AWAITING_EXTERNAL. ctx.needsUser(message, actions) → NEEDS_USER. resume(id) 로 다시 QUEUED.
* - 되돌리기: revert(id) 는 def.revert 를 실행하며 REVERTING → REVERTED(실패하면 FAILED + error). 되돌리기 전 데이터 영향은 호출자가 안내한다.
* - 부모/자식: parentId 로 대상별 작업(여러 서버 모듈 갱신 §29)을 묶고 childrenSummary 로 대상별 성공·실패·대기를 보인다. 일부 실패를 전체 성공으로 보이지 않는다.
*/
import { newId } from './crypto.mjs';
import { redact, safeError } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
import { JOB_STATUS as S, TERMINAL, CANCELLABLE } from './status.mjs';
export const MIGRATIONS = [{ id: 'lib_jobs_0001', sql: `
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL, label TEXT, subject_json TEXT, steps_json TEXT, attempt INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT, locked_by TEXT, locked_at TEXT, result_json TEXT, error TEXT, error_code TEXT, wait_reason TEXT, user_message TEXT, actions_json TEXT, eta_at TEXT, progress INTEGER NOT NULL DEFAULT 0,
idempotency_key TEXT UNIQUE, created_by TEXT, actor_type TEXT, group_id TEXT, subject_type TEXT, subject_id TEXT, parent_id TEXT, external_ref TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, log_json TEXT
);
CREATE INDEX IF NOT EXISTS jobs_status ON jobs(status, next_run_at);
CREATE INDEX IF NOT EXISTS jobs_group ON jobs(group_id, created_at);
CREATE INDEX IF NOT EXISTS jobs_subject ON jobs(subject_type, subject_id, created_at);
CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }];
/** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
export class JobError extends Error { constructor(code, message, { retryable = false, detail, status = 400 } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; this.status = status; /* API 로 새면 400 계열 — 500 으로 숨기지 않는다 */ } }
const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000;
/**
* 작업 엔진 만들기.
* @param {object} store lib/db
* @param {{workerId?:string, pollMs?:number, log?:Function}} opts
*/
export function createJobEngine(store, opts = {}) {
const kinds = new Map(); const listeners = new Set();
const workerId = opts.workerId || `${process.pid}@${(await0() || 'host')}`; const log = opts.log || (() => {});
let timer = null; let running = 0; let stopped = true; const concurrency = opts.concurrency || 4;
function row2job(r) { if (!r) return null; return { ...r, subject: parseJson(r.subject_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), actions: parseJson(r.actions_json, null), log: parseJson(r.log_json, []) }; }
function emit(job) { for (const fn of listeners) { try { fn(job); } catch (e) { log('[jobs] listener error', e.message); } } }
function save(id, patch) {
const sets = []; const vals = [];
for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); }
sets.push('updated_at=?'); vals.push(nowIso());
store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id);
const j = engine.get(id); emit(j); return j;
}
/** 실행 가능한 작업 하나를 잠근다(큐 → 실행 중). 없으면 null */
function claim() {
if (running >= concurrency) return null;
const now = nowIso();
const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now);
if (!cand) return null;
const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes;
return got ? cand.id : null;
}
const engine = {
workerId,
register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; },
kinds: () => [...kinds.keys()],
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
/**
* 작업 넣기. idempotencyKey 가 같은 작업이 있으면 그것을 돌려준다(created=false).
* @returns {{job:object, created:boolean}}
*/
enqueue({ kind, subject = {}, idempotencyKey = null, createdBy = null, actorType = 'user', groupId = null, subjectType = null, subjectId = null, label = null, parentId = null, runAt = null, maxAttempts = null, initialStatus = null }) {
const def = kinds.get(kind); if (!def) throw new JobError('unknown_job_kind', `알 수 없는 작업 종류: ${kind}`);
return store.tx(() => {
if (idempotencyKey) { const ex = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idempotencyKey); if (ex) return { job: row2job(ex), created: false }; }
const id = newId('j_'); const now = nowIso();
const steps = (def.steps || []).map((s) => ({ id: s.id, title: s.title, state: 'pending' }));
store.run('INSERT INTO jobs(id, kind, status, label, subject_json, steps_json, attempt, max_attempts, next_run_at, idempotency_key, created_by, actor_type, group_id, subject_type, subject_id, parent_id, created_at, updated_at, log_json) VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?,?)',
id, kind, initialStatus || S.QUEUED, label || def.title || kind, toJson(subject), toJson(steps), maxAttempts || def.maxAttempts || 1, runAt || now, idempotencyKey, createdBy, actorType, groupId, subjectType, subjectId, parentId, now, now, '[]');
const job = engine.get(id); emit(job); return { job, created: true };
});
},
get: (id) => row2job(store.get('SELECT * FROM jobs WHERE id=?', id)),
list({ groupId, status, kind, subjectType, subjectId, parentId, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); }
if (kind) { w.push('kind=?'); p.push(kind); } if (subjectType) { w.push('subject_type=?'); p.push(subjectType); } if (subjectId) { w.push('subject_id=?'); p.push(subjectId); } if (parentId) { w.push('parent_id=?'); p.push(parentId); }
return store.all(`SELECT * FROM jobs ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map(row2job);
},
/** 자식 작업 요약(대상별 성공·실패·대기). 일부 실패는 'partial' */
childrenSummary(parentId) {
const kids = engine.list({ parentId, limit: 1000 }); const by = {}; for (const k of kids) by[k.status] = (by[k.status] || 0) + 1;
const failed = kids.filter((k) => ['failed', 'needs_user'].includes(k.status)).length; const done = kids.filter((k) => k.status === 'done').length;
return { total: kids.length, byStatus: by, overall: kids.length === 0 ? 'none' : done === kids.length ? 'done' : failed && done ? 'partial' : failed === kids.length ? 'failed' : 'running', children: kids.map((k) => ({ id: k.id, label: k.label, status: k.status, subject: k.subject, error: k.error, attempt: k.attempt, next_run_at: k.next_run_at })) };
},
/** 외부 대기·사용자 확인·일시 중지 상태의 작업을 다시 큐에 넣는다 */
resume(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.AWAITING_EXTERNAL, S.NEEDS_USER, S.PAUSED, S.AWAITING_APPROVAL, S.AWAITING_PAYMENT, S.NOT_READY, S.NEEDS_SETUP].includes(j.status)) throw new JobError('job_not_resumable', `지금 상태(${j.status})에서는 이어서 할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), wait_reason: null, user_message: null, actions_json: null, log_json: appendLog(j, `재개 (${by || 'system'})`) }); },
/** 실패한 작업 재시도(사용자·운영자 명시) */
retry(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_retryable', `지금 상태(${j.status})에서는 재시도할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), error: null, error_code: null, max_attempts: j.attempt + 1, log_json: appendLog(j, `재시도 요청 (${by || 'system'})`) }); },
pause(id, by = null) { const j = engine.get(id); if (!j || ![S.QUEUED, S.AWAITING_EXTERNAL].includes(j.status)) throw new JobError('job_not_pausable', '지금 상태에서는 일시 중지할 수 없습니다'); return save(id, { status: S.PAUSED, log_json: appendLog(j, `일시 중지 (${by || 'system'})`) }); },
/** 취소: 대기 상태면 즉시, 실행 중이면 취소 요청 표시(ctx.checkCancel 에서 멈춤) */
cancel(id, by = null) {
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
if (CANCELLABLE.has(j.status)) return save(id, { status: S.CANCELLED, ended_at: nowIso(), log_json: appendLog(j, `취소 (${by || 'system'})`) });
if (j.status === S.RUNNING) return save(id, { cancel_requested: 1, log_json: appendLog(j, `취소 요청 (${by || 'system'})`) });
throw new JobError('job_not_cancellable', `지금 상태(${j.status})에서는 취소할 수 없습니다`);
},
/** 되돌리기 */
async revert(id, by = null) {
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
const def = kinds.get(j.kind); if (!def || !def.revert) throw new JobError('job_not_revertible', '이 작업은 되돌리기를 지원하지 않습니다');
if (![S.DONE, S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_revertible', `지금 상태(${j.status})에서는 되돌릴 수 없습니다`);
save(id, { status: S.REVERTING, log_json: appendLog(j, `되돌리기 시작 (${by || 'system'})`) });
const ctx = makeCtx(engine.get(id), { reverting: true });
try { await def.revert(engine.get(id), ctx); return save(id, { status: S.REVERTED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs() }); }
catch (e) { return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'revert_failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso() }); }
},
/** 외부(HAL 등)에서 진행 상태를 밀어 넣을 때 */
update(id, patch) { return save(id, patch); },
/** 프로세스 시작 시: 이전 실행에서 RUNNING 으로 남은 작업(잠금 소유자 불문, 단일 프로세스 운영)을 QUEUED 로 되돌려 이어서 실행한다 */
recover() {
const stale = store.all('SELECT * FROM jobs WHERE status=?', S.RUNNING);
for (const r of stale) { const j = row2job(r); save(j.id, { status: S.QUEUED, locked_by: null, locked_at: null, next_run_at: nowIso(), log_json: appendLog(j, '프로세스 재시작 — 끝난 단계 다음부터 이어서 실행') }); }
return stale.length;
},
/** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */
async tick() { const id = claim(); if (!id) return null; running++; try { return await execute(id); } finally { running--; } },
/** 루프용: 작업을 집어 **기다리지 않고** 실행을 시작한다(느린 작업이 다른 작업을 막지 않게, 부모/자식 작업 교착 방지). 집은 작업 id 또는 null */
dispatch() { const id = claim(); if (!id) return null; running++; execute(id).catch((e) => log('[jobs] execute error', safeError(e))).finally(() => { running--; }); return id; },
/** 모든 큐 작업을 비울 때까지 tick(테스트용) */
async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; },
start() { stopped = false; engine.recover(); const loop = () => { if (stopped) return; try { let n = 0; while (running < concurrency && n < concurrency && engine.dispatch()) n++; } catch (e) { log('[jobs] dispatch error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; },
stop() { stopped = true; if (timer) clearTimeout(timer); },
};
function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; }
function makeCtx(job, { reverting = false } = {}) {
const steps = (job.steps || []).map((s) => ({ ...s })); const logs = (job.log || []).slice(-200);
const ctx = {
job, reverting, workerId,
/** 단계 실행. 이미 done 이면 저장된 결과를 돌려준다. 결과는 JSON 직렬화 가능해야 한다. */
async step(id, fn, title) {
let s = steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; steps.push(s); }
if (s.state === 'done' && !reverting) return s.result;
s.state = 'running'; s.startedAt = nowIso(); delete s.error; ctx.flush();
try { const result = await fn(); s.state = 'done'; s.endedAt = nowIso(); s.result = result === undefined ? null : redact(result); ctx.flush(); return result; }
catch (e) { if (e instanceof Signal) { s.state = e.signal === 'wait' ? 'waiting' : e.signal === 'needs_user' ? 'needs_user' : 'pending'; s.detail = e.payload.reason || e.payload.message || ''; ctx.flush(); throw e; } s.state = 'failed'; s.endedAt = nowIso(); s.error = safeError(e); ctx.flush(); throw e; }
},
log(line) { logs.push({ at: nowIso(), line: String(line).slice(0, 500) }); ctx.flush(); },
progress(pct) { store.run('UPDATE jobs SET progress=?, updated_at=? WHERE id=?', Math.max(0, Math.min(100, Math.round(pct))), nowIso(), job.id); },
eta(at) { store.run('UPDATE jobs SET eta_at=?, updated_at=? WHERE id=?', at || null, nowIso(), job.id); },
/** 외부 작업(사용자 DNS 변경·운영자 승인 등)을 기다린다. run() 은 여기서 끝나고 resume() 뒤 다시 불린다. */
waitExternal(reason, { resumeAt = null } = {}) { throw new Signal('wait', { reason, resumeAt }); },
/** 사용자 확인이 필요해 멈춘다. actions: [{id,label,description}] */
needsUser(message, actions = null) { throw new Signal('needs_user', { message, actions }); },
/** 일시 오류로 나중에 다시(지수 백오프 대신 지정 시각) */
retryLater(ms, reason) { throw new Signal('retry', { ms, reason }); },
/** 취소 요청이 들어왔으면 멈춘다 */
checkCancel() { const r = store.get('SELECT cancel_requested FROM jobs WHERE id=?', job.id); if (r && r.cancel_requested) throw new Signal('cancel', {}); },
steps: () => steps, logs: () => logs,
flush() { store.run('UPDATE jobs SET steps_json=?, log_json=?, updated_at=? WHERE id=?', toJson(steps), toJson(logs), nowIso(), job.id); },
};
return ctx;
}
async function execute(id) {
const job = engine.get(id); const def = kinds.get(job.kind);
if (!def) return save(id, { status: S.FAILED, error: `알 수 없는 작업 종류: ${job.kind}`, error_code: 'unknown_job_kind', ended_at: nowIso() });
const ctx = makeCtx(job);
try {
const result = await def.run(job, ctx);
return save(id, { status: S.DONE, result_json: redact(result === undefined ? null : result), progress: 100, steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null, error: null, error_code: null });
} catch (e) {
if (e instanceof Signal) {
// 단계 밖에서 대기·확인 신호가 나면 다음 미완료 단계에 그 상태를 표시해 화면이 "현재 단계" 를 보일 수 있게 한다
if (['wait', 'needs_user'].includes(e.signal)) { const st = ctx.steps(); if (!st.some((x) => ['waiting', 'needs_user'].includes(x.state))) { const nx = st.find((x) => x.state === 'pending'); if (nx) { nx.state = e.signal === 'wait' ? 'waiting' : 'needs_user'; nx.detail = e.payload.reason || e.payload.message || ''; } } }
if (e.signal === 'wait') return save(id, { status: S.AWAITING_EXTERNAL, wait_reason: e.payload.reason || null, eta_at: e.payload.resumeAt || null, next_run_at: e.payload.resumeAt || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
if (e.signal === 'needs_user') return save(id, { status: S.NEEDS_USER, user_message: e.payload.message || null, actions_json: e.payload.actions || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
if (e.signal === 'cancel') return save(id, { status: S.CANCELLED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null, cancel_requested: 0 });
if (e.signal === 'retry') { const at = new Date(Date.now() + (e.payload.ms || BACKOFF_BASE_MS)).toISOString(); return save(id, { status: S.QUEUED, next_run_at: at, wait_reason: e.payload.reason || null, max_attempts: Math.max(job.max_attempts, job.attempt + 1), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); }
}
const retryable = !!(e && e.retryable) || (def.retryable ? !!def.retryable(e) : false);
const fresh = engine.get(id);
if (retryable && fresh.attempt < fresh.max_attempts) {
const backoff = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (fresh.attempt - 1));
return save(id, { status: S.QUEUED, next_run_at: new Date(Date.now() + backoff).toISOString(), error: safeError(e), error_code: e.code || 'transient', steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
}
log(`[jobs] ${job.kind} ${id} 실패: ${safeError(e)}`);
return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null });
}
}
return engine;
}
function await0() { try { return (process.env.HOSTNAME || '').slice(0, 20); } catch (_) { return ''; } }
/** 화면용 요약: 현재 단계·완료 단계·다음 단계·재시도 정보(§2.2) */
export function describe(job) {
if (!job) return null;
const steps = job.steps || []; const doneSteps = steps.filter((s) => s.state === 'done'); const current = steps.find((s) => ['running', 'waiting', 'needs_user', 'failed'].includes(s.state)) || steps.find((s) => s.state === 'pending') || null;
const idx = current ? steps.indexOf(current) : steps.length; const next = steps[idx + 1] || null;
return {
id: job.id, kind: job.kind, label: job.label, status: job.status, progress: job.progress, attempt: job.attempt, maxAttempts: job.max_attempts, nextRunAt: job.next_run_at, etaAt: job.eta_at || null,
etaUnknownReason: job.eta_at ? null : '외부 반영 시간에 따라 달라져 산정할 수 없습니다', updatedAt: job.updated_at, startedAt: job.started_at, endedAt: job.ended_at,
currentStep: current ? { id: current.id, title: current.title, state: current.state, detail: current.detail || current.error || null } : null,
completedSteps: doneSteps.map((s) => ({ id: s.id, title: s.title })), nextStep: next ? { id: next.id, title: next.title } : null, steps,
waitReason: job.wait_reason, userMessage: job.user_message, actions: job.actions, error: job.error, errorCode: job.error_code,
canCancel: CANCELLABLE.has(job.status) || job.status === 'running', canRetry: ['failed', 'needs_user'].includes(job.status), canResume: ['awaiting_external', 'needs_user', 'paused'].includes(job.status), terminal: TERMINAL.has(job.status),
stale: job.status === 'running' && Date.now() - Date.parse(job.updated_at) > 10 * 60 * 1000,
};
}
+25
View File
@@ -0,0 +1,25 @@
/**
* lib/kst.mjs — 한국 표준시(KST, UTC+9) 월 경계 계산.
*
* 월간 사용량(웹 아웃바운드·메일 통수)은 "KST 월" 기준으로 집계·초기화한다(§13.3·§13.4·§17.2).
* UTC 로 저장된 시각을 KST 월로 나누려면 9시간을 더해 날짜를 구하고, 월 시작을 다시 UTC 로 돌린다.
* 테스트가 고정 시각으로 검증할 수 있게 모든 함수는 `at` 을 인자로 받는다(AriMail usage.test 의 월 경계 오류 재발 방지).
*/
const KST_OFFSET_MS = 9 * 3600 * 1000;
/** at 이 속한 KST 월의 시작(UTC ISO). 예: 2026-10-31T16:00:00Z(=11-01 01:00 KST) → 2026-10-31T15:00:00.000Z */
export function monthStart(at = new Date()) {
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), 1) - KST_OFFSET_MS).toISOString();
}
/** 다음 KST 월 시작(UTC ISO) = 월간 사용량 초기화 시각 */
export function nextMonthStart(at = new Date()) {
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth() + 1, 1) - KST_OFFSET_MS).toISOString();
}
/** KST 기준 YYYY-MM-DD */
export function kstDate(at = new Date()) { return new Date(new Date(at).getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); }
/** KST 기준 YYYY-MM */
export function kstMonth(at = new Date()) { return kstDate(at).slice(0, 7); }
/** 화면 표시용 "YYYY-MM-DD HH:mm (KST)" */
export function fmtKst(at) { if (!at) return ''; const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; }
+59
View File
@@ -0,0 +1,59 @@
/**
* lib/notify.mjs — 알림 센터(§24.1).
*
* 분류(category): billing | site | domain | mail | usage | backup | relocation | security | incident | membership | ops
* 중요도(severity): info | warning | critical. mandatory=1 은 필수 알림(서비스 중단·보안·결제·데이터 삭제 예정) → 외부 이메일도 보낸다.
* 같은 원인의 반복 알림은 incident_key 로 하나의 사건으로 묶어 count·last_at·title/body 를 갱신한다(§24.1).
* link 는 사용자가 조치할 정확한 화면(§2.3). group_id 가 없으면 운영자 알림.
* 외부 이메일은 setMailer(fn) 로 끼운 발송기(포털이 AriMail/Resend 로 보냄)가 담당하며, 실패해도 알림 자체는 남는다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_notify_0001', sql: `
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY, group_id TEXT, user_id TEXT, category TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', mandatory INTEGER NOT NULL DEFAULT 0,
incident_key TEXT, title TEXT NOT NULL, body TEXT, link TEXT, subject_type TEXT, subject_id TEXT, action_required TEXT,
state TEXT NOT NULL DEFAULT 'open', count INTEGER NOT NULL DEFAULT 1, first_at TEXT NOT NULL, last_at TEXT NOT NULL, read_at TEXT, resolved_at TEXT, email_sent_at TEXT, email_error TEXT, detail_json TEXT
);
CREATE INDEX IF NOT EXISTS notif_group ON notifications(group_id, state, last_at);
CREATE INDEX IF NOT EXISTS notif_incident ON notifications(incident_key, state);` }];
let mailer = null;
/** 외부 이메일 발송기 등록: async ({groupId, userId, title, body, link}) => void */
export function setMailer(fn) { mailer = fn; }
/**
* 알림 만들기(또는 같은 사건 갱신).
* @returns {{id:string, merged:boolean}}
*/
export function notify(store, n) {
const now = nowIso(); const detail = toJson(redact(n.detail || null));
if (n.incidentKey) {
const open = store.get('SELECT id, count FROM notifications WHERE incident_key=? AND state=\'open\'', n.incidentKey);
if (open) { store.run('UPDATE notifications SET count=count+1, last_at=?, title=?, body=?, link=COALESCE(?, link), severity=?, action_required=?, detail_json=?, read_at=NULL WHERE id=?', now, n.title, n.body || null, n.link || null, n.severity || 'info', n.actionRequired || null, detail, open.id); return { id: open.id, merged: true }; }
}
const id = newId('n_');
store.run('INSERT INTO notifications(id, group_id, user_id, category, severity, mandatory, incident_key, title, body, link, subject_type, subject_id, action_required, state, count, first_at, last_at, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,\'open\',1,?,?,?)',
id, n.groupId || null, n.userId || null, n.category, n.severity || 'info', n.mandatory ? 1 : 0, n.incidentKey || null, n.title, n.body || null, n.link || null, n.subjectType || null, n.subjectId || null, n.actionRequired || null, now, now, detail);
// 필수 알림·critical 은 외부 이메일(§24.1). 발송 실패는 email_error 에 남긴다(알림은 유지).
if ((n.mandatory || n.severity === 'critical' || n.email) && mailer) {
Promise.resolve().then(() => mailer({ groupId: n.groupId, userId: n.userId, title: n.title, body: n.body, link: n.link }))
.then(() => store.run('UPDATE notifications SET email_sent_at=? WHERE id=?', nowIso(), id))
.catch((e) => { try { store.run('UPDATE notifications SET email_error=? WHERE id=?', String(e.message || e).slice(0, 300), id); } catch (_) {} });
}
return { id, merged: false };
}
/** 사건 종료(같은 incident_key 의 열린 알림을 resolved 로) */
export function resolveIncident(store, incidentKey, note = '') { return store.run('UPDATE notifications SET state=\'resolved\', resolved_at=?, body=CASE WHEN ?<>\'\' THEN ? ELSE body END WHERE incident_key=? AND state=\'open\'', nowIso(), note, note, incidentKey).changes; }
/** 목록(그룹 기준, 최신순). unreadOnly / category 필터 */
export function listNotifications(store, { groupId, userId, category, unreadOnly = false, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId !== undefined) { w.push(groupId === null ? 'group_id IS NULL' : '(group_id=? OR (group_id IS NULL AND user_id=?))'); if (groupId !== null) p.push(groupId, userId || ''); }
if (category) { w.push('category=?'); p.push(category); }
if (unreadOnly) w.push('read_at IS NULL');
return store.all(`SELECT * FROM notifications ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY last_at DESC LIMIT ?`, ...p, Math.min(limit, 500)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
export function markRead(store, id, groupId) { return store.run('UPDATE notifications SET read_at=? WHERE id=? AND (group_id=? OR group_id IS NULL) AND read_at IS NULL', nowIso(), id, groupId || null).changes; }
export function unreadCount(store, groupId) { return store.get('SELECT COUNT(*) AS n FROM notifications WHERE group_id=? AND read_at IS NULL AND state=\'open\'', groupId)?.n || 0; }
+34
View File
@@ -0,0 +1,34 @@
/**
* lib/provenance.mjs — 도입 소스 출처(IMPORTED_SOURCES.json) 검사. 릴리스 build·register --approve·HAL 승인이 공유하는 순수 규칙(0.2.0 검토 수정, 차단 항목 11).
*
* 깨끗한 출처(clean provenance)란: 각 소스가 40자리 고정 커밋(commit)을 가지고, 파일 목록이 비어 있지 않으며(각 항목에 from·to·origHash),
* upstream 기록이 있으면 완전하고(version·branch·headCommit) headCommit 이 commit 과 같고 workingTreeDirty 가 정확히 false 인 것.
* - `--allow-dirty` 로 참조 저장소의 작업 트리 변경을 함께 가져온 기록(workingTreeDirty=true)은 "원본 저장소에 아직 없는 코드" 를 담고 있어 릴리스로 승인할 수 없다.
* 원본 저장소에 커밋되면 pin 을 올려 `scripts/import-sources.mjs` 를 다시 실행해 깨끗한 기록을 만든다.
* - upstream 칸이 없는 기록은 0.1.x 의 import-sources 가 만든 것이다(그 스크립트는 깨끗한 고정 커밋 체크아웃에서만 가져왔다) → legacy 로 표시하고 받아들인다.
* 모르는 형태·빈 목록은 모두 "불완전" 으로 보아 거절한다(fail-closed).
* @param {object} manifest IMPORTED_SOURCES.json 내용
* @returns {{ ok: boolean, problems: string[], sources: { name: string, commit: string|null, upstream: object|null, legacy: boolean }[] }}
*/
export function checkProvenance(manifest) {
const problems = []; const sources = [];
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.sources)) return { ok: false, problems: ['IMPORTED_SOURCES: sources 목록이 없습니다(불완전한 기록)'], sources };
if (!manifest.sources.length) return { ok: false, problems: ['IMPORTED_SOURCES: 도입 소스가 하나도 기록되지 않았습니다(불완전한 기록)'], sources };
for (const s of manifest.sources) {
const name = (s && typeof s.name === 'string' && s.name) || '(이름 없음)';
const up = s && s.upstream && typeof s.upstream === 'object' ? s.upstream : null;
const summary = { name, commit: s && typeof s.commit === 'string' ? s.commit : null, upstream: up ? { version: up.version ?? null, branch: up.branch ?? null, headCommit: up.headCommit ?? null, workingTreeDirty: up.workingTreeDirty } : null, legacy: !up };
sources.push(summary);
if (!s || typeof s !== 'object') { problems.push(`${name}: 기록 형식이 올바르지 않습니다`); continue; }
if (!/^[0-9a-f]{40}$/.test(String(s.commit || ''))) problems.push(`${name}: 고정 커밋(commit)이 40자리 커밋 해시가 아닙니다 (${String(s.commit || '없음').slice(0, 12)})`);
if (!Array.isArray(s.files) || !s.files.length) problems.push(`${name}: 가져온 파일 목록이 비어 있습니다(불완전한 기록)`);
else if (s.files.some((f) => !f || typeof f.from !== 'string' || typeof f.to !== 'string' || typeof f.origHash !== 'string')) problems.push(`${name}: 파일 기록에 from·to·origHash 가 빠진 항목이 있습니다`);
if (!up) continue; // 0.1.x 기록: 깨끗한 고정 커밋 체크아웃만 받던 스크립트가 만든 것(legacy)
if (up.workingTreeDirty !== false) problems.push(`${name}: upstream.workingTreeDirty=${JSON.stringify(up.workingTreeDirty)} — 원본 저장소에 커밋되지 않은 작업 트리 변경을 함께 가져온 기록은 릴리스로 승인할 수 없습니다. 원본에 커밋한 뒤 pin 을 올려 scripts/import-sources.mjs 를 다시 실행하세요`);
if (typeof up.version !== 'string' || !up.version) problems.push(`${name}: upstream.version 이 없습니다(불완전한 기록)`);
if (typeof up.branch !== 'string' || !up.branch) problems.push(`${name}: upstream.branch 가 없습니다(불완전한 기록)`);
if (!/^[0-9a-f]{40}$/.test(String(up.headCommit || ''))) problems.push(`${name}: upstream.headCommit 이 없거나 형식이 틀립니다(불완전한 기록)`);
else if (up.headCommit !== s.commit) problems.push(`${name}: upstream.headCommit(${String(up.headCommit).slice(0, 7)}) ≠ 고정 커밋 commit(${String(s.commit).slice(0, 7)}) — 기록이 어긋났습니다. scripts/import-sources.mjs 를 다시 실행하세요`);
}
return { ok: problems.length === 0, problems, sources };
}

Some files were not shown because too many files have changed in this diff Show More