Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a0aef37b9 |
@@ -120,29 +120,6 @@
|
|||||||
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
|
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
|
||||||
- **기존 사이트 인벤토리**(`agent/webmaker-inventory.mjs`, HAL 서버 상세): Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조해 모든 디렉터리의 ID·제목·계정 식별자·배포 상태(`deployed`/`unpublished`/`draft`/`unknown`)·서비스 상태·도메인·크기·파일 수를 HAL에 연결한다. 메타데이터가 없는 디렉터리도 `missing`으로 남기며 총 수·수집 수·잘림 여부·스캔 오류를 함께 기록한다. 2 MiB heartbeat 제한과 1,000항목 제한을 넘는 인벤토리는 512 KiB 이하 인증 배치로 전송하고 마지막 배치에서만 원자 반영한다. 페이지 본문·프롬프트·연락처·인증정보는 전송하지 않는다.
|
- **기존 사이트 인벤토리**(`agent/webmaker-inventory.mjs`, HAL 서버 상세): Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조해 모든 디렉터리의 ID·제목·계정 식별자·배포 상태(`deployed`/`unpublished`/`draft`/`unknown`)·서비스 상태·도메인·크기·파일 수를 HAL에 연결한다. 메타데이터가 없는 디렉터리도 `missing`으로 남기며 총 수·수집 수·잘림 여부·스캔 오류를 함께 기록한다. 2 MiB heartbeat 제한과 1,000항목 제한을 넘는 인벤토리는 512 KiB 이하 인증 배치로 전송하고 마지막 배치에서만 원자 반영한다. 페이지 본문·프롬프트·연락처·인증정보는 전송하지 않는다.
|
||||||
|
|
||||||
|
|
||||||
### 운영 Webmaker ↔ HAL 연동 점검 도구(2026-10-27, webmaker.ariweb.net)
|
|
||||||
- **`scripts/webmaker-inventory.mjs`**(+ `test/webmaker-inventory.test.mjs` 5건): 운영 Webmaker 데이터(JSON)·포털 DB·HAL DB 를 읽기 전용으로 안정 식별자(프로젝트 32-hex id, 포털 `s_/g_/u_`, HAL `srv_`)로 대조해 연결 등록부 일치·충돌·중복·누락, SSO 회원(`aw-<g8>-<u8>`) 신원, 도메인, Gitea `sites/<id>/main` ref, HAL 모듈 등록·버전·건강을 판정한다. 비밀로 보이는 키는 출력 전에 거부(`assertNoSecrets`). 충돌·누락이 있으면 종료 코드 2.
|
|
||||||
- 2026-10-27 hostinger 실데이터 결과: 프로젝트 10(포털 연결 일치 3·Webmaker 고유 7), 회원 12(SSO 3 일치·고유 9), 도메인 2, Gitea refs 13, HAL `module_targets.webmaker` 0.1.1 health ok — 충돌 0·누락 0, **적용 변경 없음**(교체도 불필요). 사전 스냅샷·복원 검증·사후 readback 은 `docs/verification/webmaker-hal-link-2026-10-27.md`, 절차는 RUNBOOK §8.
|
|
||||||
- **HAL21 제어 평면 전환 완료**: bear HAL을 main `12958ab`로 갱신한 뒤 hostinger Node Agent를 `hal21.ariweb.net`의 기존 서버 `srv_01a17bd4dbcdb55877f2d9fcab`에 재등록·승인했다. Webmaker unit·데이터는 변경하거나 재시작하지 않았다. 명시적 `DATA_ROOT`·`DATA_DIR`·`SITES_ROOT`로 수집한 HAL 인벤토리는 프로젝트 10/10, 절단 없음, 오류 0이며 `module_targets.webmaker`는 0.1.1/health ok다. 사전 체크섬 32,608건 불일치 0, Git 7개 fsck 성공, Gitea ref 13개 동일. 결과와 롤백은 `docs/verification/webmaker-hal21-link-2026-10-27.md`.
|
|
||||||
|
|
||||||
### 기존 메일 서비스(AriMail 인스턴스) ↔ HAL 관리형 MailService/Node 가져오기(2026-10-27, mail.arihakdang.com · webmail.ibit.kr → bear `hal21.ariweb.net`)
|
|
||||||
- **`hal/mailsvc.mjs`**(+ 마이그레이션 `hal_0003_mail_services`, `test/hal-mailimport.test.mjs` 7건): HAL 관리형 메일 서비스 등록부(`mail_services`, 도메인 단위 논리 리소스, 불변 id `mls_…`, 출처 권한 `arimail:<hostname>:<unit>`, 도메인별 active owner 정확히 하나 — 부분 유일 인덱스)와 가져오기 단위(`mail_imports`). 읽기 전용 discovery 문서만 입력으로 받고 **비밀값이 어디에든 있으면 거부**(비밀은 `secretRefs` 의 file:/dir:/vault:/env: 참조만 저장). dry-run 이 기본, 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락이 하나라도 있으면 아무것도 적용하지 않음, 적용은 한 트랜잭션 + 감사(immutable), 같은 출처·서버의 재적용은 update(멱등), 적용 단위 되돌리기(생성 행 삭제·갱신 행 복원). 소유 조직은 증거로 확인되기 전까지 `unassigned_legacy_managed`. Agent heartbeat 의 arimail 모듈 상태가 그 서버의 행에 `last_sync`·`health` 로 비친다(`core.heartbeat`). Data Plane(AriMail 송수신·계정·메일·DNS/MX·TLS·provider)은 바꾸지 않는다 — HAL 이 멈춰도 메일은 그대로다.
|
|
||||||
- **`scripts/mail-discover.mjs`**(+ `test/mail-discover.test.mjs` 2건): 원본 호스트에서 단독 실행하는 읽기 전용 discovery — 유닛 상태(`systemctl show`)·`current`/REVISION·package.json·AriMail 도메인 등록부(`data/domains.json`)·건강 API·haraka-status·게이트웨이/테넌트 DB **집계**(계정·메일 수·크기·무결성, 주소·내용 미열람)·유닛 PID 소유 리스너·nginx vhost·TLS 주체/만료·공개 DNS(1.1.1.1/8.8.8.8, 호스트 /etc/hosts 오염 방지)·certbot 훅. env/config 는 키 이름과 경로·권한만.
|
|
||||||
- **HAL CLI**(`hal/index.mjs`): `mail-import --discovery <json> (--server <id> | --create-server --name …) [--apply]`, `mail-import-rollback --import <id>`, `mail-services`(readback JSON), `server-token --server <id>`(운영자 콘솔이 닿지 않는 부트스트랩·연동 단계의 시스템 신원 토큰 발급, 감사 `cli:<by>`). **운영자 API**: `GET /ops/api/mail-services`(+readback: 총수·active·도메인 중복·서버별·비밀 누출 검사), `GET /ops/api/mail-services/:id`(감사 포함), `GET /ops/api/mail-services/imports`, `POST /ops/api/mail-services/import {discovery, serverId, apply}`(적용은 재인증), `POST /ops/api/mail-services/imports/:id/rollback`(재인증). **운영자 화면** `/ops/mail`: HAL 관리형 메일 서비스 표(도메인·공개 주소·서버/인스턴스·리비전·계정/메일함/메일·건강/동기화·TLS/DNS·비밀 참조 수)를 먼저 그리고 포털 메일 서비스는 그 아래 `#portal-mail` 에 중계로 채운다(`ops.js`).
|
|
||||||
- 2026-10-27 실제 적용: bear HAL 을 커밋 79b45ca 릴리스로 교체(재시작 2.6초, 메일 서비스 프로브 무중단) → 서버 2(pending) · MailService 7(hostinger 인스턴스 6 도메인 + jitsi 1, 모두 `unassigned_legacy_managed`, duplicates 0, secretLeaks 0) · jitsi Node Agent 등록(heartbeat → `last_sync_source=agent`, 승인은 운영자 결정) · hostinger 는 기존 HAL(`hal.ariweb.net`) 제어 평면과의 이중 제어를 피해 Agent 미설치(관측 전용). 원본 메일 서비스 변경 0. 결과·백업 sha256·readback 은 `docs/verification/mail-hal-link-2026-10-27.md`, 절차·되돌리기는 `docs/RUNBOOK-0.1.1.md` §9.
|
|
||||||
|
|
||||||
### 포털·hostinger Node Agent 제어 평면 이전 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
|
|
||||||
- HAL CLI(시스템 신원, 감사 actor `cli:<by>`·reason) 보완: `config-set`(운영 값만 data/config.json 에 기록, 비밀 키 거부, 서비스 재시작 뒤 반영) · `module-deploy`(운영자 API `POST /ops/api/modules/:name/deploy` 와 같은 멱등 키 규칙으로 `module.deploy` 작업을 큐에 넣고, 실행은 serve 프로세스의 작업 엔진이 맡는다) · `job --id`(작업·자식 readback). 운영자 콘솔 자격 증명이 없는 연동 단계용. 테스트 `test/hal-cli.test.mjs` 3건(별도 프로세스 실행·멱등·감사·서비스 측 엔진이 집어 실행).
|
|
||||||
- 결과(docs/verification/portal-hal21-link-2026-10-27.md): bear HAL21 을 main `dfb708e` 릴리스(artifact 4e419a72…)로 교체·등록·승인, `portalUrl=https://my.ariweb.net`; `link-portal` 값을 파이프로만 포털 vault `service:hal` 에 넣고(구 `service:portal` 제거) 포털 `halUrl=https://hal21.ariweb.net`; HAL21 작업 `j_01a17cb5716c669450412967f7` 로 portal 0.1.1 재배포(이전 릴리스 `.prev-20261027093350` 보존, health ok). 포털→HAL21 인증 읽기(health·servers·jobs 계약) 실측, 포털 DB 집계·Webmaker 인벤토리·AriMail 전후 동일, 외부 URL/TLS 전부 정상. 구 HAL 은 server offline 상태로 잔존(중지는 사람 결정). blocker: hostinger 서버 행 role `mail` → `pickServer(web)` null(운영자가 control 로 변경 필요).
|
|
||||||
|
|
||||||
### 운영자 콘솔 부트스트랩 배너 결함 수정 — 완료 뒤에도 "부트스트랩 미완료" 경고(2026-10-27, bear `hal21.ariweb.net`, Hera)
|
|
||||||
- **증상**: bear HAL21 `/api/health` 는 `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent 있음 · manualInstallLocked=true` 인데, 운영자 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보이고, 배포 릴리스 화면도 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려졌다.
|
|
||||||
- **원인**: `hal/ui.mjs` 의 공통 틀(shell) 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`)이 아니라 존재하지 않는 snake_case 열 이름(`completed_at`·`hal_running_version`·`first_agent_id`)을 읽어 항상 `undefined` → 늘 "미완료·확인 전·미등록"으로 렌더링. 같은 파일의 `/ops/releases` 라우트는 `releasesView` 가 bootstrap 행(snake_case `hal_running_version`·`manual_install_locked`)을 기대하는데 camelCase `selfHealth().bootstrap` 을 넘겨 같은 증상. 서버 렌더(HTML) 결함이며 `ops.js`/`ops.css` 는 관여하지 않는다(브라우저 캐시 무관).
|
|
||||||
- **수정(`hal/ui.mjs` 만, 2곳)**: 배너는 `health.bootstrap.complete` / `halRunningVersion` / `firstAgent` 를 쓰고, `/ops/releases` 는 `running: core.bootstrapState(d)` 를 넘긴다. 옛 snake_case fallback 은 두지 않았다(단일 계약).
|
|
||||||
- **검증(strict TDD)**: `test/hal-ui.test.mjs` 에 회귀 1건 추가 — selfHealth.bootstrap 에 snake_case 필드가 없음을 고정하고, (1) 초기 `확인 전·미등록` (2) `claimLive` 뒤 `0.1.1·미등록` (3) 첫 Agent 등록·승인(완료) 뒤 운영 현황·서버·릴리스 화면에 경고 없음 + 릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`. RED: 수정 전 (2)에서 `확인 전` 으로 실패(완료 상태에서도 경고·릴리스 화면 `확인 전` 잔존을 별도 스크립트로 기록). GREEN: 파일 7건·전체 22파일 150건 통과·실패 0. 배포 결과는 `docs/verification/hal21-ops-banner-fix-2026-10-27.md`.
|
|
||||||
- **배포 결과(bear hal21, 2026-10-27 14:36Z)**: 커밋 `648247f` 릴리스(hal artifact `ed957a0a…`)를 `/srv/ariweb/hal/releases/0.1.1-648247f22415` 에 stage → 추출된 `hal/ui.mjs` sha·`node --check`·snake_case 참조 0 확인 → register-release(approved, by hera-ops-banner-fix)·bootstrap-release → current 전환·재시작(health 까지 1.08초, NRestarts 0). data/config(opsAllowCidrs 5항목)/vault/DB sha·크기 전후 동일, 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z`. readback: `/api/health` bootstrap complete·mainCommit 648247f, Agent 2대 heartbeat 재개, 배포된 코드만 import 한 in-memory 서버 렌더에서 완료 상태 배너 없음·릴리스 화면 `HAL 실행 버전: 0.1.1 · 수동 설치 잠김`; 포털·Webmaker·메일 회귀 이상 없음. 되돌리기: current 를 `0.1.1-dfb708e933e5` 로 되돌리고 재시작.
|
|
||||||
|
|
||||||
### 도입 소스
|
### 도입 소스
|
||||||
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
|
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
|
||||||
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
|
||||||
|
|||||||
@@ -85,62 +85,3 @@ sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariw
|
|||||||
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
|
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
|
||||||
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
|
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
|
||||||
```
|
```
|
||||||
|
|
||||||
## 8. 운영 Webmaker(webmaker.ariweb.net) ↔ HAL 연동 점검·dry-run (2026-10-27)
|
|
||||||
사실: `webmaker.ariweb.net` = hostinger, 이미 HAL 작업으로 설치된 Ariweb Suite Webmaker 모듈 0.1.1(상류 1.7.3)이며 hostinger HAL 의 `module_targets.webmaker` 에 등록돼 있다(교체 불필요). HAL 은 사이트·계정을 모델링하지 않으므로 "HAL 리소스" 는 모듈 등록이고, 프로젝트 연결은 포털 ↔ Webmaker 양쪽 등록부로만 이뤄진다. 기록: `docs/verification/webmaker-hal-link-2026-10-27.md`.
|
|
||||||
```bash
|
|
||||||
# 1) 사전 스냅샷(hostinger, master, 사설 0700) — /srv/webmaker/backups/pre-hal-link-<UTC>/ : data·sites tar, 원본 32,608 파일 sha256, conf, Gitea refs, HAL·포털 DB VACUUM INTO 사본, MANIFEST.sha256, ROLLBACK.md. 만든 뒤 임시 디렉터리에 풀어 체크섬 전수 대조(RESTORE_TEST_OK)
|
|
||||||
# 2) 읽기 전용 inventory + dry-run (비밀값 없음, 충돌·누락이 있으면 종료 코드 2)
|
|
||||||
node --no-warnings scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker --sites-root /var/www/webmaker-sites --app-dir /srv/webmaker/current \
|
|
||||||
--portal-db /srv/ariweb/portal/data/portal.sqlite --hal-db /srv/ariweb/hal/data/hal.sqlite --nginx-enabled /etc/nginx/sites-enabled \
|
|
||||||
--gitea-refs <ls-remote --heads 출력> --out <백업 디렉터리>/dry-run.json
|
|
||||||
# 3) 사후 readback: (cd / && sha256sum --quiet -c <백업>/checksums-source.txt) · git fsck · ls-remote diff · systemctl show webmaker -p NRestarts · nginx -t · 외부 URL
|
|
||||||
```
|
|
||||||
판정 뜻: `linked_consistent` 양쪽 등록부 일치 · `unlinked_native` Webmaker 고유 프로젝트(변경 대상 아님) · `link_*`/`duplicate`/`missing_project`/`gitea_ref`/`hal_*` 는 충돌·누락이라 적용 금지. 2026-10-27 결과: 프로젝트 10(일치 3·고유 7), 회원 12(SSO 3 일치·고유 9), 충돌 0·누락 0 → **적용 변경 없음**. 되돌리기는 스냅샷 디렉터리의 `ROLLBACK.md`.
|
|
||||||
남은 사람 결정: hal21(bear) 운영자 자격 증명으로 서버 등록·Agent 재등록 여부(제어 평면 이전은 §34 "하나의 HAL" 절차), 고유 회원 9명의 포털 계정 전환(사용자 자격 증명·동의 필요).
|
|
||||||
|
|
||||||
## 9. 기존 메일 서비스(AriMail) ↔ HAL 관리형 MailService/Node 가져오기 (2026-10-27)
|
|
||||||
사실(읽기 전용 조사): `https://mail.arihakdang.com/` = hostinger(187.53.133.173, 유닛 `arihakdang-mail`, `/srv/arihakdang-mail`, 실행 계정 arihakdang, Ariweb Suite arimail 모듈 0.1.1 = AriMail 1.0.11, 도메인 6개 멀티테넌트) · `https://webmail.ibit.kr/` = 115.68.194.192(`ssh jitsi`, 유닛 `arimail`, `/srv/arimail`, 실행 계정 arimail, 원본 AriMail 1.0.11 `a07f69f`, 도메인 1개). **서로 다른 호스트·다른 인스턴스**다. 두 호스트 모두 Haraka 비활성(25 포트 없음), 수신 MX 는 Resend(inbound-smtp.ap-northeast-1.amazonaws.com), 제출 587/465·IMAP 143/993·POP 110/995 는 AriMail 프로세스가 직접 연다. hostinger 에는 자체 HAL(`hal.ariweb.net`)과 거기에 등록된 Node Agent 가 이미 있다(bear `hal21` 과 별개 제어 평면).
|
|
||||||
|
|
||||||
원칙: 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 그대로. HAL 에는 **등록·감시·통제용 등록부**(`mail_services` 도메인별 논리 리소스 + `servers` 노드)만 만든다. AriMail 재시작·DNS 변경·계정/DB 변형·리스너 교체 금지.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1) 사전 사설 스냅샷(각 원본 호스트, root 0700/0600, git 밖) — <app>/backups/pre-hal-onboard-<UTC>/ : archive.tgz(데이터·설정·유닛·env·nginx·훅, DB 제외) · db.tgz(VACUUM INTO 사본 + integrity_check) · code.tgz(current 릴리스 트리) · meta.tgz · SHA256SUMS · RESTORE.md
|
|
||||||
# bear HAL 도 같은 모양: /srv/ariweb/backups/hal-pre-mail-onboard-<UTC>/ (hal.sqlite VACUUM 사본·vault·config·유닛·nginx·current 트리)
|
|
||||||
# 2) 읽기 전용 discovery(원본 호스트, 파일 하나만 복사해 sudo 로 실행 — 데이터 디렉터리가 0700 이라서). 출력에 비밀값·메일 주소 없음
|
|
||||||
sudo node scripts/mail-discover.mjs --unit arihakdang-mail.service --app-dir /srv/arihakdang-mail --etc /etc/arihakdang-mail \
|
|
||||||
--vhost /etc/nginx/sites-available/mail.arihakdang.conf --hook /etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh --out discovery.json
|
|
||||||
# 3) bear(HAL 데이터 소유자 master): 서버(노드) 만들기 + dry-run → 거부 0 이면 --apply. 같은 문서를 다시 적용하면 update(멱등)
|
|
||||||
cd /srv/ariweb/hal/current && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
|
|
||||||
node hal/index.mjs mail-import --discovery discovery.json --create-server --name "srv1953374 (mail.arihakdang.com)" --location hostinger --by hera # dry-run, 서버 id 출력
|
|
||||||
node hal/index.mjs mail-import --discovery discovery.json --server <srv_id> --apply --by hera
|
|
||||||
node hal/index.mjs mail-services # readback: counts.active = 도메인 수, duplicates [], secretLeaks 0
|
|
||||||
# 4) (선택) Node Agent — Agent 가 없는 호스트만. 유닛 ariweb-agent(별도 프로세스, HAL 로의 outbound HMAC 만, 메일 포트·유닛 무관)
|
|
||||||
node hal/index.mjs server-token --server <srv_id> --by hera # 1회 표시 토큰 → 원본 호스트에서 install.sh (--role mail)
|
|
||||||
# 운영자 콘솔에서 서버 '승인' 은 사람 결정(첫 Agent 승인은 부트스트랩을 닫고 수동 설치를 잠근다 §32.2)
|
|
||||||
```
|
|
||||||
되돌리기: `node hal/index.mjs mail-import-rollback --import <mimp_id>`(그 적용이 만든 행 삭제·갱신 행 복원), 서버 행은 운영자 콘솔 서버 삭제(3단계 확인). Agent 는 `systemctl disable --now ariweb-agent` + `/srv/ariweb/agent` 제거(메일 서비스 영향 없음). 원본 메일 데이터 복원은 각 백업의 RESTORE.md(운영자 승인 뒤에만 — 메일 중단 동반).
|
|
||||||
hostinger 주의: `arihakdang-mail` 은 hostinger HAL(`hal.ariweb.net`)의 `module_targets.arimail` 로도 관리된다. hal21 에 같은 호스트의 두 번째 Agent 를 설치하면 두 제어 평면이 같은 유닛에 배포할 수 있으므로(§34 "하나의 HAL") **설치하지 않고** 서버 행을 pending(Agent 미연결)으로 두었다 — 제어 평면 이전은 사람 결정.
|
|
||||||
|
|
||||||
## 10. 포털·Node Agent 제어 평면 이전 — hostinger HAL(hal.ariweb.net) → bear HAL21 (2026-10-27)
|
|
||||||
전제: 포털은 hostinger 에서 이미 active(신규 초기화·빈 DB 생성 금지), Agent 는 한 HAL 에만 연결(단일 제어 평면), 비밀값은 파이프로만 이동. 기록: `docs/verification/portal-hal21-link-2026-10-27.md`.
|
|
||||||
```bash
|
|
||||||
# 0) 읽기 전용 preflight: DNS(공개·권한 resolver)·TLS·login·/api/health, 유닛 PID/NRestarts/cwd/REVISION/리스너, Agent 의 halUrl(agent.json 의 비밀 아닌 값), 포털 config 값·vault 이름, DB integrity/집계, 구 HAL·HAL21 의 servers/agents/jobs/module_targets
|
|
||||||
# 1) 사설 백업(각 호스트, root 0700/0600, RESTORE.md 먼저): 포털 DB VACUUM 사본(+wal/shm 크기 기록)·vault·config·유닛·nginx·cert 메타·Agent data·구 HAL DB/config, HAL21 DB/config/release — SHA256SUMS·stage 추출 readback·복원 경로 존재 검증
|
|
||||||
# 2) HAL21(bear, HAL 데이터 소유자): main 릴리스 artifact 등록·승인, 포털 주소 설정(재시작 필요), current 전환
|
|
||||||
cd /srv/ariweb/hal/releases/0.1.1-<commit12> && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data
|
|
||||||
node hal/index.mjs register-release --manifest <dist>/manifest.json --approve --by <who>
|
|
||||||
node hal/index.mjs bootstrap-release --version 0.1.1 --commit <sha> --sha256 <hal artifact sha>
|
|
||||||
node hal/index.mjs config-set --portalUrl https://my.ariweb.net --by <who> --reason '…' # 포털이 다른 호스트일 때
|
|
||||||
ln -sfn $PWD /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal && curl -s http://127.0.0.1:8900/api/health
|
|
||||||
# 3) 포털 연동 키: 값을 화면에 두지 않고 host-to-host 파이프로 포털 vault 에 넣는다(service:hal 하나 — 포털은 양방향에 같은 이름을 쓴다)
|
|
||||||
ssh bear 'cd /srv/ariweb/hal/current && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs link-portal | sed -n "s/^ service:hal = //p"' \
|
|
||||||
| ssh hostinger 'node -e "let s=\"\";process.stdin.on(\"data\",c=>s+=c).on(\"end\",async()=>{const m=await import(\"/srv/ariweb/portal/current/lib/vault.mjs\");m.openVault(\"/srv/ariweb/portal/data\").set(\"service:hal\",s.trim(),{kind:\"service-key\"})})"'
|
|
||||||
# 4) 포털 주소(비밀 아님): portal/config.mjs 의 set 으로 config.json 기록(0600, 원자적). 포털은 기동 때 읽으므로 다음 단계의 재배포(재시작)로 반영된다
|
|
||||||
ARIWEB_PORTAL_DATA=/srv/ariweb/portal/data node -e "import('/srv/ariweb/portal/current/portal/config.mjs').then(m=>m.loadConfig().set({publicUrl:'https://my.ariweb.net',halUrl:'https://hal21.ariweb.net'},'cli:<who>'))"
|
|
||||||
# 5) HAL21 작업으로 portal 재배포(같은 0.1.1 이면 Agent 가 현재 릴리스를 <ver>.prev-<시각> 으로 보존하고 health 실패 시 자동 복귀). 실행은 ariweb-hal 서비스가 맡는다
|
|
||||||
node hal/index.mjs module-deploy --module portal --version 0.1.1 --servers <srv_id> --by <who> --reason '…' # → jobId
|
|
||||||
node hal/index.mjs job --id <j_id> # status done, children[].steps await.result.actualVersion/previousVersion/health
|
|
||||||
# 6) readback: module_targets.portal target=actual=0.1.1 health ok · 포털의 실제 client+config+vault 로 /portal/v1/health·servers·jobs 계약 · bear nginx access log 의 포털 요청 200 · 포털 DB 집계·Webmaker 인벤토리 전후 비교 · 외부 URL/TLS · 유닛/NRestarts/journal
|
|
||||||
```
|
|
||||||
주의: 포털 vault 교체와 포털 재시작 사이(수 초)에는 포털→HAL 호출이 인증 실패한다(화면은 '정보 확인 지연'). 서버 행의 role 이 `mail` 이면 `pickServer(web)` 이 null 이라 포털 사이트 배포 대상이 없다 — 운영자 콘솔에서 role 을 control 로 바꾼다(운영자 API 만 있음). 구 HAL 은 Agent 가 떠나면 server offline 으로 남는다; 중지·폐기는 사람 결정.
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# 운영자 콘솔 "부트스트랩 미완료" 배너 결함 수정·배포 검증 기록 — 2026-10-27 (bear hal21.ariweb.net, Hera)
|
|
||||||
|
|
||||||
비밀값(토큰·비밀번호·Vault 값·운영자 자격)은 이 문서에 없다. 시각은 UTC.
|
|
||||||
|
|
||||||
## 1. 증상과 원인
|
|
||||||
- 오즈 보고: HAL21 운영 콘솔 모든 화면 상단에 `! 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)…` 경고가 계속 보임.
|
|
||||||
- 실제 readback(수정 전, 14:2xZ `curl http://127.0.0.1:8900/api/health`): `bootstrap.complete=true · halRunningVersion=0.1.1 · firstAgent=ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, Agent 2대 active.
|
|
||||||
- 원인(코드): `hal/ui.mjs` shell 배너가 `core.selfHealth(d).bootstrap` 의 실제 계약(camelCase `complete`·`halRunningVersion`·`firstAgent`, `hal/core.mjs` selfHealth)이 아니라 존재하지 않는 snake_case `completed_at`·`hal_running_version`·`first_agent_id` 를 읽어 항상 `undefined` → "미완료·확인 전·미등록". 같은 파일 `/ops/releases` 라우트는 `releasesView`(`hal/ui/render.mjs`, bootstrap 행의 `hal_running_version`·`manual_install_locked` 기대)에 camelCase 객체를 넘겨 `HAL 실행 버전: 확인 전 · 부트스트랩 미완료` 로 그려짐.
|
|
||||||
- **서버 렌더(HTML) 결함**이다. `hal/ui/public/ops.js`·`ops.css` 는 bootstrap 필드를 읽지 않으며(grep 0건) 변경하지 않았다 → 브라우저 캐시와 무관.
|
|
||||||
|
|
||||||
## 2. RED (수정 전 실패 증명)
|
|
||||||
- 회귀 테스트 `test/hal-ui.test.mjs` "부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약…": (0) `selfHealth.bootstrap` 에 snake_case 필드가 없음을 고정 (1) 초기 `확인 전·미등록` (2) `core.claimLive` 뒤 `HAL 실행 버전 0.1.1, 첫 Agent 미등록` (3) 첫 Agent 등록(`POST /agent/v1/register`)·`core.approveServer` → 완료 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 에 경고 없음 + 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김` (4) Agent 만 승인된 새 HAL 은 `확인 전·등록됨`.
|
|
||||||
- 수정 전 실행 결과(`node --no-warnings test/hal-ui.test.mjs`): **통과 6 · 실패 1** — `Error: 실행 버전 0.1.1 표시: 부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록) (실제 false)` (test/hal-ui.test.mjs:50).
|
|
||||||
- 완료 상태 증상도 수정 전 코드로 기록(scratchpad `g7/red-evidence.mjs`): `selfHealth.bootstrap.complete=true` 인데 `/ops/` 배너 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)`, `/ops/releases` `HAL 실행 버전: <b>확인 전</b>`.
|
|
||||||
|
|
||||||
## 3. GREEN (수정, `hal/ui.mjs` 만 2곳)
|
|
||||||
- 배너: `!health.bootstrap.complete` / `health.bootstrap.halRunningVersion` / `health.bootstrap.firstAgent`.
|
|
||||||
- `/ops/releases`: `running: core.bootstrapState(d)`(렌더러가 기대하는 bootstrap 행). snake_case fallback 없음, 다른 기능 변경 없음.
|
|
||||||
- 수정 뒤 `test/hal-ui.test.mjs` 통과 7 · 실패 0.
|
|
||||||
|
|
||||||
## 4. 리비전·테스트·빌드
|
|
||||||
| 항목 | 값 |
|
|
||||||
|---|---|
|
|
||||||
| 작업 시작 origin/main = HEAD(fetch 뒤 동일, worktree clean — e2e 테스트가 재생성한 `docs/verification/local-e2e-2026-10-27.*` 는 HEAD 로 복원) | `d8e7beeb861ae5edb45fd56d6950fd8a50ba204e` |
|
|
||||||
| 수정 커밋(hal/ui.mjs + test/hal-ui.test.mjs + VERSION_HISTORY, author `Hera (헤라) <hera@mp.local>`) | `648247f2241579e947d61ab2928176f678f4b141` |
|
|
||||||
| 전체 테스트 `node test/run.mjs`(수정 뒤, 2m37s) | 22파일 **150건 통과 · 실패 0**(기존 149 + 신규 1) |
|
|
||||||
| release build(`scripts/release.mjs build`, 커밋 648247f, 테스트 재실행 150건) | 0.1.1, testsPassed=true, dirty=false; verify 5 artifact ✔ |
|
|
||||||
| hal artifact sha256 | `ed957a0aeb8f065085e3163cf3f367a240e90b55f6221844c93fa4404de8512f` (portal `32c76a60…`, agent `cf2e52fe…`, webmaker `9490b59d…`, arimail `b22868c0…` 는 이전 빌드와 동일) |
|
|
||||||
| `bash -n deploy/*.sh` | 4개 통과(agent-install·bootstrap-hal·hal-restore·module-as-user); shellcheck 미설치 |
|
|
||||||
| 소스 `hal/ui.mjs` sha256 | `904059364106888cab17609e3e9f743d96ea239bae87c0af856980a673eed92d` |
|
|
||||||
|
|
||||||
## 5. bear 배포 (코드 릴리스만, data/config/DB/Vault 무변경)
|
|
||||||
- 사전 상태: current `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5`, PID 1792203(14:21:13Z 다른 세션의 opsAllowCidrs 변경·재시작, config.json `updatedBy cli:hera-ops-access` 14:21:12Z, `config.json.pre-ops-cidrs-20261027T142020Z` 존재).
|
|
||||||
- 사설 백업 `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/`(root 0700, 파일 0600, `sha256sum -c` OK, stage 추출 검증 halVault 4·integrity ok·servers 2·configKeys [publicUrl, portalUrl, opsAllowCidrs], 경로 검증 6/6): archive.tgz `5c0e91f8530403061adf108ec1dbcab684c58854ccd7b60eed20e08aa81490c3`(7: config.json·pre-ops-cidrs 사본·vault.json·vault.key·unit·nginx conf), db.tgz `5182f1a65bb99d8dc084590e3993408fc7dd2129998be14bd536ec03e21c7bd0`(hal.sqlite VACUUM 835,584 B, integrity ok, 28표: servers 2·agents 2·releases 1·jobs 4·agent_jobs 2·mail_services 7), code.tgz `0ec00cb9c4698e4e228d31ffa5432ab0a55e6847da1ff8db721700b1e165b73a`(81, 0.1.1-dfb708e933e5), meta.tgz `75d7ff23f1ebeeccb7ad8da8518c5bbe440c39bd4088197a2d3e236a7d466166`, RESTORE.md `78769ba25e4033a310d9b5c348863946603130fc430ed8356713758e3eaaa1f7`, hal-verify.json `2435b0eb87252cb42a589d98a83b55442f3720c898e51b08c3e81ce1e8d4148d`.
|
|
||||||
- 적용(`~/hera-deploy/bear-apply-g7.sh`, 14:36:34Z): artifact sha 확인 OK → `/srv/ariweb/hal/releases/0.1.1-648247f22415` 추출 → **stage 검증**: 추출된 `hal/ui.mjs` sha256 = 소스(`90405936…`) OK, `node --check` OK, `health.bootstrap.complete` 참조 1, snake_case 참조 0 → `REVISION.json`(by hera-ops-banner-fix) → `register-release --manifest … --approve --by hera-ops-banner-fix`(릴리스 0.1.1 approved, 감사 release.register·release.approve 14:36:34.403Z) → `bootstrap-release --version 0.1.1 --commit 648247f… --sha256 ed957a0a…` → `ln -sfn` current 전환 → `systemctl restart ariweb-hal`. config-set 없음.
|
|
||||||
- 결과: restart→health **1.08초**, PID 1816086, ActiveState active, NRestarts 0, ActiveEnter 14:36:35Z, 프로세스 cwd `/proc/<pid>/cwd` = `/srv/ariweb/hal/releases/0.1.1-648247f22415`, journal 오류 0, 기동 로그 "부트스트랩 완료".
|
|
||||||
- 보존 확인(sha256 전=후): config.json `ae5963d29ae899bc77fcb7c03df7d5802bd493b61fc4b2ab2b7a263a525ba0b7`, vault.json `0e2dc9875f2779a0e7a3c1a56c00321dd2a45b0613a3952b62a8c7505be50bb7`, vault.key `345d92e77c3fcd7853b01024d4aaa3f07d27977ea04a30a287070048afe1bf81`; config readback `opsAllowCidrs=["127.0.0.1/32","::1/128","192.168.10.0/24","192.168.20.0/24","119.203.225.42/32"]`, publicUrl `https://hal21.ariweb.net`, portalUrl `https://my.ariweb.net`, updatedAt 14:21:12.623Z 그대로. data 디렉터리 0700, hal.sqlite 860,160 B·wal 4,132,392 B·shm 32,768 B 그대로. 이전 릴리스 디렉터리 전부 보존(0.1.1, -12958ab6952e, -79b45ca96102, -ac072e6f12a9, -dfb708e933e5).
|
|
||||||
|
|
||||||
## 6. readback
|
|
||||||
- `/api/health`(로컬·`https://hal21.ariweb.net`): ok, version 0.1.1, db true, jobs true, bootstrap `complete=true · releaseVersion 0.1.1 · mainCommit 648247f2241579e947d61ab2928176f678f4b141 · artifactSha256 ed957a0a… · halRunningVersion 0.1.1 · firstAgent ag_01a17bd7201c9a73c157e29968 · manualInstallLocked=true`, instanceId `hal_01a17dcaa5d2cb7866a47b3fa3`.
|
|
||||||
- HAL DB(읽기 전용): bootstrap_state `release_version 0.1.1 · main_commit 648247f… · hal_running_version 0.1.1 · hal_started_at 14:36:35.156Z · first_agent_id ag_01a17bd7… · completed_at 07:21:53.139Z · manual_install_locked 1`; releases 1행 0.1.1 approved by hera-ops-banner-fix(hal_deployed_at null — module.deploy hal 작업이 아닌 CLI bootstrap-release 경로, 이전과 동일); 열린 작업 0; mail_services 7.
|
|
||||||
- Agent heartbeat: 서버 2대 ready, last_seen 14:36:59.073Z(hostinger ag_01a17c99…)·14:36:59.619Z(jitsi ag_01a17bd7…) — 재시작(14:36:35Z) 뒤 재개, 14:38:10Z 까지 heartbeat 6회(bear nginx log). 재시작 순간 Agent long-poll `GET /agent/v1/jobs/next?wait=25` 502 1회(14:36:35Z, 재시작 창) 외 Agent 요청 비-2xx 없음.
|
|
||||||
- **배포된 코드 서버 렌더 검증**(운영자 자격 없음 → `~/hera-deploy/deployed-render.mjs` 가 `/srv/ariweb/hal/current` 의 app/deps/core 만 import, in-memory DB·임시 data dir, 운영 데이터·Vault 미접근): 초기 `부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)` → claimLive 뒤 `(HAL 실행 버전 0.1.1, 첫 Agent 미등록)` → 첫 Agent 등록·승인(complete=true) 뒤 `/ops/`·`/ops/servers`·`/ops/releases` 모두 **배너 없음**, 릴리스 화면 `HAL 실행 버전: <b>0.1.1</b> · 수동 설치 잠김(HAL 전용 운영)`.
|
|
||||||
- 외부(14:37Z): `https://hal21.ariweb.net/api/health` 200 · `/ops/` 200(세션 없음 → 로그인 화면, 본문에 "부트스트랩 미완료" 0건) · `/ops/login` 200 · `/portal/v1/health` 무인증 401; TLS CN=hal21.ariweb.net 만료 2027-01-25, verify 0.
|
|
||||||
- 회귀: `https://my.ariweb.net/api/health` 200 ok 0.1.1(로컬 8910 동일), `/` 302→/login, `/login` 200, http→https 301; hostinger 유닛 ariweb-portal 1500968·ariweb-agent 1483523·webmaker 1041361·arihakdang-mail 1046648·nginx 1414934·(구) ariweb-hal 1059540 모두 active NRestarts 0, `nginx -t` 성공, Agent/포털 journal 오류 0(14:36Z 이후); `https://webmaker.ariweb.net/` 302(첫 프로브 4.5~5.0초, 이후 0.29초 — hostinger 측 콜드 응답, HAL 무관); `https://mail.arihakdang.com/` 200·`/api/health` ok 1.0.11 domains 6; `https://webmail.ibit.kr/` 200·ok 1.0.11 domains 1.
|
|
||||||
|
|
||||||
## 7. 되돌리기
|
|
||||||
- 코드만: `ln -sfn /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 /srv/ariweb/hal/current && sudo systemctl restart ariweb-hal` (이전 릴리스 디렉터리 보존됨). 릴리스 행·bootstrap 행의 main_commit/artifact 는 `cd /srv/ariweb/hal/releases/0.1.1-dfb708e933e5 && ARIWEB_HAL_DATA=/srv/ariweb/hal/data node hal/index.mjs register-release --manifest ~/hera-deploy/dist-dfb708e/manifest.json --approve --by <who> && node hal/index.mjs bootstrap-release --version 0.1.1 --commit dfb708e933e524894822719bce0169008285f4fd --sha256 4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377`.
|
|
||||||
- 데이터까지: `/srv/ariweb/backups/hal-pre-banner-fix-20261027T143608Z/RESTORE.md`(검증 `sudo sha256sum -c SHA256SUMS`).
|
|
||||||
|
|
||||||
## 8. blocker / unknown
|
|
||||||
- 운영자 자격 증명이 없어 실제 운영자 세션으로 콘솔 화면을 열어 보지는 못했다 — 배포된 코드의 서버 렌더 harness(§6)와 회귀 테스트, 배포 파일 sha 일치로 판단. 오즈가 로그인하면 경고가 보이지 않아야 한다(서버 렌더이므로 새로고침만으로 충분, 캐시 삭제 불필요).
|
|
||||||
- `releases.hal_deployed_at` 은 null(HAL 자체는 module.deploy 작업이 아니라 CLI bootstrap-release + current 전환으로 교체) — 이전 배포와 같은 방식.
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 기존 메일 서비스 ↔ HAL(bear hal21) 연동 기록 — 2026-10-27 (Hera)
|
|
||||||
|
|
||||||
대상: `https://mail.arihakdang.com/` · `https://webmail.ibit.kr/` → bear HAL `https://hal21.ariweb.net` 의 관리형 MailService/Node 등록. 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 바꾸지 않았다. 이 문서에 비밀값은 없다(경로·키 이름·해시만).
|
|
||||||
|
|
||||||
## 1. 소스·배포 리비전
|
|
||||||
| 항목 | 값 |
|
|
||||||
|---|---|
|
|
||||||
| 작업 시작 시 origin/main | `ac072e6f12a910ccd60dc7e63a9724363617a46b` (rebase 뒤 `03cb9c0777ddc3dc2003893d3dddd53a01565f2b`) |
|
|
||||||
| 코드 커밋(배포한 HAL) | `79b45ca96102213d2bd30856ef79b6d732758349` — release.mjs build: testsPassed=true, dirty=false, hal artifact sha256 `bf636f847266e319c888f3ba9f40fc8a22c9f5e6f43775d61d967752742db489` |
|
|
||||||
| bear HAL 배포 전 | `/srv/ariweb/hal/releases/0.1.1-ac072e6f12a9` (hal.sqlite servers 0 · agents 0 · ops_accounts 1 · mail_services 표 없음) |
|
|
||||||
| bear HAL 배포 후 | `/srv/ariweb/hal/releases/0.1.1-79b45ca96102` → `current`, `ariweb-hal` 05:28:55Z 재시작(재시작→/api/health 2.6초, NRestarts 0), `/api/health.bootstrap.mainCommit=79b45ca…`, 릴리스 0.1.1 artifact 재등록(approved, by hera-mail-onboard) |
|
|
||||||
|
|
||||||
## 2. 실제 호스트·인스턴스(읽기 전용 조사)
|
|
||||||
| | mail.arihakdang.com | webmail.ibit.kr |
|
|
||||||
|---|---|---|
|
|
||||||
| 호스트 | hostinger `srv1953374` (187.53.133.173, Ubuntu, Node v24.14.1 /opt/arihakdang-node) | `jitsi.ibit.kr` (115.68.194.192, Ubuntu 22.04, Node v22.23.3 /opt/arimail-node) |
|
|
||||||
| 유닛 / 실행 계정 / cwd | `arihakdang-mail.service` (+`-haraka`) / arihakdang / `/srv/arihakdang-mail/current` | `arimail.service` (+`-haraka`) / arimail / `/srv/arimail/current` |
|
|
||||||
| 리비전 | `releases/0.1.1` REVISION.json: Suite arimail 0.1.1, upstream AriMail 1.0.11, 2026-10-26 설치(hostinger HAL 모듈 배포) | `releases/20261024-204656` REVISION `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`, package 1.0.11 |
|
|
||||||
| 설정·비밀 참조 | `/etc/arihakdang-mail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arihakdang-mail/tls/` · `data/setup-token` | `/etc/arimail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arimail/tls/` · `data/setup-token` |
|
|
||||||
| 도메인 등록부 | 6: arihakdang.com(primary, auth www) · ariweb.net(webmaker) · check/solo-mvpcgayc·mvpcu283.ariweb.net(4개, Suite E2E 테넌트, operating=frozen, hostinger 포털 group/mailService 링크) | 1: ibit.kr(primary, auth local) |
|
|
||||||
| 집계(계정=메일함 / 메일 / 저장) | 10 / 195 / 30.4MB(DB 기준; data 디렉터리 545MB — 과거 pre-* DB 백업 포함) | 3 / 0 / 2.1MB |
|
|
||||||
| 리스너(유닛 PID 소유) | 110·143·465·587·993·995(0.0.0.0), 8790·8791(127.0.0.1). 25 없음 | 110·143·465·587·993·995, 8790. 25 없음 |
|
|
||||||
| provider | Resend(inbound/outbound), Haraka 설치됨·비활성 | Resend, Haraka 비활성. journal: `resend._domainkey.ibit.kr` TXT 검증 실패 반복(기존 상태, 변경 없음) |
|
|
||||||
| DNS(공개 리졸버) | A 187.53.133.173 · MX arihakdang.com→inbound-smtp.ap-northeast-1.amazonaws.com | A 115.68.194.192 · MX ibit.kr→inbound-smtp.ap-northeast-1.amazonaws.com |
|
|
||||||
| TLS | LE `CN=mail.arihakdang.com`(+SAN webmail.ariweb.net) 만료 2027-01-12 | LE `CN=webmail.ibit.kr` 만료 2027-01-19 |
|
|
||||||
| nginx / certbot 훅 | `sites-available/mail.arihakdang.conf` → 127.0.0.1:8790, 80→301 https · deploy 훅 `arihakdang-mail.sh`(갱신 시 유닛 재시작) | `webmail.ibit.kr.conf` → 127.0.0.1:8790, 80→301 · 훅 `arimail.sh` |
|
|
||||||
| 기존 제어 평면 | hostinger HAL(`hal.ariweb.net`, 부트스트랩 완료) `module_targets.arimail` 0.1.1 ok, Agent `ag_01a178829f…` 등록 | 없음 |
|
|
||||||
|
|
||||||
두 URL 은 **서로 다른 호스트·다른 AriMail 인스턴스**다(같은 멀티도메인 리스너가 아님).
|
|
||||||
|
|
||||||
## 3. 사전 스냅샷(비밀 제외 sha256, 모두 root 0700/0600, git 밖, `sha256sum -c` 통과, SQLite `integrity_check=ok`)
|
|
||||||
| 위치 | archive.tgz | db.tgz | code.tgz |
|
|
||||||
|---|---|---|---|
|
|
||||||
| hostinger `/srv/arihakdang-mail/backups/pre-hal-onboard-20261027T051350Z/` (RESTORE.md 포함) | `abb731f26e6a06c4…` (32 entries: data·config·domains.json·gateway.env·유닛·nginx·훅) | `1ba2eb9c2ba6c8ad…` (VACUUM INTO 사본 6개: 게이트웨이 + 테넌트 5) | `releases/0.1.1` 트리 106 entries |
|
|
||||||
| jitsi `/srv/arimail/backups/pre-hal-onboard-20261027T051352Z/` | `7e36bcbff719dc36…` (20 entries) | `26035be7a4afdc71…` (사본 1) | `releases/20261024-204656` 66 entries |
|
|
||||||
| bear `/srv/ariweb/backups/hal-pre-mail-onboard-20261027T051353Z/` | `534c9eb1228c011e…` (vault·config·유닛·nginx) | `da848bc05c84ef7d…` (hal.sqlite 사본, servers 0/agents 0/ops_accounts 1) | `releases/0.1.1-ac072e6f12a9` 79 entries |
|
|
||||||
복원 순서는 각 디렉터리의 `RESTORE.md`(메일 복원은 서비스 중단 동반 → 운영자 승인 뒤에만). HAL 되돌리기: `mail-import-rollback --import <id>` 또는 RESTORE.md(hal.sqlite 사본 + current 심링크 `0.1.1-ac072e6f12a9`).
|
|
||||||
|
|
||||||
## 4. discovery → dry-run → apply → readback (bear, HAL CLI, 시스템 신원 `cli:hera`)
|
|
||||||
- discovery 문서: `scripts/mail-discover.mjs`(sudo, 읽기 전용) — sha256 `1cfb2042138d1de3…`(hostinger, 05:22:06Z) · `18cda7182ac68dd7…`(jitsi, 05:22:07Z). `validateDiscovery` ok(비밀값 0, 메일 주소 0).
|
|
||||||
- 서버(노드) 생성: `srv_01a17bd4dbcdb55877f2d9fcab` "srv1953374 (mail.arihakdang.com)" role mail · `srv_01a17bd4dc2a2bc3050054fee0` "jitsi.ibit.kr (webmail.ibit.kr)" role mail — 둘 다 pending.
|
|
||||||
- dry-run: `mimp_01a17bd4dbd076ebd300d5a681`(create 6·reject 0) · `mimp_01a17bd4dc2e21a185a4cefc09`(create 1·reject 0). DB 변경 없음(mail_services 0).
|
|
||||||
- apply: `mimp_01a17bd5f2685f8929d7638a28` → 생성 6(`mls_01a17bd5f26934dd18266db83a` arihakdang.com, `…f26a6e17e69ff81adb` ariweb.net, `…f26afc33723ce9c622` check-mvpcgayc, `…f26a1478f1bafcdca7` solo-mvpcgayc, `…f26aa7eab73a48883e` check-mvpcu283, `…f26bbb6aa9e045b23b` solo-mvpcu283) · `mimp_01a17bd5f2eaf5667cbc127dac` → 생성 1(`mls_01a17bd5f2eaf032215343d7cb` ibit.kr).
|
|
||||||
- 멱등·충돌 검증: 같은 문서 재적용 `mimp_01a17bd5f35c29928b0609ec45` = update 6·create 0(id 유지) · jitsi 문서를 hostinger 서버에 적용 시도 `mimp_01a17bd5f3c482c2797e178e46` = rejected(ibit.kr collision) — 부분 적용 없음.
|
|
||||||
- readback(`hal/index.mjs mail-services`, 05:33Z): total 7 · active 7 · 도메인 7(각 1) · duplicates [] · secretLeaks 0 · perServer {hostinger 6, jitsi 1} · 모두 kind legacy_managed · ownership **unassigned_legacy_managed**(ariweb.net 테넌트의 hostinger 포털 group 링크는 domain_json 증거로만 보관, 매핑하지 않음). 각 행: 서버·유닛·appDir·리비전·집계·리스너·TLS 만료·DNS MX·nginx·secretRefs(참조 4건, 값 없음).
|
|
||||||
- 감사: `server.create` 2 · `mail_service.import_dry_run` 2 · `mail_service.import` 13(immutable) · `mail_service.import_rejected` 1 · `release.register/approve`. hal.sqlite `integrity_check=ok`, 마이그레이션 `hal_0003_mail_services` 적용(기존 표 변경 없음).
|
|
||||||
|
|
||||||
## 5. Node Agent / heartbeat
|
|
||||||
- jitsi: 토큰 `hal/index.mjs server-token --server srv_01a17bd4dc2a2…`(1회, 화면·로그 미기록) → `https://hal21.ariweb.net/agent/v1/install.sh --role mail --label jitsi.ibit.kr`. 결과 Agent `ag_01a17bd7201c9a73c157e29968`(managed, 05:30:58Z), 유닛 `ariweb-agent`(master, `/srv/ariweb/agent/current` 0.1.1) active NRestarts 0, 비밀은 `/srv/ariweb/agent/data` 보관소만. 설치가 만든 것: `/srv/ariweb/agent`, `/srv/ariweb/backups`, `/var/www/ariweb-sites`, `/etc/systemd/system/ariweb-agent.service`, `/etc/sudoers.d/ariweb-agent`, `/etc/nginx/conf.d/awstats.conf`(log_format, `nginx -t` 성공·graceful reload). **arimail 유닛·PID·리스너 변화 없음**(PID 3304850 / 3304871 그대로).
|
|
||||||
- heartbeat readback(05:32:59Z): servers.last_seen 갱신, agent_version 0.1.1, metrics_samples 5건, module_targets `arimail actual 1.0.11 health ok`(REVISION.json 없음 → package.json), 시계 차이 25,053ms(경고 기준 60초 미만 — 호스트 시각 확인 권장). `mail_services`(ibit.kr) `last_sync_source=agent`, health ok, observedVersion 1.0.11.
|
|
||||||
- hostinger: hal21용 Agent **설치하지 않음** — 기존 Agent 가 hostinger HAL 에 등록되어 있고 같은 유닛을 두 제어 평면이 배포하면 §34 "하나의 HAL" 위반. 서버 행은 pending(Agent 미연결), 메일 서비스 6건은 `last_sync_source=import`(관측 전용). 제어 평면 이전은 사람 결정.
|
|
||||||
- 서버 승인은 하지 않았다(첫 Agent 승인이 부트스트랩을 닫고 수동 설치를 잠근다 — 운영자 결정).
|
|
||||||
|
|
||||||
## 6. Data Plane 독립·회귀(적용 뒤)
|
|
||||||
- HAL 재시작 구간(05:28:5x) 동안 외부 프로브: `mail.arihakdang.com /api/health` 200 · `webmail.ibit.kr /api/health` 200 · IMAPS 993 `* OK` 연속 유지(12회, 실패 0). AriMail 유닛은 HAL 을 참조하지 않는다(EnvironmentFile·유닛 파일 sha256 변화 없음).
|
|
||||||
- 두 호스트 사후 점검: 유닛 active/enabled, NRestarts 0, 시작 시각·PID 변화 없음(hostinger 1046648/1046634, jitsi 3304850/3304871) · 리스너 동일 · `/api/health` {ok, 1.0.11, domains 6 / 1} · DB 계정/메일 수 동일(2+2+2+1+2+1 / 189+6, 3 / 0) · gateway.env·config.json·domains.json·유닛·nginx vhost·fullchain.pem sha256 변화 없음 · `nginx -t` 성공 · journal 60분 오류 0(hostinger) / 1(jitsi, 기존 Resend DKIM TXT 검증 실패 반복) · outbound_queue 0 · Haraka 비활성 그대로.
|
|
||||||
- 외부 프로토콜(05:33Z): 두 호스트 모두 http→https 301, https 200, 587 STARTTLS·465·993·995 인증서 일치(verify 0), IMAPS `* OK`·POP3S `+OK`·SMTPS `220` greeting, 25 닫힘, MX/A 변화 없음.
|
|
||||||
- hostinger HAL 제어 평면 변화 없음(`module_targets.arimail` 0.1.1 ok, Agent 1051076 active).
|
|
||||||
- HAL 외부: `/api/health` 200, `/status` 200, `/` → `/ops/` 302, `/ops/mail` 200(로그인 화면), `/ops/api/mail-services` 401(운영자 세션 필요), 인증서 `hal21.ariweb.net` 2027-01-25.
|
|
||||||
|
|
||||||
## 7. 테스트
|
|
||||||
`node test/run.mjs`: 21 파일 144 건 통과·실패 0(신규 `hal-mailimport` 7, `mail-discover` 2). `bash -n deploy/*.sh` 통과(shellcheck 미설치). `scripts/release.mjs build/verify` 통과.
|
|
||||||
|
|
||||||
## 8. 바꾸지 않은 것 · blocker · 미확인
|
|
||||||
- 바꾸지 않음: DNS/A/MX/TXT, 인증서, AriMail 코드·유닛·env·config·domains.json·DB·메일·계정, 리스너, Haraka 상태, hostinger HAL/포털/Agent, bear nginx. 테스트 메일 발송·임시 계정 생성 없음.
|
|
||||||
- blocker(사용자 조작 필요): (1) hal21 운영자 자격 증명 없음 → 운영자 API/콘솔 readback·서버 승인·접근권 발급은 수행하지 않았다(readback 은 HAL DB/CLI 로 수행). (2) hostinger 의 제어 평면(hal.ariweb.net ↔ hal21) 결정 전에는 hostinger Agent 를 hal21 에 등록하지 않는다. (3) 4개 E2E 테넌트(frozen)와 ariweb.net 의 소유 조직 매핑은 포털 그룹 대조 뒤 운영자가 지정.
|
|
||||||
- 미확인: jitsi 시계 차이 25초(원인 미조사), ibit.kr Resend DKIM TXT 미검증(기존).
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# 포털·hostinger Node Agent 제어 평면 이전 기록 — hal.ariweb.net → hal21.ariweb.net (2026-10-27, Hera)
|
|
||||||
|
|
||||||
대상: hostinger(`srv1953374`, 187.53.133.173)의 Ariweb Portal 0.1.1(`https://my.ariweb.net`)과 Node Agent 의 제어 평면을 hostinger 구 HAL(`hal.ariweb.net`)에서 bear HAL21(`https://hal21.ariweb.net`)로 옮기고 실제 readback 으로 증명한 기록. Webmaker·포털 데이터·고객 계정·사이트·세션·DB·Vault·DNS·TLS·메일은 변경하지 않았다. 비밀값은 어디에도 출력하지 않았다(파이프로만 이동).
|
|
||||||
|
|
||||||
## 1. 리비전
|
|
||||||
| 항목 | 값 |
|
|
||||||
|---|---|
|
|
||||||
| 작업 시작 origin/main(rebase 뒤) | `0b9c70e90c7278e895de54e75e3d07f706c703bf` (Neo 의 Webmaker 연동 커밋 `12958ab`·`0b9c70e` 포함 확인, worktree clean) |
|
|
||||||
| 코드 커밋 = 배포한 HAL / 등록 릴리스 | `dfb708e933e524894822719bce0169008285f4fd` — HAL CLI `config-set`·`module-deploy`·`job` + 테스트 3건 |
|
|
||||||
| 릴리스 manifest | version 0.1.1, mainCommit `dfb708e…`, testsPassed=true, dirty=false. artifact sha256: hal `4e419a7293dc5be07936db58c8cf70dc1c7972f52c11787a9455029eff677377` · portal `32c76a6047b2a7d74de0929c993984040cb1263044af2593abd399ed7daff04e`(05:28Z 등록본과 동일) · agent `cf2e52fe23496b126248b65d3bb7e94106db573559392bd20f365f1b970e315f` · webmaker `9490b59dc1aca3d953f907bb7e5b447901a4558a7275913ffb1cb03e2c944caa` · arimail `b22868c08d2551e7d10891cd4858ee0525ac6529e6ece965881d601c20bde983` |
|
|
||||||
| 포털 코드 | 운영 포털(2026-10-26 14:30:42Z 설치, 빌드 3951be5)과 HEAD 의 portal/lib/lang/catalog 경로 diff 없음 → 같은 0.1.1 재배포(제어 평면 이전 목적) |
|
|
||||||
| HAL21 이전 | `/srv/ariweb/hal/releases/0.1.1-12958ab6952e`(Neo 09:02Z 수동 설치, PID 1314165), bootstrap mainCommit `12958ab…`, releases 행 main_commit `79b45ca…`(등록부와 실행본 불일치) |
|
|
||||||
| HAL21 이후 | `/srv/ariweb/hal/releases/0.1.1-dfb708e933e5` → `current`, `ariweb-hal` 09:31:52Z 재시작(재시작→/api/health 1.11초, PID 1360348, NRestarts 0, instanceId `hal_01a17cb3add236d2c112f3df1a`), bootstrap mainCommit `dfb708e…`/artifact `4e419a72…`, releases 행 0.1.1 approved(by hera-portal-link 09:31:51.753Z), config `portalUrl` `http://127.0.0.1:8910` → `https://my.ariweb.net`(감사 config.update, actor cli:hera-portal-link) |
|
|
||||||
| hostinger 포털 | `/srv/ariweb/portal/releases/0.1.1`(REVISION installedAt 2026-10-27T09:33:50.021Z, by master) → `current`; 이전 릴리스 `0.1.1.prev-20261027093350` 보존(.previous.json), 유닛 `ariweb-portal` PID 1500968(09:33:50Z), 127.0.0.1:8910 |
|
|
||||||
| hostinger Agent | `/srv/ariweb/agent/releases/0.1.1-12958ab6952e`(Neo 09:03Z) 그대로, `ag_01a17c99f0102ce24409dc258a` → `https://hal21.ariweb.net`, PID 1483523, NRestarts 0 |
|
|
||||||
|
|
||||||
## 2. 읽기 전용 preflight(09:13~09:18Z)
|
|
||||||
- DNS `my.ariweb.net` A 187.53.133.173 — 1.1.1.1·8.8.8.8·권한 서버 ns1/ns2.hosting.co.kr(TCP) 일치. `hal21.ariweb.net` 121.152.149.45. `hal.ariweb.net` 공개 A 없음(hostinger `/etc/hosts` 127.0.0.1 매핑만). TLS my: CN=`*.ariweb.net` 만료 2027-01-13T07:57:11Z verify 0; hal21: CN=hal21.ariweb.net 만료 2027-01-25T02:03:16Z.
|
|
||||||
- hostinger 유닛(모두 active/enabled, NRestarts 0): ariweb-hal PID 1059540(10-26 14:33:18Z) · ariweb-portal PID 1059582(14:33:19Z, cwd `/srv/ariweb/portal/current`) · ariweb-agent PID 1483523(10-27 09:04:12Z) · webmaker PID 1041361(14:00:23Z) · arihakdang-mail PID 1046648(14:09:30Z). 리스너 127.0.0.1:8900/8910/8790/3080, nginx 80/443. `/etc/hosts`: `127.0.0.1 hal.ariweb.net my.ariweb.net webmail.ariweb.net mail.arihakdang.com`.
|
|
||||||
- Agent(hostinger) 연결 HAL: 이미 hal21(Neo 09:03:45.729Z 재등록, label `srv1953374 (mail+webmaker)`, drop-in `90-hal21-webmaker.conf`). hal21 의 jobs/agent_jobs 0건. 구 HAL: server `srv_01a178829d5848f93a15f93a17` status offline(last_seen 09:03:14.274Z), agent `ag_01a178829f1c3d88381d2c0c75` active(heartbeat 없음), 열린 jobs 0·agent_jobs 0, module_targets 7(목표 0.1.1), 운영자 ariweb-ops 1, vault 3(service:agent, service:portal, service:hal).
|
|
||||||
- 포털 config.json 값(비밀 아님): mailer·mailFrom·operatorEmail 만 → halUrl 은 코드 기본값 `https://hal.ariweb.net`(=/etc/hosts → 구 HAL). 포털 vault 이름 11: service:hal, service:portal, sso:webmaker, mailer:resend-key, arimail:* 7.
|
|
||||||
- 포털 DB(portal.sqlite, wal): integrity ok, 표 53, users 7(active), groups 10, memberships 13, sites 3, domains 6, mail_services 4(cancelled), mail_servers 1, mailboxes 6, jobs 4(failed 2 backup.scheduled, needs_user 2 mail.activate), sessions 7(live 7), audit_log 71, mail_usage_log 899.
|
|
||||||
- HAL21 DB: integrity ok; 서버 2 모두 ready(hostinger 승인 09:04:02.227Z by `cli:neo-webmaker-link`, jitsi 07:21:53.138Z by `op_01a17bae6c0b70acd18b16f029`), bootstrap complete 07:21:53.139Z(firstAgent `ag_01a17bd7201c9a73c157e29968`, manual_install_locked 1); agents 2 active; module_targets 12(hostinger: agent·arimail·backup·hal·observability·portal·webmaker 모두 actual 0.1.1 ok); 운영자 admin-hal 1(세션 live 1, 마지막 09:08:59Z — 다른 세션 소유); mail_services 7.
|
|
||||||
- Webmaker baseline(`scripts/webmaker-inventory.mjs`, 읽기 전용): 회원 12(고유 9·SSO 3), 프로젝트 10(배포 3), 도메인 2, 연결 3; 포털 users 7·groups 10·sites 3(linked 3)·domains 6·mailServices 4; 판정 linked_consistent 3·unlinked_native 7, 충돌 0·누락 0. 공개 사이트 디렉터리 10.
|
|
||||||
|
|
||||||
## 3. 백업(Gate 2, root 0700/0600, git 밖, `sha256sum -c` OK, stage 추출·readback 검증)
|
|
||||||
**hostinger `/srv/ariweb/backups/pre-hal21-migrate-20261027T092204Z/`** (RESTORE.md 먼저 작성, 경로 존재 검증 12/12 ok)
|
|
||||||
- archive.tgz `dca689d8c0fcb5d38455351b5db102e31a5d846b4f4735992627edfa7751bf63` (21 entries: portal/agent/hal data 의 config·vault.json·vault.key·agent.json·jobs, 유닛 3+drop-in, nginx ariweb-portal.conf·ariweb-hal.conf, /etc/hosts)
|
|
||||||
- db.tgz `d36000015028b6cea72173a8365cbd388f3b17c8b81e7daecb942dd0ac038e9c` (VACUUM INTO 일관 사본: portal.sqlite 1,028,096 B integrity ok 53표 — 원본 db 1,032,192 / wal 4,140,632 / shm 32,768 B; hal.sqlite 888,832 B ok 25표)
|
|
||||||
- code.tgz `7a9c56e39a63ad79706eaa046869e122c135bb0aa85f7420d74d3536b2f7c08e` (296 entries: portal·agent·hal current 실경로 트리), meta.tgz `7be814eb0030d3ccfc4b1fe1e4d778f70e3a56868158ed5ce66805a4ded2b76c`, RESTORE.md `3954ce32299bc51c86c6ccb6e3a157730d4de11ec862ea10c12e404d578b1300`, portal-verify.json `aa212574b7d3dc1c7266c456379a89b0a71e3e1bf2eb00cfe92f248aae953788`, hal-verify.json `91c33cc8b0d6155bd481b02ca7258f4f589884bb841b4de07e6ed067106eee55`
|
|
||||||
- stage 검증: portalVault 11 · agentVault 1 · halVault 3 · agent.json id `ag_01a17c99…` · 사본 integrity ok/ok.
|
|
||||||
**bear `/srv/ariweb/backups/hal-pre-portal-link-20261027T092207Z/`**
|
|
||||||
- archive.tgz `97b880370d4a94a64d712ba63d8b5ea072a847f893bf977435a1160d6dbcc9ed` (6), db.tgz `1deb62fdca1a77792c3367cb27668d297a4ec5d83e41c0d6c256d7c3b9425a32` (hal.sqlite 548,864 B ok 28표), code.tgz `e543244242541faadb067e66e48db32c128fcecfe23d3b1a15c830abb1f6d9ba` (80), meta.tgz `4c1078937387148e69ed602be66a528de14187eb04040d2908a7744a3c8cbe5c`, RESTORE.md `b1245a65fc82ad8a38866211a3cb261069bd77ef928725fc4921838dcc313ec6`, hal-verify.json `aebb63337c569abc821a9b88750b48d9cd0df45acedb80466c2d97f74135961f`; stage 검증 halVault 2 · servers 2 · configKeys publicUrl,portalUrl.
|
|
||||||
**source**: origin/main `0b9c70e…` git bundle(작업 PC scratchpad, 0600) sha256 `d55c924495b08337efb6fb1a3385bf5648a07b97231284f4d123bba2080e67eb` + clone.
|
|
||||||
구 HAL 등록 Agent 데이터는 Neo 백업 `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`(RESTORE.md 의 B 절차가 가리킴).
|
|
||||||
|
|
||||||
## 4. 적용(모두 지원 경로, 직접 SQL 없음)
|
|
||||||
1. 09:31:51Z bear: `register-release --manifest … --approve --by hera-portal-link`(artifact 5개 복사·등록부 갱신) → `bootstrap-release --version 0.1.1 --commit dfb708e… --sha256 4e419a72…` → `config-set --portalUrl https://my.ariweb.net`(감사) → current 전환·`ariweb-hal` 재시작(1.11초). 재시작 구간 외부 프로브 8회: my=200·mail=200·hal21=200·sample=200 전부, webmaker 302×6/000×2(프로브 4초 타임아웃; 직후 5회 재확인 302, 0.29초 — hostinger 호스트라 HAL 재시작과 무관). 두 Agent heartbeat 재개(09:32:29Z·09:32:45Z).
|
|
||||||
2. 09:33:44Z `link-portal`(bear vault service:portal·service:hal 09:33:45.756Z 갱신) → stdout 을 `sed` 로 값만 추려 ssh 파이프로 hostinger `lib/vault.mjs` 에 전달 → 포털 vault `service:hal` 09:33:46.334Z 교체(replacedExisting true), 구 연동의 미사용 이름 `service:portal` 제거. 값은 화면·로그·셸 변수에 없음(두 node 프로세스 사이 파이프만).
|
|
||||||
3. 09:33:46Z 포털 `config.set({publicUrl:'https://my.ariweb.net', halUrl:'https://hal21.ariweb.net'}, 'cli:hera-portal-link')`(config.json 198→292 B, 0600; webmakerUrl·arimailUrl 기본값 유지).
|
|
||||||
4. 09:33:47Z bear `module-deploy --module portal --version 0.1.1 --servers srv_01a17bd4dbcdb55877f2d9fcab --by hera-portal-link --reason …` → HAL 작업 `j_01a17cb5716c669450412967f7`(module.deploy, created_by cli:hera-portal-link, actor operator) → 자식 `j_01a17cb5719cc5385804317f08`(module.deploy.target) → Agent 작업 `aj_01a17cb573916795ab7bc67a54`(module.install, 09:33:49.009→09:33:52.775Z). 결과: actualVersion 0.1.1, previousVersion `0.1.1.prev-20261027093350`, health ok(200, 10 ms), releaseDir `/srv/ariweb/portal/releases/0.1.1`, runUser master; 부모 done 09:33:53.325Z(precheck→fanout→collect, overall done 1/1). 감사 `deploy.module.request`·`deploy.module`. 포털 재시작 09:33:50Z(vault 교체 뒤 4초 — 그 사이 포털→HAL 호출 없음, 포털 journal 오류 0).
|
|
||||||
|
|
||||||
## 5. readback
|
|
||||||
- HAL21 `module_targets.portal`(hostinger): target 0.1.1 · actual 0.1.1 · health ok · last_job `j_01a17cb5719cc5385804317f08` · last_check 09:34:45Z(heartbeat 로 계속 갱신). agent_jobs 1(done). jobs 2(done). outbox 0(운영자 작업은 포털 이벤트를 만들지 않음).
|
|
||||||
- **Portal→HAL21 인증 읽기(09:35:09Z, 포털의 실제 `portal/hal/client.mjs` + 실제 config.json·vault 로)**: `/portal/v1/health` ok(version 0.1.1, instanceId `hal_01a17cb3…`, bootstrap complete, mainCommit dfb708e…) · `/portal/v1/servers` 2건(둘 다 ready/ok, hostinger modules webmaker·arimail·hal·portal·agent·observability·backup = 0.1.1/ok) · `POST /portal/v1/servers/pick {role:web}` → null(§7 blocker) · `GET /portal/v1/jobs/j_01a17cb5716c…` 200 · `GET /portal/v1/jobs/j_none` → `job_not_found` 404(작업 계약). bear nginx access log: 187.53.133.173 → 위 5요청 200/200/200/200/404, Agent heartbeat 67회/5분. HAL21 journal 오류 0(재시작 이후).
|
|
||||||
- 바이트 보존(sha256, 배포 전=후): 유닛 `ef800cf0…1e59`(Agent 가 템플릿으로 다시 썼으나 내용 동일, diff 없음) · nginx ariweb-portal.conf `d7e8e1c7…7167` · fullchain `6650b903…b780` · portal vault.key `6a6af7d6…0094` · agent.json `335cc9e2…b524` · agent vault.json `393fe23d…4d64` · /etc/hosts `eafc0116…6c02`. config.json `a703ce78…`→`66fcb558…`, vault.json `8b3598bc…`→`a7e9e8ae…` 는 3·2단계의 의도된 변경(배포 자체는 data 디렉터리를 건드리지 않음). DB 원본 크기 동일(1,032,192/4,140,632/32,768).
|
|
||||||
- 포털 DB 전후: integrity ok/ok, 표 53/53, 차이는 `mail_usage_log` 899→915(사용량 수집 주기 작업)뿐; users 7·groups 10·memberships 13·sites 3·domains 6·mail_services 4·mailboxes 6·jobs 4·sessions 7(live 7) 동일. vault 이름 11→10(service:portal 제거·service:hal 교체), config 키 +publicUrl·+halUrl.
|
|
||||||
- Webmaker 전후 동일(회원 12·프로젝트 10·배포 3·연결 3, linked_consistent 3·unlinked_native 7, 충돌 0·누락 0, apply no-op), webmaker PID 1041361·NRestarts 0 유지, 공개 사이트 10. AriMail health ok 1.0.11 domains 6, webmail.ibit.kr ok domains 1.
|
|
||||||
- 외부(09:35Z): my http→301 https, https / →302 /login, /login 200 `로그인 · Ariweb 고객 포털`, /api/health ok 0.1.1, TLS verify 0; hal21 /api/health ok·/ops/ 200·/portal/v1/health(무인증) 401; webmaker 302, sample 200, guide 200, mail.arihakdang.com 200, webmail.ibit.kr 200. 유닛 6개 active/enabled NRestarts 0, nginx -t 성공, journal 오류: 포털 0, Agent 1(09:31:52 HAL 재시작 중 502 1회), 구 HAL 0, webmaker 0, mail 0.
|
|
||||||
- 구 HAL(hostinger): 계속 실행 중(PID 1059540)이나 서버 offline(last_seen 09:03:14Z, Agent heartbeat 없음), 열린 작업 0 — hostinger Agent 의 active owner 아님.
|
|
||||||
|
|
||||||
## 6. 테스트·빌드
|
|
||||||
`node test/run.mjs` 22파일 149건 통과·실패 0(코드 커밋 뒤 release build 안에서 1회, 문서 커밋 뒤 재실행). 신규 `test/hal-cli.test.mjs` 3건. `scripts/release.mjs build/verify` 통과(artifact 5개 ✔). `bash -n deploy/*.sh` 통과, shellcheck 미설치.
|
|
||||||
|
|
||||||
## 7. 되돌리기·blocker
|
|
||||||
- 되돌리기: hostinger `RESTORE.md`(A 포털 연결만 → B Agent 구 HAL 복귀(Neo 백업) → C 구 HAL 데이터 → D 유닛·nginx·hosts), bear `RESTORE.md`(vault·config·DB 사본·current `0.1.1-12958ab6952e`), 포털 코드 `ln -sfn /srv/ariweb/portal/releases/0.1.1.prev-20261027093350 /srv/ariweb/portal/current && systemctl restart ariweb-portal`, HAL 작업 되돌리기는 운영자 콘솔 모듈 되돌리기.
|
|
||||||
- blocker/unknown: (1) hal21 운영자 자격 증명 없음 — 서버 승인·역할 변경·콘솔 readback 은 수행하지 않음(승인·부트스트랩은 이미 완료 상태를 readback). (2) **hostinger 서버 행 role 이 `mail`**(메일 가져오기 때 생성) → `pickServer(web)` null → 포털 관리형 사이트 신규 배포·백업 저장 대상이 없다. 운영자가 `PATCH /ops/api/servers/srv_01a17bd4dbcdb55877f2d9fcab {role:'control'}`(재인증 필요)로 바꿔야 한다. (3) 구 HAL `ariweb-hal`(hostinger)은 중지하지 않았다(폐기·보존은 사람 결정). `/etc/hosts` 의 `hal.ariweb.net` 매핑도 그대로. (4) Agent 의 3개 보존 정책으로 코드 디렉터리 `0.1.1.prev-20261026141643` 가 정리됨(데이터 아님). (5) HAL→포털 이벤트 경로(`/internal/hal-events`)는 포털 발 작업이 없어 실측하지 못함(portalUrl 은 설정됨). (6) jitsi 서버의 `module_targets.webmaker` actual null(Neo 작업 범위).
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# webmaker.ariweb.net ↔ HAL 연동 점검·dry-run 기록 (2026-10-27)
|
|
||||||
|
|
||||||
작업 승인: 오즈. 실행: Claude Fable 5.1(세션 ariweb-suite-8a). 저장소 기준 main `ac072e6` 에서 도구 추가(`scripts/webmaker-inventory.mjs`, `test/webmaker-inventory.test.mjs`). 비밀값(해시·토큰·키·세션)은 어디에도 출력·기록하지 않았다.
|
|
||||||
|
|
||||||
## 1. 실제 호스트 (DNS·운영 위치, 개발 저장소로 추정하지 않음)
|
|
||||||
|
|
||||||
| 항목 | 실제 값 |
|
|
||||||
|---|---|
|
|
||||||
| `webmaker.ariweb.net` A | 187.53.133.173 = hostinger(`srv1953374`, Ubuntu 24.04.4, Node v22.23.2, nginx 1.24.0) |
|
|
||||||
| 서비스 | systemd `webmaker.service`(User master, ExecStart `node server.js`, 127.0.0.1:3080) + `webmaker-setup.service` |
|
|
||||||
| 코드 | `/srv/webmaker/current → /srv/webmaker/releases/0.1.1`(프로세스 cwd 동일), `REVISION.json` = 모듈 0.1.1 / 상류 1.7.3, HAL 작업으로 2026-10-26 14:00:21Z 설치 — 즉 **이미 Ariweb Suite Webmaker(도입 모듈)** 이며 교체 대상이 아니다 |
|
|
||||||
| 데이터 | `DATA_ROOT=/data/webmaker`(0700, 691M) · `DATA_DIR=/data/webmaker/sites` · `SITES_ROOT=/var/www/webmaker-sites`(969M) · `STATS_LOG=/var/log/nginx/webmaker-sites.log` · 설정 `/data/webmaker/config.json`(값은 보관소) |
|
|
||||||
| 저장 구조 | JSON 파일(원자적 저장 + `.bak`): `users.json`(회원·scrypt 해시) · `sites/<id>.json`(프로젝트·배포 이력·git 변경) · `domains.json` · `deploy-targets.json` · `ariweb-links.json`(포털 연결 등록부) · `mail-servers.json` · `credits.json` 등. 로컬 git `/data/webmaker/git/<id>`(7개) → Gitea `127.0.0.1:3030`(hostinger 로컬, 저장소 `webmaker/sites`, 브랜치 `sites/<id>/main`) |
|
|
||||||
| HAL | hostinger 자체 HAL(`ariweb-hal`, 127.0.0.1:8900, nginx `hal.ariweb.net` — 공개 DNS 없음) 부트스트랩 완료, 서버 `srv_01a178829d5848f93a15f93a17`(control, ariweb.net) + Agent `ag_01a178829f1c3d88381d2c0c75` 활성, `module_targets.webmaker` 0.1.1/0.1.1 health ok |
|
|
||||||
| bear HAL(`hal21.ariweb.net`) | 운영자 1·서버 0·Agent 0·부트스트랩 미완료. 운영자 로그인 정보는 이 세션에 없다(피어 세션 ariweb-suite-36 도 만들지 않았다고 답함) |
|
|
||||||
|
|
||||||
## 2. inventory (원본, 안정 식별자)
|
|
||||||
|
|
||||||
| 자원 | 수 | 식별자·비고 |
|
|
||||||
|---|---|---|
|
|
||||||
| Webmaker 회원 | 12 | 고유 9(`oz-admin`, `admin`, `guest01`, `icqmomo`, `keytron`, `thewings`, `mingks01`, `visionplus`, `yunmi7266`) + 포털 SSO 3(`aw-01a17894-01a17894`, `aw-01a17895-01a17895`, `aw-01a1789f-01a1789f`) |
|
|
||||||
| 프로젝트(사이트) | 10 | 32-hex id. 배포 이력 있음 3(`291da329…` sample.ariweb.net 14회, `41ca22fd…` guide.ariweb.net 3회, `572a1d94…` hs-company 1회), 로컬 git 7, 포털 연결 3(검증 사이트) |
|
|
||||||
| 도메인(Webmaker 등록부) | 2 | sample.ariweb.net, guide.ariweb.net(모두 icqmomo, active). `hs-company.ariweb.net` 은 프로젝트 도메인 목록에만 있고 DNS 는 bear(121.152.149.45)를 가리킴 |
|
|
||||||
| 배포 대상 | 1 | `a7ae448c0593` 기본서버-자체호스팅(local, `/var/www/webmaker-sites`, `https://webmaker.ariweb.net`) |
|
|
||||||
| 메일 서버 | 0 | `mail-servers.json` items 비어 있음(메일 연동은 `config.mail.provider=arimail`, `https://webmail.ariweb.net`) |
|
|
||||||
| Gitea refs | 13 heads | `sites/<id>/main` 7개 + `changes/*` 5개 + `main` |
|
|
||||||
| 사이트 디렉터리 | 10 | 파일 수: 291da 13 · 41ca 16 · 4cbe 61 · 572a 31,780(961M) · 7e10 8 · b354 4 · cf7c 4 · 검증 3개 0 |
|
|
||||||
| nginx | 3 | `webmaker.ariweb.net.conf`, `wh-291da…conf`, `wh-41ca…conf` (+`wh-default.conf`) |
|
|
||||||
| 포털 DB | users 7 · groups 10 · sites 3 · domains 6 · mail_services 4 · deployments 0 | 사이트 3개 모두 `webmaker_project_id` 로 검증 프로젝트에 연결 |
|
|
||||||
|
|
||||||
## 3. 사전 스냅샷 (hostinger, 사설, 비밀 미출력)
|
|
||||||
|
|
||||||
위치 `/srv/webmaker/backups/pre-hal-link-20261027T045715Z/`(master 0700, 파일 0600). 내용: `data-webmaker.tar.gz`(708,460,310B) · `sites-root.tar.gz`(76,467,803B) · `checksums-source.txt`(원본 32,608 파일 sha256) · `conf/`(webmaker·webmaker-setup 유닛, nginx 3+1 파일, sites-enabled 목록, 유닛 상태) · `gitea-refs.txt`(13) · `local-git-heads.txt` · `hal.sqlite`·`portal.sqlite`(VACUUM INTO 일관 사본) · `MANIFEST.sha256` · `ROLLBACK.md` · `dry-run.json`.
|
|
||||||
MANIFEST.sha256 자체 sha256 앞 16자리 `b3d7d97e66ace583`, data tar `29d13e5846871a63`, sites tar `8690f9de6e1246fd`, checksums `8d01acf5721ac7ec`.
|
|
||||||
복원 가능성 검증: 두 tar 를 임시 디렉터리에 풀어 `checksums-source.txt` 32,608건 전부 대조 → `RESTORE_TEST_OK 32608 files`(04:58:32Z), 임시 디렉터리 삭제. 되돌리기 절차는 `ROLLBACK.md`(정지 → 현재 보존 → tar 복원 → 설정 복원 → 기동 → 체크섬·refs 대조).
|
|
||||||
|
|
||||||
## 4. 연동 경로 조사 (코드·테스트)
|
|
||||||
|
|
||||||
- HAL 은 사이트·계정을 모델링하지 않는다(테이블: servers·agents·modules·module_targets·releases·jobs…). Webmaker 의 "HAL 리소스" 는 모듈 등록(`module_targets`)뿐이며 이미 등록·건강 ok.
|
|
||||||
- 포털 ↔ Webmaker 연결은 양쪽 등록부(`ariweb-links.json` ↔ `sites.webmaker_project_id`)로만 이뤄지고, 기존 프로젝트 연결은 포털 사용자 세션의 `사이트 생성(projectMode=existing)` 경로뿐이다(`portal/sites/service.mjs`). Webmaker 내부 API `/api/internal/ariweb/*` 는 포털 서비스 서명 필요. "콘텐츠 가져오기" 는 이번 버전 미지원(`portal/sites/ui.mjs`).
|
|
||||||
- 비밀 입력 없이 쓸 수 있는 경로: 읽기 전용 inventory/dry-run(`scripts/webmaker-inventory.mjs`)뿐. 고유 회원 9명을 포털 계정으로 만드는 일은 사용자 자격 증명·동의가 필요해 하지 않았다(해시·세션 재사용 금지).
|
|
||||||
|
|
||||||
## 5. dry-run 결과 (hostinger 실데이터, 읽기 전용, `dry-run.json` sha256 `e94116e71a5c1a81…`)
|
|
||||||
|
|
||||||
| 판정 | 수 |
|
|
||||||
|---|---|
|
|
||||||
| linked_consistent(양쪽 등록부 일치) | 3 (`85e1859a…`→`s_01a1789f…`, `899f5bbe…`→`s_01a17895…`, `ad015867…`→`s_01a17894…`) |
|
|
||||||
| unlinked_native(Webmaker 고유, 변경 대상 아님) | 7 |
|
|
||||||
| 충돌(link_mismatch·duplicate·한쪽만·SSO 신원 누락·도메인 불일치·HAL 버전/건강) | **0** |
|
|
||||||
| 누락(missing_project·Gitea ref·HAL 등록) | **0** (Gitea `sites/<id>/main` 7/7, 마지막 푸시 커밋 일치) |
|
|
||||||
| SSO 회원 ↔ 포털 그룹·사용자 | 3/3 일치, 고유 회원 9 = 포털 신원 없음(keep) |
|
|
||||||
| HAL 모듈 | registered, server `srv_01a178829d…`, target 0.1.1 = actual 0.1.1 = 설치 0.1.1(상류 1.7.3), health ok, Agent online |
|
|
||||||
| applyPlan | **no-op — 적용할 변경 없음** |
|
|
||||||
|
|
||||||
## 6. 적용 (5단계)
|
|
||||||
|
|
||||||
적용한 변경 **없음**. 교체 불필요(이미 Ariweb Suite Webmaker 모듈 0.1.1). 운영 Webmaker 의 회원·프로젝트·도메인·배포·메일 연결·Gitea·설정·세션은 바이트 단위로 보존(아래 7).
|
|
||||||
|
|
||||||
## 7. 사후 검증 (05:01~05:03Z, 모두 readback)
|
|
||||||
|
|
||||||
- 콘텐츠 체크섬: `/data/webmaker` + `/var/www/webmaker-sites` 32,608 파일 `sha256sum -c` 불일치 0.
|
|
||||||
- git fsck 로컬 7개 저장소 오류 0 · Gitea `ls-remote --heads` 13건 스냅샷과 동일.
|
|
||||||
- `webmaker.service` active, MainPID 1041361, NRestarts 0, ActiveEnter 2026-10-26 14:00:23Z(스냅샷 전후 동일), 최근 2시간 err 저널 0. ariweb-hal/portal/agent, arihakdang-mail, nginx, webmaker-setup 모두 active·재시작 0.
|
|
||||||
- nginx `-t` 성공, 유닛·vhost 4개 sha256 스냅샷과 동일. TLS: webmaker.ariweb.net CN 전용(2027-01-12), sample/guide `*.ariweb.net`(2027-01-13).
|
|
||||||
- 외부 HTTP: `https://webmaker.ariweb.net/` 302→`/login`(200, 제목 "로그인 — AI Webmaker" — 화면·세션 호환, 자격 증명 미사용) · sample.ariweb.net `/`,`/en/`,`/ja/` 200 · guide.ariweb.net `/`,`/en/` 200 · webmaker.ariweb.net 미리보기 경로 3개(`/291da…/`, `/41ca…/`, `/572a…/`) 200.
|
|
||||||
- HAL readback: `module_targets.webmaker` 0.1.1/0.1.1 health ok last_check 05:01:44Z. `session.secret`·`users.json` mtime 변동 없음.
|
|
||||||
|
|
||||||
## 8. 미해결 blocker (사람 결정 필요)
|
|
||||||
|
|
||||||
1. bear HAL(`hal21.ariweb.net`)에 hostinger 를 서버로 등록하거나 Agent 를 재등록하려면 hal21 운영자 로그인(등록 토큰 발급)이 필요하다 — 이 세션에는 자격 증명이 없고, 운영자 1건은 누가 만들었는지 확인되지 않았다. 또한 hostinger Agent 는 hostinger HAL 에 등록돼 있어 제어 평면을 hal21 로 옮기는 것은 별도 결정·절차(§34 "하나의 HAL")가 필요하다.
|
|
||||||
2. Webmaker 고유 회원 9명·고유 프로젝트 7개를 포털 자원(계정·그룹·사이트)으로 옮기는 기능은 없고, 만들려면 각 사용자의 자격 증명·동의가 필요하다.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# webmaker.ariweb.net → HAL21 운영 연동 검증
|
|
||||||
|
|
||||||
**작성자:** Neo
|
|
||||||
|
|
||||||
**작성일:** 2026-10-27 UTC
|
|
||||||
|
|
||||||
**목적:** 운영 Webmaker의 데이터·계정·배포 사이트를 변경하지 않고 `hal21.ariweb.net`의 HAL 제어 평면에 연결한 결과와 롤백 근거를 기록한다.
|
|
||||||
|
|
||||||
**요약:** 운영 Webmaker는 이미 Ariweb Suite Webmaker 0.1.1(상류 1.7.3)이므로 애플리케이션 교체는 하지 않았다. 검증된 사전 백업을 유지한 채 Node Agent만 HAL21로 재등록했고, HAL21은 프로젝트 10개를 누락·절단·오류 없이 읽었다. 회원 12명(고유 9, SSO 3), 데이터 32,608개 파일, Gitea ref 13개와 서비스 프로세스는 보존됐다. 비밀값은 출력·문서화하지 않았다.
|
|
||||||
|
|
||||||
## 개정 이력
|
|
||||||
|
|
||||||
- v1.0 — 2026-10-27 — HAL21 연동, 인벤토리, 무결성, 회귀 및 롤백 검증 기록.
|
|
||||||
|
|
||||||
## 1. 범위와 결정
|
|
||||||
|
|
||||||
- 대상: `webmaker.ariweb.net` / `srv1953374` (`187.53.133.173`).
|
|
||||||
- 제어 평면: `https://hal21.ariweb.net` / bear.
|
|
||||||
- Webmaker 실행 버전은 이미 Ariweb Suite 모듈 `0.1.1`, 상류 Webmaker `1.7.3`이었다. 동일 코드를 다시 덮어쓰면 위험만 늘어나므로 Webmaker 코드·DB·데이터는 교체하지 않았다.
|
|
||||||
- 기존 Webmaker 고유 계정의 비밀번호 해시나 세션을 Portal 계정으로 복제하지 않았다. 고유 회원 9명은 Webmaker 인증 정본으로 유지하고, 기존 SSO 회원 3명과 Portal 연결 3건도 유지했다.
|
|
||||||
- 기존 hostinger HAL에 등록돼 있던 Node Agent를 HAL21로 재등록했다. Webmaker Data Plane은 작업 중 계속 실행됐다.
|
|
||||||
|
|
||||||
## 2. 배포 정본
|
|
||||||
|
|
||||||
- AriWeb Suite main: `12958ab6952e78159f4de22771ddf46a51441a34`.
|
|
||||||
- HAL artifact SHA-256: `01c70d098d614fbd38195bde21c383c1dcaefc2891a4b907d0d268fc08f8f28f`.
|
|
||||||
- HAL 설치: `/srv/ariweb/hal/releases/0.1.1-12958ab6952e` → `/srv/ariweb/hal/current`.
|
|
||||||
- Agent 설치: `/srv/ariweb/agent/releases/0.1.1-12958ab6952e` → `/srv/ariweb/agent/current`.
|
|
||||||
- HAL 백업: `bear:/srv/ariweb/backups/hal-pre-12958ab-20261027T090222Z`.
|
|
||||||
- Agent 전환 백업: `srv1953374:/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z`.
|
|
||||||
- Webmaker 사전 백업·복원 시험: `/srv/webmaker/backups/pre-hal-link-20261027T045715Z` (`RESTORE_TEST_OK 32608 files`).
|
|
||||||
|
|
||||||
## 3. HAL21 연결
|
|
||||||
|
|
||||||
- HAL21 서버: `srv_01a17bd4dbcdb55877f2d9fcab`, 상태 `ready`.
|
|
||||||
- HAL21 Agent: `ag_01a17c99f0102ce24409dc258a`, 상태 active.
|
|
||||||
- Agent의 Webmaker 경로는 systemd drop-in으로 명시했다. 경로를 추측하거나 기존 Webmaker unit을 재작성하지 않았다.
|
|
||||||
- `DATA_ROOT=/data/webmaker`
|
|
||||||
- `DATA_DIR=/data/webmaker/sites`
|
|
||||||
- `SITES_ROOT=/var/www/webmaker-sites`
|
|
||||||
- HAL21 모듈 readback: Webmaker actual `0.1.1`, active, health `ok`, drift 없음.
|
|
||||||
- HAL21 인벤토리 readback: total `10`, returned `10`, truncated `false`, errors `[]`.
|
|
||||||
- 인벤토리에 포함된 도메인: `sample.ariweb.net`, `guide.ariweb.net`, `hs-company.ariweb.net`.
|
|
||||||
- 사이트 본문, 생성 프롬프트, 연락처, 비밀번호·해시·토큰·키·세션은 HAL로 보내지 않는다.
|
|
||||||
|
|
||||||
## 4. 데이터 보존 검증
|
|
||||||
|
|
||||||
- `/data/webmaker`와 `/var/www/webmaker-sites` 사전 체크섬 32,608건 재검증: exit 0, 불일치 0.
|
|
||||||
- 로컬 Git 저장소 7개: `git fsck` 성공 7, 실패 0.
|
|
||||||
- Gitea heads: 사전 스냅샷과 동일한 13개 ref.
|
|
||||||
- Webmaker 프로세스는 재시작하지 않았다.
|
|
||||||
- MainPID `1041361`
|
|
||||||
- NRestarts `0`
|
|
||||||
- ActiveEnter `2026-10-26 14:00:23 UTC`
|
|
||||||
- Webmaker 회원 12명(고유 9, SSO 3), 프로젝트 10개, Portal 연결 3개, 배포 이력 및 도메인 등록부는 변경하지 않았다.
|
|
||||||
|
|
||||||
## 5. 회귀 검증
|
|
||||||
|
|
||||||
- `webmaker.service`, `ariweb-agent`, `nginx`, `ariweb-hal`: active.
|
|
||||||
- nginx 설정 검사: 성공. 기존 `server_name`/protocol 중복 경고는 남아 있으나 이번 작업에서 nginx 파일을 변경하지 않았다.
|
|
||||||
- 외부 HTTP:
|
|
||||||
- `https://webmaker.ariweb.net/` → 로그인 화면 HTTP 200.
|
|
||||||
- `https://sample.ariweb.net/` → HTTP 200.
|
|
||||||
- `https://guide.ariweb.net/` → HTTP 200.
|
|
||||||
- Webmaker의 `572a1d…` 미리보기 경로 → HTTP 200.
|
|
||||||
- `https://hal21.ariweb.net/api/health` → HTTP 200.
|
|
||||||
- `https://hal21.ariweb.net/ops/` → HTTP 200.
|
|
||||||
- 알려진 기존 상태: `https://hs-company.ariweb.net/`은 공개 TLS SNI 오류가 있으며 이번 HAL 연결 작업으로 새로 생긴 문제가 아니다. 프로젝트와 파일은 Webmaker 인벤토리 및 미리보기에서 보존됨을 확인했다.
|
|
||||||
|
|
||||||
## 6. 롤백
|
|
||||||
|
|
||||||
1. hostinger에서 `ariweb-agent`를 중지한다.
|
|
||||||
2. `/srv/ariweb/backups/agent-pre-hal21-20261027T090325Z/data`를 `/srv/ariweb/agent/data`로 복원한다.
|
|
||||||
3. `/srv/ariweb/agent/current`를 백업의 `current-target.txt` 값으로 되돌리고 `90-hal21-webmaker.conf`를 제거한다.
|
|
||||||
4. daemon-reload 후 `ariweb-agent`를 시작하면 기존 hostinger HAL 등록으로 복귀한다.
|
|
||||||
5. HAL21의 신규 Agent는 폐기 후 revoke하고, 서버 상태를 점검한다.
|
|
||||||
6. HAL 자체 롤백이 필요하면 bear의 HAL 백업에서 data를 복원하고 `current-target.txt`의 이전 release로 되돌린 뒤 `ariweb-hal`을 기동한다.
|
|
||||||
|
|
||||||
Webmaker Data Plane은 HAL과 분리돼 있으므로 위 롤백 중에도 기존 공개 사이트와 Webmaker 자체 인증 데이터는 그대로 유지된다.
|
|
||||||
@@ -24,7 +24,6 @@ import * as core from './core.mjs';
|
|||||||
import * as ops from './ops.mjs';
|
import * as ops from './ops.mjs';
|
||||||
import * as emergency from './emergency.mjs';
|
import * as emergency from './emergency.mjs';
|
||||||
import * as dr from './dr.mjs';
|
import * as dr from './dr.mjs';
|
||||||
import * as mailsvc from './mailsvc.mjs';
|
|
||||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||||
|
|
||||||
@@ -130,12 +129,6 @@ export function buildApp(d) {
|
|||||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||||
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
|
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
|
||||||
// HAL 관리형 메일 서비스(legacy 가져오기, hal/mailsvc.mjs). 응답에 비밀 없음(참조만 저장).
|
|
||||||
app.get('/ops/api/mail-services', () => { const rb = mailsvc.readback(d); return { ok: true, services: rb.services, readback: { counts: rb.counts, duplicates: rb.duplicates, perServer: rb.perServer, secretLeaks: rb.secretLeaks, generatedAt: rb.generatedAt } }; });
|
|
||||||
app.get('/ops/api/mail-services/imports', () => ({ ok: true, imports: mailsvc.listImports(d) }));
|
|
||||||
app.post('/ops/api/mail-services/import', wrap(async (ctx) => { const b = await ctx.body(); if (b.apply) requireRecent(ctx); const v = mailsvc.validateDiscovery(b.discovery); if (!v.ok) throw new HttpError(400, 'bad_discovery', 'discovery 문서 검증 실패', { errors: v.errors }); return { ok: true, result: mailsvc.applyImport(d, b.discovery, { serverId: b.serverId, by: ctx.operator.id, dryRun: !b.apply }) }; }));
|
|
||||||
app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; }));
|
|
||||||
app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; }));
|
|
||||||
// 릴리스·모듈 배포(§32)
|
// 릴리스·모듈 배포(§32)
|
||||||
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
|
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
|
||||||
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { serviceFetch } from '../lib/serviceauth.mjs';
|
|||||||
import { JobError } from '../lib/jobs.mjs';
|
import { JobError } from '../lib/jobs.mjs';
|
||||||
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
import { existsSync, statSync, mkdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
|
||||||
|
|
||||||
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
|
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
|
||||||
export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
|
export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
|
||||||
@@ -180,8 +179,6 @@ export function heartbeat(d, agent, body) {
|
|||||||
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
||||||
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
||||||
}
|
}
|
||||||
// [legacy 메일 서비스] Agent 가 보고한 arimail 모듈 상태를 그 서버의 HAL 관리형 메일 서비스 행에 비춘다(행이 없으면 무관)
|
|
||||||
try { syncFromHeartbeat(d, agent.server_id, body.modules || {}); } catch (e) { d.log && d.log('[hal] mail_services sync 실패', e.message); }
|
|
||||||
// 시계 차이·디스크 고갈 경고(§28.1)
|
// 시계 차이·디스크 고갈 경고(§28.1)
|
||||||
if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` });
|
if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` });
|
||||||
else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} }
|
else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} }
|
||||||
|
|||||||
@@ -7,16 +7,6 @@
|
|||||||
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
|
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
|
||||||
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
|
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
|
||||||
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
|
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
|
||||||
* node hal/index.mjs server-token --server <srv_id> [--by <who>] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록)
|
|
||||||
* node hal/index.mjs mail-import --discovery <json> (--server <srv_id> | --create-server --name <n> [--location <l>]) [--apply] [--by <who>]
|
|
||||||
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
|
|
||||||
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
|
|
||||||
* node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음)
|
|
||||||
* node hal/index.mjs config-set [--publicUrl <u>] [--portalUrl <u>] [--offlineAfterMs <n>] [--metricsRetentionDays <n>] [--by <who>] [--reason <text>]
|
|
||||||
* 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다
|
|
||||||
* node hal/index.mjs module-deploy --module <m> --version <v> --servers <srv_id[,srv_id]> [--by <who>] [--reason <text>] [--nonce <n>]
|
|
||||||
* 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다
|
|
||||||
* node hal/index.mjs job --id <j_id> 작업·자식 작업 readback(JSON)
|
|
||||||
*/
|
*/
|
||||||
import { loadConfig } from './config.mjs';
|
import { loadConfig } from './config.mjs';
|
||||||
import { createHalDeps, VERSION } from './deps.mjs';
|
import { createHalDeps, VERSION } from './deps.mjs';
|
||||||
@@ -25,11 +15,7 @@ import * as core from './core.mjs';
|
|||||||
import * as ops from './ops.mjs';
|
import * as ops from './ops.mjs';
|
||||||
import * as emergency from './emergency.mjs';
|
import * as emergency from './emergency.mjs';
|
||||||
import * as dr from './dr.mjs';
|
import * as dr from './dr.mjs';
|
||||||
import * as mailsvc from './mailsvc.mjs';
|
|
||||||
import { purgeSessions } from '../lib/session.mjs';
|
import { purgeSessions } from '../lib/session.mjs';
|
||||||
import { audit } from '../lib/audit.mjs';
|
|
||||||
import { describe } from '../lib/jobs.mjs';
|
|
||||||
import { descriptor } from './modules/index.mjs';
|
|
||||||
import { randomToken } from '../lib/crypto.mjs';
|
import { randomToken } from '../lib/crypto.mjs';
|
||||||
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||||
import { join, dirname, basename } from 'node:path';
|
import { join, dirname, basename } from 'node:path';
|
||||||
@@ -56,43 +42,6 @@ if (cmd === 'setup-token') {
|
|||||||
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
|
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
|
||||||
} else if (cmd === 'bootstrap-release') {
|
} else if (cmd === 'bootstrap-release') {
|
||||||
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
|
||||||
} else if (cmd === 'server-token') {
|
|
||||||
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
|
|
||||||
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
|
|
||||||
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` });
|
|
||||||
console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`);
|
|
||||||
} else if (cmd === 'mail-import') {
|
|
||||||
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
|
|
||||||
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
|
|
||||||
let serverId = opt('server'); const by = `cli:${opt('by', 'cli')}`;
|
|
||||||
if (!serverId && opt('create-server')) { const s = core.createServer(d, { name: opt('name', doc.source.hostname), role: 'mail', location: opt('location', ''), by }); serverId = s.id; console.log(`서버 생성(pending, Agent 미연결): ${s.id} ${s.name}`); }
|
|
||||||
if (!serverId) { console.error('--server <srv_id> 또는 --create-server 가 필요합니다'); process.exit(1); }
|
|
||||||
try { const r = mailsvc.applyImport(d, doc, { serverId, by, dryRun: !opt('apply') }); console.log(JSON.stringify({ importId: r.importId, mode: r.mode, serverId, authority: r.plan.authority, summary: r.plan.summary, items: r.plan.items, missingInSource: r.plan.missingInSource, created: r.created, updated: r.updated }, null, 1)); if (r.mode === 'rejected') process.exit(3); }
|
|
||||||
catch (e) { console.error(`가져오기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
|
|
||||||
} else if (cmd === 'mail-import-rollback') {
|
|
||||||
try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); }
|
|
||||||
} else if (cmd === 'mail-services') {
|
|
||||||
console.log(JSON.stringify(mailsvc.readback(d), null, 1));
|
|
||||||
} else if (cmd === 'config-set') {
|
|
||||||
// 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:<by>.
|
|
||||||
const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) { const v = opt(k); if (v != null && v !== true) patch[k] = String(v); }
|
|
||||||
if (!Object.keys(patch).length) { console.error('바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상'); process.exit(1); }
|
|
||||||
const by = `cli:${opt('by', 'cli')}`; cfg.set(patch, by);
|
|
||||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'config.update', subjectType: 'config', subjectId: 'hal', detail: { keys: Object.keys(patch), values: patch, reason: opt('reason', '') } });
|
|
||||||
console.log(JSON.stringify({ updated: Object.keys(patch), file: cfg.file, note: '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' }));
|
|
||||||
} else if (cmd === 'module-deploy') {
|
|
||||||
// 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다.
|
|
||||||
const module = opt('module'); const version = opt('version'); const serverIds = String(opt('servers', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const by = `cli:${opt('by', 'cli')}`;
|
|
||||||
if (!module || module === true || !version || version === true || !serverIds.length) { console.error('--module <name> --version <v> --servers <srv_id[,srv_id]> 가 필요합니다'); process.exit(1); }
|
|
||||||
try { descriptor(module); } catch (e) { console.error(e.message); process.exit(1); }
|
|
||||||
if (!core.getRelease(d, version)) { console.error(`릴리스 ${version} 이(가) 등록되어 있지 않습니다`); process.exit(1); }
|
|
||||||
const missing = serverIds.filter((sid) => !core.getServer(d, sid)); if (missing.length) { console.error(`없는 서버: ${missing.join(', ')}`); process.exit(1); }
|
|
||||||
const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds }, idempotencyKey: `module.deploy:${module}:${version}:${serverIds.slice().sort().join(',')}:${opt('nonce', '') === true ? '' : opt('nonce', '')}`, createdBy: by, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${version} 배포` });
|
|
||||||
audit(d.store, { actorType: 'operator', actorId: by, action: 'deploy.module.request', subjectType: 'module', subjectId: module, detail: { version, serverIds, reason: opt('reason', ''), jobId: r.job.id, created: r.created } });
|
|
||||||
console.log(JSON.stringify({ jobId: r.job.id, created: r.created, status: r.job.status, kind: r.job.kind, label: r.job.label, note: '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' }));
|
|
||||||
} else if (cmd === 'job') {
|
|
||||||
const j = opt('id') && opt('id') !== true ? d.jobs.get(opt('id')) : null; if (!j) { console.error('--id <j_id> 가 필요합니다(없는 작업)'); process.exit(1); }
|
|
||||||
console.log(JSON.stringify({ job: describe(j), children: d.jobs.list({ parentId: j.id, limit: 100 }).map(describe) }, null, 1));
|
|
||||||
} else if (cmd === 'serve') {
|
} else if (cmd === 'serve') {
|
||||||
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
|
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
|
||||||
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
|
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
|
||||||
|
|||||||
-197
@@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* hal/mailsvc.mjs — HAL 관리형 메일 서비스 등록부와 기존(legacy) AriMail 인스턴스 가져오기(importer).
|
|
||||||
*
|
|
||||||
* 왜 있나: 이미 운영 중인 AriMail(mail.arihakdang.com · webmail.ibit.kr 같은)을 교체·중단·재구성하지 않고, HAL 이 **등록·감시·통제**할 수 있는
|
|
||||||
* 논리 리소스(MailService = 도메인 단위)와 노드(servers 행)로 가져온다. Data Plane(AriMail 의 송수신·계정·메일·DNS/MX·TLS·provider)은 그대로이며
|
|
||||||
* HAL 이 멈춰도 메일 서비스는 계속 동작한다(HAL 은 제어 평면, 이 표는 등록부일 뿐이다).
|
|
||||||
*
|
|
||||||
* 원칙
|
|
||||||
* - 읽기 전용 discovery: 원본 호스트에서 `scripts/mail-discover.mjs` 가 만든 문서(JSON)만 입력으로 받는다. 비밀값(토큰·키·비밀번호·해시)은 문서에 있으면 거부하고,
|
|
||||||
* 비밀은 `secretRefs`(file:/dir:/vault:/env: 참조)로만 둔다. HAL DB 에는 참조만 저장한다.
|
|
||||||
* - 불변 id(`mls_…`), 출처 권한(authority = `arimail:<hostname>:<unit>`), 도메인별 active owner 정확히 하나(부분 유일 인덱스).
|
|
||||||
* - dry-run 이 기본. 계획(plan)은 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락을 거부하며, 거부가 하나라도 있으면 아무것도 적용하지 않는다(전체 거부).
|
|
||||||
* - 적용은 한 트랜잭션, 모든 변화는 감사 기록(immutable), 적용 단위(mail_imports)로 되돌리기(생성 행 삭제·갱신 행 복원) 가능.
|
|
||||||
* - 같은 authority·서버의 재적용은 update(집계·증거 갱신, id 유지) — 멱등.
|
|
||||||
* - 소유 조직이 증거로 확인되지 않으면 임의 매핑하지 않고 `unassigned_legacy_managed` 로 둔다(AriMail 등록부 `ariweb.groupId` 가 있어도 포털 그룹과 대조하기 전에는 추정하지 않는다).
|
|
||||||
* - Agent heartbeat 가 arimail 모듈 상태를 보고하면 last_sync·health 가 갱신된다(core.heartbeat → syncFromHeartbeat). Agent 가 없는 서버의 행은 import 시각만 가진다.
|
|
||||||
*/
|
|
||||||
import { newId } from '../lib/crypto.mjs';
|
|
||||||
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
|
|
||||||
import { audit } from '../lib/audit.mjs';
|
|
||||||
import { looksSecret } from '../lib/redact.mjs';
|
|
||||||
import { JobError } from '../lib/jobs.mjs';
|
|
||||||
import * as core from './core.mjs';
|
|
||||||
|
|
||||||
export const DISCOVERY_FORMAT = 1;
|
|
||||||
export const MAIL_SERVER_ROLES = ['mail', 'control'];
|
|
||||||
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
|
|
||||||
const SECRET_KEY_RE = /password|passwd|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|^(pass|key)$/i;
|
|
||||||
const REF_KEY_RE = /(ref|refs|path|file|files|dir|keys|names|at|mode|owner|hint|exists|count|url)$/i;
|
|
||||||
const REF_RE = /^(file|dir|vault|env):\S+$/;
|
|
||||||
/** git 커밋·artifact 해시처럼 "비밀처럼 보이지만 공개 식별자" 인 값: 키 이름이 commit/sha256 류이고 값이 40/64 hex 일 때만 허용 */
|
|
||||||
const isPublicDigest = (k, v) => /^(commit|main_?commit|sha256|artifact_?sha256|digest)$/i.test(String(k)) && /^([a-f0-9]{40}|[a-f0-9]{64})$/i.test(String(v));
|
|
||||||
|
|
||||||
export const MIGRATIONS = [{ id: 'hal_0003_mail_services', sql: `
|
|
||||||
-- HAL 관리형 메일 서비스(도메인 단위 논리 리소스). 비밀은 secret_refs_json 의 참조만. 도메인별 active 는 정확히 하나.
|
|
||||||
CREATE TABLE IF NOT EXISTS mail_services (
|
|
||||||
id TEXT PRIMARY KEY, domain TEXT NOT NULL, server_id TEXT NOT NULL, authority TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'legacy_managed',
|
|
||||||
ownership TEXT NOT NULL DEFAULT 'unassigned_legacy_managed', owner_group_id TEXT, active INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'managed',
|
|
||||||
public_host TEXT, web_url TEXT, instance_json TEXT NOT NULL, domain_json TEXT, evidence_json TEXT, aggregates_json TEXT, secret_refs_json TEXT, health TEXT,
|
|
||||||
import_id TEXT, imported_at TEXT NOT NULL, imported_by TEXT, last_sync_at TEXT, last_sync_source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS mail_services_active_domain ON mail_services(domain) WHERE active=1;
|
|
||||||
CREATE INDEX IF NOT EXISTS mail_services_server ON mail_services(server_id);
|
|
||||||
-- 가져오기 단위(dry-run 도 기록). status: planned(dry-run) | rejected | applied | rolled_back
|
|
||||||
CREATE TABLE IF NOT EXISTS mail_imports (
|
|
||||||
id TEXT PRIMARY KEY, mode TEXT NOT NULL, status TEXT NOT NULL, actor TEXT, server_id TEXT, authority TEXT NOT NULL, plan_json TEXT NOT NULL,
|
|
||||||
created_ids_json TEXT, updated_json TEXT, created_at TEXT NOT NULL, applied_at TEXT, rolled_back_at TEXT, rolled_back_by TEXT
|
|
||||||
);` }];
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 검증
|
|
||||||
/** discovery 문서 검증. 비밀값이 어디에든 있으면 거부한다. */
|
|
||||||
export function validateDiscovery(doc) {
|
|
||||||
const errors = [];
|
|
||||||
if (!doc || typeof doc !== 'object') return { ok: false, errors: ['discovery 문서가 객체가 아닙니다'] };
|
|
||||||
if (doc.format !== DISCOVERY_FORMAT) errors.push(`format 은 ${DISCOVERY_FORMAT} 이어야 합니다`);
|
|
||||||
if (!doc.collectedAt || isNaN(Date.parse(doc.collectedAt))) errors.push('collectedAt(ISO 시각)이 필요합니다');
|
|
||||||
const s = doc.source;
|
|
||||||
if (!s || typeof s !== 'object') errors.push('source 가 필요합니다');
|
|
||||||
else { for (const k of ['hostname', 'unit', 'appDir', 'user']) if (!s[k] || typeof s[k] !== 'string') errors.push(`source.${k} 가 필요합니다`); if (!s.revision || typeof s.revision !== 'object') errors.push('source.revision 이 필요합니다'); if (!s.health || typeof s.health !== 'object') errors.push('source.health 가 필요합니다'); }
|
|
||||||
if (!Array.isArray(doc.domains) || !doc.domains.length) errors.push('domains 배열이 필요합니다');
|
|
||||||
else {
|
|
||||||
const seen = new Set();
|
|
||||||
doc.domains.forEach((x, i) => {
|
|
||||||
if (!x || typeof x !== 'object') { errors.push(`domains[${i}] 가 객체가 아닙니다`); return; }
|
|
||||||
const dn = String(x.domain || '').toLowerCase(); if (!DOMAIN_RE.test(dn)) errors.push(`domains[${i}] 도메인 형식 오류: ${x.domain}`);
|
|
||||||
if (seen.has(dn)) errors.push(`도메인 중복: ${dn}`); seen.add(dn);
|
|
||||||
if (!Array.isArray(x.hosts)) errors.push(`domains[${i}].hosts 배열이 필요합니다`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!Array.isArray(doc.listeners)) errors.push('listeners 배열이 필요합니다');
|
|
||||||
if (!doc.aggregates || typeof doc.aggregates !== 'object') errors.push('aggregates 가 필요합니다');
|
|
||||||
if (doc.secretRefs != null) {
|
|
||||||
if (!Array.isArray(doc.secretRefs)) errors.push('secretRefs 는 배열이어야 합니다');
|
|
||||||
else doc.secretRefs.forEach((r, i) => { if (!r || typeof r !== 'object') { errors.push(`secretRefs[${i}] 형식 오류`); return; } if ('value' in r || 'secret' in r) errors.push(`secretRefs[${i}] 에 비밀값을 넣을 수 없습니다 — 참조만`); if (!REF_RE.test(String(r.ref || ''))) errors.push(`secretRefs[${i}].ref 는 file:/dir:/vault:/env: 형식이어야 합니다`); });
|
|
||||||
}
|
|
||||||
// 비밀값 탐지(이름·형태). 참조 키(…Ref/Path/File/Dir/Keys)는 허용.
|
|
||||||
const walk = (v, path, depth) => {
|
|
||||||
if (depth > 8 || v == null) return;
|
|
||||||
if (typeof v === 'string') { if (looksSecret(v)) errors.push(`비밀 형태의 값: ${path}`); return; }
|
|
||||||
if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, depth + 1)); return; }
|
|
||||||
if (typeof v === 'object') for (const [k, val] of Object.entries(v)) { if (isPublicDigest(k, val)) continue; if (SECRET_KEY_RE.test(k) && !REF_KEY_RE.test(k) && typeof val === 'string' && val !== '') errors.push(`비밀 이름의 키에 값이 있습니다: ${path}.${k}`); walk(val, `${path}.${k}`, depth + 1); }
|
|
||||||
};
|
|
||||||
walk(doc, 'doc', 0);
|
|
||||||
return { ok: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
export const authorityOf = (doc) => `arimail:${doc.source.hostname}:${doc.source.unit}`;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 조회
|
|
||||||
function rowToPublic(d, r) {
|
|
||||||
if (!r) return null;
|
|
||||||
const srv = d.store.get('SELECT name, agent_id, status, last_seen_at FROM servers WHERE id=?', r.server_id);
|
|
||||||
return { id: r.id, domain: r.domain, serverId: r.server_id, serverName: srv?.name || null, agentId: srv?.agent_id || null, serverStatus: srv?.status || null, authority: r.authority, kind: r.kind, ownership: r.ownership, ownerGroupId: r.owner_group_id, active: !!r.active, status: r.status,
|
|
||||||
publicHost: r.public_host, webUrl: r.web_url, instance: parseJson(r.instance_json, {}), domainInfo: parseJson(r.domain_json, {}), evidence: parseJson(r.evidence_json, {}), aggregates: parseJson(r.aggregates_json, {}), secretRefs: parseJson(r.secret_refs_json, []), health: r.health,
|
|
||||||
importId: r.import_id, importedAt: r.imported_at, importedBy: r.imported_by, lastSyncAt: r.last_sync_at, lastSyncSource: r.last_sync_source, createdAt: r.created_at, updatedAt: r.updated_at };
|
|
||||||
}
|
|
||||||
export function listMailServices(d, { serverId = null } = {}) { return d.store.all(`SELECT * FROM mail_services ${serverId ? 'WHERE server_id=?' : ''} ORDER BY domain, created_at`, ...(serverId ? [serverId] : [])).map((r) => rowToPublic(d, r)); }
|
|
||||||
export function getMailService(d, id) { return rowToPublic(d, d.store.get('SELECT * FROM mail_services WHERE id=?', id)); }
|
|
||||||
export function listImports(d, { limit = 50 } = {}) { return d.store.all('SELECT id, mode, status, actor, server_id, authority, created_at, applied_at, rolled_back_at, rolled_back_by, plan_json, created_ids_json FROM mail_imports ORDER BY created_at DESC LIMIT ?', limit).map((r) => ({ id: r.id, mode: r.mode, status: r.status, actor: r.actor, serverId: r.server_id, authority: r.authority, createdAt: r.created_at, appliedAt: r.applied_at, rolledBackAt: r.rolled_back_at, rolledBackBy: r.rolled_back_by, summary: parseJson(r.plan_json, {}).summary || null, createdIds: parseJson(r.created_ids_json, []) })); }
|
|
||||||
/** 적용 뒤 정확성 readback: 총수·active·도메인별 중복·서버별·비밀 누출(형태 검사). */
|
|
||||||
export function readback(d) {
|
|
||||||
const rows = d.store.all('SELECT * FROM mail_services ORDER BY domain'); const services = rows.map((r) => rowToPublic(d, r));
|
|
||||||
const byDomain = {}; for (const r of rows) if (r.active) byDomain[r.domain] = (byDomain[r.domain] || 0) + 1;
|
|
||||||
const duplicates = Object.entries(byDomain).filter(([, n]) => n > 1).map(([dn]) => dn);
|
|
||||||
const perServer = {}; for (const r of rows) perServer[r.server_id] = (perServer[r.server_id] || 0) + 1;
|
|
||||||
let secretLeaks = 0; const scan = (v, depth = 0) => { if (depth > 8 || v == null) return; if (typeof v === 'string') { if (looksSecret(v)) secretLeaks++; return; } if (Array.isArray(v)) v.forEach((x) => scan(x, depth + 1)); else if (typeof v === 'object') Object.entries(v).forEach(([k, x]) => { if (!isPublicDigest(k, x)) scan(x, depth + 1); }); };
|
|
||||||
for (const r of rows) scan([parseJson(r.instance_json, null), parseJson(r.evidence_json, null), parseJson(r.aggregates_json, null), parseJson(r.secret_refs_json, null), parseJson(r.domain_json, null)]);
|
|
||||||
return { counts: { total: rows.length, active: rows.filter((r) => r.active).length, domains: Object.keys(byDomain).length, byDomain }, duplicates, perServer, secretLeaks, services, generatedAt: nowIso() };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 계획(dry-run)
|
|
||||||
/** 계획 세우기. 거부(reject)가 하나라도 있으면 plan.ok=false. 서버 없음·역할·authority 불일치는 예외로 던진다(문서 전체가 대상이 아님). */
|
|
||||||
export function planImport(d, doc, { serverId }) {
|
|
||||||
const v = validateDiscovery(doc); if (!v.ok) throw new JobError('bad_discovery', `discovery 문서 검증 실패: ${v.errors.join(' · ')}`, { errors: v.errors });
|
|
||||||
const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
|
|
||||||
if (!MAIL_SERVER_ROLES.includes(srv.role)) throw new JobError('bad_role', `메일 서비스를 둘 서버 역할은 ${MAIL_SERVER_ROLES.join('/')} 여야 합니다(현재 ${srv.role})`);
|
|
||||||
const facts = srv.facts || {}; if (facts.hostname && facts.hostname !== doc.source.hostname) throw new JobError('authority_mismatch', `서버 facts 의 hostname(${facts.hostname})과 discovery 의 hostname(${doc.source.hostname})이 다릅니다`);
|
|
||||||
if (facts.units && facts.units.arimail && facts.units.arimail !== doc.source.unit.replace(/\.service$/, '')) throw new JobError('authority_mismatch', `서버 facts 의 arimail 유닛(${facts.units.arimail})과 discovery 의 유닛(${doc.source.unit})이 다릅니다`);
|
|
||||||
const authority = authorityOf(doc); const items = [];
|
|
||||||
for (const x of doc.domains) {
|
|
||||||
const domain = String(x.domain).toLowerCase();
|
|
||||||
if (x.enabled === false) { items.push({ domain, action: 'skip', reason: 'disabled_in_source' }); continue; }
|
|
||||||
const ex = d.store.get('SELECT * FROM mail_services WHERE domain=? AND active=1', domain);
|
|
||||||
if (!ex) items.push({ domain, action: 'create' });
|
|
||||||
else if (ex.authority === authority && ex.server_id === serverId) items.push({ domain, action: 'update', existingId: ex.id });
|
|
||||||
else items.push({ domain, action: 'reject', reason: `collision: 도메인 ${domain} 의 active owner 는 이미 ${ex.id}(${ex.authority}, 서버 ${ex.server_id}) 입니다`, existingId: ex.id });
|
|
||||||
}
|
|
||||||
const present = new Set(doc.domains.map((x) => String(x.domain).toLowerCase()));
|
|
||||||
const missingInSource = d.store.all('SELECT domain FROM mail_services WHERE authority=? AND server_id=? AND active=1', authority, serverId).map((r) => r.domain).filter((dn) => !present.has(dn));
|
|
||||||
const summary = { create: 0, update: 0, reject: 0, skip: 0, missingInSource: missingInSource.length }; for (const i of items) summary[i.action]++;
|
|
||||||
return { ok: summary.reject === 0, authority, serverId, serverName: srv.name, items, missingInSource, summary, collectedAt: doc.collectedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
const domainView = (x) => ({ domain: String(x.domain).toLowerCase(), enabled: x.enabled !== false, primary: !!x.primary, hosts: (x.hosts || []).map(String), authMode: x.authMode || null, operating: x.operating || null, ariweb: x.ariweb || null, note: x.note || null, tenantDb: x.tenantDb || null, createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
|
|
||||||
function buildRow(doc, x, serverId, authority) {
|
|
||||||
const dv = domainView(x); const host = dv.hosts[0] ? String(dv.hosts[0]).split('/')[0] : null; const agg = (doc.aggregates.byDomain || {})[dv.domain] || null;
|
|
||||||
const s = doc.source;
|
|
||||||
const instance = { hostname: s.hostname, unit: s.unit, appDir: s.appDir, currentTarget: s.currentTarget || null, user: s.user, nodeBin: s.nodeBin || null, nodeVersion: s.nodeVersion || null, revision: s.revision || {}, packageVersion: s.packageVersion || null, healthUrl: s.healthUrl || null, health: s.health || null, unitState: s.unitState || null, harakaUnit: s.harakaUnit || null, dataDir: s.dataDir || null, listeners: doc.listeners, provider: doc.provider || null, collector: doc.collector || null, collectedAt: doc.collectedAt };
|
|
||||||
return { domain: dv.domain, server_id: serverId, authority, public_host: host, web_url: host ? `https://${host}/` : null, instance_json: toJson(instance), domain_json: toJson(dv), evidence_json: toJson(doc.evidence || {}), aggregates_json: toJson(agg), secret_refs_json: toJson(doc.secretRefs || []), health: s.health && s.health.ok === false ? 'down' : s.unitState && s.unitState.active && s.unitState.active !== 'active' ? 'down' : 'ok' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 적용
|
|
||||||
/**
|
|
||||||
* dry-run(기본) 또는 적용. 결과 { importId, mode: 'dry_run'|'rejected'|'apply', plan, created[], updated[] }.
|
|
||||||
* 거부가 있으면 아무것도 적용하지 않고 mode='rejected' 로 기록만 남긴다.
|
|
||||||
*/
|
|
||||||
export function applyImport(d, doc, { serverId, by = null, dryRun = true }) {
|
|
||||||
const plan = planImport(d, doc, { serverId }); const authority = plan.authority; const importId = newId('mimp_'); const now = nowIso();
|
|
||||||
const actorType = by ? 'operator' : 'system';
|
|
||||||
if (dryRun || !plan.ok) {
|
|
||||||
const mode = plan.ok ? 'dry_run' : 'rejected';
|
|
||||||
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_at) VALUES(?,?,?,?,?,?,?,?)', importId, mode, plan.ok ? 'planned' : 'rejected', by, serverId, authority, toJson(plan), now);
|
|
||||||
audit(d.store, { actorType, actorId: by, action: plan.ok ? 'mail_service.import_dry_run' : 'mail_service.import_rejected', subjectType: 'server', subjectId: serverId, detail: { importId, authority, summary: plan.summary, rejected: plan.items.filter((i) => i.action === 'reject').map((i) => i.domain) } });
|
|
||||||
return { importId, mode, plan, created: [], updated: [] };
|
|
||||||
}
|
|
||||||
return d.store.tx(() => {
|
|
||||||
const created = []; const updated = [];
|
|
||||||
for (const item of plan.items) {
|
|
||||||
if (item.action === 'skip') continue;
|
|
||||||
const x = doc.domains.find((y) => String(y.domain).toLowerCase() === item.domain); const row = buildRow(doc, x, serverId, authority);
|
|
||||||
if (item.action === 'create') {
|
|
||||||
const id = newId('mls_');
|
|
||||||
d.store.run('INSERT INTO mail_services(id, domain, server_id, authority, kind, ownership, owner_group_id, active, status, public_host, web_url, instance_json, domain_json, evidence_json, aggregates_json, secret_refs_json, health, import_id, imported_at, imported_by, last_sync_at, last_sync_source, created_at, updated_at) VALUES(?,?,?,?,\'legacy_managed\',\'unassigned_legacy_managed\',NULL,1,\'managed\',?,?,?,?,?,?,?,?,?,?,?,?,\'import\',?,?)',
|
|
||||||
id, row.domain, row.server_id, row.authority, row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, importId, now, by, now, now, now);
|
|
||||||
created.push(id); item.createdId = id;
|
|
||||||
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: id, immutable: true, detail: { importId, domain: row.domain, serverId, authority, publicHost: row.public_host, aggregates: parseJson(row.aggregates_json, null) } });
|
|
||||||
} else if (item.action === 'update') {
|
|
||||||
const before = d.store.get('SELECT * FROM mail_services WHERE id=?', item.existingId);
|
|
||||||
d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=\'import\', updated_at=? WHERE id=?', row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, now, now, item.existingId);
|
|
||||||
updated.push({ id: item.existingId, before });
|
|
||||||
audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: item.existingId, immutable: true, detail: { importId, domain: row.domain, serverId, authority, update: true } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_ids_json, updated_json, created_at, applied_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', importId, 'apply', 'applied', by, serverId, authority, toJson(plan), toJson(created), toJson(updated), now, now);
|
|
||||||
return { importId, mode: 'apply', plan, created, updated: updated.map((u) => u.id) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/** 되돌리기: 그 적용이 만든 행 삭제, 갱신한 행은 이전 값으로 복원. */
|
|
||||||
export function rollbackImport(d, importId, by = null) {
|
|
||||||
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', importId); if (!imp) throw new JobError('import_not_found', '가져오기 기록이 없습니다');
|
|
||||||
if (imp.status === 'rolled_back') throw new JobError('already_rolled_back', '이미 되돌린 가져오기입니다');
|
|
||||||
if (imp.status !== 'applied') throw new JobError('not_applied', `적용된 가져오기만 되돌릴 수 있습니다(상태 ${imp.status})`);
|
|
||||||
return d.store.tx(() => {
|
|
||||||
let deleted = 0, restored = 0;
|
|
||||||
for (const id of parseJson(imp.created_ids_json, [])) deleted += d.store.run('DELETE FROM mail_services WHERE id=? AND import_id=?', id, importId).changes;
|
|
||||||
for (const u of parseJson(imp.updated_json, [])) { const b = u.before; if (!b) continue; restored += d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=?, updated_at=? WHERE id=?', b.public_host, b.web_url, b.instance_json, b.domain_json, b.evidence_json, b.aggregates_json, b.secret_refs_json, b.health, b.last_sync_at, b.last_sync_source, nowIso(), u.id).changes; }
|
|
||||||
d.store.run('UPDATE mail_imports SET status=\'rolled_back\', rolled_back_at=?, rolled_back_by=? WHERE id=?', nowIso(), by, importId);
|
|
||||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'mail_service.import_rollback', subjectType: 'server', subjectId: imp.server_id, immutable: true, detail: { importId, deleted, restored } });
|
|
||||||
return { importId, deleted, restored };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/** heartbeat 연동(core.heartbeat 가 부른다): 그 서버의 메일 서비스 행에 Agent 관측 상태를 비춘다. 행이 없으면 아무것도 하지 않는다. */
|
|
||||||
export function syncFromHeartbeat(d, serverId, modules = {}) {
|
|
||||||
const m = modules && modules.arimail; if (!m) return 0;
|
|
||||||
const rows = d.store.all('SELECT id, instance_json FROM mail_services WHERE server_id=?', serverId); if (!rows.length) return 0; const now = nowIso();
|
|
||||||
for (const r of rows) { const inst = parseJson(r.instance_json, {}); inst.observedVersion = m.version || null; inst.observedActive = !!m.active; inst.observedAt = now; d.store.run('UPDATE mail_services SET health=?, last_sync_at=?, last_sync_source=\'agent\', instance_json=?, updated_at=? WHERE id=?', m.health || 'unknown', now, toJson(inst), now, r.id); }
|
|
||||||
return rows.length;
|
|
||||||
}
|
|
||||||
+2
-5
@@ -6,7 +6,6 @@ import * as session from '../lib/session.mjs';
|
|||||||
import * as jobs from '../lib/jobs.mjs';
|
import * as jobs from '../lib/jobs.mjs';
|
||||||
import * as notify from '../lib/notify.mjs';
|
import * as notify from '../lib/notify.mjs';
|
||||||
import * as audit from '../lib/audit.mjs';
|
import * as audit from '../lib/audit.mjs';
|
||||||
import { MIGRATIONS as MAIL_MIGRATIONS } from './mailsvc.mjs';
|
|
||||||
|
|
||||||
export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: `
|
export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: `
|
||||||
-- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다.
|
-- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다.
|
||||||
@@ -86,10 +85,8 @@ CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_js
|
|||||||
` },
|
` },
|
||||||
// [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID
|
// [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID
|
||||||
{ id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` },
|
{ id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` },
|
||||||
// [legacy 메일 서비스 가져오기] HAL 관리형 메일 서비스 등록부·가져오기 단위(hal/mailsvc.mjs). 표 추가만(기존 표 변경 없음).
|
|
||||||
...MAIL_MIGRATIONS,
|
|
||||||
// Webmaker 전체 인벤토리는 heartbeat 2 MiB 한도를 넘을 수 있어 인증된 배치로 원자적으로 조립한다.
|
// Webmaker 전체 인벤토리는 heartbeat 2 MiB 한도를 넘을 수 있어 인증된 배치로 원자적으로 조립한다.
|
||||||
{ id: 'hal_0004_webmaker_inventory_stage', sql: `
|
{ id: 'hal_0003_webmaker_inventory_stage', sql: `
|
||||||
CREATE TABLE IF NOT EXISTS agent_inventory_staging (
|
CREATE TABLE IF NOT EXISTS agent_inventory_staging (
|
||||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, kind TEXT NOT NULL, scan_id TEXT NOT NULL, total INTEGER NOT NULL, next_offset INTEGER NOT NULL DEFAULT 0,
|
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, kind TEXT NOT NULL, scan_id TEXT NOT NULL, total INTEGER NOT NULL, next_offset INTEGER NOT NULL DEFAULT 0,
|
||||||
meta_json TEXT NOT NULL, items_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL,
|
meta_json TEXT NOT NULL, items_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL,
|
||||||
@@ -97,7 +94,7 @@ CREATE TABLE IF NOT EXISTS agent_inventory_staging (
|
|||||||
);
|
);
|
||||||
` },
|
` },
|
||||||
// 사전 릴리스에서 FK 없이 같은 staging 표를 만든 경우에도 고아 row를 정리하고 이후 삭제를 보장한다.
|
// 사전 릴리스에서 FK 없이 같은 staging 표를 만든 경우에도 고아 row를 정리하고 이후 삭제를 보장한다.
|
||||||
{ id: 'hal_0005_webmaker_inventory_cleanup', sql: `
|
{ id: 'hal_0004_webmaker_inventory_cleanup', sql: `
|
||||||
DELETE FROM agent_inventory_staging WHERE agent_id NOT IN (SELECT id FROM agents);
|
DELETE FROM agent_inventory_staging WHERE agent_id NOT IN (SELECT id FROM agents);
|
||||||
CREATE TRIGGER IF NOT EXISTS agent_inventory_staging_agent_cleanup
|
CREATE TRIGGER IF NOT EXISTS agent_inventory_staging_agent_cleanup
|
||||||
AFTER DELETE ON agents BEGIN
|
AFTER DELETE ON agents BEGIN
|
||||||
|
|||||||
+3
-23
@@ -22,7 +22,6 @@ import * as core from './core.mjs';
|
|||||||
import * as ops from './ops.mjs';
|
import * as ops from './ops.mjs';
|
||||||
import * as emergency from './emergency.mjs';
|
import * as emergency from './emergency.mjs';
|
||||||
import * as dr from './dr.mjs';
|
import * as dr from './dr.mjs';
|
||||||
import * as mailsvc from './mailsvc.mjs';
|
|
||||||
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
|
||||||
import * as R from './ui/render.mjs';
|
import * as R from './ui/render.mjs';
|
||||||
|
|
||||||
@@ -46,8 +45,7 @@ export function registerUi(app, d) {
|
|||||||
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
|
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
|
||||||
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
|
||||||
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
|
||||||
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
|
if (health.bootstrap && !health.bootstrap.completed_at) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.hal_running_version || '확인 전')}, 첫 Agent ${health.bootstrap.first_agent_id ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
||||||
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
|
|
||||||
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
|
||||||
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
|
||||||
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
|
||||||
@@ -66,9 +64,7 @@ export function registerUi(app, d) {
|
|||||||
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
|
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
|
||||||
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
|
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
|
||||||
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
|
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
|
||||||
// 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다).
|
relayPage('/ops/mail', 'mail', '메일 서비스', 'mail'); relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
||||||
app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `<h2>HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)</h2>${mailServicesView(mailsvc.readback(d))}<h2>포털 메일 서비스</h2><div id="portal-mail">${R.statePanel('loading', '포털에서 불러오는 중')}</div>` }));
|
|
||||||
relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
|
|
||||||
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
|
||||||
// ---------------- 서버(§28)
|
// ---------------- 서버(§28)
|
||||||
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
|
||||||
@@ -76,7 +72,7 @@ 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)
|
||||||
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.bootstrapState(d), modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
|
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }, changelog()) }));
|
||||||
// ---------------- 작업(§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 }));
|
||||||
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
|
||||||
@@ -113,19 +109,3 @@ export function overviewLocal(d) {
|
|||||||
}
|
}
|
||||||
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
|
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
|
||||||
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
|
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
|
||||||
|
|
||||||
/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */
|
|
||||||
function mailServicesView(rb) {
|
|
||||||
const warn = []; if (rb.duplicates.length) warn.push(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`); if (rb.secretLeaks) warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
|
|
||||||
const t = R.table([
|
|
||||||
{ l: '도메인', f: (s) => `<b>${esc(s.domain)}</b><br><span class="muted">${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}</span>` },
|
|
||||||
{ l: '공개 주소', f: (s) => s.webUrl ? `<a href="${esc(s.webUrl)}" rel="noopener" target="_blank">${esc(s.publicHost)}</a>` : '—' },
|
|
||||||
{ l: '서버·인스턴스', f: (s) => `<a href="/ops/servers/${esc(s.serverId)}">${esc(s.serverName || s.serverId)}</a><br><span class="muted mono">${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}</span>` },
|
|
||||||
{ l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} <span class="muted">(AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})</span>${s.instance.observedVersion ? `<br><span class="muted">Agent 관측 ${esc(s.instance.observedVersion)}</span>` : ''}` },
|
|
||||||
{ l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' },
|
|
||||||
{ l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}<br><span class="muted">${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}</span>` },
|
|
||||||
{ l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}</span>` },
|
|
||||||
{ l: '비밀', f: (s) => `<span class="muted">참조 ${(s.secretRefs || []).length}건(값 없음)</span>` },
|
|
||||||
], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' });
|
|
||||||
return warn.join('') + t + `<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const PAGES = {
|
|||||||
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
|
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
mail: async () => { const el = document.getElementById('portal-mail') || body(); try { el.innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { el.innerHTML = R.relayError(e); } },
|
mail: async () => { try { body().innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
|
domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
|
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
|
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
/**
|
|
||||||
* scripts/mail-discover.mjs — 운영 중인 AriMail 인스턴스의 **읽기 전용** discovery 문서 만들기(HAL `mail-import` 의 입력, hal/mailsvc.mjs).
|
|
||||||
*
|
|
||||||
* sudo node scripts/mail-discover.mjs --unit arihakdang-mail.service --app-dir /srv/arihakdang-mail [--etc /etc/arihakdang-mail] [--vhost /etc/nginx/sites-available/mail.arihakdang.conf]
|
|
||||||
* [--hook /etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh] [--no-net] [--out <json>]
|
|
||||||
*
|
|
||||||
* 무엇을 읽나(모두 읽기 전용, 원본 파일은 바꾸지 않음): systemd 유닛 상태(systemctl show)·유닛 파일의 EnvironmentFile 경로, 앱 디렉터리의 current 심링크·REVISION(.json)·package.json,
|
|
||||||
* AriMail 도메인 등록부(data/domains.json, AriMail 1.0.7+ 형식)·건강 API(/api/health)·haraka-status.json, 게이트웨이 DB 와 도메인별 테넌트 DB 의 **집계**(계정 수·메일 수·DB 크기 — 주소·내용은 읽지 않음),
|
|
||||||
* 리스너(ss -ltnp 에서 유닛 PID 소유 포트), nginx vhost 의 server_name/proxy_pass, TLS 인증서 주체·만료(127.0.0.1:IMAPS 로 handshake), DNS(A·MX, --no-net 이면 생략), certbot 훅 경로.
|
|
||||||
* 비밀값은 읽지도 쓰지도 않는다: env/config 는 **키 이름과 경로·권한**만(값 없음), 메일 주소는 제외, TLS 개인키 경로만. 결과는 `secretRefs` 에 참조로 남는다.
|
|
||||||
* 이 파일은 단독 실행용이라 저장소의 다른 모듈을 import 하지 않는다(원본 호스트에 파일 하나만 복사해 실행).
|
|
||||||
*/
|
|
||||||
import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
||||||
import { join, basename } from 'node:path';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { hostname } from 'node:os';
|
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
|
||||||
import { connect as tlsConnect } from 'node:tls';
|
|
||||||
import { promises as dns } from 'node:dns';
|
|
||||||
|
|
||||||
export const FORMAT = 1;
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
|
||||||
const flag = (n) => args.includes('--' + n);
|
|
||||||
const readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } };
|
|
||||||
const run = (cmd, a) => { try { return execFileSync(cmd, a, { encoding: 'utf8', timeout: 10000 }); } catch (_) { return ''; } };
|
|
||||||
const mode = (f) => { try { const st = statSync(f); return { mode: (st.mode & 0o777).toString(8), size: st.size }; } catch (_) { return null; } };
|
|
||||||
const owner = (f) => run('stat', ['-c', '%U:%G', f]).trim() || null;
|
|
||||||
/** 환경 파일의 **키 이름만**(값은 절대 읽지 않는다) */
|
|
||||||
const envKeys = (f) => { try { return readFileSync(f, 'utf8').split('\n').map((l) => /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(l)).filter(Boolean).map((m) => m[1]); } catch (_) { return null; } };
|
|
||||||
|
|
||||||
export function unitShow(unit) {
|
|
||||||
const out = run('systemctl', ['show', unit, '-p', 'ActiveState,SubState,UnitFileState,NRestarts,ExecMainPID,ExecMainStartTimestamp,User,Group,WorkingDirectory,FragmentPath,EnvironmentFiles']);
|
|
||||||
const kv = Object.fromEntries(out.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)]; }));
|
|
||||||
if (!Object.keys(kv).length) return null;
|
|
||||||
return { active: kv.ActiveState || null, sub: kv.SubState || null, enabled: kv.UnitFileState || null, nRestarts: Number(kv.NRestarts || 0), mainPid: Number(kv.ExecMainPID || 0) || null, startedAt: kv.ExecMainStartTimestamp || null, user: kv.User || null, group: kv.Group || null, workingDirectory: kv.WorkingDirectory || null, fragmentPath: kv.FragmentPath || null, environmentFiles: (kv.EnvironmentFiles || '').split(/\s+/).map((x) => x.replace(/\s*\(ignore_errors=\w+\)/, '')).filter((x) => x.startsWith('/')) };
|
|
||||||
}
|
|
||||||
/** DB 집계: 표 이름·행 수만. 주소·본문은 읽지 않는다. */
|
|
||||||
export function dbCounts(file) {
|
|
||||||
if (!existsSync(file)) return null;
|
|
||||||
const db = new DatabaseSync(file, { readOnly: true });
|
|
||||||
try {
|
|
||||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r) => r.name); const counts = {};
|
|
||||||
for (const t of tables) { try { counts[t] = db.prepare(`SELECT count(*) c FROM "${t}"`).get().c; } catch (_) { counts[t] = null; } }
|
|
||||||
const integrity = db.prepare('PRAGMA integrity_check').get().integrity_check;
|
|
||||||
let activeAccounts = null; try { activeAccounts = db.prepare("SELECT count(*) c FROM mail_users WHERE status='active'").get().c; } catch (_) {}
|
|
||||||
const wal = existsSync(file + '-wal') ? statSync(file + '-wal').size : 0;
|
|
||||||
return { file, bytes: statSync(file).size + wal, integrity, accounts: counts.mail_users ?? null, activeAccounts, messages: counts.messages ?? null, attachments: counts.attachments ?? null, outboundQueue: counts.outbound_queue ?? null, migrations: counts.schema_migrations ?? null, tables: Object.keys(counts).length };
|
|
||||||
} finally { db.close(); }
|
|
||||||
}
|
|
||||||
export function listeners(pid) {
|
|
||||||
const out = run('ss', ['-ltnpH']); const rows = [];
|
|
||||||
for (const l of out.split('\n')) { const m = /LISTEN\s+\S+\s+\S+\s+(\S+):(\d+)\s+\S+\s*(.*)$/.exec(l.trim()); if (!m) continue; const p = /pid=(\d+)/.exec(m[3]); rows.push({ bind: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]), pid: p ? Number(p[1]) : null }); }
|
|
||||||
const mine = rows.filter((r) => r.pid && r.pid === pid).map((r) => ({ port: r.port, bind: r.bind, owner: 'unit' })).sort((a, b) => a.port - b.port);
|
|
||||||
const mailPorts = [25, 110, 143, 465, 587, 993, 995]; const others = rows.filter((r) => mailPorts.includes(r.port) && r.pid !== pid).map((r) => ({ port: r.port, bind: r.bind, owner: 'other', pid: r.pid }));
|
|
||||||
return { unit: mine, otherMailPortOwners: others, port25Open: rows.some((r) => r.port === 25) };
|
|
||||||
}
|
|
||||||
const dirBytes = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) { try { n += statSync(p).size; } catch (_) {} } } }; try { walk(dir); } catch (_) {} return n; };
|
|
||||||
function tlsInfo(host, port = 993) {
|
|
||||||
return new Promise((resolve) => { const t = setTimeout(() => { try { s.destroy(); } catch (_) {} resolve({ host, port, error: 'timeout' }); }, 5000); const s = tlsConnect({ host: '127.0.0.1', port, servername: host, rejectUnauthorized: false }, () => { const c = s.getPeerCertificate(); clearTimeout(t); s.destroy(); resolve({ host, port, subject: c.subject ? `CN=${c.subject.CN}` : null, issuer: c.issuer ? `${c.issuer.O || ''} ${c.issuer.CN || ''}`.trim() : null, validFrom: c.valid_from ? new Date(c.valid_from).toISOString() : null, validTo: c.valid_to ? new Date(c.valid_to).toISOString() : null, altNames: c.subjectaltname || null, protocol: s.getProtocol() }); }); s.on('error', (e) => { clearTimeout(t); resolve({ host, port, error: e.code || e.message }); }); });
|
|
||||||
}
|
|
||||||
/** 공개 DNS(1.1.1.1·8.8.8.8)로 조회한다 — 호스트의 /etc/hosts(자기 이름 → 127.0.0.1)가 증거를 오염시키지 않게. */
|
|
||||||
async function dnsInfo(hosts, domains) {
|
|
||||||
const r = new dns.Resolver({ timeout: 4000, tries: 2 }); try { r.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {}
|
|
||||||
const out = { _resolver: 'public:1.1.1.1,8.8.8.8' };
|
|
||||||
for (const h of hosts) { out[h] = {}; try { out[h].a = await r.resolve4(h); } catch (e) { out[h].a = []; out[h].error = e.code || e.message; } }
|
|
||||||
for (const dn of domains) { out[dn] = out[dn] || {}; try { out[dn].mx = (await r.resolveMx(dn)).sort((a, b) => a.priority - b.priority).map((m) => m.exchange); } catch (e) { out[dn].mx = []; } try { out[dn].txt = (await r.resolveTxt(dn)).map((t) => t.join('')).filter((t) => /^v=spf1/i.test(t)); } catch (e) { out[dn].txt = []; } }
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
async function httpHealth(url) { try { const r = await fetch(url, { signal: AbortSignal.timeout(5000) }); const j = await r.json().catch(() => null); return { status: r.status, body: j && typeof j === 'object' ? { ok: j.ok, version: j.version, domains: j.domains } : null }; } catch (e) { return { status: 0, error: e.code || e.message }; } }
|
|
||||||
|
|
||||||
/** discovery 문서 만들기(순수: 파일 시스템·명령 결과만 읽는다). opts.net=false 면 TLS/DNS/HTTP 를 생략. */
|
|
||||||
export async function discover({ unit, appDir, etcDir = null, vhost = null, hook = null, net = true, host = hostname() }) {
|
|
||||||
const us = unitShow(unit); const haraka = unitShow(unit.replace(/\.service$/, '') + '-haraka.service');
|
|
||||||
const current = existsSync(join(appDir, 'current')) ? realpathSync(join(appDir, 'current')) : null;
|
|
||||||
const revision = readJson(join(appDir, 'current', 'REVISION.json')) || (existsSync(join(appDir, 'current', 'REVISION')) ? { commit: readFileSync(join(appDir, 'current', 'REVISION'), 'utf8').trim() } : {});
|
|
||||||
const pkg = readJson(join(appDir, 'current', 'package.json'), {});
|
|
||||||
const nodeBin = us && us.fragmentPath ? (/^ExecStart=(\S+)/m.exec(readFileSync(us.fragmentPath, 'utf8')) || [])[1] || null : null;
|
|
||||||
const nodeVersion = nodeBin ? run(nodeBin, ['--version']).trim() || null : null;
|
|
||||||
const dataDir = join(appDir, 'data'); const reg = readJson(join(dataDir, 'domains.json'), { domains: {} });
|
|
||||||
const regDomains = Array.isArray(reg.domains) ? reg.domains : Object.values(reg.domains || {});
|
|
||||||
const domains = []; const byDomain = {}; let accounts = 0, messages = 0, storageBytes = 0;
|
|
||||||
const main = dbCounts(join(dataDir, 'mail-gateway.sqlite'));
|
|
||||||
for (const x of regDomains) {
|
|
||||||
const dn = String(x.domain || '').toLowerCase(); const tenant = join(dataDir, 'domains', dn, 'mail.sqlite'); const isPrimary = !!x.primary || dn === String(reg.primary || '').toLowerCase();
|
|
||||||
const c = isPrimary ? main : dbCounts(tenant); const tenantDir = join(dataDir, 'domains', dn);
|
|
||||||
const agg = c ? { accounts: c.accounts, activeAccounts: c.activeAccounts, mailboxes: c.accounts, messages: c.messages, attachments: c.attachments, outboundQueue: c.outboundQueue, dbBytes: c.bytes, dbIntegrity: c.integrity, dbMigrations: c.migrations, storageBytes: isPrimary ? dirBytes(dataDir) - dirBytes(join(dataDir, 'domains')) - dirBytes(join(dataDir, 'backups')) : dirBytes(tenantDir) } : null;
|
|
||||||
if (agg) { accounts += agg.accounts || 0; messages += agg.messages || 0; storageBytes += agg.storageBytes || 0; byDomain[dn] = agg; }
|
|
||||||
domains.push({ domain: dn, enabled: x.enabled !== false, primary: isPrimary, hosts: (x.hosts || []).map(String), authMode: x.auth && x.auth.mode || null, operating: x.operating || 'active', ariweb: x.ariweb ? { kind: x.ariweb.kind || null, siteId: x.ariweb.siteId || null, groupId: x.ariweb.groupId || null, mailServiceId: x.ariweb.mailServiceId || null } : null, note: x.note || null, tenantDb: isPrimary ? join(dataDir, 'mail-gateway.sqlite') : (existsSync(tenant) ? tenant : null), createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
|
|
||||||
}
|
|
||||||
const hs = readJson(join(dataDir, 'haraka-status.json'), null);
|
|
||||||
const cfg = readJson(join(dataDir, 'config.json'), null); const configKeys = cfg ? Object.keys(cfg).filter((k) => k !== '_meta') : null;
|
|
||||||
const providerKind = hs && hs.enabled ? 'haraka' : cfg && (cfg.outbound || cfg.inbound) ? String((cfg.outbound && cfg.outbound.provider) || (cfg.inbound && cfg.inbound.provider) || (configKeys && configKeys.some((k) => /^RESEND/.test(k)) ? 'resend' : 'unknown')) : (configKeys && configKeys.some((k) => /^RESEND/.test(k)) ? 'resend' : 'unknown');
|
|
||||||
const secretRefs = [];
|
|
||||||
for (const f of (us && us.environmentFiles) || []) secretRefs.push({ name: basename(f), ref: `file:${f}`, ...(mode(f) ? { mode: mode(f).mode } : {}), owner: owner(f), keys: envKeys(f) });
|
|
||||||
if (existsSync(join(dataDir, 'config.json'))) secretRefs.push({ name: 'config.json', ref: `file:${join(dataDir, 'config.json')}`, mode: mode(join(dataDir, 'config.json')).mode, owner: owner(join(dataDir, 'config.json')), keys: configKeys });
|
|
||||||
if (etcDir && existsSync(join(etcDir, 'tls'))) secretRefs.push({ name: 'tls', ref: `dir:${join(etcDir, 'tls')}`, files: readdirSync(join(etcDir, 'tls')) });
|
|
||||||
if (existsSync(join(dataDir, 'setup-token'))) secretRefs.push({ name: 'setup-token', ref: `file:${join(dataDir, 'setup-token')}`, mode: mode(join(dataDir, 'setup-token')).mode });
|
|
||||||
const vh = vhost && existsSync(vhost) ? readFileSync(vhost, 'utf8') : '';
|
|
||||||
const evidence = { nginx: vhost ? { vhostFile: vhost, exists: !!vh, serverNames: [...new Set([...vh.matchAll(/server_name\s+([^;]+);/g)].flatMap((m) => m[1].trim().split(/\s+/)))], proxyPass: [...new Set([...vh.matchAll(/proxy_pass\s+([^;]+);/g)].map((m) => m[1].trim()))], httpRedirect: /return\s+301\s+https/.test(vh), sslCertificate: (/ssl_certificate\s+([^;]+);/.exec(vh) || [])[1] || null } : null, certbot: hook ? { hookFile: hook, exists: existsSync(hook), restartsUnit: existsSync(hook) ? /systemctl\s+restart/.test(readFileSync(hook, 'utf8')) : null } : null, haraka: hs ? { enabled: !!hs.enabled, running: !!hs.running, inbound: !!hs.inbound, outbound: !!hs.outbound, listen: hs.listen || [], version: hs.version || null, dkim: !!hs.dkim, updatedAt: hs.updatedAt || null } : null };
|
|
||||||
const hostsAll = [...new Set(domains.flatMap((x) => x.hosts.map((h) => h.split('/')[0])))];
|
|
||||||
let health = null;
|
|
||||||
if (net) {
|
|
||||||
health = await httpHealth('http://127.0.0.1:8790/api/health');
|
|
||||||
evidence.tls = hostsAll.length ? await tlsInfo(hostsAll[0]) : null;
|
|
||||||
evidence.dns = await dnsInfo(hostsAll, domains.map((x) => x.domain));
|
|
||||||
evidence.http = { localHealth: health, public: {} };
|
|
||||||
for (const h of hostsAll.slice(0, 4)) { try { const r = await fetch(`https://${h}/api/health`, { signal: AbortSignal.timeout(8000) }); const j = await r.json().catch(() => null); evidence.http.public[h] = { status: r.status, ok: !!(j && j.ok), version: j && j.version }; const r2 = await fetch(`http://${h}/`, { redirect: 'manual', signal: AbortSignal.timeout(8000) }); evidence.http.public[h].httpRedirect = { status: r2.status, location: r2.headers.get('location') }; } catch (e) { evidence.http.public[h] = { error: e.code || e.message }; } }
|
|
||||||
}
|
|
||||||
const doc = {
|
|
||||||
format: FORMAT, collectedAt: new Date().toISOString(), collector: 'scripts/mail-discover.mjs',
|
|
||||||
source: { hostname: host, unit, unitState: us, harakaUnit: haraka, appDir, currentTarget: current, user: (us && us.user) || (owner(dataDir) || '').split(':')[0] || null, nodeBin, nodeVersion, revision, packageName: pkg.name || null, packageVersion: pkg.version || null, healthUrl: 'http://127.0.0.1:8790/api/health', health: health && health.body ? health.body : { ok: null, version: pkg.version || null, domains: domains.length, note: net ? 'health 응답 없음' : 'no-net' }, dataDir, dataBytes: dirBytes(dataDir), releases: existsSync(join(appDir, 'releases')) ? readdirSync(join(appDir, 'releases')).sort() : [], previous: readJson(join(appDir, '.previous.json'), null) },
|
|
||||||
domains, listeners: us && us.mainPid ? listeners(us.mainPid).unit : [], listenerDetail: us && us.mainPid ? listeners(us.mainPid) : null,
|
|
||||||
aggregates: { accounts, mailboxes: accounts, messages, storageBytes, domains: domains.length, enabledDomains: domains.filter((x) => x.enabled).length, byDomain },
|
|
||||||
provider: { kind: providerKind, haraka: evidence.haraka, configPath: existsSync(join(dataDir, 'config.json')) ? join(dataDir, 'config.json') : null, configKeys },
|
|
||||||
secretRefs, evidence,
|
|
||||||
};
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && /mail-discover\.mjs$/.test(process.argv[1]) && !process.env.ARIWEB_DISCOVER_NO_MAIN) {
|
|
||||||
const unit = opt('unit'); const appDir = opt('app-dir'); if (!unit || !appDir) { console.error('--unit <유닛> --app-dir <앱 디렉터리> 가 필요합니다'); process.exit(1); }
|
|
||||||
const doc = await discover({ unit: unit.endsWith('.service') ? unit : unit + '.service', appDir, etcDir: opt('etc'), vhost: opt('vhost'), hook: opt('hook'), net: !flag('no-net') });
|
|
||||||
const text = JSON.stringify(doc, null, 1);
|
|
||||||
if (/-----BEGIN|password=|secret=/i.test(text)) { console.error('비밀 형태의 내용이 포함되어 출력하지 않습니다'); process.exit(2); }
|
|
||||||
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); console.error(`discovery → ${opt('out')} (${doc.domains.length} 도메인, 계정 ${doc.aggregates.accounts}, 메일 ${doc.aggregates.messages})`); } else console.log(text);
|
|
||||||
}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
/**
|
|
||||||
* scripts/webmaker-inventory.mjs — 운영 Webmaker ↔ 포털 ↔ HAL 연동 inventory 와 dry-run(읽기 전용).
|
|
||||||
*
|
|
||||||
* node scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker [--sites-root /var/www/webmaker-sites]
|
|
||||||
* [--portal-db /srv/ariweb/portal/data/portal.sqlite] [--hal-db /srv/ariweb/hal/data/hal.sqlite]
|
|
||||||
* [--app-dir /srv/webmaker/current] [--nginx-enabled /etc/nginx/sites-enabled] [--gitea-refs <git ls-remote --heads 출력 파일>] [--out <json>]
|
|
||||||
*
|
|
||||||
* 무엇을 하나: 세 저장소(Webmaker JSON·포털 DB·HAL DB)를 **읽기만** 하고 안정 식별자로 대조해 연동 판정을 낸다.
|
|
||||||
* - 정본(source of truth): Webmaker 프로젝트·회원·도메인·배포 이력은 Webmaker 데이터(`data/*.json`)가 정본이다. 포털은 "연결 등록부"
|
|
||||||
* (Webmaker `ariweb-links.json` ↔ 포털 `sites.webmaker_project_id`)로 프로젝트를 자기 사이트에 연결할 뿐 프로젝트를 복제하지 않는다.
|
|
||||||
* HAL 은 사이트·계정을 모델링하지 않고 모듈(webmaker) 설치·버전·건강만 등록부(`module_targets`)에 둔다. 그래서 "HAL 리소스로 가져오기" 의
|
|
||||||
* 실체는 (1) HAL 모듈 등록(서버·버전·건강) (2) 포털 연결 등록부의 일치 확인이며, 그 밖의 복제는 없다. Data Plane(Webmaker 서비스·배포 사이트)은
|
|
||||||
* HAL 이 죽어도 그대로 동작한다(HAL 은 제어 평면이다).
|
|
||||||
* - 안정 식별자: Webmaker 프로젝트 id(32 hex, 변하지 않음) · 포털 site id(`s_…`) · group id(`g_…`) · HAL server id(`srv_…`).
|
|
||||||
* 포털 SSO 사용자의 Webmaker 로그인은 `aw-<group8>-<user8>` 이므로 접두어로 포털 그룹·사용자와 대조한다.
|
|
||||||
* - 판정: linked_consistent(양쪽 등록부 일치) · link_portal_only / link_webmaker_only(한쪽만 — 충돌) · link_mismatch(다른 사이트를 가리킴 — 충돌)
|
|
||||||
* · duplicate(같은 프로젝트를 둘 이상의 사이트가 가리킴 — 충돌) · missing_project(포털이 없는 프로젝트를 가리킴 — 누락) · unlinked_native(포털 연결이
|
|
||||||
* 없는 Webmaker 고유 프로젝트 — 변경 대상 아님, 가져오려면 포털 계정이 있어야 하므로 사람 결정 필요).
|
|
||||||
* - 비밀값(해시·salt·토큰·키·세션)은 읽지도 출력하지도 않는다. 이메일은 앞 2글자만 남긴다.
|
|
||||||
*/
|
|
||||||
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
|
||||||
const readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } };
|
|
||||||
const maskEmail = (e) => (typeof e === 'string' && e.includes('@') ? e.slice(0, 2) + '…@' + e.split('@')[1] : null);
|
|
||||||
const SECRET = /pass|hash|salt|secret|token|key|session|cookie|credential/i;
|
|
||||||
const countFiles = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(join(d, e.name)); else if (e.isFile()) n++; } }; try { walk(dir); } catch (_) { return null; } return n; };
|
|
||||||
|
|
||||||
/** Webmaker 데이터 루트에서 비밀 없는 inventory 를 읽는다. */
|
|
||||||
export function readWebmaker(dataRoot, { sitesRoot = null, nginxEnabled = null, appDir = null } = {}) {
|
|
||||||
const usersRaw = readJson(join(dataRoot, 'users.json'), {});
|
|
||||||
const users = Object.values(usersRaw).filter((u) => u && u.id).map((u) => ({ id: u.id, name: u.name, role: u.role || 'user', accountType: u.accountType || 'domain', sso: u.sso || null, createdAt: u.createdAt || null, lastLoginAt: u.lastLoginAt || null }));
|
|
||||||
const links = (readJson(join(dataRoot, 'ariweb-links.json'), { links: {} }) || {}).links || {};
|
|
||||||
const sitesDir = join(dataRoot, 'sites'); const projects = [];
|
|
||||||
if (existsSync(sitesDir)) for (const f of readdirSync(sitesDir).filter((x) => /^[0-9a-f]{32}\.json$/.test(x)).sort()) {
|
|
||||||
const s = readJson(join(sitesDir, f)); if (!s || !s.id) continue;
|
|
||||||
const deployments = Array.isArray(s.deployments) ? s.deployments : []; const last = deployments[deployments.length - 1] || null;
|
|
||||||
const changes = (s.git && Array.isArray(s.git.changes)) ? s.git.changes : []; const pushed = changes.filter((c) => c.pushed).slice(-1)[0] || null;
|
|
||||||
const vhost = nginxEnabled ? existsSync(join(nginxEnabled, `wh-${s.id}.conf`)) : null;
|
|
||||||
projects.push({ id: s.id, title: String(s.title || '').slice(0, 60), ownerId: s.ownerId || null, status: s.status || null, mode: s.mode || null, createdAt: s.createdAt || null, updatedAt: s.updatedAt || null,
|
|
||||||
domains: Array.isArray(s.domains) ? s.domains.map((d) => (typeof d === 'string' ? d : d.name || d.domain)).filter(Boolean) : [],
|
|
||||||
deployments: deployments.length, lastDeploy: last ? { target: last.targetId || last.target || null, at: last.at || last.deployedAt || null, url: last.url || last.publicUrl || null } : null,
|
|
||||||
git: { changes: changes.length, lastPushed: pushed ? { no: pushed.no, kind: pushed.kind, commit: pushed.commit, branch: pushed.branch } : null },
|
|
||||||
langs: s.langs && Array.isArray(s.langs.list) ? s.langs.list : null, unpublishedAt: s.unpublishedAt || null,
|
|
||||||
siteDirFiles: sitesRoot ? countFiles(join(sitesRoot, s.id)) : null, vhost,
|
|
||||||
link: links[s.id] ? { siteId: links[s.id].siteId, groupId: links[s.id].groupId, siteName: links[s.id].siteName, linkedAt: links[s.id].linkedAt } : null });
|
|
||||||
}
|
|
||||||
const domainsRaw = readJson(join(dataRoot, 'domains.json'), { domains: [] }) || {};
|
|
||||||
const domains = (Array.isArray(domainsRaw) ? domainsRaw : domainsRaw.domains || []).map((d) => ({ name: d.name || d.domain, siteId: d.siteId || null, ownerId: d.ownerId || d.userId || null, status: d.status || null }));
|
|
||||||
const dt = readJson(join(dataRoot, 'deploy-targets.json'), { defaultId: null, targets: [] }) || {};
|
|
||||||
const deployTargets = (dt.targets || []).map((t) => ({ id: t.id, name: t.name, type: t.type, baseDir: t.baseDir || null, publicBaseUrl: t.publicBaseUrl || null, isDefault: t.id === dt.defaultId }));
|
|
||||||
const ms = readJson(join(dataRoot, 'mail-servers.json'), { items: [] }) || {}; const msItems = Array.isArray(ms.items) ? ms.items : Object.values(ms.items || {});
|
|
||||||
const mailServers = msItems.map((m) => ({ id: m.id, name: m.name || null, provider: m.provider || m.kind || m.type || null, isDefault: m.id === ms.defaultId }));
|
|
||||||
const linkEntries = Object.entries(links).map(([projectId, l]) => ({ projectId, siteId: l.siteId, groupId: l.groupId }));
|
|
||||||
const rev = appDir ? (readJson(join(appDir, 'REVISION.json')) || null) : null; // HAL 이 설치한 모듈은 앱 디렉터리(current/)에 REVISION.json 을 둔다
|
|
||||||
const config = readJson(join(dataRoot, 'config.json'), {}) || {}; const v = config.values || {};
|
|
||||||
const out = { users, projects, domains, deployTargets, mailServers, links: linkEntries,
|
|
||||||
config: { publicBaseUrl: v.service?.publicBaseUrl || null, sourceProvider: v.source?.provider || null, mailProvider: v.mail?.provider || null, portalUrl: v.ariweb?.portalUrl || null }, revision: rev };
|
|
||||||
assertNoSecrets(out); return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 포털 DB(읽기 전용) inventory. */
|
|
||||||
export function readPortal(dbFile) {
|
|
||||||
const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all();
|
|
||||||
const out = {
|
|
||||||
users: all('SELECT id, email, display_name, status, is_operator, created_at FROM users').map((u) => ({ id: u.id, email: maskEmail(u.email), displayName: u.display_name, status: u.status, isOperator: !!u.is_operator, createdAt: u.created_at })),
|
|
||||||
groups: all('SELECT id, name, kind, owner_user_id, status FROM groups').map((g) => ({ id: g.id, name: g.name, kind: g.kind, ownerUserId: g.owner_user_id, status: g.status })),
|
|
||||||
sites: all('SELECT id, group_id, name, webmaker_project_id, lifecycle, deploy_mode, primary_domain, server_id, current_version FROM sites').map((s) => ({ id: s.id, groupId: s.group_id, name: s.name, webmakerProjectId: s.webmaker_project_id, lifecycle: s.lifecycle, deployMode: s.deploy_mode, primaryDomain: s.primary_domain, serverId: s.server_id, currentVersion: s.current_version })),
|
|
||||||
domains: all('SELECT id, group_id, site_id, name, purpose, is_primary, ownership_state, dns_state FROM domains').map((x) => ({ id: x.id, groupId: x.group_id, siteId: x.site_id, name: x.name, purpose: x.purpose, isPrimary: !!x.is_primary, ownershipState: x.ownership_state, dnsState: x.dns_state })),
|
|
||||||
mailServices: all('SELECT id, group_id, site_id, kind, domain, status FROM mail_services').map((m) => ({ id: m.id, groupId: m.group_id, siteId: m.site_id, kind: m.kind, domain: m.domain, status: m.status })),
|
|
||||||
deployments: all('SELECT id, site_id, status, version_label, created_at FROM deployments').map((x) => ({ id: x.id, siteId: x.site_id, status: x.status, version: x.version_label, createdAt: x.created_at })),
|
|
||||||
};
|
|
||||||
d.close(); assertNoSecrets(out); return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HAL DB(읽기 전용) inventory — 서버·Agent·모듈 등록부·릴리스. */
|
|
||||||
export function readHal(dbFile) {
|
|
||||||
const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all();
|
|
||||||
const out = {
|
|
||||||
servers: all('SELECT id, name, role, location, status, agent_id, agent_version, last_seen_at FROM servers').map((s) => ({ id: s.id, name: s.name, role: s.role, location: s.location, status: s.status, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at })),
|
|
||||||
agents: all('SELECT id, server_id, kind, status, version, last_seen_at FROM agents').map((a) => ({ id: a.id, serverId: a.server_id, kind: a.kind, status: a.status, version: a.version, lastSeenAt: a.last_seen_at })),
|
|
||||||
modules: all('SELECT name, target_version FROM modules'),
|
|
||||||
moduleTargets: all('SELECT module, server_id, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets').map((m) => ({ module: m.module, serverId: m.server_id, targetVersion: m.target_version, actualVersion: m.actual_version, health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error })),
|
|
||||||
releases: all('SELECT version, main_commit, status FROM releases').map((r) => ({ version: r.version, mainCommit: r.main_commit, status: r.status })),
|
|
||||||
bootstrap: (all('SELECT release_version, completed_at FROM bootstrap_state WHERE id=1')[0] || null),
|
|
||||||
opsAccounts: all('SELECT count(*) AS c FROM ops_accounts')[0].c,
|
|
||||||
};
|
|
||||||
d.close(); assertNoSecrets(out); return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** dry-run: 세 inventory 를 안정 식별자로 대조해 판정과 충돌·누락·중복 목록을 만든다(아무것도 쓰지 않는다). */
|
|
||||||
export function dryRun({ webmaker, portal = null, hal = null, giteaRefs = null }) {
|
|
||||||
const findings = []; const add = (severity, kind, detail) => findings.push({ severity, kind, ...detail });
|
|
||||||
const portalSites = portal ? portal.sites : []; const byProject = new Map();
|
|
||||||
for (const s of portalSites) if (s.webmakerProjectId) { if (!byProject.has(s.webmakerProjectId)) byProject.set(s.webmakerProjectId, []); byProject.get(s.webmakerProjectId).push(s); }
|
|
||||||
const projects = webmaker.projects.map((p) => {
|
|
||||||
const ps = byProject.get(p.id) || []; let verdict;
|
|
||||||
if (ps.length > 1) { verdict = 'duplicate'; add('conflict', 'duplicate_portal_sites', { projectId: p.id, siteIds: ps.map((s) => s.id) }); }
|
|
||||||
else if (ps.length === 1 && p.link) { if (p.link.siteId === ps[0].id && p.link.groupId === ps[0].groupId) verdict = 'linked_consistent'; else { verdict = 'link_mismatch'; add('conflict', 'link_mismatch', { projectId: p.id, webmakerLink: p.link.siteId, portalSite: ps[0].id }); } }
|
|
||||||
else if (ps.length === 1 && !p.link) { verdict = 'link_portal_only'; add('conflict', 'link_portal_only', { projectId: p.id, portalSite: ps[0].id }); }
|
|
||||||
else if (!ps.length && p.link) { verdict = portal ? 'link_webmaker_only' : 'link_unverified'; if (portal) add('conflict', 'link_webmaker_only', { projectId: p.id, webmakerLink: p.link.siteId }); }
|
|
||||||
else verdict = 'unlinked_native';
|
|
||||||
return { projectId: p.id, ownerId: p.ownerId, verdict, portalSiteId: ps[0]?.id || null, portalGroupId: ps[0]?.groupId || null, domains: p.domains, deployed: p.deployments > 0, action: verdict === 'linked_consistent' ? 'none' : verdict === 'unlinked_native' ? 'keep (가져오기는 포털 계정 필요 — 사람 결정)' : 'human_review' };
|
|
||||||
});
|
|
||||||
const known = new Set(webmaker.projects.map((p) => p.id));
|
|
||||||
for (const s of portalSites) if (s.webmakerProjectId && !known.has(s.webmakerProjectId)) add('missing', 'missing_project', { siteId: s.id, projectId: s.webmakerProjectId });
|
|
||||||
// 회원: SSO 로그인(aw-<g8>-<u8>)은 포털 그룹·사용자 접두어와 대조, 고유 회원은 포털 신원 없음(변경 대상 아님)
|
|
||||||
const g8 = new Map((portal ? portal.groups : []).map((g) => [g.id.replace(/^g_/, '').slice(0, 8).toLowerCase(), g.id])); const u8 = new Map((portal ? portal.users : []).map((u) => [u.id.replace(/^u_/, '').slice(0, 8).toLowerCase(), u.id]));
|
|
||||||
const users = webmaker.users.map((u) => { const m = /^aw-([0-9a-f]{8})-([0-9a-f]{8})$/.exec(u.id); if (!m) return { userId: u.id, role: u.role, verdict: 'native_no_portal_identity', action: 'keep' }; const gid = g8.get(m[1]) || null, uid = u8.get(m[2]) || null; if (!portal) return { userId: u.id, role: u.role, verdict: 'sso_unverified', action: 'none' }; if (gid && uid) return { userId: u.id, role: u.role, verdict: 'sso_consistent', portalGroupId: gid, portalUserId: uid, action: 'none' }; add('conflict', 'sso_identity_missing', { userId: u.id, portalGroupId: gid, portalUserId: uid }); return { userId: u.id, role: u.role, verdict: 'sso_identity_missing', action: 'human_review' }; });
|
|
||||||
// 도메인: 이름으로 대조
|
|
||||||
const pd = new Map((portal ? portal.domains : []).map((d) => [d.name.toLowerCase(), d]));
|
|
||||||
const domains = webmaker.domains.map((d) => { const p = pd.get(String(d.name).toLowerCase()); if (!portal) return { name: d.name, siteId: d.siteId, verdict: 'unverified' }; if (!p) return { name: d.name, siteId: d.siteId, verdict: 'webmaker_only', action: 'keep' }; const site = projects.find((x) => x.projectId === d.siteId); if (site && site.portalSiteId && p.siteId === site.portalSiteId) return { name: d.name, siteId: d.siteId, verdict: 'consistent', portalDomainId: p.id }; add('conflict', 'domain_site_mismatch', { name: d.name, webmakerSite: d.siteId, portalSite: p.siteId }); return { name: d.name, siteId: d.siteId, verdict: 'mismatch', action: 'human_review' }; });
|
|
||||||
// Gitea refs: 프로젝트마다 sites/<id>/main 이 있고 마지막 푸시 커밋과 같은지
|
|
||||||
let gitea = null;
|
|
||||||
if (giteaRefs) { const refs = new Map(giteaRefs.split('\n').map((l) => l.trim().split(/\s+/)).filter((x) => x.length === 2).map(([sha, ref]) => [ref.replace(/^refs\/heads\//, ''), sha])); gitea = webmaker.projects.filter((p) => p.git.changes > 0).map((p) => { const main = refs.get(`sites/${p.id}/main`) || null; const lp = p.git.lastPushed; const expect = lp ? refs.get(lp.branch) || null : null; const verdict = !main ? 'main_missing' : lp && expect !== lp.commit ? 'last_push_mismatch' : 'ok'; if (verdict !== 'ok') add('missing', 'gitea_ref', { projectId: p.id, verdict, main, lastPushed: lp }); return { projectId: p.id, main, verdict }; }); }
|
|
||||||
// HAL: webmaker 모듈 등록(서버·버전·건강)과 설치 REVISION 대조
|
|
||||||
let halModule = null;
|
|
||||||
if (hal) { const mts = hal.moduleTargets.filter((m) => m.module === 'webmaker'); if (!mts.length) { add('missing', 'hal_module_target_missing', { module: 'webmaker' }); halModule = { registered: false }; } else { const rev = webmaker.revision; const t = mts[0]; const versionOk = !rev || rev.version === t.actualVersion; if (!versionOk) add('conflict', 'hal_version_mismatch', { installed: rev.version, halActual: t.actualVersion }); if (t.health !== 'ok') add('conflict', 'hal_health', { health: t.health, error: t.lastError }); halModule = { registered: true, serverId: t.serverId, targetVersion: t.targetVersion, actualVersion: t.actualVersion, installedVersion: rev ? rev.version : null, upstreamVersion: rev ? rev.upstreamVersion : null, health: t.health, lastCheckAt: t.lastCheckAt, agentOnline: hal.agents.some((a) => a.serverId === t.serverId && a.status === 'active') }; } }
|
|
||||||
const summary = {
|
|
||||||
webmaker: { users: webmaker.users.length, usersNative: users.filter((u) => u.verdict.startsWith('native')).length, usersSso: users.filter((u) => u.verdict.startsWith('sso')).length, projects: webmaker.projects.length, projectsDeployed: webmaker.projects.filter((p) => p.deployments > 0).length, domains: webmaker.domains.length, deployTargets: webmaker.deployTargets.length, mailServers: webmaker.mailServers.length, links: webmaker.links.length },
|
|
||||||
portal: portal ? { users: portal.users.length, groups: portal.groups.length, sites: portal.sites.length, sitesLinked: portalSites.filter((s) => s.webmakerProjectId).length, domains: portal.domains.length, mailServices: portal.mailServices.length } : null,
|
|
||||||
hal: hal ? { servers: hal.servers.length, agents: hal.agents.length, moduleTargets: hal.moduleTargets.length, opsAccounts: hal.opsAccounts, bootstrapComplete: !!(hal.bootstrap && hal.bootstrap.completed_at) } : null,
|
|
||||||
verdicts: countBy(projects.map((p) => p.verdict)), conflicts: findings.filter((f) => f.severity === 'conflict').length, missing: findings.filter((f) => f.severity === 'missing').length,
|
|
||||||
};
|
|
||||||
const out = { generatedAt: new Date().toISOString(), authority: { projects: 'webmaker', accounts: 'webmaker(native)/portal(sso)', links: 'webmaker ariweb-links.json ↔ portal sites.webmaker_project_id (both must agree)', module: 'hal module_targets' }, summary, projects, users, domains, gitea, halModule, findings, applyPlan: findings.length ? 'blocked: 충돌·누락이 0 이 될 때까지 적용하지 않는다' : 'no-op: 등록부가 이미 일치한다 — 적용할 변경 없음' };
|
|
||||||
assertNoSecrets(out); return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function countBy(list) { const o = {}; for (const x of list) o[x] = (o[x] || 0) + 1; return o; }
|
|
||||||
function requireSqlite() { return process.getBuiltinModule ? process.getBuiltinModule('node:sqlite') : null; }
|
|
||||||
/** 출력 객체 어디에도 비밀처럼 보이는 키가 없어야 한다(방어선). */
|
|
||||||
export function assertNoSecrets(o, path = '') { if (!o || typeof o !== 'object') return; for (const [k, v] of Object.entries(o)) { if (SECRET.test(k) && !/^(sessionGen|isDefault|lastCheckAt)$/.test(k)) throw new Error(`비밀로 보이는 키가 출력에 있습니다: ${path}${k}`); assertNoSecrets(v, `${path}${k}.`); } }
|
|
||||||
|
|
||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
||||||
const dataRoot = opt('webmaker-data'); if (!dataRoot) { console.error('--webmaker-data <Webmaker 데이터 루트> 가 필요합니다'); process.exit(1); }
|
|
||||||
const webmaker = readWebmaker(dataRoot, { sitesRoot: opt('sites-root'), nginxEnabled: opt('nginx-enabled'), appDir: opt('app-dir') });
|
|
||||||
const portal = opt('portal-db') ? readPortal(opt('portal-db')) : null; const hal = opt('hal-db') ? readHal(opt('hal-db')) : null;
|
|
||||||
const giteaRefs = opt('gitea-refs') ? readFileSync(opt('gitea-refs'), 'utf8') : null;
|
|
||||||
const result = { inventory: { webmaker, portal, hal }, dryRun: dryRun({ webmaker, portal, hal, giteaRefs }) };
|
|
||||||
const text = JSON.stringify(result, null, 1);
|
|
||||||
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); }
|
|
||||||
const s = result.dryRun.summary; console.log(JSON.stringify({ summary: s, halModule: result.dryRun.halModule, findings: result.dryRun.findings, applyPlan: result.dryRun.applyPlan }, null, 1));
|
|
||||||
process.exit(s.conflicts || s.missing ? 2 : 0);
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
/** test/hal-cli.test.mjs — HAL CLI(시스템 신원) 회귀 테스트: config-set / module-deploy / job.
|
|
||||||
* 운영자 콘솔이 닿지 않는 연동 단계에서 HAL 데이터 소유자가 쓰는 명령이다. 별도 프로세스(node hal/index.mjs …)로 실행해 실제 CLI 경로를 검증하고,
|
|
||||||
* 같은 데이터 디렉터리를 연 서비스 측 작업 엔진이 CLI 가 넣은 작업을 집어 실행하는지(실행은 serve 프로세스 몫) 확인한다. 비밀값은 출력하지 않는다. */
|
|
||||||
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
|
||||||
import { createHalDeps } from '../hal/deps.mjs';
|
|
||||||
import * as core from '../hal/core.mjs';
|
|
||||||
import { listAudit } from '../lib/audit.mjs';
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { readFileSync, mkdirSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
function makeDataDir() { const dir = join(tmpDir('hal-cli-'), 'data'); mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; }
|
|
||||||
/** CLI 를 별도 프로세스로. 실패(종료 코드≠0)는 { code, stdout, stderr } 로 돌려준다. */
|
|
||||||
function cli(dataDir, args) {
|
|
||||||
try { const out = execFileSync(process.execPath, ['--no-warnings', join(ROOT, 'hal', 'index.mjs'), ...args], { env: { ...process.env, ARIWEB_HAL_DATA: dataDir, PORT: '0' }, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); return { code: 0, stdout: out, stderr: '' }; }
|
|
||||||
catch (e) { return { code: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') }; }
|
|
||||||
}
|
|
||||||
const deps = (dataDir) => createHalDeps({ 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: [], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 1000, emergencyTtlMinutes: 30, agentPollMs: 10, values: {}, set() {} } });
|
|
||||||
|
|
||||||
await test('config-set: 운영 값만 data/config.json 에 기록(actor cli:<by>, 감사), 인자 없으면 거부, 비밀 키는 받지 않음', () => {
|
|
||||||
const dir = makeDataDir();
|
|
||||||
eq(cli(dir, ['config-set']).code, 1, '바꿀 값이 없으면 실패');
|
|
||||||
const r = cli(dir, ['config-set', '--portalUrl', 'https://my.example.test', '--publicUrl', 'https://hal21.example.test', '--by', 'hera', '--reason', 'control-plane move']);
|
|
||||||
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); eq(out.updated.sort(), ['portalUrl', 'publicUrl']);
|
|
||||||
const cfg = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')); eq(cfg.values.portalUrl, 'https://my.example.test'); eq(cfg.values.publicUrl, 'https://hal21.example.test'); eq(cfg.updatedBy, 'cli:hera');
|
|
||||||
const d = deps(dir); const a = listAudit(d.store, { action: 'config.update' }); eq(a.length, 1); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a[0].detail_json).keys.sort(), ['portalUrl', 'publicUrl']); eq(JSON.parse(a[0].detail_json).reason, 'control-plane move');
|
|
||||||
// 비밀값 이름은 config-set 이 받는 키 목록에 없다 — 무시되어 '바꿀 값 없음' 으로 거부된다(설정 파일에 비밀을 두지 않는다)
|
|
||||||
const s = cli(dir, ['config-set', '--apiKey', 'x']); eq(s.code, 1); ok(!readFileSync(join(dir, 'config.json'), 'utf8').includes('apiKey'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('module-deploy: 운영자 API 와 같은 멱등 규칙으로 module.deploy 작업을 큐에 넣는다(실행은 서비스 프로세스), 검증 실패는 종료 코드 1, job 명령으로 readback', () => {
|
|
||||||
const dir = makeDataDir(); const d = deps(dir);
|
|
||||||
const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
|
|
||||||
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id]).code, 1, '릴리스가 없으면 거부');
|
|
||||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
|
|
||||||
eq(cli(dir, ['module-deploy', '--module', 'nope', '--version', '0.1.1', '--servers', srv.id]).code, 1, '알 수 없는 모듈 거부');
|
|
||||||
eq(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', 'srv_none']).code, 1, '없는 서버 거부');
|
|
||||||
const r = cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--reason', 'portal → hal21 link']);
|
|
||||||
eq(r.code, 0, r.stderr); const out = JSON.parse(r.stdout.trim().split('\n').pop()); ok(out.jobId.startsWith('j_')); eq(out.created, true); eq(out.status, 'queued');
|
|
||||||
const j = d.jobs.get(out.jobId); eq(j.kind, 'module.deploy'); eq(j.subject, { module: 'portal', version: '0.1.1', serverIds: [srv.id] }); eq(j.created_by, 'cli:hera'); eq(j.actor_type, 'operator'); eq(j.idempotency_key, `module.deploy:portal:0.1.1:${srv.id}:`);
|
|
||||||
const again = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop()); eq(again.jobId, out.jobId); eq(again.created, false, '같은 멱등 키 → 같은 작업');
|
|
||||||
const nonce = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera', '--nonce', '2']).stdout.trim().split('\n').pop()); ok(nonce.jobId !== out.jobId && nonce.created, 'nonce 로 새 작업');
|
|
||||||
const a = listAudit(d.store, { action: 'deploy.module.request' }); eq(a.length, 3); eq(a[0].actor_id, 'cli:hera'); eq(JSON.parse(a.find((x) => JSON.parse(x.detail_json).jobId === out.jobId && JSON.parse(x.detail_json).created).detail_json).reason, 'portal → hal21 link');
|
|
||||||
const rb = cli(dir, ['job', '--id', out.jobId]); eq(rb.code, 0, rb.stderr); const view = JSON.parse(rb.stdout); eq(view.job.id, out.jobId); eq(view.job.status, 'queued'); eq(view.children, []);
|
|
||||||
eq(cli(dir, ['job', '--id', 'j_none']).code, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('CLI 가 넣은 작업을 같은 데이터의 서비스 측 작업 엔진이 집어 실행한다(artifact 없음·부트스트랩 미완료 → precheck 실패로 끝남, 큐에 남지 않음)', async () => {
|
|
||||||
const dir = makeDataDir(); const d = deps(dir); const srv = core.createServer(d, { name: 'web1', role: 'control', by: 'test' });
|
|
||||||
core.registerRelease(d, { version: '0.1.1', mainCommit: 'abc', builtAt: 'now', artifacts: { portal: { file: 'portal-0.1.1.tar.gz', sha256: 'x', size: 1 } }, approve: true, by: 'test' });
|
|
||||||
const out = JSON.parse(cli(dir, ['module-deploy', '--module', 'portal', '--version', '0.1.1', '--servers', srv.id, '--by', 'hera']).stdout.trim().split('\n').pop());
|
|
||||||
d.jobsRunning = true; const n = await d.jobs.drain(); ok(n >= 1, '서비스 측 엔진이 큐 작업을 집었다');
|
|
||||||
const j = d.jobs.get(out.jobId); eq(j.status, 'failed'); ok(['artifact_missing', 'bootstrap_incomplete'].includes(j.error_code), `precheck 실패로 끝남: ${j.error_code}`); eq(d.jobs.list({ status: 'queued' }).length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
done();
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/** test/hal-mailimport.test.mjs — HAL 기존(legacy) 메일 서비스 가져오기(hal/mailsvc.mjs) 회귀 테스트.
|
|
||||||
* 읽기 전용 discovery 문서 → 검증(비밀값 차단) → dry-run 계획 → 트랜잭션 적용 → 감사 → readback → 되돌리기. 도메인별 active owner 는 정확히 하나. */
|
|
||||||
import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
|
|
||||||
import { invoke } from '../lib/http.mjs';
|
|
||||||
import { signHeaders } from '../lib/serviceauth.mjs';
|
|
||||||
import { createHalDeps } from '../hal/deps.mjs';
|
|
||||||
import { buildApp } from '../hal/app.mjs';
|
|
||||||
import * as core from '../hal/core.mjs';
|
|
||||||
import * as ms from '../hal/mailsvc.mjs';
|
|
||||||
import { listAudit } from '../lib/audit.mjs';
|
|
||||||
import { mkdirSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
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, values: {}, set(p) { Object.assign(this, p); } };
|
|
||||||
const d = createHalDeps({ config, dbFile: ':memory:' }); d.jobsRunning = true; return { d, app: buildApp(d) };
|
|
||||||
}
|
|
||||||
/** 실제 환경(mail.arihakdang.com)과 같은 모양의 discovery 문서. 비밀값 없음, 참조만. */
|
|
||||||
function discovery(over = {}) {
|
|
||||||
return {
|
|
||||||
format: 1, collectedAt: '2026-10-27T05:00:00.000Z', collector: 'scripts/mail-discover.mjs',
|
|
||||||
source: { hostname: 'srv1953374', unit: 'arihakdang-mail.service', appDir: '/srv/arihakdang-mail', currentTarget: '/srv/arihakdang-mail/releases/0.1.1', user: 'arihakdang', nodeBin: '/opt/arihakdang-node/bin/node',
|
|
||||||
unitState: { active: 'active', enabled: 'enabled', nRestarts: 0, mainPid: 1046648, fragmentPath: '/etc/systemd/system/arihakdang-mail.service', environmentFiles: ['/etc/arihakdang-mail/gateway.env'] },
|
|
||||||
revision: { module: 'arimail', version: '0.1.1', upstreamVersion: '1.0.11', installedAt: '2026-10-26T14:09:29.902Z' }, packageVersion: '1.0.11', healthUrl: 'http://127.0.0.1:8790/api/health', health: { ok: true, version: '1.0.11', domains: 2 }, dataDir: '/srv/arihakdang-mail/data' },
|
|
||||||
domains: [
|
|
||||||
{ domain: 'arihakdang.com', enabled: true, primary: true, hosts: ['mail.arihakdang.com'], authMode: 'www', operating: 'active', ariweb: null },
|
|
||||||
{ domain: 'ariweb.net', enabled: true, primary: false, hosts: ['webmail.ariweb.net'], authMode: 'webmaker', operating: 'active', ariweb: null },
|
|
||||||
],
|
|
||||||
listeners: [{ port: 8790, bind: '127.0.0.1', owner: 'unit' }, { port: 587, bind: '0.0.0.0', owner: 'unit' }, { port: 993, bind: '0.0.0.0', owner: 'unit' }],
|
|
||||||
aggregates: { accounts: 4, mailboxes: 4, messages: 195, storageBytes: 545259520, byDomain: { 'arihakdang.com': { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }, 'ariweb.net': { accounts: 2, mailboxes: 2, messages: 6, dbBytes: 372736 } } },
|
|
||||||
provider: { kind: 'resend', haraka: { enabled: false, running: false }, configPath: '/srv/arihakdang-mail/data/config.json', configKeys: ['RESEND_API_KEY', 'inbound', 'outbound'] },
|
|
||||||
secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/arihakdang-mail/gateway.env', mode: '600', owner: 'arihakdang' }, { name: 'config.json', ref: 'file:/srv/arihakdang-mail/data/config.json', mode: '600' }, { name: 'tls', ref: 'dir:/etc/arihakdang-mail/tls' }],
|
|
||||||
evidence: { nginx: { vhostFile: '/etc/nginx/sites-available/mail.arihakdang.conf', serverNames: ['mail.arihakdang.com'], proxyPass: 'http://127.0.0.1:8790' }, tls: { host: 'mail.arihakdang.com', subject: 'CN=mail.arihakdang.com', validTo: '2027-01-12T08:26:09.000Z' }, dns: { 'mail.arihakdang.com': { a: ['187.53.133.173'] }, 'arihakdang.com': { mx: ['inbound-smtp.ap-northeast-1.amazonaws.com'] } } },
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v]));
|
|
||||||
|
|
||||||
await test('discovery 검증: 형식·필수 항목·도메인 형식·중복 도메인·비밀값 포함 거부, 참조(secretRefs)만 허용', () => {
|
|
||||||
eq(ms.validateDiscovery(discovery()).ok, true);
|
|
||||||
ok(ms.validateDiscovery({ format: 2 }).errors.some((e) => /format/.test(e)), '형식 버전');
|
|
||||||
ok(ms.validateDiscovery(discovery({ source: null })).errors.some((e) => /source/.test(e)), 'source 필수');
|
|
||||||
ok(ms.validateDiscovery(discovery({ domains: [{ domain: 'Bad Domain', enabled: true, hosts: [] }] })).errors.some((e) => /도메인 형식/.test(e)), '도메인 형식');
|
|
||||||
ok(ms.validateDiscovery(discovery({ domains: [...discovery().domains, discovery().domains[0]] })).errors.some((e) => /중복/.test(e)), '문서 안 중복 도메인');
|
|
||||||
ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/x', value: 'abc' }] })).errors.some((e) => /비밀/.test(e)), 'secretRefs 에 값 금지');
|
|
||||||
ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', apiKey: 're_abcdefghijklmnopqrstuvwxyz0123456789' } })).errors.some((e) => /비밀/.test(e)), '비밀 형태 값 거부');
|
|
||||||
ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', password: 'hunter2hunter2' } })).errors.some((e) => /비밀/.test(e)), '비밀 이름 키의 값 거부');
|
|
||||||
ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'x', ref: 'plain-value' }] })).errors.some((e) => /ref/.test(e)), 'ref 는 file:/dir:/vault:/env: 형식');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('dry-run 계획: 서버 없음·역할·호스트 불일치 거부, 도메인별 create, 비활성 도메인 skip, DB 변경 없음, 계획은 mail_imports 에 기록', async () => {
|
|
||||||
const { d } = makeHal(); const doc = discovery();
|
|
||||||
await rejects(() => ms.applyImport(d, doc, { serverId: 'srv_none', by: 'test', dryRun: true }), 'server_not_found');
|
|
||||||
const web = core.createServer(d, { name: 'web', role: 'web' });
|
|
||||||
await rejects(() => ms.applyImport(d, doc, { serverId: web.id, by: 'test', dryRun: true }), 'bad_role');
|
|
||||||
const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail', location: 'hostinger' });
|
|
||||||
d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'other-host' }), srv.id);
|
|
||||||
await rejects(() => ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: true }), 'authority_mismatch');
|
|
||||||
d.store.run('UPDATE servers SET facts_json=NULL WHERE id=?', srv.id);
|
|
||||||
const r = ms.applyImport(d, { ...doc, domains: [...doc.domains, { domain: 'off.example', enabled: false, hosts: ['mail.off.example'] }] }, { serverId: srv.id, by: 'test', dryRun: true });
|
|
||||||
eq(r.mode, 'dry_run'); eq(r.plan.ok, true); eq(r.plan.summary, { create: 2, update: 0, reject: 0, skip: 1, missingInSource: 0 });
|
|
||||||
eq(r.plan.items.find((i) => i.domain === 'off.example').action, 'skip');
|
|
||||||
eq(ms.listMailServices(d).length, 0, 'dry-run 은 mail_services 를 바꾸지 않음');
|
|
||||||
const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', r.importId); eq(imp.mode, 'dry_run'); eq(imp.status, 'planned'); ok(!imp.applied_at);
|
|
||||||
ok(listAudit(d.store, { action: 'mail_service.import_dry_run' }).length === 1, '감사 기록');
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('적용: 트랜잭션으로 생성, 불변 id, unassigned legacy managed, 도메인별 active owner 1, 비밀 참조만, 감사(immutable), 재적용은 update 멱등', async () => {
|
|
||||||
const { d } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail' }); const doc = discovery();
|
|
||||||
const r = ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: false });
|
|
||||||
eq(r.mode, 'apply'); eq(r.created.length, 2); eq(r.updated.length, 0);
|
|
||||||
const list = ms.listMailServices(d); eq(list.length, 2);
|
|
||||||
const a = list.find((x) => x.domain === 'arihakdang.com'); ok(/^mls_/.test(a.id)); eq(a.kind, 'legacy_managed'); eq(a.ownership, 'unassigned_legacy_managed'); eq(a.ownerGroupId, null); eq(a.active, true); eq(a.serverId, srv.id);
|
|
||||||
eq(a.authority, 'arimail:srv1953374:arihakdang-mail.service'); eq(a.instance.revision.version, '0.1.1'); eq(a.instance.revision.upstreamVersion, '1.0.11'); eq(a.publicHost, 'mail.arihakdang.com'); eq(a.webUrl, 'https://mail.arihakdang.com/');
|
|
||||||
eq(a.aggregates, { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }); eq(a.secretRefs.map((s) => s.ref), ['file:/etc/arihakdang-mail/gateway.env', 'file:/srv/arihakdang-mail/data/config.json', 'dir:/etc/arihakdang-mail/tls']);
|
|
||||||
ok(a.evidence.tls.subject === 'CN=mail.arihakdang.com' && a.evidence.dns['arihakdang.com'].mx[0].includes('amazonaws'), '증거 보존');
|
|
||||||
eq(a.lastSyncSource, 'import'); ok(a.lastSyncAt);
|
|
||||||
const au = listAudit(d.store, { action: 'mail_service.import' }); eq(au.length, 2); ok(au.every((x) => x.immutable === 1 && x.actor_type === 'operator' && x.actor_id === 'test'));
|
|
||||||
ok(!JSON.stringify(d.store.all('SELECT * FROM mail_services')).includes('re_'), 'DB 에 비밀 없음');
|
|
||||||
// 재적용: 같은 authority·서버 → update(id 유지), 집계만 갱신
|
|
||||||
const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 190;
|
|
||||||
const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 0); eq(r2.updated.length, 2);
|
|
||||||
const a2 = ms.getMailService(d, a.id); eq(a2.id, a.id); eq(a2.aggregates.messages, 190); eq(ms.listMailServices(d).length, 2, '중복 생성 없음');
|
|
||||||
const rb = ms.readback(d); eq(rb.counts.total, 2); eq(rb.counts.active, 2); eq(rb.duplicates, []); eq(rb.secretLeaks, 0); eq(rb.perServer[srv.id], 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('충돌·누락: 다른 서버/인스턴스가 이미 active 인 도메인은 collision 으로 전체 거부(부분 적용 없음), 원본에서 사라진 도메인은 missing_in_source 로 보고(삭제하지 않음)', async () => {
|
|
||||||
const { d } = makeHal(); const s1 = core.createServer(d, { name: 'h1', role: 'mail' }); const s2 = core.createServer(d, { name: 'h2', role: 'mail' });
|
|
||||||
ms.applyImport(d, discovery(), { serverId: s1.id, by: 'test', dryRun: false });
|
|
||||||
const other = discovery({ source: { ...discovery().source, hostname: 'jitsi', unit: 'arimail.service', appDir: '/srv/arimail' }, domains: [{ domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'] }, { domain: 'ariweb.net', enabled: true, hosts: ['webmail.ariweb.net'] }] });
|
|
||||||
const r = ms.applyImport(d, other, { serverId: s2.id, by: 'test', dryRun: false });
|
|
||||||
eq(r.mode, 'rejected'); eq(r.plan.ok, false); eq(r.plan.items.find((i) => i.domain === 'ariweb.net').action, 'reject'); ok(/collision/.test(r.plan.items.find((i) => i.domain === 'ariweb.net').reason));
|
|
||||||
eq(ms.listMailServices(d).filter((x) => x.serverId === s2.id).length, 0, '부분 적용 없음(ibit.kr 도 만들지 않음)');
|
|
||||||
eq(d.store.get('SELECT status FROM mail_imports WHERE id=?', r.importId).status, 'rejected');
|
|
||||||
ok(listAudit(d.store, { action: 'mail_service.import_rejected' }).length === 1);
|
|
||||||
// 충돌 도메인을 뺀 문서는 적용된다
|
|
||||||
const okDoc = { ...other, domains: other.domains.slice(0, 1) }; const r2 = ms.applyImport(d, okDoc, { serverId: s2.id, by: 'test', dryRun: false }); eq(r2.created.length, 1);
|
|
||||||
eq(ms.readback(d).duplicates, []); eq(ms.readback(d).counts.active, 3);
|
|
||||||
// 원본에서 ariweb.net 이 사라지면: 보고만, 삭제 없음
|
|
||||||
const shrunk = discovery({ domains: [discovery().domains[0]] }); const r3 = ms.applyImport(d, shrunk, { serverId: s1.id, by: 'test', dryRun: true });
|
|
||||||
eq(r3.plan.summary.missingInSource, 1); eq(r3.plan.missingInSource, ['ariweb.net']); eq(ms.listMailServices(d).length, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('되돌리기: 적용으로 만든 행 삭제·갱신한 행 복원, 두 번 되돌리기 거부, dry-run 되돌리기 거부', async () => {
|
|
||||||
const { d } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' });
|
|
||||||
const r1 = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false });
|
|
||||||
const before = ms.getMailService(d, r1.created[0]).aggregates.messages;
|
|
||||||
const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 999; doc2.domains.push({ domain: 'new.example', enabled: true, hosts: ['mail.new.example'] });
|
|
||||||
const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 1); eq(r2.updated.length, 2);
|
|
||||||
const rb = ms.rollbackImport(d, r2.importId, 'test'); eq(rb.deleted, 1); eq(rb.restored, 2);
|
|
||||||
eq(ms.listMailServices(d).length, 2); eq(ms.getMailService(d, r1.created[0]).aggregates.messages, before, '갱신 전 값 복원');
|
|
||||||
await rejects(() => ms.rollbackImport(d, r2.importId, 'test'), 'already_rolled_back');
|
|
||||||
const dr = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: true }); await rejects(() => ms.rollbackImport(d, dr.importId, 'test'), 'not_applied');
|
|
||||||
ok(listAudit(d.store, { action: 'mail_service.import_rollback' }).length === 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('heartbeat 연동: 서버의 Agent 가 arimail 모듈 상태를 보고하면 last_sync·health 갱신(Data Plane 과 독립: HAL 행이 없어도 heartbeat 정상)', async () => {
|
|
||||||
const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374', role: 'mail' });
|
|
||||||
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: srv.id, label: 'm' }); const reg = await invoke(app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'srv1953374' }, version: '0.1.1' } }); ok(reg.json.ok);
|
|
||||||
const hb = (modules) => invoke(app, 'POST', '/agent/v1/heartbeat', { body: { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }, headers: sig(`agent:${reg.json.agentId}`, reg.json.secret, 'POST', '/agent/v1/heartbeat', { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }) });
|
|
||||||
eq((await hb({ arimail: { version: '0.1.1', active: true, health: 'ok' } })).json.ok, true, 'mail_services 행 없이도 정상');
|
|
||||||
const r = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false }); const beforeSync = ms.getMailService(d, r.created[0]).lastSyncAt;
|
|
||||||
await new Promise((x) => setTimeout(x, 5)); await hb({ arimail: { version: '0.1.1', active: false, health: 'down' } });
|
|
||||||
const a = ms.getMailService(d, r.created[0]); eq(a.health, 'down'); eq(a.lastSyncSource, 'agent'); ok(a.lastSyncAt > beforeSync); eq(a.instance.observedVersion, '0.1.1');
|
|
||||||
eq(ms.readback(d).services[0].agentId, reg.json.agentId);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('운영자 API: 목록·상세·dry-run/적용(재인증)·되돌리기, 응답에 비밀 없음; CLI 와 같은 함수', async () => {
|
|
||||||
const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' });
|
|
||||||
const { createAccount, login } = await import('../hal/ops.mjs'); createAccount(d, { login: 'op1', password: 'operator-pass-1', displayName: 'op' });
|
|
||||||
const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'op1', password: 'operator-pass-1' } }); const cookie = l.cookies.aw_ops; const csrf = l.json.csrf; ok(cookie && csrf, '로그인');
|
|
||||||
const opsCall = (m, p, b) => invoke(app, m, p, { body: b, cookies: { aw_ops: cookie }, headers: { 'x-csrf': csrf } });
|
|
||||||
let r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.ok, true); eq(r.json.services, []); eq(r.json.readback.counts.total, 0);
|
|
||||||
r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'dry_run');
|
|
||||||
r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id, apply: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'apply'); eq(r.json.result.created.length, 2);
|
|
||||||
r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.services.length, 2); ok(!JSON.stringify(r.json).includes('re_abc'));
|
|
||||||
r = await opsCall('GET', `/ops/api/mail-services/${r.json.services[0].id}`); eq(r.json.ok, true); ok(r.json.service.domain); ok(Array.isArray(r.json.audit));
|
|
||||||
r = await opsCall('GET', '/ops/api/mail-services/imports'); eq(r.json.imports.length, 2);
|
|
||||||
const applied = r.json.imports.find((i) => i.mode === 'apply'); r = await opsCall('POST', `/ops/api/mail-services/imports/${applied.id}/rollback`, {}); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.deleted, 2);
|
|
||||||
eq((await opsCall('GET', '/ops/api/mail-services')).json.services.length, 0);
|
|
||||||
// 메일 서비스 화면(HAL 관리형 표 + 포털 중계)
|
|
||||||
ms.applyImport(d, discovery(), { serverId: srv.id, by: 'op1', dryRun: false });
|
|
||||||
const page = await invoke(app, 'GET', '/ops/mail', { cookies: { aw_ops: cookie } }); ok(page.text.includes('arihakdang.com') && page.text.includes('legacy') && page.text.includes('unassigned'), '화면에 HAL 관리형 메일 서비스 표');
|
|
||||||
});
|
|
||||||
|
|
||||||
done();
|
|
||||||
@@ -39,29 +39,6 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
|
|||||||
// 로그인 화면은 고객 로그인과 분리된 문구
|
// 로그인 화면은 고객 로그인과 분리된 문구
|
||||||
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
|
r = await invoke(h.app, 'GET', '/ops/login'); ok(r.text.includes('고객 계정으로는 로그인할 수 없습니다'));
|
||||||
});
|
});
|
||||||
await test('부트스트랩 배너는 selfHealth().bootstrap 의 실제 계약(complete·halRunningVersion·firstAgent)만 본다 — 완료 뒤에는 경고가 사라지고, 미완료 때는 실행 버전·첫 Agent 상태가 정확하다', async () => {
|
|
||||||
const h = makeHal(); const op = await loginOp(h);
|
|
||||||
// 계약 고정: selfHealth 는 camelCase 만 내고 옛 snake_case 열 이름은 없다(배너가 어떤 필드를 읽는지 못 박는다)
|
|
||||||
const b0 = core.selfHealth(h.d).bootstrap; eq(b0.complete, false); ok(!('completed_at' in b0) && !('hal_running_version' in b0) && !('first_agent_id' in b0), 'selfHealth.bootstrap 에 snake_case 필드가 없다');
|
|
||||||
// 1) 아무것도 안 된 상태: 실행 버전 확인 전 · 첫 Agent 미등록
|
|
||||||
let r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 미등록)'), '초기 상태 문구');
|
|
||||||
// 2) HAL 기동(실행 버전 기록)만 된 상태: 실행 버전은 보이고 첫 Agent 는 아직 미등록
|
|
||||||
core.claimLive(h.d, { instanceId: h.d.instanceId, version: '0.1.1' });
|
|
||||||
r = await op.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 0.1.1, 첫 Agent 미등록)'), `실행 버전 0.1.1 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
|
||||||
// 3) 첫 Agent 등록·승인 → 부트스트랩 완료(core.approveServer → maybeCompleteBootstrap). 경고는 사라져야 한다
|
|
||||||
const s = core.createServer(h.d, { name: '웹1', role: 'web' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
|
||||||
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok, JSON.stringify(reg.json));
|
|
||||||
core.approveServer(h.d, s.id, 'ozops');
|
|
||||||
const b1 = core.selfHealth(h.d).bootstrap; eq(b1.complete, true); eq(b1.halRunningVersion, '0.1.1'); eq(b1.firstAgent, reg.json.agentId); eq(b1.manualInstallLocked, true);
|
|
||||||
r = await op.as('GET', '/ops/'); eq(r.status, 200); ok(!r.text.includes('부트스트랩 미완료'), `완료 뒤 운영 현황에 경고 없음: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
|
||||||
r = await op.as('GET', '/ops/servers'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 다른 화면(서버)에도 경고 없음');
|
|
||||||
r = await op.as('GET', '/ops/releases'); ok(!r.text.includes('부트스트랩 미완료'), '완료 뒤 배포 릴리스 화면에도 경고 없음'); ok(r.text.includes('HAL 실행 버전: <b>0.1.1</b>') && r.text.includes('수동 설치 잠김'), '릴리스 화면의 실행 버전·수동 설치 잠김은 실제 bootstrap 행 값');
|
|
||||||
// 4) 첫 Agent 는 등록됐지만 HAL 실행 버전이 없는 새 HAL(재해복구 등): 실행 버전 확인 전 · 첫 Agent 등록됨
|
|
||||||
const h2 = makeHal(); const op2 = await loginOp(h2); const s2 = core.createServer(h2.d, { name: '웹2', role: 'web' }); const t2 = core.issueRegistrationToken(h2.d, { serverId: s2.id });
|
|
||||||
const reg2 = await invoke(h2.app, 'POST', '/agent/v1/register', { body: { token: t2.token, facts: { hostname: 'web2' }, version: '0.1.1' } }); ok(reg2.json.ok); core.approveServer(h2.d, s2.id, 'ozops');
|
|
||||||
eq(core.selfHealth(h2.d).bootstrap.complete, false); eq(core.selfHealth(h2.d).bootstrap.firstAgent, reg2.json.agentId);
|
|
||||||
r = await op2.as('GET', '/ops/'); ok(r.text.includes('부트스트랩 미완료(HAL 실행 버전 확인 전, 첫 Agent 등록됨)'), `첫 Agent 등록됨 표시: ${(r.text.match(/부트스트랩 미완료\([^)]*\)/) || [''])[0]}`);
|
|
||||||
});
|
|
||||||
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
|
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
|
||||||
const h = makeHal(); const op = await loginOp(h);
|
const h = makeHal(); const op = await loginOp(h);
|
||||||
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
/** test/mail-discover.test.mjs — scripts/mail-discover.mjs(읽기 전용 AriMail discovery) 회귀 테스트.
|
|
||||||
* 가짜 앱 디렉터리(AriMail 1.0.7+ 등록부 형식·실제 스키마 이름의 SQLite)로 문서 모양·집계·비밀 미포함·HAL 검증 통과를 확인한다. systemctl/ss/네트워크는 없는 환경에서도 돈다(--no-net). */
|
|
||||||
process.env.ARIWEB_DISCOVER_NO_MAIN = '1';
|
|
||||||
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
|
||||||
import { discover, dbCounts } from '../scripts/mail-discover.mjs';
|
|
||||||
import { validateDiscovery, authorityOf } from '../hal/mailsvc.mjs';
|
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
|
||||||
import { mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
function fakeApp() {
|
|
||||||
const app = join(tmpDir('mail-disc-'), 'srv', 'arimail'); const rel = join(app, 'releases', '20261024-204656'); mkdirSync(rel, { recursive: true }); mkdirSync(join(app, 'data', 'domains', 'second.example'), { recursive: true }); mkdirSync(join(app, 'data', 'backups'), { recursive: true });
|
|
||||||
symlinkSync(rel, join(app, 'current')); writeFileSync(join(rel, 'REVISION'), 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924\n'); writeFileSync(join(rel, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' }));
|
|
||||||
writeFileSync(join(app, 'data', 'domains.json'), JSON.stringify({ _meta: { format: 1 }, primary: 'ibit.kr', domains: { 'ibit.kr': { domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'], mailbox: 'admin@ibit.kr', auth: { mode: 'local' }, note: '' }, 'second.example': { domain: 'second.example', enabled: true, hosts: ['mail.second.example'], mailbox: 'x@second.example', auth: { mode: 'webmaker' }, ariweb: { kind: 'standalone', siteId: '', groupId: 'g_1', mailServiceId: 'msv_1' }, operating: 'frozen' } } }));
|
|
||||||
writeFileSync(join(app, 'data', 'config.json'), JSON.stringify({ _meta: {}, RESEND_API_KEY: 're_SECRETSECRETSECRETSECRETSECRET1234', inbound: { provider: 'resend' }, outbound: { provider: 'resend' } }), { mode: 0o600 });
|
|
||||||
writeFileSync(join(app, 'data', 'haraka-status.json'), JSON.stringify({ enabled: false, running: false, inbound: false, outbound: false, listen: ['127.0.0.1:2525'], version: '3.3.4', dkim: false }));
|
|
||||||
writeFileSync(join(app, 'data', 'setup-token'), 'tokentokentokentokentoken', { mode: 0o600 });
|
|
||||||
const mk = (f, users, msgs) => { const db = new DatabaseSync(f); db.exec("CREATE TABLE mail_users(id INTEGER PRIMARY KEY, login TEXT, password_hash TEXT, status TEXT); CREATE TABLE messages(id INTEGER PRIMARY KEY, subject TEXT); CREATE TABLE attachments(id INTEGER PRIMARY KEY); CREATE TABLE schema_migrations(id TEXT); CREATE TABLE outbound_queue(id INTEGER PRIMARY KEY)"); for (let i = 0; i < users; i++) db.prepare("INSERT INTO mail_users(login, password_hash, status) VALUES(?, 'HASHHASHHASHHASHHASHHASHHASHHASHHASHHASHHASH', 'active')").run('u' + i); for (let i = 0; i < msgs; i++) db.prepare('INSERT INTO messages(subject) VALUES(?)').run('s' + i); db.close(); };
|
|
||||||
mk(join(app, 'data', 'mail-gateway.sqlite'), 3, 7); mk(join(app, 'data', 'domains', 'second.example', 'mail.sqlite'), 2, 1);
|
|
||||||
const etc = join(app, '..', 'etc'); mkdirSync(join(etc, 'tls'), { recursive: true }); writeFileSync(join(etc, 'gateway.env'), 'MAILBOX=admin@ibit.kr\nRESEND_API_KEY=re_SECRETSECRETSECRETSECRETSECRET1234\nPUBLIC_HOST=webmail.ibit.kr\n', { mode: 0o600 }); writeFileSync(join(etc, 'tls', 'privkey.pem'), '-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n');
|
|
||||||
const vhost = join(etc, 'webmail.ibit.kr.conf'); writeFileSync(vhost, 'server { listen 80; server_name webmail.ibit.kr; location / { return 301 https://webmail.ibit.kr$request_uri; } }\nserver { listen 443 ssl; server_name webmail.ibit.kr; ssl_certificate /etc/letsencrypt/live/webmail.ibit.kr/fullchain.pem; location / { proxy_pass http://127.0.0.1:8790; } }');
|
|
||||||
const hook = join(etc, 'arimail.sh'); writeFileSync(hook, '#!/bin/bash\nsystemctl restart arimail || true\n');
|
|
||||||
return { app, etc, vhost, hook };
|
|
||||||
}
|
|
||||||
|
|
||||||
await test('dbCounts: 표 이름·행 수·무결성만(주소·해시는 읽지 않음)', () => {
|
|
||||||
const { app } = fakeApp(); const c = dbCounts(join(app, 'data', 'mail-gateway.sqlite'));
|
|
||||||
eq(c.accounts, 3); eq(c.activeAccounts, 3); eq(c.messages, 7); eq(c.integrity, 'ok'); eq(c.outboundQueue, 0); ok(!JSON.stringify(c).includes('HASH'));
|
|
||||||
eq(dbCounts(join(app, 'data', 'nope.sqlite')), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('discover(--no-net): 등록부 도메인별 집계(기본 도메인은 게이트웨이 DB, 나머지는 테넌트 DB), 리비전, 비밀은 참조만(키 이름만), HAL 검증 통과, authority', async () => {
|
|
||||||
const { app, etc, vhost, hook } = fakeApp();
|
|
||||||
const doc = await discover({ unit: 'arimail.service', appDir: app, etcDir: etc, vhost, hook, net: false, host: 'jitsi' });
|
|
||||||
eq(doc.format, 1); eq(doc.source.unit, 'arimail.service'); eq(doc.source.revision.commit, 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924'); eq(doc.source.packageVersion, '1.0.11'); ok(doc.source.currentTarget.endsWith('releases/20261024-204656')); eq(doc.source.releases, ['20261024-204656']);
|
|
||||||
eq(doc.domains.length, 2); const p = doc.domains.find((x) => x.domain === 'ibit.kr'); eq(p.primary, true); eq(p.hosts, ['webmail.ibit.kr']); eq(p.authMode, 'local'); ok(p.tenantDb.endsWith('mail-gateway.sqlite'));
|
|
||||||
const s = doc.domains.find((x) => x.domain === 'second.example'); eq(s.primary, false); eq(s.ariweb, { kind: 'standalone', siteId: null, groupId: 'g_1', mailServiceId: 'msv_1' }, '빈 값은 null 로 정규화'); eq(s.operating, 'frozen');
|
|
||||||
eq(doc.aggregates.accounts, 5); eq(doc.aggregates.mailboxes, 5); eq(doc.aggregates.messages, 8); eq(doc.aggregates.byDomain['ibit.kr'].accounts, 3); eq(doc.aggregates.byDomain['second.example'].messages, 1); ok(doc.aggregates.storageBytes > 0);
|
|
||||||
eq(doc.provider.kind, 'resend'); eq(doc.provider.configKeys, ['RESEND_API_KEY', 'inbound', 'outbound']); eq(doc.evidence.haraka.enabled, false);
|
|
||||||
const txt = JSON.stringify(doc); ok(!txt.includes('re_SECRET') && !txt.includes('HASHHASH') && !txt.includes('BEGIN PRIVATE') && !txt.includes('tokentoken'), '비밀값 없음'); ok(!txt.includes('admin@ibit.kr'), '메일 주소 없음');
|
|
||||||
const refs = doc.secretRefs.map((r) => r.ref); ok(refs.some((r) => r === `file:${join(app, 'data', 'config.json')}`) && refs.some((r) => r === `dir:${join(etc, 'tls')}`) && refs.some((r) => r.endsWith('setup-token')), JSON.stringify(refs));
|
|
||||||
eq(doc.evidence.nginx.serverNames, ['webmail.ibit.kr']); eq(doc.evidence.nginx.proxyPass, ['http://127.0.0.1:8790']); eq(doc.evidence.nginx.httpRedirect, true); eq(doc.evidence.certbot.restartsUnit, true);
|
|
||||||
const v = validateDiscovery(doc); eq(v.errors, []); eq(authorityOf(doc), 'arimail:jitsi:arimail.service');
|
|
||||||
});
|
|
||||||
|
|
||||||
done();
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
/** test/webmaker-inventory.test.mjs — 운영 Webmaker ↔ 포털 ↔ HAL inventory/dry-run 도구(scripts/webmaker-inventory.mjs) 회귀 테스트.
|
|
||||||
* 가짜 Webmaker 데이터 루트(JSON)와 실제 스키마로 만든 포털·HAL SQLite 로 판정·충돌·누락·중복·비밀 차단을 확인한다. */
|
|
||||||
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
|
|
||||||
import { readWebmaker, readPortal, readHal, dryRun, assertNoSecrets } from '../scripts/webmaker-inventory.mjs';
|
|
||||||
import { openDb } from '../lib/db.mjs';
|
|
||||||
import { allMigrations } from '../portal/schema.mjs';
|
|
||||||
import { MIGRATIONS as HAL_MIGRATIONS } from '../hal/schema.mjs';
|
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const P1 = 'a'.repeat(32), P2 = 'b'.repeat(32), P3 = 'c'.repeat(32), P4 = 'd'.repeat(32);
|
|
||||||
function webmakerRoot() {
|
|
||||||
const root = tmpDir('wm-inv-'); const data = join(root, 'data'); mkdirSync(join(data, 'sites'), { recursive: true }); mkdirSync(join(root, 'sites-root', P1), { recursive: true }); writeFileSync(join(root, 'sites-root', P1, 'index.html'), '<html>');
|
|
||||||
writeFileSync(join(data, 'users.json'), JSON.stringify({ icqmomo: { id: 'icqmomo', name: '오즈', salt: 'S', passHash: 'H', role: 'admin', accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }, 'aw-01a17894-01a17894': { id: 'aw-01a17894-01a17894', name: '검증', salt: 'S', passHash: 'H', role: 'user', accountType: 'domain', sso: 'ariweb' }, 'aw-deadbeef-deadbeef': { id: 'aw-deadbeef-deadbeef', name: '고아', salt: 'S', passHash: 'H', role: 'user', sso: 'ariweb' } }));
|
|
||||||
const site = (id, extra) => writeFileSync(join(data, 'sites', id + '.json'), JSON.stringify({ id, title: 'T' + id.slice(0, 2), ownerId: 'icqmomo', status: 'idle', domains: [], deployments: [], git: { changes: [] }, sessionId: 'SECRET', ...extra }));
|
|
||||||
site(P1, { domains: ['sample.example'], deployments: [{ targetId: 't1', at: '2026-10-15T07:27:00Z', url: 'https://x/' }], git: { changes: [{ no: 1, kind: 'deploy', branch: `sites/${P1}/main`, commit: '1111', pushed: true }] } });
|
|
||||||
site(P2, { ownerId: 'aw-01a17894-01a17894' }); site(P3, {}); site(P4, {});
|
|
||||||
writeFileSync(join(data, 'ariweb-links.json'), JSON.stringify({ version: 1, links: { [P2]: { siteId: 's_01a17894dd8ab3df984ae079f3', groupId: 'g_01a17894dd24bf64a49ff70600', siteName: '검증', linkedAt: 'x' }, [P4]: { siteId: 's_gone', groupId: 'g_gone' } } }));
|
|
||||||
writeFileSync(join(data, 'domains.json'), JSON.stringify({ domains: [{ name: 'sample.example', siteId: P1, ownerId: 'icqmomo', status: 'active' }], contacts: {} }));
|
|
||||||
writeFileSync(join(data, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '기본', type: 'local', baseDir: '/var/www/x', publicBaseUrl: 'https://x', privateKey: 'SECRET' }] }));
|
|
||||||
writeFileSync(join(data, 'mail-servers.json'), JSON.stringify({ version: 1, items: [{ id: 'm1', name: 'AriMail', provider: 'arimail', apiToken: 'SECRET' }], defaultId: 'm1' }));
|
|
||||||
writeFileSync(join(data, 'config.json'), JSON.stringify({ version: 1, values: { service: { publicBaseUrl: 'https://wm.example' }, source: { provider: 'gitea', token: 'SECRET' } } }));
|
|
||||||
writeFileSync(join(root, 'REVISION.json'), JSON.stringify({ module: 'webmaker', version: '0.1.1', upstreamVersion: '1.7.3' }));
|
|
||||||
mkdirSync(join(root, 'nginx')); writeFileSync(join(root, 'nginx', `wh-${P1}.conf`), 'server {}');
|
|
||||||
return { root, data };
|
|
||||||
}
|
|
||||||
async function portalDb(root, rows) {
|
|
||||||
const db = openDb(join(root, 'portal.sqlite'), await allMigrations()); const now = '2026-10-26T00:00:00Z';
|
|
||||||
db.run('INSERT INTO users (id,email,display_name,status,created_at,updated_at) VALUES (?,?,?,?,?,?)', 'u_01a17894dce305ff35231f3676', 'alice@check.example', '앨리스', 'active', now, now);
|
|
||||||
db.run('INSERT INTO groups (id,name,kind,owner_user_id,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', 'g_01a17894dd24bf64a49ff70600', '팀', 'team', 'u_01a17894dce305ff35231f3676', 'active', now, now);
|
|
||||||
for (const [id, pid] of rows) db.run('INSERT INTO sites (id,group_id,name,webmaker_project_id,lifecycle,deploy_mode,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', id, 'g_01a17894dd24bf64a49ff70600', 'S', pid, 'draft', 'managed', now, now);
|
|
||||||
db.close(); return join(root, 'portal.sqlite');
|
|
||||||
}
|
|
||||||
function halDb(root, { webmakerTarget = true, health = 'ok' } = {}) {
|
|
||||||
const db = openDb(join(root, 'hal.sqlite'), HAL_MIGRATIONS); const now = '2026-10-26T00:00:00Z';
|
|
||||||
db.run('INSERT INTO servers (id,name,role,location,status,agent_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', 'srv_1', 'srv', 'control', 'x', 'ready', 'ag_1', now, now);
|
|
||||||
db.run('INSERT INTO agents (id,server_id,kind,status,version,registered_at,secret_hash) VALUES (?,?,?,?,?,?,?)', 'ag_1', 'srv_1', 'managed', 'active', '0.1.1', now, 'x');
|
|
||||||
if (webmakerTarget) db.run('INSERT INTO module_targets (module,server_id,target_version,actual_version,health,active,updated_at) VALUES (?,?,?,?,?,?,?)', 'webmaker', 'srv_1', '0.1.1', '0.1.1', health, 1, now);
|
|
||||||
db.close(); return join(root, 'hal.sqlite');
|
|
||||||
}
|
|
||||||
|
|
||||||
await test('Webmaker inventory: 비밀 없이 회원·프로젝트·도메인·배포 대상·연결 등록부를 읽는다', async () => {
|
|
||||||
const { root, data } = webmakerRoot(); const w = readWebmaker(data, { sitesRoot: join(root, 'sites-root'), nginxEnabled: join(root, 'nginx'), appDir: root });
|
|
||||||
eq(w.users.length, 3); eq(w.projects.length, 4); eq(w.domains.length, 1); eq(w.deployTargets.length, 1); eq(w.mailServers.length, 1); eq(w.links.length, 2);
|
|
||||||
const p1 = w.projects.find((p) => p.id === P1); eq(p1.deployments, 1); eq(p1.siteDirFiles, 1); eq(p1.vhost, true); eq(p1.git.lastPushed.commit, '1111'); eq(w.revision.version, '0.1.1');
|
|
||||||
ok(!JSON.stringify(w).includes('SECRET'), '비밀값이 출력에 없어야 한다'); ok(!JSON.stringify(w).includes('passHash'));
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('dry-run: 양쪽 등록부 일치는 linked_consistent, 고유 프로젝트는 unlinked_native, 모두 일치하면 no-op', async () => {
|
|
||||||
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
|
|
||||||
// P4 는 Webmaker 쪽에만 연결이 있으므로 충돌 — 이 테스트는 P4 연결을 지운 데이터로 본다
|
|
||||||
w.projects.find((p) => p.id === P4).link = null; w.links = w.links.filter((l) => l.projectId !== P4); w.users = w.users.filter((u) => u.id !== 'aw-deadbeef-deadbeef'); // 고아 SSO 회원은 충돌 테스트용
|
|
||||||
const portal = readPortal(await portalDb(root, [['s_01a17894dd8ab3df984ae079f3', P2]])); const hal = readHal(halDb(root));
|
|
||||||
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `1111\trefs/heads/sites/${P1}/main\n` });
|
|
||||||
eq(r.summary.verdicts.linked_consistent, 1); eq(r.summary.verdicts.unlinked_native, 3); eq(r.summary.conflicts, 0); eq(r.summary.missing, 0);
|
|
||||||
eq(r.users.find((u) => u.userId === 'aw-01a17894-01a17894').verdict, 'sso_consistent'); eq(r.users.find((u) => u.userId === 'icqmomo').verdict, 'native_no_portal_identity');
|
|
||||||
eq(r.domains[0].verdict, 'webmaker_only'); eq(r.gitea[0].verdict, 'ok'); eq(r.halModule.registered, true); eq(r.halModule.agentOnline, true);
|
|
||||||
ok(r.applyPlan.startsWith('no-op'), r.applyPlan);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('dry-run: 한쪽만 있는 연결·다른 사이트를 가리키는 연결·중복·없는 프로젝트·HAL 등록 누락·Gitea ref 누락을 모두 충돌/누락으로 잡는다', async () => {
|
|
||||||
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
|
|
||||||
const portal = readPortal(await portalDb(root, [['s_x', P2], ['s_y', P3], ['s_z', P3], ['s_ghost', 'f'.repeat(32)]])); const hal = readHal(halDb(root, { webmakerTarget: false }));
|
|
||||||
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `2222\trefs/heads/sites/${P1}/main\n` });
|
|
||||||
const kinds = r.findings.map((f) => f.kind).sort();
|
|
||||||
eq(kinds, ['duplicate_portal_sites', 'gitea_ref', 'hal_module_target_missing', 'link_mismatch', 'link_webmaker_only', 'missing_project', 'sso_identity_missing']);
|
|
||||||
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_mismatch'); eq(r.projects.find((p) => p.projectId === P3).verdict, 'duplicate'); eq(r.projects.find((p) => p.projectId === P4).verdict, 'link_webmaker_only');
|
|
||||||
ok(r.applyPlan.startsWith('blocked'), r.applyPlan); ok(r.summary.conflicts >= 4 && r.summary.missing >= 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('포털 없이(HAL 만) 돌리면 연결은 unverified 로 두고 판정하지 않는다', async () => {
|
|
||||||
const { root, data } = webmakerRoot(); const w = readWebmaker(data); const r = dryRun({ webmaker: w, hal: readHal(halDb(root)) });
|
|
||||||
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_unverified'); eq(r.summary.portal, null); eq(r.summary.conflicts, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test('assertNoSecrets 는 비밀로 보이는 키를 거부한다', async () => {
|
|
||||||
let threw = false; try { assertNoSecrets({ a: { passHash: 'x' } }); } catch (_) { threw = true; } ok(threw);
|
|
||||||
assertNoSecrets({ sessionGen: 1, isDefault: true, lastCheckAt: 'x' });
|
|
||||||
});
|
|
||||||
|
|
||||||
done();
|
|
||||||
Reference in New Issue
Block a user