HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
54db663531
commit
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, 모듈 배포 결과, 운영자 조치, 알려진 제한을 보고한다.
|
||||||
+51
-20
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"generatedAt": "2026-10-28T14:53:58.554Z",
|
"generatedAt": "2026-10-29T06:35:15.406Z",
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"name": "webmaker",
|
"name": "webmaker",
|
||||||
"repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent",
|
"repo": "https://source.ibit.kr/OZ-Group/WebHosting_AI_agent",
|
||||||
"commit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
"commit": "8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af",
|
||||||
"dest": "modules/webmaker",
|
"dest": "modules/webmaker",
|
||||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.",
|
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.",
|
||||||
"upstream": {
|
"upstream": {
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"branch": "feature/visual-editor-v1.8.0",
|
"branch": "main",
|
||||||
"headCommit": "4b8c4d0de0f69025001a9e0a28a58a94e1a1f088",
|
"headCommit": "8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af",
|
||||||
"workingTreeDirty": false,
|
"workingTreeDirty": false,
|
||||||
"note": "고정 커밋의 깨끗한 체크아웃"
|
"note": "고정 커밋의 깨끗한 체크아웃"
|
||||||
},
|
},
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
{
|
{
|
||||||
"from": "README.md",
|
"from": "README.md",
|
||||||
"to": "modules/webmaker/README.md",
|
"to": "modules/webmaker/README.md",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "d28788cd6b28ec72"
|
"origHash": "71f4a93b1afbd6e4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "deploy/deploy.sh",
|
"from": "deploy/deploy.sh",
|
||||||
@@ -339,6 +339,12 @@
|
|||||||
"status": "unchanged",
|
"status": "unchanged",
|
||||||
"origHash": "bfdaa70652f846cc"
|
"origHash": "bfdaa70652f846cc"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "lib/hal-managed.js",
|
||||||
|
"to": "modules/webmaker/lib/hal-managed.js",
|
||||||
|
"status": "imported",
|
||||||
|
"origHash": "8c2d41a82a6c160f"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "lib/i18n.js",
|
"from": "lib/i18n.js",
|
||||||
"to": "modules/webmaker/lib/i18n.js",
|
"to": "modules/webmaker/lib/i18n.js",
|
||||||
@@ -528,8 +534,8 @@
|
|||||||
{
|
{
|
||||||
"from": "lib/setupapi.js",
|
"from": "lib/setupapi.js",
|
||||||
"to": "modules/webmaker/lib/setupapi.js",
|
"to": "modules/webmaker/lib/setupapi.js",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "3fd30ebd9efa47a6"
|
"origHash": "73c9b5fb48bebdfc"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "lib/setupauth.js",
|
"from": "lib/setupauth.js",
|
||||||
@@ -792,8 +798,8 @@
|
|||||||
{
|
{
|
||||||
"from": "package.json",
|
"from": "package.json",
|
||||||
"to": "modules/webmaker/package.json",
|
"to": "modules/webmaker/package.json",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "7b5c2bf8a18df617"
|
"origHash": "f34de8005d9750a7"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "public/404.html",
|
"from": "public/404.html",
|
||||||
@@ -816,8 +822,8 @@
|
|||||||
{
|
{
|
||||||
"from": "public/admin.js",
|
"from": "public/admin.js",
|
||||||
"to": "modules/webmaker/public/admin.js",
|
"to": "modules/webmaker/public/admin.js",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "6f37dafdf4502684"
|
"origHash": "f2e3de1470ba087f"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "public/app.js",
|
"from": "public/app.js",
|
||||||
@@ -1051,7 +1057,7 @@
|
|||||||
"from": "server.js",
|
"from": "server.js",
|
||||||
"to": "modules/webmaker/server.js",
|
"to": "modules/webmaker/server.js",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "b15f25a0b4f8278b"
|
"origHash": "03b3253877ca772d"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "setup-server.js",
|
"from": "setup-server.js",
|
||||||
@@ -1077,6 +1083,12 @@
|
|||||||
"status": "unchanged",
|
"status": "unchanged",
|
||||||
"origHash": "57ff395fcf97fbab"
|
"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",
|
"from": "test/lib.test.js",
|
||||||
"to": "modules/webmaker/test/lib.test.js",
|
"to": "modules/webmaker/test/lib.test.js",
|
||||||
@@ -1124,9 +1136,16 @@
|
|||||||
{
|
{
|
||||||
"name": "arimail",
|
"name": "arimail",
|
||||||
"repo": "https://source.ibit.kr/OZ-Group/AriMail",
|
"repo": "https://source.ibit.kr/OZ-Group/AriMail",
|
||||||
"commit": "a07f69f1ffb6667de6e66b3d1ec43c1c66fab924",
|
"commit": "f959b656f410289e024001f1f4712f2737c3b42b",
|
||||||
"dest": "modules/arimail",
|
"dest": "modules/arimail",
|
||||||
"license": "OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.",
|
"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": [
|
"files": [
|
||||||
{
|
{
|
||||||
"from": "README.md",
|
"from": "README.md",
|
||||||
@@ -1348,7 +1367,7 @@
|
|||||||
"from": "package.json",
|
"from": "package.json",
|
||||||
"to": "modules/arimail/package.json",
|
"to": "modules/arimail/package.json",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "f986ee70b4841c73"
|
"origHash": "594a37396efedeb4"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "server/api.mjs",
|
"from": "server/api.mjs",
|
||||||
@@ -1422,6 +1441,12 @@
|
|||||||
"status": "unchanged",
|
"status": "unchanged",
|
||||||
"origHash": "0425698a1fe8652b"
|
"origHash": "0425698a1fe8652b"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "server/hal-managed.mjs",
|
||||||
|
"to": "modules/arimail/server/hal-managed.mjs",
|
||||||
|
"status": "imported",
|
||||||
|
"origHash": "dcb97dcdcce8d3b9"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "server/imap-mime.mjs",
|
"from": "server/imap-mime.mjs",
|
||||||
"to": "modules/arimail/server/imap-mime.mjs",
|
"to": "modules/arimail/server/imap-mime.mjs",
|
||||||
@@ -1450,7 +1475,7 @@
|
|||||||
"from": "server/main.mjs",
|
"from": "server/main.mjs",
|
||||||
"to": "modules/arimail/server/main.mjs",
|
"to": "modules/arimail/server/main.mjs",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "90ad71bdc1bca7c0"
|
"origHash": "135f4c90b14ac2b9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "server/mime.mjs",
|
"from": "server/mime.mjs",
|
||||||
@@ -1509,8 +1534,8 @@
|
|||||||
{
|
{
|
||||||
"from": "server/setup.mjs",
|
"from": "server/setup.mjs",
|
||||||
"to": "modules/arimail/server/setup.mjs",
|
"to": "modules/arimail/server/setup.mjs",
|
||||||
"status": "unchanged",
|
"status": "updated",
|
||||||
"origHash": "ece096ed19cbb679"
|
"origHash": "958cfcecd0dcefa9"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "server/smtp.mjs",
|
"from": "server/smtp.mjs",
|
||||||
@@ -1584,6 +1609,12 @@
|
|||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "fc5ef627262d950f"
|
"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",
|
"from": "test/imap.test.mjs",
|
||||||
"to": "modules/arimail/test/imap.test.mjs",
|
"to": "modules/arimail/test/imap.test.mjs",
|
||||||
@@ -1648,7 +1679,7 @@
|
|||||||
"from": "web/setup.js",
|
"from": "web/setup.js",
|
||||||
"to": "modules/arimail/web/setup.js",
|
"to": "modules/arimail/web/setup.js",
|
||||||
"status": "modified",
|
"status": "modified",
|
||||||
"origHash": "24f72af0d740a559"
|
"origHash": "ef53b87b26a862a2"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "web/style.css",
|
"from": "web/style.css",
|
||||||
|
|||||||
+20
-15
@@ -1,19 +1,19 @@
|
|||||||
# 도입 소스 기록(IMPORTED_SOURCES)
|
# 도입 소스 기록(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
|
## webmaker
|
||||||
|
|
||||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
|
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
|
||||||
- 고정 커밋(불변 기준): `4b8c4d0de0f69025001a9e0a28a58a94e1a1f088`
|
- 고정 커밋(불변 기준): `8f8716cc2d59fedd94c9916f7f65c3cfdcd7a6af`
|
||||||
- 원본 버전(package.json): 1.8.0 · 브랜치: `feature/visual-editor-v1.8.0` · 작업 트리: 깨끗함
|
- 원본 버전(package.json): 1.9.0 · 브랜치: `main` · 작업 트리: 깨끗함
|
||||||
- 대상 경로: `modules/webmaker/`
|
- 대상 경로: `modules/webmaker/`
|
||||||
- 파일 수: 184 (수정 8)
|
- 파일 수: 186 (수정 8)
|
||||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
|
- 라이선스 검토: 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/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
|
||||||
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
|
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
|
||||||
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
|
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
|
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
|
||||||
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
|
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | b9d68792f6638161 |
|
||||||
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
|
| 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/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
|
||||||
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
|
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
|
||||||
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
|
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
|
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
|
||||||
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
|
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
|
||||||
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
|
| 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/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
|
||||||
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
|
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
|
||||||
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
|
| 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/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
|
||||||
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
|
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
|
||||||
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
|
| 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/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
|
||||||
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
|
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
|
||||||
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
|
| 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/app.js | modules/webmaker/public/app.js | **수정** | f8a5d75f9d7b734a |
|
||||||
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
|
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
|
||||||
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
|
| 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/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
|
||||||
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
|
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
|
||||||
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
|
| 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 |
|
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
|
||||||
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
|
| 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/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/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/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
|
||||||
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
|
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
|
||||||
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
|
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
|
||||||
@@ -201,9 +203,10 @@
|
|||||||
## arimail
|
## arimail
|
||||||
|
|
||||||
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
|
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
|
||||||
- 고정 커밋(불변 기준): `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
|
- 고정 커밋(불변 기준): `f959b656f410289e024001f1f4712f2737c3b42b`
|
||||||
|
- 원본 버전(package.json): 1.1.0 · 브랜치: `main` · 작업 트리: 깨끗함
|
||||||
- 대상 경로: `modules/arimail/`
|
- 대상 경로: `modules/arimail/`
|
||||||
- 파일 수: 88 (수정 15)
|
- 파일 수: 90 (수정 15)
|
||||||
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
- 라이선스 검토: 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/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/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 |
|
| 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/api.mjs | modules/arimail/server/api.mjs | **수정** | b51ef607e4c0e65b |
|
||||||
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
|
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
|
||||||
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
|
| 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/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
|
||||||
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
|
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
|
||||||
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
|
| 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-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
|
||||||
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
|
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
|
||||||
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
|
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
|
||||||
| server/limits.mjs | modules/arimail/server/limits.mjs | **수정** | 016c3aecef9a6da7 |
|
| 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/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
|
||||||
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
|
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
|
||||||
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | **수정** | 5774cb75b5b1c9d4 |
|
| 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/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
|
||||||
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
|
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
|
||||||
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
|
| 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/smtp.mjs | modules/arimail/server/smtp.mjs | **수정** | 61e2e7187c636297 |
|
||||||
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
|
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
|
||||||
| server/usage.mjs | modules/arimail/server/usage.mjs | **수정** | 29c97a4b5f627741 |
|
| 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/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
|
||||||
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
|
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
|
||||||
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | **수정** | fc5ef627262d950f |
|
| 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/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
|
||||||
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
|
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
|
||||||
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | **수정** | f92287ef6e7df130 |
|
| 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/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
|
||||||
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
|
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
|
||||||
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
|
| 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 |
|
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
|
||||||
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
|
||||||
|
|
||||||
- 버전: **0.3.17** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
- 버전: **0.4.0** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
|
||||||
- HAL AI 채팅룸(연결·방·도구·MCP, 0.3.0): [docs/HAL-AI.md](docs/HAL-AI.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)
|
- 설계·개발 규약: [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`)
|
- HAL 통합 설정 관제(0.4.0 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리): [docs/HAL-SETTINGS.md](docs/HAL-SETTINGS.md)
|
||||||
- 개발 지시 원문: `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)
|
- 도입 소스(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,50 @@
|
|||||||
# VERSION_HISTORY
|
# VERSION_HISTORY
|
||||||
|
|
||||||
|
## 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파일.
|
||||||
|
|
||||||
|
### 알려진 제한
|
||||||
|
- HAL 에서 지정을 지워도 모듈에 적용된 값은 남는다(잠금만 풀림). Webmaker 등급·내부 모델은 그 서버에서 사용 가능한 모델이어야 적용된다.
|
||||||
|
- 실행 계정이 다른 모듈의 결과 파일을 Agent 가 읽지 못하면(디렉터리 권한) "쓰기 완료·적용 미확인"으로 남는다(`appliedVerified=false`).
|
||||||
|
|
||||||
## 0.3.17 (2026-10-29)
|
## 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 배포까지 수행(아래 "배포").
|
- **작성자**: 개발 지시 — 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 배포까지 수행(아래 "배포").
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,7 @@ import { openVault } from '../lib/vault.mjs';
|
|||||||
|
|
||||||
export function loadAgentConfig(env = process.env) {
|
export function loadAgentConfig(env = process.env) {
|
||||||
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
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 file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
|
||||||
const cfg = {
|
const cfg = {
|
||||||
dataDir, file, ...saved,
|
dataDir, file, ...saved,
|
||||||
@@ -24,7 +25,9 @@ export function loadAgentConfig(env = process.env) {
|
|||||||
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
|
||||||
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
|
||||||
},
|
},
|
||||||
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
|
// [0.4.0] HAL 통합 설정(module.configure 가 쓰는 <dataDir>/hal-managed.json)이 있으면 그 값이 우선한다(범위 5초~10분)
|
||||||
|
heartbeatMs: (() => { const m = Number(managed.heartbeatMs); return Number.isFinite(m) && m >= 5000 && m <= 600000 ? m : Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000); })(),
|
||||||
|
halManaged: { hash: managedDoc.hash || null, keys: Object.keys(managed) },
|
||||||
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
|
||||||
vault: () => openVault(dataDir),
|
vault: () => openVault(dataDir),
|
||||||
};
|
};
|
||||||
|
|||||||
+41
-2
@@ -15,8 +15,8 @@
|
|||||||
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
* [실제 환경 결함 수정 0.1.1] 기존 유닛 보존: 도입 모듈 기술서에 keepExistingUnit 이 있고 서버의 유닛이 이미 <appDir>/current 를 가리키면 유닛을 다시 쓰지 않는다
|
||||||
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
* (운영 서버의 강화 지시문·ReadWritePaths 를 템플릿이 지워 버리지 않게). 유닛이 없거나 다른 곳을 가리킬 때만 템플릿으로 만든다.
|
||||||
*/
|
*/
|
||||||
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, chmodSync, renameSync, writeFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join, isAbsolute } from 'node:path';
|
||||||
import { userInfo } from 'node:os';
|
import { userInfo } from 'node:os';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { ROOT, readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
|
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 };
|
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 = {
|
export const kinds = {
|
||||||
|
'module.configure': { title: '모듈 설정 적용(HAL 관리)', run: configure },
|
||||||
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
|
||||||
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
|
'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() }; } },
|
'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() }; } },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
#
|
#
|
||||||
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
# 사용: module-as-user.sh <하위 명령> --app-dir <모듈 디렉터리> [인자…]
|
||||||
# extract --tgz <artifact.tar.gz> --dest <appDir/releases/<ver>> 릴리스 디렉터리를 비우고 artifact 를 푼다
|
# 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)
|
# switch --link <appDir/current> --target <릴리스 디렉터리> 심링크 원자 교체(ln → mv -T)
|
||||||
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
# rename --dir <releases 아래 디렉터리> --target <releases 아래 새 이름> 디렉터리 이름 바꾸기(같은 버전 재설치 때 실행 중인 디렉터리 보존)
|
||||||
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
# remove --dir <appDir/releases/<old>> 오래된 릴리스 삭제(releases 아래만)
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
# migrate --cwd <릴리스 디렉터리> --script <상대 경로> [--env-file <f>] [--node <node>] EnvironmentFile 을 읽어 마이그레이션 실행
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SUB="${1:-}"; shift || true
|
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
|
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;;
|
--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
|
*) echo "알 수 없는 인자 $1" >&2; exit 2;; esac; done
|
||||||
[ -n "$APP_DIR" ] && [ -d "$APP_DIR" ] || { echo "--app-dir <존재하는 디렉터리> 가 필요합니다" >&2; exit 2; }
|
[ -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
|
[ -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";;
|
rm -rf "$DEST"; mkdir -p "$DEST"; tar -xzf "$TGZ" -C "$DEST"; echo "extracted $DEST";;
|
||||||
write)
|
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)
|
switch)
|
||||||
inside "$LINK"; inside "$TARGET"; [ -d "$TARGET" ] || { echo "대상 디렉터리가 없습니다: $TARGET" >&2; exit 2; }
|
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";;
|
ln -sfn "$TARGET" "$LINK.tmp-$$"; mv -Tf "$LINK.tmp-$$" "$LINK"; echo "switched $LINK -> $TARGET";;
|
||||||
|
|||||||
@@ -29,6 +29,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}` | |
|
| `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` |
|
| `GET releases/:version/artifact?module=` | tar.gz 스트리밍, `X-Sha256` |
|
||||||
| `POST/GET transfers/:id` | HAL 경유 파일 전송(이전·원격 백업), 24시간 보관 |
|
| `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
|
## 4. 포털 API(`/portal/v1`, 키 `portal`) — docs/HAL-JOBS.md
|
||||||
|
|
||||||
|
|||||||
@@ -77,3 +77,6 @@ HAL 서비스와 같은 SQLite 를 WAL 로 함께 읽는다. 읽기 도구만
|
|||||||
- 원인: 단말 영역이 모든 키를 셸로 보내 Ctrl+C 가 복사 대신 ^C 가 되었고, 긴 인증 주소가 줄 끝(열 수)에서 끊겨 두 줄로 보여 통째로 고를 수 없었다.
|
- 원인: 단말 영역이 모든 키를 셸로 보내 Ctrl+C 가 복사 대신 ^C 가 되었고, 긴 인증 주소가 줄 끝(열 수)에서 끊겨 두 줄로 보여 통째로 고를 수 없었다.
|
||||||
- `term.mjs`: `joinedLines`(꽉 찬 줄은 다음 줄과 이어 붙임) · `plainText` · `extractUrls`(스크롤백·대체 화면 포함, 상자 문자 제외, 꼬리 구두점 제거, 중복 제거) · `copyDecision`(선택이 있으면 Ctrl+C=복사, Ctrl+Shift+C·Ctrl+Insert=항상 복사, Ctrl+V/Shift+Insert=붙여넣기, 나머지=전송).
|
- `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 → 안내.
|
- 화면(`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` 로 보고된다.
|
||||||
+9
-1
@@ -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` 로도 읽을 수 있다. 비밀값은 없다.
|
이 문서는 HAL 운영자 콘솔 AI 채팅룸에 연결된 모델의 system prompt 에 들어가고, 도구 `hal_skill` 과 `GET /ops/api/ai/skill` 로도 읽을 수 있다. 비밀값은 없다.
|
||||||
|
|
||||||
@@ -40,6 +40,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`)이 있다. 배포 대상 서버를 비우면 역할에 맞는 준비된 서버 전체로 간다.
|
- 배포 순서(`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)"이지 정상이 아니다.
|
- `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`)
|
## 5. 사건·알림 (`hal_incidents_list`, `hal_incident_open`, `hal_incident_resolve`)
|
||||||
- `severity` : `info` · `warning` · `critical`. `customerImpact` : 고객 영향 설명(없으면 내부 경고). `public` : 공개 상태 페이지 게시 여부.
|
- `severity` : `info` · `warning` · `critical`. `customerImpact` : 고객 영향 설명(없으면 내부 경고). `public` : 공개 상태 페이지 게시 여부.
|
||||||
- 같은 원인은 `key` 로 묶인다(열린 사건에 같은 key 로 등록하면 병합). 사건 종료(`resolve`)는 **실제 사용자 경로 확인**(`verifiedUserPath`) 없이는 거부된다.
|
- 같은 원인은 `key` 로 묶인다(열린 사건에 같은 key 로 등록하면 병합). 사건 종료(`resolve`)는 **실제 사용자 경로 확인**(`verifiedUserPath`) 없이는 거부된다.
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import * as dr from '../dr.mjs';
|
|||||||
import * as mailsvc from '../mailsvc.mjs';
|
import * as mailsvc from '../mailsvc.mjs';
|
||||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES } from '../modules/index.mjs';
|
||||||
import { overviewLocal, changelog } from '../ui.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 도구가 같은 내용을 쓴다. */
|
/** 스킬 문서(상태·로그의 뜻). system prompt 와 hal_skill 도구가 같은 내용을 쓴다. */
|
||||||
export const SKILL_DOC = readFileSync(new URL('./skill.md', import.meta.url), 'utf8');
|
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_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_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_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): 승인 + 재인증
|
// ---------------- 쓰기(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_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)) }) },
|
{ 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_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_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_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]));
|
const BY_NAME = new Map(TOOLS.map((t) => [t.name, t]));
|
||||||
/** [0.3.2] 연결별 도구 정책 프리셋. 기본값 all(전부) — 쓰기·고위험은 어차피 승인 전 실행되지 않으므로 "바로 쓸 수 있게" 전부 켜 둔다. */
|
/** [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('·')} 중 하나여야 합니다`; }
|
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 === '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 === '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} 항목은 문자열이어야 합니다`; }
|
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;
|
return null;
|
||||||
|
|||||||
+24
-2
@@ -28,7 +28,9 @@ import * as dr from './dr.mjs';
|
|||||||
import * as mailsvc from './mailsvc.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 { 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 { 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 * 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 ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
|
const 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; };
|
||||||
@@ -63,6 +65,7 @@ export function buildApp(d) {
|
|||||||
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
|
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.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
|
||||||
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
|
app.get('/agent/v1/jobs/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.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/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
|
||||||
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
|
app.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 }; }));
|
||||||
@@ -186,6 +189,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()) }) })));
|
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/* 를 운영자 세션으로 대신 부른다
|
// 포털 운영 화면 중계(§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' }; }));
|
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 으로 인증 키 변경·제안 승인을 지킨다
|
registerAiRoutes(app, d, { requireRecent, threeStep, wrap }); // 0.3.0: 같은 requireRecent·threeStep 으로 인증 키 변경·제안 승인을 지킨다
|
||||||
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
|
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>`); });
|
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,6 +203,18 @@ export function buildApp(d) {
|
|||||||
}
|
}
|
||||||
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
|
||||||
/** [0.3.17] 릴리스 화면 데이터: 릴리스 목록 + 모듈(HAL 제외)별 최신 승인 릴리스·안내·상호 연결·프로필·배치 상태·서버 이름 */
|
/** [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) {
|
export function releasesData(d) {
|
||||||
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
|
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; }
|
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; }
|
||||||
@@ -206,7 +228,7 @@ function overview(d) {
|
|||||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
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 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
|
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; }, {}) },
|
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() };
|
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() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { readFileSync, existsSync } from 'node:fs';
|
|||||||
import { join, dirname } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import * as core from './core.mjs';
|
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';
|
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES, SERVER_PROFILES } from './modules/index.mjs';
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
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)); } }
|
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(싱글턴 검사) */
|
/** 포털이 다른 서버에 이미 설치되어 있으면 그 서버 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; }
|
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: '모듈 순서대로 배포' }],
|
engine.register('server.provision', { title: '역할별 통합 배포', steps: [{ id: 'precheck', title: '서버·프로필·릴리스 확인' }, { id: 'profile', title: '역할·프로필·슬롯 기록' }, { id: 'deploy', title: '모듈 순서대로 배포' }],
|
||||||
async run(job, ctx) {
|
async run(job, ctx) {
|
||||||
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
const { serverId, profile, version } = job.subject; const prof = SERVER_PROFILES[profile]; if (!prof) throw new JobError('bad_profile', `모르는 프로필: ${profile}`);
|
||||||
|
|||||||
@@ -135,4 +135,9 @@ INSERT OR IGNORE INTO modules(name, target_version, updated_at) SELECT 'hawkeye'
|
|||||||
DELETE FROM modules WHERE name='observability';
|
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;` },
|
{ 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,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);
|
||||||
+9
-2
@@ -28,13 +28,16 @@ import * as mailsvc from './mailsvc.mjs';
|
|||||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||||
import { releasesData } from './app.mjs';
|
import { releasesData } from './app.mjs';
|
||||||
import * as R from './ui/render.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)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
/** 메뉴(§25) */
|
/** 메뉴(§25) */
|
||||||
export const MENU = [
|
export const MENU = [
|
||||||
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
|
||||||
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
|
['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', '운영자 계정'],
|
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['ai', '/ops/ai', 'AI 연동'], ['account', '/ops/account', '운영자 계정'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -81,6 +84,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>`;
|
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>` }); });
|
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)
|
// ---------------- 릴리스(§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 제외
|
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)
|
// ---------------- 작업(§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 }));
|
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 +140,7 @@ export function overviewLocal(d) {
|
|||||||
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
|
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 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: '배치 없음' }); });
|
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; }, {}) },
|
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() };
|
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() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); },
|
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 (_) {} },
|
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(); },
|
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; } }; },
|
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; } }; },
|
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,6 +89,13 @@ 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; },
|
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-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
|
||||||
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
|
'module-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; },
|
'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-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
|
||||||
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
|
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
|
||||||
|
|||||||
+32
-1
@@ -118,6 +118,37 @@ export function serverChoiceHtml(servers, { name = 'serverId', multi = false, el
|
|||||||
if (!servers || !servers.length) return '<div class="aw-state">선택할 서버가 없습니다 — 먼저 서버를 등록하고 Agent 를 연결·승인하세요.</div>';
|
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>`;
|
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 = []) {
|
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: '진행 중이거나 기록된 사건이 없습니다' })}
|
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>
|
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
|
||||||
@@ -154,7 +185,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 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 || {};
|
const jobs = o.jobs || {}; const srv = o.servers || {};
|
||||||
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
|
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>${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.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>
|
<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>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "arimail",
|
"name": "arimail",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"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 로 수·발신하는 메일 게이트웨이",
|
"description": "AriMail — 웹 메일함, IMAP·POP3·SMTP 서버, Resend 또는 내장 Haraka 로 수·발신하는 메일 게이트웨이",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server/index.mjs",
|
"start": "node server/index.mjs",
|
||||||
"migrate": "node server/db-migrate.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": {
|
"engines": {
|
||||||
"node": ">=22.13"
|
"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 { recoverQueue } from './sendqueue.mjs';
|
||||||
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
|
||||||
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
|
||||||
|
import { startHalManaged } from './hal-managed.mjs';
|
||||||
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
|
||||||
import { autoVerifyDomain } from './providers/resend.mjs';
|
import { autoVerifyDomain } from './providers/resend.mjs';
|
||||||
import { openTenants, noServicePage, normalizePrefix } from './domains.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 db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
|
||||||
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
|
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
|
||||||
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
|
// 운영 설정(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 로 관리자를 인증하는 연동 실행,
|
// 로그인 방식(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 로 정함).
|
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
|
||||||
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
|
// 값이 없으면 예전처럼 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 { publicConfig, saveConfig, generateDkim, dnsRecords, setupToken, configDir, configFile, harakaHostname, mailDomain, harakaDir, harakaStatus, maskSecret } from './config.mjs';
|
||||||
import { buildPlan, runPlan } from './provision.mjs';
|
import { buildPlan, runPlan } from './provision.mjs';
|
||||||
import { probeDns } from './dns/index.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 { outboundStatus, inboundMode, outboundMode, usesHaraka, usesResend } from './providers/index.mjs';
|
||||||
import { noServicePage, normalizeHost } from './domains.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 } = {}) {
|
export async function setupApi(env, request, path, { ip = '', user = null, tenants = null } = {}) {
|
||||||
const method = request.method, action = path[0] || '';
|
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)은 자기 도메인만 다룹니다.
|
// 도메인 등록부(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 === '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 === 'GET') return respond();
|
||||||
if (action === 'config' && method === 'PUT') {
|
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');
|
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 이 되는 일 방지).
|
// 사용량 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(', ') }); }
|
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; }
|
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') {
|
if (action === 'usage-key' && method === 'POST') {
|
||||||
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
|
const primary = env.TENANTS ? env.TENANTS.primary.env : env;
|
||||||
if (primary !== env) return bad('primary_only', 409);
|
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(() => ({}));
|
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 === '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');
|
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 } = {}) {
|
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
|
||||||
const [label, help] = FIELD[key] || [key, ''];
|
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>`;
|
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 && 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>`;
|
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 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 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 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 = `
|
$('#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">
|
<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.inbound[m.inbound][0])}</div></div>
|
||||||
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][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) {
|
function collect(form) {
|
||||||
const patch = {};
|
const patch = {};
|
||||||
for (const el of form.elements) {
|
for (const el of form.elements) {
|
||||||
|
if (el.disabled) continue; // 1.1.0: HAL 관리 칸
|
||||||
if (!el.name) continue;
|
if (!el.name) continue;
|
||||||
if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; 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; }
|
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)에서 바로 호스팅하는 에이전트.
|
AI 로 웹페이지를 자동 생성하고 같은 서버(nginx)에서 바로 호스팅하는 에이전트.
|
||||||
|
|
||||||
## 변경 이력
|
## 변경 이력
|
||||||
전체 변경 기록은 [CHANGELOG.md](CHANGELOG.md), 오류 점검 목록은 [ERRORS.md](ERRORS.md) 에 있다.
|
전체 변경 기록은 [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.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.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 배포 중단.
|
- **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) {
|
function save(body, by) {
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const values = body && body.values && typeof body.values === 'object' ? body.values : body;
|
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 점검표(공개 주소 등) 다시 계산
|
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}개). 바로 적용됩니다.`) : '바뀐 값이 없습니다.' };
|
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",
|
"name": "webhosting-ai-agent",
|
||||||
"version": "1.8.0",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"ariwebSuite": { "halCompat": "0.4.0", "note": "Ariweb Suite HAL 0.4.0 대응 — HAL 통합 설정 관제(lib/hal-managed.js)" },
|
||||||
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
"description": "AI 기반 자동 웹페이지 생성·호스팅 에이전트 (웹메이커)",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"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; } };
|
$('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; } };
|
$('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() {
|
async function loadSettings() {
|
||||||
settingsData = await api('/api/admin/settings'); const s = settingsData;
|
settingsData = await api('/api/admin/settings'); const s = settingsData;
|
||||||
|
renderHalManaged(s.halManaged); // v1.9.0 (Ariweb Suite HAL 0.4.0 대응)
|
||||||
loadVault();
|
loadVault();
|
||||||
$('ver').textContent = s.version ? `v${s.version}` : '';
|
$('ver').textContent = s.version ? `v${s.version}` : '';
|
||||||
renderTiers(s);
|
renderTiers(s);
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ const modules = require('./lib/modules');
|
|||||||
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
|
const mailsys = require('./lib/mailsys'); // v1.3.0 사이트 메일 시스템
|
||||||
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
|
const mailServers = require('./lib/mailservers'); // v1.6.38 메일 서버(Arimail) 등록부 — 여러 대, 닉네임·IP 로 구분
|
||||||
const mailusage = require('./lib/mailusage'); // v1.7.3 메일 사용량(Arimail 1.0.9 사용량 API, 캐시)
|
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 logview = require('./lib/logview'); // v1.3.2 관리자 로그 보기
|
||||||
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
|
const seo = require('./lib/seo'); // v1.3.5 검색·AI 최적화(SEO·AEO·GEO), 검색사이트 등록
|
||||||
const templates = require('./lib/templates'); // v1.3.10 사이트 템플릿
|
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 === '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 === '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' && 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') {
|
if (req.method === 'PUT') {
|
||||||
const { fields: b } = await readBody(req);
|
const { fields: b } = await readBody(req);
|
||||||
// v1.4.5 첫 화면 페이지는 관리자가 만든 사이트만 (다른 회원 사이트를 모든 사용자에게 보이지 않게)
|
// 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)가 없습니다. 생성을 마친 뒤 고르세요' }); }
|
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 */ });
|
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') {
|
if (req.method === 'POST' && arg === 'test-connection') {
|
||||||
@@ -2376,12 +2378,14 @@ async function handle(req, res) {
|
|||||||
if (what === 'credentials') {
|
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 }]));
|
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 === '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) — 파일은 저장하지 않고 메모리에서 읽는다
|
// 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 === '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); 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 === '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 === '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.0): 전체 켜기/끄기·메일 서버 주소, 웹메일을 켠 사이트 목록 ----
|
||||||
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
|
// ---- 메일 시스템 (v1.3.2: 사이트(호스팅 도메인) 단위로 켜기) ----
|
||||||
@@ -3219,10 +3223,12 @@ function staticFile(res, name, status = 200) {
|
|||||||
|
|
||||||
http.createServer((req, res) => {
|
http.createServer((req, res) => {
|
||||||
handle(req, res).catch((err) => {
|
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();
|
else res.end();
|
||||||
});
|
});
|
||||||
}).listen(PORT, HOST, () => {
|
}).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}`);
|
console.log(`webhosting-agent listening on http://${HOST}:${PORT} sites=${SITES_ROOT} public=${PUBLIC_BASE_URL} model=${MODEL}`);
|
||||||
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
|
// v1.6.31 (요구 12): 이전 프로세스가 남긴 AI 자식 프로세스 정리 · (요구 10) 손상 파일 안내
|
||||||
try { const n = require('./lib/jobpids').sweep(); if (n) console.log(`[jobs] 이전 실행이 남긴 AI 작업 프로세스 ${n}개를 종료했습니다`); } catch (_) {}
|
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",
|
"name": "ariweb-suite",
|
||||||
"version": "0.3.17",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
"description": "Ariweb Suite — 고객 포털(사이트·호스팅·메일·도메인·백업·결제) + HAL 제어 평면 + Node Agent. Webmaker·AriMail 을 모듈로 도입한다.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ import { homedir } from 'node:os';
|
|||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
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 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 = [
|
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 에서 재사용 허용. 외부 배포 불가.' },
|
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) 를 설치 시점에 내려받는 래퍼만 포함.' },
|
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);
|
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,
|
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 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
upstream: { version, branch, headCommit: head, workingTreeDirty: dirty, note: dirty ? '고정 커밋(commit) 위에 커밋되지 않은 작업 트리 변경을 함께 가져왔다. 원본 저장소에 커밋되면 pin 을 그 커밋으로 올려 다시 실행한다.' : '고정 커밋의 깨끗한 체크아웃' },
|
||||||
files: [] };
|
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) {
|
for (const f of files) {
|
||||||
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
const from = join(s.src, f); const to = join(ROOT, s.dest, f);
|
||||||
if (!statSync(from).isFile()) continue;
|
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'; }
|
if (!existsSync(to)) { mkdirSync(dirname(to), { recursive: true }); copyFileSync(from, to); status = 'imported'; }
|
||||||
else if (sha(to) === origHash) status = 'unchanged';
|
else if (sha(to) === origHash) status = 'unchanged';
|
||||||
else if (force) { copyFileSync(from, to); status = 'restored'; }
|
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';
|
else status = 'modified';
|
||||||
entry.files.push({ from: f, to: `${s.dest}/${f}`, status, origHash });
|
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 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` : '';
|
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`;
|
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';
|
md += '\n';
|
||||||
}
|
}
|
||||||
writeFileSync(join(ROOT, 'IMPORTED_SOURCES.md'), md);
|
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';
|
import { ROOT as ROOT_DIR, readJson } from '../agent/jobs/common.mjs';
|
||||||
|
|
||||||
/** 가짜 HAL 클라이언트: 전송은 메모리(파일 경로) 저장, artifact 는 미리 둔 파일 */
|
/** 가짜 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 });
|
const transfers = new Map(); const calls = []; const up = join(tmpDir(), 'uploads'); mkdirSync(up, { recursive: true });
|
||||||
return { calls, transfers,
|
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 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 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 }; },
|
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' } });
|
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'));
|
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();
|
done();
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ await test('3·5·7) 화면: 부분 갱신 diff(순수)·ops.js 정적 계약,
|
|||||||
});
|
});
|
||||||
|
|
||||||
await test('버전·문서(0.3.17)', async () => {
|
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.0'); eq(h.d.version, '0.4.0');
|
||||||
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, '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');
|
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.0'); eq(h.d.version, '0.4.0');
|
||||||
|
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();
|
||||||
@@ -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 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 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('복사'), '셸 안내에 복사 방법');
|
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.0'); eq(d.version, '0.4.0');
|
||||||
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'));
|
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();
|
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);
|
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');
|
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>'));
|
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.0'); eq(h.d.version, '0.4.0');
|
||||||
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'));
|
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();
|
done();
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ await test('화면·버전·문서(0.3.9): 셸 화면에 원시 키·단말 에
|
|||||||
eq((await invoke(h.app, 'GET', '/ops/ui/term.mjs')).status, 200);
|
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');
|
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('확인'));
|
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.0'); eq(h.d.version, '0.4.0');
|
||||||
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'));
|
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();
|
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);
|
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('<네오>'), '통합이 먼저·이스케이프');
|
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>'));
|
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.0'); eq(h.d.version, '0.4.0');
|
||||||
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'), '상태·로그 의미 설명');
|
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, '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 도구 문서');
|
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); };
|
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: 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: 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.0');
|
||||||
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: 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);
|
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);
|
child.kill(); await sleep(50);
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ const require = createRequire(import.meta.url);
|
|||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const WM = join(ROOT, 'modules', 'webmaker');
|
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.0(통합 설정 관제) 이다', () => {
|
||||||
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);
|
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(WM, 'package.json'), 'utf8')).version, '1.9.0');
|
||||||
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.3.17');
|
eq(JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version, '0.4.0');
|
||||||
const imported = JSON.parse(readFileSync(join(ROOT, 'IMPORTED_SOURCES.json'), 'utf8')).sources.find((s) => s.name === 'webmaker');
|
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');
|
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 기준 커밋 위의 작업 트리');
|
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'); }
|
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'), '도입 기록에 새 파일');
|
ok(imported.files.some((f) => f.from === 'lib/visual-editor.js'), '도입 기록에 새 파일');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user