HAL 0.4.1: 서버 등록 TLS 수정·"이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음

- hal/tls.mjs: 공개 주소 TLS 신뢰 확인(시스템 CA → 실패면 체인·공개키 핀), 신뢰 앵커(체인 자체 서명 / <data>/tls/hal-ca.pem), 10분 캐시, 핀 설치 명령(curl -k --pinnedpubkey … --hal-pin). 공개 경로 GET /agent/v1/hal-ca.pem · /agent/v1/tls, CLI tls-probe
- deploy/agent-install.sh: --hal-pin(핀으로 CA 받기)·--ca·--insecure, curl --cacert / Node NODE_EXTRA_CA_CERTS / register --ca / 유닛 Environment, 연결 실패 원인·조치 한 줄, 이미 등록된 서버 안내
- agent/halca.mjs + agent/index.mjs·config.mjs: caFile 런타임 적용(tls.setDefaultCACertificates), fetch 실패를 읽을 수 있는 한 줄로(종료 1), 이미 등록(종료 2, --force), HAL 거부(종료 3), heartbeat observability → hawkeye
- core.registerAgent: 같은 서버의 두 번째 토큰 409 server_already_registered / 쓴 토큰 409 token_used("이미 등록된 서버"), 거부 토큰 미소비, 재등록 허용(replace: 기존 Agent 해지, 감사 agent.replace), 마이그레이션 hal_0011_token_replace, API/CLI --replace --json(재인증), ops.js 재등록 확인·핀 안내
- deploy/hal-host-agent.sh(server-create --profile hal → server-token --json → agent-install loopback → server-approve), bootstrap-hal.sh 6/7 단계(--no-agent), CLI server-create·server-approve, SERVER_PROFILES.hal
- core.deploymentStatus/selfHealth.deployment, 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기: …" / "진행 중", 배포 릴리스 화면 문구, heartbeat canonicalModuleName(0.3.17 잔여 결함)
- 개발 지시 v0.4.1(Oz)·VERSION_HISTORY 0.4.1·README·docs/HAL-AGENT §6·RUNBOOK §11·AI 스킬, 도입 모듈 변경 없음(Webmaker 1.9.0·AriMail 1.1.0)
- test/hal-041.test.mjs RED → GREEN 7/7(실제 자체 서명 TLS 픽스처·별도 프로세스 CLI), 기존 배너·프로필·버전 테스트 갱신, 전체 32파일 통과, 로컬 E2E 보고서 2026-11-06

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-11-06 08:40:09 +00:00
co-authored by Claude Fable 5.1
parent 0f5e01a4cb
commit 2b31543a86
34 changed files with 4639 additions and 72 deletions
+37
View File
@@ -0,0 +1,37 @@
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.4.1
**작성자:** Oz
**작성일:** 2026-11-06
**이전 버전:** v0.4.0(통합 설정 관제, Oz) · v0.3.17 · v0.3.10 · v0.3.9 · v0.3.2 · v0.3.1 · v0.3.0 · v0.2.1 · v0.2.0 · v0.1.4 · v0.1.1
**이번 개정:** 서버 등록(Agent 설치) 때의 TLS 오류·"이미 등록된 서버" 안내 수정, HAL 배포가 운영용 Agent(HawkEye 포함)를 함께 설치해 "부트스트랩 미완료" 상태를 만들지 않음. 지시문은 "HAL 0.4.0" 이라 썼으나 0.4.0 은 이미 승인·배포된 불변 릴리스(bear hal21, 2026-10-29)이므로 이번 변경은 **HAL 0.4.1** 로 낸다(가운데 버전은 0.4 그대로 — 0.4.0 대응 모듈과 호환). 도입 모듈(Webmaker 1.9.0·AriMail 1.1.0, halCompat 0.4.0)은 변경이 없어 올리지 않고 배포하지 않는다("Do not deploy if it was not modified").
## 1. 사용자 요구(Oz 원문, 약화 금지)
> Based on https://source.ibit.kr/OZ-Group/AriWeb-Suite, implement the requirements listed at the end of this message. After development, push all changes to the repository and merge them into main. Deploy the development into the Ariweb suite. Do not deploy if it was not modified. Use HAL to deploy, operate, monitor, update, and recover every remaining Ariweb Suite service on the required servers if possible. After development and merge, deploy it to "https://hal21.ariweb.net/"
> 필요한 경우 Arimail, Webmaker, backup 등 필요한 모듈을 모두 업그레이드합니다. 각각의 모듈의 버전은 중간 버전을 1 올립니다. 또한 각 모듈은 HAL 0.4.0 대응 버전임을 명시합니다. This release is version HAL 0.4.0.
1. **서버 등록시 에러 수정.** 서버 등록을 위한 curl 명령에서 아래와 같은 에러가 발생함. 이미 등록되어 있는 경우에 발생할 수 있으나, 그럴 경우에라도 이미 등록된 서버라는 메시지가 나와야 함. 새 서버 등록시에도 에러 발생함. 원인 찾아서 수정하세요.
- 기존 서버(WS1): `curl -fsSL https://hal.local.test/agent/v1/install.sh | bash -s -- --hal https://hal.local.test --token …` → `== 등록` 단계에서 `TypeError: fetch failed … [cause]: Error: self-signed certificate in certificate chain (SELF_SIGNED_CERT_IN_CHAIN)` (agent/index.mjs:28, Node v22.23.4).
- 새 서버(WS2): `curl: (60) SSL certificate problem: self-signed certificate in certificate chain`.
2. **HAL 배포시 기본 Agent 등 필요한 도구 동시 배포 및 에러 수정.** HAL 만 배포한 상태에서는 `! 부트스트랩 미완료(HAL 실행 버전 0.4.0, 첫 Agent 미등록) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)` 가 나온다. HAL 을 배포할 경우 Web, Mail, Backup, Portal 을 제외한 나머지 운영을 위한 에이전트 등은 함께 배포되어야 하며, '부트스트랩 미완료' 상태를 만들지 않아야 한다. '부트스트랩' 은 완료되고, '기능단위 서버(Web, Webmaker, Mail, HawkEye, Backup, Portal 등)의 배포 대기중이라거나 배포가 필요하다' 는 등의 메시지가 되어야 한다.
## 2. 원인(점검 결과)
1. HAL 공개 주소(`hal.local.test`)의 인증서가 사설 CA·자체 서명이라 시스템 CA 로 검증되지 않는다. 설치 명령(`curl -fsSL …`)·설치 스크립트(`curl -fsS $HAL/api/health`, bootstrap-artifact)·Agent 등록(`fetch`)·Agent 서비스(heartbeat) 모두 공개 신뢰 인증서를 전제했다. WS1 은 curl 이 이미 성공했던(또는 `-k` 로 받은) 상태에서 Node `fetch` 가 거부했고, WS2 는 curl 단계에서 끊겼다. "이미 등록된 서버" 와는 무관한 TLS 신뢰 문제이며, 설치 스크립트는 `agent.json` 이 있을 때만 "이미 등록됨" 을 알리고 HAL 은 쓴 토큰·같은 서버의 두 번째 토큰을 구분해 알리지 않았다.
2. 부트스트랩(`bootstrap-hal.sh`)은 HAL 설치·릴리스 등록·설정 토큰까지만 하고, 첫 Agent 설치·승인은 운영자 수동 단계였다. 그 사이 HAL 은 `bootstrap_state.completed_at` 이 비어 "부트스트랩 미완료" 배너를 그리고 포털 API·모듈 배포를 닫는다.
## 3. 필수 동작(계약)
1. **TLS 신뢰 확인·핀 설치 명령** `hal/tls.mjs`: HAL 이 자기 공개 주소에 접속해 시스템 CA 신뢰 여부를 확인한다(기동 때·10분마다·토큰 발급 때, `d.tlsInfo`). 신뢰되지 않으면 서버 공개키 핀(`sha256//<SPKI sha256 base64>`, curl `--pinnedpubkey` 형식)과 신뢰 앵커(체인 안의 자체 서명 인증서, 또는 운영자 지정 `<data>/tls/hal-ca.pem`)를 구한다. 설치 명령은 `curl -fsSL -k --pinnedpubkey '<핀>' <HAL>/agent/v1/install.sh | bash -s -- --hal <HAL> --token <t> --hal-pin '<핀>'` 이 된다(`-k` 는 CA 검증만 끄고 핀은 강제된다). 공개 경로 `GET /agent/v1/hal-ca.pem`(신뢰 앵커 PEM) · `GET /agent/v1/tls`(상태). CLI `tls-probe`.
2. **설치 스크립트** `deploy/agent-install.sh`: `--hal-pin`(핀으로 CA 받기) · `--ca <pem>` · `--insecure`(경고). 받은 CA 는 `<app-dir>/data/hal-ca.pem` 에 두고 이후 curl 은 `--cacert`, Node 는 `NODE_EXTRA_CA_CERTS` + `register --ca`(agent.json `caFile`), 유닛은 `Environment=NODE_EXTRA_CA_CERTS=…`(자기 갱신 때 보존). HAL 연결 실패는 원인(인증서/연결)과 조치를 한 줄로. 이미 등록된 서버(`agent.json`)는 재등록하지 않고 "이미 등록된 서버" 안내.
3. **Agent** `agent/halca.mjs`: 기동 때 `caFile` 을 TLS 기본 CA 에 더한다(`tls.setDefaultCACertificates`, Node 22.19+/24). `register` 는 fetch 실패를 스택 대신 원인·조치 한 줄로(종료 1), 이미 등록된 Agent 가 있으면 "이미 등록된 서버"(종료 2, `--force` 로만 재등록), HAL 이 `server_already_registered`/`token_used` 로 거부하면 종료 3. heartbeat 의 기능 이름 `observability` → `hawkeye`.
4. **HAL 등록부** `core.registerAgent`: 토큰에 묶인 서버(또는 같은 호스트 이름의 서버)에 살아 있는 Agent 가 있으면 409 `server_already_registered`("이미 등록된 서버입니다(서버·Agent·등록 시각) — 다시 설치할 필요가 없습니다 …"). 쓴 토큰 재사용은 409 `token_used`(같은 문구 + 그 토큰으로 등록된 Agent). 운영자가 "재등록 허용"(`replace`, 재인증) 토큰을 발급하면 기존 Agent 해지(`agent.replace` 감사) 후 새 Agent. 마이그레이션 `hal_0011_token_replace`. CLI `server-token --replace --json`.
5. **부트스트랩이 Agent·HawkEye 를 함께 설치** `deploy/hal-host-agent.sh`: HAL 서버 자신을 관리 서버(역할 control, 프로필 `hal` = agent + hawkeye)로 만들고(`server-create`), 토큰(`server-token --json`)으로 loopback(`http://127.0.0.1:8900`, DNS·인증서 무관) Agent 를 설치·등록한 뒤 승인(`server-approve`)해 부트스트랩을 닫는다. `bootstrap-hal.sh` 6/7 단계에서 부른다(`--no-agent` 로 끌 수 있음). 기존 HAL 서버(0.4.0 이하 설치)는 같은 스크립트를 직접 실행한다. Web·Mail·Backup·Portal 은 설치하지 않는다.
6. **메시지**: 부트스트랩 완료 뒤 배너는 "부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업 — 역할별 통합 배포로 …"(`core.deploymentStatus`, `/api/health.deployment`, 배포 릴리스 화면). 완료 전에는 "부트스트랩 진행 중(HAL 서버의 Node Agent 미설치) — hal-host-agent.sh 안내". heartbeat 의 옛 이름은 `canonicalModuleName` 으로 hawkeye 행에 모은다(0.3.17 잔여 결함).
## 4. TDD·검증
- `test/hal-041.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현(TLS 확인·핀·CA 경로 / 설치 명령 / 이미 등록 409·재등록 허용·재인증 / Agent CLI 실제 TLS 픽스처(자체 서명 hal.local.test)로 오류 문구·--ca·status 런타임 CA / 부트스트랩 완료·배너·hawkeye / CLI server-create·server-token --json·server-approve / 스크립트·문서·버전). 기존 버전 검사 테스트 0.4.0 → 0.4.1.
- 배포 뒤 bear read-back(health.deployment·배너·TLS 상태), HAL 서버 자신의 Agent 설치(`hal-host-agent.sh`), HAL 작업으로 agent 0.4.1 을 운영 서버(srv1953374·jitsi)에 배포하고 hawkeye 행 갱신을 확인한다. Webmaker·AriMail·포털은 변경 없음 → 배포하지 않음.
## 5. 버전·배포
- HAL/Agent 0.4.1: package.json·README·VERSION_HISTORY·개발 지시·docs(HAL-AGENT·RUNBOOK). 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 배포 → HAL 서버 Agent → read-back → HAL 로 agent 배포.
## 6. 완료 조건
변경 파일, RED/GREEN·전체 테스트, 커밋·병합 커밋, artifact sha256, 배포 read-back, Agent 배포 결과, 운영자 조치(사설 CA 운영 절차 포함), 알려진 제한을 보고한다.
+2 -1
View File
@@ -3,7 +3,8 @@
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
- 버전: **0.4.0** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
- 버전: **0.4.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
- 0.4.1: 사설 CA·자체 서명 HAL 주소의 서버 등록(핀 설치 명령·`/agent/v1/hal-ca.pem`), "이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음(`deploy/hal-host-agent.sh`): [docs/HAL-AGENT.md §6](docs/HAL-AGENT.md)
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.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)
- HAL 통합 설정 관제(0.4.0 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리): [docs/HAL-SETTINGS.md](docs/HAL-SETTINGS.md)
+43
View File
@@ -1,5 +1,48 @@
# VERSION_HISTORY
## 0.4.1 (2026-11-06)
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.4.1.md`, 2026-11-06) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-bootstrap-tls-v0.4.1`, 기준 main `0f5e01a` = 0.4.0). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포·HAL 서버 Agent·HAL 작업으로 agent 배포까지 수행(아래 "배포").
- **이전 버전**: 0.4.0(2026-10-29). 지시문은 "HAL 0.4.0" 이라 썼으나 0.4.0 은 이미 승인·배포된 불변 릴리스(`release_immutable`)라 **0.4.1** 로 낸다. 도입 모듈 **Webmaker 1.9.0 · AriMail 1.1.0(halCompat 0.4.0) 변경 없음** — 올리지 않고 배포하지 않는다(지시: "Do not deploy if it was not modified"). Node Agent 0.4.1.
### 요구(사용자 요구 — 약화 없이 반영)
1. 서버 등록(Agent 설치) curl 명령의 오류 수정 — 기존 서버(WS1) `== 등록` 에서 `TypeError: fetch failed … SELF_SIGNED_CERT_IN_CHAIN`, 새 서버(WS2) `curl: (60) SSL certificate problem: self-signed certificate in certificate chain`. 이미 등록된 서버면 그렇다는 메시지가 나와야 한다.
2. HAL 배포 때 Web·Mail·Backup·Portal 을 제외한 운영용 Agent 등을 함께 배포해 "부트스트랩 미완료" 상태를 만들지 않는다. 부트스트랩은 완료되고 "기능 단위 서버(Web·Webmaker·Mail·HawkEye·Backup·Portal 등) 배포 대기/필요" 메시지가 되어야 한다.
### 원인
- (1) HAL 공개 주소의 인증서가 사설 CA·자체 서명이면 시스템 CA 로 검증되지 않는다. 설치 명령·설치 스크립트(curl)·Agent 등록(`fetch`)·Agent 서비스 모두 공개 신뢰 인증서를 전제했고, 오류는 스택으로만 나왔다. HAL 은 쓴 토큰·같은 서버의 두 번째 토큰을 "이미 등록된 서버" 로 구분해 알리지 않았다(두 번째 Agent 를 조용히 만들었다).
- (2) `bootstrap-hal.sh` 는 HAL 까지만 설치했고 첫 Agent 설치·승인은 운영자 수동 단계라 그 사이 `bootstrap_state.completed_at` 이 비어 있었다.
### 추가한 기능
- `hal/tls.mjs`: 공개 주소 TLS 신뢰 확인(`probePublicTls` — 시스템 CA 검증 → 실패면 체인·공개키 핀 수집), 신뢰 앵커(체인 안의 자체 서명 인증서, 또는 운영자 지정 `<data>/tls/hal-ca.pem` 우선), `ensureTlsInfo`(10분 캐시, 기동·주기·토큰 발급 때), `installCommand`(신뢰되지 않으면 `curl -fsSL -k --pinnedpubkey '<핀>' … --hal-pin '<핀>'`). 공개 경로 `GET /agent/v1/hal-ca.pem`(신뢰 앵커 PEM, 비밀 없음) · `GET /agent/v1/tls`. CLI `tls-probe`.
- `deploy/agent-install.sh`: `--hal-pin`(핀으로 CA 받아 `<app-dir>/data/hal-ca.pem`) · `--ca <pem>` · `--insecure`(경고). 이후 curl `--cacert`, Node `NODE_EXTRA_CA_CERTS` + `register --ca`, 유닛 `Environment=NODE_EXTRA_CA_CERTS=…`(자기 갱신 때 Environment 줄 보존). HAL 연결 실패는 원인·조치 한 줄(인증서 문제면 핀 명령/`--ca` 안내). 이미 등록된 서버(`agent.json`)는 "이미 등록된 서버(Agent·HAL·등록 시각) — 코드·유닛만 갱신" 안내, 새 CA 가 생기면 `caFile` 만 보완.
- `agent/halca.mjs`: `applyHalCa`(기동 때 `caFile` 을 TLS 기본 CA 에 더함 — `tls.setDefaultCACertificates`, Node 22.19+/24; 유닛 env 가 있으면 그대로), `friendlyFetchError`(인증서 코드 → "HAL 인증서를 확인할 수 없습니다(코드) — 핀 명령/--ca", 연결 코드 → "연결할 수 없습니다"). `agent/config.mjs` `caFile`(`ARIWEB_HAL_CA` > agent.json).
- `core.registerAgent`: 토큰에 묶인 서버(또는 같은 호스트 이름의 서버)에 살아 있는 Agent 가 있으면 409 `server_already_registered`("이미 등록된 서버입니다(서버·Agent·등록 시각) — 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 재등록 허용 토큰"). 쓴 토큰 재사용은 409 `token_used`(같은 문구 + 그 토큰으로 등록된 Agent). 거부된 토큰은 소비되지 않는다. 재등록 허용 토큰(`replace`)이면 기존 Agent 해지(비밀 제거·작업 취소, 감사 `agent.replace`) 후 새 Agent. 마이그레이션 `hal_0011_token_replace`(`registration_tokens.replace_existing`). 발급 응답에 `tls`·`existingAgent`·`replace`.
- 운영자 API `POST /ops/api/servers/:id/token {replace}`(replace 는 재인증), 화면(ops.js)은 Agent 가 있는 서버면 "재등록 허용" 여부를 묻고, 사설 CA 면 핀 안내를 함께 보인다. CLI `server-token --replace --json`(스크립트용 한 줄 JSON, 기존 Agent 주의 출력).
- `deploy/hal-host-agent.sh`: HAL 서버 자신을 관리 서버(역할 control, 프로필 `hal` = agent + hawkeye)로 만들고(`server-create --profile hal`) 토큰(`server-token --json`)으로 loopback(`http://127.0.0.1:8900`, DNS·인증서 무관) Agent 를 설치·등록 → `server-approve` 로 승인해 부트스트랩을 닫는다. 멱등(이미 `agent.json` 이 있으면 승인만). CLI `server-create`·`server-approve`.
- `deploy/bootstrap-hal.sh`: 6/7 단계에서 `hal-host-agent.sh` 를 부른다(`--no-agent` 로 이전 방식). 5/7 뒤 `tls-probe` 로 공개 주소 신뢰 상태를 보인다. 완료 안내가 "기능 단위 서버를 역할별 통합 배포로 추가" 로 바뀐다.
- `SERVER_PROFILES.hal`(HAL 서버: agent·hawkeye 만), `core.deploymentStatus`/`FEATURE_MODULES`(portal·webmaker·arimail·backup 의 배치 여부), `selfHealth().deployment {pending, deployed, agents}`, `/ops/api/releases.deployment`, `render.moduleLabel`.
### 수정한 기존 기능
- 운영자 콘솔 배너: 부트스트랩 완료 뒤 "ⓘ 부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업 — 역할별 통합 배포(새 서버)로 …"(배포 릴리스 화면 링크). 완료 전은 "! 부트스트랩 진행 중(HAL 서버의 Node Agent 미설치) — bootstrap-hal.sh/hal-host-agent.sh 안내". 배포 릴리스 화면 "부트스트랩 미완료" → "진행 중(HAL 서버 Agent 등록·승인 전)" / "완료 · 기능 단위 서버 배포 대기: …".
- `core.heartbeat`: 모듈 이름을 `canonicalModuleName` 으로 정규화(0.4.0 이하 Agent 의 `observability` 보고를 `hawkeye` 행으로, 옛 행 삭제) — 0.3.17 잔여 결함. Agent 0.4.1 은 `hawkeye` 로 보고.
- `agent/index.mjs register`: 이미 등록된 Agent 가 있으면 조용히 덮어쓰지 않고 "이미 등록된 서버"(종료 2, `--force`), fetch 실패는 한 줄 원인·조치(종료 1), HAL 거부(`server_already_registered`/`token_used`)는 종료 3 — 설치 스크립트가 "재설치가 필요 없습니다" 로 안내. `status`·기동 로그에 CA 적용 상태.
- `MODULE_GUIDES.agent.install` 문구, 버전 검사 테스트 0.4.0 → 0.4.1.
### 고객 화면
- 변경 없음.
### 운영자 화면
- 배너·배포 릴리스 화면 문구(위), 서버 등록 토큰 발급의 재등록 확인·핀 안내.
### 보안 결정
- 핀(`sha256//…`)·CA 인증서는 공개 정보다. 개인키·토큰·Agent 비밀은 어디에도 출력되지 않는다(`hal-host-agent.sh` 는 토큰을 변수로만 쓴다). `-k` 는 `--pinnedpubkey` 와 함께만 쓰며 핀이 다르면 curl 이 자료를 받기 전에 끊는다(중간자 방지). `--insecure` 는 경고를 내는 실험실 전용.
- 재등록 허용(기존 Agent 해지)은 운영자 재인증(10분) + 확인. 거부된 등록은 토큰을 소비하지 않는다. HAL 서버 Agent 는 loopback 평문이지만 모든 요청이 HMAC 서명이다.
- 부트스트랩이 첫 Agent 를 스스로 승인한다 — "사람 결정" 은 `bootstrap-hal.sh` 실행 자체(sudo 가능한 운영자)이며 감사에 `cli:bootstrap` 으로 남는다.
### 테스트
- `test/hal-041.test.mjs` RED(모듈·스크립트·문서 없음) → GREEN 7/7: 실제 자체 서명 HTTPS 픽스처(hal.local.test)로 TLS 확인·핀·CA 경로·설치 명령, 이미 등록 409·재등록 허용·재인증, Agent CLI(별도 프로세스) 오류 문구·`--ca`·`status` 런타임 CA, 부트스트랩 완료·배너·hawkeye, CLI 3종, 스크립트·문서·버전. 기존 테스트 갱신: 배너 문구(`hal-ui`·`hal-reauth-banner`: "부트스트랩 미완료(…첫 Agent 미등록)" → "부트스트랩 진행 중(…HAL 서버의 Node Agent 미설치)"), 프로필 목록에 `hal`(`hal-0317`), 버전 0.4.1(`hal-ai`·`webmaker-visual-editor` 등). 전체 Suite 32파일 통과. 로컬 E2E(실제 HAL 프로세스 + 자체 서명 TLS 앞단, sudo/systemctl 셈): 핀 없는 curl → 60(WS2 재현), 핀 명령 → CA 받기·등록·유닛 `NODE_EXTRA_CA_CERTS`, 같은 명령 재실행 → "이미 등록된 서버"(종료 0), 새 토큰 → HAL 409 안내(종료 1), `hal-host-agent.sh` → 부트스트랩 완료·pending 4.
## 0.4.0 (2026-10-29)
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.4.0.md`, 2026-10-29) · 구현 — Claude Opus 5.5(브랜치 `feature/hal-settings-v0.4.0`, 기준 main `54db663` = 0.3.17).
+3 -1
View File
@@ -28,7 +28,9 @@ export function loadAgentConfig(env = process.env) {
// [0.4.0] HAL 통합 설정(module.configure 가 쓰는 <dataDir>/hal-managed.json)이 있으면 그 값이 우선한다(범위 5초~10분)
heartbeatMs: (() => { const m = Number(managed.heartbeatMs); return Number.isFinite(m) && m >= 5000 && m <= 600000 ? m : Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000); })(),
halManaged: { hash: managedDoc.hash || null, keys: Object.keys(managed) },
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); },
// [0.4.1] caFile: HAL 공개 주소가 사설 CA·자체 서명 인증서일 때 설치 스크립트가 받아 둔 신뢰 앵커 PEM 경로(agent/index.mjs 가 기동 때 TLS 기본 CA 에 더한다)
caFile: env.ARIWEB_HAL_CA || saved.caFile || null,
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label', 'caFile']) if (cfg[k] !== undefined && cfg[k] !== null) 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;
+30
View File
@@ -0,0 +1,30 @@
/**
* agent/halca.mjs — [0.4.1] HAL 공개 주소가 사설 CA·자체 서명 인증서일 때의 Agent 쪽 처리(개발 지시 v0.4.1 요구 1).
*
* applyHalCa(caFile) 설치 스크립트가 받아 둔 신뢰 앵커 PEM(<dataDir>/hal-ca.pem)을 이 프로세스의 TLS 기본 CA 에 더한다.
* 유닛이 NODE_EXTRA_CA_CERTS 로 이미 넣었으면 그대로 둔다. Node 22.19+/24 의 tls.setDefaultCACertificates 를 쓰고, 없으면 이유를 돌려준다.
* friendlyFetchError(e, hal) fetch 의 TypeError('fetch failed') 를 운영자가 읽을 수 있는 한 줄로 바꾼다 — 스택 대신 원인(인증서·연결)과 조치.
* 비밀 없음: CA 는 공개 인증서다.
*/
import tls from 'node:tls';
import { existsSync, readFileSync } from 'node:fs';
/** 인증서 검증 실패로 분류하는 OpenSSL/Node 오류 코드 */
export const TLS_TRUST_CODES = new Set(['SELF_SIGNED_CERT_IN_CHAIN', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_UNTRUSTED', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_GET_ISSUER_CERT', 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'CERT_SIGNATURE_FAILURE', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_REVOKED']);
/** PEM 번들을 인증서 단위로 나눈다 */
export function splitPem(text) { return String(text || '').split(/(?=-----BEGIN CERTIFICATE-----)/).map((s) => s.trim()).filter((s) => s.startsWith('-----BEGIN CERTIFICATE-----')); }
export function applyHalCa(caFile, { env = process.env, tlsImpl = tls } = {}) {
if (!caFile) return { applied: false, reason: 'no_ca' };
if (!existsSync(caFile)) return { applied: false, reason: 'missing', error: `HAL CA 파일이 없습니다: ${caFile}` };
if (env.NODE_EXTRA_CA_CERTS && env.NODE_EXTRA_CA_CERTS === caFile) return { applied: true, via: 'env' };
const pems = splitPem(readFileSync(caFile, 'utf8')); if (!pems.length) return { applied: false, reason: 'empty', error: `HAL CA 파일에 인증서가 없습니다: ${caFile}` };
if (typeof tlsImpl.setDefaultCACertificates !== 'function' || typeof tlsImpl.getCACertificates !== 'function') return { applied: false, reason: 'unsupported', error: 'tls.setDefaultCACertificates 가 없는 Node 입니다(22.19+ 필요) — 유닛의 Environment=NODE_EXTRA_CA_CERTS 로 대신 넣으세요' };
tlsImpl.setDefaultCACertificates([...tlsImpl.getCACertificates('default'), ...pems]); return { applied: true, via: 'runtime', count: pems.length };
}
/** fetch 실패를 조치가 보이는 한 줄 메시지로. { code, message, tls } */
export function friendlyFetchError(e, hal) {
const code = (e && (e.cause?.code || e.code)) || null; const base = String(hal || '').replace(/\/$/, '');
if (code && TLS_TRUST_CODES.has(code)) return { code, tls: true, message: `HAL(${base}) 인증서를 확인할 수 없습니다(${code}) — HAL 운영자 콘솔이 만든 설치 명령(--hal-pin 포함)을 그대로 쓰거나, HAL 의 CA 파일을 받아 --ca <PEM 파일> 로 지정하세요. (예: curl -k --pinnedpubkey '<HAL 이 알려 준 핀>' ${base}/agent/v1/hal-ca.pem -o hal-ca.pem)` };
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'UND_ERR_CONNECT_TIMEOUT') return { code, tls: false, message: `HAL(${base}) 에 연결할 수 없습니다(${code}) — 주소·DNS·방화벽을 확인하세요` };
return { code, tls: false, message: `HAL(${base}) 요청 실패: ${code || ''} ${e && e.cause && e.cause.message ? e.cause.message : e && e.message ? e.message : ''}`.trim() };
}
+15 -7
View File
@@ -13,25 +13,33 @@ 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 { applyHalCa, friendlyFetchError } from './halca.mjs'; // [0.4.1] 사설 CA·읽을 수 있는 연결 오류
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { join, resolve } 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 });
// [0.4.1] HAL 사설 CA: --ca <pem> > ARIWEB_HAL_CA > agent.json 의 caFile. 기동 때 이 프로세스의 TLS 기본 CA 에 더한다(유닛의 NODE_EXTRA_CA_CERTS 와 병행).
const caFile = (() => { const o = opt('ca'); return o && o !== true ? resolve(String(o)) : cfg.caFile; })();
const caState = applyHalCa(caFile); if (caState.error) console.error(`[agent] HAL CA 적용 실패: ${caState.error}`);
if (cmd === 'register') {
const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); }
// [0.4.1] 이미 등록된 Agent 가 있으면 조용히 덮어쓰지 않는다(요구 1 — "이미 등록된 서버" 메시지). --force 는 새 토큰(재등록 허용)으로 바꿀 때만.
if (cfg.id && !opt('force')) { console.error(`이미 등록된 서버입니다 — Agent ${cfg.id}(${cfg.kind || 'managed'}${cfg.serverId ? `, 서버 ${cfg.serverId}` : ''}) 가 ${cfg.registeredAt || '이전'} 에 ${cfg.halUrl} 에 등록되었습니다. 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 "재등록 허용" 토큰을 받아 --force 로 실행하세요`); process.exit(2); }
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()) });
let r; try { r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }), signal: AbortSignal.timeout(30000) }); }
catch (e) { const fe = friendlyFetchError(e, hal); console.error(`등록 실패: ${fe.message}`); process.exit(1); } // 스택 대신 원인·조치 한 줄(이전: TypeError: fetch failed … SELF_SIGNED_CERT_IN_CHAIN)
const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(j.error === 'server_already_registered' || j.error === 'token_used' ? 3 : 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()), caFile: caFile && existsSync(caFile) ? caFile : null });
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); }
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${caFile ? `, HAL CA ${caFile}(${caState.via || '미적용'})` : ''}`); } catch (e) { console.error(`HAL 연결 실패: ${e.status ? e.message : friendlyFetchError(e, cfg.halUrl).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') });
@@ -48,7 +56,7 @@ if (cmd === 'register') {
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 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.hawkeye = { version: VERSION, active: true, health: 'ok' }; /* [0.4.1] 0.3.17 에서 바뀐 이름(observability → hawkeye)으로 보고 — 옛 이름 보고가 hawkeye 행을 stale 로 두던 결함 */ 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'))) : [];
@@ -63,7 +71,7 @@ if (cmd === 'register') {
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}`);
log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}${caFile ? ` (HAL CA ${caFile}: ${caState.applied ? caState.via : '미적용'})` : ''}`);
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); }
+48 -10
View File
@@ -1,29 +1,67 @@
#!/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]
# 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] [--label <이름>]
# [0.4.1] HAL 인증서가 사설 CA·자체 서명이면(개발 지시 v0.4.1 요구 1) HAL 이 만든 설치 명령에 공개키 핀이 붙는다:
# curl -fsSL -k --pinnedpubkey 'sha256//…' https://hal/agent/v1/install.sh | bash -s -- --hal https://hal --token … --hal-pin 'sha256//…'
# --hal-pin : 그 핀으로 HAL 의 신뢰 앵커(/agent/v1/hal-ca.pem)를 받아 <app-dir>/data/hal-ca.pem 에 두고, 이후 모든 curl(--cacert)·Node(NODE_EXTRA_CA_CERTS·agent.json caFile) 호출의 CA 로 쓴다.
# --ca <pem> : 운영자가 가진 CA 파일을 직접 지정(핀 대신). --insecure : 검증 없이 진행(실험실 전용, 경고 출력).
# 하는 일: node 22+ 확인(없으면 NodeSource 로 설치), agent artifact 를 HAL 에서 받아 <app-dir>/releases/<ver> 에 풀고 current 연결, 등록(토큰 1회), systemd 유닛 ariweb-agent 설치·시작, sudoers 최소 규칙(관리형만).
# 멱등: 다시 실행하면 이미 등록된 Agent 는 재등록하지 않고 코드·유닛만 맞춘다. 토큰 외의 비밀은 출력하지 않는다.
# 멱등: 다시 실행하면 이미 등록된 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
HAL=""; TOKEN=""; ROLE="web"; EXTERNAL=0; SITE=""; APP_DIR="/srv/ariweb/agent"; LABEL="$(hostname)"; HAL_PIN=""; CA_FILE=""; INSECURE=0
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;; --hal-pin) HAL_PIN="$2"; shift 2;; --ca) CA_FILE="$2"; shift 2;; --insecure) INSECURE=1; shift;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
[ -n "$HAL" ] || { echo "--hal 이 필요합니다" >&2; exit 1; }
HAL="${HAL%/}"; USER_NAME="$(id -un)"
HAL="${HAL%/}"; USER_NAME="$(id -un)"; SCHEME="${HAL%%://*}"
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"
# 데이터 디렉터리를 먼저 만든다 — HAL CA 파일(hal-ca.pem)을 둘 곳. (0.4.1: CA 는 HAL 확인보다 먼저 있어야 한다)
sudo -n mkdir -p "$APP_DIR/data"; sudo -n chown "$USER_NAME:$USER_NAME" "$APP_DIR" "$APP_DIR/data"; chmod 700 "$APP_DIR/data"
# ---- [0.4.1] HAL 인증서 신뢰: curl 은 --cacert, Node 는 NODE_EXTRA_CA_CERTS(+ agent.json caFile) 로 같은 CA 를 쓴다
CURL=(curl -fsS); CA=""
if [ "$SCHEME" = "https" ]; then
if [ -n "$CA_FILE" ]; then [ -f "$CA_FILE" ] || { echo "--ca 파일이 없습니다: $CA_FILE" >&2; exit 1; }; cp "$CA_FILE" "$APP_DIR/data/hal-ca.pem"; CA="$APP_DIR/data/hal-ca.pem"
elif [ -n "$HAL_PIN" ]; then
echo "== HAL CA 받기(공개키 핀 확인: ${HAL_PIN:0:20}…)"
# -k 는 CA 검증만 끈다. --pinnedpubkey 는 서버 공개키가 핀과 다르면 자료를 받기 전에 끊는다(중간자 방지). 핀은 HAL 운영자 콘솔이 설치 명령에 넣어 준 값이다.
if ! curl -fsS -k --pinnedpubkey "$HAL_PIN" "$HAL/agent/v1/hal-ca.pem" -o "$APP_DIR/data/hal-ca.pem.tmp"; then rm -f "$APP_DIR/data/hal-ca.pem.tmp"; echo "HAL CA 를 받지 못했습니다 — 핀이 HAL 의 공개키와 다르거나(명령을 새로 발급받으세요) HAL 이 CA 를 제공하지 않습니다(HAL 서버의 <data>/tls/hal-ca.pem 또는 --ca 로 지정)" >&2; exit 1; fi
grep -q "BEGIN CERTIFICATE" "$APP_DIR/data/hal-ca.pem.tmp" || { rm -f "$APP_DIR/data/hal-ca.pem.tmp"; echo "받은 CA 파일에 인증서가 없습니다" >&2; exit 1; }
mv "$APP_DIR/data/hal-ca.pem.tmp" "$APP_DIR/data/hal-ca.pem"; CA="$APP_DIR/data/hal-ca.pem"
elif [ -f "$APP_DIR/data/hal-ca.pem" ]; then CA="$APP_DIR/data/hal-ca.pem"; fi # 이전 실행이 받아 둔 CA(재실행·갱신)
if [ -n "$CA" ]; then chmod 644 "$CA"; CURL+=(--cacert "$CA"); export NODE_EXTRA_CA_CERTS="$CA"; echo "== HAL CA 사용: $CA"; fi
if [ "$INSECURE" = "1" ]; then CURL+=(-k); export NODE_TLS_REJECT_UNAUTHORIZED=0; echo "경고: --insecure — HAL 인증서를 검증하지 않습니다(실험실 전용)" >&2; fi
fi
echo "== HAL 확인 $HAL"
if ! HEALTH="$("${CURL[@]}" "$HAL/api/health" 2>"/tmp/ariweb-agent-$$.err")"; then
ERR="$(cat "/tmp/ariweb-agent-$$.err" 2>/dev/null || true)"; rm -f "/tmp/ariweb-agent-$$.err"
echo "HAL($HAL) 에 연결할 수 없습니다: ${ERR:-원인 불명}" >&2
case "$ERR" in *certificate*|*SSL*|*ssl*) echo "인증서 문제입니다 — HAL 운영자 콘솔이 만든 설치 명령(--hal-pin 포함)을 그대로 쓰거나, HAL 의 CA 파일을 --ca <PEM> 으로 지정하세요" >&2;; esac
exit 1
fi
rm -f "/tmp/ariweb-agent-$$.err"
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" /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
if [ -n "$TOKEN" ]; then "${CURL[@]}" -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz || { echo "agent artifact 를 받지 못했습니다 — 토큰이 만료·사용되었거나(이미 등록된 서버면 재설치가 필요 없습니다) HAL 에 릴리스 $VERSION 의 agent artifact 가 없습니다" >&2; exit 1; }; 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
if [ ! -f "$APP_DIR/data/agent.json" ]; then
[ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"
CA_OPT=(); [ -n "$CA" ] && CA_OPT=(--ca "$CA")
if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL" "${CA_OPT[@]}"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL" "${CA_OPT[@]}"; fi || { RC=$?; [ "$RC" = "3" ] && echo "이 서버는 HAL 에 이미 등록되어 있습니다 — 재설치가 필요 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 \"재등록 허용\" 토큰을 발급받으세요" >&2 || echo "등록에 실패했습니다 — 위 메시지를 확인하세요" >&2; exit 1; }
else
echo "== 이미 등록된 서버($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const j=JSON.parse(s);console.log('Agent '+j.id+' → '+j.halUrl+(j.registeredAt?', 등록 '+j.registeredAt:''))})")) — 재등록하지 않고 코드·유닛만 갱신합니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 \"재등록 허용\" 토큰을 받고 $APP_DIR/data/agent.json 을 지운 뒤 다시 실행하세요"
# 0.4.1: 이전 설치가 CA 를 몰랐고 지금 CA 가 생겼으면 agent.json 에 caFile 을 기록한다(Agent 가 기동 때 적용)
if [ -n "$CA" ]; then $NODE_BIN -e "const fs=require('fs');const f='$APP_DIR/data/agent.json';const j=JSON.parse(fs.readFileSync(f,'utf8'));if(j.caFile!=='$CA'){j.caFile='$CA';fs.writeFileSync(f+'.tmp',JSON.stringify(j,null,1),{mode:0o600});fs.renameSync(f+'.tmp',f);console.log(' agent.json caFile 갱신')}"; fi
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
ENV_LINES=""; [ "$EXTERNAL" = "1" ] && ENV_LINES="Environment=ARIWEB_AGENT_EXTERNAL=1"
[ -n "$CA" ] && ENV_LINES="${ENV_LINES:+$ENV_LINES\\n}Environment=NODE_EXTRA_CA_CERTS=$CA" # Agent 서비스도 같은 CA 로 HAL 을 믿는다(자기 갱신 때 Environment 줄은 보존된다)
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}}#$ENV_LINES#" 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; }
+21 -12
View File
@@ -6,10 +6,12 @@
# 사용(대상 서버에서, 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 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
# [0.4.1] 이 스크립트는 HAL 과 함께 이 서버의 Node Agent·HawkEye 를 설치·등록·승인해 부트스트랩을 닫는다(deploy/hal-host-agent.sh, 요구 2) — "부트스트랩 미완료" 상태를 남기지 않는다.
# Web·Mail·Backup·Portal 은 설치하지 않는다(기능 단위 서버로 HAL 작업 배포). --no-agent 로 끌 수 있다(이전 방식: 운영자가 첫 Agent 를 직접 설치·승인).
# 출력: 운영자 최초 설정 토큰(1회). 비밀값은 그 밖에 출력하지 않는다.
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
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)"; WITH_AGENT=1
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;; --no-agent) WITH_AGENT=0; shift;; *) 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; }
@@ -21,12 +23,12 @@ DIRTY="$($NODE_BIN -e "const m=JSON.parse(require('fs').readFileSync('$DIST/mani
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}…)"
echo "== 1/7 릴리스 $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 디렉터리·보관소·접근 경로 준비"
echo "== 2/7 디렉터리·보관소·접근 경로 준비"
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"
@@ -34,11 +36,11 @@ export ARIWEB_HAL_DATA="$DATA"; $NODE_BIN -e "import('./lib/vault.mjs').then(m=>
$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"
echo "== 3/7 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)"
echo "== 4/7 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
@@ -49,17 +51,24 @@ if [ "$PORTAL_HOST" != "none" ]; then
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 자체 건강 확인"
echo "== 5/7 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"
# [0.4.1] HAL 공개 주소 TLS 신뢰 상태(사설 CA·자체 서명이면 설치 명령에 핀이 붙고 /agent/v1/hal-ca.pem 이 CA 를 준다)
$NODE_BIN hal/index.mjs tls-probe | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const t=JSON.parse(s);console.log(' 공개 주소 TLS:',t.trusted===true?'시스템 CA 로 신뢰됨':t.trusted===false?'신뢰되지 않음('+t.reason+') — 다른 서버의 설치 명령에 공개키 핀이 붙습니다(신뢰 앵커 '+t.caCount+'개)':'확인 불가('+(t.error||t.reason)+')')})" || true
if [ "$WITH_AGENT" = "1" ]; then
echo "== 6/7 이 서버의 Node Agent·HawkEye 설치·등록·승인(부트스트랩 닫기)"
bash deploy/hal-host-agent.sh --hal-data "$DATA" --hal-local http://127.0.0.1:8900 --name "$HOST" --by bootstrap
else
echo "== 6/7 (--no-agent) 이 서버의 Node Agent 는 설치하지 않습니다 — 부트스트랩은 첫 Agent 승인 때 닫힙니다"
fi
echo "== 7/7 최초 운영자 설정 토큰"
$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 작업으로 배포합니다.
2) $([ "$WITH_AGENT" = "1" ] && echo "부트스트랩은 완료되었습니다(이 서버의 Node Agent·HawkEye 가 함께 설치·승인됨). 운영자 콘솔 › 배포 릴리스의 \"역할별 통합 배포(새 서버)\" 로 기능 단위 서버(Webmaker·AriMail·백업·포털)를 추가해 배포합니다." || echo "운영자 콘솔 › 서버에서 이 서버(역할 control)를 만들고 등록 토큰을 발급해 첫 Node Agent 를 설치합니다. 첫 Agent 가 승인되면 부트스트랩이 닫힙니다.")
다른 서버의 설치 명령은 운영자 콘솔이 만들어 줍니다 — HAL 인증서가 사설 CA 면 명령에 공개키 핀(--hal-pin)이 붙습니다.
3) 포털 연동 키: node hal/index.mjs link-portal (출력 값을 포털 보관소에 넣습니다)
MSG
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# deploy/hal-host-agent.sh — [0.4.1] HAL 서버 자신에 Node Agent(+HawkEye) 를 설치·등록·승인한다(개발 지시 v0.4.1 요구 2).
# bootstrap-hal.sh 가 HAL 설치 직후 부르며(부트스트랩을 "미완료" 로 남기지 않는다), 0.4.0 이하로 설치된 기존 HAL 서버에서는 직접 실행한다:
# cd /srv/ariweb/hal/current && bash deploy/hal-host-agent.sh [--hal-data /srv/ariweb/hal/data] [--hal-local http://127.0.0.1:8900] [--name <서버 이름>] [--agent-dir /srv/ariweb/agent] [--by bootstrap]
# 왜 loopback 인가: HAL 서버의 Agent 는 HAL 에 http://127.0.0.1:8900 으로 붙는다 — DNS 레코드·공개 인증서(사설 CA 포함)와 무관하게 부트스트랩이 끝난다. 요청은 HMAC 서명이라 평문 loopback 으로 충분하다.
# 설치 범위: Node Agent + HawkEye(Agent 안의 기능)만. Web·Mail·Backup·Portal 은 설치하지 않는다 — 기능 단위 서버로 따로 배포한다.
# 멱등: 이미 이 서버에 Agent 가 있으면(agent.json) 재등록하지 않고 승인 상태만 확인한다. 토큰은 스크립트 안에서만 쓰고 출력하지 않는다.
set -euo pipefail
HAL_DATA="${ARIWEB_HAL_DATA:-/srv/ariweb/hal/data}"; HAL_LOCAL="http://127.0.0.1:8900"; NAME=""; AGENT_DIR="/srv/ariweb/agent"; BY="bootstrap"
while [ $# -gt 0 ]; do case "$1" in --hal-data) HAL_DATA="$2"; shift 2;; --hal-local) HAL_LOCAL="$2"; shift 2;; --name) NAME="$2"; shift 2;; --agent-dir) AGENT_DIR="$2"; shift 2;; --by) BY="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
HERE="$(cd "$(dirname "$0")/.." && pwd)"; cd "$HERE" # HAL 코드 트리(current)
[ -n "$NAME" ] || NAME="$(hostname -f 2>/dev/null || hostname)"
NODE_BIN="$(command -v node || true)"; [ -n "$NODE_BIN" ] || { echo "node 가 필요합니다" >&2; exit 1; }
export ARIWEB_HAL_DATA="$HAL_DATA"
json() { $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const j=JSON.parse(s);const v=j[process.argv[1]];console.log(v==null?'':(typeof v==='object'?JSON.stringify(v):v))})" "$1"; }
echo "== HAL 서버 Node Agent: HAL $HAL_LOCAL 확인"
curl -fsS -m 5 "$HAL_LOCAL/api/health" >/dev/null || { echo "HAL 이 $HAL_LOCAL 에서 응답하지 않습니다(ariweb-hal 유닛·포트 확인)" >&2; exit 1; }
if [ -f "$AGENT_DIR/data/agent.json" ]; then
SRV="$(cat "$AGENT_DIR/data/agent.json" | json serverId)"; AG="$(cat "$AGENT_DIR/data/agent.json" | json id)"
echo "== 이미 이 서버에 Agent 가 있습니다(Agent $AG, 서버 ${SRV:-?}) — 재등록하지 않습니다"
[ -n "$SRV" ] || { echo "agent.json 에 서버 id 가 없어 승인을 진행할 수 없습니다" >&2; exit 1; }
else
echo "== 1/3 관리 서버 등록: $NAME (역할 control, 프로필 hal)"
SRV="$($NODE_BIN --no-warnings hal/index.mjs server-create --name "$NAME" --role control --profile hal --by "$BY" | json id)"; [ -n "$SRV" ] || { echo "서버 생성 실패" >&2; exit 1; }
echo "== 2/3 일회용 등록 토큰 → Agent 설치(loopback, 토큰은 표시하지 않음)"
TOKEN="$($NODE_BIN --no-warnings hal/index.mjs server-token --server "$SRV" --by "$BY" --json | json token)"; [ -n "$TOKEN" ] || { echo "토큰 발급 실패" >&2; exit 1; }
bash deploy/agent-install.sh --hal "$HAL_LOCAL" --token "$TOKEN" --role control --label "$NAME" --app-dir "$AGENT_DIR"
fi
echo "== 3/3 승인(부트스트랩 닫기)"
OUT="$($NODE_BIN --no-warnings hal/index.mjs server-approve --server "$SRV" --by "$BY")"; echo " $OUT"
[ "$(echo "$OUT" | json bootstrapComplete)" = "true" ] && echo "== 부트스트랩 완료 — 기능 단위 서버 배포 대기: $(echo "$OUT" | json pendingModules)" || { echo "부트스트랩이 아직 닫히지 않았습니다(HAL 실행 버전 기록 전?) — HAL 로그를 확인하세요" >&2; exit 1; }
+13 -1
View File
@@ -21,6 +21,8 @@ Dev-Order §5(아키텍처)·§28·§29·§32·§33·§34 의 백엔드 구현.
| 경로 | 설명 |
|---|---|
| `GET install.sh` | 설치 스크립트(deploy/agent-install.sh) |
| `GET hal-ca.pem` | [0.4.1] HAL 공개 주소가 사설 CA·자체 서명일 때의 신뢰 앵커 PEM(공개, 무서명). 시스템 CA 로 신뢰되면 404 `no_private_ca` |
| `GET tls` | [0.4.1] 공개 주소 TLS 신뢰 상태(trusted·reason·pin·caAvailable, `?refresh=1`) |
| `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 |
@@ -51,7 +53,17 @@ 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}` 로 배포한다.
[0.4.1] 부트스트랩은 HAL 설치·보관소·유닛·nginx·릴리스 등록(승인)에 이어 **이 서버의 Node Agent·HawkEye 를 함께 설치·등록·승인**해 부트스트랩을 닫는다(`deploy/hal-host-agent.sh`: `server-create --profile hal` → `server-token --json` → `agent-install.sh --hal http://127.0.0.1:8900 --role control`(loopback, DNS·인증서 무관) → `server-approve`). 그래서 설치 직후 운영자 콘솔은 "부트스트랩 미완료" 가 아니라 "부트스트랩 완료 · 기능 단위 서버 배포 대기: 고객 포털, Webmaker, AriMail, 백업" 을 보인다(`/api/health.deployment.pending`). Web·Mail·Backup·Portal 은 설치하지 않는다 — 역할별 통합 배포(새 서버)로 추가한다. 0.4.0 이하로 설치된 HAL 서버는 `cd /srv/ariweb/hal/current && bash deploy/hal-host-agent.sh` 를 한 번 실행한다(`--no-agent` 로 부트스트랩하면 이전처럼 첫 Agent 승인 때 닫힌다). 마지막에 운영자 설정 토큰을 낸다.
**사설 CA·자체 서명 인증서로 HAL 을 운영할 때(0.4.1)**: HAL 은 기동 때와 토큰 발급 때 자기 공개 주소에 접속해 시스템 CA 신뢰 여부를 확인한다(`hal/tls.mjs`, CLI `node hal/index.mjs tls-probe`). 신뢰되지 않으면 운영자 콘솔이 만드는 설치 명령에 서버 공개키 핀이 붙는다:
```
curl -fsSL -k --pinnedpubkey 'sha256//…' https://hal.local.test/agent/v1/install.sh | bash -s -- --hal https://hal.local.test --token … --hal-pin 'sha256//…'
```
`-k` 는 CA 검증만 끄고 `--pinnedpubkey` 는 서버 공개키가 핀과 다르면 자료를 받기 전에 끊는다. 스크립트는 같은 핀으로 `GET /agent/v1/hal-ca.pem`(체인 안의 자체 서명 인증서, 또는 HAL 서버의 `<data>/tls/hal-ca.pem` 이 있으면 그 파일)을 받아 `<app-dir>/data/hal-ca.pem` 에 두고, 이후 curl 은 `--cacert`, Node 는 `NODE_EXTRA_CA_CERTS` + `agent.json caFile`(Agent 가 기동 때 `tls.setDefaultCACertificates` 로 더함), 유닛은 `Environment=NODE_EXTRA_CA_CERTS=…` 로 같은 CA 를 쓴다. 운영자가 CA 파일을 직접 줄 때는 `--ca <pem>`. 루트를 체인에 싣지 않는 사설 CA 는 HAL 서버에 `<data>/tls/hal-ca.pem` 을 두면 된다.
**이미 등록된 서버(0.4.1)**: 같은 서버에 두 번째 토큰으로 등록하면 HAL 이 409 `server_already_registered`("이미 등록된 서버입니다(서버·Agent·등록 시각) — 다시 설치할 필요가 없습니다") 로 거부하고 토큰을 소비하지 않는다. 쓴 토큰을 다시 쓰면 409 `token_used` 에 같은 문구. 설치 스크립트는 `agent.json` 이 있으면 등록을 건너뛰고 "이미 등록된 서버" 를 안내한다. 서버를 다시 설치해 새 Agent 로 바꾸려면 운영자 콘솔 › 서버 › 등록 토큰에서 "재등록 허용"(재인증) 토큰을 발급한다 — 기존 Agent 는 해지된다(CLI `server-token --server <id> --replace`). Agent 쪽 연결 오류는 스택 대신 원인·조치 한 줄로 나온다(`등록 실패: HAL(…) 인증서를 확인할 수 없습니다(SELF_SIGNED_CERT_IN_CHAIN) — …`).
그 뒤 `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 가 수행한다.
+19
View File
@@ -144,3 +144,22 @@ node hal/index.mjs job --id <j_id> # status done, children[].steps await.re
# 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 으로 남는다; 중지·폐기는 사람 결정.
## 11. 0.4.1 — HAL 서버 자신의 Node Agent·HawkEye, 사설 CA 서버 등록 (2026-11-06)
### 11.1 기존 HAL 서버(0.4.0 이하로 부트스트랩)에 Agent 보완 — bear hal21
```
cd /srv/ariweb/hal/current && bash deploy/hal-host-agent.sh --name hal21.ariweb.net --by <who>
```
- 하는 일: 관리 서버 `hal21.ariweb.net`(역할 control, 프로필 `hal`) 생성 → 일회용 토큰(표시 안 함) → `agent-install.sh --hal http://127.0.0.1:8900 --role control`(agent artifact 는 HAL 릴리스 등록부에서, `/srv/ariweb/agent`, 유닛 `ariweb-agent`, sudoers 는 부트스트랩 때 둔 것과 같은 파일) → `server-approve`. 이미 `agent.json` 이 있으면 승인만 한다.
- 확인: `curl -fsS http://127.0.0.1:8900/api/health`(bootstrap.complete·deployment.pending), 운영자 콘솔 서버 목록에 HAL 서버(역할 control, Agent 0.4.1, HawkEye 지표), 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기…"(기능 모듈이 다 배치된 운영 HAL 은 배너 없음).
- 되돌리기: `sudo systemctl disable --now ariweb-agent && sudo rm /etc/systemd/system/ariweb-agent.service && sudo systemctl daemon-reload`, 운영자 콘솔에서 그 서버 삭제(Agent 해지). HAL 데이터는 서버·Agent 행만 추가된다.
### 11.2 새 HAL 부트스트랩(0.4.1)
`bootstrap-hal.sh` 가 6/7 단계에서 11.1 을 스스로 수행한다. `--no-agent` 를 주면 이전처럼 첫 Agent 승인 때 부트스트랩이 닫힌다.
### 11.3 사설 CA·자체 서명 HAL 주소(예: hal.local.test)의 서버 등록
1. HAL 서버에서 상태 확인: `node hal/index.mjs tls-probe` → `trusted:false, reason:…, pin:"sha256//…", caCount:1`. `caCount:0` 이면(루트를 체인에 싣지 않는 사설 CA) `/srv/ariweb/hal/data/tls/hal-ca.pem` 에 루트 CA PEM 을 두고 HAL 을 재시작하거나 10분 기다린다(`GET /agent/v1/tls?refresh=1`).
2. 운영자 콘솔 › 서버 › 등록 토큰: 핀이 붙은 명령이 나온다. 대상 서버에서 그대로 실행한다(`-k --pinnedpubkey` 로 스크립트를 받고 `--hal-pin` 으로 CA 를 받는다). CA 는 `/srv/ariweb/agent/data/hal-ca.pem`, 유닛에 `NODE_EXTRA_CA_CERTS`, `agent.json` 에 `caFile`.
3. 이미 등록된 서버에서 같은 명령을 다시 실행하면 "이미 등록된 서버 … 코드·유닛만 갱신" 으로 끝난다. 새 Agent 로 바꾸려면 토큰 발급 때 "재등록 허용" 을 선택한다(재인증).
4. 0.4.0 Agent 가 이미 설치된 서버(인증서가 사설 CA 로 바뀐 경우): `curl -k --pinnedpubkey '<핀>' https://<HAL>/agent/v1/hal-ca.pem -o /srv/ariweb/agent/data/hal-ca.pem` 후 유닛에 `Environment=NODE_EXTRA_CA_CERTS=/srv/ariweb/agent/data/hal-ca.pem` 을 더하고 재시작, 또는 HAL 작업으로 agent 0.4.1 배포 뒤 설치 명령을 다시 실행(`--hal-pin`)해 `caFile` 을 보완한다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,7 +14,8 @@
## 1. HAL 자체 건강 (`hal_health`)
- `ok` : DB 와 작업 엔진이 모두 살아 있을 때만 true. false 면 일반 배포 작업이 거부된다(플랫폼 복구 화면).
- `version` : 실행 중인 HAL 코드 버전(package.json). `bootstrap.halRunningVersion` 과 같아야 정상.
- `bootstrap.complete` : HAL 설치 + 첫 Node Agent 등록·승인이 끝났는지. false 면 포털 API·일반 모듈 배포가 열리지 않는다.
- `bootstrap.complete` : HAL 설치 + HAL 서버의 Node Agent 등록·승인이 끝났는지(0.4.1 부터 bootstrap-hal.sh 가 함께 설치). false 면 포털 API·일반 모듈 배포가 열리지 않는다.
- `deployment.pending` : [0.4.1] 아직 어떤 서버에도 배치되지 않은 기능 모듈(portal·webmaker·arimail·backup) — "기능 단위 서버 배포 대기". `deployment.agents` 는 Agent 가 있는 서버 수.
- `bootstrap.releaseVersion / mainCommit / artifactSha256` : 부트스트랩 때 선택한 릴리스. `manualInstallLocked` true 면 수동 설치 잠김(HAL 전용 운영).
- `instanceId` : "하나의 HAL" 임대 소유자. 재시작하면 바뀐다.
+8 -3
View File
@@ -29,6 +29,7 @@ import * as mailsvc from './mailsvc.mjs';
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, MODULE_GUIDES, MODULE_LINKS, SERVER_PROFILES, descriptor, canonicalModuleName } from './modules/index.mjs';
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
import { registerAiRoutes } from './ai/routes.mjs';
import { ensureTlsInfo, caBundle } from './tls.mjs'; // [0.4.1] 사설 CA·핀 설치 명령
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
import { MANAGED_MODULES, publicCatalog, catalogFor } from './settings/catalog.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
@@ -47,6 +48,9 @@ export function buildApp(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'); });
// [0.4.1] 사설 CA·자체 서명 인증서 운영: 설치 스크립트가 핀(--pinnedpubkey)으로 받아 이후 curl·Node 의 CA 로 쓰는 신뢰 앵커 PEM(공개 정보, 비밀 없음). 시스템 CA 로 신뢰되는 주소면 404.
app.get('/agent/v1/hal-ca.pem', wrap(async (ctx) => { const info = await ensureTlsInfo(d); const pem = caBundle(info); if (!pem) throw new HttpError(404, 'no_private_ca', info && info.trusted ? 'HAL 공개 주소는 시스템 CA 로 신뢰됩니다 — 별도 CA 가 필요 없습니다' : 'HAL 공개 주소의 신뢰 앵커를 구하지 못했습니다(운영자가 <data>/tls/hal-ca.pem 을 두면 제공합니다)'); ctx.outHeaders['Cache-Control'] = 'no-store'; return ctx.text(pem); }));
app.get('/agent/v1/tls', wrap(async (ctx) => { const info = await ensureTlsInfo(d, { force: ctx.query.refresh === '1' }); return { ok: true, tls: { url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caAvailable: !!caBundle(info), caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, probedAt: info.probedAt, error: info.error || null } }; }));
// ---------------- 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() }; }));
@@ -61,7 +65,7 @@ export function buildApp(d) {
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.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact', '/agent/v1/hal-ca.pem', '/agent/v1/tls'].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)); } }));
@@ -132,7 +136,8 @@ export function buildApp(d) {
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); if (b.slots !== undefined) { if (b.slots && typeof b.slots === 'object') { b.slots_json = toJson(Object.fromEntries(Object.entries(b.slots).filter(([k, v]) => ['sites', 'mailServices', 'backups', 'webmaker'].includes(k) && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]))); } delete b.slots; } // [0.3.17] 슬롯
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 }) })));
// [0.4.1] replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지 — 고위험이라 재인증). 설치 명령은 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다.
app.post('/ops/api/servers/:id/token', wrap(async (ctx) => { const b = await ctx.body(); if (b.replace) requireRecent(ctx); await ensureTlsInfo(d); return { ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id, replace: !!b.replace }) }; }));
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 }; }));
@@ -219,7 +224,7 @@ export function releasesData(d) {
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
for (const m of DEPLOYABLE_MODULES) { const r = approved.find((x) => MODULES[m].layout === 'agent-feature' ? true : !!x.artifacts[m]); latest[m] = r ? r.version : null; }
const servers = Object.fromEntries(core.listServers(d).map((s) => [s.id, { name: s.name, role: s.role, status: s.status, profile: s.profile }]));
return { ok: true, releases, running: core.selfHealth(d).bootstrap, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, latest, guides: MODULE_GUIDES, links: MODULE_LINKS, profiles: Object.values(SERVER_PROFILES), servers, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') };
return { ok: true, releases, running: core.selfHealth(d).bootstrap, deployment: core.selfHealth(d).deployment, modules: DEPLOYABLE_MODULES, deployOrder: DEPLOY_ORDER, latest, guides: MODULE_GUIDES, links: MODULE_LINKS, profiles: Object.values(SERVER_PROFILES), servers, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') };
}
/** 운영 현황(§26.1) */
function overview(d) {
+43 -12
View File
@@ -11,7 +11,8 @@ 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 { descriptor } from './modules/index.mjs';
import { descriptor, canonicalModuleName, MODULES } from './modules/index.mjs';
import { installCommand as tlsInstallCommand } from './tls.mjs'; // [0.4.1] 사설 CA 핀 설치 명령
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
import { join, basename } from 'node:path';
import { syncFromHeartbeat } from './mailsvc.mjs';
@@ -47,10 +48,24 @@ export function maybeCompleteBootstrap(d, agentId) {
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단계) */
/**
* [0.4.1] 기능 단위 서버 배포 현황(요구 2): 부트스트랩(HAL + HAL 서버의 Node Agent·HawkEye)이 끝난 뒤 아직 배치되지 않은 기능 모듈(포털·Webmaker·AriMail·백업)을 센다.
* 화면 배너·/api/health 가 "부트스트랩 미완료" 대신 "배포 대기" 로 보이는 근거. 배치 = 어떤 서버든 module_targets 에 실제 버전이 보고된 행이 있는 것.
*/
export const FEATURE_MODULES = ['portal', 'webmaker', 'arimail', 'backup'];
export function deploymentStatus(d) {
const rows = d.store.all('SELECT module, server_id, actual_version, target_version, health, active FROM module_targets');
const servers = d.store.all('SELECT id, name, role, status, agent_id FROM servers');
const byModule = (m) => rows.filter((r) => r.module === m && (r.actual_version || r.target_version));
const items = FEATURE_MODULES.map((m) => { const rs = byModule(m); const deployed = rs.length > 0; const roles = (MODULES[m] && MODULES[m].roles) || []; const candidates = servers.filter((s) => s.agent_id && s.status === 'ready' && roles.includes(s.role)).length; return { module: m, label: MODULES[m] ? MODULES[m].label : m, status: deployed ? 'deployed' : 'pending', servers: rs.map((r) => r.server_id), candidates }; });
const agents = servers.filter((s) => s.agent_id).length;
return { pending: items.filter((i) => i.status === 'pending'), deployed: items.filter((i) => i.status === 'deployed'), agents, servers: servers.length };
}
/** HAL 자체 건강(§32.2 4단계). [0.4.1] deployment: 기능 단위 서버 배포 현황(pending 모듈 이름 목록) */
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 };
let deployment = null; try { const ds = deploymentStatus(d); deployment = { pending: ds.pending.map((i) => i.module), deployed: ds.deployed.map((i) => i.module), agents: ds.agents }; } catch (_) { deployment = null; }
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 }, deployment, instanceId: d.instanceId };
}
// ------------------------------------------------------------------ 서버·Agent
@@ -107,13 +122,19 @@ export function defaultTargets(d, module) {
}
/** [0.3.17] 버전 비교(숫자 점 구분). a >= b 이면 true. 비교 불가(없음)면 false. */
export function versionGte(a, b) { if (!a || !b) return false; const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] || 0; const y = pb[i] || 0; if (x !== y) return x > y; } return true; }
/** 일회용 등록 토큰(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', '서버가 없습니다');
/**
* 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다.
* [0.4.1] replace: 이미 Agent 가 등록된 서버에 새 Agent 를 등록(기존 Agent 해지)하도록 허용. 없으면 등록 때 server_already_registered 로 거부된다(요구 1 — 이미 등록된 서버 메시지).
* [0.4.1] 설치 명령은 HAL 공개 주소의 TLS 신뢰 상태(d.tlsInfo, hal/tls.mjs)에 따라 핀(--pinnedpubkey / --hal-pin)을 붙인다(사설 CA·자체 서명 인증서).
*/
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000, replace = false }) {
const srv = kind === 'managed' && serverId ? getServer(d, serverId) : null; if (kind === 'managed' && serverId && !srv) 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}` : ''}` };
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at, replace_existing) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt, replace ? 1 : 0);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label, replace: !!replace } });
const existing = srv && srv.agent_id ? getAgent(d, srv.agent_id) : null;
const cmd = tlsInstallCommand(d.config.publicUrl, token, { kind, siteId, tlsInfo: d.tlsInfo || null });
return { id, token, expiresAt, installCommand: cmd.installCommand, tls: cmd.tls, replace: !!replace, existingAgent: existing && existing.status === 'active' ? { id: existing.id, registeredAt: existing.registered_at, label: existing.label } : null };
}
/**
* Agent 등록(토큰 1회 사용). 비밀은 보관소에 두고 응답에서 한 번만 돌려준다.
@@ -122,13 +143,21 @@ export function issueRegistrationToken(d, { kind = 'managed', serverId = null, g
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', '이미 사용한 등록 토큰입니다 — 새 토큰을 발급받으세요');
// [0.4.1] 이미 쓴 토큰: 그 토큰으로 등록된 Agent 가 살아 있으면 "이미 등록된 서버" 라고 알린다(같은 명령을 두 번 실행한 경우)
if (t.used_at) { const prev = t.used_by_agent ? getAgent(d, t.used_by_agent) : null; const ps = prev && prev.server_id ? getServer(d, prev.server_id) : null; throw new JobError('token_used', prev && prev.status === 'active' ? `이미 등록된 서버입니다 — 이 토큰은 ${t.used_at} 에 Agent ${prev.id}${ps ? `(서버 ${ps.name})` : ''} 등록에 사용되었습니다. 다시 설치할 필요가 없으며, 새로 등록하려면 운영자 콘솔에서 "재등록 허용" 토큰을 발급받으세요` : `이미 사용한 등록 토큰입니다(${t.used_at}) — 새 토큰을 발급받으세요`, { status: 409 }); }
if (Date.parse(t.expires_at) < Date.now()) throw new JobError('token_expired', '등록 토큰이 만료되었습니다 — 새 토큰을 발급받으세요');
// [0.4.1] 이미 Agent 가 있는 서버(토큰에 묶인 서버, 또는 같은 호스트 이름의 서버): 토큰에 재등록 허용이 없으면 거부 — 조용히 두 번째 Agent 를 만들지 않는다
const bound = t.kind === 'managed' && t.server_id ? getServer(d, t.server_id) : null;
const sameHost = t.kind === 'managed' && !t.server_id && facts.hostname ? d.store.get('SELECT * FROM servers WHERE name=? AND agent_id IS NOT NULL ORDER BY created_at LIMIT 1', String(facts.hostname)) : null;
const target = bound || sameHost; const existing = target && target.agent_id ? getAgent(d, target.agent_id) : null;
if (existing && existing.status === 'active' && !t.replace_existing) throw new JobError('server_already_registered', `이미 등록된 서버입니다(서버 ${target.name}, Agent ${existing.id}, 등록 ${existing.registered_at}) — 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔 › 서버에서 "재등록 허용" 토큰을 발급받으세요`, { status: 409 });
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', '이미 사용한 등록 토큰입니다');
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다', { status: 409 });
let serverId = t.server_id;
if (!serverId && sameHost) serverId = sameHost.id; // 같은 호스트 이름의 서버가 있고 재등록 허용이면 그 서버를 쓴다
if (existing && existing.status === 'active') { revokeAgent(d, existing.id, `replace:${agentId}`); audit(d.store, { actorType: 'system', action: 'agent.replace', subjectType: 'server', subjectId: serverId, detail: { previousAgent: existing.id, newAgent: agentId } }); }
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' });
@@ -189,7 +218,9 @@ export function heartbeat(d, agent, body) {
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 || {})) {
for (const [rawName, mod] of Object.entries(body.modules || {})) {
const name = canonicalModuleName(rawName); // [0.4.1] 옛 이름(observability)으로 보고하는 0.4.0 이하 Agent 도 hawkeye 행으로 모은다(0.3.17 잔여 결함)
if (name !== rawName) d.store.run('DELETE FROM module_targets WHERE module=? AND server_id=?', rawName, agent.server_id);
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);
}
+23 -3
View File
@@ -30,8 +30,9 @@ 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 { descriptor, profileOf } from './modules/index.mjs';
import { randomToken } from '../lib/crypto.mjs';
import { ensureTlsInfo, probePublicTls } from './tls.mjs'; // [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀)
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
@@ -62,9 +63,26 @@ if (cmd === 'setup-token') {
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>).
// [0.4.1] --replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지). --json: 스크립트(부트스트랩)가 읽는 한 줄 JSON. 설치 명령은 공개 주소 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다.
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}`);
await ensureTlsInfo(d);
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}`, replace: !!opt('replace') });
if (opt('json')) console.log(JSON.stringify({ token: t.token, expiresAt: t.expiresAt, installCommand: t.installCommand, tls: t.tls, replace: t.replace, existingAgent: t.existingAgent }));
else console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}${t.tls && t.tls.trusted === false ? `\n(HAL 공개 주소의 인증서가 시스템 CA 로 신뢰되지 않아(${t.tls.reason}) 핀을 붙였습니다 — 설치 스크립트가 핀으로 HAL 의 CA 를 받아 씁니다)` : ''}${t.existingAgent && !t.replace ? `\n주의: 이 서버에는 이미 Agent ${t.existingAgent.id} 가 있습니다 — 이 토큰으로는 등록이 거부됩니다(--replace 로 재등록 허용)` : ''}`);
} else if (cmd === 'server-create') {
// [0.4.1] 부트스트랩이 HAL 서버 자신을 관리 서버(control)로 만들 때 쓴다(운영자 콘솔이 아직 없는 단계). 출력은 한 줄 JSON.
const name = opt('name'); const role = opt('role', 'control'); if (!name || name === true) { console.error('--name <이름> 이 필요합니다'); process.exit(1); }
const by = `cli:${opt('by', 'cli')}`; let s;
try { s = core.createServer(d, { name, role, location: opt('location', '') === true ? '' : opt('location', ''), by }); } catch (e) { console.error(`서버 생성 실패: ${e.code || ''} ${e.message}`); process.exit(1); }
const profile = opt('profile'); if (profile && profile !== true) { const prof = profileOf(profile); if (!prof) { console.error(`알 수 없는 프로필: ${profile}`); process.exit(1); } core.updateServer(d, s.id, { profile: prof.id, role: prof.role, slots_json: JSON.stringify(prof.slots || {}) }, by); s = core.getServer(d, s.id); }
console.log(JSON.stringify({ id: s.id, name: s.name, role: s.role, status: s.status, profile: s.profile || null }));
} else if (cmd === 'server-approve') {
// [0.4.1] 부트스트랩이 HAL 서버의 첫 Agent 를 승인해 부트스트랩을 닫는다(사람 결정은 bootstrap-hal.sh 실행 자체). 감사 actor cli:<by>.
const sid = opt('server'); if (!sid || sid === true) { console.error('--server <srv_id> 가 필요합니다'); process.exit(1); }
try { const s = core.approveServer(d, sid, `cli:${opt('by', 'cli')}`); const h = core.selfHealth(d); console.log(JSON.stringify({ id: s.id, name: s.name, status: s.status, agentId: s.agent_id, bootstrapComplete: h.bootstrap.complete, pendingModules: h.deployment ? h.deployment.pending : [] })); } catch (e) { console.error(`승인 실패: ${e.code || ''} ${e.message}`); process.exit(1); }
} else if (cmd === 'tls-probe') {
// [0.4.1] 공개 주소 TLS 신뢰 상태 readback(핀·신뢰 앵커 유무, 비밀 없음)
const info = await probePublicTls(opt('url') && opt('url') !== true ? opt('url') : cfg.publicUrl, { dataDir: cfg.dataDir }); console.log(JSON.stringify({ url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caCount: (info.ca || []).length, caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, error: info.error || null }, null, 1));
} 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); }
@@ -109,6 +127,8 @@ if (cmd === 'setup-token') {
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);
// [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀·CA 배포 근거) — 기동 직후 1회, 이후 10분마다(인증서 갱신 반영)
ensureTlsInfo(d).then((t) => log(`[hal] 공개 주소 TLS: ${t.trusted === true ? '신뢰됨' : t.trusted === false ? `시스템 CA 로 신뢰되지 않음(${t.reason}) — 설치 명령에 핀을 붙입니다` : `확인 불가(${t.error || t.reason})`}`)); every(600000, () => ensureTlsInfo(d, { force: true }));
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); }
+3 -1
View File
@@ -53,7 +53,7 @@ export function descriptor(name) { const d = MODULES[canonicalModuleName(name)];
* [0.3.17] 모듈 안내(릴리스 화면·AI 스킬): 무엇을 하는지, 어떤 역할 서버에, 무엇이 먼저 있어야 하는지(requires), 무엇을 제공하는지(provides), 설치 방법.
*/
export const MODULE_GUIDES = {
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: '서버 등록 → 일회용 등록 정보로 Agent 설치(bootstrap) → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: 'HAL 서버의 Agent 는 부트스트랩(bootstrap-hal.sh)이 함께 설치·승인한다(0.4.1). 다른 서버는 서버 등록 → 일회용 등록 정보(HAL 인증서가 사설 CA 면 핀이 붙은 명령)로 Agent 설치 → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
portal: { label: '고객 포털', description: '고객 가입·상품·사이트·메일·도메인·백업·결제를 하나로 잇는 사용자 접근 화면과 API. HAL 과 서비스 키로 연결되어 작업을 HAL 에 맡긴다.', roles: MODULES.portal.roles, requires: ['agent', 'hal 연동 키(link-portal)'], provides: ['고객 화면 my.ariweb.net', '/internal/ops 운영 중계'], install: '포털 서버 프로필로 통합 배포하거나 이 화면에서 portal 배포. 포털은 **하나만** 운영한다 — 이미 있으면 설치 대신 "포털 이전"을 쓴다(새 서버 설치 → 데이터·DNS 전환 확인 → 옛 서버 비활성).', singleton: true },
webmaker: { label: 'Webmaker', description: 'AI 사이트 제작 도구(도입 모듈). 제작 화면·직접 편집·배포 요청을 제공하고, 사용자 사이트는 웹호스팅 서버로 배포된다.', roles: MODULES.webmaker.roles, requires: ['agent', 'portal(SSO·배포 연동)', 'webhosting 서버 1대 이상(배포 대상)'], provides: ['사이트 제작·편집', '배포 요청'], install: 'Webmaker 서버 프로필(이 서버에서는 Webmaker 만 동작)로 통합 배포하거나 web 역할 서버에 배포.', singleton: false },
arimail: { label: 'AriMail', description: '메일 서비스(도입 모듈): 웹메일 접속, POP·IMAP·SMTP, 도메인별 메일함. HAL 관리형 메일 서비스 등록부와 연결된다.', roles: MODULES.arimail.roles, requires: ['agent', 'mail 역할 서버', 'DNS(MX·SPF·DKIM)'], provides: ['webmail', 'POP/IMAP/SMTP'], install: 'AriMail 서버 프로필로 통합 배포하거나 mail 역할 서버에 배포. 기존 AriMail 인스턴스는 mail-import 로 가져온다.', singleton: false },
@@ -79,5 +79,7 @@ export const SERVER_PROFILES = {
arimail: { id: 'arimail', label: 'AriMail 서버', role: 'mail', modules: ['agent', 'arimail', 'hawkeye'], description: '사용자에게 메일 서비스(웹메일·POP·IMAP·SMTP)를 제공합니다.', slots: { mailServices: 20 } },
backup: { id: 'backup', label: '백업 서버', role: 'backup', modules: ['agent', 'backup', 'hawkeye'], description: '사용자 서비스의 백업을 저장·검증합니다. 운영 서버와 분리를 권장합니다.', slots: { backups: 500 } },
portal: { id: 'portal', label: '포털 서버', role: 'control', modules: ['agent', 'portal', 'hawkeye'], description: '사용자 접근을 위한 포털. 하나만 운영하며, 이미 있으면 설치 대신 이전(relocate)합니다.', slots: {}, singleton: true },
// [0.4.1] HAL 서버 자신: 부트스트랩(bootstrap-hal.sh)이 HAL 과 함께 Node Agent·HawkEye 를 설치·등록·승인해 부트스트랩을 닫는다. Web·Mail·Backup·Portal 은 포함하지 않는다(요구 2).
hal: { id: 'hal', label: 'HAL 서버', role: 'control', modules: ['agent', 'hawkeye'], description: 'HAL(제어 평면)이 있는 서버. 부트스트랩 때 Node Agent 와 HawkEye 가 함께 설치되어 HAL 서버 자체의 지표·건강이 운영 현황에 보입니다. 기능 모듈(포털·Webmaker·AriMail·백업)은 역할별 서버에 따로 배포합니다.', slots: {} },
};
export const profileOf = (id) => SERVER_PROFILES[id] || null;
+2
View File
@@ -140,4 +140,6 @@ DELETE FROM modules WHERE name='observability';
CREATE TABLE IF NOT EXISTS managed_settings (scope TEXT NOT NULL, module TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT, is_secret INTEGER NOT NULL DEFAULT 0, updated_by TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(scope, module, key));
CREATE TABLE IF NOT EXISTS managed_settings_state (server_id TEXT NOT NULL, module TEXT NOT NULL, applied_hash TEXT, applied_at TEXT, status TEXT NOT NULL DEFAULT 'unknown', error TEXT, job_id TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(server_id, module));
` },
// [0.4.1] 등록 토큰의 재등록 허용 표시: 이미 Agent 가 있는 서버에 새 Agent 를 등록할 때(기존 Agent 해지) 운영자가 명시해야 한다 — 없으면 server_already_registered 로 거부
{ id: 'hal_0011_token_replace', sql: `ALTER TABLE registration_tokens ADD COLUMN replace_existing INTEGER NOT NULL DEFAULT 0;` },
];
+79
View File
@@ -0,0 +1,79 @@
/**
* hal/tls.mjs — [0.4.1] HAL 공개 주소(publicUrl)의 TLS 신뢰 상태 확인과 사설 CA 배포.
*
* 왜 필요한가(개발 지시 v0.4.1 요구 1): 사설 CA·자체 서명 인증서로 HAL 을 운영하면(예: hal.local.test) 새 서버의 curl·Node 가 HAL 인증서를 믿지 못해
* 설치 스크립트 내려받기(curl 60)와 등록(fetch failed: SELF_SIGNED_CERT_IN_CHAIN)이 모두 실패했다. 이 모듈은 HAL 이 스스로 자기 공개 주소에 접속해
* (1) 시스템 CA 로 신뢰되는지, (2) 아니면 서버 공개키 핀(curl --pinnedpubkey 형식 `sha256//<base64>`)과 신뢰 앵커(체인 안의 자체 서명 인증서) PEM 을 구한다.
* 운영자가 만든 설치 명령은 핀을 붙여 내려받고(`curl -k --pinnedpubkey`), 설치 스크립트는 핀으로 `/agent/v1/hal-ca.pem` 을 받아 그 뒤 모든 curl·Node 호출에 CA 로 쓴다.
*
* 비밀 없음: 인증서·공개키·핀은 모두 공개 정보다. 개인키는 읽지 않는다.
* 결과 모양: { url, scheme, host, port, probedAt, trusted: true|false|null, reason, pin, ca: [PEM…], caSource: 'file'|'chain'|null, subject, issuer, validTo, error }
* trusted null = 접속 자체가 안 됨(DNS·포트) — 설치 명령은 신뢰로 가정하지 않고 핀 없이 만들되 경고를 함께 돌려준다.
* 운영자 지정 CA: <dataDir>/tls/hal-ca.pem 이 있으면 체인에서 뽑은 것보다 우선한다(루트를 체인에 싣지 않는 사설 CA 구성용).
*/
import tls from 'node:tls';
import { X509Certificate, createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/** curl --pinnedpubkey 와 같은 핀: SubjectPublicKeyInfo(DER) 의 sha256 을 base64 로 */
export function spkiPin(x509) { return 'sha256//' + createHash('sha256').update(x509.publicKey.export({ type: 'spki', format: 'der' })).digest('base64'); }
/** 체인에서 신뢰 앵커로 쓸 수 있는 것: 자체 서명(issuer == subject) 인증서. 자체 서명 leaf 도 포함된다. */
export function trustAnchorsFromChain(chainPems) { return chainPems.filter((pem) => { try { const x = new X509Certificate(pem); return x.issuer === x.subject; } catch (_) { return false; } }); }
/** 운영자 지정 CA 파일 경로 */
export const caFilePath = (dataDir) => join(dataDir, 'tls', 'hal-ca.pem');
/**
* 공개 주소에 접속해 신뢰 상태를 확인한다. http:// 는 TLS 가 없으니 trusted=true(핀 없음).
* 두 번 접속한다: ① 기본 CA 로 검증(authorized), ② 검증 끄고 체인·핀 수집(검증 실패일 때만).
*/
export async function probePublicTls(publicUrl, { timeoutMs = 5000, dataDir = null, connect = tls.connect } = {}) {
let u; try { u = new URL(publicUrl); } catch (_) { return { url: publicUrl, scheme: null, trusted: null, reason: 'bad_url', pin: null, ca: [], caSource: null, probedAt: new Date().toISOString(), error: '공개 주소가 올바르지 않습니다' }; }
const base = { url: publicUrl, scheme: u.protocol.replace(':', ''), host: u.hostname, port: Number(u.port || (u.protocol === 'https:' ? 443 : 80)), probedAt: new Date().toISOString(), pin: null, ca: [], caSource: null, subject: null, issuer: null, validTo: null, error: null };
if (u.protocol !== 'https:') return { ...base, trusted: true, reason: 'plain_http' };
const fileCa = dataDir && existsSync(caFilePath(dataDir)) ? readFileSync(caFilePath(dataDir), 'utf8') : null;
const once = (rejectUnauthorized) => new Promise((resolve) => {
let done = false; const fin = (v) => { if (!done) { done = true; try { sock.destroy(); } catch (_) {} resolve(v); } };
const sock = connect({ host: base.host, port: base.port, servername: base.host, rejectUnauthorized, ...(fileCa && rejectUnauthorized ? { ca: fileCa } : {}) }, () => {
const chain = []; let c = sock.getPeerCertificate(true); const seen = new Set();
while (c && c.raw && !seen.has(c.fingerprint256)) { seen.add(c.fingerprint256); chain.push(new X509Certificate(c.raw)); c = c.issuerCertificate; }
fin({ ok: true, authorized: sock.authorized, authorizationError: sock.authorizationError ? String(sock.authorizationError.code || sock.authorizationError) : null, chain });
});
sock.setTimeout(timeoutMs, () => fin({ ok: false, error: 'timeout' })); sock.on('error', (e) => fin({ ok: false, error: e.code || e.message }));
});
const r = await once(true);
if (!r.ok) {
// 검증 때문에 끊긴 게 아니라 접속 자체가 안 된 경우(DNS·포트). 핸드셰이크 거부(인증서 오류)는 rejectUnauthorized=true 에서도 콜백이 아니라 error 로 올 수 있어 ② 로 확인한다.
const r2 = await once(false); if (!r2.ok) return { ...base, trusted: null, reason: r.error, error: `HAL 공개 주소에 접속할 수 없습니다(${r.error})` };
return describe(base, r2, false, r.error, fileCa);
}
if (r.authorized) return describe(base, r, true, null, fileCa);
return describe(base, r, false, r.authorizationError, fileCa);
}
function describe(base, r, trusted, reason, fileCa) {
const leaf = r.chain[0]; const pems = r.chain.map((x) => x.toString());
const anchors = fileCa ? [fileCa.trim() + '\n'] : trustAnchorsFromChain(pems);
return { ...base, trusted, reason: trusted ? (fileCa ? 'trusted_by_file_ca' : 'system_ca') : reason, pin: leaf ? spkiPin(leaf) : null, ca: trusted && !fileCa ? [] : anchors, caSource: fileCa ? 'file' : anchors.length ? 'chain' : null, subject: leaf?.subject || null, issuer: leaf?.issuer || null, validTo: leaf?.validTo || null };
}
/** CA 번들 텍스트(설치 스크립트·Agent 가 --cacert / NODE_EXTRA_CA_CERTS 로 쓴다). 없으면 null */
export function caBundle(info) { return info && info.ca && info.ca.length ? info.ca.join('') : null; }
/**
* deps 에 캐시된 확인 결과(d.tlsInfo). 10분 지나면 다시 확인. 실패해도 예외 없이 trusted=null 결과를 둔다.
* 운영자 API(토큰 발급)·CLI(server-token)·기동 때 부른다. 테스트는 d.tlsInfo 를 직접 둘 수 있다.
*/
export async function ensureTlsInfo(d, { maxAgeMs = 10 * 60 * 1000, force = false } = {}) {
const cur = d.tlsInfo; if (!force && cur && Date.now() - Date.parse(cur.probedAt) < maxAgeMs) return cur;
if (d.tlsProbe) return d.tlsProbe; // 동시 호출은 같은 확인을 기다린다
d.tlsProbe = probePublicTls(d.config.publicUrl, { dataDir: d.config.dataDir }).then((info) => { d.tlsInfo = info; d.tlsProbe = null; return info; }).catch((e) => { d.tlsInfo = { url: d.config.publicUrl, trusted: null, reason: 'probe_error', error: e.message, pin: null, ca: [], probedAt: new Date().toISOString() }; d.tlsProbe = null; return d.tlsInfo; });
return d.tlsProbe;
}
/**
* 설치 명령 만들기. 신뢰되지 않는 공개 주소면 내려받기에 핀을 붙이고(-k 는 CA 검증만 끄며 핀은 그대로 강제된다) 스크립트에 --hal-pin 을 넘긴다.
* 스크립트는 핀으로 /agent/v1/hal-ca.pem 을 받아 이후 curl·Node 호출의 CA 로 쓴다. 핀은 공개 정보라 명령에 넣어도 된다.
*/
export function installCommand(publicUrl, token, { kind = 'managed', siteId = null, tlsInfo = null } = {}) {
const pinned = tlsInfo && tlsInfo.trusted === false && tlsInfo.pin;
const fetchPart = pinned ? `curl -fsSL -k --pinnedpubkey '${tlsInfo.pin}' ${publicUrl}/agent/v1/install.sh` : `curl -fsSL ${publicUrl}/agent/v1/install.sh`;
const extra = `${kind === 'external' ? ` --external --site ${siteId}` : ''}${pinned ? ` --hal-pin '${tlsInfo.pin}'` : ''}`;
return { installCommand: `${fetchPart} | bash -s -- --hal ${publicUrl} --token ${token}${extra}`, tls: tlsInfo ? { trusted: tlsInfo.trusted, reason: tlsInfo.reason || null, pin: tlsInfo.pin || null, caAvailable: !!(tlsInfo.ca && tlsInfo.ca.length) } : null };
}
+3 -1
View File
@@ -53,7 +53,9 @@ export function registerUi(app, d) {
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>`);
// [0.4.1] 부트스트랩은 HAL 서버의 Node Agent·HawkEye 까지 bootstrap-hal.sh 가 함께 설치해 닫는다. 완료 뒤에는 "미완료" 대신 기능 단위 서버(포털·Webmaker·AriMail·백업)의 배포 대기 안내를 보인다(개발 지시 v0.4.1 요구 2).
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 진행 중(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, HAL 서버의 Node Agent ${health.bootstrap.firstAgent ? '등록됨 — 승인 대기' : '미설치'}) — 0.4.1 부터 bootstrap-hal.sh 가 HAL 서버의 Agent·HawkEye 를 함께 설치합니다. 기존 설치는 HAL 서버에서 <code>bash deploy/hal-host-agent.sh</code> 를 실행하세요. 포털 API·모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
else if (health.deployment && health.deployment.pending.length) banner.push(`<a class="alert info" href="/ops/releases">ⓘ 부트스트랩 완료 · 기능 단위 서버 배포 대기: ${health.deployment.pending.map((m) => esc(R.moduleLabel(m))).join(', ')} — 배포 릴리스 화면의 "역할별 통합 배포(새 서버)" 로 서버를 추가해 배포하세요</a>`);
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="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
+6 -1
View File
@@ -98,7 +98,12 @@ const ACT = {
if (!v) return; if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/settings/${b.m}/apply`, { serverIds: v.serverIds, restart: !!v.restart, nonce: Date.now() }); toast(`설정 적용 작업을 만들었습니다(${r.targets.length}대)`); 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 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
// [0.4.1] 이미 Agent 가 있는 서버면 "재등록 허용"(기존 Agent 해지, 재인증)을 물어 본다. HAL 인증서가 사설 CA 면 핀이 붙은 명령과 안내가 함께 온다.
'server-token': async (b) => {
let replace = false; try { const s = await call('GET', `/ops/api/servers/${b.id}`); if (s.server && s.server.agentId) { if (!confirm(`이 서버에는 이미 Node Agent(${s.server.agentId})가 등록되어 있습니다.\n같은 서버를 다시 설치하는 경우가 아니면 토큰이 필요 없습니다.\n\n기존 Agent 를 해지하고 새 Agent 를 등록하는 "재등록 허용" 토큰을 발급할까요?`)) return; replace = true; } } catch (_) {}
const r = await call('POST', `/ops/api/servers/${b.id}/token`, replace ? { replace: true } : {});
const tlsNote = r.tls && r.tls.trusted === false ? ` HAL 인증서가 시스템 CA 로 신뢰되지 않아(${r.tls.reason || '사설 CA'}) 명령에 공개키 핀을 붙였습니다 — 그대로 실행하면 스크립트가 HAL 의 CA 를 받아 씁니다.` : '';
showOnce(replace ? '일회용 재등록 정보(10분·1회, 기존 Agent 해지)' : '일회용 등록 정보(10분·1회)', r.installCommand || r.token, `대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.${tlsNote}`); },
'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(); },
+7 -1
View File
@@ -100,7 +100,9 @@ export function releasesView(data, changelog = {}) {
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">항목 없음</span>' },
{ l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).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 를 만들고 등록하세요' });
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
// [0.4.1] 부트스트랩 완료 뒤에는 기능 단위 서버 배포 대기 목록을 보인다("부트스트랩 미완료" 문구는 진행 중일 때만)
const pendingFeat = (data.deployment && data.deployment.pending) || [];
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영) · 부트스트랩 완료' : ' · <span class="err">부트스트랩 진행 중(HAL 서버 Agent 등록·승인 전)</span>'}${pendingFeat.length ? ` · <b>기능 단위 서버 배포 대기: ${pendingFeat.map((m) => esc(moduleLabel(m))).join(', ')}</b>` : running.manual_install_locked ? ' · 기능 모듈 모두 배치됨' : ''} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
<h2>최신 릴리스 배포(모듈별)</h2><div class="cards rel-cards">${cards}</div>
<div class="actions"><button class="btn primary" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>
<h2>상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)</h2><ul class="small">${(data.links || []).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
@@ -229,3 +231,7 @@ export function backupView(d) { return `<h2>복원 승인(§30.3)</h2>${table([{
<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: '소유권 이전 요청이 없습니다' })}`; }
/** [0.4.1] 모듈 표시 이름(배너·릴리스 화면의 "기능 단위 서버 배포 대기" 목록). 브라우저에서도 쓰므로 hal/modules 를 import 하지 않고 여기 둔다. */
export const MODULE_LABELS = { hal: 'HAL', agent: 'Node Agent', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye', observability: 'HawkEye' };
export function moduleLabel(m) { return MODULE_LABELS[m] || String(m); }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ariweb-suite",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
"type": "module",
+2 -2
View File
@@ -91,7 +91,7 @@ await test('4·6) 배포 릴리스: HAL 은 배포 대상 아님, HAL 먼저 규
await test('2) 역할별 통합 배포: 프로필대로 모듈을 순서대로 배포하고 서버에 프로필·역할·슬롯을 기록, 포털은 하나만(이미 있으면 거부) + 포털 이전 작업, 슬롯 기반 배치', async () => {
const h = makeHal(); const op = await loginOp(h); const s1 = await addServer(h, { name: '새서버', role: 'web' }); const s2 = await addServer(h, { name: '컨트롤', role: 'control' }); bootstrapDone(h.d, '0.3.17'); addRelease(h, '0.3.17', ['agent', 'portal', 'webmaker', 'arimail']);
ok(SERVER_PROFILES.webmaker && SERVER_PROFILES.webmaker.modules.includes('webmaker') && SERVER_PROFILES.webmaker.modules.includes('hawkeye') && SERVER_PROFILES.webmaker.modules[0] === 'agent', JSON.stringify(SERVER_PROFILES.webmaker));
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,portal,webhosting,webmaker');
let r = await op.as('GET', '/ops/api/provision/profiles'); eq(r.status, 200); ok(r.json.profiles.map((p) => p.id).sort().join(',') === 'arimail,backup,hal,portal,webhosting,webmaker'); // [0.4.1] hal 프로필(HAL 서버: agent·hawkeye)
// webmaker 프로필을 새서버에: agent → webmaker → hawkeye 순서로 자식 작업, 서버 profile·role·slots 기록
r = await op.as('POST', '/ops/api/provision', { serverId: s1.serverId, profile: 'webmaker', version: '0.3.17' }); eq(r.status, 200, JSON.stringify(r.json)); const pj = r.json.job;
const seen = []; await runAgents(h, [s1, s2], (job) => { seen.push(job.payload.module); return { status: 'done', result: { module: job.payload.module, actualVersion: job.payload.version || '0.1.1', health: { ok: true }, active: true } }; });
@@ -141,7 +141,7 @@ await test('3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약,
});
await test('버전·문서(0.3.17)', async () => {
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.17.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.17'));
ok(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').includes('HawkEye') && !/observability\b(?! 는 옛 이름)/.test(readFileSync(join(ROOT, 'hal', 'ai', 'skill.md'), 'utf8').replace('옛 이름 observability', '')), '스킬 문서도 HawkEye');
});
+1 -1
View File
@@ -150,7 +150,7 @@ await test('5) 화면·AI 도구·정적 계약', async () => {
});
await test('버전·문서·모듈 대응(0.4.0 · Webmaker 1.9.0 · AriMail 1.1.0)', async () => {
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
const h = makeHal(); eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.0.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.0')); ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.0'));
ok(existsSync(join(ROOT, 'docs', 'HAL-SETTINGS.md')));
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite && wm.ariwebSuite.halCompat, '0.4.0');
+192
View File
@@ -0,0 +1,192 @@
/**
* test/hal-041.test.mjs — Ariweb Suite 0.4.1(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.1.md`, Oz 2026-11-06): 서버 등록 TLS·이미 등록 메시지 + HAL 배포가 Agent·HawkEye 를 함께 설치해 부트스트랩을 닫는다.
*
* 계약(요약)
* 1) hal/tls.mjs: HAL 공개 주소가 자체 서명·사설 CA 면 trusted=false·공개키 핀(curl --pinnedpubkey 형식)·신뢰 앵커 PEM. http 는 trusted. 설치 명령은 핀(-k --pinnedpubkey / --hal-pin)을 붙인다.
* 공개 경로 GET /agent/v1/hal-ca.pem(신뢰 앵커) · GET /agent/v1/tls(상태) — 무서명.
* 2) 이미 등록된 서버: 같은 서버의 두 번째 토큰 → 409 server_already_registered("이미 등록된 서버"), 쓴 토큰 재사용 → 409 token_used(같은 문구), 재등록 허용 토큰(replace) → 기존 Agent 해지·새 Agent. API 의 replace 는 재인증.
* 3) Agent CLI register: CA 없이 자체 서명 HAL → 스택 대신 한 줄 원인·조치(종료 1), --ca → HAL 응답(bad_token), 등록 뒤 재실행 → "이미 등록된 서버"(종료 2). agent.json 에 caFile, status 는 런타임 CA 로 연결.
* 4) 부트스트랩: HAL 서버 자신(control, 프로필 hal) 의 Agent 등록·승인으로 완료(CLI server-create/server-token --json/server-approve), 배너는 "부트스트랩 미완료" 대신 기능 단위 서버 배포 대기. heartbeat 의 observability 는 hawkeye 로.
* 5) 스크립트·문서·버전: agent-install.sh(--hal-pin·--ca·NODE_EXTRA_CA_CERTS·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 가 부름, 0.4.1.
*/
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { invoke } from '../lib/http.mjs';
import { signHeaders } from '../lib/serviceauth.mjs';
import { createHalDeps } from '../hal/deps.mjs';
import { buildApp } from '../hal/app.mjs';
import * as core from '../hal/core.mjs';
import * as ops from '../hal/ops.mjs';
import { probePublicTls, installCommand, caBundle, spkiPin, ensureTlsInfo } from '../hal/tls.mjs';
import { SERVER_PROFILES, MODULE_GUIDES } from '../hal/modules/index.mjs';
import * as R from '../hal/ui/render.mjs';
import { applyHalCa, friendlyFetchError, splitPem } from '../agent/halca.mjs';
import { loadAgentConfig } from '../agent/config.mjs';
import https from 'node:https';
import { X509Certificate } from 'node:crypto';
import { execFileSync, spawnSync, spawn } from 'node:child_process';
import { mkdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomBytes } from 'node:crypto';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const rnd = (p) => `${p}-${randomBytes(4).toString('hex')}`;
function makeHal(over = {}) {
const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true });
const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, agentPollMs: 20, agentWaitTimeoutMs: 4000, fanoutTimeoutMs: 6000, values: {}, set(p) { Object.assign(this, p); }, ...over };
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => ({ ok: true, status: 200, text: async () => 'ok', json: async () => ({ ok: true }) }) }); d.jobsRunning = true;
return { d, app: buildApp(d), dataDir };
}
async function loginOp(h) { const pw = rnd('pw'); ops.createAccount(h.d, { login: 'ozops', password: pw, displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } }); ok(r.json.ok); return { pw, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
const agentCall = (app, id, secret, method, path, body) => invoke(app, method, path, { body, headers: sig(`agent:${id}`, secret, method, path, body) });
/** 자체 서명 인증서(hal.local.test) + HTTPS 서버. handler 가 없으면 HAL 앱(invoke)으로 중계한다 — 실제 TLS 핸드셰이크·fetch 경로를 시험한다. */
async function tlsFixture(h = null) {
const dir = tmpDir(); const key = join(dir, 'key.pem'); const cert = join(dir, 'cert.pem');
execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1', '-nodes', '-keyout', key, '-out', cert, '-days', '2', '-subj', '/CN=hal.local.test', '-addext', 'subjectAltName=DNS:hal.local.test,DNS:localhost,IP:127.0.0.1'], { stdio: 'ignore' });
const srv = https.createServer({ key: readFileSync(key), cert: readFileSync(cert) }, async (req, res) => {
if (!h) { res.setHeader('content-type', 'application/json'); return res.end(JSON.stringify({ ok: true })); }
let text = ''; for await (const c of req) text += c; const r = await invoke(h.app, req.method, req.url, { body: text ? JSON.parse(text) : undefined, headers: Object.fromEntries(Object.entries(req.headers).filter(([k]) => k.startsWith('x-') || k === 'content-type')) });
res.writeHead(r.status || 200, { 'content-type': 'application/json', ...(r.headers || {}) }); res.end(r.text);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r)); const port = srv.address().port;
return { dir, cert, key, port, url: `https://127.0.0.1:${port}`, close: () => new Promise((r) => srv.close(r)) };
}
/** Agent CLI 를 별도 프로세스로(비동기 — 같은 프로세스의 HTTPS 픽스처가 응답해야 하므로 spawnSync 를 쓰면 안 된다) */
const agentCli = (dataDir, args, extraEnv = {}) => new Promise((resolve) => { const p = spawn(process.execPath, ['--no-warnings', join(ROOT, 'agent', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_AGENT_DATA: dataDir, ARIWEB_AGENT_STUB_CMDS: '1', ...extraEnv } }); let stdout = ''; let stderr = ''; p.stdout.on('data', (c) => { stdout += c; }); p.stderr.on('data', (c) => { stderr += c; }); const t = setTimeout(() => p.kill('SIGKILL'), 40000); p.on('close', (status) => { clearTimeout(t); resolve({ status, stdout, stderr }); }); });
// HAL CLI: 공개 주소는 환경(HAL_PUBLIC_URL)으로 고정해 실제 hal.ariweb.net 에 TLS 확인을 보내지 않는다
const halCli = (dataDir, args, extraEnv = {}) => spawnSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0', HAL_PUBLIC_URL: 'http://127.0.0.1:1', ...extraEnv }, encoding: 'utf8', timeout: 40000 });
await test('[요구 1] TLS 확인: 자체 서명 HAL 주소 → trusted=false·공개키 핀·신뢰 앵커 PEM / http 주소 → 신뢰 / 접속 불가 → null', async () => {
const fx = await tlsFixture();
try {
const info = await probePublicTls(fx.url); eq(info.trusted, false); eq(info.reason, 'DEPTH_ZERO_SELF_SIGNED_CERT'); ok(/^sha256\/\/[A-Za-z0-9+/]+=*$/.test(info.pin), '핀 형식 ' + info.pin);
eq(info.pin, spkiPin(new X509Certificate(readFileSync(fx.cert))), '핀 = 서버 인증서 SPKI sha256'); eq(info.ca.length, 1); eq(info.caSource, 'chain'); ok(caBundle(info).includes('BEGIN CERTIFICATE')); ok(info.subject.includes('hal.local.test'));
// 운영자 지정 CA 파일(<data>/tls/hal-ca.pem)이 있으면 체인 대신 그 파일 — 그리고 그 CA 로 검증되면 trusted=true
const dataDir = tmpDir(); mkdirSync(join(dataDir, 'tls')); writeFileSync(join(dataDir, 'tls', 'hal-ca.pem'), readFileSync(fx.cert)); const info2 = await probePublicTls(fx.url, { dataDir }); eq(info2.trusted, true); eq(info2.reason, 'trusted_by_file_ca'); eq(info2.caSource, 'file'); eq(info2.ca.length, 1, '파일 CA 를 그대로 제공(다른 서버가 받아 쓴다)');
const plain = await probePublicTls('http://127.0.0.1:1'); eq(plain.trusted, true); eq(plain.reason, 'plain_http'); eq(plain.pin, null);
const dead = await probePublicTls('https://127.0.0.1:1', { timeoutMs: 1500 }); eq(dead.trusted, null); ok(dead.error.includes('접속할 수 없습니다'));
eq((await probePublicTls('not a url')).reason, 'bad_url');
} finally { await fx.close(); }
});
await test('[요구 1] 설치 명령·공개 경로: 신뢰되지 않으면 -k --pinnedpubkey 와 --hal-pin, 신뢰되면 핀 없음; /agent/v1/hal-ca.pem·/agent/v1/tls 는 무서명 공개', async () => {
const fx = await tlsFixture(); const h = makeHal({ publicUrl: fx.url });
try {
const info = await ensureTlsInfo(h.d); eq(info.trusted, false); eq(h.d.tlsInfo.pin, info.pin); ok(await ensureTlsInfo(h.d) === info, '10분 안에는 캐시');
const srv = core.createServer(h.d, { name: 'ws2', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
ok(t.installCommand.startsWith(`curl -fsSL -k --pinnedpubkey '${info.pin}' ${fx.url}/agent/v1/install.sh | bash -s -- --hal ${fx.url} --token ${t.token} --hal-pin '${info.pin}'`), t.installCommand); eq(t.tls.trusted, false); eq(t.tls.caAvailable, true); eq(t.existingAgent, null);
const plain = installCommand('https://hal.ariweb.net', 'TOK', { tlsInfo: { trusted: true, pin: 'sha256//x' } }); eq(plain.installCommand, 'curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token TOK'); eq(installCommand('https://h', 'T', { tlsInfo: null }).tls, null);
eq(installCommand('https://h', 'T', { kind: 'external', siteId: 's1', tlsInfo: { trusted: false, pin: 'sha256//p' } }).installCommand, "curl -fsSL -k --pinnedpubkey 'sha256//p' https://h/agent/v1/install.sh | bash -s -- --hal https://h --token T --external --site s1 --hal-pin 'sha256//p'");
let r = await invoke(h.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 200); ok(r.text.includes('BEGIN CERTIFICATE'), '신뢰 앵커 PEM'); ok(!r.text.includes('PRIVATE'), '개인키 없음');
r = await invoke(h.app, 'GET', '/agent/v1/tls'); eq(r.status, 200); eq(r.json.tls.trusted, false); eq(r.json.tls.pin, info.pin); eq(r.json.tls.caAvailable, true); ok(!JSON.stringify(r.json).includes('PRIVATE'));
// 신뢰되는(http) 주소면 CA 경로는 404(필요 없음)
const h2 = makeHal({ publicUrl: 'http://127.0.0.1:1' }); r = await invoke(h2.app, 'GET', '/agent/v1/hal-ca.pem'); eq(r.status, 404); eq(r.json.error, 'no_private_ca');
// 운영자 API: 토큰 발급 응답에 tls·installCommand(핀)·existingAgent
const op = await loginOp(h); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); ok(r.json.installCommand.includes('--hal-pin')); eq(r.json.tls.trusted, false);
} finally { await fx.close(); }
});
await test('[요구 1] 이미 등록된 서버: 두 번째 토큰 → 409 server_already_registered, 쓴 토큰 → 409 token_used(이미 등록된 서버 문구), 재등록 허용 토큰 → 기존 Agent 해지·새 Agent, API replace 는 재인증', async () => {
const h = makeHal(); const srv = core.createServer(h.d, { name: 'WS1', role: 'web' });
const t1 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a1 = r.json.agentId;
// 같은 명령을 다시 실행(쓴 토큰)
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t1.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'token_used'); ok(r.json.message.includes('이미 등록된 서버'), r.json.message); ok(r.json.message.includes(a1));
// 새 토큰(재등록 허용 없음)
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); ok(t2.existingAgent && t2.existingAgent.id === a1, '발급 응답이 기존 Agent 를 알린다');
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.status, 409); eq(r.json.error, 'server_already_registered'); ok(r.json.message.includes('이미 등록된 서버입니다') && r.json.message.includes('WS1') && r.json.message.includes(a1), r.json.message);
eq(core.getServer(h.d, srv.id).agent_id, a1, '서버의 Agent 는 그대로'); eq(core.getAgent(h.d, a1).status, 'active'); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at, '거부된 토큰은 소비되지 않는다');
// 서버 없는 managed 토큰 + 같은 호스트 이름 → 같은 거부
const t3 = core.issueRegistrationToken(h.d, { kind: 'managed' }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t3.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); eq(r.json.error, 'server_already_registered');
// 재등록 허용 토큰
const t4 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, replace: true }); eq(t4.replace, true);
r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t4.token, facts: { hostname: 'WS1' }, version: '0.4.1' } }); ok(r.json.ok, JSON.stringify(r.json)); const a2 = r.json.agentId; ok(a2 !== a1);
eq(core.getServer(h.d, srv.id).agent_id, a2); eq(core.getAgent(h.d, a1).status, 'revoked'); eq(core.getAgent(h.d, a2).status, 'active'); ok(!h.d.vault.has(`service:agent:${a1}`), '옛 Agent 비밀 제거'); ok(h.d.vault.has(`service:agent:${a2}`));
const hb = await agentCall(h.app, a1, 'x', 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: {} }); eq(hb.status, 401, '해지된 Agent 는 거부');
ok(h.d.store.get("SELECT COUNT(*) AS n FROM audit_log WHERE action='agent.replace'").n >= 1, '감사 agent.replace');
// 운영자 API: replace 는 재인증 필요, 보통 발급은 재인증 없이
const op = await loginOp(h); h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, {}); eq(r.status, 200); eq(r.json.existingAgent.id, a2); eq(r.json.replace, false);
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date().toISOString()); r = await op.as('POST', `/ops/api/servers/${srv.id}/token`, { replace: true }); eq(r.status, 200); eq(r.json.replace, true);
});
await test('[요구 1] Agent CLI register: CA 없이 자체 서명 HAL → 한 줄 원인·조치(스택 없음), --ca → HAL 응답, 등록 뒤 재실행 → 이미 등록된 서버, caFile 저장·런타임 CA 로 status 연결', async () => {
const h = makeHal(); const fx = await tlsFixture(h); h.d.config.publicUrl = fx.url;
try {
const data = join(tmpDir(), 'agent-data'); mkdirSync(data, { recursive: true });
let r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope']); eq(r.status, 1); ok(r.stderr.includes('인증서를 확인할 수 없습니다') && r.stderr.includes('DEPTH_ZERO_SELF_SIGNED_CERT') && r.stderr.includes('--hal-pin') && r.stderr.includes('--ca'), r.stderr); ok(!r.stderr.includes('TypeError') && !r.stderr.includes('at async'), '스택 없음: ' + r.stderr);
r = await agentCli(data, ['register', '--hal', fx.url, '--token', 'nope', '--ca', fx.cert]); eq(r.status, 1); ok(r.stderr.includes('등록 실패: bad_token'), r.stderr);
const srv = core.createServer(h.d, { name: 'ws-cli', role: 'web' }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id });
r = await agentCli(data, ['register', '--hal', fx.url, '--token', t.token, '--ca', fx.cert, '--role', 'web', '--label', 'ws-cli']); eq(r.status, 0, r.stderr); ok(r.stdout.includes('등록 완료'));
const saved = JSON.parse(readFileSync(join(data, 'agent.json'), 'utf8')); eq(saved.caFile, fx.cert); eq(saved.serverId, srv.id); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: data }).caFile, fx.cert);
// 다시 실행(새 토큰이어도) → 이미 등록된 서버, 종료 2, HAL 호출 없음
const t2 = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id }); r = await agentCli(data, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 2); ok(r.stderr.includes('이미 등록된 서버입니다') && r.stderr.includes(saved.id), r.stderr); ok(!h.d.store.get('SELECT used_at FROM registration_tokens WHERE id=?', t2.id).used_at);
// status: agent.json 의 caFile 을 런타임에 적용해 자체 서명 HAL 에 연결(NODE_EXTRA_CA_CERTS 없이)
r = await agentCli(data, ['status']); eq(r.status, 0, r.stderr + r.stdout); ok(r.stdout.includes('연결 정상') && r.stdout.includes('runtime'), r.stdout);
// HAL 쪽 거부(이미 등록된 서버)도 읽을 수 있는 한 줄 + 종료 3
const data2 = join(tmpDir(), 'agent-data2'); mkdirSync(data2, { recursive: true }); r = await agentCli(data2, ['register', '--hal', fx.url, '--token', t2.token, '--ca', fx.cert]); eq(r.status, 3); ok(r.stderr.includes('server_already_registered') && r.stderr.includes('이미 등록된 서버'), r.stderr);
} finally { await fx.close(); }
// halca 단위: 메시지 분류·PEM 분할·적용
eq(friendlyFetchError({ cause: { code: 'SELF_SIGNED_CERT_IN_CHAIN' } }, 'https://hal.local.test/').tls, true); ok(friendlyFetchError({ cause: { code: 'ECONNREFUSED' } }, 'https://x').message.includes('연결할 수 없습니다'));
eq(splitPem('junk\n-----BEGIN CERTIFICATE-----\nA\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nB\n-----END CERTIFICATE-----\n').length, 2); eq(applyHalCa(null).reason, 'no_ca'); eq(applyHalCa('/nonexistent/ca.pem').reason, 'missing'); eq(applyHalCa('/etc/hostname', { env: { NODE_EXTRA_CA_CERTS: '/etc/hostname' } }).via, 'env');
});
await test('[요구 2] 부트스트랩: HAL 서버 자신의 Agent(control·프로필 hal) 등록·승인으로 완료, 배너는 기능 단위 서버 배포 대기, observability 보고는 hawkeye 로', async () => {
const h = makeHal(); core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.4.1' }); core.setBootstrap(h.d, { release_version: '0.4.1', main_commit: 'abc', artifact_sha256: 'x' });
const op = await loginOp(h);
let page = await op.as('GET', '/ops/'); ok(page.text.includes('부트스트랩 진행 중') && page.text.includes('hal-host-agent.sh'), '완료 전: 진행 중 안내'); ok(!page.text.includes('기능 단위 서버 배포 대기'));
eq(core.selfHealth(h.d).bootstrap.complete, false); eq(core.selfHealth(h.d).deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']);
// HAL 서버 자신(프로필 hal: agent + hawkeye 만)
ok(SERVER_PROFILES.hal && SERVER_PROFILES.hal.role === 'control'); eq(SERVER_PROFILES.hal.modules, ['agent', 'hawkeye']); ok(MODULE_GUIDES.agent.install.includes('bootstrap-hal.sh'));
const srv = core.createServer(h.d, { name: 'hal21.ariweb.net', role: 'control', by: 'cli:bootstrap' }); core.updateServer(h.d, srv.id, { profile: 'hal', role: 'control', slots_json: '{}' }, 'cli:bootstrap');
const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: srv.id, by: 'cli:bootstrap' });
let r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'bear', units: { hal: 'ariweb-hal' } }, version: '0.4.1' } }); ok(r.json.ok); const ag = r.json;
r = await agentCall(h.app, ag.agentId, ag.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: { cpu: 1, mem: { total: 10, used: 5 }, disk: { total: 100, used: 10 }, load1: 0.1 }, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, observability: { version: '0.4.1', active: true, health: 'ok' }, hal: { version: '0.4.1', active: true, health: 'ok' } }, clockSkewMs: 0 }); ok(r.json.ok);
const mods = h.d.store.all('SELECT module FROM module_targets WHERE server_id=? ORDER BY module', srv.id).map((x) => x.module); eq(mods, ['agent', 'hal', 'hawkeye'], 'observability → hawkeye(옛 행 없음)');
const s = core.approveServer(h.d, srv.id, 'cli:bootstrap'); eq(s.status, 'ready'); eq(s.profile, 'hal');
const hh = core.selfHealth(h.d); eq(hh.bootstrap.complete, true); eq(hh.bootstrap.manualInstallLocked, true); eq(hh.bootstrap.firstAgent, ag.agentId); eq(hh.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); eq(hh.deployment.agents, 1);
page = await op.as('GET', '/ops/'); ok(!page.text.includes('부트스트랩 미완료') && !page.text.includes('부트스트랩 진행 중'), '완료 뒤 미완료 문구 없음'); ok(page.text.includes('부트스트랩 완료') && page.text.includes('기능 단위 서버 배포 대기') && page.text.includes('고객 포털, Webmaker, AriMail, 백업'), page.text.slice(page.text.indexOf('alerts'), page.text.indexOf('alerts') + 600));
r = await op.as('GET', '/ops/api/releases'); eq(r.json.deployment.pending, ['portal', 'webmaker', 'arimail', 'backup']); const html = R.releasesPage ? R.releasesPage(r.json) : ''; if (html) ok(html.includes('기능 단위 서버 배포 대기') && !html.includes('부트스트랩 미완료'));
r = await invoke(h.app, 'GET', '/api/health'); eq(r.json.bootstrap.complete, true); eq(r.json.deployment.pending.length, 4);
// 기능 모듈이 어떤 서버에든 배치되면 대기 목록에서 빠진다(Webmaker 서버 추가 + heartbeat)
const web = core.createServer(h.d, { name: 'web1', role: 'web' }); const tw = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: web.id }); r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: tw.token, facts: { hostname: 'web1' }, version: '0.4.1' } }); const aw = r.json;
await agentCall(h.app, aw.agentId, aw.secret, 'POST', '/agent/v1/heartbeat', { version: '0.4.1', metrics: {}, modules: { agent: { version: '0.4.1', active: true, health: 'ok' }, webmaker: { version: '1.9.0', active: true, health: 'ok' } }, clockSkewMs: 0 });
eq(core.selfHealth(h.d).deployment.pending, ['portal', 'arimail', 'backup']); eq(core.deploymentStatus(h.d).deployed.map((i) => i.module), ['webmaker']);
page = await op.as('GET', '/ops/'); ok(page.text.includes('고객 포털, AriMail, 백업'));
eq(R.moduleLabel('observability'), 'HawkEye'); eq(R.moduleLabel('zzz'), 'zzz');
});
await test('[요구 2] CLI: server-create --profile hal · server-token --json · server-approve → 부트스트랩 완료(별도 프로세스)', async () => {
const dataDir = join(tmpDir(), 'hal-cli'); mkdirSync(dataDir, { recursive: true });
// HAL 기동이 남기는 실행 버전(claimLive) — 서비스 대신 같은 DB 로 기록
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); core.claimLive(d, { instanceId: d.instanceId, version: '0.4.1' }); d.store.close(); }
let r = halCli(dataDir, ['server-create', '--name', 'hal.local.test', '--role', 'control', '--profile', 'hal', '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const srv = JSON.parse(r.stdout.trim()); ok(srv.id.startsWith('srv_')); eq(srv.profile, 'hal'); eq(srv.role, 'control');
r = halCli(dataDir, ['server-create', '--name', 'x', '--profile', 'nope']); eq(r.status, 1); ok(r.stderr.includes('알 수 없는 프로필'));
r = halCli(dataDir, ['server-token', '--server', srv.id, '--by', 'bootstrap', '--json']); eq(r.status, 0, r.stderr); const tok = JSON.parse(r.stdout.trim()); ok(tok.token && tok.installCommand.includes('--hal http://127.0.0.1:1')); eq(tok.tls.trusted, true, 'http 주소는 신뢰(핀 없음)'); ok(!tok.installCommand.includes('--hal-pin'));
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 1); ok(r.stderr.includes('agent_missing'), 'Agent 연결 전 승인 거부: ' + r.stderr);
// 등록(앱 없이 core 로 같은 DB) → 승인 CLI → 부트스트랩 완료
{ const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'http://127.0.0.1:1', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1, emergencyTtlMinutes: 30, values: {}, set() {} }; const d = createHalDeps({ config }); const reg = core.registerAgent(d, { token: tok.token, facts: { hostname: 'hal.local.test' }, version: '0.4.1' }); ok(reg.agentId); d.store.close(); }
r = halCli(dataDir, ['server-approve', '--server', srv.id, '--by', 'bootstrap']); eq(r.status, 0, r.stderr); const ap = JSON.parse(r.stdout.trim()); eq(ap.status, 'ready'); eq(ap.bootstrapComplete, true); eq(ap.pendingModules, ['portal', 'webmaker', 'arimail', 'backup']);
r = halCli(dataDir, ['tls-probe', '--url', 'http://127.0.0.1:1']); eq(r.status, 0, r.stderr); eq(JSON.parse(r.stdout).trusted, true);
// --replace 가 없으면 기존 Agent 가 있는 서버의 토큰 발급 출력에 주의가 붙는다
r = halCli(dataDir, ['server-token', '--server', srv.id]); eq(r.status, 0); ok(r.stdout.includes('이미 Agent') && r.stdout.includes('--replace'), r.stdout);
});
await test('[요구 1·2] 스크립트·문서·버전: agent-install.sh(핀·CA·이미 등록), hal-host-agent.sh, bootstrap-hal.sh 연결, 0.4.1 문서', async () => {
const inst = readFileSync(join(ROOT, 'deploy', 'agent-install.sh'), 'utf8');
for (const s of ['--hal-pin', '--ca', '--insecure', '--pinnedpubkey', '/agent/v1/hal-ca.pem', '--cacert', 'NODE_EXTRA_CA_CERTS', '이미 등록된 서버', '인증서 문제', 'Environment=NODE_EXTRA_CA_CERTS']) ok(inst.includes(s), `agent-install.sh: ${s}`);
const host = readFileSync(join(ROOT, 'deploy', 'hal-host-agent.sh'), 'utf8'); for (const s of ['server-create', '--profile hal', 'server-token', '--json', 'agent-install.sh', '--role control', 'server-approve', 'http://127.0.0.1:8900']) ok(host.includes(s), `hal-host-agent.sh: ${s}`);
const boot = readFileSync(join(ROOT, 'deploy', 'bootstrap-hal.sh'), 'utf8'); ok(boot.includes('hal-host-agent.sh') && boot.includes('--no-agent') && boot.includes('tls-probe') && boot.includes('6/7') && boot.includes('7/7'));
for (const f of ['agent-install.sh', 'hal-host-agent.sh', 'bootstrap-hal.sh']) { const r = spawnSync('bash', ['-n', join(ROOT, 'deploy', f)], { encoding: 'utf8' }); eq(r.status, 0, `${f} bash -n: ${r.stderr}`); }
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(makeHal().d.version, '0.4.1');
ok(existsSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md')) && readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.4.1.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.4.1'));
ok(readFileSync(join(ROOT, 'README.md'), 'utf8').includes('0.4.1')); ok(readFileSync(join(ROOT, 'docs', 'HAL-AGENT.md'), 'utf8').includes('hal-ca.pem'));
// 도입 모듈은 바뀌지 않았다(HAL 0.4.0 대응 그대로 — 0.4.1 호환)
const wm = JSON.parse(readFileSync(join(ROOT, 'modules', 'webmaker', 'package.json'), 'utf8')); eq(wm.version, '1.9.0'); eq(wm.ariwebSuite.halCompat, '0.4.0');
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite.halCompat, '0.4.0');
ok(!readFileSync(join(ROOT, 'agent', 'index.mjs'), 'utf8').includes('out.observability'), 'Agent 는 hawkeye 로 보고');
});
done();
+1 -1
View File
@@ -58,7 +58,7 @@ await test('버전·문서(0.3.10)', async () => {
const d = createHalDeps({ config, dbFile: ':memory:', fetchImpl: async () => { throw new Error('no net'); } }); const app = buildApp(d);
const pw = 'pw-' + randomBytes(12).toString('hex'); ops.createAccount(d, { login: 'ozops', password: pw, displayName: '오즈' }); const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: pw } });
const r = await invoke(app, 'GET', '/ops/ai', { cookies: { aw_ops: l.cookies.aw_ops } }); ok(r.text.includes('복사'), '셸 안내에 복사 방법');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.10.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.10'));
});
done();
+1 -1
View File
@@ -192,7 +192,7 @@ await test('화면·버전·문서(0.3.2): AI 연동 화면에 프리셋·사전
for (const s of ['OpenRouter', 'Gemini', 'ChatGPT', '기타', '사전 준비', '텍스트 셸', 'MCP', '도구 정책']) ok(r.text.includes(s), '화면 문구 ' + s);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.shellHtml === 'function' && typeof AR.guideHtml === 'function' && typeof AR.toolPolicyHtml === 'function');
ok(AR.shellHtml({ id: 's1', status: 'running', tool: 'claude', command: 'login' }, 'Open <https://x> paste').includes('&lt;https://x&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.2.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.2'));
});
done();
+1 -1
View File
@@ -162,7 +162,7 @@ await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
const AR = await import('../hal/ui/public/ai-render.mjs'); ok(typeof AR.modelSelectHtml === 'function' && typeof AR.shellSessionsHtml === 'function' && typeof AR.commandsHelpHtml === 'function');
ok(AR.modelSelectHtml({ id: 'c1', model: 'a', models: ['a', 'b<'] }).includes('b&lt;') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.9.md'), 'utf8').includes('Oz')); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.9'));
});
done();
+2 -2
View File
@@ -302,7 +302,7 @@ await test('화면·버전·문서: 공통 틀에 AI 패널(통합 탭 고정)·
r = await invoke(h.app, 'GET', '/ops/ui/ai.js'); eq(r.status, 200); r = await invoke(h.app, 'GET', '/ops/ui/ai-render.mjs'); eq(r.status, 200);
const AR = await import('../hal/ui/public/ai-render.mjs'); const tabs = AR.tabsHtml([{ id: 'room_group', kind: 'group', title: '통합', fixed: true }, { id: 'r2', kind: 'direct', title: '<네오>', connection: { status: 'active' } }], 'r2'); ok(tabs.indexOf('통합') < tabs.indexOf('&lt;네오&gt;') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('&lt;b&gt;'));
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0'); eq(h.d.version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1'); eq(h.d.version, '0.4.1');
r = await op.as('GET', '/ops/api/ai/skill'); eq(r.status, 200); ok(r.json.skill.includes('pending') && r.json.skill.includes('needs_user') && r.json.skill.includes('reauth_required'), '상태·로그 의미 설명');
ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.0.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.0'); ok(readFileSync(join(ROOT, 'Ariweb-Suitle_Dev-Order_v0.3.1.md'), 'utf8').includes('Oz'), '개발 지시 v0.3.1'); ok(readFileSync(join(ROOT, 'VERSION_HISTORY.md'), 'utf8').includes('## 0.3.1'));
ok(readFileSync(join(ROOT, 'docs', 'HAL-AI.md'), 'utf8').includes('hal_servers_list'), 'AI 도구 문서');
@@ -315,7 +315,7 @@ await test('MCP(stdio) 서버: initialize·tools/list·읽기 도구 call 은
const send = (o) => child.stdin.write(JSON.stringify(o) + '\n'); const waitFor = async (id) => { for (let i = 0; i < 200; i++) { const m = out.find((x) => x.id === id); if (m) return m; await sleep(25); } throw new Error('MCP 응답 없음 ' + id); };
send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'test' } } }); const init = await waitFor(1); ok(init.result.serverInfo.name.includes('HAL') && init.result.capabilities.tools);
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }); const tl = await waitFor(2); ok(tl.result.tools.some((t) => t.name === 'hal_servers_list' && t.inputSchema)); ok(!tl.result.tools.some((t) => /shell|exec/.test(t.name)));
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.4.0');
send({ jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'hal_health', arguments: {} } }); const hc = await waitFor(3); ok(!hc.result.isError); ok(JSON.parse(hc.result.content[0].text).version === '0.4.1');
send({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'hal_incident_open', arguments: { key: 'k', title: 't', severity: 'warning' } } }); const wc = await waitFor(4); ok(!wc.result.isError && /승인/.test(wc.result.content[0].text) && JSON.parse(wc.result.content[0].text).approvalRequested === true, JSON.stringify(wc)); // [0.3.2] 오류가 아니라 승인 요청(제안)으로 올라간다
send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'nope', arguments: {} } }); const nc = await waitFor(5); ok(nc.error || nc.result.isError);
child.kill(); await sleep(50);
+1 -1
View File
@@ -58,7 +58,7 @@ await test('일반 화면에는 고위험 재인증 전역 배너가 없다 —
ok(r.text.includes('data-act="reauth"'), '계정 화면의 수동 재인증 단추 유지');
ok(!r.text.includes(BANNER) && !r.text.includes('고위험 작업'), '계정 화면에 고위험 안내 없음');
// 다른 배너(부트스트랩 미완료)는 그대로 — 배너 영역 자체를 없앤 것이 아님
ok(alerts(r.text).includes('부트스트랩 미완료'), '다른 전역 배너는 유지');
ok(alerts(r.text).includes('부트스트랩 진행 중'), '다른 전역 배너는 유지');
});
await test('backend 보안 경계는 그대로: 10분 경과 뒤 고위험 API 는 403 reauth_required, CSRF 없는 재인증 거부, 재인증 뒤에만 통과', async () => {
+5 -5
View File
@@ -35,7 +35,7 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
const op = await loginOp(h);
r = await invoke(h.app, 'GET', '/ops/servers', { cookies: { aw_session: 'customer-cookie-example' } }); eq(r.status, 200); ok(r.text.includes('운영자 로그인') && !r.text.includes('서버 등록'), '고객 세션 쿠키로는 로그인 화면만');
r = await op.as('GET', '/ops/'); eq(r.status, 200); for (const m of ['운영 현황', '고객과 사용자 그룹', '주문과 결제 승인', '상품과 정책', '사이트와 배포', '메일 서비스', '도메인과 인증서', '서버와 용량', '백업과 복원', '서버 이전', '배포 릴리스', '운영 작업', '장애와 알림', '감사 기록', '플랫폼 복구']) ok(r.text.includes(m), '메뉴 ' + m);
ok(r.text.includes('부트스트랩 미완료'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"'));
ok(r.text.includes('부트스트랩 진행 중'), '부트스트랩 상태 배너'); ok(r.text.includes('data-page="overview"'));
// 로그인 화면은 고객 로그인과 분리된 문구
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
});
@@ -44,23 +44,23 @@ await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(c
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다');
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)'), '초기 상태 문구');
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 미설치)'), '초기 상태 문구');
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' });
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 0.1.1, 첫 Agent 미등록)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 0.1.1, HAL 서버의 Node Agent 미설치)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json));
core.approveServer(h.d, s.id, 'ozops');
const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true);
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음');
r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: <b>0.1.1</b>') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값');
// 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨
const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id });
const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops');
eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId);
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 등록됨)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 진행 중(HAL 실행 버전 확인 전, HAL 서버의 Node Agent 등록됨 — 승인 대기)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 진행 중\([^)]*\)/) || [''])[0]}`);
});
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
const h = makeHal(); const op = await loginOp(h);
+2 -2
View File
@@ -19,10 +19,10 @@ const require = createRequire(import.meta.url);
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WM = join(ROOT, 'modules', 'webmaker');
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.9.0(HAL 0.4.0 대응, 직접 편집 유지) · Suite 0.4.0(통합 설정 관제) 이다', () => {
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.9.0(HAL 0.4.0 대응, 직접 편집 유지) · Suite 0.4.1(통합 설정 관제 + 등록 TLS·부트스트랩 Agent) 이다', () => {
for (const f of ['lib/visual-editor.js', 'public/visual-editor-core.js', 'public/wm-ve-canvas.js', 'public/visual-editor-ui.js']) ok(existsSync(join(WM, f)), f);
eq(JSON.parse(readFileSync(join(WM, 'package.json'), 'utf8')).version, '1.9.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0');
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.1');
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
const pin = (readFileSync(join(ROOT, 'scripts', 'import-sources.mjs'), 'utf8').match(/name: 'webmaker'[^\n]*pin: '([0-9a-f]{40})'/) || [])[1]; ok(pin, '도입 스크립트의 webmaker pin');
if (imported.upstream && imported.upstream.workingTreeDirty) eq(imported.commit, '3b10859884ef2fd0f2af11934ab6c16370fd64b8', '개발 중 기록(--allow-dirty)은 0.1.1 기준 커밋 위의 작업 트리');