Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b31543a86 | ||
|
|
0f5e01a4cb | ||
|
|
9f1e490b51 |
@@ -0,0 +1,32 @@
|
||||
# Ariweb Suite 개발 지시 — 작성자: Oz · v0.4.0
|
||||
|
||||
**작성자:** Oz
|
||||
**작성일:** 2026-10-29
|
||||
**이전 버전:** v0.3.17(HawkEye·통합 배포, Oz) · 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
|
||||
**이번 개정:** 도입 모듈 관리 콘솔의 서버 단위 설정 권한을 HAL 로 이전(통합 설정 관제). HAL 0.3.17 → **0.4.0**. Webmaker 1.8.0 → **1.9.0**, AriMail 1.0.11 → **1.1.0**(각각 "HAL 0.4.0 대응" 명시). 이전 지시의 모든 기준은 그대로다.
|
||||
|
||||
## 1. 사용자 요구(Oz 원문, 약화 금지)
|
||||
1. **Webmaker 관리 콘솔의 권한 이전.** HAL 에서 각 Webmaker 의 시스템 콘솔 주요 기능을 통합 관제한다: API 키 관리 · 마진·가격 정책 관리 · 모델 등 주요 설정 관리 · 기타 전체 적용 서비스 · 개별 서버 설정. 통합 인터페이스를 새로 만들고, HAL 에서 배포된 모든 서버를 관리할 수 있게 한다.
|
||||
2. **AriMail 관리 콘솔의 통합 관리.** 도메인별 관리를 제외한 서버 단위 관리는 HAL 에서 통합 관리한다. 개별 메일 서버(도메인) 관리는 메일 서버 사용자가 직접 한다.
|
||||
3. **기타 서비스 서버의 설정 통합 관리.** 그 밖의 서버(Node Agent·백업·HawkEye 등) 설정도 HAL 에서 통합 관리한다.
|
||||
4. 필요한 모듈(Arimail·Webmaker·backup 등)을 모두 업그레이드한다. 각 모듈의 중간 버전을 1 올리고, 각 모듈이 **HAL 0.4.0 대응 버전**임을 명시한다. 이번 릴리스는 **HAL 0.4.0**.
|
||||
|
||||
## 2. 필수 동작(계약)
|
||||
1. **설정 목록(catalog)** `hal/settings/catalog.mjs`: 모듈별(webmaker·arimail·agent) 관리 항목을 묶음(group)으로 선언한다 — Webmaker: `API 키·인증`(auth.mode·auth.apiKey·auth.oauthToken·serviceApiKey·backlogKey·mail.apiKey·source.token·ariweb.secret·mail.adminPassword) · `마진·가격 정책`(marginPct·fx.*·signupCredit·maxEdits·defaultTier·tiers.*·signupApproval) · `모델`(internalRef·defaultModel·claudeModels·ai.claudeMaxTurns·ai.openaiMaxTurns·ai.maxJobMinutes·ai.maxConcurrentJobs·ai.codexSandbox) · `전체 적용 서비스`(service.allowSignup·letsEncryptEmail·mail.fromName·mail.defaultDomain·ariweb.portalUrl·endpoints.*·limits.*) · `서버별 설정`(service.publicBaseUrl·service.siteOrigin·service.trustedProxies·service.internalAllow·ai.claudeBin·ai.codexBin·source.*·mail.url·mail.apiUrl·mail.domain·mail.adminLogin·mail.nickname·mail.ip). AriMail: 서버 단위 항목(수·발신 방식, Resend, DNS 제공자, SMTP 중계, Haraka, 사이트 표시, 사용량 API, Ariweb SSO) — 도메인별 `LIMIT_*` 는 제외. Agent: heartbeatMs. 항목마다 형식(text·int·number·bool·enum·list·url·secret)·범위·재시작 필요 여부·범위(global/server).
|
||||
2. **저장소** `managed_settings`(scope `global` | `server:<id>`, module, key, value_json): 비밀값은 행에 두지 않고 Vault(`managed:<scope>:<module>:<key>`)에만 두며 행에는 참조만. 유효값 = 기본값 ← 전체(global) ← 서버 덮어쓰기(server). `managed_settings_state`(server_id, module, applied_hash, applied_at, status, error, job_id)에 서버별 적용 상태.
|
||||
3. **운영자 API**(`/ops/api/settings`): 목록·조회(GET, 비밀값은 "있음/없음"만) · 저장(PUT, 재인증 필요 — 가격·키·모델은 고객 영향) · 삭제(DELETE, 재인증) · 적용(POST `/:module/apply`, 재인증; 대상 비우면 그 모듈이 배치된 서버 전체, 없으면 즉시 `no_targets`). 알 수 없는 항목·형식 오류는 400 `bad_setting`. 감사 기록에 값은 남기지 않고 키 이름만.
|
||||
4. **적용 작업** `settings.apply`(부모) → `settings.apply.target`(서버별) → Agent 작업 `module.configure`: Agent 가 HAL 에서(`GET /agent/v1/settings/:module`, Agent HMAC 인증, 자기 서버 범위의 유효값·비밀값) 설정을 받아 모듈 데이터 디렉터리의 `hal-managed.json`(0600, 실행 계정이 다르면 그 계정으로)으로 쓰고, 요청 시 서비스를 재시작한 뒤 건강을 확인한다. 결과의 hash 가 HAL 이 계산한 hash 와 같아야 `ok`. 다른 서버의 설정은 받을 수 없다.
|
||||
5. **모듈 대응**: Webmaker 1.9.0 `lib/hal-managed.js` — 기동 때 `hal-managed.json` 을 읽어 settings·config·vault 에 적용하고(같은 hash 면 생략), 관리 항목은 관리자 콘솔 API 에서 409 `managed_by_hal` 로 잠그며 화면에 "HAL 관리" 표시. AriMail 1.1.0 `server/hal-managed.mjs` — 기동 때 설정 파일에 적용(hidden 항목 포함), 설정 API 에서 관리 항목 409 `managed_by_hal`, 화면 표시. 두 모듈 모두 package.json 에 `ariwebSuite.halCompat = "0.4.0"` 을 명시하고 CHANGELOG 에 적는다. Agent 0.4.0: `module.configure` 작업, 자기 설정(heartbeatMs)도 `hal-managed.json` 으로 받는다.
|
||||
6. **운영자 화면** `/ops/settings` "통합 설정 관제": 모듈 탭 → 범위(전체 / 서버 라디오) → 묶음별 입력(비밀값은 있음/없음·새 값 입력만) → 저장 → "서버에 적용"(체크 선택·재시작 여부) → 서버별 적용 상태(hash·시각·오류). 운영 현황에 "적용 안 된 설정" 수.
|
||||
7. **AI 도구**: `hal_settings_get`(읽기) · `hal_settings_set`(고위험, 비밀값은 받지 않음) · `hal_settings_apply`(고위험). 스킬 문서에 설명.
|
||||
8. 도입 모듈은 참조 저장소(WebHosting_AI_agent `release/1.9.0`, AriMail `release/1.1.0`)에 같은 변경을 커밋·push 하고, `scripts/import-sources.mjs` 의 고정 커밋을 새 커밋으로 갱신해 다시 들여온다(Suite 전용 수정 파일은 보존).
|
||||
|
||||
## 3. TDD·검증
|
||||
- `test/hal-040.test.mjs` 를 먼저 쓰고 RED 확인 뒤 구현(카탈로그·저장/조회/유효값/비밀값 Vault·API 권한·적용 작업→Agent→상태·Agent 설정 API 범위·화면·AI 도구·버전·문서). `test/agent.test.mjs` 에 `module.configure`. 모듈 저장소에 `test/hal-managed.test.*`. 기존 테스트·전체 Suite(버전 0.4.0).
|
||||
- 배포 뒤 bear read-back(마이그레이션·설정 API·화면), 이어서 HAL 작업으로 agent 0.4.0 → webmaker 1.9.0 → arimail 1.1.0 을 대상 서버에 순서대로 배포하고 건강을 확인한다(실패 시 중단·되돌리기).
|
||||
|
||||
## 4. 버전·배포
|
||||
- HAL 0.4.0: package.json·README·VERSION_HISTORY·개발 지시·docs. 커밋 → push → main 병합 → 깨끗한 main 빌드·verify → bear HAL 배포 → read-back → HAL 로 나머지 모듈 배포.
|
||||
|
||||
## 5. 완료 조건
|
||||
변경 파일, RED/GREEN·전체 테스트, 모듈 저장소 커밋·고정 커밋, 커밋·병합 커밋, artifact sha256, 배포 read-back, 모듈 배포 결과, 운영자 조치, 알려진 제한을 보고한다.
|
||||
@@ -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 운영 절차 포함), 알려진 제한을 보고한다.
|
||||
+51
-20
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"generatedAt": "2026-10-28T14:53:58.554Z",
|
||||
"generatedAt": "2026-10-29T06:35:15.406Z",
|
||||
"sources": [
|
||||
{
|
||||
"name": "webmaker",
|
||||
"repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent",
|
||||
"commit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
||||
"commit": "8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af",
|
||||
"dest": "modules/webmaker",
|
||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.",
|
||||
"upstream": {
|
||||
"version": "1.8.0",
|
||||
"branch": "feature/visual-editor-v1.8.0",
|
||||
"headCommit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
||||
"version": "1.9.0",
|
||||
"branch": "main",
|
||||
"headCommit": "8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af",
|
||||
"workingTreeDirty": false,
|
||||
"note": "고정 커밋의 깨끗한 체크아웃"
|
||||
},
|
||||
@@ -18,8 +18,8 @@
|
||||
{
|
||||
"from": "README.md",
|
||||
"to": "modules/webmaker/README.md",
|
||||
"status": "unchanged",
|
||||
"origHash": "d28788cd6b28ec72"
|
||||
"status": "updated",
|
||||
"origHash": "71f4a93b1afbd6e4"
|
||||
},
|
||||
{
|
||||
"from": "deploy/deploy.sh",
|
||||
@@ -339,6 +339,12 @@
|
||||
"status": "unchanged",
|
||||
"origHash": "bfdaa70652f846cc"
|
||||
},
|
||||
{
|
||||
"from": "lib/hal-managed.js",
|
||||
"to": "modules/webmaker/lib/hal-managed.js",
|
||||
"status": "imported",
|
||||
"origHash": "8c2d41a82a6c160f"
|
||||
},
|
||||
{
|
||||
"from": "lib/i18n.js",
|
||||
"to": "modules/webmaker/lib/i18n.js",
|
||||
@@ -528,8 +534,8 @@
|
||||
{
|
||||
"from": "lib/setupapi.js",
|
||||
"to": "modules/webmaker/lib/setupapi.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "3fd30ebd9efa47a6"
|
||||
"status": "updated",
|
||||
"origHash": "73c9b5fb48bebdfc"
|
||||
},
|
||||
{
|
||||
"from": "lib/setupauth.js",
|
||||
@@ -792,8 +798,8 @@
|
||||
{
|
||||
"from": "package.json",
|
||||
"to": "modules/webmaker/package.json",
|
||||
"status": "unchanged",
|
||||
"origHash": "7b5c2bf8a18df617"
|
||||
"status": "updated",
|
||||
"origHash": "f34de8005d9750a7"
|
||||
},
|
||||
{
|
||||
"from": "public/404.html",
|
||||
@@ -816,8 +822,8 @@
|
||||
{
|
||||
"from": "public/admin.js",
|
||||
"to": "modules/webmaker/public/admin.js",
|
||||
"status": "unchanged",
|
||||
"origHash": "6f37dafdf4502684"
|
||||
"status": "updated",
|
||||
"origHash": "f2e3de1470ba087f"
|
||||
},
|
||||
{
|
||||
"from": "public/app.js",
|
||||
@@ -1051,7 +1057,7 @@
|
||||
"from": "server.js",
|
||||
"to": "modules/webmaker/server.js",
|
||||
"status": "modified",
|
||||
"origHash": "b15f25a0b4f8278b"
|
||||
"origHash": "03b3253877ca772d"
|
||||
},
|
||||
{
|
||||
"from": "setup-server.js",
|
||||
@@ -1077,6 +1083,12 @@
|
||||
"status": "unchanged",
|
||||
"origHash": "57ff395fcf97fbab"
|
||||
},
|
||||
{
|
||||
"from": "test/hal-managed.test.js",
|
||||
"to": "modules/webmaker/test/hal-managed.test.js",
|
||||
"status": "imported",
|
||||
"origHash": "2371c251433073c9"
|
||||
},
|
||||
{
|
||||
"from": "test/lib.test.js",
|
||||
"to": "modules/webmaker/test/lib.test.js",
|
||||
@@ -1124,9 +1136,16 @@
|
||||
{
|
||||
"name": "arimail",
|
||||
"repo": "https://source.ibit.kr/OZ-Group/AriMail",
|
||||
"commit": "a07f69f1ffb6667de6e66b3d1ec43c1c66fab924",
|
||||
"commit": "f959b656f410289e024001f1f4712f2737c3b42b",
|
||||
"dest": "modules/arimail",
|
||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.",
|
||||
"upstream": {
|
||||
"version": "1.1.0",
|
||||
"branch": "main",
|
||||
"headCommit": "f959b656f410289e024001f1f4712f2737c3b42b",
|
||||
"workingTreeDirty": false,
|
||||
"note": "고정 커밋의 깨끗한 체크아웃"
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"from": "README.md",
|
||||
@@ -1348,7 +1367,7 @@
|
||||
"from": "package.json",
|
||||
"to": "modules/arimail/package.json",
|
||||
"status": "modified",
|
||||
"origHash": "f986ee70b4841c73"
|
||||
"origHash": "594a37396efedeb4"
|
||||
},
|
||||
{
|
||||
"from": "server/api.mjs",
|
||||
@@ -1422,6 +1441,12 @@
|
||||
"status": "unchanged",
|
||||
"origHash": "0425698a1fe8652b"
|
||||
},
|
||||
{
|
||||
"from": "server/hal-managed.mjs",
|
||||
"to": "modules/arimail/server/hal-managed.mjs",
|
||||
"status": "imported",
|
||||
"origHash": "dcb97dcdcce8d3b9"
|
||||
},
|
||||
{
|
||||
"from": "server/imap-mime.mjs",
|
||||
"to": "modules/arimail/server/imap-mime.mjs",
|
||||
@@ -1450,7 +1475,7 @@
|
||||
"from": "server/main.mjs",
|
||||
"to": "modules/arimail/server/main.mjs",
|
||||
"status": "modified",
|
||||
"origHash": "90ad71bdc1bca7c0"
|
||||
"origHash": "135f4c90b14ac2b9"
|
||||
},
|
||||
{
|
||||
"from": "server/mime.mjs",
|
||||
@@ -1509,8 +1534,8 @@
|
||||
{
|
||||
"from": "server/setup.mjs",
|
||||
"to": "modules/arimail/server/setup.mjs",
|
||||
"status": "unchanged",
|
||||
"origHash": "ece096ed19cbb679"
|
||||
"status": "updated",
|
||||
"origHash": "958cfcecd0dcefa9"
|
||||
},
|
||||
{
|
||||
"from": "server/smtp.mjs",
|
||||
@@ -1584,6 +1609,12 @@
|
||||
"status": "modified",
|
||||
"origHash": "fc5ef627262d950f"
|
||||
},
|
||||
{
|
||||
"from": "test/hal-managed.test.mjs",
|
||||
"to": "modules/arimail/test/hal-managed.test.mjs",
|
||||
"status": "imported",
|
||||
"origHash": "1e213850d2298611"
|
||||
},
|
||||
{
|
||||
"from": "test/imap.test.mjs",
|
||||
"to": "modules/arimail/test/imap.test.mjs",
|
||||
@@ -1648,7 +1679,7 @@
|
||||
"from": "web/setup.js",
|
||||
"to": "modules/arimail/web/setup.js",
|
||||
"status": "modified",
|
||||
"origHash": "24f72af0d740a559"
|
||||
"origHash": "ef53b87b26a862a2"
|
||||
},
|
||||
{
|
||||
"from": "web/style.css",
|
||||
|
||||
+20
-15
@@ -1,19 +1,19 @@
|
||||
# 도입 소스 기록(IMPORTED_SOURCES)
|
||||
|
||||
생성: 2026-10-28T14:53:58.554Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
|
||||
생성: 2026-10-29T06:35:15.406Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
|
||||
|
||||
## webmaker
|
||||
|
||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
|
||||
- 고정 커밋(불변 기준): `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`
|
||||
- 원본 버전(package.json): 1.8.0 · 브랜치: `feature/visual-editor-v1.8.0` · 작업 트리: 깨끗함
|
||||
- 고정 커밋(불변 기준): `8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af`
|
||||
- 원본 버전(package.json): 1.9.0 · 브랜치: `main` · 작업 트리: 깨끗함
|
||||
- 대상 경로: `modules/webmaker/`
|
||||
- 파일 수: 184 (수정 8)
|
||||
- 파일 수: 186 (수정 8)
|
||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
|
||||
|
||||
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
||||
|---|---|---|---|
|
||||
| README.md | modules/webmaker/README.md | 그대로 | d28788cd6b28ec72 |
|
||||
| README.md | modules/webmaker/README.md | 새 원본으로 갱신 | 71f4a93b1afbd6e4 |
|
||||
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
|
||||
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
|
||||
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
|
||||
@@ -67,6 +67,7 @@
|
||||
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
|
||||
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
|
||||
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
|
||||
| lib/hal-managed.js | modules/webmaker/lib/hal-managed.js | 그대로 | 8c2d41a82a6c160f |
|
||||
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
|
||||
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
|
||||
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
|
||||
@@ -98,7 +99,7 @@
|
||||
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
|
||||
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
|
||||
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
|
||||
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 그대로 | 3fd30ebd9efa47a6 |
|
||||
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 새 원본으로 갱신 | 73c9b5fb48bebdfc |
|
||||
| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
|
||||
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
|
||||
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
|
||||
@@ -142,11 +143,11 @@
|
||||
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
|
||||
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
|
||||
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
|
||||
| package.json | modules/webmaker/package.json | 그대로 | 7b5c2bf8a18df617 |
|
||||
| package.json | modules/webmaker/package.json | 새 원본으로 갱신 | f34de8005d9750a7 |
|
||||
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
|
||||
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
|
||||
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
|
||||
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | 6f37dafdf4502684 |
|
||||
| public/admin.js | modules/webmaker/public/admin.js | 새 원본으로 갱신 | f2e3de1470ba087f |
|
||||
| public/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a |
|
||||
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
|
||||
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
|
||||
@@ -185,11 +186,12 @@
|
||||
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
|
||||
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
|
||||
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
|
||||
| server.js | modules/webmaker/server.js | **수정** | b15f25a0b4f8278b |
|
||||
| server.js | modules/webmaker/server.js | **수정** | 03b3253877ca772d |
|
||||
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
|
||||
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
|
||||
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
|
||||
| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab |
|
||||
| test/hal-managed.test.js | modules/webmaker/test/hal-managed.test.js | 그대로 | 2371c251433073c9 |
|
||||
| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
|
||||
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
|
||||
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
|
||||
@@ -201,9 +203,10 @@
|
||||
## arimail
|
||||
|
||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
|
||||
- 고정 커밋(불변 기준): `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
|
||||
- 고정 커밋(불변 기준): `f959b656f410289e024001f1f4712f2737c3b42b`
|
||||
- 원본 버전(package.json): 1.1.0 · 브랜치: `main` · 작업 트리: 깨끗함
|
||||
- 대상 경로: `modules/arimail/`
|
||||
- 파일 수: 88 (수정 15)
|
||||
- 파일 수: 90 (수정 15)
|
||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||
|
||||
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|
||||
@@ -244,7 +247,7 @@
|
||||
| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b |
|
||||
| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe |
|
||||
| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 |
|
||||
| package.json | modules/arimail/package.json | **수정** | f986ee70b4841c73 |
|
||||
| package.json | modules/arimail/package.json | **수정** | 594a37396efedeb4 |
|
||||
| server/api.mjs | modules/arimail/server/api.mjs | **수정** | b51ef607e4c0e65b |
|
||||
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
|
||||
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
|
||||
@@ -257,11 +260,12 @@
|
||||
| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
|
||||
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
|
||||
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
|
||||
| server/hal-managed.mjs | modules/arimail/server/hal-managed.mjs | 그대로 | dcb97dcdcce8d3b9 |
|
||||
| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
|
||||
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
|
||||
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
|
||||
| server/limits.mjs | modules/arimail/server/limits.mjs | **수정** | 016c3aecef9a6da7 |
|
||||
| server/main.mjs | modules/arimail/server/main.mjs | **수정** | 90ad71bdc1bca7c0 |
|
||||
| server/main.mjs | modules/arimail/server/main.mjs | **수정** | 135f4c90b14ac2b9 |
|
||||
| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
|
||||
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
|
||||
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | **수정** | 5774cb75b5b1c9d4 |
|
||||
@@ -271,7 +275,7 @@
|
||||
| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
|
||||
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
|
||||
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
|
||||
| server/setup.mjs | modules/arimail/server/setup.mjs | 그대로 | ece096ed19cbb679 |
|
||||
| server/setup.mjs | modules/arimail/server/setup.mjs | 새 원본으로 갱신 | 958cfcecd0dcefa9 |
|
||||
| server/smtp.mjs | modules/arimail/server/smtp.mjs | **수정** | 61e2e7187c636297 |
|
||||
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
|
||||
| server/usage.mjs | modules/arimail/server/usage.mjs | **수정** | 29c97a4b5f627741 |
|
||||
@@ -284,6 +288,7 @@
|
||||
| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
|
||||
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
|
||||
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | **수정** | fc5ef627262d950f |
|
||||
| test/hal-managed.test.mjs | modules/arimail/test/hal-managed.test.mjs | 그대로 | 1e213850d2298611 |
|
||||
| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
|
||||
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
|
||||
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | **수정** | f92287ef6e7df130 |
|
||||
@@ -294,6 +299,6 @@
|
||||
| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
|
||||
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
|
||||
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
|
||||
| web/setup.js | modules/arimail/web/setup.js | **수정** | 24f72af0d740a559 |
|
||||
| web/setup.js | modules/arimail/web/setup.js | **수정** | ef53b87b26a862a2 |
|
||||
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||
|
||||
- 버전: **0.3.17** — 변경 이력은 [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)
|
||||
- 도입 소스(Webmaker 1.8.0 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.10.md`(텍스트 셸 복사·주소 추출, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.17.md`(HawkEye·역할별 통합 배포·릴리스 화면·작업 진행 창·서버 선택, Oz)
|
||||
- HAL 통합 설정 관제(0.4.0 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리): [docs/HAL-SETTINGS.md](docs/HAL-SETTINGS.md)
|
||||
- 도입 소스(Webmaker 1.9.0 · AriMail 1.1.0, 모두 HAL 0.4.0 대응): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신(참조 저장소와 나란히 개발 중이면 `--allow-dirty`)
|
||||
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`(기준) · `Ariweb-Suitle_Dev-Order_v0.1.4.md` · `Ariweb-Suitle_Dev-Order_v0.2.0.md`(Webmaker 직접 편집) · `Ariweb-Suitle_Dev-Order_v0.2.1.md`(HAL 고위험 재인증 안내 위치) · `Ariweb-Suitle_Dev-Order_v0.3.0.md`(HAL AI 채팅룸, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.1.md`(서버 설치 CLI 자동 탐색, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.2.md`(AI 연동 편의성·도구 기본 설정·승인 요청, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.9.md`(로그인 터미널·텍스트 셸·모델 선택·슬래시 명령, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.10.md`(텍스트 셸 복사·주소 추출, Oz) · `Ariweb-Suitle_Dev-Order_v0.3.17.md`(HawkEye·역할별 통합 배포·릴리스 화면·작업 진행 창·서버 선택, Oz) · `Ariweb-Suitle_Dev-Order_v0.4.0.md`(통합 설정 관제)
|
||||
|
||||
## 구성
|
||||
|
||||
|
||||
@@ -1,5 +1,103 @@
|
||||
# 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).
|
||||
- **이전 버전**: 0.3.17(2026-10-29). 지시된 버전 **0.4.0**. 도입 모듈 함께 올림: **Webmaker 1.8.0 → 1.9.0**, **AriMail 1.0.11 → 1.1.0**(가운데 버전 +1, 둘 다 `ariwebSuite.halCompat = "0.4.0"` 로 HAL 0.4.0 대응 명시). Node Agent 0.4.0(`module.configure`).
|
||||
|
||||
### 요구(사용자 요구 — 약화 없이 반영)
|
||||
1. Webmaker 관리 콘솔의 권한 이전 — HAL 에서 각 Webmaker 시스템 콘솔의 주요 기능(API 키 관리, 마진·가격 정책, 모델 등 주요 설정, 기타 전체 적용 서비스, 개별 서버 설정)을 통합 관제하는 새 인터페이스, 배포된 모든 서버 관리.
|
||||
2. AriMail 관리 콘솔 통합 — 도메인별 관리를 뺀 서버 단위 관리는 HAL 에서, 개별 메일 서버(도메인) 관리는 메일 서버 사용자가 직접.
|
||||
3. 기타 서비스 서버의 설정도 HAL 에서 통합 관리.
|
||||
4. 필요한 모듈 모두 업그레이드(가운데 버전 +1, HAL 0.4.0 대응 명시).
|
||||
|
||||
### 추가한 기능
|
||||
- `hal/settings/catalog.mjs`: 모듈별 설정 목록 — Webmaker 5묶음 64항목(API 키·인증 / 마진·가격 / 모델 / 전체 적용 서비스 / 서버별), AriMail 8묶음 29항목(도메인별 `LIMIT_*` 제외), Agent `heartbeatMs`. 형식·범위·재시작 필요·서버 전용 범위.
|
||||
- `hal/settings/index.mjs` + 마이그레이션 `hal_0010_managed_settings`: 범위(global / server:<id>) 저장, 비밀값은 Vault(`managed:<scope>:<module>:<key>`)에만, 유효값 = 전체 ← 서버, hash(`sha256:` 접두어 — 긴 hex 는 redact 가 가림), 적용 상태(`managed_settings_state`)·개요·적용 필요 수.
|
||||
- API: `GET /ops/api/settings`, `GET|PUT /ops/api/settings/:module`, `DELETE /ops/api/settings/:module/:key`, `POST /ops/api/settings/:module/apply`(저장·삭제·적용은 재인증 10분), `GET /agent/v1/settings/:module`(Agent HMAC, 자기 서버 범위만). 감사 `settings.set`·`settings.apply` 는 키 이름만.
|
||||
- 작업: `settings.apply`(부모) → `settings.apply.target`(서버별) → Agent `module.configure`. payload 에는 hash 만 — Agent 가 HAL 에서 직접 받아 hash 를 확인하고 `<configDir>/hal-managed.json`(0600, 실행 계정이 다르면 `module-as-user.sh write --mode 600`)으로 원자 교체, 재시작·건강 확인, 모듈의 `hal-managed.applied.json` 을 기다려 결과에 싣는다. hash 불일치 `settings_hash_mismatch`, 모듈 적용 오류 `module_apply_failed`.
|
||||
- 화면 `/ops/settings` "통합 설정"(메뉴): 모듈 탭 → 범위(전체 / 서버) → 묶음별 입력(비밀값은 설정됨/없음·새 값만, 서버 범위는 "전체 값 …" 안내, 재시작 필요 표시) → 저장 → "서버에 적용"(서버 체크·재시작) → 서버별 적용 상태. 운영 현황 서버 카드 "설정 적용 필요".
|
||||
- AI 도구 36개: `hal_settings_get`(읽기) · `hal_settings_set`(고위험, 비밀값 거부 `secret_not_allowed`) · `hal_settings_apply`(고위험). 스킬 §4.1. 도구 인자 형식에 object 추가.
|
||||
- Webmaker 1.9.0(원본 `release/1.9.0` → main `8f8716c`): `lib/hal-managed.js`(기동·파일 변경 때 settings/config/보관소 적용, 결과 파일), 관리자 콘솔 설정·보관소·설정 페이지에서 관리 항목 409 `managed_by_hal`, 설정 탭 "HAL 관리" 안내. 원본 test 119/119.
|
||||
- AriMail 1.1.0(원본 `release/1.1.0` → main `f959b65`): `server/hal-managed.mjs`(기본 도메인=서버 설정에만 적용, LIMIT_*·설치 항목 제외), 설정 페이지 관리 항목 값 변경 409·사용량 키 잠금, "HAL 관리" 카드. 원본 test 9파일 통과.
|
||||
- Agent 0.4.0: `module.configure`, `<dataDir>/hal-managed.json` 의 `heartbeatMs` 우선.
|
||||
|
||||
### 수정한 기존 기능
|
||||
- `scripts/import-sources.mjs`: pin 을 올릴 때 Suite 사본이 "이전에 가져온 원본"과 같으면 새 원본으로 갱신(`updated`) — 예전에는 `--force` 없이 원본이 바뀐 파일이 모두 'modified' 로 남아 갱신되지 않았다. Suite 가 고친 파일(webmaker `server.js`, arimail `main.mjs`·`web/setup.js`·`package.json`)은 원본 변경분을 같은 패치로 옮겼다. pin: webmaker `8f8716c`, arimail `f959b65`.
|
||||
- `deploy/module-as-user.sh write --mode 600`.
|
||||
- 버전 검사 테스트 0.3.17 → 0.4.0, Webmaker 1.9.0.
|
||||
|
||||
### 고객 화면
|
||||
- 변경 없음. Webmaker·AriMail 관리 콘솔(관리자)에 "HAL 관리" 안내·잠금이 생긴다(관리 항목이 지정된 서버만).
|
||||
|
||||
### 운영자 화면
|
||||
- "통합 설정" 메뉴·화면, 운영 현황 "설정 적용 필요".
|
||||
|
||||
### 보안 결정
|
||||
- 비밀값은 HAL Vault·모듈 보관소·0600 파일에만. 작업 payload·작업 결과·Agent 단계 기록·감사·API 응답·AI 도구 결과·모듈 결과 파일에는 비밀값이 없다(테스트로 확인). AI 도구는 비밀값을 넣을 수 없다.
|
||||
- 저장·삭제·적용은 CSRF + 최근 재인증. AI 의 설정 변경·적용은 고위험(승인·재인증·3단계).
|
||||
- Agent 는 자기 서버 범위의 설정만 받는다(다른 서버의 덮어쓰기 불가).
|
||||
|
||||
### 테스트
|
||||
- `test/hal-040.test.mjs` RED(모듈 없음) → GREEN 5/5, `test/agent.test.mjs` `module.configure`·heartbeat RED → GREEN 11/11, 원본 저장소 `test/hal-managed.test.js` 4/4 · `test/hal-managed.test.mjs` 3/3. 전체 Suite 31파일.
|
||||
|
||||
### 배포(2026-10-29 UTC, bear `hal21.ariweb.net` HAL + HAL 작업으로 모듈)
|
||||
- main ff `54db663 → 9f1e490`, 깨끗한 main 빌드·verify(전체 31파일 통과). artifact sha256: hal `0633e4ef82d29fc8ce7c94019bd864eb28889167ba6f02e0de5323e8963ffff8` · agent `d76cdc81dc399fc9e2c134b70d210b251c7c2e9e8ced2b32132925e0d1ac09c1` · webmaker `b7bf920fc07de3e577def4776b35ca57a7586c6a343e03601dcd44f5724934f2` · arimail `918167b7d919953e9f10714c15b2bbf416764abe3a6388eeb88f3e21c982b08d` · portal `18ecca8b157a7f23124b9e7c0fcfb1c655e352cfbfa0f9a13157751ec9225d72`.
|
||||
- HAL: 백업 `/srv/ariweb/hal/backups/pre-v0.4.0-20261029T065026Z` → stage → `register-release --approve` → `bootstrap-release` → current `releases/0.4.0-9f1e490` → 재시작 뒤 500ms 에 health ok(0.4.0). config·vault 보존 sha256 전=후. 운영 DB read-back: 마이그레이션 `hal_0010_managed_settings`, 표 `managed_settings`·`managed_settings_state`(0행), 릴리스 0.4.0 approved. 배포 코드 read-back(메모리 DB): 통합 설정 화면·메뉴·묶음, 저장 응답에 비밀값 없음, 대상 없음 `no_targets`, Agent 설정 API 무서명 401, 도구 36개.
|
||||
- HAL 작업(CLI `module-deploy`, 감사 `cli:claude-opus-5.5`, 한 대씩·건강 확인): agent 0.4.0 → srv1953374(0.3.17→0.4.0) · jitsi(0.1.1→0.4.0) / webmaker 0.4.0(Webmaker 1.9.0) → srv1953374(`/srv/webmaker/releases/0.4.0`, webmaker.ariweb.net 200) / arimail 0.4.0(AriMail 1.1.0) → srv1953374(`/srv/arihakdang-mail/releases/0.4.0`, health 1.1.0·도메인 6, mail.arihakdang.com 200) · jitsi(`/srv/arimail/releases/0.4.0`, health 1.1.0, webmail.ibit.kr 200) / backup·hawkeye 0.4.0 기록(Agent 기능 확인). 실패 작업 0. 포털은 코드 변경이 없어 배포하지 않음(0.1.1 유지).
|
||||
- 되돌리기: HAL `ln -sfn /srv/ariweb/hal/releases/0.3.17-843b2b1 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal`. 모듈은 HAL 작업 `module.rollback`(이전 릴리스 디렉터리 보존: webmaker·arimail `releases/` 의 직전 디렉터리).
|
||||
- 운영자 조치: 통합 설정 화면(`/ops/settings`)에서 실제 값(API 키·마진 등)을 지정하고 "서버에 적용"을 처음 실행한다(지금은 관리 항목 0 — 모듈은 예전처럼 단독 설정으로 동작). Webmaker 를 jitsi 의 배치 목록에서 정리할지 결정(jitsi 에는 Webmaker 가 없음 — Agent 가 보고한 빈 행).
|
||||
|
||||
### 점검에서 발견(0.3.17 잔여 결함, 이번 범위 밖)
|
||||
- Agent heartbeat 가 기능 이름을 옛 `observability` 로 보고하고 HAL 이 그대로 저장해, 마이그레이션 뒤에도 `observability` 행이 다시 생기고 `hawkeye` 행의 확인 시각이 갱신되지 않는다(운영 현황 HawkEye 가 "정보 지연"으로 보일 수 있음). 수정: HAL `heartbeat` 에서 `canonicalModuleName` 적용 + Agent 보고 이름 `hawkeye`(다음 릴리스).
|
||||
|
||||
### 알려진 제한
|
||||
- HAL 에서 지정을 지워도 모듈에 적용된 값은 남는다(잠금만 풀림). Webmaker 등급·내부 모델은 그 서버에서 사용 가능한 모델이어야 적용된다.
|
||||
- 실행 계정이 다른 모듈의 결과 파일을 Agent 가 읽지 못하면(디렉터리 권한) "쓰기 완료·적용 미확인"으로 남는다(`appliedVerified=false`).
|
||||
|
||||
## 0.3.17 (2026-10-29)
|
||||
|
||||
- **작성자**: 개발 지시 — Oz(`Ariweb-Suitle_Dev-Order_v0.3.17.md`, 2026-10-29) · 구현 — Claude Fable 5.1(브랜치 `feature/hal-provision-v0.3.17`, 기준 main `c460f57` = 0.3.10). 커밋·push·`main` 병합·릴리스 빌드·bear `hal21.ariweb.net` HAL 배포까지 수행(아래 "배포").
|
||||
|
||||
+7
-2
@@ -9,6 +9,7 @@ import { openVault } from '../lib/vault.mjs';
|
||||
|
||||
export function loadAgentConfig(env = process.env) {
|
||||
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
||||
let managedDoc = {}; try { managedDoc = JSON.parse(readFileSync(join(dataDir, 'hal-managed.json'), 'utf8')) || {}; } catch (_) { managedDoc = {}; } const managed = managedDoc.values && typeof managedDoc.values === 'object' ? managedDoc.values : {};
|
||||
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
|
||||
const cfg = {
|
||||
dataDir, file, ...saved,
|
||||
@@ -24,8 +25,12 @@ export function loadAgentConfig(env = process.env) {
|
||||
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
||||
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
||||
},
|
||||
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
|
||||
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
||||
// [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) },
|
||||
// [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;
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
+41
-2
@@ -15,8 +15,8 @@
|
||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join, isAbsolute } from 'node:path';
|
||||
import { userInfo } from 'node:os';
|
||||
import http from 'node:http';
|
||||
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
||||
@@ -124,7 +124,46 @@ async function installRelease(ctx, { module, version, appDir, desc, action, toVe
|
||||
}, '릴리스 풀기');
|
||||
return { relDir, prev: prevDir };
|
||||
}
|
||||
/**
|
||||
* [0.4.0] 통합 설정 적용(module.configure). HAL 작업 payload 에는 비밀값이 없다 — Agent 가 HAL 의 settingsUrl(/agent/v1/settings/<모듈>, HMAC)에서
|
||||
* 자기 서버 범위의 유효 설정을 직접 받아, payload 의 hash 와 같을 때만 <configDir>/hal-managed.json(0600)으로 원자 교체한다.
|
||||
* - 실행 계정이 다르면(User=) 그 계정으로 module-as-user.sh write --mode 600 (configDir 를 --app-dir 로 써서 그 아래로만 쓰게 한다)
|
||||
* - restart: 서비스 재시작 → 건강 확인. agent 자신은 결과를 먼저 보고한 뒤 재시작(restartAfterReport).
|
||||
* - 모듈(Webmaker 1.9.0·AriMail 1.1.0)은 파일을 읽어 적용하고 <configDir>/hal-managed.applied.json(비밀 없음: hash·시각·오류)을 남긴다. Agent 는 그것을 기다려(최대 waitAppliedMs) 결과에 싣는다.
|
||||
* 비밀값은 단계 결과·로그·작업 상태 파일에 남기지 않는다(받은 내용은 이 함수 안에서만 쓴다).
|
||||
*/
|
||||
async function configure(ctx) {
|
||||
const p = ctx.payload; const module = String(p.module || ''); if (!/^[a-z][a-z0-9-]{0,40}$/.test(module)) throw Object.assign(new Error(`모듈 이름이 올바르지 않습니다: ${module}`), { code: 'bad_module' });
|
||||
const appDir = resolveAppDir(ctx, p); const configDir = String(p.configDir || '').replace('<appDir>', appDir).replace('<dataDir>', ctx.dataDir);
|
||||
if (!configDir || !isAbsolute(configDir) || configDir.includes('..')) throw Object.assign(new Error(`설정 디렉터리가 올바르지 않습니다: ${configDir}`), { code: 'bad_config_dir' });
|
||||
const unit = p.unit || p.descriptor?.unit || null; const file = join(configDir, 'hal-managed.json');
|
||||
let settings; try { const r = await ctx.client.request('GET', p.settingsUrl || `/agent/v1/settings/${module}`); settings = r && r.settings; } catch (e) { throw Object.assign(new Error(`HAL 에서 설정을 받지 못했습니다: ${e.message}`), { code: 'settings_fetch_failed', retryable: true }); }
|
||||
if (!settings || settings.module !== module || typeof settings.values !== 'object') throw Object.assign(new Error('HAL 설정 응답 형식이 올바르지 않습니다'), { code: 'settings_fetch_failed' });
|
||||
if (p.hash && settings.hash !== p.hash) throw Object.assign(new Error('HAL 이 지금 주는 설정이 작업을 만들 때와 다릅니다(그 사이 설정이 바뀜) — 다시 적용하세요'), { code: 'settings_hash_mismatch' });
|
||||
const doc = JSON.stringify({ format: 1, module, serverId: settings.serverId || null, hash: settings.hash, generatedAt: settings.generatedAt || null, writtenAt: new Date().toISOString(), values: settings.values }, null, 1);
|
||||
const keys = Object.keys(settings.values).length;
|
||||
await ctx.step('write', async () => {
|
||||
const svc = module === 'agent' ? { asUser: false } : await serviceContext(ctx, unit);
|
||||
if (svc.asUser) { await asUser(ctx, svc, configDir, 'write', ['--file', file, '--mode', '600'], { input: doc }); }
|
||||
else { mkdirSync(configDir, { recursive: true, mode: 0o700 }); const tmp = `${file}.tmp-${process.pid}`; writeFileSync(tmp, doc, { mode: 0o600 }); chmodSync(tmp, 0o600); renameSync(tmp, file); }
|
||||
return { file, keys, asUser: svc.asUser ? svc.user : null };
|
||||
}, `설정 파일 쓰기(${keys}개 항목)`);
|
||||
ctx.log(`HAL 관리 설정 ${keys}개 항목을 ${file} 에 썼습니다(${String(settings.hash).slice(0, 19)})`);
|
||||
let restarted = false; let healthRes = { ok: true, skipped: 'no_restart' }; let restartAfterReport = null;
|
||||
if (p.restart && unit) {
|
||||
if (module === 'agent') { restartAfterReport = unit; restarted = true; }
|
||||
else { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); restarted = true; healthRes = await ctx.step('health', () => health(ctx, p.descriptor || {}), '건강 확인'); }
|
||||
}
|
||||
let applied = null;
|
||||
if (module !== 'agent' && !ctx.sys.stub) {
|
||||
applied = await ctx.step('applied', async () => { const af = join(configDir, 'hal-managed.applied.json'); const until = Date.now() + Number(p.waitAppliedMs || 45000); let last = null;
|
||||
while (Date.now() < until) { try { last = JSON.parse(readFileSync(af, 'utf8')); if (last && last.hash === settings.hash) return { hash: last.hash, at: last.appliedAt || null, errors: Array.isArray(last.errors) ? last.errors.map((x) => ({ key: x.key, error: String(x.error || '').slice(0, 200) })) : [] }; } catch (e) { if (e.code === 'EACCES') return { unreadable: true }; } await new Promise((r) => setTimeout(r, 1500)); }
|
||||
return last ? { hash: last.hash || null, at: last.appliedAt || null, stale: true } : { missing: true }; }, '모듈 적용 확인');
|
||||
}
|
||||
return { module, hash: settings.hash, file, keys, restarted, health: healthRes, applied, restartAfterReport };
|
||||
}
|
||||
export const kinds = {
|
||||
'module.configure': { title: '모듈 설정 적용(HAL 관리)', run: configure },
|
||||
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
||||
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
||||
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); const v = await detectVersion(ctx, desc, appDir); return { module: p.module, actualVersion: v.version, upstreamVersion: v.upstreamVersion, active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
|
||||
|
||||
+48
-10
@@ -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
@@ -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
|
||||
|
||||
Executable
+31
@@ -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; }
|
||||
@@ -8,7 +8,7 @@
|
||||
#
|
||||
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
||||
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
|
||||
# write --file <appDir 아래 파일> 표준 입력을 파일로 쓴다(REVISION.json·.previous.json)
|
||||
# write --file <appDir 아래 파일> [--mode 600] 표준 입력을 파일로 쓴다(REVISION.json·.previous.json · 0.4.0 hal-managed.json 은 --mode 600)
|
||||
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
|
||||
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
||||
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
||||
@@ -16,9 +16,9 @@
|
||||
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
||||
set -euo pipefail
|
||||
SUB="${1:-}"; shift || true
|
||||
APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
|
||||
MODE=""; APP_DIR=""; TGZ=""; DEST=""; FILE=""; LINK=""; TARGET=""; DIR=""; DB=""; OUT=""; NODE_BIN=""; CWD=""; SCRIPT=""; ENV_FILE=""
|
||||
while [ $# -gt 0 ]; do case "$1" in
|
||||
--app-dir) APP_DIR="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
|
||||
--app-dir) APP_DIR="$2"; shift 2;; --mode) MODE="$2"; shift 2;; --tgz) TGZ="$2"; shift 2;; --dest) DEST="$2"; shift 2;; --file) FILE="$2"; shift 2;; --link) LINK="$2"; shift 2;; --target) TARGET="$2"; shift 2;;
|
||||
--dir) DIR="$2"; shift 2;; --db) DB="$2"; shift 2;; --out) OUT="$2"; shift 2;; --node) NODE_BIN="$2"; shift 2;; --cwd) CWD="$2"; shift 2;; --script) SCRIPT="$2"; shift 2;; --env-file) ENV_FILE="$2"; shift 2;;
|
||||
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
|
||||
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
|
||||
@@ -31,7 +31,10 @@ case "$SUB" in
|
||||
[ -f "$TGZ" ] || { echo "artifact 가 없습니다: $TGZ" >&2; exit 2; }; inside "$DEST"; case "$DEST" in "$APP_DIR"/releases/*) ;; *) echo "--dest 는 releases 아래여야 합니다" >&2; exit 3;; esac
|
||||
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
|
||||
write)
|
||||
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"; cat > "$tmp"; mv -f "$tmp" "$FILE"; echo "written $FILE";;
|
||||
# --mode 600(0.4.0): 비밀값이 든 파일(hal-managed.json)은 처음부터 그 계정만 읽게 만든다(umask 077 → chmod 600 → 원자 교체)
|
||||
inside "$FILE"; mkdir -p "$(dirname "$FILE")"; tmp="$FILE.tmp-$$"
|
||||
if [ "$MODE" = "600" ]; then ( umask 077; cat > "$tmp" ); chmod 600 "$tmp"; elif [ -n "$MODE" ]; then echo "--mode 는 600 만 허용합니다" >&2; exit 2; else cat > "$tmp"; fi
|
||||
mv -f "$tmp" "$FILE"; echo "written $FILE";;
|
||||
switch)
|
||||
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
|
||||
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
|
||||
|
||||
+14
-1
@@ -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 |
|
||||
@@ -29,6 +31,7 @@ Dev-Order §5(아키텍처)·§28·§29·§32·§33·§34 의 백엔드 구현.
|
||||
| `POST jobs/:id/progress {progress, steps, log}` → `{cancelRequested}` / `POST jobs/:id/result {status: done\|failed\|needs_user, result, error, errorCode, userMessage}` | |
|
||||
| `GET releases/:version/artifact?module=` | tar.gz 스트리밍, `X-Sha256` |
|
||||
| `POST/GET transfers/:id` | HAL 경유 파일 전송(이전·원격 백업), 24시간 보관 |
|
||||
| `GET settings/:module` | [0.4.0] 자기 서버 범위의 HAL 관리 설정(유효값·비밀값·hash). 작업 `module.configure` 가 부른다 — docs/HAL-SETTINGS.md |
|
||||
|
||||
## 4. 포털 API(`/portal/v1`, 키 `portal`) — docs/HAL-JOBS.md
|
||||
|
||||
@@ -50,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 가 수행한다.
|
||||
|
||||
|
||||
@@ -77,3 +77,6 @@ HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다. 읽기 도구만
|
||||
- 원인: 단말 영역이 모든 키를 셸로 보내 Ctrl+C 가 복사 대신 ^C 가 되었고, 긴 인증 주소가 줄 끝(열 수)에서 끊겨 두 줄로 보여 통째로 고를 수 없었다.
|
||||
- `term.mjs`: `joinedLines`(꽉 찬 줄은 다음 줄과 이어 붙임) · `plainText` · `extractUrls`(스크롤백·대체 화면 포함, 상자 문자 제외, 꼬리 구두점 제거, 중복 제거) · `copyDecision`(선택이 있으면 Ctrl+C=복사, Ctrl+Shift+C·Ctrl+Insert=항상 복사, Ctrl+V/Shift+Insert=붙여넣기, 나머지=전송).
|
||||
- 화면(`ai-render.shellModalHtml`·`shellUrlsHtml`, `ai.js`): "감지된 주소" 목록(복사·열기 — 새 탭, noopener), "전체 출력 복사", "선택 복사", "텍스트로 보기"(선택 가능한 textarea), 단말 드래그 선택 가능(`user-select:text`). 복사는 Clipboard API → 실패 시 숨은 textarea+execCommand → 안내.
|
||||
|
||||
## 10. 0.4.0 — 통합 설정 관제 도구
|
||||
- `hal_settings_get`(읽기: 저장 값·비밀값 설정 여부 `confidential[]`·서버 범위 유효값·적용 상태) · `hal_settings_set`(고위험 — 비밀값은 받지 않음 `secret_not_allowed`) · `hal_settings_apply`(고위험 — 작업 `settings.apply`). 스킬 문서 §4.1. 자세한 계약은 docs/HAL-SETTINGS.md.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# HAL 통합 설정 관제 (Ariweb Suite 0.4.0)
|
||||
|
||||
HAL 이 도입 모듈·Node Agent 의 **서버 단위 설정**을 대신 관리한다. 개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.0.md`(Oz).
|
||||
|
||||
## 1. 무엇을 관리하나
|
||||
| 모듈 | 묶음 | 대표 항목 |
|
||||
|---|---|---|
|
||||
| Webmaker 1.9.0 | API 키·인증 | `auth.mode`·`auth.apiKey`·`auth.oauthToken`·`serviceApiKey`·`backlogKey`·`mail.apiKey`·`source.token`·`ariweb.secret`·`mail.adminPassword` |
|
||||
| | 마진·가격 정책 | `marginPct`·`fx.mode/manualRate/spreadPct`·`signupCredit`·`maxEdits`·`defaultTier`·`tiers.*`·`signupApproval` |
|
||||
| | 모델 | `internalRef`·`defaultModel`·`claudeModels`·`ai.*MaxTurns`·`ai.maxJobMinutes`·`ai.maxConcurrentJobs`·`ai.codexSandbox` |
|
||||
| | 전체 적용 서비스 | `service.allowSignup`·`letsEncryptEmail`·`mail.fromName/defaultDomain/formBase`·`ariweb.portalUrl`·`endpoints.*`·`limits.*`·`ssl.acmeWaitMin` |
|
||||
| | 서버별 설정(서버 범위만) | `service.publicBaseUrl/formBaseUrl/siteOrigin/trustedProxies/internalAllow`·`ai.claudeBin/codexBin`·`source.*`·`mail.provider/url/apiUrl/domain/adminLogin/nickname/ip`·`ssl.acmeDir` |
|
||||
| AriMail 1.1.0 | 서버 단위 | 수·발신 방식, Resend, DNS 제공자, SMTP 중계, 내장 Haraka(서버 범위), 표시·동기화, 사용량 API, Ariweb SSO |
|
||||
| Node Agent 0.4.0 | Agent | `heartbeatMs` |
|
||||
|
||||
- **관리하지 않는 것**: AriMail 도메인별 관리(추가 도메인 설정·`LIMIT_*`·계정·메일함 — 메일 서버 사용자가 직접), Webmaker 사이트·회원·템플릿·테마 등 콘텐츠.
|
||||
- 목록 정의: `hal/settings/catalog.mjs`(형식 text·int·number·bool·enum·list·url·path·secret, 범위, 재시작 필요).
|
||||
|
||||
## 2. 저장·유효값
|
||||
- `managed_settings(scope, module, key, value_json, is_secret)` — scope `global` | `server:<서버 id>`. 비밀값은 HAL Vault `managed:<scope>:<module>:<key>` 에만, 행에는 참조만(마이그레이션 `hal_0010_managed_settings`).
|
||||
- 유효값 = 전체 ← 서버 덮어쓰기. hash = `sha256:` + 모듈·유효값(비밀값 포함)의 정규 JSON sha256.
|
||||
- `managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id)` — 적용 상태. `upToDate` = 마지막 적용 hash 가 지금 유효값 hash 와 같음.
|
||||
|
||||
## 3. API(운영자 세션·CSRF)
|
||||
| 경로 | 설명 |
|
||||
|---|---|
|
||||
| `GET /ops/api/settings` | 모듈 목록·설정 목록·모듈별 개요(배치 서버·적용 상태·적용 필요 수) |
|
||||
| `GET /ops/api/settings/:module?scope=` | 저장된 값(비밀값은 있음/없음), 서버 범위면 유효값·적용 상태 |
|
||||
| `PUT /ops/api/settings/:module {scope, values}` | 저장(재인증 10분). null·빈 값 = 지정 삭제. 형식 오류·모르는 항목 400 `bad_setting` |
|
||||
| `DELETE /ops/api/settings/:module/:key?scope=` | 지정 삭제(재인증) |
|
||||
| `POST /ops/api/settings/:module/apply {serverIds, restart}` | 적용 작업(재인증). 대상 비우면 배치 서버 전체, 없으면 400 `no_targets`, 배치 안 된 서버 400 `not_placed` |
|
||||
| `GET /agent/v1/settings/:module` | Agent HMAC. 자기 서버 범위의 유효값(비밀값 포함)·hash |
|
||||
|
||||
감사 기록(`settings.set`·`settings.apply`)에는 키 이름만 남는다.
|
||||
|
||||
## 4. 적용 흐름
|
||||
`settings.apply`(부모) → 서버마다 `settings.apply.target` → Agent `module.configure`:
|
||||
1. 작업 payload 에는 hash 만(비밀값 없음). Agent 가 `/agent/v1/settings/:module` 에서 직접 받고 hash 가 다르면 `settings_hash_mismatch`(그 사이 설정이 바뀜).
|
||||
2. `<configDir>/hal-managed.json`(0600) 원자 교체. 실행 계정이 다르면 `module-as-user.sh write --mode 600`. configDir: Webmaker `/data/webmaker`(DATA_ROOT), AriMail `<appDir>/data`(설정 파일 옆), Agent `<dataDir>`. 서버 facts `configDirs` 로 바꿀 수 있다.
|
||||
3. `restart` 면 서비스 재시작·건강 확인(Agent 자신은 결과 보고 뒤 재시작).
|
||||
4. 모듈이 파일을 적용하고 `hal-managed.applied.json`(hash·시각·항목·오류, 비밀값 없음)을 남기면 Agent 가 기다려(최대 45초) 결과에 싣는다. 모듈이 다른 hash·오류를 보고하면 `module_apply_failed`.
|
||||
|
||||
## 5. 모듈 쪽(HAL 0.4.0 대응)
|
||||
- Webmaker 1.9.0 `lib/hal-managed.js`: 기동·파일 변경(10초 확인) 때 settings/config/보관소에 적용(auth·fx·tiers·mail 은 묶음 저장). 관리자 콘솔 설정·보관소·설정 페이지에서 관리 항목 409 `managed_by_hal`, 설정 탭 "HAL 관리" 안내.
|
||||
- AriMail 1.1.0 `server/hal-managed.mjs`: 기본 도메인(서버) 설정에만 적용, `LIMIT_*`·설치 항목 제외. 설정 페이지에서 관리 항목 값 변경 409, 사용량 키 생성 잠금, "HAL 관리" 카드.
|
||||
- 두 모듈 `package.json` 에 `ariwebSuite.halCompat = "0.4.0"`. 파일이 없으면 동작 변화 없음(단독 운영).
|
||||
|
||||
## 6. 화면·AI
|
||||
- 운영자 콘솔 `/ops/settings` "통합 설정": 모듈 탭 → 범위(전체/서버) → 묶음별 입력 → 저장 → "서버에 적용"(서버 선택·재시작) → 서버별 적용 상태. 운영 현황 서버 카드에 "설정 적용 필요".
|
||||
- AI 도구: `hal_settings_get`(읽기) · `hal_settings_set`(고위험, 비밀값 거부 `secret_not_allowed`) · `hal_settings_apply`(고위험).
|
||||
|
||||
## 7. 알려진 제한
|
||||
- HAL 에서 지정을 지워도 모듈에 이미 적용된 값은 그대로 남는다(잠금만 풀린다). 되돌리려면 HAL 에서 원하는 값을 지정해 다시 적용한다.
|
||||
- Webmaker 의 등급·내부 호출 모델은 그 서버에서 "사용 가능"인 모델만 저장된다 — 아니면 `module_apply_failed` 로 보고된다.
|
||||
@@ -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
+11
-2
@@ -1,4 +1,4 @@
|
||||
# HAL 운영 스킬 — AI 모델이 HAL 상태·로그를 읽는 법 (Ariweb Suite 0.3.0)
|
||||
# HAL 운영 스킬 — AI 모델이 HAL 상태·로그를 읽는 법 (Ariweb Suite 0.3.0 · 0.4.0 통합 설정)
|
||||
|
||||
이 문서는 HAL 운영자 콘솔 AI 채팅룸에 연결된 모델의 system prompt 에 들어가고, 도구 `hal_skill` 과 `GET /ops/api/ai/skill` 로도 읽을 수 있다. 비밀값은 없다.
|
||||
|
||||
@@ -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" 임대 소유자. 재시작하면 바뀐다.
|
||||
|
||||
@@ -40,6 +41,14 @@
|
||||
- 배포 순서(`deployOrder`): agent → portal → webmaker → arimail → backup → hawkeye(HawkEye, 전체 시스템 상황 점검 — 옛 이름 observability). **HAL 자체는 배포 대상이 아니다**(먼저 설치하는 도구, `hal_not_deployable`). HAL 실행 버전이 릴리스보다 오래되면 모듈 배포는 `hal_first` 로 거부된다. 역할별 통합 배포(`server.provision`: webmaker·webhosting·arimail·backup·portal 프로필)와 포털 이전(`portal.relocate`)이 있다. 배포 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 간다.
|
||||
- `module_targets.health` : `ok` · `down` · `unknown`. `lastCheckAt` 이 10분 넘게 오래되면 "정보 지연(stale)"이지 정상이 아니다.
|
||||
|
||||
## 4.1 통합 설정 관제 (`hal_settings_get`, `hal_settings_set`, `hal_settings_apply`) — 0.4.0
|
||||
- HAL 은 Webmaker·AriMail·Node Agent 의 **서버 단위 설정**(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정 / AriMail 의 수·발신 방식·Resend·DNS·SMTP 중계·Haraka·표시·사용량 API / Agent heartbeat)을 저장하고 Agent 를 통해 적용한다. AriMail 의 **도메인별 관리**(도메인 제한·계정·메일함)는 메일 서버 사용자가 직접 하며 HAL 이 다루지 않는다.
|
||||
- 범위: `global`(모든 서버 기본값) · `server:<서버 id>`(그 서버만 덮어쓰기). 유효값 = 전체 ← 서버. 서버별 항목(공개 주소·실행 파일 경로·Source·메일 서버 연결 등)은 서버 범위에만 둔다.
|
||||
- 비밀값은 HAL Vault 에만 있고 도구 결과에는 설정 여부(`confidential[].set`)만 나온다. `hal_settings_set` 은 비밀값을 받지 않는다(`secret_not_allowed`) — 운영자가 "통합 설정" 화면에서 직접 넣는다.
|
||||
- 저장만으로는 서버에 반영되지 않는다. `hal_settings_apply` → 작업 `settings.apply`(서버별 `settings.apply.target` → Agent `module.configure`). Agent 는 HAL 에서 자기 서버 범위의 값을 직접 받아 모듈 데이터 디렉터리의 `hal-managed.json` 에 쓰고, 모듈이 적용한 결과(hash)를 확인한다.
|
||||
- 적용 상태: `ok`+`upToDate`(적용됨) · `upToDate=false`(설정이 바뀌어 다시 적용 필요) · `failed`(`settings_hash_mismatch`: Agent 가 받은 설정이 보낸 것과 다름 — 그 사이 설정이 바뀜, `module_apply_failed`: 모듈이 일부 항목을 적용 못 함, `health_failed`: 재시작 뒤 건강 확인 실패). 운영 현황 `settings.stale` 은 적용이 필요한 서버·모듈 수.
|
||||
- HAL 이 관리하는 항목은 각 모듈 관리 콘솔에서 잠긴다(`managed_by_hal`, 409). 모듈 콘솔에서 바꾸라고 안내하지 마라.
|
||||
|
||||
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||
- `severity` : `info` · `warning` · `critical`. `customerImpact` : 고객 영향 설명(없으면 내부 경고). `public` : 공개 상태 페이지 게시 여부.
|
||||
- 같은 원인은 `key` 로 묶인다(열린 사건에 같은 key 로 등록하면 병합). 사건 종료(`resolve`)는 **실제 사용자 경로 확인**(`verifiedUserPath`) 없이는 거부된다.
|
||||
|
||||
@@ -21,6 +21,9 @@ import * as dr from '../dr.mjs';
|
||||
import * as mailsvc from '../mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
||||
import { overviewLocal, changelog } from '../ui.mjs';
|
||||
import * as settingsStore from '../settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { MANAGED_MODULES, keyDef } from '../settings/catalog.mjs';
|
||||
import { enqueueSettingsApply } from '../app.mjs';
|
||||
|
||||
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
|
||||
@@ -54,6 +57,7 @@ export const TOOLS = [
|
||||
{ name: 'hal_action_status', risk: 'read', description: '승인 요청(제안) 하나의 상태·결과를 본다(proposed·executing·executed·failed·rejected·cancelled). 쓰기·고위험 도구를 부른 뒤 운영자 승인 결과를 확인할 때 쓴다.', schema: S({ actionId: str('제안 id(aia_…)') }, ['actionId']), run: (d, a) => { const x = d.ai && d.ai.getAction(a.actionId); if (!x) throw notFound('action_not_found', '제안이 없습니다'); return { action: x }; } },
|
||||
{ name: 'hal_actions_pending', risk: 'read', description: '운영자 승인을 기다리는 제안 목록(모든 출처).', schema: S({}), run: (d) => ({ actions: d.ai ? d.ai.listActions({ status: 'proposed', limit: 100 }) : [] }) },
|
||||
{ name: 'hal_changelog', risk: 'read', description: 'VERSION_HISTORY.md 의 버전별 변경 이력(버전을 주면 그 절만).', schema: S({ version: str('버전(예: 0.3.0)') }), run: (d, a) => { const c = changelog(); return a.version ? { version: a.version, text: c[a.version] || null } : { versions: Object.keys(c) }; } },
|
||||
{ name: 'hal_settings_get', risk: 'read', description: '[0.4.0] 통합 설정 관제: 모듈(webmaker·arimail·agent)의 HAL 관리 설정을 본다. scope 는 global(기본) 또는 server:<서버 id>. 서버 범위면 유효값(전체←서버)과 적용 상태(적용됨·적용 필요·실패)도 준다. 비밀값은 설정 여부만.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>') }, ['module']), run: (d, a) => { const sc = settingsStore.parseScope(a.scope || 'global'); const g = settingsStore.getSettings(d, a.module, sc.scope); const out = { module: g.module, scope: g.scope, values: g.values, confidential: Object.entries(g.secrets).map(([name, set]) => ({ name, set })) /* 'secret' 이 든 키 이름은 redact 가 가리므로 confidential */, updatedAt: g.updatedAt }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, a.module, sc.serverId); out.effective = { values: e.values, sources: e.sources }; out.state = settingsStore.stateFor(d, a.module, sc.serverId); } else out.overview = settingsStore.settingsOverview(d)[a.module]; return out; } },
|
||||
// ---------------- 쓰기(write): 승인 + 재인증
|
||||
{ name: 'hal_server_create', risk: 'write', description: '관리 서버를 등록(pending)한다. 등록 토큰 발급·Agent 설치·승인은 운영자가 콘솔에서 한다.', schema: S({ name: str('서버 이름'), role: str('역할', { enum: core.SERVER_ROLES }), location: str('위치') }, ['name', 'role']), run: (d, a, actor) => ({ server: core.publicServer(d, core.createServer(d, { name: a.name, role: a.role, location: a.location || '', by: actor.operatorId })) }) },
|
||||
{ name: 'hal_server_approve', risk: 'write', description: 'Agent 가 연결된 pending 서버를 승인해 배치 가능(ready)으로 만든다.', schema: S({ serverId: SERVER_ID }, ['serverId']), identifiers: (a) => [a.serverId], run: (d, a, actor) => ({ server: core.publicServer(d, core.approveServer(d, a.serverId, actor.operatorId)) }) },
|
||||
@@ -72,6 +76,8 @@ export const TOOLS = [
|
||||
{ name: 'hal_release_approve', risk: 'high', description: '등록된 릴리스를 승인한다(testsPassed·dirty=false·provenanceClean 이 모두 기록된 릴리스만 가능).', schema: S({ version: str('릴리스 버전') }, ['version']), identifiers: (a) => [a.version], run: (d, a, actor) => ({ release: core.approveRelease(d, a.version, actor.operatorId) }) },
|
||||
{ name: 'hal_job_revert', risk: 'high', description: '끝난 작업을 되돌린다(revert 를 지원하는 종류만).', schema: S({ jobId: str('작업 id') }, ['jobId']), identifiers: (a) => [a.jobId], run: async (d, a, actor) => ({ job: jobView(await d.jobs.revert(a.jobId, actor.operatorId)) }) },
|
||||
{ name: 'hal_config_set', risk: 'high', description: 'HAL 운영 설정(공개 주소·포털 주소·offline 판정 시간·지표 보존 일수)을 바꾼다. 비밀값은 받지 않는다. 서비스 재시작 뒤 반영되는 값도 있다.', schema: S({ publicUrl: str('HAL 공개 주소'), portalUrl: str('포털 내부 주소'), offlineAfterMs: num('heartbeat 없음 → offline 판정(ms)'), metricsRetentionDays: num('지표 보존 일수(최소 90)') }), identifiers: () => ['hal'], run: (d, a, actor) => { const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) if (a[k] !== undefined) patch[k] = a[k]; if (!Object.keys(patch).length) throw new HttpError(400, 'nothing_to_set', '바꿀 값이 없습니다'); d.config.set(patch, actor.operatorId); return { updated: Object.keys(patch) }; } },
|
||||
{ name: 'hal_settings_set', risk: 'high', description: '[0.4.0] 통합 설정 관제: 모듈 설정을 HAL 에 저장한다(마진·가격·모델·서비스·서버별 항목). 비밀값(API 키·토큰·비밀번호)은 받지 않는다 — 운영자가 화면에서 직접 넣는다. 저장만으로는 서버에 반영되지 않으며 hal_settings_apply 로 적용한다. 값을 null 로 주면 그 지정을 지운다.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), scope: str('global 또는 server:<서버 id>'), values: { type: 'object', description: '{ 설정 키: 값 } — 키는 hal_settings_get 의 목록을 따른다' } }, ['module', 'values']), identifiers: (a) => [`${a.module}:${a.scope || 'global'}`], run: (d, a, actor) => { for (const k of Object.keys(a.values || {})) { const def = keyDef(a.module, k); if (def && def.type === 'secret') throw new HttpError(400, 'secret_not_allowed', `비밀값(${k})은 AI 도구로 넣을 수 없습니다 — 운영자가 통합 설정 화면에서 직접 입력하세요`); } const r = settingsStore.setSettings(d, { module: a.module, scope: a.scope || 'global', values: a.values || {}, by: actor.operatorId, via: 'ai' }); return { module: r.module, scope: r.scope, changed: r.changed, restartNeeded: r.restartNeeded }; } },
|
||||
{ name: 'hal_settings_apply', risk: 'high', description: '[0.4.0] 통합 설정 관제: 저장된 설정을 서버에 적용하는 작업(settings.apply)을 큐에 넣는다. serverIds 를 비우면 그 모듈이 배치된 서버 전체. restart 는 재시작 필요 항목을 바꿨을 때.', schema: S({ module: str('모듈', { enum: MANAGED_MODULES }), serverIds: arr('대상 서버 id 목록(비우면 배치된 서버 전체)'), restart: bool('적용 뒤 서비스 재시작') }, ['module']), identifiers: (a) => (a.serverIds && a.serverIds.length ? a.serverIds : [a.module]), run: (d, a, actor) => { const r = enqueueSettingsApply(d, { module: a.module, serverIds: a.serverIds || [], restart: a.restart, nonce: 'ai', by: actor.operatorId }); return { job: jobView(d.jobs.get(r.job.id)), targets: r.targets, restart: r.restart }; } },
|
||||
];
|
||||
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
||||
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
|
||||
@@ -109,6 +115,7 @@ export function validateArgs(tool, args) {
|
||||
if (p.type === 'string') { if (typeof v !== 'string') return `${k} 는 문자열이어야 합니다`; if (v.length > 4000) return `${k} 가 너무 깁니다`; if (p.enum && !p.enum.includes(v)) return `${k} 는 ${p.enum.join('·')} 중 하나여야 합니다`; }
|
||||
else if (p.type === 'number') { if (typeof v !== 'number' || !Number.isFinite(v)) return `${k} 는 숫자여야 합니다`; }
|
||||
else if (p.type === 'boolean') { if (typeof v !== 'boolean') return `${k} 는 true/false 여야 합니다`; }
|
||||
else if (p.type === 'object') { if (typeof v !== 'object' || Array.isArray(v)) return `${k} 는 객체여야 합니다`; if (Object.keys(v).length > 100) return `${k} 항목이 너무 많습니다`; }
|
||||
else if (p.type === 'array') { if (!Array.isArray(v)) return `${k} 는 배열이어야 합니다`; if (v.length > 200) return `${k} 항목이 너무 많습니다`; if (p.items?.type === 'string' && !v.every((x) => typeof x === 'string' && x.length <= 400)) return `${k} 항목은 문자열이어야 합니다`; }
|
||||
}
|
||||
return null;
|
||||
|
||||
+32
-5
@@ -28,7 +28,10 @@ import * as dr from './dr.mjs';
|
||||
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'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
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(연결·방·제안·도구)
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
|
||||
@@ -45,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() }; }));
|
||||
@@ -59,10 +65,11 @@ 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)); } }));
|
||||
app.get('/agent/v1/settings/:module', wrap((ctx) => { if (!ctx.agent.server_id) throw new HttpError(403, 'not_managed_server', '관리 서버의 Agent 만 설정을 받을 수 있습니다'); return { ok: true, settings: settingsStore.payloadForAgent(d, ctx.params.module, ctx.agent.server_id) }; })); // [0.4.0] 자기 서버 범위의 유효 설정(비밀값 포함) — 다른 서버의 덮어쓰기는 받을 수 없다
|
||||
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
|
||||
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
|
||||
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
|
||||
@@ -129,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 }; }));
|
||||
@@ -186,6 +194,13 @@ export function buildApp(d) {
|
||||
app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) })));
|
||||
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
|
||||
app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; }));
|
||||
// ---------------- [0.4.0] 통합 설정 관제(hal/settings): 도입 모듈·Agent 의 서버 단위 설정을 HAL 이 저장(비밀값은 Vault)하고 Agent 작업으로 적용한다
|
||||
app.get('/ops/api/settings', () => ({ ok: true, modules: MANAGED_MODULES, catalog: publicCatalog(), overview: settingsStore.settingsOverview(d) }));
|
||||
app.get('/ops/api/settings/:module', wrap((ctx) => { const m = ctx.params.module; const sc = settingsStore.parseScope(ctx.query.scope || 'global'); const g = settingsStore.getSettings(d, m, sc.scope); const out = { ok: true, ...g, catalog: publicCatalog()[m], placements: settingsStore.placements(d, m) }; if (sc.serverId) { const e = settingsStore.effectiveSettings(d, m, sc.serverId); out.effective = { values: e.values, secrets: e.secrets, sources: e.sources }; out.state = settingsStore.stateFor(d, m, sc.serverId); } return out; }));
|
||||
// 가격·키·모델 변경은 고객 영향이 있으므로 저장·삭제·적용 모두 최근 재인증(10분)을 요구한다. 응답·감사 기록에 값은 싣지 않는다(키 이름만).
|
||||
app.put('/ops/api/settings/:module', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = settingsStore.setSettings(d, { module: ctx.params.module, scope: b.scope || 'global', values: b.values || {}, by: ctx.operator.id }); return { ok: true, ...r }; }));
|
||||
app.delete('/ops/api/settings/:module/:key', wrap((ctx) => { requireRecent(ctx); const r = settingsStore.deleteSetting(d, { module: ctx.params.module, scope: ctx.query.scope || 'global', key: ctx.params.key, by: ctx.operator.id }); return { ok: true, ...r }; }));
|
||||
app.post('/ops/api/settings/:module/apply', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...enqueueSettingsApply(d, { module: ctx.params.module, serverIds: b.serverIds, restart: b.restart, nonce: b.nonce, by: ctx.operator.id }) }; }));
|
||||
registerAiRoutes(app, d, { requireRecent, threeStep, wrap }); // 0.3.0: 같은 requireRecent·threeStep 으로 인증 키 변경·제안 승인을 지킨다
|
||||
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
|
||||
app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `<!doctype html><html lang="ko"><meta charset="utf-8"><title>Ariweb HAL</title><body style="font-family:sans-serif;padding:40px"><h1>Ariweb HAL 운영자 콘솔</h1><p>화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me</p></body></html>`); });
|
||||
@@ -193,11 +208,23 @@ export function buildApp(d) {
|
||||
}
|
||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
||||
/** [0.4.0] 설정 적용 작업 만들기(운영자 API·AI 도구 공용). 대상을 비우면 그 모듈이 배치된(격리되지 않은) 서버 전체, 없으면 no_targets. 배치되지 않은 서버는 not_placed. */
|
||||
export function enqueueSettingsApply(d, { module, serverIds = [], restart = undefined, nonce = '', by = null, actorType = 'operator' }) {
|
||||
settingsStore.getSettings(d, module, 'global'); // 모르는 모듈이면 unknown_module(404)
|
||||
const placed = settingsStore.placements(d, module); const placedIds = placed.map((s) => s.id);
|
||||
let ids = Array.isArray(serverIds) ? serverIds.filter(Boolean).map(String) : [];
|
||||
if (!ids.length) { ids = placed.filter((s) => s.status !== 'isolated').map((s) => s.id); if (!ids.length) throw new HttpError(400, 'no_targets', `${module} 이(가) 배치된 서버가 없습니다 — 먼저 모듈을 배포하세요`); }
|
||||
const notPlaced = ids.filter((id) => !placedIds.includes(id)); if (notPlaced.length) throw new HttpError(400, 'not_placed', `${module} 이(가) 배치되지 않은 서버: ${notPlaced.join(', ')}`);
|
||||
const cat = catalogFor(module); const rs = restart === undefined || restart === null ? !!cat.restartDefault : !!restart;
|
||||
const hashes = ids.slice().sort().map((id) => settingsStore.effectiveSettings(d, module, id).hash.slice(0, 12)).join(',');
|
||||
const r = d.jobs.enqueue({ kind: 'settings.apply', subject: { module, serverIds: ids, restart: rs }, idempotencyKey: `settings.apply:${module}:${ids.slice().sort().join(',')}:${hashes}:${rs ? 1 : 0}:${nonce || ''}`, createdBy: by, actorType, subjectType: 'module', subjectId: module, label: `${cat.label} 설정 적용` });
|
||||
return { job: describe(r.job), created: r.created, targets: ids, restart: rs };
|
||||
}
|
||||
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) {
|
||||
@@ -206,7 +233,7 @@ function overview(d) {
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); }); // [0.3.17] observability → hawkeye
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
return { ok: true, hal: health, components, settings: { stale: settingsStore.staleCount(d) }, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
}
|
||||
|
||||
+43
-12
@@ -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
@@ -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); }
|
||||
|
||||
@@ -18,6 +18,8 @@ import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as core from './core.mjs';
|
||||
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { catalogFor } from './settings/catalog.mjs';
|
||||
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
@@ -174,6 +176,36 @@ export function registerKinds(engine, d) {
|
||||
async function waitChild(ctx, id, maxMs = d.config.fanoutTimeoutMs || 12 * 3600 * 1000) { const t0 = Date.now(); while (true) { const j = engine.get(id); if (!j) throw new JobError('child_missing', '자식 작업이 없습니다'); if (['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) return j; if (Date.now() - t0 > maxMs) throw new JobError('child_timeout', '자식 작업이 제한 시간 안에 끝나지 않았습니다'); ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); } }
|
||||
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 id(싱글턴 검사) */
|
||||
function portalOwner(exceptServerId = null) { const r = d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1' + (exceptServerId ? ' AND server_id<>?' : '') + ' ORDER BY updated_at DESC LIMIT 1', ...(exceptServerId ? [exceptServerId] : [])); return r ? r.server_id : null; }
|
||||
// ---------------- [0.4.0] 통합 설정 적용: 부모(settings.apply) → 서버별(settings.apply.target) → Agent module.configure
|
||||
// Agent 는 작업 payload 로 비밀값을 받지 않는다 — 작업에는 hash 만 싣고, Agent 가 HAL 의 /agent/v1/settings/:module 에서 자기 서버 범위의 값을 직접 받아(HMAC) hash 를 맞춰 본 뒤 쓴다.
|
||||
engine.register('settings.apply', { title: '설정 적용', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
|
||||
async run(job, ctx) {
|
||||
const { module, serverIds, restart } = job.subject; if (!serverIds?.length) throw new JobError('no_targets', '대상 서버가 없습니다');
|
||||
let sum; try { sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'settings.apply.target', serverIds, mk: (serverId) => ({ module, serverId, restart: !!restart }) })); }
|
||||
finally { audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'settings.apply', subjectType: 'module', subjectId: module, detail: { serverIds, restart: !!restart, overall: engine.childrenSummary(job.id).overall } }); }
|
||||
return sum;
|
||||
} });
|
||||
engine.register('settings.apply.target', { title: '설정 적용(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설정 적용 지시' }, { id: 'await', title: '설정 쓰기·재시작' }, { id: 'verify', title: '적용 hash·건강 확인' }],
|
||||
async run(job, ctx) {
|
||||
const { module, serverId, restart } = job.subject; const cat = catalogFor(module); if (!cat) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`);
|
||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
const facts = srv.facts || {}; let desc = null; try { desc = descriptor(module); } catch (_) { desc = null; }
|
||||
const unit = (facts.units && facts.units[module]) || desc?.unit || cat.unit; const appDir = (facts.appDirs && facts.appDirs[module]) || desc?.baseDir || `/srv/ariweb/${module}`;
|
||||
const configDir = (facts.configDirs && facts.configDirs[module]) || cat.configDir; const hash = settingsStore.effectiveSettings(d, module, serverId).hash;
|
||||
try {
|
||||
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: 'module.configure', title: 'Agent 에 설정 적용 지시', payload: { module, unit, appDir, configDir, settingsUrl: `/agent/v1/settings/${module}`, hash, restart: !!restart, descriptor: desc ? { unit, health: desc.health || null } : { unit } } });
|
||||
const sentHash = core.getAgentJob(d, disp.agentJobId)?.payload?.hash || hash; // 재개 때도 실제로 보낸 payload 의 hash 로 판정
|
||||
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
|
||||
return await ctx.step('verify', () => {
|
||||
if (!r || r.hash !== sentHash) throw new JobError('settings_hash_mismatch', `${srv.name}: Agent 가 쓴 설정 hash(${String(r && r.hash).slice(7, 19)})가 보낸 설정(${sentHash.slice(7, 19)})과 다릅니다`, { detail: { hash: r && r.hash } });
|
||||
if (r.health && r.health.ok === false) throw new JobError('health_failed', `${srv.name}: 설정 적용 뒤 건강 확인 실패`, { detail: r.health });
|
||||
if (r.applied && r.applied.hash && r.applied.hash !== sentHash) throw new JobError('module_apply_failed', `${srv.name}: 모듈이 다른 설정(${String(r.applied.hash).slice(7, 19)})을 적용했다고 보고했습니다`, { detail: r.applied });
|
||||
if (r.applied && Array.isArray(r.applied.errors) && r.applied.errors.length) throw new JobError('module_apply_failed', `${srv.name}: 모듈이 일부 항목을 적용하지 못했습니다(${r.applied.errors.map((e) => e.key).join(', ')})`, { detail: r.applied });
|
||||
settingsStore.recordState(d, { serverId, module, status: 'ok', hash: sentHash, jobId: job.id });
|
||||
return { hash: sentHash, restarted: !!r.restarted, appliedVerified: !!(r.applied && r.applied.hash === sentHash), file: r.file || null };
|
||||
});
|
||||
} catch (e) { settingsStore.recordState(d, { serverId, module, status: 'failed', error: `${e.code || 'error'}: ${String(e.message || e).slice(0, 300)}`, jobId: job.id }); throw e; }
|
||||
} });
|
||||
engine.register('server.provision', { title: '역할별 통합 배포', steps: [{ id: 'precheck', title: '서버·프로필·릴리스 확인' }, { id: 'profile', title: '역할·프로필·슬롯 기록' }, { id: 'deploy', title: '모듈 순서대로 배포' }],
|
||||
async run(job, ctx) {
|
||||
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -135,4 +135,11 @@ INSERT OR IGNORE INTO modules(name, target_version, updated_at) SELECT 'hawkeye'
|
||||
DELETE FROM modules WHERE name='observability';
|
||||
` },
|
||||
{ id: 'hal_0009_server_profile', sql: `ALTER TABLE servers ADD COLUMN profile TEXT; ALTER TABLE servers ADD COLUMN slots_json TEXT;` },
|
||||
// 0.4.0 통합 설정 관제(hal/settings): 관리 설정(범위 global | server:<id>, 비밀값은 Vault 참조만) · 서버별 적용 상태(적용된 hash·시각·결과)
|
||||
{ id: 'hal_0010_managed_settings', sql: `
|
||||
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;` },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* hal/settings/catalog.mjs — 통합 설정 관제(0.4.0, 개발 지시 v0.4.0 §2.1) 설정 목록(catalog).
|
||||
*
|
||||
* HAL 이 도입 모듈·Agent 의 "서버 단위" 설정을 대신 관리한다. 모듈별로 묶음(group)과 항목(key)을 선언하고, 항목마다
|
||||
* type text · int · number · bool · enum · list(쉼표 구분 → 배열) · url · path · secret(비밀값 — Vault 에만, 화면·API 는 있음/없음만)
|
||||
* scope 'both'(기본: 전체 값 + 서버 덮어쓰기) · 'server'(서버별 항목 — 전체 범위에 둘 수 없음)
|
||||
* restart 값이 바뀌면 서비스 재시작이 필요한지(적용 작업의 안내·기본값)
|
||||
* min/max/options/pattern 검증 규칙. 알 수 없는 항목·형식 오류는 저장하지 않는다(bad_setting).
|
||||
* 모듈 쪽 적용 규칙(어느 파일·보관소에 넣는지)은 모듈 자신(Webmaker lib/hal-managed.js, AriMail server/hal-managed.mjs, Agent config.mjs)이 안다 — HAL 은 값만 보낸다.
|
||||
* 도메인별 항목(AriMail LIMIT_* 등 메일 서버 사용자가 직접 관리하는 것)은 여기 없다.
|
||||
*/
|
||||
const K = (key, label, type, extra = {}) => ({ key, label, type, scope: 'both', ...extra });
|
||||
const secret = (key, label, extra = {}) => K(key, label, 'secret', extra);
|
||||
const en = (key, label, options, extra = {}) => K(key, label, 'enum', { options, ...extra });
|
||||
const int = (key, label, min, max, extra = {}) => K(key, label, 'int', { min, max, ...extra });
|
||||
const num = (key, label, min, max, extra = {}) => K(key, label, 'number', { min, max, ...extra });
|
||||
const srv = (def) => ({ ...def, scope: 'server' });
|
||||
const MODEL_REF = { pattern: '^[a-z0-9_-]+:.+$', hint: '<제공자>:<모델> (예: anthropic:claude-opus-5-5)' };
|
||||
|
||||
export const SETTINGS_CATALOG = {
|
||||
webmaker: {
|
||||
module: 'webmaker', label: 'Webmaker', file: 'hal-managed.json', configDir: '/data/webmaker', unit: 'webmaker', restartDefault: false,
|
||||
description: 'Webmaker 관리 콘솔의 서버 단위 설정(API 키·마진·모델·서비스·서버별 설정)을 HAL 이 대신 관리한다. 사이트·회원·템플릿 등 콘텐츠 관리는 Webmaker 콘솔에 남는다.',
|
||||
groups: [
|
||||
{ id: 'auth', label: 'API 키·인증', keys: [
|
||||
en('auth.mode', 'AI 연결 방식', ['server-login', 'oauth-token', 'api-key']), secret('auth.apiKey', 'Anthropic API 키'), secret('auth.oauthToken', 'Anthropic OAuth 토큰'),
|
||||
secret('serviceApiKey', '서비스 키(X-Service-Key, 외부 캐시 조정)'), secret('backlogKey', '작업 반영 목록 읽기 키(X-Backlog-Key)'), secret('mail.apiKey', 'Resend API 키(사이트 메일 발송)'),
|
||||
secret('source.token', 'Gitea API 토큰'), secret('ariweb.secret', 'Ariweb 포털 공유 비밀(SSO·서비스 호출 서명)'), secret('mail.adminPassword', 'AriMail 관리자 비밀번호(계정 생성·삭제용)'),
|
||||
] },
|
||||
{ id: 'pricing', label: '마진·가격 정책', keys: [
|
||||
num('marginPct', '사용자 청구 마진 %', 0, 1000), en('fx.mode', '환율 방식', ['auto', 'manual']), num('fx.manualRate', '직접 입력 환율(1 USD 원화)', 0, 100000), num('fx.spreadPct', '환율 스프레드 %', 0, 50),
|
||||
int('signupCredit', '신규 회원 기본 캐시(원)', 0, 100000000), int('maxEdits', '발행 전 수정 횟수 한도(0=무제한)', 0, 10000), en('defaultTier', '기본 등급', ['basic', 'standard', 'premium']),
|
||||
K('tiers.basic', '기본 등급 모델', 'text', MODEL_REF), K('tiers.standard', '중급 등급 모델', 'text', MODEL_REF), K('tiers.premium', '고급 등급 모델', 'text', MODEL_REF), en('signupApproval', '회원가입 승인 방식', ['immediate', 'approval']),
|
||||
] },
|
||||
{ id: 'models', label: '모델', keys: [
|
||||
K('internalRef', '내부 호출 모델', 'text', MODEL_REF), K('defaultModel', 'Claude 기본 모델 id', 'text', { pattern: '^[a-z0-9.-]+$' }), K('claudeModels', '사용할 Claude 모델(id 목록, 비우면 전체)', 'list'),
|
||||
int('ai.claudeMaxTurns', 'Claude 최대 턴', 1, 500, { restart: true }), int('ai.openaiMaxTurns', 'OpenAI 호환 엔진 최대 턴', 1, 500, { restart: true }), int('ai.maxJobMinutes', 'AI 작업 최대 실행 시간(분)', 5, 240), int('ai.maxConcurrentJobs', '동시 AI 작업 수(서버 전체)', 1, 32),
|
||||
en('ai.codexSandbox', 'codex 샌드박스', ['auto', 'workspace-write', 'none']),
|
||||
] },
|
||||
{ id: 'service', label: '전체 적용 서비스', keys: [
|
||||
K('service.allowSignup', '회원가입 허용', 'bool'), K('letsEncryptEmail', "Let's Encrypt 알림 이메일", 'text'), K('mail.fromName', '발신 표시 이름', 'text'), K('mail.defaultDomain', '기본 발신 도메인', 'text'), K('mail.formBase', '폼 접수 기본 주소', 'url'),
|
||||
K('ariweb.portalUrl', '포털 공개 주소', 'url'), K('endpoints.resendApiBase', 'Resend API', 'url'), K('endpoints.openproviderApiBase', 'Openprovider API', 'url'), K('endpoints.googleApiBase', 'Google API', 'url'), K('endpoints.googleTokenUrl', 'Google 토큰 URL', 'url'), K('endpoints.indexnowEndpoint', 'IndexNow', 'url'),
|
||||
int('limits.copyMaxPages', '복사 최대 페이지 수', 1, 100000, { restart: true }), int('limits.copyMaxAssets', '복사 최대 자원 파일 수', 1, 1000000, { restart: true }), int('ssl.acmeWaitMin', '수동 DNS 대기(분)', 5, 600),
|
||||
] },
|
||||
{ id: 'server', label: '서버별 설정', scope: 'server', keys: [
|
||||
srv(K('service.publicBaseUrl', '서비스 공개 주소', 'url')), srv(K('service.formBaseUrl', '폼 접수 서버 주소', 'url')), srv(K('service.siteOrigin', '생성 사이트 전용 주소', 'text')), srv(K('service.trustedProxies', '신뢰 프록시 IP(쉼표)', 'text')), srv(K('service.internalAllow', '내부 주소 허용 목록(host:port, 쉼표)', 'text')),
|
||||
srv(K('ai.claudeBin', 'claude 실행 파일', 'path', { restart: true })), srv(K('ai.codexBin', 'codex 실행 파일', 'path', { restart: true })),
|
||||
srv(en('source.provider', 'Source 연동', ['gitea', 'none'])), srv(K('source.remote', 'git 푸시 주소', 'text')), srv(K('source.web', 'Source 웹 주소', 'url')), srv(K('source.prefix', '브랜치 접두어', 'text')), srv(K('source.apiUrl', 'Gitea API 주소', 'url')), srv(K('source.user', 'Gitea 계정', 'text')), srv(K('source.repo', 'Gitea 저장소', 'text')),
|
||||
srv(en('mail.provider', '메일 연동', ['none', 'arimail'])), srv(K('mail.url', 'AriMail 공개 주소', 'url')), srv(K('mail.apiUrl', 'AriMail API 주소(내부)', 'url')), srv(K('mail.domain', '메일 도메인', 'text')), srv(K('mail.adminLogin', 'AriMail 관리자 로그인 ID', 'text')), srv(K('mail.nickname', '기본 메일 서버 닉네임', 'text')), srv(K('mail.ip', '기본 메일 서버 IP', 'text')),
|
||||
srv(K('ssl.acmeDir', 'ACME 작업 폴더', 'path', { restart: true })),
|
||||
] },
|
||||
],
|
||||
},
|
||||
arimail: {
|
||||
module: 'arimail', label: 'AriMail', file: 'hal-managed.json', configDir: '<appDir>/data', unit: 'arimail', restartDefault: false,
|
||||
description: 'AriMail 의 서버 단위 설정(수·발신 방식, Resend, DNS, SMTP 중계, Haraka, 표시, 사용량 API, Ariweb SSO)을 HAL 이 관리한다. 도메인별 제한·계정·메일함은 메일 서버 사용자(도메인 관리자)가 직접 관리한다.',
|
||||
groups: [
|
||||
{ id: 'mode', label: '수·발신 방식', keys: [en('inbound', '수신 방식', ['resend', 'haraka'], { restart: true }), en('outbound', '발신 방식', ['resend', 'haraka', 'smtp'], { restart: true })] },
|
||||
{ id: 'resend', label: 'Resend', keys: [secret('RESEND_API_KEY', 'Resend API 키'), secret('RESEND_WEBHOOK_SECRET', 'Resend 웹훅 서명 비밀'), en('RESEND_REGION', 'Resend 지역', ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'])] },
|
||||
{ id: 'dns', label: 'DNS 제공자', keys: [en('DNS_PROVIDER', 'DNS 제공자', ['none', 'google', 'route53']), secret('DNS_GOOGLE_CREDENTIALS', 'Google Cloud DNS 서비스 계정 JSON'), K('DNS_GOOGLE_PROJECT', 'Google 프로젝트', 'text'), K('DNS_GOOGLE_ZONE', 'Google DNS 존', 'text'), K('DNS_AWS_ACCESS_KEY_ID', 'AWS Access Key ID', 'text'), secret('DNS_AWS_SECRET_ACCESS_KEY', 'AWS Secret Access Key'), K('DNS_AWS_HOSTED_ZONE_ID', 'Route 53 Hosted Zone ID', 'text')] },
|
||||
{ id: 'smtp', label: 'SMTP 중계', keys: [K('SMTP_RELAY_HOST', '중계 호스트', 'text'), int('SMTP_RELAY_PORT', '중계 포트', 1, 65535), en('SMTP_RELAY_SECURITY', '보안', ['starttls', 'tls', 'none']), K('SMTP_RELAY_USER', '중계 계정', 'text'), secret('SMTP_RELAY_PASS', '중계 비밀번호')] },
|
||||
{ id: 'haraka', label: '내장 Haraka(서버별)', scope: 'server', keys: [srv(int('HARAKA_PORT', 'SMTP 수신 포트', 1, 65535, { restart: true })), srv(int('HARAKA_RELAY_PORT', '전달 포트', 1, 65535, { restart: true })), srv(K('HARAKA_HOSTNAME', '서버 호스트 이름', 'text', { restart: true })), srv(K('HARAKA_DKIM_SELECTOR', 'DKIM 선택자', 'text', { restart: true }))] },
|
||||
{ id: 'site', label: '표시·동기화', keys: [K('MAIL_FROM_NAME', '발신 표시 이름', 'text'), K('SITE_TITLE', '사이트 제목', 'text'), K('MAILBOX_OWNER_LOGIN', '기본 메일함 소유 계정', 'text'), K('STATS_TIMEZONE', '통계 시간대', 'text'), int('SYNC_INTERVAL_MS', '동기화 주기(ms)', 60000, 86400000)] },
|
||||
{ id: 'usage', label: '사용량 API', keys: [secret('USAGE_API_KEY', '사용량 API 키(Webmaker 가 호출)'), K('USAGE_API_ALLOW', '사용량 API 허용 IP(쉼표)', 'text')] },
|
||||
{ id: 'ariweb', label: 'Ariweb 연동', keys: [secret('ARIWEB_SSO_SECRET', 'Ariweb SSO 공유 비밀')] },
|
||||
],
|
||||
},
|
||||
agent: {
|
||||
module: 'agent', label: 'Node Agent', file: 'hal-managed.json', configDir: '<dataDir>', unit: 'ariweb-agent', restartDefault: true,
|
||||
description: '각 서버의 Node Agent 설정. 적용하면 Agent 가 결과를 보고한 뒤 스스로 재시작한다.',
|
||||
groups: [{ id: 'agent', label: 'Agent', keys: [int('heartbeatMs', 'heartbeat 주기(ms)', 5000, 600000, { restart: true })] }],
|
||||
},
|
||||
};
|
||||
export const MANAGED_MODULES = Object.keys(SETTINGS_CATALOG);
|
||||
export const catalogFor = (module) => SETTINGS_CATALOG[module] || null;
|
||||
const INDEX = new Map(MANAGED_MODULES.map((m) => [m, new Map(SETTINGS_CATALOG[m].groups.flatMap((g) => g.keys.map((k) => [k.key, { ...k, group: g.id }])))]));
|
||||
export const keyDef = (module, key) => (INDEX.get(module) || new Map()).get(key) || null;
|
||||
export const allKeys = (module) => [...(INDEX.get(module) || new Map()).values()];
|
||||
/** 목록의 공개 형태(화면·AI 도구) */
|
||||
export const publicCatalog = () => Object.fromEntries(MANAGED_MODULES.map((m) => { const c = SETTINGS_CATALOG[m]; return [m, { module: m, label: c.label, description: c.description, restartDefault: c.restartDefault, groups: c.groups.map((g) => ({ id: g.id, label: g.label, scope: g.scope || 'both', keys: g.keys.map((k) => ({ key: k.key, label: k.label, type: k.type, scope: k.scope, options: k.options, min: k.min, max: k.max, restart: !!k.restart, hint: k.hint })) })) }]; }));
|
||||
/** 값 하나를 형식에 맞게 정규화한다. 실패하면 { code: 'bad_setting' } 오류 */
|
||||
export function normalizeValue(def, raw) {
|
||||
const bad = (why) => { throw Object.assign(new Error(`${def.label}(${def.key}): ${why}`), { code: 'bad_setting', status: 400, key: def.key }); };
|
||||
if (raw === null || raw === undefined) return null;
|
||||
switch (def.type) {
|
||||
case 'secret': { const s = String(raw); return s.trim() === '' ? null : s; }
|
||||
case 'bool': { if (typeof raw === 'boolean') return raw; const s = String(raw).trim().toLowerCase(); if (['true', '1', 'on', 'yes'].includes(s)) return true; if (['false', '0', 'off', 'no', ''].includes(s)) return false; return bad('참/거짓 값이어야 합니다'); }
|
||||
case 'int': case 'number': { if (String(raw).trim() === '') return null; const n = Number(raw); if (!Number.isFinite(n)) return bad('숫자여야 합니다'); if (def.type === 'int' && !Number.isInteger(n)) return bad('정수여야 합니다'); if (def.min !== undefined && n < def.min) return bad(`${def.min} 이상이어야 합니다`); if (def.max !== undefined && n > def.max) return bad(`${def.max} 이하여야 합니다`); return n; }
|
||||
case 'enum': { const s = String(raw).trim(); if (s === '') return null; if (!def.options.includes(s)) return bad(`허용 값: ${def.options.join(', ')}`); return s; }
|
||||
case 'list': { const arr = Array.isArray(raw) ? raw : String(raw).split(',').map((x) => x.trim()); return arr.map(String).map((x) => x.trim()).filter(Boolean); }
|
||||
case 'url': { const s = String(raw).trim(); if (s === '') return null; if (!/^https?:\/\/[^\s]+$/i.test(s)) return bad('http(s):// 주소여야 합니다'); return s; }
|
||||
default: { const s = String(raw).trim(); if (s === '') return null; if (s.length > 4000) return bad('너무 깁니다'); if (def.pattern && !new RegExp(def.pattern).test(s)) return bad(def.hint || '형식이 올바르지 않습니다'); return s; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* hal/settings/index.mjs — 통합 설정 관제(0.4.0) 저장소·유효값·적용 상태.
|
||||
*
|
||||
* 저장: managed_settings(scope, module, key, value_json). scope 는 'global'(전체) 또는 'server:<serverId>'(서버 덮어쓰기).
|
||||
* 비밀값(type secret)은 행에 두지 않고 Vault 에 `managed:<scope>:<module>:<key>` 로만 둔다. 행의 value_json 은 { vault: <이름> } 참조.
|
||||
* 유효값 = 전체 ← 서버 덮어쓰기. hash 는 모듈·유효값(비밀값 포함)의 정규 JSON sha256 — Agent 가 받은 내용과 HAL 이 보낸 내용이 같은지 검증하고, 적용 상태(managed_settings_state)와 비교해 "적용 필요"를 판단한다.
|
||||
* 감사 기록에는 키 이름만 남긴다(값·비밀값 없음).
|
||||
*/
|
||||
import { nowIso } from '../../lib/db.mjs';
|
||||
import { audit } from '../../lib/audit.mjs';
|
||||
import { JobError } from '../../lib/jobs.mjs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { catalogFor, keyDef, allKeys, MANAGED_MODULES } from './catalog.mjs';
|
||||
import { normalizeValue } from './catalog.mjs';
|
||||
|
||||
export { MANAGED_MODULES };
|
||||
const vaultName = (scope, module, key) => `managed:${scope}:${module}:${key}`;
|
||||
export function parseScope(scope) {
|
||||
const s = String(scope || 'global'); if (s === 'global') return { scope: 'global', serverId: null };
|
||||
const m = /^server:([A-Za-z0-9_-]+)$/.exec(s); if (!m) throw new JobError('bad_scope', '범위는 global 또는 server:<서버 id> 이어야 합니다', { status: 400 });
|
||||
return { scope: s, serverId: m[1] };
|
||||
}
|
||||
function requireModule(module) { const c = catalogFor(module); if (!c) throw new JobError('unknown_module', `관리 설정이 없는 모듈: ${module}`, { status: 404 }); return c; }
|
||||
|
||||
/** 범위 하나의 저장된 값(비밀값은 있음/없음) */
|
||||
export function getSettings(d, module, scope = 'global') {
|
||||
requireModule(module); const { scope: sc } = parseScope(scope);
|
||||
const values = {}; const secrets = {}; for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false;
|
||||
for (const r of d.store.all('SELECT key, value_json, is_secret, updated_at, updated_by FROM managed_settings WHERE scope=? AND module=?', sc, module)) {
|
||||
if (!keyDef(module, r.key)) continue;
|
||||
if (r.is_secret) secrets[r.key] = d.vault.has(vaultName(sc, module, r.key)); else values[r.key] = JSON.parse(r.value_json);
|
||||
}
|
||||
return { module, scope: sc, values, secrets, updatedAt: d.store.get('SELECT MAX(updated_at) AS t FROM managed_settings WHERE scope=? AND module=?', sc, module)?.t || null };
|
||||
}
|
||||
/** 저장(여러 항목). 비밀값: 빈 값·null 이면 삭제, 아니면 Vault 에 저장. 일반 값: null 이면 삭제. 검증을 모두 통과한 뒤에만 쓴다. */
|
||||
export function setSettings(d, { module, scope = 'global', values = {}, by = null, actorType = 'operator', via = null }) {
|
||||
requireModule(module); const { scope: sc, serverId } = parseScope(scope);
|
||||
if (!values || typeof values !== 'object' || Array.isArray(values)) throw new JobError('bad_setting', '값은 객체여야 합니다', { status: 400 });
|
||||
const plan = [];
|
||||
for (const [key, raw] of Object.entries(values)) {
|
||||
const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 });
|
||||
if (def.scope === 'server' && !serverId) throw new JobError('bad_setting', `${def.label}(${key})은(는) 서버별 항목이라 전체 범위에 둘 수 없습니다`, { status: 400 });
|
||||
let v; try { v = normalizeValue(def, raw); } catch (e) { throw new JobError('bad_setting', e.message, { status: 400, detail: { key } }); }
|
||||
plan.push({ key, def, value: v });
|
||||
}
|
||||
const now = nowIso(); const changed = [];
|
||||
for (const { key, def, value } of plan) {
|
||||
const name = vaultName(sc, module, key);
|
||||
if (value === null) { const had = d.store.run('DELETE FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key).changes > 0; if (def.type === 'secret') { if (d.vault.remove(name) || had) changed.push(key); } else if (had) changed.push(key); continue; }
|
||||
if (def.type === 'secret') { d.vault.set(name, value, { kind: 'managed-setting' }); d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,1,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=1, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, JSON.stringify({ vault: name }), by, now); changed.push(key); continue; }
|
||||
const prev = d.store.get('SELECT value_json FROM managed_settings WHERE scope=? AND module=? AND key=?', sc, module, key); const vj = JSON.stringify(value);
|
||||
if (prev && prev.value_json === vj) continue;
|
||||
d.store.run('INSERT INTO managed_settings(scope, module, key, value_json, is_secret, updated_by, updated_at) VALUES(?,?,?,?,0,?,?) ON CONFLICT(scope, module, key) DO UPDATE SET value_json=excluded.value_json, is_secret=0, updated_by=excluded.updated_by, updated_at=excluded.updated_at', sc, module, key, vj, by, now); changed.push(key);
|
||||
}
|
||||
if (changed.length) audit(d.store, { actorType, actorId: by, action: 'settings.set', subjectType: 'settings', subjectId: `${module}:${sc}`, detail: { module, scope: sc, keys: changed, ...(via ? { via } : {}) } });
|
||||
return { module, scope: sc, changed, restartNeeded: plan.some((p) => changed.includes(p.key) && p.def.restart) };
|
||||
}
|
||||
export function deleteSetting(d, { module, scope = 'global', key, by = null, actorType = 'operator' }) {
|
||||
requireModule(module); const { scope: sc } = parseScope(scope); const def = keyDef(module, key); if (!def) throw new JobError('bad_setting', `알 수 없는 설정 항목: ${module}.${key}`, { status: 400 });
|
||||
return setSettings(d, { module, scope: sc, values: { [key]: null }, by, actorType });
|
||||
}
|
||||
/** 서버 하나의 유효값(전체 ← 서버). includeSecrets 면 비밀값을 values 에 포함(Agent 전송·hash 계산용). hash 는 언제나 비밀값을 포함해 계산한다. */
|
||||
export function effectiveSettings(d, module, serverId, { includeSecrets = false } = {}) {
|
||||
requireModule(module); const scopes = ['global', ...(serverId ? [`server:${serverId}`] : [])];
|
||||
const values = {}; const sources = {}; const secretVals = {}; const secrets = {};
|
||||
for (const k of allKeys(module)) if (k.type === 'secret') secrets[k.key] = false;
|
||||
for (const sc of scopes) for (const r of d.store.all('SELECT key, value_json, is_secret FROM managed_settings WHERE scope=? AND module=?', sc, module)) {
|
||||
if (!keyDef(module, r.key)) continue; const src = sc === 'global' ? 'global' : 'server';
|
||||
if (r.is_secret) { const v = d.vault.get(vaultName(sc, module, r.key)); if (v === null) continue; secretVals[r.key] = v; secrets[r.key] = true; sources[r.key] = src; }
|
||||
else { values[r.key] = JSON.parse(r.value_json); sources[r.key] = src; }
|
||||
}
|
||||
const canon = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
|
||||
const hash = 'sha256:' + createHash('sha256').update(`${module}|${canon(values)}|${canon(secretVals)}`).digest('hex'); // 'sha256:' 접두어 — 긴 hex 는 비밀값 형태로 가려지므로(lib/redact) 작업 결과·로그에서 비교할 수 있게
|
||||
return { module, serverId: serverId || null, values: includeSecrets ? { ...values, ...secretVals } : values, secrets, sources, hash };
|
||||
}
|
||||
/** Agent 에 보내는 내용(비밀값 포함) */
|
||||
export function payloadForAgent(d, module, serverId) { const e = effectiveSettings(d, module, serverId, { includeSecrets: true }); return { module, serverId, hash: e.hash, generatedAt: nowIso(), values: e.values }; }
|
||||
/** 모듈이 배치된 서버(적용 대상). agent 는 활성 Agent 가 있는 모든 서버. */
|
||||
export function placements(d, module) {
|
||||
requireModule(module);
|
||||
if (module === 'agent') return d.store.all("SELECT s.id, s.name, s.status FROM servers s WHERE EXISTS (SELECT 1 FROM agents a WHERE a.server_id=s.id AND a.status='active') ORDER BY s.created_at");
|
||||
return d.store.all('SELECT s.id, s.name, s.status FROM module_targets t JOIN servers s ON s.id=t.server_id WHERE t.module=? AND t.active=1 ORDER BY s.created_at', module);
|
||||
}
|
||||
export function recordState(d, { serverId, module, status, hash = null, error = null, jobId = null }) {
|
||||
const now = nowIso();
|
||||
if (status === 'ok') d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET applied_hash=excluded.applied_hash, applied_at=excluded.applied_at, status=excluded.status, error=NULL, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, hash, now, 'ok', null, jobId, now);
|
||||
else d.store.run('INSERT INTO managed_settings_state(server_id, module, applied_hash, applied_at, status, error, job_id, updated_at) VALUES(?,?,NULL,NULL,?,?,?,?) ON CONFLICT(server_id, module) DO UPDATE SET status=excluded.status, error=excluded.error, job_id=excluded.job_id, updated_at=excluded.updated_at', serverId, module, status, error, jobId, now);
|
||||
}
|
||||
/** 서버 하나의 적용 상태(현재 유효값 hash 와 비교) */
|
||||
export function stateFor(d, module, serverId) {
|
||||
const r = d.store.get('SELECT * FROM managed_settings_state WHERE server_id=? AND module=?', serverId, module); const cur = effectiveSettings(d, module, serverId).hash;
|
||||
return { serverId, module, status: r ? r.status : 'unknown', appliedHash: r?.applied_hash || null, appliedAt: r?.applied_at || null, error: r?.error || null, jobId: r?.job_id || null, currentHash: cur, upToDate: !!(r && r.status === 'ok' && r.applied_hash === cur) };
|
||||
}
|
||||
const hasAnySettings = (d, module) => !!d.store.get('SELECT 1 AS x FROM managed_settings WHERE module=? LIMIT 1', module);
|
||||
/** 모듈별 개요: 배치 서버·적용 상태·적용 필요 수(설정이 하나도 없는 모듈은 적용 필요로 세지 않는다) */
|
||||
export function settingsOverview(d) {
|
||||
const out = {};
|
||||
for (const m of MANAGED_MODULES) {
|
||||
const any = hasAnySettings(d, m); const servers = placements(d, m).map((s) => { const st = stateFor(d, m, s.id); return { serverId: s.id, serverName: s.name, serverStatus: s.status, ...st, upToDate: any ? st.upToDate : (st.status === 'ok' || st.status === 'unknown') }; });
|
||||
out[m] = { module: m, hasSettings: any, servers, stale: servers.filter((s) => !s.upToDate).length, globalCount: d.store.get('SELECT COUNT(*) AS n FROM managed_settings WHERE module=? AND scope=?', m, 'global').n };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export const staleCount = (d) => Object.values(settingsOverview(d)).reduce((n, m) => n + m.stale, 0);
|
||||
+79
@@ -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 };
|
||||
}
|
||||
+12
-3
@@ -28,13 +28,16 @@ import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||
import { releasesData } from './app.mjs';
|
||||
import * as R from './ui/render.mjs';
|
||||
import * as settingsStore from './settings/index.mjs';
|
||||
import { staleCount as settingsStaleCount, settingsOverview } from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { publicCatalog, MANAGED_MODULES } from './settings/catalog.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/** 메뉴(§25) */
|
||||
export const MENU = [
|
||||
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
||||
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
|
||||
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
|
||||
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['settings', '/ops/settings', '통합 설정'], ['jobs', '/ops/jobs', '운영 작업'],
|
||||
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
|
||||
];
|
||||
|
||||
@@ -50,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>
|
||||
@@ -81,6 +86,10 @@ export function registerUi(app, d) {
|
||||
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
|
||||
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
|
||||
// ---------------- 릴리스(§32.1)
|
||||
// [0.4.0] 통합 설정 관제: 모듈 탭 → 범위(전체 / 서버) → 묶음별 입력 → 저장 → 서버에 적용 → 서버별 적용 상태
|
||||
app.get('/ops/settings', (ctx) => { const module = MANAGED_MODULES.includes(ctx.query.module) ? ctx.query.module : MANAGED_MODULES[0]; let scope = String(ctx.query.scope || 'global'); const placements = settingsStore.placements(d, module); if (scope !== 'global' && !placements.some((p) => scope === 'server:' + p.id)) scope = 'global';
|
||||
const stored = settingsStore.getSettings(d, module, scope); const effective = scope === 'global' ? null : settingsStore.effectiveSettings(d, module, scope.slice(7));
|
||||
return shell(ctx, { title: '통합 설정 관제', nav: 'settings', page: 'settings', id: module, body: `<p class="muted">각 서버의 Webmaker·AriMail·Node Agent 관리 콘솔 중 서버 단위 설정(API 키·마진·가격·모델·서비스·서버별 설정)을 HAL 이 관리합니다. HAL 이 관리하는 항목은 각 모듈 관리 콘솔에서 잠깁니다. 도메인별 메일 관리·사이트·회원은 각 서비스에서 관리합니다.</p>${R.settingsPageHtml({ modules: MANAGED_MODULES, catalog: publicCatalog(), module, scope, placements, stored, effective, overview: settingsOverview(d) })}` }); });
|
||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ ...releasesData(d), running: core.bootstrapState(d) }, changelog()) })); // [0.3.17] 모듈별 최신 릴리스·안내 중심, HAL 제외
|
||||
// ---------------- 작업(§29)
|
||||
app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }));
|
||||
@@ -133,7 +142,7 @@ export function overviewLocal(d) {
|
||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
||||
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
|
||||
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'hawkeye'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
|
||||
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
return { ok: true, hal: health, components, settings: { stale: settingsStaleCount(d) }, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
|
||||
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
|
||||
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
|
||||
}
|
||||
|
||||
+15
-1
@@ -66,6 +66,8 @@ const PAGES = {
|
||||
server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); },
|
||||
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb && jb.outerHTML !== R.jobBlock(j)) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
|
||||
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
|
||||
// [0.4.0] 통합 설정 관제: 범위 바꾸면 그 범위로 다시 연다
|
||||
settings: () => { const sel = $('#settings-scope'); if (sel) sel.onchange = () => { location.href = `/ops/settings?module=${encodeURIComponent(sel.dataset.module)}&scope=${encodeURIComponent(sel.value)}`; }; },
|
||||
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
|
||||
};
|
||||
@@ -87,9 +89,21 @@ const ACT = {
|
||||
const r = await call('POST', '/ops/api/portal/relocate', { fromServerId: cur.server_id, toServerId: v.toServerId, version: v.version, ...c }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
|
||||
// ---- [0.4.0] 통합 설정 관제: 저장(재인증) · 비밀값 삭제 · 서버에 적용(서버 선택·재시작 여부 → 작업)
|
||||
'settings-save': async () => { const f = $('#settings-form'); if (!f) return; const values = {}; for (const el of f.querySelectorAll('[data-key]')) { const k = el.dataset.key; const v = el.value; if (el.dataset.type === 'secret') { if (v !== '') values[k] = v; continue; } values[k] = v === '' ? null : v; }
|
||||
const r = await call('PUT', `/ops/api/settings/${f.dataset.module}`, { scope: f.dataset.scope, values }); toast(r.changed.length ? `저장했습니다(${r.changed.length}개${r.restartNeeded ? ' · 재시작 필요 항목 포함' : ''}) — "서버에 적용"으로 반영하세요` : '바뀐 항목이 없습니다'); reload(); },
|
||||
'settings-delete': async (b) => { const f = $('#settings-form'); if (!f || !confirm(`${b.key} 비밀값을 HAL 에서 지웁니다(서버에는 다음 적용 때 반영)`)) return; await call('DELETE', `/ops/api/settings/${f.dataset.module}/${encodeURIComponent(b.key)}?scope=${encodeURIComponent(f.dataset.scope)}`); reload(); },
|
||||
'settings-apply': async (b) => { const sv = await api('GET', `/ops/api/settings/${b.m}`); const servers = (sv.placements || []).map((p) => ({ id: p.id, name: p.name, status: p.status === 'isolated' ? 'isolated' : 'ready' })); const restartDefault = !!(sv.catalog && sv.catalog.restartDefault);
|
||||
const v = await ask(`${(sv.catalog && sv.catalog.label) || b.m} 설정 적용 — 대상 서버`, [{ name: 'serverIds', label: '대상 서버(이 모듈이 배치된 서버)', type: 'checks', html: R.serverChoiceHtml(servers, { name: 'serverIds', multi: true, eligible: b.s ? [b.s] : null }) }, { name: 'restart', label: '적용 뒤 서비스 재시작(재시작 필요 항목을 바꾼 경우)', type: 'checkbox', value: restartDefault }], { okLabel: '적용 작업 만들기', note: 'Agent 가 HAL 에서 설정을 직접 받아 모듈 데이터 디렉터리의 hal-managed.json 에 쓰고, 모듈이 적용한 결과(hash)를 확인합니다.' });
|
||||
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(); },
|
||||
|
||||
+39
-2
@@ -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>
|
||||
@@ -118,6 +120,37 @@ export function serverChoiceHtml(servers, { name = 'serverId', multi = false, el
|
||||
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
|
||||
return `<div class="choice-list">${servers.map((s) => { const ok = s.status === 'ready'; const checked = multi && ok && (!eligible || eligible.includes(s.id)); return `<label class="choice${ok ? '' : ' off'}"><input type="${multi ? 'checkbox' : 'radio'}" name="${esc(name)}" value="${esc(s.id)}"${ok ? '' : ' disabled'}${checked ? ' checked' : ''}> <b>${esc(s.name)}</b> <span class="muted small">${esc(s.role)}${s.profile ? ' · ' + esc(s.profile) : ''} · ${esc(SERVER_LABEL[s.status] || s.status)}${s.agentVersion ? ' · Agent ' + esc(s.agentVersion) : ' · Agent 없음'}${s.modules && Object.keys(s.modules).length ? ' · ' + Object.entries(s.modules).map(([m, x]) => `${esc(m)} ${esc(x.actual || '—')}`).join(', ') : ''}</span></label>`; }).join('')}</div>`;
|
||||
}
|
||||
// ---------------- [0.4.0] 통합 설정 관제
|
||||
const SRC_LABEL = { global: '전체', server: '이 서버' };
|
||||
const showVal = (v) => Array.isArray(v) ? v.join(', ') : typeof v === 'boolean' ? (v ? '켬' : '끔') : String(v);
|
||||
/** 설정 입력 폼. cat = 설정 목록의 모듈 하나(groups·keys), scope = 'global' | 'server:<id>'. 비밀값은 값을 보이지 않고 설정됨/없음과 새 값 입력만. 전체 범위에는 서버별 항목이 없다. */
|
||||
export function settingsFormHtml(cat, { scope = 'global', values = {}, secrets = {}, effective = null } = {}) {
|
||||
const isServer = String(scope).startsWith('server:');
|
||||
const field = (k) => {
|
||||
const v = values[k.key]; const has = v !== undefined && v !== null; const inh = isServer && effective && effective.sources && effective.sources[k.key] === 'global' && !has ? `<span class="muted small">전체 값 ${esc(showVal(effective.values[k.key]))}</span>` : '';
|
||||
const tags = `${k.restart ? ' <span class="badge neutral">재시작 필요</span>' : ''}`; const id = `set-${esc(k.key.replace(/[^a-zA-Z0-9_-]/g, '_'))}`;
|
||||
let input;
|
||||
if (k.type === 'secret') { const on = !!secrets[k.key]; input = `<input id="${id}" type="password" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="secret" autocomplete="new-password" placeholder="새 값 입력(비우면 그대로)"> <span class="${on ? 'ok' : 'muted'}">${on ? '● 설정됨' : '○ 없음'}</span>${on ? ` <button type="button" class="btn small" data-act="settings-delete" data-key="${esc(k.key)}">삭제</button>` : ''}`; }
|
||||
else if (k.type === 'bool') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="bool"><option value="">(지정 안 함)</option><option value="true"${v === true ? ' selected' : ''}>켬</option><option value="false"${v === false ? ' selected' : ''}>끔</option></select>`;
|
||||
else if (k.type === 'enum') input = `<select id="${id}" name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="enum"><option value="">(지정 안 함)</option>${(k.options || []).map((o) => `<option value="${esc(o)}"${v === o ? ' selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
|
||||
else input = `<input id="${id}" type="${['int', 'number'].includes(k.type) ? 'number' : 'text'}"${k.type === 'number' ? ' step="any"' : ''}${k.min !== undefined ? ` min="${k.min}"` : ''}${k.max !== undefined ? ` max="${k.max}"` : ''} name="${esc(k.key)}" data-key="${esc(k.key)}" data-type="${esc(k.type)}" value="${has ? esc(showVal(v)) : ''}"${k.hint ? ` placeholder="${esc(k.hint)}"` : ''}>`;
|
||||
return `<div class="set-field"><label for="${id}">${esc(k.label)} <span class="mono muted small">${esc(k.key)}</span>${tags}</label><div>${input} ${inh}</div></div>`;
|
||||
};
|
||||
const groups = (cat.groups || []).map((g) => { const keys = g.keys.filter((k) => isServer || k.scope !== 'server'); if (!keys.length) return ''; return `<fieldset class="set-group"><legend>${esc(g.label)}</legend>${keys.map(field).join('')}</fieldset>`; }).join('');
|
||||
return `<form id="settings-form" class="set-form" data-module="${esc(cat.module)}" data-scope="${esc(scope)}" autocomplete="off">${groups}<div class="actions"><button type="button" class="btn primary" data-act="settings-save">저장(${isServer ? '이 서버 덮어쓰기' : '전체'})</button> <span class="muted small">비운 칸은 지정을 지웁니다(${isServer ? '전체 값을 따름' : '모듈 기본값을 따름'}). 비밀값은 새 값을 넣을 때만 바뀝니다. 저장만으로는 서버에 반영되지 않습니다 — 아래 "서버에 적용"을 누르세요.</span></div></form>`;
|
||||
}
|
||||
/** 서버별 적용 상태 표 */
|
||||
export function settingsStateHtml(rows, { module = '' } = {}) {
|
||||
const st = (r) => r.status === 'failed' ? badge('failed', '적용 실패') : r.status === 'ok' && r.upToDate ? badge('ok', '적용됨') : r.status === 'ok' ? badge('warn', '적용 필요(설정 변경됨)') : badge('warn', '적용 필요');
|
||||
return `${table([{ l: '서버', f: (r) => `<a href="/ops/settings?module=${esc(module)}&scope=server:${esc(r.serverId)}"><b>${esc(r.serverName || r.serverId)}</b></a><br><span class="muted small">${esc(r.serverStatus || '')}</span>` }, { l: '상태', f: st }, { l: '적용 시각', f: (r) => fmt(r.appliedAt) }, { l: '적용된 설정', f: (r) => `<span class="mono small">${esc(String(r.appliedHash || '—').slice(0, 19))}</span>` }, { l: '오류', f: (r) => r.error ? `<span class="err small">${esc(r.error)}</span>` : '—' }, { l: '', f: (r) => `<button class="btn small" data-act="settings-apply" data-m="${esc(module)}" data-s="${esc(r.serverId)}">이 서버에 적용</button>` }], rows || [], { empty: '이 모듈이 배치된 서버가 없습니다 — 배포 릴리스에서 먼저 배포하세요' })}<div class="actions"><button class="btn primary" data-act="settings-apply" data-m="${esc(module)}">서버에 적용(선택)</button></div>`;
|
||||
}
|
||||
/** 통합 설정 관제 화면 본문 */
|
||||
export function settingsPageHtml({ modules = [], catalog = {}, module, scope = 'global', placements = [], stored = {}, effective = null, overview = {} }) {
|
||||
const cat = catalog[module] || { groups: [] }; const ov = overview[module] || { servers: [] };
|
||||
const tabs = `<div class="tabs">${modules.map((m) => `<a href="/ops/settings?module=${esc(m)}" class="${m === module ? 'on' : ''}">${esc((catalog[m] || {}).label || m)}${overview[m] && overview[m].stale ? ` <span class="badge warn">${overview[m].stale}</span>` : ''}</a>`).join('')}</div>`;
|
||||
const scopeSel = `<div class="row"><label>범위 <select id="settings-scope" data-module="${esc(module)}"><option value="global"${scope === 'global' ? ' selected' : ''}>전체(모든 서버 기본값)</option>${placements.map((p) => `<option value="server:${esc(p.id)}"${scope === 'server:' + p.id ? ' selected' : ''}>서버 ${esc(p.name)}(덮어쓰기)</option>`).join('')}</select></label></div>`;
|
||||
return `${tabs}<p class="muted">${esc(cat.description || '')}</p>${scopeSel}${settingsFormHtml(cat, { scope, values: stored.values || {}, secrets: stored.secrets || {}, effective })}<h2>서버별 적용 상태</h2><div id="settings-state">${settingsStateHtml(ov.servers, { module })}</div>`;
|
||||
}
|
||||
export function incidentsView(incidents, notifications = []) {
|
||||
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
|
||||
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
|
||||
@@ -154,7 +187,7 @@ export function overviewView(o) {
|
||||
const comp = (c) => `<div class="card comp"><b>${esc(COMP_LABEL[c.name] || c.name)}</b> <span class="muted small">${COMP_LABEL[c.name] && COMP_LABEL[c.name] !== c.name ? esc(c.name) : ''}</span> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
|
||||
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
|
||||
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음'], o.settings ? ['설정 적용 필요', o.settings.stale ? `<a class="err" href="/ops/settings">${o.settings.stale}건</a>` : '없음'] : null])}<a class="btn" href="/ops/servers">서버와 용량</a> <a class="btn" href="/ops/settings">통합 설정</a></div>
|
||||
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
|
||||
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
|
||||
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
|
||||
@@ -198,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,13 +1,14 @@
|
||||
{
|
||||
"name": "arimail",
|
||||
"version": "1.0.11",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(server/hal-managed.mjs)" },
|
||||
"description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server/index.mjs",
|
||||
"migrate": "node server/db-migrate.mjs",
|
||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs"
|
||||
"test": "node test/gateway.test.mjs && node test/imap.test.mjs && node test/smtp.test.mjs && node test/config.test.mjs && node test/provision.test.mjs && node test/domains.test.mjs && node test/webmaker.test.mjs && node test/usage.test.mjs && node test/ariweb.test.mjs && node test/hal-managed.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 1.1.0 (Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정 관제.
|
||||
*
|
||||
* Ariweb HAL 이 이 메일 서버의 "서버 단위 설정"(수·발신 방식, Resend, DNS 제공자, SMTP 중계, 내장 Haraka, 표시·동기화, 사용량 API, Ariweb SSO)을 관리합니다.
|
||||
* Node Agent 가 HAL 에서 받은 값을 기본 도메인 설정 파일 옆 hal-managed.json(0600, { hash, values })으로 쓰면, 기동 때와 파일이 바뀔 때
|
||||
* saveConfig(…, { allowHidden: true })로 기본 도메인(서버) 설정에 적용하고 hal-managed.applied.json 에 결과(hash·시각·항목·오류 — 비밀값 없음)를 남깁니다.
|
||||
* 도메인별 관리(추가 도메인의 설정·제한 LIMIT_*·계정·메일함)는 HAL 이 다루지 않습니다 — 메일 서버 사용자(도메인 관리자)가 직접 관리합니다.
|
||||
* HAL 이 관리하는 항목은 설정 페이지(/setup, 기본 도메인)에서 값을 바꾸면 409 managed_by_hal 로 거부됩니다. 파일이 없으면 아무것도 하지 않습니다.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, renameSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { configDir, saveConfig, CONFIG_SCHEMA } from './config.mjs';
|
||||
|
||||
export const HAL_COMPAT = '0.4.0';
|
||||
const BY_KEY = new Map(CONFIG_SCHEMA.map((i) => [i.key, i]));
|
||||
export const managedFile = (env) => join(configDir(env), 'hal-managed.json');
|
||||
export const appliedFile = (env) => join(configDir(env), 'hal-managed.applied.json');
|
||||
/** 이 env 가 서버(기본 도메인)인지 — 추가 도메인 env 는 HAL 관리 대상이 아닙니다. */
|
||||
export const isServerEnv = (env) => !env.TENANTS || env.TENANTS.primary.env === env;
|
||||
|
||||
const cache = new WeakMap();
|
||||
export function readManaged(env) {
|
||||
const f = managedFile(env); let st; try { st = statSync(f); } catch { return null; }
|
||||
const c = cache.get(env); if (c && c.mtimeMs === st.mtimeMs) return c.doc;
|
||||
try { const doc = JSON.parse(readFileSync(f, 'utf8')); if (!doc || typeof doc.values !== 'object' || !doc.hash) throw new Error('형식'); cache.set(env, { mtimeMs: st.mtimeMs, doc }); return doc; }
|
||||
catch (e) { console.error(`[hal-managed] ${f} 을(를) 읽지 못했습니다: ${e.message}`); return null; }
|
||||
}
|
||||
const readApplied = (env) => { try { return JSON.parse(readFileSync(appliedFile(env), 'utf8')); } catch { return null; } };
|
||||
export function managedKeys(env) { if (!isServerEnv(env)) return []; const d = readManaged(env); return d ? Object.keys(d.values) : []; }
|
||||
/**
|
||||
* 설정 저장 요청(values)에서 HAL 관리 항목의 값을 바꾸려는 키. 화면은 폼의 모든 칸을 보내므로 지금 값과 같은 칸은 변경이 아닙니다.
|
||||
* 비밀 칸은 입력했거나('...') 비우기('')를 누른 경우만 오므로 그대로 변경입니다.
|
||||
*/
|
||||
export function lockedKeys(env, values) {
|
||||
const keys = managedKeys(env); if (!keys.length || !values || typeof values !== 'object') return [];
|
||||
return Object.keys(values).filter((k) => keys.includes(k)).filter((k) => { const item = BY_KEY.get(k); if (item && item.type === 'secret') return true; const cur = env.CONFIG && env.CONFIG[k] !== undefined && env.CONFIG[k] !== null ? env.CONFIG[k] : ''; return String(values[k]) !== String(cur); });
|
||||
}
|
||||
/** hal-managed.json 을 적용합니다. 같은 hash 를 오류 없이 이미 적용했으면 건너뜁니다(force 면 다시). 항목마다 따로 저장해 한 항목의 오류가 나머지를 막지 않습니다. */
|
||||
export function applyManaged(env, { force = false, log = console.log } = {}) {
|
||||
if (!isServerEnv(env)) return { skipped: 'not_server_env' };
|
||||
const doc = readManaged(env); if (!doc) return { skipped: 'no_file' };
|
||||
const prev = readApplied(env); if (!force && prev && prev.hash === doc.hash && !(prev.errors || []).length) return { skipped: 'same_hash', hash: doc.hash };
|
||||
const keys = []; const errors = [];
|
||||
for (const [key, value] of Object.entries(doc.values)) {
|
||||
const item = BY_KEY.get(key);
|
||||
if (!item) { errors.push({ key, error: '이 AriMail 버전이 모르는 항목입니다' }); continue; }
|
||||
if (/^LIMIT_/.test(key) || item.group === 'setup') { errors.push({ key, error: '도메인별·설치 항목은 HAL 이 관리하지 않습니다' }); continue; }
|
||||
try { saveConfig(env, { [key]: value }, { allowHidden: true }); keys.push(key); } catch (e) { errors.push({ key, error: e.message === 'validation' ? '값 형식이 올바르지 않습니다' : String(e.message || e).slice(0, 200) }); }
|
||||
}
|
||||
let version = null; try { version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { /* 버전 표시만 */ }
|
||||
const out = { format: 1, hash: doc.hash, appliedAt: new Date().toISOString(), keys, errors, halCompat: HAL_COMPAT, version };
|
||||
try { const tmp = `${appliedFile(env)}.tmp-${process.pid}`; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o644 }); renameSync(tmp, appliedFile(env)); } catch (e) { console.error(`[hal-managed] 적용 결과를 쓰지 못했습니다: ${e.message}`); }
|
||||
log(`[hal-managed] HAL 통합 설정 적용: ${keys.length}개 항목${errors.length ? `, 오류 ${errors.length}개(${errors.map((x) => x.key).join(', ')})` : ''}`);
|
||||
return out;
|
||||
}
|
||||
/** 화면용(비밀값 없음) */
|
||||
export function publicManaged(env) { const d = isServerEnv(env) ? readManaged(env) : null; const a = d ? readApplied(env) : null; return { managed: !!d, halCompat: HAL_COMPAT, keys: d ? Object.keys(d.values) : [], hash: d ? d.hash : null, appliedHash: a ? a.hash : null, appliedAt: a ? a.appliedAt : null, errors: a ? a.errors || [] : [] }; }
|
||||
/** 기동 때 적용하고, 그 뒤 파일이 바뀌면(Agent 가 새로 씀) 다시 적용합니다. */
|
||||
export function startHalManaged(env, { intervalMs = 10000 } = {}) {
|
||||
try { applyManaged(env); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); }
|
||||
const t = setInterval(() => { try { const d = readManaged(env); const a = readApplied(env); if (d && (!a || a.hash !== d.hash)) applyManaged(env); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); } }, intervalMs);
|
||||
if (t.unref) t.unref(); return t;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { systemLimits } from './limits.mjs';
|
||||
import { recoverQueue } from './sendqueue.mjs';
|
||||
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
||||
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
||||
import { startHalManaged } from './hal-managed.mjs';
|
||||
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
||||
import { autoVerifyDomain } from './providers/resend.mjs';
|
||||
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
|
||||
@@ -45,7 +46,7 @@ const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite')
|
||||
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
|
||||
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
|
||||
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
|
||||
loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
|
||||
loadConfig(env); startHalManaged(env); /* 1.1.0 (HAL 0.4.0 대응): HAL 통합 설정(hal-managed.json) 적용·변경 감시 */ console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
|
||||
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
|
||||
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
|
||||
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
|
||||
|
||||
@@ -29,6 +29,7 @@ import { parseAllowList, invalidAllowEntries } from './usage.mjs';
|
||||
import { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs';
|
||||
import { buildPlan, runPlan } from './provision.mjs';
|
||||
import { probeDns } from './dns/index.mjs';
|
||||
import { lockedKeys, managedKeys, publicManaged } from './hal-managed.mjs'; // 1.1.0 (HAL 0.4.0 대응) HAL 통합 설정 관제
|
||||
import { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
|
||||
import { noServicePage, normalizeHost } from './domains.mjs';
|
||||
|
||||
@@ -67,12 +68,14 @@ async function meta(env) {
|
||||
*/
|
||||
export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) {
|
||||
const method = request.method, action = path[0] || '';
|
||||
const respond = async () => json({ ok: true, ...publicConfig(env), meta: await meta(env) });
|
||||
const respond = async () => json({ ok: true, ...publicConfig(env), halManaged: publicManaged(env), meta: await meta(env) });
|
||||
// 도메인 등록부(1.0.7): 전용 포트(토큰)에서만. 관리자 세션(/setup)은 자기 도메인만 다룹니다.
|
||||
if (action === 'domains') { if (!tenants) return bad('not_found', 404); return await domainsApi(tenants, request, path.slice(1), ip); }
|
||||
if (action === 'config' && method === 'GET') return respond();
|
||||
if (action === 'config' && method === 'PUT') {
|
||||
const body = await request.json().catch(() => null); if (!body || typeof body !== 'object' || typeof body.values !== 'object') return bad('bad_request');
|
||||
// 1.1.0: HAL 이 관리하는 서버 설정은 여기서 바꿀 수 없습니다(HAL 운영자 콘솔 › 통합 설정). 추가 도메인의 설정은 잠그지 않습니다.
|
||||
{ const locked = lockedKeys(env, body.values); if (locked.length) return bad('managed_by_hal', 409, { keys: locked, message: `HAL 이 관리하는 항목입니다: ${locked.join(', ')} — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요` }); }
|
||||
// 사용량 API 허용 IP(1.0.9): 잘못된 항목이 하나라도 있으면 저장하지 않습니다(오타 하나로 모든 호출이 403 이 되는 일 방지).
|
||||
if (body.values.USAGE_API_ALLOW !== undefined) { const badEntries = invalidAllowEntries(body.values.USAGE_API_ALLOW); if (badEntries.length) return bad('validation', 422, { field: 'USAGE_API_ALLOW', detail: badEntries.join(', ') }); }
|
||||
try { saveConfig(env, body.values); } catch (e) { if (e.message === 'validation') return bad('validation', 422, { field: e.field }); throw e; }
|
||||
@@ -97,6 +100,7 @@ export async function setupApi(env, request, path, { ip = '', user = null, tenan
|
||||
if (action === 'usage-key' && method === 'POST') {
|
||||
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
|
||||
if (primary !== env) return bad('primary_only', 409);
|
||||
if (managedKeys(env).includes('USAGE_API_KEY')) return bad('managed_by_hal', 409, { keys: ['USAGE_API_KEY'], message: '사용량 API 키는 HAL 이 관리합니다 — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요' }); // 1.1.0
|
||||
const body = await request.json().catch(() => ({}));
|
||||
if (body.action === 'revoke') { saveConfig(env, { USAGE_API_KEY: '' }, { allowHidden: true }); console.log(`사용량 API 키 삭제(${user ? user.login : 'setup-token'} ${ip})`); return json({ ok: true, enabled: false }); }
|
||||
if (body.action !== 'generate') return bad('bad_request');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 1.1.0 시험(Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정(server/hal-managed.mjs) — 적용·결과 파일(비밀값 없음)·도메인별 항목 제외·설정 페이지 잠금.
|
||||
* 실행: node test/hal-managed.test.mjs (npm test 가 함께 돌립니다). 비밀값 시험 값은 실행할 때마다 만듭니다(고정 문자열 없음).
|
||||
*/
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { makeEnv, runner, eq, ok, fakeResend } from './_helpers.mjs';
|
||||
import { loadConfig } from '../server/config.mjs';
|
||||
import { applyManaged, lockedKeys, managedKeys, publicManaged, managedFile, appliedFile, HAL_COMPAT } from '../server/hal-managed.mjs';
|
||||
import { setupApi } from '../server/setup.mjs';
|
||||
|
||||
const { t, done } = runner();
|
||||
const rnd = (p) => `${p}-${randomBytes(12).toString('hex')}`;
|
||||
const tmp = () => mkdtempSync(join(tmpdir(), 'arimail-halm-'));
|
||||
const writeManaged = (env, values) => { const hash = 'sha256:' + createHash('sha256').update(JSON.stringify(values)).digest('hex'); writeFileSync(managedFile(env), JSON.stringify({ format: 1, module: 'arimail', hash, values }), { mode: 0o600 }); return hash; };
|
||||
const setup = (env, path, method = 'GET', body) => setupApi(env, new Request('https://mail.arihakdang.com/api/setup/' + path, { method, headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) }), path.split('/'), { ip: '127.0.0.1' });
|
||||
async function freshEnv() { const dir = tmp(); const env = await makeEnv({ CONFIG_FILE: join(dir, 'config.json'), RESEND_FETCH: fakeResend() }); /* 외부 호출 없음 */ loadConfig(env); return env; }
|
||||
|
||||
await t('파일이 없으면 아무것도 하지 않는다', async () => {
|
||||
const env = await freshEnv(); eq(applyManaged(env).skipped, 'no_file', '무동작'); eq(managedKeys(env).length, 0, '관리 항목 없음'); eq(publicManaged(env).managed, false, '표시'); eq(HAL_COMPAT, '0.4.0', 'HAL 대응 버전');
|
||||
});
|
||||
await t('서버 설정에 적용(hidden 포함)·결과 파일 비밀값 없음·도메인별(LIMIT_*)·모르는 항목은 오류로 기록', async () => {
|
||||
const env = await freshEnv(); const rk = rnd('re'); const uk = rnd('au');
|
||||
const hash = writeManaged(env, { MAIL_FROM_NAME: '아리메일', SMTP_RELAY_HOST: 'smtp.example.test', SMTP_RELAY_PORT: 2525, RESEND_API_KEY: rk, USAGE_API_KEY: uk, USAGE_API_ALLOW: '10.0.0.0/8', LIMIT_ACCOUNTS: 5, NOPE_KEY: 1 });
|
||||
const r = applyManaged(env); eq(r.hash, hash, 'hash'); eq(r.errors.map((x) => x.key).sort().join(','), 'LIMIT_ACCOUNTS,NOPE_KEY', '제외 항목');
|
||||
eq(env.CONFIG.MAIL_FROM_NAME, '아리메일', '설정 파일'); eq(env.MAIL_FROM_NAME, '아리메일', 'env 즉시 반영'); eq(env.RESEND_API_KEY, rk, '비밀값 적용'); eq(env.USAGE_API_KEY, uk, 'hidden 항목 적용'); eq(Number(env.CONFIG.SMTP_RELAY_PORT), 2525, '숫자');
|
||||
ok(env.CONFIG.LIMIT_ACCOUNTS === undefined, '도메인별 제한은 건드리지 않음');
|
||||
const a = readFileSync(appliedFile(env), 'utf8'); ok(!a.includes(rk) && !a.includes(uk), '결과 파일에 비밀값 없음'); eq(JSON.parse(a).halCompat, '0.4.0', '결과 파일 HAL 대응');
|
||||
const cfg = readFileSync(join(env.CONFIG_FILE), 'utf8'); ok(cfg.includes('아리메일'), '설정 파일 저장'); eq(statSync(env.CONFIG_FILE).mode & 0o777, 0o600, '설정 파일 0600');
|
||||
writeManaged(env, { MAIL_FROM_NAME: '아리' }); eq(applyManaged(env).errors.length, 0, '두 번째 적용'); eq(applyManaged(env).skipped, 'same_hash', '같은 hash 생략');
|
||||
const pub = publicManaged(env); eq(pub.keys.join(','), 'MAIL_FROM_NAME', '관리 키'); ok(!JSON.stringify(pub).includes(rk), '표시에 비밀 없음');
|
||||
});
|
||||
await t('설정 페이지 잠금: 관리 항목의 값 변경은 409 managed_by_hal, 같은 값(폼 전체 전송)·관리하지 않는 항목은 저장, 사용량 키 생성도 잠김', async () => {
|
||||
const env = await freshEnv(); writeManaged(env, { MAIL_FROM_NAME: '아리', SMTP_RELAY_PASS: rnd('pw'), USAGE_API_KEY: rnd('au') }); applyManaged(env);
|
||||
eq(lockedKeys(env, { MAIL_FROM_NAME: '아리', SITE_TITLE: 'x' }).length, 0, '같은 값은 변경 아님'); eq(lockedKeys(env, { MAIL_FROM_NAME: '다른' }).join(','), 'MAIL_FROM_NAME', '변경'); eq(lockedKeys(env, { SMTP_RELAY_PASS: '' }).join(','), 'SMTP_RELAY_PASS', '비밀 비우기도 변경');
|
||||
let r = await setup(env, 'config', 'PUT', { values: { MAIL_FROM_NAME: '다른' } }); eq(r.status, 409, '잠금'); let j = await r.json(); eq(j.error, 'managed_by_hal', '코드'); eq(j.keys.join(','), 'MAIL_FROM_NAME', '키');
|
||||
r = await setup(env, 'config', 'PUT', { values: { MAIL_FROM_NAME: '아리', SITE_TITLE: '메일' } }); eq(r.status, 200, '같은 값 + 관리하지 않는 항목'); j = await r.json(); eq(j.halManaged.managed, true, '응답에 halManaged'); eq(env.CONFIG.SITE_TITLE, '메일', '저장');
|
||||
r = await setup(env, 'usage-key', 'POST', { action: 'generate' }); eq(r.status, 409, '사용량 키 잠금');
|
||||
});
|
||||
done();
|
||||
@@ -148,6 +148,9 @@ function dnsCard() {
|
||||
|
||||
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
|
||||
const [label, help] = FIELD[key] || [key, ''];
|
||||
// 1.1.0 (HAL 0.4.0 대응): HAL 이 관리하는 항목은 잠그고 표시합니다(저장해도 서버가 409 로 거부). 잠긴 칸은 collect 가 건너뜁니다.
|
||||
const halKeys = (typeof D !== 'undefined' && D && D.halManaged && D.halManaged.keys) || [];
|
||||
if (halKeys.includes(key)) return `<label class="field"><span>${esc(label)} <span class="tag off">HAL 관리</span></span>${secret ? `<input type="password" disabled placeholder="${value ? '저장됨: ' + esc(value) : '(없음)'} — HAL 통합 설정에서 관리">` : `<input type="text" disabled value="${esc(value)}">`}<span class="help">HAL 운영자 콘솔 › 통합 설정에서 바꿉니다</span></label>`;
|
||||
const src = `<span class="src">${esc(SRC[source] || source)}</span>`;
|
||||
if (secret && multiline) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><textarea name="${key}" rows="4" autocomplete="off" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 붙여 넣기)' : '(없음) 붙여 넣으세요'}" data-secret="1"></textarea><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
||||
if (secret) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><input type="password" name="${key}" autocomplete="new-password" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 입력)' : '(없음) 입력하세요'}" data-secret="1"><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
|
||||
@@ -166,8 +169,9 @@ function render() {
|
||||
const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기');
|
||||
const choices = (name, current) => `<div class="choices">${Object.entries(MODES[name]).map(([k, [t, d]]) => `<label class="choice ${k === current ? 'selected' : ''}"><input type="radio" name="${name}" value="${k}" ${k === current ? 'checked' : ''}><span><span class="t">${esc(t)}</span><div class="d">${esc(d)}</div></span></label>`).join('')}</div>`;
|
||||
const missing = checklist(D);
|
||||
const hal = D.halManaged && D.halManaged.managed ? `<div class="card"><b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(D.halManaged.halCompat || '')})이 관리합니다: <code>${D.halManaged.keys.map(esc).join(', ')}</code>. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다. 도메인별 설정은 그대로 여기서 관리합니다.${D.halManaged.appliedAt ? `<div class="meta muted">마지막 적용 ${esc(D.halManaged.appliedAt)}${(D.halManaged.errors || []).length ? ` · 적용 오류: ${D.halManaged.errors.map((x) => esc(x.key)).join(', ')}` : ''}</div>` : ''}</div>` : ''; // 1.1.0
|
||||
$('#main').innerHTML = `
|
||||
${domainsCard()}
|
||||
${hal}${domainsCard()}
|
||||
<section class="card"><h2>현재 상태${MODE === 'token' ? ` — <code>${esc(m.domain)}</code>${m.primary ? ' <span class="tag">기본 도메인</span>' : ''}` : ''}</h2><div class="summary">
|
||||
<div class="item"><div class="k">수신</div><div class="v">${esc(MODES.inbound[m.inbound][0])}</div></div>
|
||||
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][0])}</div></div>
|
||||
@@ -222,6 +226,7 @@ function checklist(d) {
|
||||
function collect(form) {
|
||||
const patch = {};
|
||||
for (const el of form.elements) {
|
||||
if (el.disabled) continue; // 1.1.0: HAL 관리 칸
|
||||
if (!el.name) continue;
|
||||
if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; }
|
||||
if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# AI Webmaker (WebHoting AI Agent) — v1.8.0
|
||||
# AI Webmaker (WebHoting AI Agent) — v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||
|
||||
AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트.
|
||||
|
||||
## 변경 이력
|
||||
전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다.
|
||||
- **1.9.0** **HAL 0.4.0 대응 — 통합 설정 관제** — Ariweb HAL 이 이 서버의 관리 콘솔 중 서버 단위 설정(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정)을 관리한다. Node Agent 가 쓴 `DATA_ROOT/hal-managed.json` 을 기동 때·변경 때 적용하고(`lib/hal-managed.js`), HAL 관리 항목은 관리자 콘솔·설정 페이지에서 잠긴다(409 `managed_by_hal`, 설정 탭 맨 위 "HAL 관리" 안내). 파일이 없으면 예전처럼 단독 운영.
|
||||
- **1.8.0** **직접 편집(시각 편집기)** — 초안이 만들어져 미리보기가 보이면 오른쪽 도구 줄의 "직접 편집" 으로 글을 바로 고치고, 블록을 끌어 옮기고, 구성 요소(섹션·제목·문단·버튼·이미지·구분선·여백)를 팔레트에서 끌어다 놓는다(버튼·키보드 대안 포함). 속성 창(글·링크·이미지·정렬·색·배경·여백), 실행 취소/다시 실행, 저장 상태 표시, Ctrl+S 저장, 페이지·사이트 전환·창 닫기 전 저장하지 않은 변경 보호. 저장은 선택한 HTML 파일을 갱신하고 버전 `visual-edit` 로 남기며 AI 캐시를 쓰지 않는다. 캔버스는 sandbox(출처 null)+CSP nonce 로 페이지 스크립트를 실행하지 않고, 서버는 원본에 없던 스크립트·이벤트 속성·위험 주소·iframe 등을 거절하며 머리·꼬리는 글자 그대로 보존한다(`lib/visual-editor.js`, `public/visual-editor-core.js`, `public/wm-ve-canvas.js`, `public/visual-editor-ui.js`). 독립 검토 차단 항목 12건 수정(캔버스 MessagePort 연결 규약, 이름 엔티티 주소 정규화, 구조 스캐너 기반 CSP 삽입, 완성 문서 전체 비교, 사이트별 변경 잠금, 저장 트랜잭션·되돌리기, 전환 보호, 복제 id, 끌기 정리 — CHANGELOG §8) + 종결 검토 3건(SVG SMIL 애니메이션 요소 거절, Git 저장 트랜잭션 재설계 — 로컬 커밋·기록 뒤 main + 변경 브랜치 원자적 푸시·검증된 되돌리기, Suite 릴리스 승인 규칙 — CHANGELOG §9) + 최종 검토 5건(Suite 승인 불변, 사이트 변경 요청의 잠금 안 재적재, 트랜잭션 준비 단계·모호한 푸시 판정, 문서, 배경 작업 저장이 짧은 변경을 덮어쓰지 않는 잎 단위 저장소 합치기와 같은 잎 동시 변경의 fail-closed 거절 — CHANGELOG §10) + 4차 검토 4건(Suite artifact 설치 불변, 읽기 요청 부작용 제거, 폐기 잠금·표식 규약, svg/math 안 title 파서 차이 — CHANGELOG §11). 테스트 115개(원본 52 + 직접 편집 63). 실제 브라우저 게이트(Playwright, Chrome) 61/61.
|
||||
- **1.7.3** 도메인 연결 오류 수정(배포 서버에 `log_format wmstats` 가 이미 있으면 중복 정의하지 않고, 중복으로 nginx -t 가 실패하면 우리 파일을 지워 자가 복구 · nginx 1.19.4 미만은 ssl_reject_handshake 대신 자체 서명 인증서 기본 서버) + **메일 사용량 연동**(AriMail 1.0.9 사용량 API: 관리자 콘솔에서 키 발급·보관(보관소 `arimail-usage`), 도메인별 메일함 용량·메일 수·발송/수신 표, 사용자 메일 화면 사용량 행, 사이트 정보 "메일 데이터" = 서버 메일함 용량 + 메일 사용량 구역(30일 막대)).
|
||||
- **1.7.2** 도메인 관리 역할 분리 — 도메인 화면은 구매·갱신·**네임서버 이전 4단계 띠**, DNS 화면은 **사이트 ↔ 도메인 연결 표**(하위 이름 선택·연결 끊기)가 먼저이고 레코드 관리는 그 아래, 메일 화면은 계정 형식·접속 주소·초기 관리자를 한눈에; **사이트 정보 페이지**를 오른쪽 캔버스로 옮겨 방문자 막대·송수신 선 그래프·7일/월간(3개월) 막대·디스크(웹/DB/메일) 그래프를 SVG 로 표시. 두 서버 로그 0건. bear 배포 중단.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 통합 설정 관제.
|
||||
// HAL 이 이 서버의 관리 콘솔 중 "서버 단위 설정"(API 키·인증, 마진·가격 정책, 모델, 전체 적용 서비스, 서버별 설정)을 대신 관리한다.
|
||||
// Node Agent 가 HAL 에서 받은 값을 DATA_ROOT/hal-managed.json(0600, { hash, values })로 쓰면, 이 모듈이 기동 때와 파일이 바뀔 때
|
||||
// 기존 저장 경로(lib/settings.save · lib/config.set · 보관소)로 적용하고 DATA_ROOT/hal-managed.applied.json 에 결과(hash·시각·항목·오류 — 비밀값 없음)를 남긴다.
|
||||
// HAL 이 관리하는 항목은 관리자 콘솔·설정 페이지 API 에서 409 managed_by_hal 로 잠긴다(HAL 에서만 바꾼다). 파일이 없으면 아무것도 하지 않는다(단독 운영 그대로).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
|
||||
const FILE = path.join(DATA_ROOT, 'hal-managed.json');
|
||||
const APPLIED = path.join(DATA_ROOT, 'hal-managed.applied.json');
|
||||
const HAL_COMPAT = '0.4.0';
|
||||
|
||||
// lib/settings.js 로 저장하는 항목(점 = 중첩 객체). 나머지는 lib/config.js 의 설정 파일 항목(config.KEYS)이다.
|
||||
const SETTINGS_KEYS = new Set(['auth.mode', 'auth.apiKey', 'auth.oauthToken', 'serviceApiKey', 'backlogKey', 'mail.apiKey', 'marginPct', 'fx.mode', 'fx.manualRate', 'fx.spreadPct', 'signupCredit', 'maxEdits', 'defaultTier', 'tiers.basic', 'tiers.standard', 'tiers.premium', 'signupApproval', 'internalRef', 'defaultModel', 'claudeModels', 'letsEncryptEmail', 'mail.fromName', 'mail.defaultDomain', 'mail.formBase']);
|
||||
const NESTED = new Set(['auth', 'fx', 'tiers', 'mail']);
|
||||
// 보관소 항목 종류 ↔ HAL 관리 비밀값(관리 중이면 관리자 콘솔 › 연동 정보에서 그 종류를 바꿀 수 없다)
|
||||
const VAULT_KIND = { 'auth.apiKey': 'anthropic-api-key', 'auth.oauthToken': 'anthropic-oauth', 'mail.apiKey': 'resend', 'source.token': 'gitea', 'ariweb.secret': 'ariweb', 'mail.adminPassword': 'arimail' };
|
||||
const MASK = '••••';
|
||||
|
||||
let cache = { mtimeMs: -1, doc: null };
|
||||
function read() {
|
||||
let st; try { st = fs.statSync(FILE); } catch (_) { cache = { mtimeMs: -1, doc: null }; return null; }
|
||||
if (st.mtimeMs === cache.mtimeMs && cache.doc) return cache.doc;
|
||||
try { const doc = JSON.parse(fs.readFileSync(FILE, 'utf8')); if (!doc || typeof doc.values !== 'object' || !doc.hash) throw new Error('형식'); cache = { mtimeMs: st.mtimeMs, doc }; return doc; }
|
||||
catch (e) { console.error(`[hal-managed] ${FILE} 을(를) 읽지 못했습니다: ${e.message}`); return null; }
|
||||
}
|
||||
function readApplied() { try { return JSON.parse(fs.readFileSync(APPLIED, 'utf8')); } catch (_) { return null; } }
|
||||
/** HAL 이 관리하는 항목 이름 목록 */
|
||||
function managedKeys() { const d = read(); return d ? Object.keys(d.values) : []; }
|
||||
const isManaged = (key) => managedKeys().includes(key);
|
||||
/** 관리자 콘솔이 보낸 settings PUT 본문에서 HAL 관리 항목을 바꾸려는 키(마스크·빈 비밀값은 "바꾸지 않음"이라 제외) */
|
||||
function lockedKeys(body) {
|
||||
const keys = managedKeys(); if (!keys.length || !body || typeof body !== 'object') return [];
|
||||
const touched = [];
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (NESTED.has(k) && v && typeof v === 'object' && !Array.isArray(v)) { for (const [k2, v2] of Object.entries(v)) { if (v2 === MASK || (VAULT_KIND[`${k}.${k2}`] && (v2 === '' || v2 == null))) continue; touched.push(`${k}.${k2}`); } }
|
||||
else touched.push(k);
|
||||
}
|
||||
return touched.filter((k) => keys.includes(k));
|
||||
}
|
||||
/** 관리 중인 비밀값의 보관소 종류 */
|
||||
const lockedVaultKinds = () => managedKeys().map((k) => VAULT_KIND[k]).filter(Boolean);
|
||||
const managedError = (keys) => Object.assign(new Error(`HAL 이 관리하는 항목은 여기서 바꿀 수 없습니다: ${keys.join(', ')} — HAL 운영자 콘솔 › 통합 설정에서 바꾸세요`), { status: 409, code: 'managed_by_hal', keys });
|
||||
function assertNotManaged(keys) { if (keys && keys.length) throw managedError(keys); }
|
||||
|
||||
function setNested(o, key, v) { const [a, b] = key.split('.'); if (b && NESTED.has(a)) { o[a] = o[a] || {}; o[a][b] = v; } else o[key] = v; }
|
||||
/**
|
||||
* hal-managed.json 을 적용한다. 같은 hash 를 이미 적용했으면 건너뛴다(force 면 다시).
|
||||
* 항목마다 따로 저장해 한 항목의 오류가 나머지를 막지 않게 하고, 오류는 applied 파일에 키와 이유로 남긴다(값은 남기지 않는다).
|
||||
*/
|
||||
function apply({ force = false, log = console.log } = {}) {
|
||||
const doc = read(); if (!doc) return { skipped: 'no_file' };
|
||||
const prev = readApplied(); if (!force && prev && prev.hash === doc.hash && !(prev.errors || []).length) return { skipped: 'same_hash', hash: doc.hash };
|
||||
const settings = require('./settings'); const config = require('./config'); const errors = []; const appliedKeys = []; let restart = false;
|
||||
// 묶음 단위: 서로 기대는 항목(fx.mode=manual 은 fx.manualRate 가 있어야 함 등)은 auth·fx·tiers·mail 묶음 하나로 함께 저장하고, 나머지는 항목마다 따로 저장한다
|
||||
const units = new Map();
|
||||
for (const [key, value] of Object.entries(doc.values)) { const head = key.split('.')[0]; const id = SETTINGS_KEYS.has(key) && NESTED.has(head) && key.includes('.') ? `settings:${head}` : `key:${key}`; if (!units.has(id)) units.set(id, []); units.get(id).push([key, value]); }
|
||||
for (const [id, entries] of units) {
|
||||
const keys = entries.map(([k]) => k);
|
||||
try {
|
||||
if (id.startsWith('settings:') || SETTINGS_KEYS.has(keys[0])) { const patch = {}; for (const [k, v] of entries) setNested(patch, k, v); settings.save(patch); }
|
||||
else if (config.KEYS && config.KEYS[keys[0]]) { const v = entries[0][1]; const r = config.set({ [keys[0]]: Array.isArray(v) ? v.join(',') : v }, 'hal'); if (r.restart) restart = true; }
|
||||
else throw new Error('이 Webmaker 버전이 모르는 항목입니다');
|
||||
appliedKeys.push(...keys);
|
||||
} catch (e) { for (const k of keys) errors.push({ key: k, error: String(e.message || e).slice(0, 200) }); }
|
||||
}
|
||||
try { require('./setupstate').invalidate(); } catch (_) {}
|
||||
const out = { format: 1, hash: doc.hash, appliedAt: new Date().toISOString(), keys: appliedKeys, errors, restartNeeded: restart, halCompat: HAL_COMPAT, version: require('../package.json').version };
|
||||
try { const tmp = `${APPLIED}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o644 }); fs.renameSync(tmp, APPLIED); } catch (e) { console.error(`[hal-managed] 적용 결과를 쓰지 못했습니다: ${e.message}`); }
|
||||
try { require('./audit').log({ by: 'hal', action: 'hal.settings.apply', targetType: 'settings', target: 'hal-managed', ok: !errors.length, detail: { hash: doc.hash, keys: appliedKeys, errors: errors.map((x) => x.key) } }); } catch (_) {}
|
||||
log(`[hal-managed] HAL 통합 설정 적용: ${appliedKeys.length}개 항목${errors.length ? `, 오류 ${errors.length}개(${errors.map((x) => x.key).join(', ')})` : ''}${restart ? ' — 재시작이 필요한 항목이 있습니다' : ''}`);
|
||||
return out;
|
||||
}
|
||||
/** 화면용 정보(비밀값 없음) */
|
||||
function publicInfo() { const d = read(); const a = readApplied(); return { managed: !!d, halCompat: HAL_COMPAT, keys: d ? Object.keys(d.values) : [], hash: d ? d.hash : null, appliedHash: a ? a.hash : null, appliedAt: a ? a.appliedAt : null, errors: a ? a.errors || [] : [] }; }
|
||||
let timer = null;
|
||||
/** 기동 때 한 번 적용하고, 그 뒤 파일이 바뀌면(Agent 가 새로 씀) 다시 적용한다 */
|
||||
function start({ intervalMs = 10000 } = {}) {
|
||||
try { apply(); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); }
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(() => { try { const d = read(); const a = readApplied(); if (d && (!a || a.hash !== d.hash)) apply(); } catch (e) { console.error(`[hal-managed] 적용 실패: ${e.message}`); } }, intervalMs); if (timer.unref) timer.unref();
|
||||
return timer;
|
||||
}
|
||||
module.exports = { FILE, APPLIED, HAL_COMPAT, SETTINGS_KEYS, VAULT_KIND, read, managedKeys, isManaged, lockedKeys, lockedVaultKinds, assertNotManaged, managedError, apply, publicInfo, start };
|
||||
@@ -107,6 +107,8 @@ async function aiAction(kind, body, by) {
|
||||
function save(body, by) {
|
||||
const config = require('./config');
|
||||
const values = body && body.values && typeof body.values === 'object' ? body.values : body;
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 관리하는 설정 파일 항목은 설정 페이지·관리자 콘솔에서 바꿀 수 없다(409 managed_by_hal). HAL 의 적용(by 'hal')은 lib/hal-managed 가 config.set 을 직접 부른다
|
||||
{ const hm = require('./hal-managed'); const mk = hm.managedKeys(); hm.assertNotManaged(Object.keys(values || {}).filter((k) => mk.includes(k))); }
|
||||
const r = config.set(values, by); try { require('./setupstate').invalidate(); } catch (_) {} // v1.5.11 점검표(공개 주소 등) 다시 계산
|
||||
return { ok: true, changed: r.changed, restart: r.restart, note: r.changed.length ? (r.restart ? `저장했습니다 (${r.changed.length}개). 재시작해야 적용되는 항목이 있습니다 — "서비스 재시작" 을 누르세요.` : `저장했습니다 (${r.changed.length}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "webhosting-ai-agent",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(lib/hal-managed.js)" },
|
||||
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -958,8 +958,17 @@ for (const [id, key, num] of [['thLeftWidth', 'leftWidth', 1], ['thFontSize', 'f
|
||||
$('thSave').onclick = async () => { try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: thCur }) }); $('thMsg').textContent = '저장했습니다. 사용자 화면을 새로고침하면 적용됩니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
||||
$('thReset').onclick = async () => { if (!confirm('화면 외형을 기본값으로 되돌릴까요?')) return; try { await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ theme: { reset: true } }) }); $('thMsg').textContent = '기본값으로 되돌렸습니다.'; loadTheme(); } catch (e) { $('thMsg').textContent = e.message; } };
|
||||
|
||||
/** v1.9.0: HAL 통합 설정이 관리하는 항목 안내(설정 탭 맨 위). 그 항목을 저장하면 서버가 409 로 거부한다 — HAL 에서 바꾼다 */
|
||||
function renderHalManaged(h) {
|
||||
const tab = document.getElementById('tab-settings'); if (!tab) return; let el = document.getElementById('halManagedNote');
|
||||
if (!h || !h.managed || !(h.keys || []).length) { if (el) el.remove(); return; }
|
||||
if (!el) { el = document.createElement('div'); el.id = 'halManagedNote'; el.className = 'msg'; el.style.cssText = 'border:1px solid #c9a227;background:#fff8e1;color:#5d4500;padding:10px 12px;border-radius:8px;margin:0 0 12px'; tab.prepend(el); }
|
||||
const esc = (t) => String(t).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
el.innerHTML = `<b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(h.halCompat || '')})이 관리합니다. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다.<br><span style="font-family:monospace;font-size:12px">${h.keys.map(esc).join(', ')}</span>${h.appliedAt ? `<br><small>마지막 적용 ${esc(h.appliedAt)}${(h.errors || []).length ? ` · 적용 오류: ${h.errors.map((x) => esc(x.key)).join(', ')}` : ''}</small>` : ''}`;
|
||||
}
|
||||
async function loadSettings() {
|
||||
settingsData = await api('/api/admin/settings'); const s = settingsData;
|
||||
renderHalManaged(s.halManaged); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||
loadVault();
|
||||
$('ver').textContent = s.version ? `v${s.version}` : '';
|
||||
renderTiers(s);
|
||||
|
||||
@@ -48,7 +48,8 @@ const modules = require('./lib/modules');
|
||||
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
|
||||
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
|
||||
const mailusage = require('./lib/mailusage'); // v1.7.3 메일 사용량(Arimail 1.0.9 사용량 API, 캐시)
|
||||
const vault = require('./lib/vault'); // v1.3.1 외부 연동 접속 정보 암호화 보관소
|
||||
const vault = require('./lib/vault');
|
||||
const halManaged = require('./lib/hal-managed'); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응) HAL 통합 설정 관제 — 관리 항목 적용·잠금 // v1.3.1 외부 연동 접속 정보 암호화 보관소
|
||||
const logview = require('./lib/logview'); // v1.3.2 관리자 로그 보기
|
||||
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
|
||||
const templates = require('./lib/templates'); // v1.3.10 사이트 템플릿
|
||||
@@ -2275,11 +2276,12 @@ async function handle(req, res) {
|
||||
if (req.method === 'GET' && arg === 'preview-page' && ['index', 'login', 'site-admin'].includes(sub2)) return send(res, 200, { html: fs.readFileSync(path.join(PUBLIC_DIR, `${sub2}.html`), 'utf8') });
|
||||
if (req.method === 'POST' && arg === 'theme-preview') { const { fields: b } = await readBody(req); return send(res, 200, { css: settings.themeCss(b.theme || null) }); }
|
||||
if (req.method === 'GET' && arg === 'theme') return send(res, 200, { theme: settings.getTheme(), saved: !!settings.load().theme, defaults: settings.THEME_DEFAULTS, presets: settings.THEME_PRESETS });
|
||||
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||
if (req.method === 'GET') return send(res, 200, { ...settings.publicView(), halManaged: halManaged.publicInfo() /* v1.9.0 */, availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||
if (req.method === 'PUT') {
|
||||
const { fields: b } = await readBody(req);
|
||||
// v1.4.5 첫 화면 페이지는 관리자가 만든 사이트만 (다른 회원 사이트를 모든 사용자에게 보이지 않게)
|
||||
if (b.welcomeSiteId) { const c = welcomeCandidates().find((x) => x.id === String(b.welcomeSiteId)); if (!c) return send(res, 400, { error: '첫 화면 페이지는 관리자 계정이 만든 사이트 중에서 고르세요' }); if (!c.published && !c.hasIndex) return send(res, 400, { error: '그 사이트에는 아직 첫 페이지(index.html)가 없습니다. 생성을 마친 뒤 고르세요' }); }
|
||||
halManaged.assertNotManaged(halManaged.lockedKeys(b)); // v1.9.0: HAL 이 관리하는 항목은 409 managed_by_hal
|
||||
settings.save(b); audit.log({ by: user.id, action: 'settings.update', targetType: 'settings', target: 'settings', detail: { keys: Object.keys(b || {}) } }); return send(res, 200, { ...settings.publicView(), availableModels: providers.availableModels(null), unavailableProviders: providers.unavailableProviders(), serverLogin: settings.serverLoginInfo(), claude: claudeInfo(), version: VERSION, welcomeCandidates: welcomeCandidates(), domainProviders: domainsLib.publicCatalog() /* v1.5.36 */ });
|
||||
}
|
||||
if (req.method === 'POST' && arg === 'test-connection') {
|
||||
@@ -2376,12 +2378,14 @@ async function handle(req, res) {
|
||||
if (what === 'credentials') {
|
||||
const kinds = Object.fromEntries(Object.entries(vault.KINDS).map(([k, v]) => [k, { label: v.label, fixedId: v.fixedId, defaultUrl: v.defaultUrl, secretLabel: v.secretLabel }]));
|
||||
if (req.method === 'GET' && !arg) return send(res, 200, { items: vault.list(), kinds });
|
||||
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
|
||||
// v1.9.0: HAL 이 관리하는 비밀값의 보관소 종류는 여기서 바꾸거나 지울 수 없다(HAL 통합 설정에서만)
|
||||
const vaultLocked = (kind) => { if (kind && halManaged.lockedVaultKinds().includes(String(kind))) throw halManaged.managedError(Object.entries(halManaged.VAULT_KIND).filter(([, v]) => v === String(kind)).map(([k]) => k)); };
|
||||
if (req.method === 'POST' && !arg) { const { fields: b } = await readBody(req); vaultLocked(b.kind); const r = vault.set(b, 'input'); audit.log({ by: user.id, action: 'vault.set', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label } }); return send(res, 200, r); }
|
||||
// v1.3.2: .key 파일 자체를 올려 가져오기 (multipart: kind, id?, label?, file) — 파일은 저장하지 않고 메모리에서 읽는다
|
||||
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg === 'upload') { const { fields: b, files } = await readBody(req); vaultLocked(b.kind); const f = files[0]; if (!f) return send(res, 400, { error: '키 파일을 고르세요' }); if (f.size > 64 * 1024) return send(res, 400, { error: '키 파일이 너무 큽니다 (64KB 이하)' }); const text = Buffer.from(await f.arrayBuffer()).toString('utf8'); const r = vault.importText(b.kind, text, { id: b.id, label: b.label }, `upload:${String(f.name || 'key').slice(0, 60)}`); audit.log({ by: user.id, action: 'vault.upload', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: String(f.name || '').slice(0, 60) } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg === 'import') { const { fields: b } = await readBody(req); vaultLocked(b.kind); const r = vault.importFile(b.kind, b.file, { id: b.id, label: b.label }); audit.log({ by: user.id, action: 'vault.import', targetType: 'vault', target: (r && r.id) || b.id || b.kind, detail: { kind: b.kind, label: b.label, file: b.file } }); return send(res, 200, r); }
|
||||
if (req.method === 'POST' && arg && sub2 === 'test') return send(res, 200, await vault.test(arg));
|
||||
if (req.method === 'DELETE' && arg) { vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
|
||||
if (req.method === 'DELETE' && arg) { const it0 = vault.list().find((x) => x.id === arg); vaultLocked(it0 && it0.kind); vault.remove(arg); audit.log({ by: user.id, action: 'vault.remove', targetType: 'vault', target: arg }); return send(res, 200, { ok: true }); }
|
||||
}
|
||||
// ---- 메일 시스템 (v1.3.0): 전체 켜기/끄기·메일 서버 주소, 웹메일을 켠 사이트 목록 ----
|
||||
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
|
||||
@@ -3219,10 +3223,12 @@ function staticFile(res, name, status = 200) {
|
||||
|
||||
http.createServer((req, res) => {
|
||||
handle(req, res).catch((err) => {
|
||||
if (!res.headersSent) send(res, err.status || 500, { error: err.message });
|
||||
if (!res.headersSent) send(res, err.status || 500, { error: err.message, ...(err.code === 'managed_by_hal' ? { code: err.code, keys: err.keys } : {}) }); // v1.9.0 HAL 관리 항목 잠금은 코드·키를 함께 알린다
|
||||
else res.end();
|
||||
});
|
||||
}).listen(PORT, HOST, () => {
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응): HAL 이 Agent 로 내려준 통합 설정(DATA_ROOT/hal-managed.json)을 적용하고, 파일이 바뀌면 다시 적용한다
|
||||
try { halManaged.start(); } catch (e) { console.error(`[hal-managed] 시작 실패: ${e.message}`); }
|
||||
console.log(`webhosting-agent listening on http://${HOST}:${PORT} sites=${SITES_ROOT} public=${PUBLIC_BASE_URL} model=${MODEL}`);
|
||||
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
|
||||
try { const n = require('./lib/jobpids').sweep(); if (n) console.log(`[jobs] 이전 실행이 남긴 AI 작업 프로세스 ${n}개를 종료했습니다`); } catch (_) {}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
// v1.9.0 (Ariweb Suite HAL 0.4.0 대응) 자동 검증 — HAL 통합 설정(lib/hal-managed.js): 적용·결과 파일·잠금(라이브러리 + 서버 API). 외부 서비스 없음.
|
||||
// 비밀값 시험 값은 실행할 때마다 crypto 로 만든다(고정 문자열을 저장소에 두지 않는다).
|
||||
const test = require('node:test'); const assert = require('node:assert/strict');
|
||||
const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const { spawn } = require('child_process');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-halm-')); process.env.DATA_ROOT = tmp; process.env.DATA_DIR = path.join(tmp, 'sites'); process.env.SITES_ROOT = path.join(tmp, 'www'); fs.mkdirSync(process.env.SITES_ROOT, { recursive: true });
|
||||
const rnd = (p) => `${p}-${crypto.randomBytes(12).toString('hex')}`;
|
||||
const writeManaged = (dir, values) => { const hash = 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(values)).digest('hex'); fs.writeFileSync(path.join(dir, 'hal-managed.json'), JSON.stringify({ format: 1, module: 'webmaker', hash, values }), { mode: 0o600 }); return hash; };
|
||||
|
||||
test('hal-managed: 파일이 없으면 아무것도 하지 않는다', () => {
|
||||
const hm = require(ROOT + '/lib/hal-managed'); assert.deepEqual(hm.apply(), { skipped: 'no_file' }); assert.deepEqual(hm.managedKeys(), []); assert.deepEqual(hm.lockedKeys({ marginPct: 1 }), []); assert.equal(hm.publicInfo().managed, false);
|
||||
});
|
||||
test('hal-managed: settings·설정 파일·보관소에 적용하고 결과 파일(비밀값 없음)을 남긴다 · 같은 hash 는 건너뜀 · 모르는 항목은 오류로 기록', () => {
|
||||
const hm = require(ROOT + '/lib/hal-managed'); const settings = require(ROOT + '/lib/settings'); const config = require(ROOT + '/lib/config'); const vault = require(ROOT + '/lib/vault');
|
||||
const key = rnd('sk-ant'); const svc = rnd('svc');
|
||||
const hash = writeManaged(tmp, { marginPct: 35, 'fx.mode': 'manual', 'fx.manualRate': 1400, signupCredit: 500, maxEdits: 7, 'auth.mode': 'api-key', 'auth.apiKey': key, serviceApiKey: svc, 'service.allowSignup': false, 'ai.maxConcurrentJobs': 3, 'nope.key': 1 });
|
||||
const r = hm.apply(); assert.equal(r.hash, hash); assert.deepEqual(r.errors.map((x) => x.key), ['nope.key']);
|
||||
const s = settings.load(); assert.equal(s.marginPct, 35); assert.equal(s.fx.mode, 'manual'); assert.equal(s.fx.manualRate, 1400); assert.equal(s.signupCredit, 500); assert.equal(s.maxEdits, 7); assert.equal(s.auth.mode, 'api-key'); assert.equal(s.serviceApiKey, svc);
|
||||
assert.equal(vault.secretOf('anthropic-api-key'), key, 'Claude 키는 보관소에'); assert.equal(config.get('service.allowSignup'), false); assert.equal(Number(config.get('ai.maxConcurrentJobs')), 3);
|
||||
const applied = fs.readFileSync(hm.APPLIED, 'utf8'); assert.ok(!applied.includes(key) && !applied.includes(svc), '결과 파일에 비밀값 없음'); const aj = JSON.parse(applied); assert.equal(aj.hash, hash); assert.equal(aj.halCompat, '0.4.0'); assert.ok(aj.keys.includes('auth.apiKey'));
|
||||
assert.equal(hm.apply().skipped, undefined, '오류가 남은 적용은 다음에 다시 시도'); writeManaged(tmp, { marginPct: 36 }); const r2 = hm.apply(); assert.deepEqual(r2.errors, []); assert.equal(settings.load().marginPct, 36); assert.equal(hm.apply().skipped, 'same_hash');
|
||||
const info = hm.publicInfo(); assert.equal(info.managed, true); assert.deepEqual(info.keys, ['marginPct']); assert.ok(!JSON.stringify(info).includes(key));
|
||||
});
|
||||
test('hal-managed: 잠금 — 관리 항목 변경은 409 managed_by_hal(마스크·빈 비밀값은 변경 아님), 설정 페이지 저장도 잠김', () => {
|
||||
const hm = require(ROOT + '/lib/hal-managed'); writeManaged(tmp, { marginPct: 30, 'auth.mode': 'api-key', 'auth.apiKey': rnd('k'), 'service.allowSignup': true });
|
||||
assert.deepEqual(hm.lockedKeys({ marginPct: 40, signupCredit: 1, auth: { mode: 'server-login', apiKey: '••••' } }).sort(), ['auth.mode', 'marginPct']); assert.deepEqual(hm.lockedKeys({ auth: { apiKey: '' } }), []); assert.deepEqual(hm.lockedKeys({ signupCredit: 1 }), []);
|
||||
assert.throws(() => hm.assertNotManaged(['marginPct']), (e) => e.status === 409 && e.code === 'managed_by_hal'); assert.deepEqual(hm.lockedVaultKinds(), ['anthropic-api-key']);
|
||||
assert.throws(() => require(ROOT + '/lib/setupapi').save({ values: { 'service.allowSignup': false } }, 'test'), (e) => e.status === 409 && e.code === 'managed_by_hal');
|
||||
});
|
||||
test('서버: 관리 항목 저장 409·설정 응답의 halManaged·기동 때 적용', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wm-halm-srv-')); fs.writeFileSync(path.join(dir, 'setup.token'), crypto.randomBytes(16).toString('hex') + '\n', { mode: 0o600 }); writeManaged(dir, { marginPct: 33 });
|
||||
const PORT = 4000 + Math.floor(Math.random() * 90); const B = `http://127.0.0.1:${PORT}`; const jar = {};
|
||||
const api = async (p, init = {}, who = 'a') => { const headers = { 'Content-Type': 'application/json', referer: `${B}/` }; if (jar[who]) headers.cookie = jar[who]; const r = await fetch(B + p, { ...init, headers, redirect: 'manual' }); const sc = r.headers.get('set-cookie'); if (sc) jar[who] = sc.split(';')[0]; let b = {}; try { b = await r.json(); } catch (_) {} return { status: r.status, b }; };
|
||||
const child = spawn('node', [path.join(ROOT, 'server.js')], { env: { ...process.env, PORT: String(PORT), HOST: '127.0.0.1', DATA_ROOT: dir, DATA_DIR: path.join(dir, 'sites'), SITES_ROOT: path.join(dir, 'www'), PUBLIC_BASE_URL: B, WEBMAKER_SETUP_PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; child.stdout.on('data', (c) => { out += c; }); child.stderr.on('data', (c) => { out += c; });
|
||||
try {
|
||||
for (let i = 0; i < 100 && !/listening on/.test(out); i++) await new Promise((r) => setTimeout(r, 100)); assert.match(out, /listening on/);
|
||||
for (let i = 0; i < 50 && !fs.existsSync(path.join(dir, 'hal-managed.applied.json')); i++) await new Promise((r) => setTimeout(r, 100)); assert.ok(fs.existsSync(path.join(dir, 'hal-managed.applied.json')), '기동 때 적용');
|
||||
const tok = fs.readFileSync(path.join(dir, 'setup.token'), 'utf8').trim(); const pw = rnd('Pw1!'); assert.equal((await api('/api/setup/login', { method: 'POST', body: JSON.stringify({ token: tok }) }, 's')).status, 200);
|
||||
assert.ok((await api('/api/setup/admin/create', { method: 'POST', body: JSON.stringify({ id: 'root1', name: 'Root', password: pw }) }, 's')).status < 300); assert.equal((await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ id: 'root1', password: pw }) }, 'r')).status, 200);
|
||||
const g = await api('/api/admin/settings', {}, 'r'); assert.equal(g.status, 200); assert.equal(g.b.marginPct, 33); assert.deepEqual(g.b.halManaged.keys, ['marginPct']); assert.equal(g.b.halManaged.halCompat, '0.4.0');
|
||||
const p = await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ marginPct: 50 }) }, 'r'); assert.equal(p.status, 409); assert.equal(p.b.code, 'managed_by_hal'); assert.deepEqual(p.b.keys, ['marginPct']);
|
||||
assert.equal((await api('/api/admin/settings', { method: 'PUT', body: JSON.stringify({ signupCredit: 10 }) }, 'r')).status, 200, '관리하지 않는 항목은 그대로 저장');
|
||||
} finally { try { child.kill('SIGTERM'); } catch (_) {} }
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ariweb-suite",
|
||||
"version": "0.3.17",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||
"type": "module",
|
||||
|
||||
@@ -23,9 +23,9 @@ import { homedir } from 'node:os';
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2); const force = args.includes('--force'); const only = args.includes('--only') ? args[args.indexOf('--only') + 1] : null; const allowDirty = args.includes('--allow-dirty');
|
||||
const SOURCES = [
|
||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '4b8c4d0de0f69025001a9e0a28a58a94e1a1f088',
|
||||
{ name: 'webmaker', repo: 'https://source.ibit.kr/OZ-Group/WebHosting_AI_agent', src: process.env.WEBMAKER_SRC || join(homedir(), 'work', 'WebHoting_AI_Agent'), dest: 'modules/webmaker', pin: '8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af',
|
||||
exclude: [/^reports\//, /^sample\//, /\.pdf$/, /^Dev_Order/, /^WebHosting_AI_agent_/, /^CHANGELOG\.md$/, /^ERRORS\.md$/, /^\.gitignore$/], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.' },
|
||||
{ name: 'arimail', repo: 'https://source.ibit.kr/OZ-Group/AriMail', src: process.env.ARIMAIL_SRC || join(homedir(), 'work', 'mail-gateway'), dest: 'modules/arimail', pin: 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924',
|
||||
{ name: 'arimail', repo: 'https://source.ibit.kr/OZ-Group/AriMail', src: process.env.ARIMAIL_SRC || join(homedir(), 'work', 'mail-gateway'), dest: 'modules/arimail', pin: 'f959b656f410289e024001f1f4712f2737c3b42b',
|
||||
exclude: [/^CHANGELOG\.md$/, /^\.gitignore$/, /^deploy\/sites\//], license: 'OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.' },
|
||||
];
|
||||
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex').slice(0, 16);
|
||||
@@ -45,6 +45,7 @@ for (const s of SOURCES) {
|
||||
const entry = { name: s.name, repo: s.repo, commit: head, dest: s.dest, license: s.license,
|
||||
upstream: { version, branch, headCommit: head, workingTreeDirty: dirty, note: dirty ? '고정 커밋(commit) 위에 커밋되지 않은 작업 트리 변경을 함께 가져왔다. 원본 저장소에 커밋되면 pin 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
||||
files: [] };
|
||||
const prevFiles = new Map(((prevManifest.sources.find((x) => x.name === s.name) || {}).files || []).map((x) => [x.from, x.origHash]));
|
||||
for (const f of files) {
|
||||
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
||||
if (!statSync(from).isFile()) continue;
|
||||
@@ -53,6 +54,8 @@ for (const s of SOURCES) {
|
||||
if (!existsSync(to)) { mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); status = 'imported'; }
|
||||
else if (sha(to) === origHash) status = 'unchanged';
|
||||
else if (force) { copyFileSync(from, to); status = 'restored'; }
|
||||
// [0.4.0] pin 을 올릴 때: Suite 사본이 "이전에 가져온 원본"과 같으면(Suite 가 고치지 않은 파일) 새 원본으로 갱신한다. Suite 가 고친 파일만 'modified' 로 남겨 직접 옮긴다.
|
||||
else if (prevFiles.get(f) && sha(to) === prevFiles.get(f)) { copyFileSync(from, to); status = 'updated'; }
|
||||
else status = 'modified';
|
||||
entry.files.push({ from: f, to: `${s.dest}/${f}`, status, origHash });
|
||||
}
|
||||
@@ -65,8 +68,8 @@ for (const e of manifest.sources) {
|
||||
const mod = e.files.filter((f) => f.status === 'modified').length;
|
||||
const up = e.upstream ? `- 원본 버전(package.json): ${e.upstream.version || '-'} · 브랜치: \`${e.upstream.branch || '-'}\` · 작업 트리: ${e.upstream.workingTreeDirty ? '**커밋되지 않은 변경 포함(--allow-dirty)** — ' + e.upstream.note : '깨끗함'}\n` : '';
|
||||
md += `## ${e.name}\n\n- 원본 저장소: ${e.repo}\n- 고정 커밋(불변 기준): \`${e.commit}\`\n${up}- 대상 경로: \`${e.dest}/\`\n- 파일 수: ${e.files.length} (수정 ${mod})\n- 라이선스 검토: ${e.license}\n\n| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |\n|---|---|---|---|\n`;
|
||||
for (const f of e.files) md += `| ${f.from} | ${f.to} | ${f.status === 'modified' ? '**수정**' : f.status === 'restored' ? '원본 복원' : '그대로'} | ${f.origHash} |\n`;
|
||||
for (const f of e.files) md += `| ${f.from} | ${f.to} | ${f.status === 'modified' ? '**수정**' : f.status === 'restored' ? '원본 복원' : f.status === 'updated' ? '새 원본으로 갱신' : '그대로'} | ${f.origHash} |\n`;
|
||||
md += '\n';
|
||||
}
|
||||
writeFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), md);
|
||||
for (const e of manifest.sources) console.log(`${e.name}: ${e.files.length} 파일 (${e.files.filter((f) => f.status === 'imported').length} 새로 가져옴, ${e.files.filter((f) => f.status === 'modified').length} 수정됨)`);
|
||||
for (const e of manifest.sources) console.log(`${e.name}: ${e.files.length} 파일 (${e.files.filter((f) => f.status === 'imported').length} 새로 가져옴, ${e.files.filter((f) => f.status === 'updated').length} 갱신, ${e.files.filter((f) => f.status === 'modified').length} 수정됨)`);
|
||||
|
||||
+26
-2
@@ -14,10 +14,10 @@ import { createHash } from 'node:crypto';
|
||||
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
||||
|
||||
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
||||
function fakeClient({ artifacts = {} } = {}) {
|
||||
function fakeClient({ artifacts = {}, settings = null } = {}) {
|
||||
const transfers = new Map(); const calls = []; const up = join(tmpDir(), 'uploads'); mkdirSync(up, { recursive: true });
|
||||
return { calls, transfers,
|
||||
async request(method, path, body) { calls.push({ method, path, body }); return { ok: true }; },
|
||||
async request(method, path, body) { calls.push({ method, path, body }); if (method === 'GET' && path.startsWith('/agent/v1/settings/')) { if (!settings) throw Object.assign(new Error('404'), { code: 'http_404' }); return { ok: true, settings }; } return { ok: true }; },
|
||||
async download(path, dest, expected) { const src = artifacts[path]; if (!src) { const e = new Error('404'); e.code = 'http_404'; throw e; } copyFileSync(src, dest); const sum = createHash('sha256').update(readFileSync(dest)).digest('hex'); if (expected && expected !== sum) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: sum, size: statSync(dest).size }; },
|
||||
async upload(id, file) { const copy = join(up, id + '.bin'); copyFileSync(file, copy); const sum = createHash('sha256').update(readFileSync(copy)).digest('hex'); this.transfers.set(id, { path: copy, sha256: sum }); return { id, sha256: sum, size: statSync(copy).size }; },
|
||||
async downloadTransfer(id, dest, expected) { const t = this.transfers.get(id); if (!t) throw Object.assign(new Error('transfer missing'), { code: 'transfer_not_found' }); copyFileSync(t.path, dest); if (expected && String(expected).replace(/^sha256:/, '') !== t.sha256) throw Object.assign(new Error('checksum'), { code: 'checksum_mismatch' }); return { sha256: t.sha256, size: statSync(dest).size }; },
|
||||
@@ -191,4 +191,28 @@ await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite
|
||||
r = await runner3.execute({ id: 'u3', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases' }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.steps.find((s) => s.id === 'unit').result.written, true); ok(e.sys.calls.slice(b2).some((c) => c.cmd === 'sudo' && c.args.includes('tee')) || existsSync('/etc/systemd/system/webmaker.service'));
|
||||
});
|
||||
await test('[0.4.0] module.configure: HAL 설정을 받아 hal-managed.json(0600)으로 쓰고 재시작·건강 확인 뒤 hash 를 보고 — 불일치·받기 실패는 실패로', async () => {
|
||||
const sec = 'v-' + createHash('sha256').update(String(Date.now())).digest('hex').slice(0, 12); const hash = 'sha256:' + createHash('sha256').update('settings-' + sec).digest('hex');
|
||||
const settings = { module: 'webmaker', serverId: 'srv_1', hash, generatedAt: new Date().toISOString(), values: { marginPct: 35, 'auth.apiKey': sec } };
|
||||
const e = env({ settings }); const configDir = join(e.base, 'wmdata');
|
||||
let r = await e.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir, settingsUrl: '/agent/v1/settings/webmaker', hash, restart: true, descriptor: { unit: 'webmaker', health: {} } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.hash, hash); eq(r.result.restarted, true); ok(r.result.health && r.result.health.ok);
|
||||
const f = join(configDir, 'hal-managed.json'); ok(existsSync(f)); eq(statSync(f).mode & 0o777, 0o600, '0600'); const j = JSON.parse(readFileSync(f, 'utf8')); eq(j.hash, hash); eq(j.values.marginPct, 35); eq(j.values['auth.apiKey'], sec);
|
||||
ok(e.sys.calls.some((c) => { const a = [c.cmd, ...c.args]; return a.includes('systemctl') && a.includes('restart') && a.includes('webmaker'); }), '재시작'); ok(e.client.calls.some((c) => c.method === 'GET' && c.path === '/agent/v1/settings/webmaker'), 'HAL 에서 받음');
|
||||
ok(!JSON.stringify(r.log || []).includes(sec), '로그에 비밀값 없음');
|
||||
// restart=false 면 재시작하지 않는다 · <appDir> 토큰은 모듈 디렉터리로 푼다
|
||||
const e2 = env({ settings: { ...settings, module: 'arimail' } }); const appDir = join(e2.roots.srv, 'arimail'); mkdirSync(appDir, { recursive: true });
|
||||
r = await e2.run('module.configure', { module: 'arimail', unit: 'arimail', appDir, configDir: '<appDir>/data', settingsUrl: '/agent/v1/settings/arimail', hash, restart: false, descriptor: { unit: 'arimail' } });
|
||||
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.restarted, false); ok(existsSync(join(appDir, 'data', 'hal-managed.json'))); ok(!e2.sys.calls.some((c) => [c.cmd, ...c.args].includes('restart')));
|
||||
// HAL 응답의 hash 가 작업의 hash 와 다르면(그 사이 설정이 바뀜) 쓰지 않고 실패
|
||||
const e3 = env({ settings: { ...settings, hash: 'sha256:' + 'f'.repeat(64) } }); r = await e3.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e3.base, 'w3'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false });
|
||||
eq(r.status, 'failed'); eq(r.errorCode, 'settings_hash_mismatch'); ok(!existsSync(join(e3.base, 'w3', 'hal-managed.json')));
|
||||
const e4 = env({}); r = await e4.run('module.configure', { module: 'webmaker', unit: 'webmaker', configDir: join(e4.base, 'w4'), settingsUrl: '/agent/v1/settings/webmaker', hash, restart: false }); eq(r.status, 'failed'); eq(r.errorCode, 'settings_fetch_failed');
|
||||
});
|
||||
await test('[0.4.0] Agent 설정: <dataDir>/hal-managed.json 의 heartbeatMs 가 환경 변수보다 우선(범위 밖이면 무시)', async () => {
|
||||
const { loadAgentConfig } = await import('../agent/config.mjs'); const dir = join(tmpDir(), 'ad'); mkdirSync(dir, { recursive: true });
|
||||
eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }).heartbeatMs, 20000);
|
||||
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ hash: 'sha256:' + 'a'.repeat(64), values: { heartbeatMs: 45000 } })); const c = loadAgentConfig({ ARIWEB_AGENT_DATA: dir, ARIWEB_AGENT_HEARTBEAT_MS: '20000' }); eq(c.heartbeatMs, 45000); eq(c.halManaged.keys, ['heartbeatMs']);
|
||||
writeFileSync(join(dir, 'hal-managed.json'), JSON.stringify({ values: { heartbeatMs: 10 } })); eq(loadAgentConfig({ ARIWEB_AGENT_DATA: dir }).heartbeatMs, 30000);
|
||||
});
|
||||
done();
|
||||
|
||||
@@ -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.3.17'); eq(h.d.version, '0.3.17');
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* test/hal-040.test.mjs — Ariweb Suite 0.4.0(개발 지시 `Ariweb-Suitle_Dev-Order_v0.4.0.md`, Oz 2026-10-29): 통합 설정 관제.
|
||||
*
|
||||
* 계약(요약)
|
||||
* 1) 설정 목록(catalog): 모듈별(webmaker·arimail·agent) 관리 항목 묶음. Webmaker 는 API 키·인증 / 마진·가격 / 모델 / 전체 적용 서비스 / 서버별 설정. AriMail 은 서버 단위 항목(도메인별 LIMIT_* 제외). Agent 는 heartbeatMs.
|
||||
* 2) 저장소: scope global | server:<id>, 비밀값은 Vault(`managed:<scope>:<module>:<key>`)에만. 유효값 = 전체 ← 서버 덮어쓰기. 형식·범위·알 수 없는 항목은 bad_setting.
|
||||
* 3) 운영자 API: 조회(비밀값은 있음/없음만)·저장(재인증·CSRF)·삭제·적용(대상 비우면 배치 서버 전체, 없으면 no_targets). 감사 기록에 값 없음.
|
||||
* 4) 적용 작업 settings.apply → settings.apply.target → Agent module.configure: Agent 는 자기 서버 범위의 설정만 받고(HMAC), 결과 hash 가 다르면 실패·상태 failed.
|
||||
* 5) 화면·AI 도구·버전·문서·모듈 대응(Webmaker 1.9.0 · AriMail 1.1.0, halCompat 0.4.0).
|
||||
*/
|
||||
import { test, eq, ok, tmpDir, done, sleep } from '../lib/test.mjs';
|
||||
import { invoke } from '../lib/http.mjs';
|
||||
import { signHeaders } from '../lib/serviceauth.mjs';
|
||||
import { listAudit } from '../lib/audit.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 { SETTINGS_CATALOG, catalogFor, keyDef, MANAGED_MODULES } from '../hal/settings/catalog.mjs';
|
||||
import * as S from '../hal/settings/index.mjs';
|
||||
import { MIGRATIONS } from '../hal/schema.mjs';
|
||||
import { TOOLS, toolByName, runTool, SKILL_DOC } from '../hal/ai/tools.mjs';
|
||||
import * as R from '../hal/ui/render.mjs';
|
||||
import { KINDS as AGENT_KINDS } from '../agent/jobs/index.mjs';
|
||||
import { mkdirSync, readFileSync, existsSync } 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 = 'fx') => `${p}-${randomBytes(12).toString('hex')}`;
|
||||
function makeHal() {
|
||||
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); } };
|
||||
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, cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (m, p, b) => invoke(h.app, m, p, { body: b, cookies: { aw_ops: r.cookies.aw_ops }, headers: { 'x-csrf': r.json.csrf } }) }; }
|
||||
async function addServer(h, { name, role }) {
|
||||
const s = core.createServer(h.d, { name, role }); const t = core.issueRegistrationToken(h.d, { kind: 'managed', serverId: s.id });
|
||||
const r = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: name, nginx: true, units: { webmaker: 'webmaker' }, appDirs: { webmaker: '/srv/webmaker' } }, version: '0.3.17' } }); ok(r.json.ok, JSON.stringify(r.json));
|
||||
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 50e9, diskTotalBytes: 100e9, sitesHosted: 0, reservedBytes: 0 }), s.id); core.approveServer(h.d, s.id, 'op');
|
||||
return { serverId: s.id, agentId: r.json.agentId, secret: r.json.secret };
|
||||
}
|
||||
function bootstrapDone(d, version) { core.claimLive(d, { instanceId: d.instanceId, version }); core.setBootstrap(d, { release_version: version, main_commit: 'abc', artifact_sha256: 'x', first_agent_id: d.store.get('SELECT id FROM agents LIMIT 1')?.id || 'ag_x', first_agent_registered_at: new Date().toISOString(), completed_at: new Date().toISOString() }); }
|
||||
const placeModule = (h, module, serverId, version = '0.3.17') => h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', module, serverId, version, version, 'ok', 1, new Date().toISOString(), new Date().toISOString());
|
||||
const agentGet = (h, a, path) => invoke(h.app, 'GET', path, { headers: Object.fromEntries(Object.entries(signHeaders(`agent:${a.agentId}`, a.secret, 'GET', path, '')).map(([k, v]) => [k.toLowerCase(), v])) });
|
||||
/** Agent 흉내: 작업을 받아 결과를 낸다 */
|
||||
async function runAgents(h, agents, result, maxMs = 8000) {
|
||||
const t0 = Date.now(); const ps = []; const seen = [];
|
||||
while (Date.now() - t0 < maxMs) { ps.push(h.d.jobs.tick().catch(() => {})); for (const a of agents) { const j = core.nextAgentJob(h.d, a.agentId); if (j) { seen.push(j); core.agentJobResult(h.d, j.id, a.agentId, await result(j, a)); } } if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) { await sleep(30); if (!h.d.jobs.list({ status: ['queued', 'running'], limit: 5 }).length) break; } await sleep(10); }
|
||||
await Promise.all(ps); return seen;
|
||||
}
|
||||
|
||||
await test('1·2) 설정 목록·저장소: 묶음·형식, 전체/서버 범위, 비밀값은 Vault 에만, 유효값 합성·hash, 형식·범위·알 수 없는 항목 거부', async () => {
|
||||
eq(MANAGED_MODULES, ['webmaker', 'arimail', 'agent']);
|
||||
const wm = catalogFor('webmaker'); eq(wm.groups.map((g) => g.id), ['auth', 'pricing', 'models', 'service', 'server']);
|
||||
const has = (m, k) => !!keyDef(m, k); for (const k of ['auth.mode', 'auth.apiKey', 'serviceApiKey', 'marginPct', 'fx.mode', 'tiers.basic', 'internalRef', 'ai.maxConcurrentJobs', 'service.allowSignup', 'service.publicBaseUrl', 'source.token', 'mail.adminPassword', 'endpoints.resendApiBase']) ok(has('webmaker', k), 'webmaker ' + k);
|
||||
eq(keyDef('webmaker', 'marginPct').type, 'number'); eq(keyDef('webmaker', 'auth.apiKey').type, 'secret'); eq(keyDef('webmaker', 'service.publicBaseUrl').scope, 'server'); ok(keyDef('webmaker', 'ai.claudeBin').restart === true, '재시작 필요 표시');
|
||||
const am = catalogFor('arimail'); for (const k of ['inbound', 'outbound', 'RESEND_API_KEY', 'DNS_PROVIDER', 'SMTP_RELAY_HOST', 'HARAKA_HOSTNAME', 'MAIL_FROM_NAME', 'USAGE_API_ALLOW', 'ARIWEB_SSO_SECRET']) ok(has('arimail', k), 'arimail ' + k);
|
||||
ok(!am.groups.some((g) => g.keys.some((k) => /^LIMIT_/.test(k.key))), '도메인별 LIMIT_* 는 HAL 이 다루지 않는다'); eq(keyDef('arimail', 'RESEND_API_KEY').type, 'secret'); eq(keyDef('arimail', 'inbound').options.includes('haraka'), true);
|
||||
ok(has('agent', 'heartbeatMs') && keyDef('agent', 'heartbeatMs').type === 'int');
|
||||
ok(MIGRATIONS.some((m) => m.id === 'hal_0010_managed_settings' && /managed_settings_state/.test(m.sql)));
|
||||
const h = makeHal(); const sec = rnd('sk'); const srv = core.createServer(h.d, { name: '웹1', role: 'web' });
|
||||
const r1 = S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { marginPct: '35', 'auth.mode': 'api-key', 'auth.apiKey': sec, 'service.allowSignup': 'false' }, by: 'op' });
|
||||
eq(r1.changed.sort(), ['auth.apiKey', 'auth.mode', 'marginPct', 'service.allowSignup']);
|
||||
const rows = h.d.store.all('SELECT * FROM managed_settings'); ok(!JSON.stringify(rows).includes(sec), '행에 비밀값 없음'); eq(h.d.vault.get('managed:global:webmaker:auth.apiKey'), sec);
|
||||
const g = S.getSettings(h.d, 'webmaker', 'global'); eq(g.values.marginPct, 35); eq(g.values['service.allowSignup'], false); eq(g.values['auth.mode'], 'api-key'); eq(g.secrets['auth.apiKey'], true); ok(!('auth.apiKey' in g.values) && !JSON.stringify(g).includes(sec));
|
||||
S.setSettings(h.d, { module: 'webmaker', scope: `server:${srv.id}`, values: { marginPct: 40, 'service.publicBaseUrl': 'https://wm.example.test' }, by: 'op' });
|
||||
const e = S.effectiveSettings(h.d, 'webmaker', srv.id); eq(e.values.marginPct, 40); eq(e.sources.marginPct, 'server'); eq(e.values['auth.mode'], 'api-key'); eq(e.sources['auth.mode'], 'global'); eq(e.values['service.publicBaseUrl'], 'https://wm.example.test'); ok(/^sha256:[0-9a-f]{64}$/.test(e.hash)); ok(!JSON.stringify(e).includes(sec));
|
||||
const es = S.effectiveSettings(h.d, 'webmaker', srv.id, { includeSecrets: true }); eq(es.values['auth.apiKey'], sec); eq(es.hash, e.hash, '비밀값 포함 여부와 무관하게 같은 hash(hash 는 비밀값을 포함해 계산)');
|
||||
const other = core.createServer(h.d, { name: '웹2', role: 'web' }); const e2 = S.effectiveSettings(h.d, 'webmaker', other.id); eq(e2.values.marginPct, 35); ok(e2.hash !== e.hash);
|
||||
for (const [vals, why] of [[{ marginPct: 'abc' }, '숫자 아님'], [{ marginPct: 5000 }, '범위 초과'], [{ nope: 1 }, '알 수 없는 항목'], [{ 'auth.mode': 'magic' }, 'enum 밖'], [{ 'ai.maxConcurrentJobs': 1.5 }, '정수 아님']]) { let code = null; try { S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: vals, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'bad_setting', why); }
|
||||
let code = null; try { S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { 'service.publicBaseUrl': 'https://x' }, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'bad_setting', '서버별 항목은 전체 범위에 둘 수 없다');
|
||||
code = null; try { S.setSettings(h.d, { module: 'nope', scope: 'global', values: {}, by: 'op' }); } catch (x) { code = x.code; } eq(code, 'unknown_module');
|
||||
S.deleteSetting(h.d, { module: 'webmaker', scope: 'global', key: 'auth.apiKey', by: 'op' }); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false); eq(S.getSettings(h.d, 'webmaker', 'global').secrets['auth.apiKey'], false);
|
||||
S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { 'auth.apiKey': '' }, by: 'op' }); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false, '빈 값은 비밀값 삭제');
|
||||
S.setSettings(h.d, { module: 'arimail', scope: 'global', values: { inbound: 'haraka', SMTP_RELAY_PORT: 2525, MAIL_FROM_NAME: '아리메일' }, by: 'op' }); eq(S.getSettings(h.d, 'arimail', 'global').values.SMTP_RELAY_PORT, 2525);
|
||||
S.setSettings(h.d, { module: 'agent', scope: 'global', values: { heartbeatMs: 45000 }, by: 'op' }); eq(S.effectiveSettings(h.d, 'agent', srv.id).values.heartbeatMs, 45000);
|
||||
});
|
||||
|
||||
await test('3) 운영자 API: 목록·조회(비밀값 없음)·저장(CSRF·재인증)·삭제·적용 대상, 감사 기록에 값 없음', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h); const sec = rnd('sk');
|
||||
let r = await op.as('GET', '/ops/api/settings'); eq(r.status, 200); eq(r.json.modules, ['webmaker', 'arimail', 'agent']); ok(r.json.catalog.webmaker.groups.length === 5 && r.json.catalog.arimail.groups.length >= 3, JSON.stringify(Object.keys(r.json.catalog)));
|
||||
r = await invoke(h.app, 'PUT', '/ops/api/settings/webmaker', { body: { scope: 'global', values: { marginPct: 35 } }, cookies: { aw_ops: op.cookie } }); eq(r.status, 403); eq(r.json.error, 'csrf');
|
||||
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec, 'tiers.premium': 'anthropic:claude-opus-5-5' } }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.changed.sort(), ['auth.apiKey', 'marginPct', 'tiers.premium']); ok(!r.text.includes(sec));
|
||||
r = await op.as('GET', '/ops/api/settings/webmaker?scope=global'); eq(r.status, 200); eq(r.json.values.marginPct, 35); eq(r.json.secrets['auth.apiKey'], true); ok(!r.text.includes(sec), '조회 응답에 비밀값 없음'); ok(r.json.catalog && r.json.catalog.groups, '응답에 목록');
|
||||
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 'abc' } }); eq(r.status, 400); eq(r.json.error, 'bad_setting');
|
||||
r = await op.as('PUT', '/ops/api/settings/nope', { scope: 'global', values: {} }); eq(r.status, 404); eq(r.json.error, 'unknown_module');
|
||||
// 재인증 만료 → 저장·적용 거부(reauth_required), 조회는 허용
|
||||
h.d.store.run('UPDATE sessions SET reauth_at=?', new Date(Date.now() - 11 * 60000).toISOString());
|
||||
r = await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 36 } }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||||
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [] }); eq(r.status, 403); eq(r.json.error, 'reauth_required');
|
||||
r = await op.as('GET', '/ops/api/settings/webmaker?scope=global'); eq(r.status, 200); eq(r.json.values.marginPct, 35);
|
||||
r = await op.as('POST', '/ops/api/reauth', { password: op.pw }); eq(r.status, 200, JSON.stringify(r.json));
|
||||
r = await op.as('DELETE', '/ops/api/settings/webmaker/auth.apiKey?scope=global'); eq(r.status, 200); eq(h.d.vault.has('managed:global:webmaker:auth.apiKey'), false);
|
||||
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [] }); eq(r.status, 400); eq(r.json.error, 'no_targets', '배치된 서버가 없으면 즉시 no_targets');
|
||||
const au = listAudit(h.d.store, { action: 'settings.set' }); ok(au.length >= 1 && !JSON.stringify(au).includes(sec), '감사 기록에 비밀값 없음'); ok(JSON.stringify(au).includes('auth.apiKey'), '키 이름은 남긴다');
|
||||
});
|
||||
|
||||
await test('4) 적용 작업: settings.apply → 서버별 → Agent module.configure(자기 범위만, HMAC) → 상태 ok / hash 불일치 failed / 변경 뒤 upToDate=false', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h); const web = await addServer(h, { name: '웹1', role: 'web' }); const web2 = await addServer(h, { name: '웹2', role: 'web' }); const mail = await addServer(h, { name: '메일1', role: 'mail' }); bootstrapDone(h.d, '0.4.0');
|
||||
placeModule(h, 'webmaker', web.serverId); placeModule(h, 'webmaker', web2.serverId); placeModule(h, 'arimail', mail.serverId);
|
||||
const sec = rnd('sk'); await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec } }); await op.as('PUT', '/ops/api/settings/webmaker', { scope: `server:${web.serverId}`, values: { marginPct: 40, 'service.publicBaseUrl': 'https://wm1.example.test' } });
|
||||
// Agent 설정 API: 자기 서버 범위, 비밀값 포함, 다른 서버의 덮어쓰기는 보이지 않음, 서명 없으면 거부
|
||||
let r = await agentGet(h, web, '/agent/v1/settings/webmaker'); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.settings.module, 'webmaker'); eq(r.json.settings.serverId, web.serverId); eq(r.json.settings.values.marginPct, 40); eq(r.json.settings.values['auth.apiKey'], sec); eq(r.json.settings.values['service.publicBaseUrl'], 'https://wm1.example.test'); ok(/^sha256:[0-9a-f]{64}$/.test(r.json.settings.hash));
|
||||
const hash1 = r.json.settings.hash;
|
||||
r = await agentGet(h, web2, '/agent/v1/settings/webmaker'); eq(r.json.settings.values.marginPct, 35); ok(!('service.publicBaseUrl' in r.json.settings.values)); ok(r.json.settings.hash !== hash1);
|
||||
r = await invoke(h.app, 'GET', '/agent/v1/settings/webmaker'); eq(r.status, 401);
|
||||
r = await agentGet(h, web, '/agent/v1/settings/nope'); eq(r.status, 404);
|
||||
// 적용(대상 비움 → webmaker 가 배치된 서버 전체)
|
||||
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [], restart: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.targets.sort(), [web.serverId, web2.serverId].sort()); eq(r.json.job.kind, 'settings.apply'); const jobId = r.json.job.id;
|
||||
const seen = await runAgents(h, [web, web2, mail], async (j, a) => { eq(j.kind, 'module.configure'); eq(j.payload.module, 'webmaker'); eq(j.payload.settingsUrl, '/agent/v1/settings/webmaker'); eq(j.payload.unit, 'webmaker'); eq(j.payload.configDir, '/data/webmaker'); eq(j.payload.restart, true); ok(j.payload.hash && !JSON.stringify(j.payload).includes(sec), '작업 payload 에 비밀값 없음(Agent 가 직접 받는다)');
|
||||
const s = await agentGet(h, a, j.payload.settingsUrl); return { status: 'done', result: { module: 'webmaker', hash: s.json.settings.hash, file: '/data/webmaker/hal-managed.json', restarted: true, health: { ok: true } } }; });
|
||||
eq(seen.length, 2); const job = h.d.jobs.get(jobId); eq(job.status, 'done', JSON.stringify(h.d.jobs.list({ kind: 'settings.apply.target', limit: 5 }).map((c) => [c.status, c.error, c.error_code])));
|
||||
const st = h.d.store.all('SELECT server_id, status, applied_hash FROM managed_settings_state WHERE module=? ORDER BY server_id', 'webmaker'); eq(st.length, 2); ok(st.every((x) => x.status === 'ok')); eq(st.find((x) => x.server_id === web.serverId).applied_hash, hash1);
|
||||
r = await op.as('GET', `/ops/api/settings/webmaker?scope=server:${web.serverId}`); eq(r.json.state.status, 'ok'); eq(r.json.state.upToDate, true); eq(r.json.effective.values.marginPct, 40); ok(!r.text.includes(sec));
|
||||
// 설정이 바뀌면 적용 상태는 최신이 아니다 → 운영 현황·목록에 보인다
|
||||
await op.as('PUT', '/ops/api/settings/webmaker', { scope: 'global', values: { 'fx.spreadPct': 2 } }); // 웹1 은 marginPct 를 서버에서 덮어쓰므로 다른 전체 항목을 바꾼다
|
||||
r = await op.as('GET', `/ops/api/settings/webmaker?scope=server:${web.serverId}`); eq(r.json.state.upToDate, false);
|
||||
r = await op.as('GET', '/ops/api/settings'); ok(r.json.overview.webmaker && r.json.overview.webmaker.servers.length === 2 && r.json.overview.webmaker.stale === 2, JSON.stringify(r.json.overview.webmaker));
|
||||
r = await op.as('GET', '/ops/api/overview'); eq(r.json.settings.stale, 2);
|
||||
// hash 불일치 → 대상 작업 실패, 상태 failed, 부모 partial/실패
|
||||
r = await op.as('POST', '/ops/api/settings/webmaker/apply', { serverIds: [web.serverId], nonce: '2' }); eq(r.status, 200, JSON.stringify(r.json)); const j2 = r.json.job.id;
|
||||
await runAgents(h, [web], async () => ({ status: 'done', result: { module: 'webmaker', hash: 'deadbeef', file: 'x', restarted: false, health: { ok: true } } }));
|
||||
const job2 = h.d.jobs.get(j2); ok(job2.status === 'failed', job2.status); eq(h.d.store.get('SELECT status FROM managed_settings_state WHERE module=? AND server_id=?', 'webmaker', web.serverId).status, 'failed');
|
||||
const child = h.d.jobs.list({ kind: 'settings.apply.target', limit: 10 }).find((x) => x.parent_id === j2); eq(child.error_code || child.errorCode, 'settings_hash_mismatch');
|
||||
// 대상 지정: arimail 을 웹 서버에 적용하려 하면 거부
|
||||
r = await op.as('POST', '/ops/api/settings/arimail/apply', { serverIds: [web.serverId], nonce: '3' }); eq(r.status, 400); eq(r.json.error, 'not_placed');
|
||||
});
|
||||
|
||||
await test('5) 화면·AI 도구·정적 계약', async () => {
|
||||
const h = makeHal(); const op = await loginOp(h);
|
||||
let r = await op.as('GET', '/ops/settings'); eq(r.status, 200); ok(r.text.includes('통합 설정 관제') && r.text.includes('data-page="settings"') && r.text.includes('href="/ops/settings"'), '화면·메뉴');
|
||||
const sec = rnd('sk'); const html = R.settingsFormHtml(SETTINGS_CATALOG.webmaker, { scope: 'global', values: { marginPct: 35, 'auth.mode': 'api-key' }, secrets: { 'auth.apiKey': true, 'auth.oauthToken': false } });
|
||||
ok(html.includes('name="marginPct"') && html.includes('value="35"'), '값'); ok(/name="auth\.apiKey"[^>]*type="password"|type="password"[^>]*name="auth\.apiKey"/.test(html), '비밀값은 비밀번호 입력'); ok(html.includes('설정됨') && html.includes('없음'), '비밀값 있음/없음'); ok(!html.includes(sec));
|
||||
ok(!html.includes('name="service.publicBaseUrl"'), '전체 범위에는 서버별 항목이 없다'); ok(html.includes('selected') && html.includes('api-key'), 'enum 선택'); ok(html.includes('data-act="settings-save"'));
|
||||
const html2 = R.settingsFormHtml(SETTINGS_CATALOG.webmaker, { scope: 'server:srv_1', values: {}, secrets: {}, effective: { values: { marginPct: 35 }, sources: { marginPct: 'global' } } }); ok(html2.includes('name="service.publicBaseUrl"') && html2.includes('전체 값 35'), '서버 범위: 서버별 항목·전체 값 안내');
|
||||
const stateHtml = R.settingsStateHtml([{ serverId: 'srv_1', serverName: '웹1', status: 'ok', upToDate: false, appliedAt: '2026-10-29T00:00:00Z', appliedHash: 'abc', error: null }]); ok(stateHtml.includes('웹1') && stateHtml.includes('적용 필요') && stateHtml.includes('data-act="settings-apply"'));
|
||||
const js = readFileSync(join(ROOT, 'hal', 'ui', 'public', 'ops.js'), 'utf8'); for (const s of ['settings:', "'settings-save'", "'settings-apply'", '/ops/api/settings']) ok(js.includes(s), 'ops.js ' + s);
|
||||
// AI 도구
|
||||
const names = TOOLS.map((t) => t.name); for (const n of ['hal_settings_get', 'hal_settings_set', 'hal_settings_apply']) ok(names.includes(n), n);
|
||||
eq(toolByName('hal_settings_get').risk, 'read'); eq(toolByName('hal_settings_set').risk, 'high'); eq(toolByName('hal_settings_apply').risk, 'high');
|
||||
S.setSettings(h.d, { module: 'webmaker', scope: 'global', values: { marginPct: 35, 'auth.apiKey': sec }, by: 'op' });
|
||||
const g = await runTool(h.d, toolByName('hal_settings_get'), { module: 'webmaker' }, { operatorId: 'op' }); eq(g.values.marginPct, 35); ok(g.confidential.some((x) => x.name === 'auth.apiKey' && x.set === true), JSON.stringify(g.confidential)); ok(!JSON.stringify(g).includes(sec));
|
||||
let code = null; try { await runTool(h.d, toolByName('hal_settings_set'), { module: 'webmaker', scope: 'global', values: { 'auth.apiKey': 'x' } }, { operatorId: 'op' }); } catch (x) { code = x.code; } eq(code, 'secret_not_allowed', 'AI 도구는 비밀값을 받지 않는다');
|
||||
const s2 = await runTool(h.d, toolByName('hal_settings_set'), { module: 'webmaker', scope: 'global', values: { marginPct: 31 } }, { operatorId: 'op' }); eq(s2.changed, ['marginPct']); eq(S.getSettings(h.d, 'webmaker', 'global').values.marginPct, 31);
|
||||
ok(SKILL_DOC.includes('hal_settings_get') && SKILL_DOC.includes('hal_settings_apply'));
|
||||
ok(AGENT_KINDS['module.configure'], 'Agent 작업 종류');
|
||||
});
|
||||
|
||||
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.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');
|
||||
const am = JSON.parse(readFileSync(join(ROOT, 'modules', 'arimail', 'package.json'), 'utf8')); eq(am.version, '1.1.0'); eq(am.ariwebSuite && am.ariwebSuite.halCompat, '0.4.0');
|
||||
ok(existsSync(join(ROOT, 'modules', 'webmaker', 'lib', 'hal-managed.js')) && existsSync(join(ROOT, 'modules', 'arimail', 'server', 'hal-managed.mjs')));
|
||||
ok(readFileSync(join(ROOT, 'modules', 'webmaker', 'server.js'), 'utf8').includes('managed_by_hal')); ok(readFileSync(join(ROOT, 'modules', 'arimail', 'server', 'setup.mjs'), 'utf8').includes('managed_by_hal'));
|
||||
ok(readFileSync(join(ROOT, 'modules', 'webmaker', 'README.md'), 'utf8').includes('HAL 0.4.0 대응'), 'Webmaker README(모듈 CHANGELOG 는 도입 제외 — 원본 저장소에 있음)'); ok(readFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), 'utf8').includes('1.1.0'));
|
||||
const imp = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')); eq(imp.sources.find((s) => s.name === 'webmaker').upstream.version, '1.9.0'); eq(imp.sources.find((s) => s.name === 'arimail').upstream.version, '1.1.0');
|
||||
});
|
||||
done();
|
||||
@@ -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();
|
||||
@@ -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.3.17'); eq(d.version, '0.3.17');
|
||||
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();
|
||||
|
||||
@@ -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('<https://x>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
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();
|
||||
|
||||
@@ -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<') && AR.modelSelectHtml({ id: 'c1', model: 'a', models: [] }).includes('확인'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
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();
|
||||
|
||||
@@ -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('<네오>') && !tabs.includes('<네오>'), '통합이 먼저·이스케이프');
|
||||
ok(AR.messageHtml({ role: 'assistant', author: '네오', content: [{ type: 'text', text: '<b>x</b>' }], createdAt: new Date().toISOString() }).includes('<b>'));
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17'); eq(h.d.version, '0.3.17');
|
||||
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.3.17');
|
||||
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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,15 +19,15 @@ const require = createRequire(import.meta.url);
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const WM = join(ROOT, 'modules', 'webmaker');
|
||||
|
||||
await test('도입 모듈에 직접 편집 순수 모듈·런타임·화면 파일이 있고 Webmaker 1.8.0 · Suite 0.3.17(HawkEye·통합 배포·릴리스 화면, 도입 모듈 변경 없음) 이다', () => {
|
||||
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.8.0');
|
||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17');
|
||||
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.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 기준 커밋 위의 작업 트리');
|
||||
else { eq(imported.commit, pin, '깨끗한 기록은 스크립트의 고정 커밋과 같다'); eq(imported.upstream && imported.upstream.headCommit, pin, 'headCommit = pin'); }
|
||||
eq(imported.upstream && imported.upstream.version, '1.8.0');
|
||||
eq(imported.upstream && imported.upstream.version, '1.9.0');
|
||||
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user