Ariweb Suite 0.1.1 릴리스 병합 (release/0.1.1 → main)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 13:18:48 +00:00
co-authored by Claude Fable 5.1
481 changed files with 106534 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules/
data/
*.sqlite
*.sqlite-*
*.log
*.tmp
.DS_Store
/dist/
/modules/webmaker/data/
/modules/arimail/data/
/test/.tmp/
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
# 도입 소스 기록(IMPORTED_SOURCES)
생성: 2026-10-26T12:02:43.474Z — `node scripts/import-sources.mjs` 가 만든다. 파일별 '수정' 은 원본 커밋과 현재 파일의 해시 비교 결과이며, 수정 이유는 VERSION_HISTORY.md 의 "도입 소스" 절과 각 파일 머리 주석의 `[Ariweb Suite 변경]` 표시로 설명한다.
## webmaker
- 원본 저장소: https://source.ibit.kr/OZ-Group/WebHosting_AI_agent
- 고정 커밋: `3b10859884ef2fd0f2af11934ab6c16370fd64b8`
- 대상 경로: `modules/webmaker/`
- 파일 수: 176 (수정 8)
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가.
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|---|---|---|---|
| README.md | modules/webmaker/README.md | 그대로 | a7aa1eb7a6fdae8d |
| deploy/deploy.sh | modules/webmaker/deploy/deploy.sh | 그대로 | 6f016f8dcf56a3b2 |
| deploy/front-proxy-webmaker.conf | modules/webmaker/deploy/front-proxy-webmaker.conf | 그대로 | 0ec1641b554a3b50 |
| deploy/install-arimail.sh | modules/webmaker/deploy/install-arimail.sh | 그대로 | d8626ef07df49738 |
| deploy/install-gitea.sh | modules/webmaker/deploy/install-gitea.sh | 그대로 | 199631e74ea4f62c |
| deploy/install-services.sh | modules/webmaker/deploy/install-services.sh | 그대로 | f3b507512b501fd8 |
| deploy/install.sh | modules/webmaker/deploy/install.sh | 그대로 | 58ba0c250620b638 |
| deploy/nginx-webhosting.conf | modules/webmaker/deploy/nginx-webhosting.conf | 그대로 | 0058f1dafd622bc5 |
| deploy/nginx-wmstats.conf | modules/webmaker/deploy/nginx-wmstats.conf | 그대로 | cf4069704f65ef73 |
| deploy/remote-install.sh | modules/webmaker/deploy/remote-install.sh | 그대로 | 66457327d0ef1b3c |
| deploy/remote-update.sh | modules/webmaker/deploy/remote-update.sh | 그대로 | fb0cf663fc687542 |
| deploy/webhosting-agent.service | modules/webmaker/deploy/webhosting-agent.service | 그대로 | 77259a206ca5530b |
| deploy/webmaker-setup.service | modules/webmaker/deploy/webmaker-setup.service | 그대로 | 9bbcbb9239d24ef9 |
| docs/ARIMAIL.md | modules/webmaker/docs/ARIMAIL.md | 그대로 | 1e326aa2252621cf |
| docs/CALENDAR.md | modules/webmaker/docs/CALENDAR.md | 그대로 | 1d12cd1720897254 |
| docs/CONFIG.md | modules/webmaker/docs/CONFIG.md | 그대로 | 13c26d45b5002b48 |
| docs/DOMAIN-DNS-PROVIDERS.md | modules/webmaker/docs/DOMAIN-DNS-PROVIDERS.md | 그대로 | c5ee35c850c7c728 |
| docs/DOMAIN-SETUP.md | modules/webmaker/docs/DOMAIN-SETUP.md | 그대로 | 83f0e447cd82e3dd |
| docs/FIRST-INSTALL.md | modules/webmaker/docs/FIRST-INSTALL.md | 그대로 | 642ce20eb96cabb9 |
| docs/GITEA-LOCAL.md | modules/webmaker/docs/GITEA-LOCAL.md | 그대로 | 4d7462b52ef34783 |
| docs/SSL-WILDCARD.md | modules/webmaker/docs/SSL-WILDCARD.md | 그대로 | d93102519cb39333 |
| hooks/askpass.sh | modules/webmaker/hooks/askpass.sh | 그대로 | c86b42091d07748d |
| hooks/guard.js | modules/webmaker/hooks/guard.js | 그대로 | 4804e4cd337db53c |
| hooks/guard.sh | modules/webmaker/hooks/guard.sh | 그대로 | 29df99b89bc31912 |
| hooks/settings.json | modules/webmaker/hooks/settings.json | 그대로 | 30ebe47478166d24 |
| lib/audit.js | modules/webmaker/lib/audit.js | 그대로 | 5a0b6a90e3281d2f |
| lib/auth.js | modules/webmaker/lib/auth.js | **수정** | 84e3fcda15e46b49 |
| lib/awsv4.js | modules/webmaker/lib/awsv4.js | 그대로 | 8a4bdab7d701c4ec |
| lib/billing.js | modules/webmaker/lib/billing.js | 그대로 | 4e3f88fd1f8ebae1 |
| lib/brief.js | modules/webmaker/lib/brief.js | 그대로 | 9f37eb2e30c3e710 |
| lib/calendar.js | modules/webmaker/lib/calendar.js | 그대로 | c5ee8d9c2777daac |
| lib/claude.js | modules/webmaker/lib/claude.js | 그대로 | dcdebeeeb1d8ea99 |
| lib/config.js | modules/webmaker/lib/config.js | **수정** | aa577cf668b504e7 |
| lib/connectors/arimail.js | modules/webmaker/lib/connectors/arimail.js | 그대로 | f4dea3dc448c155e |
| lib/connectors/gitea.js | modules/webmaker/lib/connectors/gitea.js | 그대로 | e736d764b2cdac6a |
| lib/connectors/index.js | modules/webmaker/lib/connectors/index.js | 그대로 | 1dc6f6a8290b7a7f |
| lib/costcal.js | modules/webmaker/lib/costcal.js | 그대로 | 22b008dccc3318db |
| lib/crawler.js | modules/webmaker/lib/crawler.js | 그대로 | 392e0fd1e9c88a26 |
| lib/credits.js | modules/webmaker/lib/credits.js | 그대로 | 59caba266ed2bcda |
| lib/deploy.js | modules/webmaker/lib/deploy.js | 그대로 | ddc85c8850a243a6 |
| lib/dns/gcloud.js | modules/webmaker/lib/dns/gcloud.js | 그대로 | e006e6ff4cde77c5 |
| lib/dns/openprovider.js | modules/webmaker/lib/dns/openprovider.js | 그대로 | ae8009bedd485e50 |
| lib/dns/porkbun.js | modules/webmaker/lib/dns/porkbun.js | 그대로 | 9f779244aeddc392 |
| lib/dns/route53.js | modules/webmaker/lib/dns/route53.js | 그대로 | b409db36696b9d5e |
| lib/dnszone.js | modules/webmaker/lib/dnszone.js | 그대로 | 62f74f0772fdc4d4 |
| lib/domains.js | modules/webmaker/lib/domains.js | 그대로 | cee7a767487f9a41 |
| lib/engine-codex.js | modules/webmaker/lib/engine-codex.js | 그대로 | a074e8a4b9867d6d |
| lib/engine-openai.js | modules/webmaker/lib/engine-openai.js | 그대로 | 93e4701e165d032a |
| lib/feedback.js | modules/webmaker/lib/feedback.js | 그대로 | c5860a676dd6118a |
| lib/forms.js | modules/webmaker/lib/forms.js | 그대로 | e2f1b1bb21e5bbdd |
| lib/fx.js | modules/webmaker/lib/fx.js | 그대로 | 7514ae9840fa6307 |
| lib/gitstore.js | modules/webmaker/lib/gitstore.js | 그대로 | d4650fab7b85f9b6 |
| lib/google.js | modules/webmaker/lib/google.js | 그대로 | bfdaa70652f846cc |
| lib/i18n.js | modules/webmaker/lib/i18n.js | 그대로 | 944c12aa16d00f45 |
| lib/jobpids.js | modules/webmaker/lib/jobpids.js | 그대로 | f5df0824a0a457e9 |
| lib/jsonstore.js | modules/webmaker/lib/jsonstore.js | 그대로 | 20a9ee6f5acb22e7 |
| lib/logview.js | modules/webmaker/lib/logview.js | 그대로 | 0262fdf9042301f5 |
| lib/lunar.js | modules/webmaker/lib/lunar.js | 그대로 | fc858ad1b9166b8c |
| lib/mailer.js | modules/webmaker/lib/mailer.js | 그대로 | 7f10112c31ca2d7b |
| lib/mailservers.js | modules/webmaker/lib/mailservers.js | 그대로 | 8608b0f16a0ea9b1 |
| lib/mailsys.js | modules/webmaker/lib/mailsys.js | 그대로 | 8f2e419e859f62ea |
| lib/mailusage.js | modules/webmaker/lib/mailusage.js | 그대로 | a3b608fc766ebe06 |
| lib/maintpage.js | modules/webmaker/lib/maintpage.js | 그대로 | f57501de5a9349d8 |
| lib/modules.js | modules/webmaker/lib/modules.js | 그대로 | b0f8b6df731d180e |
| lib/multilang.js | modules/webmaker/lib/multilang.js | 그대로 | 30eaec7a02d17836 |
| lib/netguard.js | modules/webmaker/lib/netguard.js | 그대로 | 7f3f4a1a908b2371 |
| lib/openprovider.js | modules/webmaker/lib/openprovider.js | 그대로 | aa0ff8df9e04b482 |
| lib/passpolicy.js | modules/webmaker/lib/passpolicy.js | 그대로 | 96c30ce7d9f87797 |
| lib/planner.js | modules/webmaker/lib/planner.js | 그대로 | 55fe3f3597bd9278 |
| lib/prepaid.js | modules/webmaker/lib/prepaid.js | 그대로 | f1078822b25d5325 |
| lib/prompt.js | modules/webmaker/lib/prompt.js | 그대로 | 3279a47d1bb1e078 |
| lib/providers.js | modules/webmaker/lib/providers.js | 그대로 | bf01ac2ac60c40d1 |
| lib/ratelimit.js | modules/webmaker/lib/ratelimit.js | 그대로 | 5abfa063c0f78873 |
| lib/recovery.js | modules/webmaker/lib/recovery.js | 그대로 | 42272b277598a7fb |
| lib/refs.js | modules/webmaker/lib/refs.js | 그대로 | 1c530a8241d363dc |
| lib/registrar.js | modules/webmaker/lib/registrar.js | 그대로 | de6c606dcc87d665 |
| lib/registrars/openprovider.js | modules/webmaker/lib/registrars/openprovider.js | 그대로 | 04697456df4cc68e |
| lib/registrars/porkbun.js | modules/webmaker/lib/registrars/porkbun.js | 그대로 | 6bb857595564dcf6 |
| lib/registrars/route53.js | modules/webmaker/lib/registrars/route53.js | 그대로 | b60f17fefad37a4f |
| lib/remote.js | modules/webmaker/lib/remote.js | 그대로 | 965366d84a6ee859 |
| lib/report.js | modules/webmaker/lib/report.js | 그대로 | 3c14814e9a8f424f |
| lib/run.js | modules/webmaker/lib/run.js | 그대로 | aad85b1616605fc3 |
| lib/seo.js | modules/webmaker/lib/seo.js | 그대로 | fabcb8b18469ec10 |
| lib/settings.js | modules/webmaker/lib/settings.js | 그대로 | 98abbde31bbef9ce |
| lib/setupapi.js | modules/webmaker/lib/setupapi.js | 그대로 | 3fd30ebd9efa47a6 |
| lib/setupauth.js | modules/webmaker/lib/setupauth.js | 그대로 | 8e6e1981448bc343 |
| lib/setupstate.js | modules/webmaker/lib/setupstate.js | 그대로 | 5de040b7a5a88f6c |
| lib/sitemsg.js | modules/webmaker/lib/sitemsg.js | 그대로 | 09bcc4cb52e93a57 |
| lib/ssl.js | modules/webmaker/lib/ssl.js | 그대로 | f7a5bb79900d8bdd |
| lib/stats.js | modules/webmaker/lib/stats.js | 그대로 | fc2de57d4be0960a |
| lib/store.js | modules/webmaker/lib/store.js | **수정** | 8132dcde60fc5789 |
| lib/templates.js | modules/webmaker/lib/templates.js | 그대로 | bb44e537d0b28b16 |
| lib/uploads.js | modules/webmaker/lib/uploads.js | 그대로 | 5441ea45ed434575 |
| lib/vault.js | modules/webmaker/lib/vault.js | **수정** | 0730f701278d33a1 |
| lib/xlsx.js | modules/webmaker/lib/xlsx.js | 그대로 | dc353dd3b5fd6e10 |
| locales/ko.json | modules/webmaker/locales/ko.json | 그대로 | 20abf531ef8320f6 |
| locales/site/ar.json | modules/webmaker/locales/site/ar.json | 그대로 | 8644e815887e9bb1 |
| locales/site/de.json | modules/webmaker/locales/site/de.json | 그대로 | 60a7b71fd70ae371 |
| locales/site/en.json | modules/webmaker/locales/site/en.json | 그대로 | 0939d2e2eedf1af9 |
| locales/site/es.json | modules/webmaker/locales/site/es.json | 그대로 | 8ba29c21cf2b90fb |
| locales/site/fa.json | modules/webmaker/locales/site/fa.json | 그대로 | 4c2b4917b1639f87 |
| locales/site/fr.json | modules/webmaker/locales/site/fr.json | 그대로 | 55e2e55db288f856 |
| locales/site/he.json | modules/webmaker/locales/site/he.json | 그대로 | ccaf97cc3f1ea1da |
| locales/site/id.json | modules/webmaker/locales/site/id.json | 그대로 | 9dc0512cc5d63255 |
| locales/site/ja.json | modules/webmaker/locales/site/ja.json | 그대로 | e6ecb37062ec5a8b |
| locales/site/ko.json | modules/webmaker/locales/site/ko.json | 그대로 | ea80e5efdb464a7e |
| locales/site/mn.json | modules/webmaker/locales/site/mn.json | 그대로 | bf5ceec52790a369 |
| locales/site/pt.json | modules/webmaker/locales/site/pt.json | 그대로 | 6922cd08078716c5 |
| locales/site/ru.json | modules/webmaker/locales/site/ru.json | 그대로 | 9d850324dda56354 |
| locales/site/th.json | modules/webmaker/locales/site/th.json | 그대로 | 4f22ec5351f5c41d |
| locales/site/tr.json | modules/webmaker/locales/site/tr.json | 그대로 | 597f9c5b952934ab |
| locales/site/vi.json | modules/webmaker/locales/site/vi.json | 그대로 | 886013f8dfbd56fc |
| locales/site/zh-CN.json | modules/webmaker/locales/site/zh-CN.json | 그대로 | 237e520720cb0166 |
| locales/site/zh-TW.json | modules/webmaker/locales/site/zh-TW.json | 그대로 | a7982e11198a617d |
| modules/README.md | modules/webmaker/modules/README.md | 그대로 | f5d15ad9e5fb2d26 |
| modules/blog/widget.html | modules/webmaker/modules/blog/widget.html | 그대로 | 6a2e80e57038f69a |
| modules/board/widget.html | modules/webmaker/modules/board/widget.html | 그대로 | ac119ae767ce5aa5 |
| modules/booking/widget.html | modules/webmaker/modules/booking/widget.html | 그대로 | 40b6e4cefcf0e901 |
| modules/calendar/widget.html | modules/webmaker/modules/calendar/widget.html | 그대로 | 999a4f5558854ddf |
| modules/faq/widget.html | modules/webmaker/modules/faq/widget.html | 그대로 | 791e54b1dfcb8448 |
| modules/gallery/widget.html | modules/webmaker/modules/gallery/widget.html | 그대로 | 70869995fd8fd4f7 |
| modules/inquiry/widget.html | modules/webmaker/modules/inquiry/widget.html | 그대로 | b56c4807dd11a007 |
| modules/library/widget.html | modules/webmaker/modules/library/widget.html | 그대로 | 1f5ff8f6874a741a |
| modules/map/widget.html | modules/webmaker/modules/map/widget.html | 그대로 | 0f68e9b3c48e4678 |
| modules/members/widget.html | modules/webmaker/modules/members/widget.html | 그대로 | 3a0c8409adda26db |
| modules/reviews/widget.html | modules/webmaker/modules/reviews/widget.html | 그대로 | de7ad783f7cad79a |
| modules/survey/widget.html | modules/webmaker/modules/survey/widget.html | 그대로 | 62b2a3c2ee267b66 |
| package.json | modules/webmaker/package.json | 그대로 | 624dc8ee663ac2ba |
| public/404.html | modules/webmaker/public/404.html | 그대로 | 22b18c443cbb8f50 |
| public/admin.css | modules/webmaker/public/admin.css | 그대로 | b9b4a4cbaa9dc3ec |
| public/admin.html | modules/webmaker/public/admin.html | 그대로 | 6853f78ac57ef360 |
| public/admin.js | modules/webmaker/public/admin.js | 그대로 | fa5725fbe6356bbc |
| public/app.js | modules/webmaker/public/app.js | **수정** | 3baf113fbacdae72 |
| public/credits-ui.js | modules/webmaker/public/credits-ui.js | 그대로 | 44e43d5179d77578 |
| public/domains-ui.js | modules/webmaker/public/domains-ui.js | 그대로 | ea77c164db309303 |
| public/flow-ui.js | modules/webmaker/public/flow-ui.js | 그대로 | e9a92ab29b71840e |
| public/i18n.js | modules/webmaker/public/i18n.js | 그대로 | c6c4039435b77583 |
| public/import.js | modules/webmaker/public/import.js | 그대로 | 3f17de67ac16b9c4 |
| public/index.html | modules/webmaker/public/index.html | **수정** | bc33e85029da63b7 |
| public/login.html | modules/webmaker/public/login.html | 그대로 | dd870dfc417adc65 |
| public/manage-ui.js | modules/webmaker/public/manage-ui.js | 그대로 | c57d9d54dea3ab8d |
| public/modules-ui.js | modules/webmaker/public/modules-ui.js | 그대로 | 5f4f54a0abe692c4 |
| public/numfmt.js | modules/webmaker/public/numfmt.js | 그대로 | a11ef5768e547929 |
| public/optimize.js | modules/webmaker/public/optimize.js | 그대로 | 1dc20eb65f0db62f |
| public/pwrule.js | modules/webmaker/public/pwrule.js | 그대로 | 9a1ea70939fa58ce |
| public/setup.html | modules/webmaker/public/setup.html | 그대로 | 5c91302251e5cb7b |
| public/setup.js | modules/webmaker/public/setup.js | 그대로 | 9027313fdabe3be0 |
| public/site-admin.html | modules/webmaker/public/site-admin.html | 그대로 | d598563c7d617bbb |
| public/site-admin.js | modules/webmaker/public/site-admin.js | 그대로 | 762da69b23f1db85 |
| public/site-info.js | modules/webmaker/public/site-info.js | 그대로 | 99060343aaca0ab3 |
| public/style.css | modules/webmaker/public/style.css | **수정** | 041d7f216677b02d |
| public/templates-ui.js | modules/webmaker/public/templates-ui.js | 그대로 | 6adc66d8e2204b1f |
| public/webmail.html | modules/webmaker/public/webmail.html | 그대로 | 981fee72b5c72bed |
| public/webmail.js | modules/webmaker/public/webmail.js | 그대로 | 6cb9a50f39c8d025 |
| public/wizard.js | modules/webmaker/public/wizard.js | 그대로 | 2ba97adfbb4f7cc1 |
| public/wm-modules.js | modules/webmaker/public/wm-modules.js | 그대로 | efc7526ba17c338d |
| public/wm-stats.js | modules/webmaker/public/wm-stats.js | 그대로 | 7e72375620ac125d |
| scripts/acme-dns-hook.js | modules/webmaker/scripts/acme-dns-hook.js | 그대로 | 4401e102dde4c3ec |
| scripts/admin.js | modules/webmaker/scripts/admin.js | 그대로 | 02adaf25dabad0ec |
| scripts/backup-data.js | modules/webmaker/scripts/backup-data.js | 그대로 | 94b331c3e9555664 |
| scripts/collect-backlog.js | modules/webmaker/scripts/collect-backlog.js | 그대로 | d5e4a0c3ca9bf7f0 |
| scripts/config.js | modules/webmaker/scripts/config.js | 그대로 | d4b90ec541922219 |
| scripts/i18n-extract.js | modules/webmaker/scripts/i18n-extract.js | 그대로 | 7b0a2c7d76155b06 |
| scripts/migrate-gitstore.sh | modules/webmaker/scripts/migrate-gitstore.sh | 그대로 | 6b2fda206e3acb80 |
| scripts/site-msg-catalog.py | modules/webmaker/scripts/site-msg-catalog.py | 그대로 | 1f9b1ea8adbc174c |
| scripts/ssl-wildcard.js | modules/webmaker/scripts/ssl-wildcard.js | 그대로 | 68cbebdb59a5f6d9 |
| scripts/unit-check.js | modules/webmaker/scripts/unit-check.js | 그대로 | ba15624f696892b2 |
| server.js | modules/webmaker/server.js | **수정** | 4585fd7fb07d3b4b |
| setup-server.js | modules/webmaker/setup-server.js | 그대로 | 6545aab3185765a8 |
| templates/contact-widget.html | modules/webmaker/templates/contact-widget.html | 그대로 | 8063f64226c13914 |
| test/dnszone-import.test.js | modules/webmaker/test/dnszone-import.test.js | 그대로 | 33e1209656a7bf3f |
| test/domains-process.test.js | modules/webmaker/test/domains-process.test.js | 그대로 | 57ff395fcf97fbab |
| test/lib.test.js | modules/webmaker/test/lib.test.js | 그대로 | d13ab84b5f8f8b0d |
| test/mailservers.test.js | modules/webmaker/test/mailservers.test.js | 그대로 | dfb80acca9782be0 |
| test/registrar-route53.test.js | modules/webmaker/test/registrar-route53.test.js | 그대로 | 84e9eba32ad5c7ef |
| test/server.test.js | modules/webmaker/test/server.test.js | 그대로 | b931228730a8bd70 |
## arimail
- 원본 저장소: https://source.ibit.kr/OZ-Group/AriMail
- 고정 커밋: `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`
- 대상 경로: `modules/arimail/`
- 파일 수: 88 (수정 15)
- 라이선스 검토: OZ-Group 내부 전용(private: true, 별도 LICENSE 없음) — 같은 조직의 Ariweb Suite 에서 재사용 허용. 외부 배포 불가. haraka/ 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
| 원본 경로 | 대상 경로 | 수정 | 원본 해시 |
|---|---|---|---|
| README.md | modules/arimail/README.md | 그대로 | 942037563d2ec90a |
| deploy/deploy.sh | modules/arimail/deploy/deploy.sh | **수정** | cd7e1a5f26435839 |
| deploy/domain-nginx.sh | modules/arimail/deploy/domain-nginx.sh | 그대로 | c8322d284a055a68 |
| deploy/gateway.env.in | modules/arimail/deploy/gateway.env.in | 그대로 | 3255005cd674a261 |
| deploy/haraka-apply.sh | modules/arimail/deploy/haraka-apply.sh | 그대로 | 4a5700051eef5a3e |
| deploy/letsencrypt-deploy-hook.sh.in | modules/arimail/deploy/letsencrypt-deploy-hook.sh.in | 그대로 | 6d22293ac934c49f |
| deploy/lib.sh | modules/arimail/deploy/lib.sh | 그대로 | 4726339b779386e2 |
| deploy/new-site.sh | modules/arimail/deploy/new-site.sh | 그대로 | 77048380fc977d44 |
| deploy/nginx-apply.sh | modules/arimail/deploy/nginx-apply.sh | 그대로 | 9709a481130916b8 |
| deploy/nginx/mail-path.conf.in | modules/arimail/deploy/nginx/mail-path.conf.in | 그대로 | 893c6a395cfa79fb |
| deploy/nginx/mail.cloudflare.conf.in | modules/arimail/deploy/nginx/mail.cloudflare.conf.in | 그대로 | a6bd91d9ec547aa4 |
| deploy/nginx/mail.conf.in | modules/arimail/deploy/nginx/mail.conf.in | 그대로 | 55b039d39b838ae9 |
| deploy/nginx/mail.http-only.conf.in | modules/arimail/deploy/nginx/mail.http-only.conf.in | 그대로 | 3f327fdaa7c2ce9d |
| deploy/setup.sh | modules/arimail/deploy/setup.sh | 그대로 | 74fb725daf0b4481 |
| deploy/site-mail-path.sh | modules/arimail/deploy/site-mail-path.sh | 그대로 | 50b30b646233902f |
| deploy/systemd/mail-gateway.service.in | modules/arimail/deploy/systemd/mail-gateway.service.in | 그대로 | 1a129db88217c13a |
| deploy/systemd/mail-haraka.service.in | modules/arimail/deploy/systemd/mail-haraka.service.in | 그대로 | 9854efc3e9e6e877 |
| deploy/systemd/mail-tls.service.in | modules/arimail/deploy/systemd/mail-tls.service.in | 그대로 | 445add8875f3d99d |
| deploy/systemd/mail-tls.timer.in | modules/arimail/deploy/systemd/mail-tls.timer.in | 그대로 | 9eebee444b7d22a8 |
| deploy/tls-when-ready.sh | modules/arimail/deploy/tls-when-ready.sh | 그대로 | 8add122491a9c3d5 |
| deploy/webmaker-access.sh | modules/arimail/deploy/webmaker-access.sh | 그대로 | 4390f154d7b48dc4 |
| docs/SETUP.md | modules/arimail/docs/SETUP.md | 그대로 | 1362935eee8bcfb9 |
| docs/USAGE-API.md | modules/arimail/docs/USAGE-API.md | 그대로 | 622b2346d12972cd |
| haraka/README.md | modules/arimail/haraka/README.md | 그대로 | d6ce6e90a1466334 |
| haraka/package.json | modules/arimail/haraka/package.json | 그대로 | b29222d4d10a4246 |
| haraka/plugins/arimail.js | modules/arimail/haraka/plugins/arimail.js | **수정** | c33939420a33d72e |
| haraka/run.mjs | modules/arimail/haraka/run.mjs | 그대로 | c48fa74615815a7e |
| lang/ko.json | modules/arimail/lang/ko.json | 그대로 | 5e6a8354e25662ff |
| migrations/0001_init.sql | modules/arimail/migrations/0001_init.sql | 그대로 | f6b1b10b05cdb85a |
| migrations/0002_imap.sql | modules/arimail/migrations/0002_imap.sql | 그대로 | 3e234e2410a7e152 |
| migrations/0003_accounts.sql | modules/arimail/migrations/0003_accounts.sql | 그대로 | 00e8cf661fe62d5e |
| migrations/0004_no_retention.sql | modules/arimail/migrations/0004_no_retention.sql | 그대로 | 6e45e554cb1af82d |
| migrations/0005_imap_uid_reassign.sql | modules/arimail/migrations/0005_imap_uid_reassign.sql | 그대로 | 9d3d33c65e05b33d |
| migrations/0006_session_scope.sql | modules/arimail/migrations/0006_session_scope.sql | 그대로 | 9277cff3a82c282b |
| migrations/0007_auto_actions.sql | modules/arimail/migrations/0007_auto_actions.sql | 그대로 | 88c3c02ebf1f38fe |
| migrations/0008_large_files.sql | modules/arimail/migrations/0008_large_files.sql | 그대로 | a2d11409da0006b4 |
| package.json | modules/arimail/package.json | **수정** | f986ee70b4841c73 |
| server/api.mjs | modules/arimail/server/api.mjs | **수정** | b51ef607e4c0e65b |
| server/auth.mjs | modules/arimail/server/auth.mjs | 그대로 | e2d3c1906a46f0e1 |
| server/automation.mjs | modules/arimail/server/automation.mjs | 그대로 | 1f9a370fad319815 |
| server/config.mjs | modules/arimail/server/config.mjs | **수정** | 9f5cc526198f988d |
| server/db-migrate.mjs | modules/arimail/server/db-migrate.mjs | 그대로 | cbc5540c4a43e134 |
| server/db.mjs | modules/arimail/server/db.mjs | 그대로 | 35f5d6c1fc4c8ff2 |
| server/dns/common.mjs | modules/arimail/server/dns/common.mjs | 그대로 | c6ccc6ceeafa0bcf |
| server/dns/google.mjs | modules/arimail/server/dns/google.mjs | 그대로 | c825d4f04f7921c9 |
| server/dns/index.mjs | modules/arimail/server/dns/index.mjs | 그대로 | e3605c56e29aeb58 |
| server/dns/route53.mjs | modules/arimail/server/dns/route53.mjs | 그대로 | e31e330aa00af85f |
| server/domains.mjs | modules/arimail/server/domains.mjs | **수정** | ee558ae6f234b27f |
| server/files.mjs | modules/arimail/server/files.mjs | 그대로 | 0425698a1fe8652b |
| server/imap-mime.mjs | modules/arimail/server/imap-mime.mjs | 그대로 | ad5a4c60908a7847 |
| server/imap.mjs | modules/arimail/server/imap.mjs | 그대로 | 10ec4d7c53048df2 |
| server/index.mjs | modules/arimail/server/index.mjs | 그대로 | 3866bb3952efd9a8 |
| server/limits.mjs | modules/arimail/server/limits.mjs | **수정** | 016c3aecef9a6da7 |
| server/main.mjs | modules/arimail/server/main.mjs | **수정** | 90ad71bdc1bca7c0 |
| server/mime.mjs | modules/arimail/server/mime.mjs | 그대로 | 485abda8645820fe |
| server/pop3.mjs | modules/arimail/server/pop3.mjs | 그대로 | 377dcf0370e5cf9e |
| server/providers/index.mjs | modules/arimail/server/providers/index.mjs | **수정** | 5774cb75b5b1c9d4 |
| server/providers/resend.mjs | modules/arimail/server/providers/resend.mjs | 그대로 | 2b964265e2d0eb8d |
| server/providers/smtp-client.mjs | modules/arimail/server/providers/smtp-client.mjs | 그대로 | 308ef3b4dfc0b9e0 |
| server/providers/smtp.mjs | modules/arimail/server/providers/smtp.mjs | 그대로 | e7b475fc79c65834 |
| server/provision.mjs | modules/arimail/server/provision.mjs | 그대로 | d4c17584d1ef605d |
| server/resend.mjs | modules/arimail/server/resend.mjs | 그대로 | a6076fdcffd14f57 |
| server/sanitize.mjs | modules/arimail/server/sanitize.mjs | 그대로 | d14fd6ffd01e75e0 |
| server/setup.mjs | modules/arimail/server/setup.mjs | 그대로 | ece096ed19cbb679 |
| server/smtp.mjs | modules/arimail/server/smtp.mjs | **수정** | 61e2e7187c636297 |
| server/store.mjs | modules/arimail/server/store.mjs | 그대로 | a5741af42349a2dd |
| server/usage.mjs | modules/arimail/server/usage.mjs | **수정** | 29c97a4b5f627741 |
| server/user-cli.mjs | modules/arimail/server/user-cli.mjs | 그대로 | ba64bccf7f3892dc |
| server/util.mjs | modules/arimail/server/util.mjs | 그대로 | 6daf63506b35fb6c |
| server/webhook.mjs | modules/arimail/server/webhook.mjs | 그대로 | b3246cf3b830c4d8 |
| server/webmaker-snapshot.mjs | modules/arimail/server/webmaker-snapshot.mjs | 그대로 | ef6962db91b5b09a |
| server/webmaker.mjs | modules/arimail/server/webmaker.mjs | 그대로 | 6cde505d6949137e |
| test/_helpers.mjs | modules/arimail/test/_helpers.mjs | 그대로 | 3c8cf13bfc4fee0a |
| test/config.test.mjs | modules/arimail/test/config.test.mjs | 그대로 | 740db8ae5b3d105f |
| test/domains.test.mjs | modules/arimail/test/domains.test.mjs | 그대로 | 81c77eadfdfba0ea |
| test/gateway.test.mjs | modules/arimail/test/gateway.test.mjs | **수정** | fc5ef627262d950f |
| test/imap.test.mjs | modules/arimail/test/imap.test.mjs | 그대로 | d8387e1386302866 |
| test/provision.test.mjs | modules/arimail/test/provision.test.mjs | 그대로 | 58a66582f5d35655 |
| test/smtp.test.mjs | modules/arimail/test/smtp.test.mjs | **수정** | f92287ef6e7df130 |
| test/usage.test.mjs | modules/arimail/test/usage.test.mjs | **수정** | 659271c6b54935b2 |
| test/webmaker.test.mjs | modules/arimail/test/webmaker.test.mjs | 그대로 | 2a6818a40b54cfee |
| web/app.js | modules/arimail/web/app.js | 그대로 | cbfb4a748cc100f4 |
| web/favicon.svg | modules/arimail/web/favicon.svg | 그대로 | 0c44f177404b4090 |
| web/index.html | modules/arimail/web/index.html | 그대로 | f7935713b9d37b8d |
| web/setup.css | modules/arimail/web/setup.css | 그대로 | ebf376321f3f9074 |
| web/setup.html | modules/arimail/web/setup.html | 그대로 | b07043a0c5b952ad |
| web/setup.js | modules/arimail/web/setup.js | **수정** | 24f72af0d740a559 |
| web/style.css | modules/arimail/web/style.css | 그대로 | b63d6c779186c363 |
+38
View File
@@ -0,0 +1,38 @@
# Ariweb Suite
고객이 회원가입·로그인 → 상품 선택 → 사이트 제작(Webmaker) → 배포 → 도메인·HTTPS → 메일(AriMail) → 백업 → 운영(재배포·복원·이전·소유권·해지)까지 **하나의 연결된 서비스**로 이용하는 통합 플랫폼이다.
운영자는 HAL(`hal.ariweb.net`)에서 릴리스·서버·모듈·작업·장애·복구를 관리하고, HAL 이 Node Agent 를 통해 나머지 모든 모듈을 배포·갱신·되돌리기·복구한다.
- 버전: **0.1.1** — 변경 이력은 [VERSION_HISTORY.md](VERSION_HISTORY.md)
- 설계·개발 규약: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) · 포털↔HAL 작업 계약: [docs/HAL-JOBS.md](docs/HAL-JOBS.md) · HAL↔Agent 프로토콜: [docs/HAL-AGENT.md](docs/HAL-AGENT.md)
- 도입 소스(Webmaker 1.7.3 · AriMail 1.0.11): [IMPORTED_SOURCES.md](IMPORTED_SOURCES.md) — `node scripts/import-sources.mjs` 로 갱신
- 개발 지시 원문: `Ariweb-Suitle_Dev-Order_v0.1.1.md`
## 구성
| 프로세스 | 진입점 | 포트 | 공개 주소 |
|---|---|---|---|
| HAL(제어 평면·운영자 콘솔·공개 상태) | `hal/index.mjs` | 127.0.0.1:8900 | https://hal.ariweb.net |
| 고객 포털 | `portal/index.mjs` | 127.0.0.1:8910 | https://my.ariweb.net |
| Node Agent | `agent/index.mjs` | — | 각 관리 서버 |
| Webmaker(도입) | `modules/webmaker/server.js` | 127.0.0.1:3080 | https://webmaker.ariweb.net |
| AriMail(도입) | `modules/arimail/server/index.mjs` | 127.0.0.1:8790 + 메일 포트 | https://webmail.ariweb.net |
외부 npm 의존성 없음. Node 22.13 이상(`node:sqlite`).
## 개발
```bash
node test/run.mjs # 전체 회귀 테스트 (test/*.test.mjs)
node test/run.mjs lib # 이름에 lib 가 들어간 테스트만
node scripts/import-sources.mjs # 참조 저장소 고정 커밋에서 모듈 소스 갱신 + manifest
node scripts/release.mjs # 릴리스 artifact(dist/<version>/) 생성·검사
ARIWEB_PORTAL_DATA=./data/portal node portal/index.mjs
ARIWEB_HAL_DATA=./data/hal node hal/index.mjs
```
## 배포 순서(§32)
1. `main` 에 병합된 승인 릴리스에서 `node scripts/release.mjs` 로 artifact 생성
2. 최초 설치에서만 `deploy/bootstrap-hal.sh` 로 HAL 설치(HAL 자체 건강·실행 버전 확인, 첫 Node Agent 등록)
3. 이후 모든 모듈(포털·Webmaker·AriMail·Agent·백업·관측)은 HAL 운영자 콘솔의 모듈 배포 작업으로만 설치·갱신·되돌리기
+111
View File
@@ -0,0 +1,111 @@
# VERSION_HISTORY
## 0.1.1 (2026-10-26)
- **작성자**: 개발 지시 — Hera(`Ariweb-Suitle_Dev-Order_v0.1.1.md`, 통합 검토: Hera v0.1.0·Neo v0.1.0·1차 개발계획서) · 구현 — Claude Fable 5.1(Ariweb Suite 통합 개발, 커밋 Co-Authored-By 표기)
- **이전 버전**: 0.1.0 — 개발 지시 문서(`Ariweb-Suitle_Dev-Order.md`)만 저장소에 있었고 코드는 없었다. 0.1.1 은 Ariweb Suite 의 첫 구현 릴리스다.
- **정식 저장소**: https://source.ibit.kr/OZ-Group/AriWeb-Suite (브랜치 `release/0.1.1` → `main` 병합)
- **구성**: HAL(제어 평면·운영자 콘솔·공개 상태) · 고객 포털 · Node Agent · 도입 모듈 Webmaker 1.7.3 · AriMail 1.0.11. 외부 npm 의존성 없음, Node 22.13+.
### 경고 구간 변경
- 사용량 경고 구간을 **70% · 80% · 90% · 95%** 네 단계로, 100% 에서 자원별 제한으로 통일했다(v0.1.0 지시의 구간 체계를 대체). 값은 코드에 고정하지 않고 상품정책 버전(`product_versions.warn_levels_json`, 기본 `[70,80,90,95]`)에서 읽는다.
- 단계별 동작(§13.4): 70% Portal 사전 안내 → 80% Portal + 외부 이메일 → 90% 예상 소진 시각과 증설·정리 방법을 강조한 긴급 경고 → 95% 곧 제한될 기능과 즉시 조치를 적은 최종 경고 → 100% 자원별 제한과 유지 기능·해제 방법 표시. 같은 원인의 반복은 하나의 사건(incident key `<그룹>:<자원>:<대상>:<구간>`)으로 묶인다.
- 100% 제한의 자원별 의미: 웹 아웃바운드 → 사이트 대신 제한 안내, 다음 KST 월 또는 추가 용량 뒤 복구 · 웹 디스크 → 조회·삭제·내보내기 유지, 새 업로드·DB 증가·새 배포 제한 · 메일 저장 공간 → 조회·삭제 유지, 새 수신 임시 거부(발신 서버 재시도) · 월간 메일 통수 → 새 발신 차단 + 새 수신 임시 거부 · 메일함 수 → 새 메일함 생성 차단. 고객 데이터는 자동 삭제하지 않는다.
- AriMail 쪽에도 같은 구간을 적용해 `/api/usage` 가 `warnLevel`·`limitedReasons`·`monthReset` 을 돌려준다.
### 재사용한 기존 기능(그대로 도입)
기능별 상세 표는 `portal/webmaker/REUSE.md`(Webmaker, 원본 revision `3b10859`)와 `portal/mail/REUSE.md`(AriMail, 원본 revision `a07f69f`)에 있다. 두 모듈의 원본 회귀 테스트는 도입 뒤에도 모두 통과한다(Webmaker 59/59 — 원본 52 + 추가 7, AriMail 9개 묶음 모두 통과).
- **Webmaker(§0.1)**: `AI에게 맡기기`·`내가 고르기` 제작, Site Brief, Template 과 기존 Module, Page 생성·편집·미리보기·저장, AI 생성·수정과 Wallet/Cache 차감, 저장본 선택, Domain·DNS·TLS 관리(등록 업체·DNS 공급자·와일드카드 인증서), Gitea Source 연결과 변경 이력, 배포 결과 확인과 이전 Version 복구, AriMail Server 연결과 Mail 사용량 표시, 사용자·관리자 화면의 설정·상태·통계. 포털 사이트 상세 › 제작 탭의 "제작 화면 열기"(SSO) 로 진입한다.
- **AriMail(§0.3)**: Multi-domain Mail Service, Webmail(수신함·발신함·작성·회신·전달·검색), SMTP 발신·수신, IMAP·POP3, Resend·Haraka·External SMTP 발송 방식, Mail Domain·Account 관리, Mailbox 용량, 자동 응답·전달, 일반 첨부·링크 방식 대용량 첨부, POP3·IMAP 설정, DNS 설정·도메인 확인, 사용량 API, Webmaker 연동, 초기 설정·관리자·진단. 포털 메일 화면의 "웹메일 열기"(SSO 티켓) 와 외부 프로그램 설정 안내로 진입한다.
- **공통 라이브러리에 옮겨 적은 기존 방식**: Webmaker 의 원자적 JSON 저장·세션 세대·고정 관리자 비밀번호 폐지·scrypt 해시(→ `lib/db`·`lib/session`·`lib/crypto`), AriMail 의 의존성 없는 SMTP 클라이언트와 `node:sqlite` 운영(→ `portal/mailer.mjs`, `lib/db`), Webmaker `deploy/remote-update.sh` 의 갱신 순서(데이터 백업 → 교체 → 재시작 → 유닛 점검)와 AriMail `deploy/deploy.sh` 의 배포 순서(DB 백업 → 릴리스 복사 → 마이그레이션 → 재시작 → health → Haraka)를 HAL 모듈 설명자(`hal/modules/index.mjs`)와 Agent 모듈 설치기(`agent/jobs/module.mjs`)에 그대로 반영.
### 수정한 기존 기능(최소 연결 변경·오류 수정·정책 개선)
- **Webmaker(§0.2, `modules/webmaker`, 파일 머리 `[Ariweb Suite 변경]`)**
- 최소 연결 변경: `GET /api/auth/sso?ticket=`(포털 서명 티켓으로 별도 로그인 없이 진입, 계정 `aw-<그룹8>-<사용자8>` 자동 준비) · 내부 API `/api/internal/ariweb/*`(프로젝트 목록·연결·소스·저장본 export·credits·health, 서비스 서명) · 상단 배너에 현재 사용자 그룹·사이트 표시 · 설정 그룹 `ariweb.*`(포털 주소, 공유 비밀은 보관소) · `lib/ariweb.js`(티켓 검증·연결 등록부 `data/ariweb-links.json`).
- 정책 개선: 프로젝트와 운영 사이트를 구분(연결 등록부, "프로젝트" 표기), 연결된 프로젝트의 발행/재배포는 포털 배포 화면으로 안내(409 + 배포 URL), 프로젝트 삭제 전 포털 영향 조회(`/internal/webmaker/project-impact`)와 확인 필수 — 운영 사이트·배포본·도메인은 자동 삭제하지 않음.
- 오류 수정/개선: 여러 사용자의 동시 편집을 조용히 덮어쓰지 않도록 사이트 저장본 `rev` 를 두고 불일치 시 409 와 양쪽 요약을 돌려준다.
- 포털 쪽 정책: Draft 사이트는 운영 사이트 수를 쓰지 않고 최초 공개 배포 때 `예약됨` → 대표 도메인 확인 뒤 `사용 중`; AI Wallet(Webmaker credits)과 호스팅·메일 사용량을 분리 표시; 외부 자가 배포는 비용·책임·상태를 구분.
- **AriMail(§0.4, `modules/arimail`, 15개 파일, 회귀 테스트 `test/ariweb.test.mjs` 8건 추가)**
- 오류 수정: `test/usage.test.mjs` 의 KST·UTC 월 경계 — 상대 시각(`daysAgo`) 을 KST 정오 기준으로 고정하고 `limits.statsNow`/`monthlyCount(env, at)` 로 고정 시각 검사(2026-10-31T14:59:59Z ↔ 15:00:00Z 경계 테스트) · Haraka/External SMTP 발신 때 `RESEND_API_KEY` 를 잘못 요구하던 `server/smtp.mjs` 451 오류.
- 정책 개선: 도메인 등록부에 `ariweb {kind: site_bound|standalone, siteId, groupId, mailServiceId}` 와 `operating(active|migrating_out|migrating_in|frozen)` · 도메인별 보장량(`LIMIT_ACCOUNTS/TOTAL_BYTES/MAILBOX_BYTES/MONTHLY_MESSAGES` 를 테넌트 config 로) · 월간 통수 = 송신+수신, 최종 수신자 한 명당 한 통 · 저장 공간에 원문·일반 첨부·Inline·대용량 첨부 포함 · 100% 동작(저장 공간 → 수신 임시 거부·조회/삭제 유지, 월간 통수 → 발신 차단 `mail_monthly_limit` + 수신 임시 거부, 다음 KST 월 초기화) · 발송 큐(`outbound_queue` 표, 재시작 복구)와 안전 속도(도메인·서버별 초당 5건, 제출 초당 10건 이상은 제한·경고·악용 확인 `abuse_flags`, 운영자 `/api/admin/rates`) · 이전 중 두 서버 동시 처리 방지(`operating` ≠ active 면 수신 임시 거부·발신 차단) · SSO `GET /api/auth/sso?ticket=`.
- 포털 쪽 정책: Site 대표 Mail 과 독립 MailService 구분, 사이트당 대표 Mail Domain 하나, 독립 MailService 는 운영 사이트 수 미소비, Site-bound → 독립 전환, 고객용 Mail Backup·복원 미제공 명시, 해지 후 30일 읽기·Export 와 삭제 예정일.
- **미사용**: Webmaker 자체의 배포 대상(deploy targets)·되돌리기 기능은 Ariweb 연결 프로젝트에 쓰지 않는다(관리형·외부 배포는 HAL 작업으로 통일). AriMail 별칭·전달 전용 주소 생성은 AriMail 에 기능이 없고 정책이 미확정이라 이번 버전에서 제공하지 않는다(무제한 제공 금지 원칙).
### 고객 화면
- 공통 틀(§2): 상단 그룹 전환·알림·도움말·사용자 메뉴, 왼쪽 메뉴(대시보드·사이트·메일·도메인·백업·상품 및 결제·사용량·사용자 그룹 설정), 제목 아래 현재 위치·주요 작업 버튼, 상단 주의 항목. 모든 화면이 `불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음` 상태를 갖고, 상태는 색과 함께 글자·기호로 표시하며 360px 에서 표가 카드로 접힌다. 파괴적 작업은 대상·영향 재표시 + 입력 확인.
- 작업 상태 어휘(§2.2) 14종을 `lib/status` 로 통일하고, 서비스 상태·작업 상태·외부 건강 상태를 별도 필드로 둔다. 작업 블록은 현재/완료/다음 단계, 예상 완료 또는 산정 불가 사유, 마지막 갱신, 자동 재시도 횟수·다음 시각, 재시도·취소·되돌리기·문의 번호를 보인다.
- 회원가입·로그인·복구(§3): 이메일 확인 전 유료 구매·운영 서비스 생성 불가, 중복 이메일은 존재 여부 비노출, 약관·개인정보·마케팅 동의 분리, 5회 실패 → 15분 잠금(해제 시각 표시), 기기별 세션 목록과 개별/전체 종료, 외부 이메일 재설정 링크(만료·사용 완료 표시), TOTP 다중 인증과 1회 표시 복구 코드.
- 사용자 그룹(§4): 그룹 전환 즉시 반영·다른 그룹 정보 비노출, 구성원·초대(7일, 재발송·취소·만료)·역할·제거·정지(본인 세션과 미실행 작업만, 영향 범위 사전 표시)·탈퇴·소유권 이전, `user-NN` 별칭, 사이트별 권한(조회·콘텐츠 편집·배포·도메인/메일·결제/해지·전체 관리), 직접 주소 접근도 거부와 필요 역할 안내.
- 대시보드(§5): 운영/구매 사이트 수(예약됨·사용 중 구분), 초안·제작 중, 정상·경고·장애, 관리형·외부, 도메인 조치, 메일 서비스·메일함, 백업 사이트, 진행 작업, 이번 달 예상 비용·결제 대기 금액, 중요도순 주의 항목(조치 화면 링크), 사용자/자동 작업을 구분한 최근 활동.
- 알림 센터·감사 기록(§24): 분류·필수 알림·사건 묶음·조치 링크·읽음 처리, 누가·언제·어디서·무엇을·자동 여부·결과 조회, 권한·소유권·배포·해지·복원·이전·결제 승인은 삭제 불가 기록.
### 운영자 화면
- HAL 운영자 콘솔 신설(`hal.ariweb.net/ops/`, §25): 허용 네트워크(사설망/VPN) 또는 기기 귀속 접근권 뒤의 별도 로그인(MFA), 고객 세션 차단, 접근권 만료·재인증 필요·승인 대기·거부 이유 배너, 메뉴 14개(운영 현황·고객과 사용자 그룹·주문과 결제 승인·상품과 정책·사이트와 배포·메일 서비스·도메인과 인증서·서버와 용량·백업과 복원·서버 이전·배포 릴리스·장애와 알림·감사 기록·플랫폼 복구) + 운영자 계정, 고위험 작업(모듈 되돌리기·서버 삭제 등) 3단계 확인, 1회 표시 값(등록 토큰·긴급 자격·접근권·복구 코드).
- 운영 현황(§26.1): HAL·Portal·Webmaker·웹호스팅·AriMail·Source·백업·관측 상태, 등록/비정상 서버, 진행 중 배포·복원·이전, 처리 대기 승인, 용량 경고, 장애·유지보수 — 오래된 상태는 `정보 지연` 과 마지막 확인 시각.
- 고객 조회(§26.2): 검색·그룹 상세(구성원·권리·구독·사이트·메일·도메인·백업·주문·사용량·정지·보존·실패 작업), 대신 변경은 허용 항목만 전후 값·사유·승인자를 기록하고 고객 감사 기록에도 남김, 고객 지원용 일시 접근 기록(대상·이유·만료·승인).
- 주문 승인·상품 관리(§27), 서버 목록·등록 7단계·유지보수·격리·90일 지표(§28), 작업 화면의 대상별 결과(§29), 도메인·메일(원문 미노출, 접근 사유)·백업/복원·이전·Source 운영(§30), 릴리스·긴급 복구·재해복구 화면(§32~§34).
- HAL 백엔드(§25·§28·§29): 운영자 계정은 고객 계정과 분리(별도 로그인 화면·세션 종류 `operator`), 허용 네트워크(사설망/VPN CIDR) 또는 사용자·기기 귀속 단기 접근권 없이는 로그인 화면도 열리지 않음, 고위험 작업은 최근 재인증 + 3단계 확인(영향 범위 → 대상 식별정보 재확인 → 최종 실행과 되돌리기 가능 여부), 접근권 만료·재인증 필요·승인 대기·거부 이유 표시.
- 포털 운영자 API(`/internal/ops/*`, 서비스 키 서명): 주문 승인(대상·금액·권리 변화·현재 사용량, 반복 승인 안전), 상품 버전 관리(판매된 조건은 새 버전), 사용량·제한, 사이트·배포·도메인·Source·메일(원문 미노출, 진단 사유 기록)·백업/복원 승인(고객·그룹·사이트·도메인·백업 시각 최종 확인)·이전·소유권 이전·생명주기 운영.
### 사이트 생성과 배포
- 사이트 목록·필터·생성(§7): 이름·용도·Webmaker 프로젝트(새로/기존)·템플릿·언어·시간대·배포 방식 선택, 초안은 사이트 수 미소비, 생성 뒤 다음 단계 안내.
- 사이트 상세(§8) 10개 탭. 관리형 운영 사이트는 실제 수집값(`site.usage` 작업 → `usage_samples`)만 표시하고, 미배포 사이트는 준비 단계를, 외부 사이트는 Agent 연결·마지막 배포 결과·외부 확인·고객 책임 범위와 `제공되지 않음` 을 표시한다.
- 배포 방식(§10): 관리형(설명·모니터링 범위·한도·백업·이전 가능성·예상 시간·승인 비용, 정적 + 승인된 동적 실행 형태 사전 점검) / 외부(지원 대상 = External Pull Agent 를 설치할 수 있는 Linux 서버, 준비 정보, 건별 비용, 미제공 기능, 장애 분리, 버전이 기록되는 동의 `external-deploy-2026-10-0.1.1`, 외부 메일 배포 미지원).
- 배포(§11): 사전 확인(저장본·사이트 수·배포 위치·용량·도메인·필수 설정·작업 충돌, 통과/차단 구분), 10단계 진행 표시, 화면을 닫아도 계속, 완료 상태 `배포 검증 완료 / 도메인 설정 필요 / 공개 운영 중`, 최초 공개 배포 때 사이트 수 `예약됨` → 대표 도메인 확인 뒤 `사용 중`, 실패 시 단계·안전한 오류·문의 번호·기존 버전 유지·재시도·되돌리기, 중간 결과 `지금 정리하고 반환`. 배포 방식 변경은 새 위치 준비 → 별도 배포 → 검증 → 도메인 전환 → 이전 위치 정리.
- 외부 배포: Agent 설치 안내·일회용 연결·연결 확인·배포·재배포·제거·마지막 외부 확인·실패 잔여물 정리.
### 호스팅
- Node Agent 가 관리 서버에서 사이트를 `/var/www/ariweb-sites/<siteId>/releases/<n>` + `current` 로 배치하고 nginx vhost(와일드카드 인증서 재사용 또는 사이트별 certbot, 유지보수 페이지)를 템플릿으로 생성, 내부·외부 확인, 사용량 수집(디스크·DB·업로드, 일/월/누적 인바운드·아웃바운드, 24시간·7일·30일 추이, 오류율·p95).
- 서버 등록(§28.2)은 역할·위치 → 일회용 등록 정보(10분, 1회, 재표시 없음) → Agent 설치 → 신원·기능·용량 확인 → 운영자 승인 → 배치 가능. 유지보수(신규 배치 중지 → 영향 확인 → 이전 → 시작/종료 → 재개). 총 보장량·실제 용량을 넘는 배치 금지.
### 메일
- 메일 서비스 종류·목록·생성 흐름(§15), 메일함 목록·생성·설정(§16, 초기 비밀번호 1회 표시 또는 초대 링크, 정지 메일함도 수·공간 소비 표시, POP3·IMAP 서비스 스위치와 적용 후 접속 확인, 웹메일 SSO 진입, 외부 프로그램 설정값·비밀번호 비표시), 사용량·제한(§17, 월간 송신+수신 합계·최종 수신자 기준·누적·할당·다음 KST 초기화·마지막 계측, 발송 제한 사유·해제 시각·문의, 대용량 첨부 정책 사전 표시), 고객용 메일 백업·복원 미제공 명시(§17.4), 메일 도메인 보안(§18, 정상·권장 개선·오류, 복사 가능한 값).
- 활성화는 결제·승인 권리가 있을 때만, 내부 준비만으로 완료하지 않고 실제 외부 송신·수신·회신 확인 작업을 거친다(운영자가 검증용 수신 주소를 설정하지 않으면 `사용자 확인 필요` 로 정직하게 멈춤).
### 도메인과 인증서
- 도메인 목록·연결 8단계(§12: 입력 → 웹/메일/둘 다 → 소유 확인(TXT) → DNS 값 안내(복사) → 반영 확인(미반영과 잘못된 값 구분) → 인증서 발급 → 외부 접속 확인 → 대표 지정), 한 사이트 여러 도메인도 사이트 수 하나, 대표 도메인 변경 시 이전 주소 이동 처리 선택, 인증서 발급·갱신·만료 임박·실패 표시, 유효하지 않은 인증서를 정상 HTTPS 로 표시하지 않음, 같은 도메인의 다른 고객 연결 차단.
- 등록 업체·DNS 자동화는 Webmaker 의 기존 도메인 탭(SSO) 에서 수행하고 포털은 상태·확인을 담당한다. 도메인 등록 기능이 없는 환경(검증 Catalog)에서는 외부 등록기관 안내만 표시한다.
### 상거래
- 상품 목록(§6.1)은 운영자가 활성화한 최신 상품 버전을 표시(가격·한도를 화면에 고정하지 않음, 미확정 정책은 `미확정 정책` 안내). 주문 8단계(§6.2), 0.1.1 기본 결제는 **수동 결제 확인**(입금 안내·주문 번호·금액·통화·세금·기한 → 운영자 입금 확인 → 승인/거절 → 승인 뒤에만 권리 활성화), 새로 고침·연타 중복 방지(멱등 키), 거절·만료 사유와 재주문, 자동 결제수단 비표시, 갱신 전 청구 예정 안내와 `갱신 결제 대기`, 미납 제한 시점은 정책에서 읽어 고객·운영자 동일 표시, 미납 즉시 삭제 없음.
- 결제 화면(§6.3), 상품 축소·해지(§6.4, 범위 안이면 즉시, 아니면 줄여야 할 항목을 구체적으로 표시하고 보류 → 정리 후 재검증), AI Wallet 분리 표시·자동 초과 사용 기본 꺼짐·초과 허용 시 항목별 상한·기간·철회 결과 확인(§6.5).
- 출시 검증 Catalog(`catalog/release-0.1.1.json`): basic·team 두 기본 상품(사이트 수·웹 디스크·월간 웹 아웃바운드·메일 저장 공간·월간 메일 통수·메일함), 옵션(독립 메일·Source·백업·디스크/아웃바운드/메일함 추가), 가격·통화·세금·수동 승인·권리·한도·백업 일정·보존·복원 비용·생명주기 결과를 모두 채움. 출시 시험용이며 운영자가 콘솔에서 새 버전으로 관리한다.
### 백업과 복원
- 관리형 사이트 단위 유료 옵션(§19): 백업 가능한 관리형 사이트에서만 구매, 외부 사이트 미제공, 사이트당 하나, 구매 화면에 저장 한도·사용량·일정·Daily/Weekly/Monthly 보존 개수·첫 백업 예정·원격 사본 검증 방식·추가 저장 옵션(활성 상품정책에서 읽음). 포함: 현재 배포본·DB·업로드·복원에 필요한 설정. 제외: 메일 원문·소스 저장소·Webmaker 이력·외부 배포 데이터·플랫폼 내부 사본(화면에 명시).
- 백업은 운영 서버와 다른 저장 서버에 두고 생성 후 읽기 검증, 반복 실패 알림과 다음 재시도, 저장 공간 부족 시 최소 보존 세트를 지우지 않고 다음 백업 중지·안내, 중지된 사이트의 정기 백업 중지와 재개 방식 표시, 옵션 만료 후 7일 보관 뒤 삭제.
- 복원(§20): 요청 정보(백업 시각·포함 데이터·영향·예상 중단·비용·되돌리기 범위) → 비용·승인·작업 시간 → 9단계(보호 시점·격리 검증·복원·기능 확인·외부 확인·사용자 확인 또는 자동 완료·정리) → 실패 시 유지/되돌림, 보호 시점 유지 기간·마지막 되돌리기 시각·완료 방식 사전 표시.
### 이전
- 서버 이전(§21): 정적 자동(조건 충족 시)·동적/메일 승인형·고객 요청 유료 구분, 사전 안내(대상·이유·위치·시간·영향·변경 제한·비용·행동), 10단계, 시작 전과 전환 직전 재점검(조건 변화 시 자동 중지·알림), 두 서버 동시 운영 금지, 실패·일시 중지·취소·되돌리는 중·되돌림 완료 구분, 경계 표시(취소 가능 마지막 단계, 쓰기 제한 시각, 목표 중단 시간, 유일 운영 시점, 되돌리기 기간, 이전 위치 보존·삭제, DNS·인증서 대기 처리), 플랫폼 사유 이전 무과금.
- 소유권 이전(§22): 대상/제외 구분(AI Wallet·그룹 미배정 용량·청구 이력 제외, 독립 메일은 명시 선택), 요청 → 수락(받는 쪽 권리 부족 시 보류) → 운영자 심사 → 원자적 완료(일부만 이전된 상태 비노출), 진행 작업 중에는 시작 불가, 만료·취소 시점, 분쟁 동결, 청구 책임 종료·시작 시각.
- 운영 사이트 수 반환(§14) 8단계 점검표와 대표 메일의 독립 전환(주소·메일함·별칭·전달·비밀번호·DNS 유지 항목별 확인, 전환 전후 송수신 확인, 누락 시 반환 거부).
### 모니터링
- 공통 상태 어휘와 `정보 확인 지연`, HAL 응답 불가 시 포털은 신규 증가를 막고 기존 조회·삭제·내보내기를 유지(§13.4·§26.2), Agent heartbeat 30초·90초 침묵 시 오프라인, 서버 지표(CPU·메모리·디스크·inode·I/O·네트워크·시계 차이) 90일 보관, 사건(incident) 묶음, 공개 상태 페이지 `/status`(개인정보·내부 위치 비표시).
- 운영자 장애 화면(§31.2): 같은 원인 경고를 하나의 사건으로 묶고 고객 영향·최근 배포·담당자·고객 공지·실제 사용자 경로 확인 뒤 종료. 고객 화면(§31.1)은 자신의 서비스에 대한 정상·성능 저하·일부 장애·중단과 영향 기능·시각·대응 상태만 보이며 내부 서버 이름·다른 고객 정보는 노출하지 않는다. 공개 상태 페이지(§31.3)는 로그인 없이 주요 서비스 상태와 계획된 유지보수를 보인다.
### HAL/Node Agent 운영
- HAL(§32): 릴리스 등록부(버전·main 커밋·빌드 시각·artifact sha256·검사 결과·승인), 부트스트랩은 최초 설치에서만(`deploy/bootstrap-hal.sh`: 승인 릴리스·고정 artifact 선택, 보관소·인증서·접근 경로 준비, HAL 설치, 첫 Agent 등록 토큰) 이후 수동 설치 잠금, 정상 배포 순서(HAL 먼저 → HAL 자체 건강·실행 버전 확인 → Agent 연결 확인 → 모듈 배포 → 모듈별 건강·실제 버전 → 고객 경로 확인 → 완료/되돌리기), HAL 확인 실패 시 나머지 배포 금지, 대상별 성공·실패·대기 결과(일부 실패를 전체 성공으로 표시하지 않음), 목표 버전과 실제 버전 차이·조정(reconcile).
- Node Agent: 일회용 토큰 등록 → 키·비밀 1회 발급, HMAC 서명 요청, long-poll 작업 수신, 단계 저장과 재시작 후 재개, `sudo -n` 범위 최소화(systemctl·nginx·certbot), sudo 불가 시 `사용자 확인 필요` 로 보고, External Pull Agent 모드(사이트 배포·제거·확인만, 한 사이트에 귀속).
- 긴급 복구(§33): 사건 번호·사유·대상·허용 작업·짧은 유효 시간, 재인증 + 서로 다른 내용의 3단계 승인(한 명도 가능, 단계별 시각 기록), 모든 작업 기록, 자격 1회 표시·정상화 후 만료, 허용 범위(HAL·HAL 저장 정보·비밀정보·인증서 기반·artifact 저장소·최초 Agent 연결) 밖 작업 거부, HAL 정상화 뒤 실제/원하는 상태 비교·조정.
- 플랫폼 재해복구(§34): HAL 운영 정보·진행 작업·로그인·비밀정보·인증서 기반·배치·서버 등록 정보의 복구 사본·외부 보관·검증·훈련 기록, RTO/RPO/주기/보존 설정과 측정값 비교, 복구 시작 별도 승인과 기존 제어 위치 중지 확인, 하나의 HAL 만 운영(임대, 같은 호스트의 죽은 프로세스는 이어받음), 훈련 성공 전 정식 운영 준비 미표시.
### 도입 소스
- 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다.
- 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함.
- 수정된 파일(원본 대비): Webmaker 8개(`lib/auth.js lib/config.js lib/store.js lib/vault.js public/app.js public/index.html public/style.css server.js` + 신규 `lib/ariweb.js`, `test/ariweb.test.js`), AriMail 15개(`deploy/deploy.sh haraka/plugins/arimail.js package.json server/api.mjs server/config.mjs server/domains.mjs server/limits.mjs server/main.mjs server/providers/index.mjs server/smtp.mjs server/usage.mjs test/gateway.test.mjs test/smtp.test.mjs test/usage.test.mjs web/setup.js` + 신규 `server/sendqueue.mjs`, `migrations/0009_ariweb_queue.sql`, `test/ariweb.test.mjs`). 이유는 위 "수정한 기존 기능" 과 각 파일의 `[Ariweb Suite 변경]` 주석.
- 참조 저장소는 직접 배포하지 않는다. HAL 이 `modules/*` 에서 만든 artifact(`webmaker-0.1.1.tar.gz`, `arimail-0.1.1.tar.gz`)만 배포한다.
### 알려진 제한
- 로컬 E2E 검증(`docs/verification/local-e2e-2026-10-26.md`, 실제 HAL·포털·Node Agent 4대 + 가짜 Webmaker·AriMail·메일): 34개 완료 조건 중 19개 로컬 확인, 14개 부분 확인, 1개(외부 메일 왕복) 로컬 확인 불가, 결함 0. 부분 확인 항목은 실제 DNS·공인 서버·실 AriMail 왕복·시간 경과(30일 보존)가 필요한 부분이며, 실제 환경 확인 결과는 `docs/verification/` 에 별도 기록한다.
- DNS: `ariweb.net` 은 hosting.co.kr 네임서버(웹 패널)라 `hal.ariweb.net`·`my.ariweb.net` A 레코드는 운영자가 패널에서 만들어야 한다. 레코드가 생기기 전에는 로컬 `--resolve` 로만 확인된다.
- 실제 외부 메일 왕복 검증은 운영자가 검증용 수신 주소(자동 응답 메일함)를 설정해야 완료된다.
- AI Wallet 은 Webmaker credits 를 읽어 표시만 하며, 초과 사용 과금 라인 생성은 하지 않는다(표시·제한 해제만).
- 메일 서비스 서버 이전은 AriMail `operating` 상태 전환 도우미(setMigrating) 가 포털에 없어 `설정 필요` 로 안내된다.
- 별칭·전달 전용 주소 생성, 대량 .eml 내보내기(IMAP/POP3·원문 다운로드 안내로 대체), Gitea 브랜치 자동 삭제(상태만), 외부 메일 배포, 자동 결제수단은 이번 버전에서 제공하지 않는다.
- Agent 의 사용량 수집은 Agent 가 배포한 사이트의 nginx `awstats` 로그만 집계한다(Webmaker 자체 호스팅 사이트는 Webmaker 통계 유지).
### 미확정 정책값
- 활성 상품정책에 `{status: 'deferred'}` 로 표시되어 화면에 `미확정 정책` 으로 안내되는 값: `alias_counting`(별칭·전달 전용 주소 계산 방식 — 확정 전 무제한 제공 금지, 메일함 수에 포함), `large_attachment`(대용량 첨부 허용 크기·다운로드 횟수·보관 기한 — 시스템 안전 상한 우선), `ai_wallet_pricing`(AI 크레딧 단가 — Webmaker 관리자 설정을 따름).
- 운영자가 후속 상품정책에서 정할 값: 도메인 등록·갱신 가격(등록 업체 연동 시), 외부 배포 건별 비용의 최종 금액, 이전 목표 중단 시간·되돌리기 기간·이전 위치 보존 기간·고객 요청 이전 비용의 운영 값, 복원 비용·중단 시간, 보존 기간 재정의(기본 30/30/30/7일), 초과 사용 단가. 검증 Catalog 의 수치는 모두 출시 시험용이다.
+42
View File
@@ -0,0 +1,42 @@
/**
* agent/client.mjs — HAL 호출(서명·시계 차이·artifact 내려받기·전송 올리기/내려받기).
* 모든 요청은 lib/serviceauth 로 서명한다(keyId 'agent:<id>'). 응답의 Date/X-HAL-Time 으로 시계 차이를 재어 heartbeat 에 싣는다.
* 큰 파일 업로드는 본문 해시를 서명에 넣지 않고(빈 본문 서명) X-Transfer-Sha256 헤더로 무결성을 전한다(HAL 이 받은 뒤 다시 계산해 비교).
*/
import { signHeaders } from '../lib/serviceauth.mjs';
import { createHash } from 'node:crypto';
import { createWriteStream, createReadStream, statSync } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
export function createClient({ halUrl, agentId, secret, fetchImpl = fetch }) {
const base = String(halUrl).replace(/\/$/, ''); const keyId = `agent:${agentId}`; let clockSkewMs = 0;
function skew(r) { const t = Number(r.headers.get('x-hal-time') || 0) || Date.parse(r.headers.get('date') || ''); if (t) clockSkewMs = Date.now() - t; }
async function request(method, path, body, { timeoutMs = 30000 } = {}) {
const text = body == null ? undefined : JSON.stringify(body);
const r = await fetchImpl(base + path, { method, headers: signHeaders(keyId, secret, method, path, text), body: text, signal: AbortSignal.timeout(timeoutMs) }); skew(r);
let json = null; try { json = await r.json(); } catch (_) {}
if (!r.ok || (json && json.ok === false)) { const e = new Error(`HAL ${method} ${path} → ${r.status} ${json?.error || ''} ${json?.message || ''}`.trim()); e.status = r.status; e.code = json?.error || `http_${r.status}`; throw e; }
return json;
}
/** 파일 내려받기 + sha256 검증(expected 가 있으면) */
async function download(path, dest, expected = null) {
// [E2E 결함 수정] 포털이 주는 번들 URL 은 절대 주소(토큰으로 보호)다. HAL 상대 경로일 때만 HAL 서명 헤더를 붙인다.
const absolute = /^https?:\/\//i.test(path);
const r = await fetchImpl(absolute ? path : base + path, { headers: absolute ? {} : signHeaders(keyId, secret, 'GET', path, undefined), signal: AbortSignal.timeout(3600000) }); if (!absolute) skew(r);
if (!r.ok) { const e = new Error(`HAL 내려받기 실패 ${path} → ${r.status}`); e.code = `http_${r.status}`; throw e; }
const hash = createHash('sha256'); const src = Readable.fromWeb(r.body); src.on('data', (c) => hash.update(c)); await pipeline(src, createWriteStream(dest, { mode: 0o600 }));
const sum = hash.digest('hex'); const want = String(expected || r.headers.get('x-sha256') || '').replace(/^sha256:/i, '').toLowerCase(); if (want && want !== sum) throw Object.assign(new Error(`내려받은 파일 해시가 다릅니다(기대 ${want.slice(0, 12)}… 실제 ${sum.slice(0, 12)}…)`), { code: 'checksum_mismatch' });
return { sha256: sum, size: statSync(dest).size };
}
/** 전송 올리기(스트리밍). 반환 { id, sha256, size } */
async function upload(transferId, file, { purpose = '' } = {}) {
const hash = createHash('sha256'); for await (const c of createReadStream(file)) hash.update(c); const sum = hash.digest('hex'); const size = statSync(file).size;
const path = `/agent/v1/transfers/${transferId}${purpose ? `?purpose=${encodeURIComponent(purpose)}` : ''}`;
const r = await fetchImpl(base + path, { method: 'POST', headers: { ...signHeaders(keyId, secret, 'POST', path, ''), 'Content-Type': 'application/octet-stream', 'Content-Length': String(size), 'X-Transfer-Sha256': sum }, body: createReadStream(file), duplex: 'half', signal: AbortSignal.timeout(3600000) }); skew(r);
const json = await r.json().catch(() => null); if (!r.ok) throw Object.assign(new Error(`전송 올리기 실패 ${r.status} ${json?.error || ''}`), { code: json?.error || `http_${r.status}` });
return { id: transferId, sha256: sum, size };
}
const downloadTransfer = (id, dest, expected = null) => download(`/agent/v1/transfers/${id}`, dest, expected);
return { request, download, upload, downloadTransfer, get clockSkewMs() { return clockSkewMs; }, agentId, base };
}
+32
View File
@@ -0,0 +1,32 @@
/**
* agent/config.mjs — Node Agent 설정과 경로.
* 데이터 디렉터리(기본 /srv/ariweb/agent/data)의 agent.json 에 id·HAL 주소·역할·사이트(외부 모드)를 두고, 비밀은 같은 디렉터리의 보관소(lib/vault)에 'agent-secret' 로 둔다.
* 실제 시스템 경로는 환경 변수로 바꿀 수 있어 테스트는 임시 디렉터리에서 돈다.
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { openVault } from '../lib/vault.mjs';
export function loadAgentConfig(env = process.env) {
const dataDir = env.ARIWEB_AGENT_DATA || '/srv/ariweb/agent/data'; mkdirSync(dataDir, { recursive: true, mode: 0o700 });
const file = join(dataDir, 'agent.json'); let saved = {}; try { saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}; } catch (_) {}
const cfg = {
dataDir, file, ...saved,
roots: {
sites: env.ARIWEB_SITES_ROOT || '/var/www/ariweb-sites', // 사이트 파일: <sites>/<siteId>/releases/<n>, current, data(uploads)
srv: env.ARIWEB_SRV_ROOT || '/srv/ariweb', // 모듈 설치: <srv>/<module>/releases/<ver>, current
nginx: env.ARIWEB_NGINX_DIR || '/etc/nginx', // sites-available/sites-enabled
backups: env.ARIWEB_BACKUP_ROOT || '/srv/ariweb/backups', // 백업 저장소
logs: env.ARIWEB_LOG_DIR || '/var/log/nginx', // 사이트별 접속 로그 aw-<siteId>.access.log
acme: env.ARIWEB_ACME_WEBROOT || '/var/www/letsencrypt', // certbot webroot
letsencrypt: env.ARIWEB_LE_DIR || '/etc/letsencrypt/live',
wildcardCert: env.ARIWEB_WILDCARD_CERT || '', // 플랫폼 와일드카드 인증서 디렉터리(예: /etc/letsencrypt/live/wh-wild-ariweb.net)
platformDomain: env.ARIWEB_PLATFORM_DOMAIN || 'ariweb.net',
maintPage: env.ARIWEB_MAINT_PAGE || '/var/www/ariweb-maint.html',
},
heartbeatMs: Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000),
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
vault: () => openVault(dataDir),
};
return cfg;
}
+50
View File
@@ -0,0 +1,50 @@
/**
* agent/index.mjs — Node Agent 진입점·CLI(§5·§28.2·§10.2).
*
* node agent/index.mjs register --hal <url> --token <t> [--role web|mail|backup|control] [--external --site <siteId>] [--label <이름>]
* node agent/index.mjs run 서비스 루프: heartbeat(30초) + 작업 long-poll + 재시작 뒤 미완료 작업 재개
* node agent/index.mjs status 등록 상태·HAL 연결 확인
* 비밀(Agent secret)은 데이터 디렉터리의 보관소에만 둔다. 등록 응답의 비밀은 그 자리에서 저장하고 화면에 보이지 않는다.
*/
import { loadAgentConfig } from './config.mjs';
import { createClient } from './client.mjs';
import { createSys } from './sys.mjs';
import { createRunner } from './runner.mjs';
import { KINDS } from './jobs/index.mjs';
import { sample, facts } from './metrics.mjs';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { hostname } from 'node:os';
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
const log = (...a) => console.log(new Date().toISOString(), ...a);
const args = process.argv.slice(2); const cmd = args[0] || 'run'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; };
const cfg = loadAgentConfig(); const sys = createSys({ log });
if (cmd === 'register') {
const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); }
const f = facts(sys); f.role = opt('role', 'web'); f.location = opt('location', '');
const r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }) }); const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(1); }
cfg.vault().set('agent-secret', j.secret, { kind: 'agent-secret' }); cfg.save({ id: j.agentId, halUrl: hal, serverId: j.serverId || null, kind: j.kind, siteId: j.siteId || null, groupId: j.groupId || null, registeredAt: new Date().toISOString(), label: opt('label', hostname()) });
console.log(`등록 완료: agent ${j.agentId} (${j.kind}${j.serverId ? `, 서버 ${j.serverId}` : ''}). 비밀은 ${cfg.dataDir} 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다`);
} else if (cmd === 'status') {
if (!cfg.id) { console.log('등록되지 않음'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
try { const r = await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: {}, clockSkewMs: 0 }); console.log(`연결 정상: agent ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}, 대기 작업 ${r.pendingJobs}, 시계 차이 ${client.clockSkewMs}ms`); } catch (e) { console.error(`HAL 연결 실패: ${e.message}`); process.exit(1); }
} else if (cmd === 'run') {
if (!cfg.id) { console.error('등록되지 않았습니다 — register 먼저'); process.exit(1); }
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/package.json 버전 */
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); const cur = join(appDir, 'current', 'package.json'); let version = null; try { version = JSON.parse(readFileSync(cur, 'utf8')).version; } catch (_) {} const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: facts(sys) }; const sites = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))).length : 0; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: lastFacts.f, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
await heartbeat(); const hb = setInterval(heartbeat, cfg.heartbeatMs);
process.on('SIGTERM', () => { stopping = true; clearInterval(hb); });
log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}`);
while (!stopping) { try { const r = await client.request('GET', '/agent/v1/jobs/next?wait=25', undefined, { timeoutMs: 40000 }); if (r.job) execute(r.job); } catch (e) { log(`[agent] 작업 조회 실패: ${e.message}`); await new Promise((s) => setTimeout(s, 5000)); } }
process.exit(0);
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
+50
View File
@@ -0,0 +1,50 @@
/**
* agent/jobs/backup.mjs — 관리형 사이트 백업·복원(§19·§20).
*
* 백업 묶음(tar.gz) 구조: files/(현재 배포본) data/(uploads 등 영속 데이터) db/(SQLite 는 VACUUM INTO 일관 사본; 그 밖의 DB 는 payload.db.dumpCommand 가 있을 때만) config/(vhost·버전 정보·ariweb.json) meta.json
* 메일 원문·소스 저장소·Webmaker 이력은 포함하지 않는다(§19.1). 만든 뒤 목록을 읽어 검증하고 sha256 을 돌려준다.
* 저장소: <backups>/<siteId>/<backupId>.tar.gz. 운영 서버와 다른 저장 서버로 보내야 하면 uploadToHal 로 HAL 전송에 올린다(backup.store 가 저장 서버에서 받는다).
* 복원: 보호 시점(현재 상태 백업) → 격리 디렉터리에서 풀어 확인 → 새 릴리스로 배치·데이터 교체(이전 데이터는 .pre-restore 로 보존) → current 전환 → 내부 확인.
*/
import { existsSync, mkdirSync, readdirSync, renameSync, copyFileSync, writeFileSync, statSync } from 'node:fs';
import { join, basename, dirname } from 'node:path';
import { siteDir, assertSiteId, readLink, switchLink, tarDir, untar, tarList, sha256File, rmrf, readJson, writeJson, internalCheck, fmtSum, normSum } from './common.mjs';
import { configureVhost } from './site.mjs';
const stamp = () => new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
const backupDir = (ctx, siteId) => join(ctx.roots.backups, assertSiteId(siteId));
/** SQLite 일관 사본 */
async function copySqlite(src, dest) { const { DatabaseSync } = await import('node:sqlite'); const db = new DatabaseSync(src, { readOnly: true }); try { db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`); } finally { db.close(); } }
/** 현재 상태를 백업 파일로 묶는다 */
export async function makeBackup(ctx, siteId, { backupId, scope = {}, outDir, db = null, tag = null }) {
const sd = siteDir(ctx.roots, siteId); const cur = readLink(join(sd, 'current')); const work = join(sd, 'work', `bk-${backupId}`); rmrf(work); mkdirSync(work, { recursive: true });
const included = {};
if (scope.files !== false && cur && existsSync(cur)) { mkdirSync(join(work, 'files'), { recursive: true }); await ctx.sys.tar(['-cf', join(work, 'files.tar'), '-C', cur, '.']); included.files = true; }
if (scope.uploads !== false && existsSync(join(sd, 'data'))) { await ctx.sys.tar(['-cf', join(work, 'data.tar'), '-C', join(sd, 'data'), '.']); included.data = true; }
if (scope.db !== false) { mkdirSync(join(work, 'db'), { recursive: true }); const dbs = []; const dbDir = join(sd, 'data', 'db'); if (existsSync(dbDir)) for (const f of readdirSync(dbDir).filter((x) => /\.(sqlite|db)$/.test(x))) { await copySqlite(join(dbDir, f), join(work, 'db', f)); dbs.push(f); } if (db?.sqlite && existsSync(db.sqlite)) { await copySqlite(db.sqlite, join(work, 'db', basename(db.sqlite))); dbs.push(basename(db.sqlite)); } included.db = dbs; }
if (scope.config !== false) { mkdirSync(join(work, 'config'), { recursive: true }); const vh = join(ctx.roots.nginx, 'sites-available', `aw-${siteId}.conf`); if (existsSync(vh)) copyFileSync(vh, join(work, 'config', 'vhost.conf')); if (cur) for (const f of ['.ariweb-version.json', 'ariweb.json']) if (existsSync(join(cur, f))) copyFileSync(join(cur, f), join(work, 'config', f)); included.config = true; }
writeJson(join(work, 'meta.json'), { backupId, siteId, createdAt: new Date().toISOString(), version: cur ? cur.split('/').pop() : null, included, tag, excluded: ['mail', 'source_repo', 'webmaker_history', 'external_deploy_data'] });
mkdirSync(outDir, { recursive: true }); const out = join(outDir, `${backupId}.tar.gz`); const t = await tarDir(ctx, out, work); rmrf(work);
const list = await tarList(ctx, out); if (!list.some((x) => x.endsWith('meta.json'))) throw Object.assign(new Error('백업 파일을 읽을 수 없습니다'), { code: 'backup_unreadable' });
return { backupId, path: out, sizeBytes: t.size, checksum: t.sha256, createdAt: new Date().toISOString(), scope: included };
}
export const kinds = {
'backup.create': { title: '백업 생성', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const backupId = p.backupId || `bk-${stamp()}-${siteId.slice(0, 8)}`; const r = await ctx.step('create', () => makeBackup(ctx, siteId, { backupId, scope: p.scope || {}, outDir: backupDir(ctx, siteId), db: p.db || null, tag: p.retentionTag || null }), '백업 생성'); if (p.uploadToHal) { const up = await ctx.step('upload', () => ctx.client.upload(p.transferId || `tr_bk_${backupId}`, r.path, { purpose: 'backup' }), 'HAL 전송에 올리기'); r.transferId = up.id; r.uploaded = true; if (!p.keepLocalCopy) rmrf(r.path); } return r; } },
'backup.store': { title: '백업 저장소에 받기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const out = join(backupDir(ctx, siteId), `${p.backupId}.tar.gz`); mkdirSync(dirname(out), { recursive: true }); const dl = await ctx.step('download', () => ctx.client.downloadTransfer(p.transferId, out, p.sha256 || null), '받기'); const list = await tarList(ctx, out); if (!list.some((x) => x.endsWith('meta.json'))) throw Object.assign(new Error('받은 백업을 읽을 수 없습니다'), { code: 'backup_unreadable' }); return { path: out, sizeBytes: dl.size, checksum: fmtSum(dl.sha256), verified: true }; } },
'backup.verify': { title: '백업 검증', async run(ctx) { const p = ctx.payload; if (!p.location || !existsSync(p.location)) return { ok: false, reason: 'missing' }; const sum = sha256File(p.location); const list = await tarList(ctx, p.location).catch(() => []); const ok = (!p.checksum || normSum(p.checksum) === sum) && list.some((x) => x.endsWith('meta.json')); return { ok, checksum: fmtSum(sum), entries: list.length, reason: ok ? null : p.checksum && normSum(p.checksum) !== sum ? 'checksum_mismatch' : 'unreadable' }; } },
'backup.fetch': { title: '백업 저장소에서 보내기', async run(ctx) { const p = ctx.payload; if (!existsSync(p.location)) throw Object.assign(new Error('백업 파일이 없습니다'), { code: 'backup_missing' }); if (p.checksum && sha256File(p.location) !== normSum(p.checksum)) throw Object.assign(new Error('백업 파일 해시가 다릅니다'), { code: 'checksum_mismatch' }); const up = await ctx.step('upload', () => ctx.client.upload(p.transferId, p.location, { purpose: 'restore' }), '올리기'); return { transferId: up.id, checksum: fmtSum(up.sha256), sizeBytes: up.size }; } },
'backup.delete': { title: '백업 삭제', async run(ctx) { const p = ctx.payload; const ok = existsSync(p.location) && p.location.startsWith(ctx.roots.backups); if (ok) rmrf(p.location); return { deleted: ok, location: p.location }; } },
'backup.restore': { title: '백업 복원', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const work = join(sd, 'work', `rs-${stamp()}`); mkdirSync(work, { recursive: true });
const src = await ctx.step('fetch', async () => { if (p.transferId) { const f = join(work, 'backup.tar.gz'); await ctx.client.downloadTransfer(p.transferId, f, p.checksum || null); return f; } if (!p.location || !existsSync(p.location)) throw Object.assign(new Error('복원할 백업이 없습니다'), { code: 'backup_missing' }); if (p.checksum && sha256File(p.location) !== normSum(p.checksum)) throw Object.assign(new Error('백업 해시가 다릅니다'), { code: 'checksum_mismatch' }); return p.location; }, '백업 가져오기');
const protect = await ctx.step('protect', async () => { if (p.protectPoint === false) return null; const r = await makeBackup(ctx, siteId, { backupId: `protect-${stamp()}`, outDir: join(backupDir(ctx, siteId), 'protect'), tag: 'protect' }); return r.path; }, '현재 상태 보호 시점 만들기');
const iso = await ctx.step('isolated', async () => { const dir = join(work, 'iso'); await untar(ctx, src, dir); const meta = readJson(join(dir, 'meta.json'), null); if (!meta || meta.siteId !== siteId) throw Object.assign(new Error('백업의 사이트 정보가 다릅니다'), { code: 'backup_site_mismatch' }); if (existsSync(join(dir, 'files.tar'))) { mkdirSync(join(dir, 'files'), { recursive: true }); await ctx.sys.tar(['-xf', join(dir, 'files.tar'), '-C', join(dir, 'files')]); } if (existsSync(join(dir, 'data.tar'))) { mkdirSync(join(dir, 'data'), { recursive: true }); await ctx.sys.tar(['-xf', join(dir, 'data.tar'), '-C', join(dir, 'data')]); } const files = existsSync(join(dir, 'files')) ? readdirSync(join(dir, 'files')).length : 0; if (!files) throw Object.assign(new Error('백업에 사이트 파일이 없습니다'), { code: 'backup_empty' }); return { dir, meta, files }; }, '격리된 환경에서 백업 확인');
await ctx.progress(50);
const placed = await ctx.step('restore', async () => { const rel = join(sd, 'releases', `${stamp()}-restore`); mkdirSync(join(sd, 'releases'), { recursive: true }); renameSync(join(iso.dir, 'files'), rel); writeJson(join(rel, '.ariweb-version.json'), { version: rel.split('/').pop(), restoredFrom: iso.meta.backupId, restoredAt: new Date().toISOString(), originalVersion: iso.meta.version }); if (existsSync(join(iso.dir, 'data'))) { const old = join(sd, 'data'); if (existsSync(old)) renameSync(old, join(sd, `data.pre-restore-${stamp()}`)); renameSync(join(iso.dir, 'data'), old); } const dbSrc = join(iso.dir, 'db'); if (existsSync(dbSrc)) { mkdirSync(join(sd, 'data', 'db'), { recursive: true }); for (const f of readdirSync(dbSrc)) { const dest = p.db?.sqlite && basename(p.db.sqlite) === f ? p.db.sqlite : join(sd, 'data', 'db', f); if (existsSync(dest)) copyFileSync(dest, dest + `.pre-restore-${stamp()}`); copyFileSync(join(dbSrc, f), dest); } } const prev = readLink(join(sd, 'current')); switchLink(join(sd, 'current'), rel); writeJson(join(sd, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { rel, prev }; }, '데이터 복원');
const meta = readJson(join(placed.rel, 'ariweb.json'), {}); if (meta.runtime === 'node') await ctx.sys.systemctl('restart', `ariweb-site-${siteId}`, { optional: true }); else await ctx.sys.nginxReload().catch(() => {});
const ic = await ctx.step('check', () => internalCheck(ctx, siteId, (p.domains && p.domains[0]) || `${siteId}.preview.${ctx.roots.platformDomain}`), '사이트 기능 확인');
rmrf(work);
if (!ic.ok) { if (placed.prev && existsSync(placed.prev)) switchLink(join(sd, 'current'), placed.prev); throw Object.assign(new Error('복원 뒤 내부 확인 실패 — 이전 상태로 되돌렸습니다'), { code: 'internal_check_failed', partial: { protectPointLocation: protect, internalCheck: ic } }); }
return { protectPointLocation: protect, restoredVersion: placed.rel.split('/').pop(), isolatedVerify: true, internalCheck: ic, restoredFrom: iso.meta.backupId };
} },
};
+66
View File
@@ -0,0 +1,66 @@
/**
* agent/jobs/common.mjs — 사이트 작업 공통: 경로·템플릿·vhost 생성·내부 확인·tar·체크섬.
* 사이트 레이아웃: <sites>/<siteId>/releases/<버전디렉터리>, current(심링크), data/(uploads·db 등 영속 데이터), .maintenance / .limited 표식 파일.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, symlinkSync, unlinkSync, renameSync, rmSync, lstatSync, readlinkSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import http from 'node:http';
import { fileURLToPath } from 'node:url';
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
export const SITE_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{1,63}$/;
export function assertSiteId(id) { if (!SITE_ID_RE.test(String(id || ''))) throw Object.assign(new Error(`사이트 id 형식이 올바르지 않습니다: ${id}`), { code: 'bad_site_id' }); return id; }
export const siteDir = (roots, siteId) => join(roots.sites, assertSiteId(siteId));
export const tmpl = (name) => readFileSync(join(ROOT, 'deploy', 'nginx', name), 'utf8');
export const render = (t, vars) => t.replace(/\{\{(\w+)\}\}/g, (_, k) => (vars[k] === undefined || vars[k] === null ? '' : String(vars[k])));
export function sha256File(file) { const h = createHash('sha256'); h.update(readFileSync(file)); return h.digest('hex'); }
/**
* 체크섬 표기: 결과·payload 에 넣는 해시는 'sha256:<hex>' 형식으로 쓴다. 긴 hex 그대로 두면 lib/redact 가 비밀값으로 보고 '[redacted]' 로 가려
* 포털·HAL 이 검증에 쓸 수 없기 때문이다. 비교할 때는 normSum 으로 접두어·대소문자를 벗긴다(두 형식 모두 허용).
*/
export const fmtSum = (hex) => (hex ? `sha256:${String(hex).replace(/^sha256:/i, '').toLowerCase()}` : null);
export const normSum = (v) => (v ? String(v).replace(/^sha256:/i, '').toLowerCase() : null);
export function dirSize(dir) { let n = 0; if (!existsSync(dir)) return 0; for (const e of readdirSync(dir, { withFileTypes: true })) { const p = join(dir, e.name); if (e.isSymbolicLink()) continue; if (e.isDirectory()) n += dirSize(p); else n += statSync(p).size; } return n; }
/** 심링크 원자 교체 */
export function switchLink(linkPath, target) { const tmp = `${linkPath}.tmp-${process.pid}`; try { unlinkSync(tmp); } catch (_) {} symlinkSync(target, tmp); renameSync(tmp, linkPath); }
export function readLink(linkPath) { try { return lstatSync(linkPath).isSymbolicLink() ? readlinkSync(linkPath) : null; } catch (_) { return null; } }
export const MAINT_HTML = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>점검 중</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>사이트 점검 중입니다</h1><p>잠시 후 다시 접속해 주세요.</p><p style="color:#888;font-size:12px">supported by ariweb.net</p></body></html>';
export const LIMIT_HTML = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>이용 제한</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>월간 전송량 한도에 도달했습니다</h1><p>다음 달(KST 1일) 또는 추가 용량 적용 뒤 다시 제공됩니다.</p><p style="color:#888;font-size:12px">supported by ariweb.net</p></body></html>';
const esc1 = (s) => s.replace(/'/g, '&#39;');
/** 사이트 vhost 설정 문자열 만들기 */
export function renderVhost({ roots, siteId, domains = [], primaryDomain = null, runtime = 'static', port = null, certDir = null, redirectOld = [] }) {
const sd = siteDir(roots, siteId); const names = domains.length ? domains : [`${siteId}.preview.${roots.platformDomain}`];
const body = render(tmpl(runtime === 'node' ? 'site-body-node.tmpl' : 'site-body-static.tmpl'), { ROOT: join(sd, 'current'), SITE_DIR: sd, LOG: join(roots.logs, `aw-${siteId}.access.log`), MAINT_HTML: esc1(MAINT_HTML), LIMIT_HTML: esc1(LIMIT_HTML), PORT: port || 0 });
const https = certDir ? render(tmpl('site-https.tmpl'), { SERVER_NAMES: names.join(' '), CERT_DIR: certDir, BODY: body }) : '';
const httpBody = certDir ? ` location / { return 301 https://${primaryDomain || names[0]}$request_uri; }` : body;
let out = render(tmpl('site.conf.tmpl'), { SITE_ID: siteId, DOMAIN_LIST: names.join(', '), GENERATED_AT: new Date().toISOString(), SERVER_NAMES: names.join(' '), ACME_ROOT: roots.acme, HTTP_BODY: httpBody, HTTPS_SERVER: https });
if (redirectOld.length && primaryDomain) out += '\n' + render(tmpl('redirect.tmpl'), { OLD_NAMES: redirectOld.join(' '), ACME_ROOT: roots.acme, PRIMARY: primaryDomain });
return out;
}
/** 와일드카드 인증서 적용 가능 여부: 모든 도메인이 플랫폼 도메인의 1단계 하위이고 인증서 디렉터리가 있을 때 */
export function wildcardCertFor(roots, domains) { if (!roots.wildcardCert || !domains.length) return null; const pd = roots.platformDomain.toLowerCase(); const ok = domains.every((d) => { const x = String(d).toLowerCase(); return x.endsWith('.' + pd) && !x.slice(0, -pd.length - 1).includes('.'); }); return ok && existsSync(join(roots.wildcardCert, 'fullchain.pem')) ? roots.wildcardCert : null; }
/** 사이트별 인증서 디렉터리(certbot --cert-name aw-<siteId>) */
export function ownCertFor(roots, siteId) { const p = join(roots.letsencrypt, `aw-${siteId}`); return existsSync(join(p, 'fullchain.pem')) ? p : null; }
/** 인증서 만료 읽기(openssl 없이: PEM 의 notAfter 는 파싱이 번거로워 파일 mtime+90일로 추정, 정확한 값은 domain.cert 결과가 준다) */
export function certExpiry(certDir) { try { const st = statSync(join(certDir, 'fullchain.pem')); return new Date(st.mtimeMs + 90 * 86400000).toISOString(); } catch (_) { return null; } }
/** vhost 파일 쓰고 nginx 적용. stub 모드에서는 파일만 쓴다. */
export async function applyVhost(ctx, siteId, content) {
const roots = ctx.roots; const avail = join(roots.nginx, 'sites-available', `aw-${siteId}.conf`); const enabled = join(roots.nginx, 'sites-enabled', `aw-${siteId}.conf`);
await ctx.sys.writeFile(avail, content);
try { mkdirSync(dirname(enabled), { recursive: true }); if (!existsSync(enabled)) symlinkSync(avail, enabled); } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') { await ctx.sys.sudo(['ln', '-sf', avail, enabled]); } else if (e.code !== 'EEXIST') throw e; }
await ctx.sys.nginxReload(); return { file: avail };
}
export async function removeVhost(ctx, siteId) { const roots = ctx.roots; const files = [join(roots.nginx, 'sites-enabled', `aw-${siteId}.conf`), join(roots.nginx, 'sites-available', `aw-${siteId}.conf`)]; let removed = 0; for (const f of files) { try { unlinkSync(f); removed++; } catch (e) { if (e.code === 'EACCES' || e.code === 'EPERM') { await ctx.sys.sudo(['rm', '-f', f]); removed++; } } } if (removed) await ctx.sys.nginxReload().catch(() => {}); return removed; }
/** 내부 접속 확인: 127.0.0.1:80 에 Host 헤더로. stub 모드면 current/index.html 존재로 대신한다. */
export function internalCheck(ctx, siteId, host, { path = '/', expectMarker = null, port = 80 } = {}) {
if (ctx.sys.stub) { const sd = siteDir(ctx.roots, siteId); const idx = join(sd, 'current', 'index.html'); const ok = existsSync(idx) && (!expectMarker || readFileSync(idx, 'utf8').includes(expectMarker)); return Promise.resolve({ ok, status: ok ? 200 : 404, ms: 0, stubbed: true, host }); }
return new Promise((resolve) => { const t0 = Date.now(); const req = http.get({ host: '127.0.0.1', port, path, headers: { Host: host } }, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 200000) body += c; }); res.on('end', () => resolve({ ok: res.statusCode < 400 && (!expectMarker || body.includes(expectMarker)), status: res.statusCode, ms: Date.now() - t0, host, markerFound: expectMarker ? body.includes(expectMarker) : null })); }); req.setTimeout(15000, () => { req.destroy(new Error('timeout')); }); req.on('error', (e) => resolve({ ok: false, error: e.message, ms: Date.now() - t0, host })); });
}
/** 디렉터리를 tar.gz 로 */
export async function tarDir(ctx, outFile, dir, items = ['.']) { mkdirSync(dirname(outFile), { recursive: true }); await ctx.sys.tar(['-czf', outFile, '-C', dir, ...items]); return { file: outFile, size: statSync(outFile).size, sha256: fmtSum(sha256File(outFile)) }; }
export async function untar(ctx, file, destDir) { mkdirSync(destDir, { recursive: true }); await ctx.sys.tar(['-xzf', file, '-C', destDir]); return destDir; }
export async function tarList(ctx, file) { const r = await ctx.sys.run('tar', ['-tzf', file], { allowStub: false }); if (r.code !== 0) throw new Error(`tar 목록 실패: ${r.stderr.slice(0, 200)}`); return r.stdout.split('\n').filter(Boolean); }
export const rmrf = (p) => rmSync(p, { recursive: true, force: true });
export const readJson = (p, fb = null) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch (_) { return fb; } };
export const writeJson = (p, v) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(v, null, 1)); };
+27
View File
@@ -0,0 +1,27 @@
/**
* agent/jobs/domain.mjs — 도메인 작업: vhost 재구성, HTTPS 인증서 발급(certbot webroot), DNS 확인(공용 리졸버).
* 인증서: 플랫폼 서브 도메인은 와일드카드(*.ariweb.net)를 재사용하고, 고객 도메인은 certbot --webroot 로 사이트별(aw-<siteId>) 인증서를 받는다(Webmaker deploy.js 와 같은 원칙).
* 유효하지 않은 인증서를 정상 HTTPS 로 표시하지 않는다(§12.3): certbot 결과가 성공이고 fullchain.pem 이 있을 때만 'issued'.
*/
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { promises as dns } from 'node:dns';
import { assertSiteId, ownCertFor, wildcardCertFor, certExpiry } from './common.mjs';
import { configureVhost } from './site.mjs';
export const kinds = {
'domain.vhost': { title: '도메인 연결 설정', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const r = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime: p.runtime || 'static', port: p.port || null, redirectOld: p.redirectOld ? (p.oldDomains || []) : [] }), 'nginx 설정'); if (p.maintenance !== undefined) { const { kinds: site } = await import('./site.mjs'); await site['site.maintenance'].run({ ...ctx, payload: { siteId, on: !!p.maintenance } }); } return { applied: r.serverNames, certs: r.certs, https: r.https }; } },
'domain.cert': { title: 'HTTPS 인증서 발급', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const domains = (p.domains || []).map((d) => String(d).toLowerCase()); if (!domains.length) throw Object.assign(new Error('도메인이 없습니다'), { code: 'domains_required' });
const wild = wildcardCertFor(ctx.roots, domains); if (wild) { const r = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), '와일드카드 인증서 적용'); return { issued: domains, failed: [], source: 'wildcard', expiresAt: certExpiry(wild), https: r.https }; }
// 1) HTTP vhost 가 먼저 있어야 webroot 인증이 된다
await ctx.step('vhost-http', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), 'HTTP 연결 준비');
const issued = []; const failed = [];
await ctx.step('certbot', async () => { const args = ['certonly', '--webroot', '-w', ctx.roots.acme, '--cert-name', `aw-${siteId}`, '--non-interactive', '--agree-tos', '--keep-until-expiring', '--expand', ...(p.email ? ['-m', p.email] : ['--register-unsafely-without-email'])]; for (const d of domains) args.push('-d', d); const r = await ctx.sys.certbot(args); if (r.code !== 0 && !ctx.sys.stub) { const msg = (r.stderr || r.stdout).trim(); for (const d of domains) failed.push({ domain: d, reason: msg.slice(0, 300) || 'certbot 실패' }); } else { issued.push(...domains); if (ctx.sys.stub && !ownCertFor(ctx.roots, siteId)) { const { mkdirSync, writeFileSync } = await import('node:fs'); const dir = join(ctx.roots.letsencrypt, `aw-${siteId}`); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'fullchain.pem'), 'stub'); writeFileSync(join(dir, 'privkey.pem'), 'stub'); } } return { code: r.code }; }, 'certbot 실행');
if (!issued.length) { const e = new Error(`인증서 발급 실패: ${failed[0]?.reason || '원인 불명'}`); e.code = 'cert_failed'; e.partial = { failed }; throw e; }
const own = ownCertFor(ctx.roots, siteId); if (!own) throw Object.assign(new Error('certbot 은 성공했지만 인증서 파일이 없습니다'), { code: 'cert_missing' });
const r = await ctx.step('vhost-https', () => configureVhost(ctx, siteId, { domains, primaryDomain: p.primaryDomain || domains[0], runtime: p.runtime || 'static', port: p.port || null }), 'HTTPS 연결');
return { issued, failed, source: 'own', expiresAt: certExpiry(own), https: r.https };
} },
'domain.probe': { title: 'DNS 확인', async run(ctx) { const p = ctx.payload; const name = String(p.name || '').toLowerCase(); if (!name) throw Object.assign(new Error('이름이 없습니다'), { code: 'name_required' }); if (ctx.sys.stub && p._stubAnswer) return { ...p._stubAnswer, resolvedAt: new Date().toISOString() }; const r = new dns.Resolver({ timeout: 5000, tries: 2 }); r.setServers(['1.1.1.1', '8.8.8.8']); const out = { name, a: [], txtOk: null, cname: [], mx: [], resolvedAt: new Date().toISOString(), resolver: 'public' }; try { out.a = await r.resolve4(name); } catch (e) { out.aError = e.code; } try { out.cname = await r.resolveCname(name); } catch (_) {} if (p.expectTxt) { try { const txts = (await r.resolveTxt(p.expectTxt.name || name)).map((x) => x.join('')); out.txtOk = txts.includes(p.expectTxt.value); out.txtFound = txts.length; } catch (e) { out.txtOk = false; out.txtError = e.code; } } if (p.mx) { try { out.mx = await r.resolveMx(name); } catch (_) {} } if (p.expectA) { const want = new Set(p.expectA); out.aOk = out.a.length > 0 && out.a.every((x) => want.has(x)); out.aMismatch = out.a.filter((x) => !want.has(x)); } return out; } },
};
+6
View File
@@ -0,0 +1,6 @@
/** agent/jobs/index.mjs — 작업 종류 등록부. HAL 이 보내는 kind 이름과 같다. External Pull Agent 는 HAL 이 배정을 제한하므로 여기서는 모두 등록한다. */
import { kinds as site } from './site.mjs';
import { kinds as domain } from './domain.mjs';
import { kinds as backup } from './backup.mjs';
import { kinds as mod } from './module.mjs';
export const KINDS = { ...site, ...domain, ...backup, ...mod };
+77
View File
@@ -0,0 +1,77 @@
/**
* agent/jobs/module.mjs — 모듈 설치·갱신·되돌리기·건강 확인(§32·§29).
*
* 레이아웃: <appDir>/releases/<version>/ + current 심링크. 도입 모듈은 서버에 이미 있던 appDir(/srv/webmaker, /srv/arihakdang-mail …)과 유닛 이름을 facts 로 받아 그대로 쓴다.
* 절차(원본 remote-update.sh / deploy.sh 의 순서를 따른다): artifact 내려받기·sha256 확인 → releases/<ver> 에 풀기 → preStop 절차(데이터 백업) → 유닛 갱신(템플릿이 있을 때) → current 전환 → postStart 절차(마이그레이션 등) → 재시작 → 건강 확인 → 실제 버전 읽기.
* 실패하면 current 를 이전 릴리스로 되돌리고 재시작한 뒤 오류를 보고한다(명령 성공만으로 완료하지 않는다).
* 자기 자신(agent)·HAL 갱신: 결과를 먼저 보고한 뒤 재시작하도록 restartAfterReport 를 돌려준다.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, copyFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import http from 'node:http';
import { readLink, switchLink, untar, render, readJson, writeJson, rmrf } from './common.mjs';
/** 내부 건강 확인 URL 호출 */
function httpGet(url, timeoutMs = 8000) { return new Promise((resolve) => { const t0 = Date.now(); const req = http.get(url, (res) => { let body = ''; res.on('data', (c) => { if (body.length < 100000) body += c; }); res.on('end', () => resolve({ status: res.statusCode, body, ms: Date.now() - t0 })); }); req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout'))); req.on('error', (e) => resolve({ status: 0, error: e.message, ms: Date.now() - t0 })); }); }
async function health(ctx, desc) { if (ctx.sys.stub) return { ok: true, stubbed: true }; if (!desc?.health?.url) return { ok: true, skipped: 'no_health_url' }; let last = null; for (let i = 0; i < 20; i++) { const r = await httpGet(desc.health.url); last = r; if (r.status && r.status < 500 && (desc.health.expect !== 'ok' || /"ok"\s*:\s*true|ok/.test(r.body))) return { ok: true, status: r.status, ms: r.ms }; await new Promise((s) => setTimeout(s, 1500)); } return { ok: false, status: last?.status, error: last?.error || `상태 ${last?.status}` }; }
async function detectVersion(ctx, desc, appDir) { const cur = readLink(join(appDir, 'current')); if (desc?.version?.http && !ctx.sys.stub) { const r = await httpGet(desc.version.http); try { const j = JSON.parse(r.body); if (j[desc.version.field]) return j[desc.version.field]; } catch (_) {} } const pkg = cur ? readJson(join(cur, 'package.json'), null) : null; return pkg?.version || (cur ? cur.split('/').pop() : null); }
/** 기존 유닛의 Environment 줄을 보존해 템플릿을 채운다 */
async function writeUnit(ctx, desc, unit, appDir, version) {
if (!ctx.payload.unitTemplate) return { skipped: 'no_template' };
const old = await ctx.sys.unitText(unit); const envLines = old.split('\n').filter((l) => /^Environment=/.test(l) && !/^Environment=(NODE_ENV|HOST|PORT|ARIWEB_[A-Z_]+_DATA|NODE_OPTIONS)=/.test(l)).join('\n');
const user = (/^User=(.+)$/m.exec(old) || [])[1] || desc.user || process.env.USER || 'master'; const envFile = (/^EnvironmentFile=(.+)$/m.exec(old) || [])[1] || ''; const node = (/^ExecStart=(\S+)/m.exec(old) || [])[1] || process.execPath;
const txt = render(ctx.payload.unitTemplate, { APP_DIR: appDir, USER: user, NODE: /node$/.test(node) ? node : process.execPath, ENV_LINES: envLines, VERSION: version, UNIT: unit, ENV_FILE: envFile });
await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); return { written: true, preservedEnv: envLines.split('\n').filter(Boolean).length };
}
/** preStop/postStart 절차 */
async function hook(ctx, name, { appDir, cur, unit }) {
if (name === 'webmaker.backupData') { const dataRoot = (/^Environment=DATA_ROOT=(.+)$/m.exec(await ctx.sys.unitText(unit)) || [])[1] || join(appDir, 'data'); if (!existsSync(dataRoot)) return { skipped: 'no_data' }; const out = join(appDir, 'backups', `data-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.tar.gz`); mkdirSync(join(appDir, 'backups'), { recursive: true }); await ctx.sys.tar(['-czf', out, '-C', dataRoot, '--exclude=./backups', '.']); const old = readdirSync(join(appDir, 'backups')).sort(); for (const f of old.slice(0, Math.max(0, old.length - 5))) rmSync(join(appDir, 'backups', f), { force: true }); return { backup: out }; }
if (name === 'arimail.backupDb') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); if (!existsSync(db)) return { skipped: 'no_db' }; const { DatabaseSync } = await import('node:sqlite'); mkdirSync(join(appDir, 'data', 'backups'), { recursive: true }); const out = join(appDir, 'data', 'backups', `pre-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}.sqlite`); const d = new DatabaseSync(db, { readOnly: true }); try { d.exec(`VACUUM INTO '${out}'`); } finally { d.close(); } return { backup: out }; }
if (name === 'arimail.migrate') { const db = join(appDir, 'data', 'mail-gateway.sqlite'); const r = await ctx.sys.run(process.execPath, [join(cur, 'server', 'db-migrate.mjs')], { allowStub: false, timeoutMs: 120000 }).catch((e) => ({ code: 1, stderr: e.message })); if (!ctx.sys.stub && !existsSync(join(cur, 'server', 'db-migrate.mjs'))) return { skipped: 'no_migrate' }; if (r.code !== 0 && !ctx.sys.stub) throw Object.assign(new Error(`AriMail 마이그레이션 실패: ${(r.stderr || '').slice(0, 300)}`), { code: 'migrate_failed' }); return { migrated: true, db }; }
if (name === 'arimail.haraka') { const r = await ctx.sys.systemctl('restart', `${unit}-haraka`, { optional: true }); return { haraka: r.code === 0 ? 'restarted' : 'skipped' }; }
if (name === 'webmaker.unitCheck') { const r = await ctx.sys.run(process.execPath, [join(cur, 'scripts', 'unit-check.js')], { allowStub: true, timeoutMs: 60000 }).catch(() => ({ code: 1 })); return { unitCheck: r.code === 0 ? 'ok' : 'warn' }; }
return { skipped: `unknown hook ${name}` };
}
/**
* [E2E 결함 수정] 설치 디렉터리 결정. HAL 이 준 appDir 가 기본 루트(/srv/ariweb/…) 아래이고 이 Agent 의 루트(ARIWEB_SRV_ROOT)가 다른 곳으로
* 재정의되어 있으면(검증 환경·일반 계정) 그 루트 아래로 옮긴다. 서버에서 발견한 기존 경로(/srv/webmaker 등)는 그대로 둔다.
*/
function resolveAppDir(ctx, p) {
const srv = ctx.roots.srv; const given = p.appDir;
if (!given) return join(srv, p.module);
if (srv !== '/srv/ariweb' && String(given).startsWith('/srv/ariweb/')) return join(srv, String(given).slice('/srv/ariweb/'.length));
return given;
}
async function installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }) {
const relDir = join(appDir, 'releases', action === 'rollback' ? (toVersion || '') : version); const prev = readLink(join(appDir, 'current'));
if (action === 'rollback') { const target = toVersion ? relDir : readJson(join(appDir, '.previous.json'), {}).previous; if (!target || !existsSync(target)) throw Object.assign(new Error('되돌릴 이전 릴리스가 없습니다'), { code: 'no_previous_release' }); return { relDir: target, prev }; }
await ctx.step('download', async () => { if (!ctx.payload.artifactUrl) throw Object.assign(new Error('artifact 주소가 없습니다'), { code: 'artifact_missing' }); mkdirSync(join(appDir, 'work'), { recursive: true }); const f = join(appDir, 'work', `${module}-${version}.tar.gz`); const r = await ctx.client.download(ctx.payload.artifactUrl, f, ctx.payload.sha256 || null); return { file: f, sha256: r.sha256, size: r.size }; }, 'artifact 내려받기·검증');
await ctx.step('extract', async () => { rmrf(relDir); mkdirSync(relDir, { recursive: true }); await untar(ctx, join(appDir, 'work', `${module}-${version}.tar.gz`), relDir); if (desc.copyPaths && !existsSync(join(relDir, 'package.json'))) throw Object.assign(new Error('artifact 에 package.json 이 없습니다'), { code: 'bad_artifact' }); writeJson(join(relDir, 'REVISION.json'), { module, version, installedAt: new Date().toISOString() }); rmSync(join(appDir, 'work', `${module}-${version}.tar.gz`), { force: true }); return { dir: relDir }; }, '릴리스 풀기');
return { relDir, prev };
}
export const kinds = {
'module.install': { title: '모듈 설치·갱신', async run(ctx) { return installOrRollback(ctx, ctx.payload.action || 'install'); } },
'module.rollback': { title: '모듈 되돌리기', async run(ctx) { return installOrRollback(ctx, 'rollback'); } },
'module.health': { title: '모듈 건강 확인', async run(ctx) { const p = ctx.payload; const desc = p.descriptor || {}; const appDir = resolveAppDir(ctx, p); const unit = p.unit || desc.unit; const active = unit ? await ctx.sys.isActive(unit) : true; const h = await health(ctx, desc); return { module: p.module, actualVersion: await detectVersion(ctx, desc, appDir), active, health: { ok: active && h.ok, ...h }, checkedAt: new Date().toISOString() }; } },
};
async function installOrRollback(ctx, action) {
const p = ctx.payload; const { module, version, descriptor: desc = {}, toVersion = null } = p; const appDir = resolveAppDir(ctx, p); const unit = (desc && desc.unit) || `ariweb-${module}`;
mkdirSync(join(appDir, 'releases'), { recursive: true });
const { relDir, prev } = await ctx.step('place', () => installRelease(ctx, { module, version, appDir, desc, unit, action, toVersion }), action === 'rollback' ? '되돌릴 릴리스 확인' : '릴리스 배치');
await ctx.progress(40);
for (const h of desc.preStop || []) await ctx.step(`pre:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사전 절차 ${h}`);
await ctx.step('unit', () => writeUnit(ctx, desc, unit, appDir, version), '실행 유닛 갱신');
await ctx.step('switch', () => { switchLink(join(appDir, 'current'), relDir); writeJson(join(appDir, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { from: prev, to: relDir }; }, 'current 전환');
for (const h of desc.postStart || []) await ctx.step(`post:${h}`, () => hook(ctx, h, { appDir, cur: relDir, unit }), `사후 절차 ${h}`);
const self = module === 'agent'; let healthRes = { ok: true };
if (!self && desc.layout !== 'agent-feature') {
try { await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '서비스 재시작'); for (const u of desc.extraUnits || []) await ctx.sys.systemctl('restart', u, { optional: true }); }
catch (e) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(e, { code: e.code || 'restart_failed', partial: { rolledBackTo: prev } }); }
healthRes = await ctx.step('health', () => health(ctx, desc), '건강 확인');
if (!healthRes.ok) { if (prev && existsSync(prev)) { switchLink(join(appDir, 'current'), prev); await ctx.sys.systemctl('restart', unit, { optional: true }); } throw Object.assign(new Error(`재시작 뒤 건강 확인 실패(${healthRes.error || healthRes.status}) — 이전 릴리스로 되돌렸습니다`), { code: 'health_failed', partial: { rolledBackTo: prev, health: healthRes } }); }
}
const actual = self ? readJson(join(relDir, 'package.json'), {}).version || version : await ctx.step('version', () => detectVersion(ctx, desc, appDir), '실제 버전 읽기');
// 오래된 릴리스 정리(최근 3개 보존)
const rels = readdirSync(join(appDir, 'releases')).sort(); for (const r of rels.slice(0, Math.max(0, rels.length - 3))) { const d = join(appDir, 'releases', r); if (d !== relDir && d !== prev) rmrf(d); }
return { module, actualVersion: actual, previousVersion: prev ? prev.split('/').pop() : null, health: healthRes, active: true, restartAfterReport: self ? unit : null, releaseDir: relDir };
}
+114
View File
@@ -0,0 +1,114 @@
/**
* agent/jobs/site.mjs — 사이트 작업: 배포·되돌리기·제거·확인·사용량, 이전(relocate) 보조 작업.
*
* site.deploy(§11.2): 번들 가져오기(HAL 전송·서명 URL·로컬 경로) → releases/<n> 에 풀기 → 버전 기록 → current 교체 → (동적) 실행 유닛 → vhost → nginx → 내부 확인.
* 실패하면 current 를 바꾸지 않고(기존 버전 유지), 만들어 둔 디렉터리를 leftovers 로 보고해 포털이 "남은 파일" 과 정리를 보일 수 있게 한다.
* site.usage(§13.1): 사이트 디렉터리 크기(du) + nginx awstats 로그 증분 파싱 → 일별 집계(data/usage/<siteId>.json, 400일) → 24h/7d/30d/누적.
* 이전 보조: site.prepare / site.export(전송 올리기) / site.import(받아 staging) / site.stage_check / site.activate / site.deactivate / site.freeze.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, renameSync, openSync, readSync, closeSync, fstatSync, copyFileSync } from 'node:fs';
import { join } from 'node:path';
import { siteDir, assertSiteId, switchLink, readLink, renderVhost, applyVhost, removeVhost, internalCheck, wildcardCertFor, ownCertFor, certExpiry, dirSize, tarDir, untar, sha256File, rmrf, readJson, writeJson, render, ROOT, fmtSum, normSum } from './common.mjs';
const stamp = () => new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
/** 번들을 작업 디렉터리의 파일로 가져온다 */
async function fetchBundle(ctx, siteId, dest) {
const p = ctx.payload; mkdirSync(join(dest), { recursive: true }); const file = join(dest, 'bundle.tar.gz');
if (p.transferId) { const r = await ctx.client.downloadTransfer(p.transferId, file, p.checksum || null); return { file, sha256: fmtSum(r.sha256), source: 'transfer' }; }
if (p.bundleUrl) { const r = await ctx.client.download(p.bundleUrl.startsWith('http') ? p.bundleUrl : p.bundleUrl, file, p.checksum || null); return { file, sha256: fmtSum(r.sha256), source: 'url' }; }
if (p.bundlePath && existsSync(p.bundlePath)) { if (statSync(p.bundlePath).isDirectory()) { await tarDir(ctx, file, p.bundlePath); return { file, sha256: fmtSum(sha256File(file)), source: 'dir' }; } copyFileSync(p.bundlePath, file); return { file, sha256: fmtSum(sha256File(file)), source: 'file' }; }
if (p.bundleFromWebmaker?.path && existsSync(p.bundleFromWebmaker.path)) { await tarDir(ctx, file, p.bundleFromWebmaker.path); return { file, sha256: fmtSum(sha256File(file)), source: 'webmaker' }; }
throw Object.assign(new Error('배포할 저장본(transferId / bundleUrl / bundlePath / bundleFromWebmaker)이 없습니다'), { code: 'bundle_missing' });
}
/** 동적(node) 사이트 포트: 지정 없으면 siteId 해시로 20000~29999 */
function sitePort(siteId, p) { if (p.port) return Number(p.port); let h = 0; for (const c of siteId) h = (h * 31 + c.charCodeAt(0)) >>> 0; return 20000 + (h % 10000); }
async function nodeUnit(ctx, siteId, sd, start, port) { const unit = `ariweb-site-${siteId}`; const txt = render(readFileSync(join(ROOT, 'deploy', 'units', 'ariweb-site.service.tmpl'), 'utf8'), { SITE_ID: siteId, SITE_DIR: sd, PORT: port, USER: process.env.USER || 'master', NODE: process.execPath, START: start }); await ctx.sys.writeFile(`/etc/systemd/system/${unit}.service`, txt); await ctx.sys.systemctl('daemon-reload', '', { optional: true }); await ctx.sys.systemctl('enable', unit, { optional: true }); await ctx.sys.systemctl('restart', unit); return unit; }
/** 현재 운영 중인 도메인 설정으로 vhost 다시 쓰기 */
export async function configureVhost(ctx, siteId, { domains = [], primaryDomain = null, runtime = 'static', port = null, redirectOld = [] }) {
const own = ownCertFor(ctx.roots, siteId); const wild = wildcardCertFor(ctx.roots, domains); const certDir = own || wild;
const conf = renderVhost({ roots: ctx.roots, siteId, domains, primaryDomain, runtime, port, certDir, redirectOld });
const r = await applyVhost(ctx, siteId, conf);
const certs = Object.fromEntries(domains.map((dm) => [dm, certDir ? { state: 'active', source: own ? 'own' : 'wildcard', expiresAt: certExpiry(certDir) } : { state: 'none' }]));
return { ...r, https: !!certDir, certs, serverNames: domains.length ? domains : [`${siteId}.preview.${ctx.roots.platformDomain}`] };
}
export const kinds = {
'site.deploy': { title: '사이트 배포', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const leftovers = [];
const rel = await ctx.step('prepare', () => { mkdirSync(join(sd, 'releases'), { recursive: true }); mkdirSync(join(sd, 'data', 'uploads'), { recursive: true }); const n = `${stamp()}-${String(p.versionRef || 'v').replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 24)}`; return { dir: join(sd, 'releases', n), name: n }; }, '배포 위치 준비');
leftovers.push(rel.dir);
try {
const b = await ctx.step('fetch', () => fetchBundle(ctx, siteId, join(sd, 'work', rel.name)), '저장본 준비'); leftovers.push(join(sd, 'work', rel.name));
await ctx.step('extract', async () => { await untar(ctx, b.file, rel.dir); writeJson(join(rel.dir, '.ariweb-version.json'), { version: rel.name, versionRef: p.versionRef || null, deployedAt: new Date().toISOString(), sha256: b.sha256, source: b.source }); rmrf(join(sd, 'work', rel.name)); return { files: readdirSync(rel.dir).length }; }, '파일과 설정 적용');
await ctx.step('data', () => { const meta = readJson(join(rel.dir, 'ariweb.json'), {}); if (meta.db?.sqlite) mkdirSync(join(sd, 'data', 'db'), { recursive: true }); return { runtime: p.runtime || meta.runtime || 'static', db: meta.db || null }; }, '데이터베이스와 업로드 데이터 확인');
await ctx.progress(50); ctx.checkCancel();
const prev = readLink(join(sd, 'current'));
const sw = await ctx.step('switch', async () => { switchLink(join(sd, 'current'), rel.dir); writeJson(join(sd, '.previous.json'), { previous: prev, at: new Date().toISOString() }); return { previous: prev }; }, '서비스 시작(버전 전환)');
const meta = readJson(join(rel.dir, 'ariweb.json'), {}); const runtime = p.runtime || meta.runtime || 'static'; let port = null;
if (runtime === 'node') { port = sitePort(siteId, p); await ctx.step('unit', () => nodeUnit(ctx, siteId, sd, meta.start || 'server.js', port), '실행 유닛 준비'); }
const vh = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime, port }), '도메인 연결(nginx)');
await ctx.progress(80);
const ic = await ctx.step('internal', () => internalCheck(ctx, siteId, vh.serverNames[0], { expectMarker: p.expectMarker || null }), '내부 확인');
if (!ic.ok) { if (prev && existsSync(prev)) { switchLink(join(sd, 'current'), prev); await ctx.sys.nginxReload().catch(() => {}); } const e = new Error(`내부 확인 실패(${ic.status || ic.error}) — 이전 버전을 유지합니다`); e.code = 'internal_check_failed'; e.partial = { leftovers, previousKept: !!prev, internalCheck: ic }; throw e; }
// 오래된 릴리스 정리(최근 5개 보존 — 되돌리기 대상)
await ctx.step('prune', () => { const rels = readdirSync(join(sd, 'releases')).sort(); const del = rels.slice(0, Math.max(0, rels.length - 5)).filter((r) => join(sd, 'releases', r) !== rel.dir); for (const r of del) rmrf(join(sd, 'releases', r)); return { pruned: del.length }; }, '이전 배포본 정리');
return { version: rel.name, deployedAt: new Date().toISOString(), internalCheck: ic, siteUrl: p.primaryDomain ? `https://${p.primaryDomain}/` : `http://${vh.serverNames[0]}/`, serverNames: vh.serverNames, https: vh.https, certs: vh.certs, previousVersion: sw.previous ? sw.previous.split('/').pop() : null, runtime, port, leftovers: [] };
} catch (e) { if (!e.partial) e.partial = { leftovers: leftovers.filter((x) => existsSync(x)), previousKept: true }; throw e; }
} },
'site.rollback': { title: '이전 버전으로 되돌리기', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId);
const target = await ctx.step('find', () => { const want = p.toVersion ? join(sd, 'releases', p.toVersion) : readJson(join(sd, '.previous.json'), {}).previous; if (!want || !existsSync(want)) throw Object.assign(new Error('되돌릴 이전 버전이 없습니다'), { code: 'no_previous_version' }); return want; }, '되돌릴 버전 확인');
const cur = readLink(join(sd, 'current'));
await ctx.step('switch', () => { switchLink(join(sd, 'current'), target); writeJson(join(sd, '.previous.json'), { previous: cur, at: new Date().toISOString() }); return { from: cur, to: target }; }, '버전 전환');
const unit = `ariweb-site-${siteId}`; if (readJson(join(target, 'ariweb.json'), {}).runtime === 'node') await ctx.step('restart', () => ctx.sys.systemctl('restart', unit), '실행 유닛 재시작'); else await ctx.sys.nginxReload().catch(() => {});
const ic = await ctx.step('internal', () => internalCheck(ctx, siteId, (p.domains && p.domains[0]) || `${siteId}.preview.${ctx.roots.platformDomain}`), '내부 확인');
if (!ic.ok) throw Object.assign(new Error('되돌린 버전의 내부 확인에 실패했습니다'), { code: 'internal_check_failed', partial: { internalCheck: ic } });
return { version: target.split('/').pop(), internalCheck: ic };
} },
'site.remove': { title: '배포 제거', async run(ctx) {
const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const left = [];
await ctx.step('vhost', () => removeVhost(ctx, siteId), '도메인 연결 해제');
await ctx.step('unit', async () => { if (existsSync(`/etc/systemd/system/ariweb-site-${siteId}.service`) || ctx.sys.stub) { await ctx.sys.systemctl('stop', `ariweb-site-${siteId}`, { optional: true }); await ctx.sys.systemctl('disable', `ariweb-site-${siteId}`, { optional: true }); await ctx.sys.sudo(['rm', '-f', `/etc/systemd/system/ariweb-site-${siteId}.service`]); } return true; }, '실행 유닛 제거');
await ctx.step('files', () => { if (p.keepData) { for (const x of ['releases', 'current', 'work']) rmrf(join(sd, x)); if (existsSync(join(sd, 'data'))) left.push(join(sd, 'data')); } else rmrf(sd); return { removed: true }; }, '파일 정리');
return { removed: true, leftovers: left.filter((x) => existsSync(x)) };
} },
'site.check': { title: '사이트 확인', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const checks = await ctx.step('check', async () => { const out = []; for (const u of p.urls || []) { let host = u; try { host = new URL(u).hostname; } catch (_) {} out.push({ url: u, ...(await internalCheck(ctx, siteId, host, { expectMarker: p.expectMarker || null })) }); } return out; }, '내부 확인'); const sd = siteDir(ctx.roots, siteId); return { checks, currentVersion: (readLink(join(sd, 'current')) || '').split('/').pop() || null, maintenance: existsSync(join(sd, '.maintenance')), limited: existsSync(join(sd, '.limited')), tls: (() => { const c = ownCertFor(ctx.roots, siteId) || wildcardCertFor(ctx.roots, p.domains || []); return { valid: !!c, expiresAt: c ? certExpiry(c) : null }; })() }; } },
'site.usage': { title: '사용량 수집', async run(ctx) { const out = {}; for (const siteId of ctx.payload.siteIds || []) { try { out[siteId] = await ctx.step(`usage:${siteId}`, () => collectUsage(ctx, siteId), `사용량 ${siteId}`); } catch (e) { out[siteId] = { error: e.message }; } } return { sites: out, measuredAt: new Date().toISOString() }; } },
'site.limit': { title: '전송량 제한 표시', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.limited'); if (ctx.payload.limited) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { limited: !!ctx.payload.limited }; } },
'site.maintenance': { title: '유지보수 모드', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.maintenance'); if (ctx.payload.on) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { maintenance: !!ctx.payload.on }; } },
// ---------------- 이전 보조(§21)
'site.prepare': { title: '새 위치 준비', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(join(sd, 'releases'), { recursive: true }); mkdirSync(join(sd, 'staging'), { recursive: true }); mkdirSync(join(sd, 'data'), { recursive: true }); const free = (() => { try { const { statfsSync } = ctx.sys.stub ? { statfsSync: () => ({ bfree: 1e9, bsize: 1 }) } : {}; return null; } catch (_) { return null; } })(); return { prepared: true, dir: sd }; } },
'site.export': { title: '사이트 내보내기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const cur = readLink(join(sd, 'current')); if (!cur) throw Object.assign(new Error('운영 중인 배포본이 없습니다'), { code: 'no_current' }); const work = join(sd, 'work', `export-${stamp()}`); mkdirSync(work, { recursive: true }); const t = await ctx.step('tar', async () => { mkdirSync(join(work, 'pack'), { recursive: true }); await ctx.sys.tar(['-cf', join(work, 'pack', 'files.tar'), '-C', cur, '.']); if (p.includeData !== false && existsSync(join(sd, 'data'))) await ctx.sys.tar(['-cf', join(work, 'pack', 'data.tar'), '-C', join(sd, 'data'), '.']); writeJson(join(work, 'pack', 'meta.json'), { siteId, version: cur.split('/').pop(), exportedAt: new Date().toISOString(), ariweb: readJson(join(cur, 'ariweb.json'), {}) }); return tarDir(ctx, join(work, 'export.tar.gz'), join(work, 'pack')); }, '묶기'); const up = await ctx.step('upload', () => ctx.client.upload(p.transferId, t.file, { purpose: 'relocate' }), 'HAL 로 올리기'); rmrf(work); return { transferId: up.id, checksum: fmtSum(up.sha256), sizeBytes: up.size, version: cur.split('/').pop() }; } },
'site.import': { title: '사이트 받기', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const stage = join(sd, 'staging', p.stage || 'pre'); rmrf(stage); mkdirSync(stage, { recursive: true }); const dl = await ctx.step('download', () => ctx.client.downloadTransfer(p.transferId, join(stage, 'export.tar.gz'), p.checksum || null), '받기'); await ctx.step('unpack', async () => { await untar(ctx, join(stage, 'export.tar.gz'), join(stage, 'pack')); mkdirSync(join(stage, 'files'), { recursive: true }); await ctx.sys.tar(['-xf', join(stage, 'pack', 'files.tar'), '-C', join(stage, 'files')]); if (existsSync(join(stage, 'pack', 'data.tar'))) { mkdirSync(join(stage, 'data'), { recursive: true }); await ctx.sys.tar(['-xf', join(stage, 'pack', 'data.tar'), '-C', join(stage, 'data')]); } return true; }, '풀기'); const meta = readJson(join(stage, 'pack', 'meta.json'), {}); writeJson(join(sd, 'staging', 'latest.json'), { stage: p.stage || 'pre', checksum: fmtSum(dl.sha256), meta, at: new Date().toISOString() }); return { ok: dl.sha256 === normSum(p.checksum || dl.sha256), checksum: fmtSum(dl.sha256), sizeBytes: dl.size, version: meta.version || null, files: readdirSync(join(stage, 'files')).length }; } },
'site.stage_check': { title: '새 위치 확인', async run(ctx) { const p = ctx.payload; const sd = siteDir(ctx.roots, assertSiteId(p.siteId)); const latest = readJson(join(sd, 'staging', 'latest.json'), null); const stage = latest ? join(sd, 'staging', latest.stage) : null; const ok = !!(latest && stage && existsSync(join(stage, 'files')) && (!p.expectedChecksum || normSum(latest.checksum) === normSum(p.expectedChecksum)) && readdirSync(join(stage, 'files')).length > 0); return { ok, checksum: latest?.checksum || null, expected: p.expectedChecksum || null, files: ok ? readdirSync(join(stage, 'files')).length : 0, reason: ok ? null : '스테이징 사본이 없거나 체크섬이 다릅니다' }; } },
'site.activate': { title: '새 위치에서 서비스 시작', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); const latest = readJson(join(sd, 'staging', 'latest.json'), null); const rel = await ctx.step('place', () => { if (p.reactivate && readLink(join(sd, 'current'))) return { dir: readLink(join(sd, 'current')), reused: true }; if (!latest) throw Object.assign(new Error('스테이징 사본이 없습니다'), { code: 'no_staging' }); const dir = join(sd, 'releases', `${stamp()}-reloc`); renameSync(join(sd, 'staging', latest.stage, 'files'), dir); if (existsSync(join(sd, 'staging', latest.stage, 'data'))) { const old = join(sd, 'data'); if (existsSync(old)) renameSync(old, join(sd, `data.pre-reloc-${stamp()}`)); renameSync(join(sd, 'staging', latest.stage, 'data'), old); } writeJson(join(dir, '.ariweb-version.json'), { version: latest.meta?.version || dir.split('/').pop(), relocatedAt: new Date().toISOString() }); return { dir }; }, '배포본 배치'); switchLink(join(sd, 'current'), rel.dir); rmrf(join(sd, '.frozen')); let port = null; const meta = readJson(join(rel.dir, 'ariweb.json'), {}); if ((p.runtime || meta.runtime) === 'node') { port = sitePort(siteId, p); await ctx.step('unit', () => nodeUnit(ctx, siteId, sd, meta.start || 'server.js', port), '실행 유닛'); } const vh = await ctx.step('vhost', () => configureVhost(ctx, siteId, { domains: p.domains || [], primaryDomain: p.primaryDomain || null, runtime: p.runtime || meta.runtime || 'static', port }), '도메인 연결'); const ic = await internalCheck(ctx, siteId, vh.serverNames[0]); if (!ic.ok) throw Object.assign(new Error('새 위치 내부 확인 실패'), { code: 'internal_check_failed', partial: { internalCheck: ic } }); return { version: rel.dir.split('/').pop(), internalCheck: ic, serverNames: vh.serverNames }; } },
'site.deactivate': { title: '이전 위치 서비스 중지(사본 보존)', async run(ctx) { const p = ctx.payload; const siteId = assertSiteId(p.siteId); const sd = siteDir(ctx.roots, siteId); await ctx.step('vhost', () => removeVhost(ctx, siteId), '도메인 연결 해제'); await ctx.step('unit', () => ctx.sys.systemctl('stop', `ariweb-site-${siteId}`, { optional: true }), '실행 유닛 중지'); const until = new Date(Date.now() + (p.retainDays || 7) * 86400000).toISOString(); writeJson(join(sd, '.retained.json'), { until, reason: 'relocated', at: new Date().toISOString() }); rmrf(join(sd, '.frozen')); return { retainedUntil: until }; } },
'site.freeze': { title: '쓰기 제한', async run(ctx) { const sd = siteDir(ctx.roots, assertSiteId(ctx.payload.siteId)); mkdirSync(sd, { recursive: true }); const f = join(sd, '.frozen'); if (ctx.payload.freeze) writeFileSync(f, new Date().toISOString()); else rmrf(f); return { frozen: !!ctx.payload.freeze, at: new Date().toISOString() }; } },
};
// ---------------- 사용량(§13.1)
const DAY_KEEP = 400;
function usageFile(ctx, siteId) { return join(ctx.dataDir, 'usage', `${siteId}.json`); }
/** awstats 로그 증분 읽기(inode·offset 저장) → 일별 집계 */
export function ingestLog(ctx, siteId, logPath, state) {
if (!existsSync(logPath)) return state;
const fd = openSync(logPath, 'r'); try {
const st = fstatSync(fd); const off = state.offsets?.[logPath] || { inode: null, offset: 0 }; let start = off.inode === st.ino && off.offset <= st.size ? off.offset : 0;
const buf = Buffer.alloc(Math.min(st.size - start, 64 * 1024 * 1024)); const n = readSync(fd, buf, 0, buf.length, start); const text = buf.toString('utf8', 0, n); const lastNl = text.lastIndexOf('\n'); const usable = lastNl >= 0 ? text.slice(0, lastNl + 1) : '';
for (const line of usable.split('\n')) { if (!line) continue; const f = line.split('|'); if (f.length < 7) continue; const day = f[0].slice(0, 10); const d = state.days[day] || (state.days[day] = { req: 0, bytesIn: 0, bytesOut: 0, err: 0, hist: {} }); d.req++; d.bytesIn += Number(f[2]) || 0; d.bytesOut += Number(f[3]) || 0; if (Number(f[4]) >= 500) d.err++; const ms = Math.round((Number(f[5]) || 0) * 1000); const bucket = ms < 100 ? '0' : ms < 300 ? '100' : ms < 1000 ? '300' : ms < 3000 ? '1000' : '3000'; d.hist[bucket] = (d.hist[bucket] || 0) + 1; }
state.offsets = state.offsets || {}; state.offsets[logPath] = { inode: st.ino, offset: start + Buffer.byteLength(usable) };
} finally { closeSync(fd); }
const days = Object.keys(state.days).sort(); for (const k of days.slice(0, Math.max(0, days.length - DAY_KEEP))) delete state.days[k];
return state;
}
function p95(hist) { const order = ['0', '100', '300', '1000', '3000']; const total = order.reduce((a, k) => a + (hist[k] || 0), 0); if (!total) return null; let acc = 0; for (const k of order) { acc += hist[k] || 0; if (acc / total >= 0.95) return Number(k); } return 3000; }
export async function collectUsage(ctx, siteId) {
assertSiteId(siteId); const sd = siteDir(ctx.roots, siteId); const f = usageFile(ctx, siteId); const state = readJson(f, { days: {}, offsets: {} });
ingestLog(ctx, siteId, join(ctx.roots.logs, `aw-${siteId}.access.log`), state); writeJson(f, state);
const today = new Date().toISOString().slice(0, 10); const kst = (d) => new Date(new Date(d).getTime() + 9 * 3600000).toISOString().slice(0, 10); const kToday = kst(Date.now()); const month = kToday.slice(0, 7);
const sum = (filter, key) => Object.entries(state.days).filter(([d]) => filter(d)).reduce((a, [, v]) => a + (v[key] || 0), 0);
const series = (n) => { const out = []; for (let i = n - 1; i >= 0; i--) { const d = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10); const v = state.days[d] || {}; out.push({ date: d, req: v.req || 0, bytesOut: v.bytesOut || 0, bytesIn: v.bytesIn || 0, err: v.err || 0 }); } return out; };
const cur = readLink(join(sd, 'current')); const dbDir = join(sd, 'data', 'db'); const last30 = (d) => Date.now() - Date.parse(d) < 30 * 86400000;
const errs = sum(last30, 'err'), reqs = sum(last30, 'req'); const histAll = {}; for (const [d, v] of Object.entries(state.days)) if (last30(d)) for (const [k, c] of Object.entries(v.hist || {})) histAll[k] = (histAll[k] || 0) + c;
return { diskBytes: cur ? dirSize(cur) : 0, dbBytes: dirSize(dbDir), uploadsBytes: dirSize(join(sd, 'data', 'uploads')), outBytesDay: sum((d) => d === today, 'bytesOut'), outBytesMonth: sum((d) => kst(d + 'T12:00:00Z').startsWith(month), 'bytesOut'), outBytesTotal: sum(() => true, 'bytesOut'), inBytesDay: sum((d) => d === today, 'bytesIn'), inBytesMonth: sum((d) => kst(d + 'T12:00:00Z').startsWith(month), 'bytesIn'), inBytesTotal: sum(() => true, 'bytesIn'), req24h: sum((d) => d === today, 'req'), pv24h: sum((d) => d === today, 'req'), series7d: series(7), series30d: series(30), errorRate: reqs ? Math.round(errs / reqs * 10000) / 100 : 0, p95ms: p95(histAll), measuredAt: new Date().toISOString(), kstMonth: month };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* agent/metrics.mjs — 서버 지표(§28.1): CPU·메모리·디스크·inode·I/O·네트워크·load. /proc 와 df 만 읽는다(외부 도구 없음).
* CPU 는 두 표본 사이의 사용률(%). I/O·네트워크는 누적 바이트(HAL 이 차분으로 속도를 구한다). 읽을 수 없는 항목은 null.
*/
import { readFileSync, statfsSync, existsSync } from 'node:fs';
import { loadavg, hostname, release, totalmem, freemem } from 'node:os';
import { execFileSync } from 'node:child_process';
let lastCpu = null;
function cpuPercent() {
try { const l = readFileSync('/proc/stat', 'utf8').split('\n')[0].trim().split(/\s+/).slice(1).map(Number); const idle = l[3] + (l[4] || 0); const total = l.reduce((a, b) => a + b, 0); const prev = lastCpu; lastCpu = { idle, total }; if (!prev) return null; const dt = total - prev.total; return dt > 0 ? Math.round((1 - (idle - prev.idle) / dt) * 1000) / 10 : null; } catch (_) { return null; }
}
function mem() { try { const m = Object.fromEntries(readFileSync('/proc/meminfo', 'utf8').split('\n').filter(Boolean).map((l) => { const [k, v] = l.split(':'); return [k, parseInt(v, 10) * 1024]; })); return { total: m.MemTotal, used: m.MemTotal - (m.MemAvailable ?? m.MemFree) }; } catch (_) { return { total: totalmem(), used: totalmem() - freemem() }; } }
function disk(path = '/') { try { const s = statfsSync(path); return { total: s.blocks * s.bsize, used: (s.blocks - s.bfree) * s.bsize, inode: { total: s.files, used: s.files - s.ffree } }; } catch (_) { return null; } }
function io() { try { let r = 0, w = 0; for (const l of readFileSync('/proc/diskstats', 'utf8').split('\n')) { const f = l.trim().split(/\s+/); if (f.length < 14 || !/^(sd|vd|nvme|xvd|md|dm)/.test(f[2]) || /\d+p?\d+$/.test(f[2]) && !/^(md|dm)/.test(f[2])) continue; r += Number(f[5]) * 512; w += Number(f[9]) * 512; } return { read: r, write: w }; } catch (_) { return null; } }
function net() { try { let i = 0, o = 0; for (const l of readFileSync('/proc/net/dev', 'utf8').split('\n').slice(2)) { const [name, rest] = l.split(':'); if (!rest || name.trim() === 'lo') continue; const f = rest.trim().split(/\s+/); i += Number(f[0]); o += Number(f[8]); } return { in: i, out: o }; } catch (_) { return null; } }
/** heartbeat 에 실을 지표 묶음 */
export function sample(roots = {}) { const dk = disk('/'); return { cpu: cpuPercent(), mem: mem(), disk: dk ? { total: dk.total, used: dk.used } : null, inode: dk ? dk.inode : null, io: io(), net: net(), load1: loadavg()[0], at: new Date().toISOString() }; }
/** 서버 사실(facts): 등록·heartbeat 때 HAL 에 알린다. 비밀 없음. */
export function facts(sys) {
const f = { hostname: hostname(), kernel: release(), node: process.version, nginx: existsSync('/usr/sbin/nginx') || existsSync('/usr/bin/nginx'), certbot: existsSync('/usr/bin/certbot') || existsSync('/snap/bin/certbot'), units: {}, appDirs: {} };
try { const out = execFileSync('systemctl', ['list-units', '--type=service', '--all', '--no-pager', '--no-legend', '--plain'], { encoding: 'utf8', timeout: 5000 }); const names = out.split('\n').map((l) => l.trim().split(/\s+/)[0]).filter(Boolean).map((n) => n.replace(/\.service$/, ''));
for (const [mod, cands] of Object.entries({ webmaker: ['webmaker'], arimail: ['arimail', 'arihakdang-mail', 'heart-mail', 'mail-gateway'], hal: ['ariweb-hal'], portal: ['ariweb-portal'], agent: ['ariweb-agent'] })) { const u = cands.find((c) => names.includes(c)); if (u) { f.units[mod] = u; try { const txt = execFileSync('systemctl', ['cat', u], { encoding: 'utf8', timeout: 5000 }); const wd = /^WorkingDirectory=(.+)$/m.exec(txt); if (wd) f.appDirs[mod] = wd[1].replace(/\/current\/?$/, ''); const user = /^User=(.+)$/m.exec(txt); if (user) (f.users = f.users || {})[mod] = user[1]; const envf = /^EnvironmentFile=(.+)$/m.exec(txt); if (envf) (f.envFiles = f.envFiles || {})[mod] = envf[1]; const node = /^ExecStart=(\S+)/m.exec(txt); if (node) (f.nodeBins = f.nodeBins || {})[mod] = node[1]; } catch (_) {} } } } catch (_) {}
return f;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* agent/runner.mjs — Agent 작업 실행기(단계 저장·재개·진행 보고).
*
* HAL 의 lib/jobs 와 같은 원칙: 작업은 재진입 가능하고 끝난 단계는 건너뛴다. 단계 상태는 data/jobs/<id>.json 에 저장되어 Agent 재시작 뒤 이어서 실행된다.
* ctx = { job, payload, roots, sys, client, dataDir, step(id, fn, title), log(line), progress(pct), needsUser(message, hint), checkCancel(), steps() }
* 결과 보고: 성공 { status:'done', result } / 실패 { status:'failed', error, errorCode } / 사용자·운영자 확인 { status:'needs_user', userMessage }.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { redact, safeError } from '../lib/redact.mjs';
import { NeedsUser } from './sys.mjs';
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
export function createRunner({ kinds, roots, sys, client, dataDir, log = () => {} }) {
const jobsDir = join(dataDir, 'jobs'); mkdirSync(jobsDir, { recursive: true });
const stateFile = (id) => join(jobsDir, `${id}.json`);
const loadState = (id) => { try { return JSON.parse(readFileSync(stateFile(id), 'utf8')); } catch (_) { return { steps: [], log: [] }; } };
const saveState = (id, st) => { const tmp = stateFile(id) + '.tmp'; writeFileSync(tmp, JSON.stringify(st)); renameSync(tmp, stateFile(id)); };
/** 진행 보고(HAL). 실패해도 작업은 계속한다(다음 보고에서 따라잡음). */
async function report(id, body) { if (!client) return null; try { return await client.request('POST', `/agent/v1/jobs/${id}/progress`, body); } catch (e) { log(`[agent] 진행 보고 실패 ${id}: ${safeError(e)}`); return null; } }
async function execute(job) {
const def = kinds[job.kind]; const st = loadState(job.id); let cancelled = false;
const ctx = {
job, payload: job.payload || {}, roots, sys, client, dataDir, kinds,
async step(id, fn, title) {
let s = st.steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; st.steps.push(s); }
if (s.state === 'done') return s.result;
s.state = 'running'; s.startedAt = new Date().toISOString(); delete s.error; saveState(job.id, st); await report(job.id, { steps: st.steps });
try { const r = await fn(); s.state = 'done'; s.endedAt = new Date().toISOString(); s.result = r === undefined ? null : redact(r); saveState(job.id, st); return r; }
catch (e) { s.state = e instanceof Signal ? 'needs_user' : 'failed'; s.error = safeError(e); s.endedAt = new Date().toISOString(); saveState(job.id, st); throw e; }
},
log(line) { st.log.push({ at: new Date().toISOString(), line: String(line).slice(0, 500) }); st.log = st.log.slice(-200); saveState(job.id, st); log(`[${job.kind} ${job.id}] ${line}`); },
async progress(pct) { st.progress = Math.max(0, Math.min(100, Math.round(pct))); saveState(job.id, st); const r = await report(job.id, { progress: st.progress, steps: st.steps, log: st.log.slice(-20) }); if (r && r.cancelRequested) cancelled = true; },
needsUser(message, hint) { throw new Signal('needs_user', { message, hint }); },
checkCancel() { if (cancelled) throw new Signal('cancel', {}); },
steps: () => st.steps, state: st,
};
let out;
if (!def) out = { status: 'failed', error: `알 수 없는 작업 종류: ${job.kind}`, errorCode: 'unknown_kind' };
else {
try { const result = await def.run(ctx); out = { status: 'done', result: redact(result === undefined ? {} : result), steps: st.steps, log: st.log.slice(-50) }; }
catch (e) {
if (e instanceof Signal && e.signal === 'needs_user') out = { status: 'needs_user', userMessage: `${e.payload.message}${e.payload.hint ? ` (${e.payload.hint})` : ''}`, steps: st.steps, log: st.log.slice(-50) };
else if (e instanceof Signal && e.signal === 'cancel') out = { status: 'failed', error: '취소 요청으로 중단', errorCode: 'cancelled', steps: st.steps, log: st.log.slice(-50) };
else if (e instanceof NeedsUser || e.needsUser) out = { status: 'needs_user', userMessage: `${e.message}${e.hint ? ` — ${e.hint}` : ''}`, steps: st.steps, log: st.log.slice(-50) };
else out = { status: 'failed', error: safeError(e), errorCode: e.code || 'failed', steps: st.steps, log: st.log.slice(-50), result: e.partial ? redact(e.partial) : undefined };
}
}
try { unlinkSync(stateFile(job.id)); } catch (_) {}
if (client) { try { await client.request('POST', `/agent/v1/jobs/${job.id}/result`, out); } catch (e) { log(`[agent] 결과 보고 실패 ${job.id}: ${safeError(e)}`); saveState(job.id, { ...st, pendingResult: out }); } }
return out;
}
/** 재시작 뒤: 보고하지 못한 결과가 남아 있으면 다시 보고 */
async function flushPending() { if (!client) return; const { readdirSync } = await import('node:fs'); for (const f of readdirSync(jobsDir).filter((x) => x.endsWith('.json'))) { const st = loadState(f.slice(0, -5)); if (st.pendingResult) { try { await client.request('POST', `/agent/v1/jobs/${f.slice(0, -5)}/result`, st.pendingResult); unlinkSync(join(jobsDir, f)); } catch (_) {} } } }
return { execute, loadState, flushPending, kinds };
}
+36
View File
@@ -0,0 +1,36 @@
/**
* agent/sys.mjs — 시스템 명령 실행 계층(sudo·systemctl·nginx·certbot·tar) 과 권한 있는 파일 쓰기.
*
* Agent 는 일반 계정(master)으로 돌고 systemctl/nginx/certbot/tee 만 sudo -n(비밀번호 없음)으로 쓴다(deploy/sudoers/ariweb-agent).
* sudo 가 막혀 있으면 조용히 실패하지 않고 NEEDS_USER 로 운영자에게 이유(힌트)를 보인다.
* stub 모드(ARIWEB_AGENT_STUB_CMDS=1, 테스트): 외부 명령을 실행하지 않고 호출만 기록하며 성공으로 돌려준다. tar 는 실제로 실행한다(파일 결과를 검증하기 위해).
*/
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
import { dirname } from 'node:path';
export class NeedsUser extends Error { constructor(message, hint) { super(message); this.needsUser = true; this.hint = hint; } }
export function createSys({ stub = process.env.ARIWEB_AGENT_STUB_CMDS === '1', log = () => {} } = {}) {
const calls = [];
/** 명령 실행. 실패해도 예외 대신 { code, stdout, stderr } 를 돌려준다(호출자가 판단). */
function run(cmd, args = [], { input = null, timeoutMs = 600000, allowStub = true } = {}) {
calls.push({ cmd, args });
if (stub && allowStub && cmd !== 'tar' && cmd !== 'node') return Promise.resolve({ code: 0, stdout: '', stderr: '', stubbed: true });
return new Promise((resolve) => { const p = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'] }); let out = '', err = ''; const t = setTimeout(() => p.kill('SIGKILL'), timeoutMs); p.stdout.on('data', (c) => { out += c; }); p.stderr.on('data', (c) => { err += c; }); p.on('error', (e) => { clearTimeout(t); resolve({ code: 127, stdout: out, stderr: e.message }); }); p.on('close', (code) => { clearTimeout(t); resolve({ code, stdout: out, stderr: err }); }); if (input != null) p.stdin.end(input); else p.stdin.end(); });
}
const sudo = (args, o) => run('sudo', ['-n', ...args], o);
async function hasSudo() { if (stub) return true; const r = await run('sudo', ['-n', 'true']); return r.code === 0; }
async function requireSudo(what) { if (!(await hasSudo())) throw new NeedsUser(`${what} 에 sudo 권한이 필요하지만 이 서버에서 비밀번호 없는 sudo 가 허용되지 않았습니다`, 'deploy/sudoers/ariweb-agent 를 /etc/sudoers.d/ 에 설치하세요'); }
/** 권한이 필요한 경로에 파일 쓰기: 직접 쓰기 → 안 되면 sudo tee */
async function writeFile(path, content, mode = '644') {
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); return { via: 'direct' }; } catch (e) { if (e.code !== 'EACCES' && e.code !== 'EPERM') throw e; }
await requireSudo(`${path} 쓰기`); await sudo(['mkdir', '-p', dirname(path)]); const r = await sudo(['tee', path], { input: content }); if (r.code !== 0) throw new Error(`쓰기 실패 ${path}: ${r.stderr}`); await sudo(['chmod', mode, path]); return { via: 'sudo' };
}
async function systemctl(action, unit, { optional = false } = {}) { await requireSudo(`systemctl ${action} ${unit}`); const r = await sudo(['systemctl', action, unit]); if (r.code !== 0 && !optional) throw new Error(`systemctl ${action} ${unit} 실패: ${(r.stderr || r.stdout).trim().slice(0, 300)}`); return r; }
async function isActive(unit) { if (stub) return true; const r = await run('systemctl', ['is-active', unit]); return r.stdout.trim() === 'active'; }
async function unitText(unit) { if (stub) return ''; const r = await run('systemctl', ['cat', unit]); return r.code === 0 ? r.stdout : ''; }
async function nginxReload() { await requireSudo('nginx 설정 적용'); const t = await sudo(['nginx', '-t']); if (t.code !== 0) throw new Error(`nginx -t 실패: ${(t.stderr || t.stdout).trim().slice(0, 400)}`); const r = await sudo(['systemctl', 'reload', 'nginx']); if (r.code !== 0) throw new Error(`nginx reload 실패: ${r.stderr.slice(0, 300)}`); return true; }
async function certbot(args) { await requireSudo('certbot'); return sudo(['certbot', ...args], { timeoutMs: 300000 }); }
async function tar(args, o) { const r = await run('tar', args, { ...o, allowStub: false }); if (r.code !== 0) throw new Error(`tar 실패: ${r.stderr.slice(0, 300)}`); return r; }
return { stub, calls, run, sudo, hasSudo, requireSudo, writeFile, systemctl, isActive, unitText, nginxReload, certbot, tar, readIfExists: (p) => (existsSync(p) ? readFileSync(p, 'utf8') : null) };
}
+407
View File
@@ -0,0 +1,407 @@
{
"_comment": "Ariweb Suite 0.1.1 출시 검증용 버전 고정 상품 Catalog. 운영 상품정책은 운영자가 화면에서 관리하며 이 파일은 검증 환경 시드일 뿐이다. 금액·한도는 시험용 값이고 비밀값은 없다.",
"catalog_version": "release-0.1.1",
"payment_instructions": {
"method": "manual_transfer",
"bank": "검증용 은행(예시)",
"account": "000-0000-0000-00 (검증 환경 예시 계좌)",
"holder": "Ariweb 검증 환경",
"note": "입금자명에 주문 번호를 적어 주세요. 운영자가 입금을 확인한 뒤 승인하면 권리가 활성화됩니다.",
"deadline_days": 7
},
"products": [
{
"product_id": "basic",
"version": 1,
"name": "베이직",
"audience": "개인·소규모 사업자",
"kind": "base",
"description": "사이트 1개와 대표 메일을 운영하는 기본 상품",
"billing_cycle": "monthly",
"price": 19000,
"currency": "KRW",
"tax_rate": 0.1,
"limits": {
"sites": 1,
"web_disk": 5368709120,
"web_out": 53687091200,
"mail_storage": 5368709120,
"mail_msgs": 3000,
"mailboxes": 5
},
"options": [
{
"id": "standalone_mail",
"name": "독립 메일 서비스",
"price": 9000,
"grants": {
"mail_services": 1,
"mail_storage": 5368709120,
"mail_msgs": 3000,
"mailboxes": 5
},
"per": "group",
"max_qty": 3
},
{
"id": "source",
"name": "Source(Gitea) 옵션",
"price": 5000,
"grants": {
"source": 1
},
"per": "site",
"max_qty": 1
},
{
"id": "backup",
"name": "관리형 사이트 백업 옵션",
"price": 7000,
"grants": {
"backup": 1
},
"per": "site",
"max_qty": 1,
"policy": {
"schedule": "daily",
"schedule_time_kst": "03:30",
"retention": {
"daily": 7,
"weekly": 4,
"monthly": 3
},
"storage_bytes": 21474836480,
"first_backup_within_hours": 24,
"remote_copy_verification": "sha256 + 격리 환경 읽기 확인",
"restore_cost": 0,
"restore_approval": "manual",
"expired_backup_retention_days": 7,
"failure_alert_after": 3,
"extra_storage_option": "extra_backup_storage",
"restore_completion": "user_confirm",
"protect_point_days": 7,
"revert_hours": 72,
"resume_after_pause": "confirm",
"restore_downtime_minutes": 10
}
},
{
"id": "extra_disk",
"name": "웹 디스크 추가 5GB",
"price": 3000,
"grants": {
"web_disk": 5368709120
},
"per": "group",
"max_qty": 10
},
{
"id": "extra_out",
"name": "월간 웹 아웃바운드 추가 50GB",
"price": 3000,
"grants": {
"web_out": 53687091200
},
"per": "group",
"max_qty": 10
},
{
"id": "extra_mailboxes",
"name": "메일함 5개 추가",
"price": 2000,
"grants": {
"mailboxes": 5
},
"per": "group",
"max_qty": 10
},
{
"id": "extra_backup_storage",
"name": "백업 저장 공간 추가 20GB",
"price": 2000,
"grants": {},
"per": "site",
"max_qty": 5
}
],
"includes": [
"관리형 배포(정적·승인된 동적)",
"사이트 대표 메일 도메인 1개",
"도메인 연결·HTTPS 인증서",
"접속·사용량 통계",
"알림 센터"
],
"excludes": [
"외부 배포 서버 운영·백업·SLA",
"메일 원문 백업",
"도메인 등록(외부 등록기관 이용)",
"자동 결제"
],
"policy": {
"approval": "manual",
"payment_deadline_days": 7,
"renewal_notice_days": 7,
"restriction_after_unpaid_days": 14,
"restriction_effect": "신규 배포·설정 변경·새 메일함 생성 제한(기존 서비스·조회·삭제·내보내기 유지)",
"retention_days": {
"site": 30,
"mail": 30,
"source": 30,
"backup_after_expiry": 7
},
"external_deploy_fee": 3000,
"relocation": {
"customer_requested_fee": 10000,
"platform_reason_fee": 0,
"target_downtime_minutes": 15,
"revert_window_hours": 72,
"old_location_retention_hours": 72,
"dns_wait_handling": "전환 뒤 DNS·인증서 반영 대기 중에는 이전 위치가 새 위치로 요청을 넘겨 접속이 끊기지 않게 한다",
"write_freeze": "동적 사이트·메일은 최종 변경분 반영 직전부터 전환 완료까지 쓰기 제한"
},
"overage": {
"default": false,
"unit_prices": {
"web_out": {
"per_bytes": 10737418240,
"price": 1000
},
"mail_msgs": {
"per_units": 1000,
"price": 1000
}
}
},
"lifecycle": {
"pause_keeps_site_count": true,
"cancel_effective": "next_billing_date",
"readonly_retention_days": 30,
"delete_after_days": 30
},
"alias_counting": {
"value": null,
"status": "deferred",
"note": "별칭·전달 전용 주소의 계산 방식은 후속 상품정책에서 정한다. 확정 전에는 무제한으로 제공하지 않으며 메일함 수에 포함해 계산한다."
},
"large_attachment": {
"value": null,
"status": "deferred",
"note": "대용량 첨부의 허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정한다. 시스템 안전 상한이 우선한다."
},
"ai_wallet_pricing": {
"value": null,
"status": "deferred",
"note": "AI 제작 기능의 크레딧 단가는 Webmaker 관리자 설정(등급별)을 따르며 상품정책에 고정하지 않는다."
}
},
"warn_levels": [
70,
80,
90,
95
],
"effective_from": "2026-10-01T00:00:00.000Z",
"published": true
},
{
"product_id": "team",
"version": 1,
"name": "팀",
"audience": "여러 사이트를 함께 운영하는 팀·회사",
"kind": "base",
"description": "사이트 3개와 넉넉한 메일·트래픽",
"billing_cycle": "monthly",
"price": 49000,
"currency": "KRW",
"tax_rate": 0.1,
"limits": {
"sites": 3,
"web_disk": 21474836480,
"web_out": 214748364800,
"mail_storage": 21474836480,
"mail_msgs": 10000,
"mailboxes": 20
},
"options": [
{
"id": "standalone_mail",
"name": "독립 메일 서비스",
"price": 9000,
"grants": {
"mail_services": 1,
"mail_storage": 5368709120,
"mail_msgs": 3000,
"mailboxes": 5
},
"per": "group",
"max_qty": 5
},
{
"id": "source",
"name": "Source(Gitea) 옵션",
"price": 5000,
"grants": {
"source": 1
},
"per": "site",
"max_qty": 1
},
{
"id": "backup",
"name": "관리형 사이트 백업 옵션",
"price": 7000,
"grants": {
"backup": 1
},
"per": "site",
"max_qty": 1,
"policy": {
"schedule": "daily",
"schedule_time_kst": "03:30",
"retention": {
"daily": 7,
"weekly": 4,
"monthly": 3
},
"storage_bytes": 53687091200,
"first_backup_within_hours": 24,
"remote_copy_verification": "sha256 + 격리 환경 읽기 확인",
"restore_cost": 0,
"restore_approval": "manual",
"expired_backup_retention_days": 7,
"failure_alert_after": 3,
"extra_storage_option": "extra_backup_storage",
"restore_completion": "user_confirm",
"protect_point_days": 7,
"revert_hours": 72,
"resume_after_pause": "confirm",
"restore_downtime_minutes": 10
}
},
{
"id": "extra_disk",
"name": "웹 디스크 추가 5GB",
"price": 3000,
"grants": {
"web_disk": 5368709120
},
"per": "group",
"max_qty": 20
},
{
"id": "extra_out",
"name": "월간 웹 아웃바운드 추가 50GB",
"price": 3000,
"grants": {
"web_out": 53687091200
},
"per": "group",
"max_qty": 20
},
{
"id": "extra_mailboxes",
"name": "메일함 5개 추가",
"price": 2000,
"grants": {
"mailboxes": 5
},
"per": "group",
"max_qty": 20
},
{
"id": "extra_backup_storage",
"name": "백업 저장 공간 추가 20GB",
"price": 2000,
"grants": {},
"per": "site",
"max_qty": 5
}
],
"includes": [
"관리형 배포(정적·승인된 동적)",
"사이트마다 대표 메일 도메인 1개",
"도메인 연결·HTTPS 인증서",
"접속·사용량 통계",
"사이트별 구성원 권한"
],
"excludes": [
"외부 배포 서버 운영·백업·SLA",
"메일 원문 백업",
"도메인 등록(외부 등록기관 이용)",
"자동 결제"
],
"policy": {
"approval": "manual",
"payment_deadline_days": 7,
"renewal_notice_days": 7,
"restriction_after_unpaid_days": 14,
"restriction_effect": "신규 배포·설정 변경·새 메일함 생성 제한(기존 서비스·조회·삭제·내보내기 유지)",
"retention_days": {
"site": 30,
"mail": 30,
"source": 30,
"backup_after_expiry": 7
},
"external_deploy_fee": 3000,
"relocation": {
"customer_requested_fee": 10000,
"platform_reason_fee": 0,
"target_downtime_minutes": 15,
"revert_window_hours": 72,
"old_location_retention_hours": 72,
"dns_wait_handling": "전환 뒤 DNS·인증서 반영 대기 중에는 이전 위치가 새 위치로 요청을 넘겨 접속이 끊기지 않게 한다",
"write_freeze": "동적 사이트·메일은 최종 변경분 반영 직전부터 전환 완료까지 쓰기 제한"
},
"overage": {
"default": false,
"unit_prices": {
"web_out": {
"per_bytes": 10737418240,
"price": 1000
},
"mail_msgs": {
"per_units": 1000,
"price": 1000
}
}
},
"lifecycle": {
"pause_keeps_site_count": true,
"cancel_effective": "next_billing_date",
"readonly_retention_days": 30,
"delete_after_days": 30
},
"alias_counting": {
"value": null,
"status": "deferred",
"note": "별칭·전달 전용 주소의 계산 방식은 후속 상품정책에서 정한다. 확정 전에는 무제한으로 제공하지 않으며 메일함 수에 포함해 계산한다."
},
"large_attachment": {
"value": null,
"status": "deferred",
"note": "대용량 첨부의 허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정한다. 시스템 안전 상한이 우선한다."
},
"ai_wallet_pricing": {
"value": null,
"status": "deferred",
"note": "AI 제작 기능의 크레딧 단가는 Webmaker 관리자 설정(등급별)을 따르며 상품정책에 고정하지 않는다."
}
},
"warn_levels": [
70,
80,
90,
95
],
"effective_from": "2026-10-01T00:00:00.000Z",
"published": true
}
],
"settings": {
"domain_registration": {
"enabled": false,
"note": "버전 0.1.1 검증 환경에는 등록기관이 연결되어 있지 않습니다. 도메인은 외부 등록기관에서 등록한 뒤 연결하세요."
},
"usage.stale_minutes": 30
}
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# deploy/agent-install.sh — 관리 서버 또는 고객 외부 서버에 Node Agent 설치·등록(§28.2·§10.2). HAL 이 /agent/v1/install.sh 로 제공한다.
# curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <일회용 토큰> [--role web|mail|backup|control] [--external --site <siteId>] [--app-dir /srv/ariweb/agent]
# 하는 일: node 22+ 확인(없으면 NodeSource 로 설치), agent artifact 를 HAL 에서 받아 <app-dir>/releases/<ver> 에 풀고 current 연결, 등록(토큰 1회), systemd 유닛 ariweb-agent 설치·시작, sudoers 최소 규칙(관리형만).
# 멱등: 다시 실행하면 이미 등록된 Agent 는 재등록하지 않고 코드·유닛만 맞춘다. 토큰 외의 비밀은 출력하지 않는다.
set -euo pipefail
HAL=""; TOKEN=""; ROLE="web"; EXTERNAL=0; SITE=""; APP_DIR="/srv/ariweb/agent"; LABEL="$(hostname)"
while [ $# -gt 0 ]; do case "$1" in --hal) HAL="$2"; shift 2;; --token) TOKEN="$2"; shift 2;; --role) ROLE="$2"; shift 2;; --external) EXTERNAL=1; shift;; --site) SITE="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --label) LABEL="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
[ -n "$HAL" ] || { echo "--hal 이 필요합니다" >&2; exit 1; }
HAL="${HAL%/}"; USER_NAME="$(id -un)"
if ! command -v node >/dev/null 2>&1 || [ "$(node --version | sed -E 's/^v([0-9]+).*/\1/')" -lt 22 ]; then echo "== node 22 설치"; curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n bash - >/dev/null && sudo -n apt-get install -y -q nodejs >/dev/null; fi
NODE_BIN="$(command -v node)"
echo "== HAL 확인 $HAL"; HEALTH="$(curl -fsS "$HAL/api/health")"; VERSION="$(echo "$HEALTH" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).bootstrap.releaseVersion||JSON.parse(s).version))")"
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$APP_DIR/data" /var/www/ariweb-sites /srv/ariweb/backups; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /var/www/ariweb-sites /srv/ariweb/backups; chmod 700 "$APP_DIR/data"
if [ ! -f "$APP_DIR/releases/$VERSION/agent/index.mjs" ]; then
echo "== agent artifact $VERSION 내려받기"
# 등록 전에는 서명을 만들 수 없으므로 부트스트랩 전용 공개 경로가 아니라 등록 토큰으로 1회 받는다: HAL 의 install.sh 는 토큰과 함께 artifact 를 함께 보낸다(아래 등록 뒤 재시도 경로).
if [ -n "$TOKEN" ]; then curl -fsS -H "X-Registration-Token: $TOKEN" "$HAL/agent/v1/bootstrap-artifact?module=agent&version=$VERSION" -o /tmp/ariweb-agent-$$.tgz; tar -xzf /tmp/ariweb-agent-$$.tgz -C "$APP_DIR/releases/$VERSION"; rm -f /tmp/ariweb-agent-$$.tgz; else echo "코드가 없고 토큰도 없습니다" >&2; exit 1; fi
fi
ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"; cd "$APP_DIR/current"
export ARIWEB_AGENT_DATA="$APP_DIR/data"
if [ ! -f "$APP_DIR/data/agent.json" ]; then [ -n "$TOKEN" ] || { echo "등록되지 않았고 --token 도 없습니다" >&2; exit 1; }; echo "== 등록"; if [ "$EXTERNAL" = "1" ]; then $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --external --site "$SITE" --label "$LABEL"; else $NODE_BIN agent/index.mjs register --hal "$HAL" --token "$TOKEN" --role "$ROLE" --label "$LABEL"; fi; else echo "== 이미 등록됨($(cat "$APP_DIR/data/agent.json" | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>console.log(JSON.parse(s).id))")) — 코드·유닛만 갱신"; fi
echo "== systemd 유닛"
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" -e "s#{{ENV_LINES}}#$([ "$EXTERNAL" = "1" ] && echo "Environment=ARIWEB_AGENT_EXTERNAL=1" || echo "")#" deploy/units/ariweb-agent.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-agent.service >/dev/null
if [ "$EXTERNAL" = "0" ]; then sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent && sudo -n visudo -c >/dev/null; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf 2>/dev/null && (sudo -n nginx -t >/dev/null 2>&1 && sudo -n systemctl reload nginx || true); fi
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-agent >/dev/null; sudo -n systemctl restart ariweb-agent; sleep 2
systemctl is-active --quiet ariweb-agent && echo "== 완료: ariweb-agent 실행 중. HAL 운영자 콘솔에서 서버를 승인하면 배치가 가능합니다" || { echo "ariweb-agent 기동 실패 — journalctl -u ariweb-agent" >&2; exit 1; }
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# deploy/bootstrap-hal.sh — HAL 최초 설치(부트스트랩, Dev-Order §32.2). 최초 설치에서만 허용되는 작업:
# 1) 승인된 main 릴리스의 고정 artifact 선택 2) HAL 저장 기능·비밀정보 보관소·인증서 기반·접근 경로 준비 3) HAL 설치 4) 첫 관리 서버 Node Agent 등록 토큰 발급
# 그 뒤 모든 모듈(포털·Webmaker·AriMail·Agent 갱신 등)은 HAL 작업으로만 설치한다. 이 스크립트는 HAL 데이터에 부트스트랩이 완료된 흔적이 있으면 실행을 거부한다.
#
# 사용(대상 서버에서, sudo 가능한 일반 계정으로):
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net] [--app-dir /srv/ariweb/hal]
# 출력: 운영자 최초 설정 토큰(1회)과 첫 Agent 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
set -euo pipefail
DIST=""; HOST="hal.ariweb.net"; CERT="/etc/letsencrypt/live/wh-wild-ariweb.net"; APP_DIR="/srv/ariweb/hal"; PORTAL_HOST="my.ariweb.net"; ACME="/var/www/letsencrypt"; USER_NAME="$(id -un)"
while [ $# -gt 0 ]; do case "$1" in --dist) DIST="$2"; shift 2;; --host) HOST="$2"; shift 2;; --cert) CERT="$2"; shift 2;; --app-dir) APP_DIR="$2"; shift 2;; --portal-host) PORTAL_HOST="$2"; shift 2;; --acme) ACME="$2"; shift 2;; *) echo "알 수 없는 옵션 $1" >&2; exit 1;; esac; done
[ -n "$DIST" ] && [ -f "$DIST/manifest.json" ] || { echo "--dist <manifest.json 이 있는 디렉터리> 가 필요합니다" >&2; exit 1; }
NODE_BIN="$(command -v node || true)"; [ -n "$NODE_BIN" ] || { echo "node 22.13+ 가 필요합니다" >&2; exit 1; }
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
sudo -n true 2>/dev/null || { echo "비밀번호 없는 sudo 가 필요합니다" >&2; exit 1; }
VERSION="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).version)")"
COMMIT="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).mainCommit)")"
DIRTY="$($NODE_BIN -e "const m=JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8'));console.log(m.checks&&m.checks.dirty?'1':'0')")"
[ "$DIRTY" = "0" ] || { echo "main 에 병합되지 않은(dirty) 빌드는 부트스트랩에 쓸 수 없습니다" >&2; exit 1; }
ART="$DIST/hal-$VERSION.tar.gz"; [ -f "$ART" ] || { echo "artifact 없음: $ART" >&2; exit 1; }
SUM="$(sha256sum "$ART" | cut -d' ' -f1)"; WANT="$($NODE_BIN -e "console.log(JSON.parse(require('fs').readFileSync('$DIST/manifest.json','utf8')).artifacts.hal.sha256)")"
[ "$SUM" = "$WANT" ] || { echo "artifact sha256 불일치" >&2; exit 1; }
echo "== 1/6 릴리스 $VERSION (main $COMMIT, hal artifact sha256 ${SUM:0:16}…)"
DATA="$APP_DIR/data"
if [ -f "$DATA/hal.sqlite" ] && $NODE_BIN -e "const {DatabaseSync}=require('node:sqlite');const d=new DatabaseSync('$DATA/hal.sqlite',{readOnly:true});const r=d.prepare('SELECT completed_at FROM bootstrap_state WHERE id=1').get();process.exit(r&&r.completed_at?0:1)" 2>/dev/null; then
echo "이미 부트스트랩이 완료된 HAL 데이터($DATA)가 있습니다 — 이후 설치·갱신은 HAL 작업(운영자 콘솔 › 배포 릴리스)으로만 합니다" >&2; exit 2
fi
echo "== 2/6 디렉터리·보관소·접근 경로 준비"
sudo -n mkdir -p "$APP_DIR/releases/$VERSION" "$DATA" "$ACME" /srv/ariweb /var/www/ariweb-sites; sudo -n chown -R "$USER_NAME:$USER_NAME" "$APP_DIR" /srv/ariweb /var/www/ariweb-sites; chmod 700 "$DATA"
tar -xzf "$ART" -C "$APP_DIR/releases/$VERSION"; ln -sfn "$APP_DIR/releases/$VERSION" "$APP_DIR/current"
cd "$APP_DIR/current"
export ARIWEB_HAL_DATA="$DATA"; $NODE_BIN -e "import('./lib/vault.mjs').then(m=>{m.openVault('$DATA');console.log(' 보관소 준비(vault.key 0600)')})"
$NODE_BIN hal/index.mjs bootstrap-release --version "$VERSION" --commit "$COMMIT" --sha256 "$SUM"
$NODE_BIN hal/index.mjs register-release --manifest "$DIST/manifest.json" --approve --by bootstrap # hal artifact 에는 scripts/ 가 없으므로 HAL CLI 로 등록·artifact 복사
$NODE_BIN -e "const fs=require('fs');const f='$DATA/config.json';const v=fs.existsSync(f)?JSON.parse(fs.readFileSync(f,'utf8')).values:{};v.publicUrl='https://$HOST';v.portalUrl=v.portalUrl||'http://127.0.0.1:8910';fs.writeFileSync(f,JSON.stringify({version:1,updatedAt:new Date().toISOString(),updatedBy:'bootstrap',values:v},null,1),{mode:0o600})"
echo "== 3/6 systemd 유닛 ariweb-hal"
sed -e "s#{{APP_DIR}}#$APP_DIR#g" -e "s#{{USER}}#$USER_NAME#g" -e "s#{{NODE}}#$NODE_BIN#g" -e "s#{{VERSION}}#$VERSION#g" deploy/units/ariweb-hal.service.tmpl | sudo -n tee /etc/systemd/system/ariweb-hal.service >/dev/null
sudo -n install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent; sudo -n visudo -c >/dev/null
sudo -n systemctl daemon-reload; sudo -n systemctl enable --now ariweb-hal >/dev/null; sleep 2; systemctl is-active --quiet ariweb-hal || { echo "ariweb-hal 기동 실패 — journalctl -u ariweb-hal" >&2; exit 1; }
echo "== 4/6 nginx $HOST (인증서 $CERT)"
[ -f "$CERT/fullchain.pem" ] || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; }
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf; sudo -n nginx -t && sudo -n systemctl reload nginx
echo "== 5/6 HAL 자체 건강 확인"
curl -fsS http://127.0.0.1:8900/api/health | $NODE_BIN -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const h=JSON.parse(s);console.log(' HAL',h.version,'db',h.db,'jobs',h.jobs,'bootstrap',JSON.stringify(h.bootstrap))})"
curl -fsS --resolve "$HOST:443:127.0.0.1" "https://$HOST/api/health" >/dev/null && echo " https://$HOST/api/health OK (로컬 resolve)" || echo " 경고: https://$HOST 확인 실패 — DNS A 레코드($HOST → 이 서버)와 인증서를 확인하세요"
echo "== 6/6 최초 운영자 설정 토큰과 첫 Agent"
$NODE_BIN hal/index.mjs setup-token
cat <<MSG
다음 순서:
1) 위 설정 토큰으로 첫 운영자 계정을 만듭니다(POST https://$HOST/ops/api/setup). 운영자 콘솔은 허용 네트워크(사설망/VPN) 또는 접근권(node hal/index.mjs grant --login <id>)이 있어야 열립니다.
2) 운영자 콘솔 › 서버에서 이 서버(역할 control/web)를 만들고 등록 토큰을 발급해 첫 Node Agent 를 설치합니다:
curl -fsSL https://$HOST/agent/v1/install.sh | bash -s -- --hal https://$HOST --token <토큰> --role control
첫 Agent 가 승인되면 부트스트랩이 닫히고 수동 설치가 잠깁니다. 이후 포털·Webmaker·AriMail 은 HAL 작업으로 배포합니다.
3) 포털 연동 키: node hal/index.mjs link-portal (출력 값을 포털 보관소에 넣습니다)
MSG
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# deploy/hal-restore.sh — 플랫폼 재해복구(§34): HAL 복구 사본(dr snapshot tar.gz)으로 HAL 데이터를 복원한다. 운영자 콘솔 › 플랫폼 복구에서 "복원 시작" 승인과 기존 제어 위치 중지 확인 뒤에만 실행한다.
# sudo bash deploy/hal-restore.sh <snapshot.tar.gz> <sha256> [--data /srv/ariweb/hal/data] [--vault-key <hex 32바이트>]
# vault.key 는 사본에 들어 있지 않으므로 운영자가 보관한 키를 --vault-key 또는 ARIWEB_VAULT_KEY 로 준다(없으면 기존 데이터 디렉터리의 키를 유지).
set -euo pipefail
SNAP="${1:?snapshot}"; SUM="${2:?sha256}"; shift 2; DATA="/srv/ariweb/hal/data"; VKEY="${ARIWEB_VAULT_KEY:-}"
while [ $# -gt 0 ]; do case "$1" in --data) DATA="$2"; shift 2;; --vault-key) VKEY="$2"; shift 2;; *) shift;; esac; done
[ "$(sha256sum "$SNAP" | cut -d' ' -f1)" = "$SUM" ] || { echo "복구 사본 sha256 불일치" >&2; exit 1; }
systemctl is-active --quiet ariweb-hal && { echo "ariweb-hal 이 실행 중입니다 — 기존 제어 위치를 먼저 중지하세요(systemctl stop ariweb-hal)" >&2; exit 2; }
STAMP="$(date +%Y%m%d-%H%M%S)"; mkdir -p "$DATA"; [ -f "$DATA/hal.sqlite" ] && cp -a "$DATA" "$DATA.pre-restore-$STAMP" && echo "기존 데이터를 $DATA.pre-restore-$STAMP 로 보존"
WORK="$(mktemp -d)"; tar -xzf "$SNAP" -C "$WORK"; [ -f "$WORK/hal.sqlite" ] || { echo "사본에 hal.sqlite 가 없습니다" >&2; exit 1; }
install -m 600 "$WORK/hal.sqlite" "$DATA/hal.sqlite"; rm -f "$DATA/hal.sqlite-wal" "$DATA/hal.sqlite-shm"; [ -f "$WORK/vault.json" ] && install -m 600 "$WORK/vault.json" "$DATA/vault.json"; [ -f "$WORK/config.json" ] && install -m 600 "$WORK/config.json" "$DATA/config.json"
[ -n "$VKEY" ] && printf '%s' "$VKEY" > "$DATA/vault.key" && chmod 600 "$DATA/vault.key"; [ -f "$DATA/vault.key" ] || echo "경고: vault.key 가 없습니다 — 보관소 값을 읽을 수 없으니 --vault-key 로 넣으세요" >&2
OWNER="$(stat -c %U "$(dirname "$DATA")")"; chown -R "$OWNER:$OWNER" "$DATA"; rm -rf "$WORK"
echo "복원 완료($SNAP). 새 HAL 기동: systemctl start ariweb-hal (다른 인스턴스의 임대가 남아 있으면 기동이 거부됩니다 → 그 HAL 이 정말 중지되었는지 확인한 뒤 node hal/index.mjs --takeover 로 1회 기동)"
echo "기동 뒤 운영자 콘솔 › 플랫폼 복구 › 복원 완료 확인(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)을 기록하세요"
+31
View File
@@ -0,0 +1,31 @@
# hal.ariweb.net — HAL(제어 평면). 운영자 콘솔 /ops 는 HAL 이 네트워크 관문(허용 CIDR 또는 접근권 쿠키)을 적용한다.
# Agent 는 /agent/v1 로, 포털은 /portal/v1 로 서명된 요청을 보낸다. 큰 전송(/agent/v1/transfers)은 본문 제한을 넉넉히 둔다.
server {
listen 80;
listen [::]:80;
server_name {{HAL_HOST}};
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{HAL_HOST}};
server_tokens off;
client_max_body_size 0;
ssl_certificate {{CERT_DIR}}/fullchain.pem;
ssl_certificate_key {{CERT_DIR}}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
location / {
proxy_pass http://127.0.0.1:{{PORT}};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 120s;
}
}
+29
View File
@@ -0,0 +1,29 @@
# my.ariweb.net — 고객 포털
server {
listen 80;
listen [::]:80;
server_name {{PORTAL_HOST}};
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{PORTAL_HOST}};
server_tokens off;
client_max_body_size 200m;
ssl_certificate {{CERT_DIR}}/fullchain.pem;
ssl_certificate_key {{CERT_DIR}}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:{{PORT}};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 300s;
}
}
+3
View File
@@ -0,0 +1,3 @@
# deploy/nginx/awstats.conf — /etc/nginx/conf.d/awstats.conf : Ariweb 관리형 사이트 접속 통계 로그 형식(§13.1).
# Agent(site.usage)가 사이트별 로그(aw-<siteId>.access.log)를 읽어 요청 수·수신/송신 바이트·오류율·응답 시간을 집계한다. IP 는 기록하지 않는다(개인정보 최소화).
log_format awstats '$time_iso8601|$host|$request_length|$bytes_sent|$status|$request_time|$uri';
+7
View File
@@ -0,0 +1,7 @@
server {
listen 80;
listen [::]:80;
server_name {{OLD_NAMES}};
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
return 301 https://{{PRIMARY}}$request_uri;
}
+7
View File
@@ -0,0 +1,7 @@
access_log {{LOG}} awstats;
error_page 503 /__aw_maint;
location = /__aw_maint { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{MAINT_HTML}}'; }
if (-f {{SITE_DIR}}/.maintenance) { return 503; }
if (-f {{SITE_DIR}}/.limited) { rewrite ^ /__aw_limited last; }
location = /__aw_limited { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{LIMIT_HTML}}'; }
location / { proxy_pass http://127.0.0.1:{{PORT}}; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 300s; }
+11
View File
@@ -0,0 +1,11 @@
root {{ROOT}};
index index.html;
access_log {{LOG}} awstats;
error_page 503 /__aw_maint;
location = /__aw_maint { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{MAINT_HTML}}'; }
if (-f {{SITE_DIR}}/.maintenance) { return 503; }
if (-f {{SITE_DIR}}/.limited) { rewrite ^ /__aw_limited last; }
location = /__aw_limited { internal; default_type text/html; add_header Cache-Control "no-store" always; return 503 '{{LIMIT_HTML}}'; }
location ~ /\. { return 404; }
location / { try_files $uri $uri/ /index.html =404; add_header Cache-Control "no-cache" always; }
location /uploads/ { alias {{SITE_DIR}}/data/uploads/; }
+12
View File
@@ -0,0 +1,12 @@
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name {{SERVER_NAMES}};
server_tokens off;
client_max_body_size 64m;
ssl_certificate {{CERT_DIR}}/fullchain.pem;
ssl_certificate_key {{CERT_DIR}}/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
{{BODY}}
}
+11
View File
@@ -0,0 +1,11 @@
# Ariweb 관리형 사이트 vhost — Agent 가 만든다(site.deploy/domain.vhost). 손으로 고치지 마세요(다음 배포 때 덮어씁니다).
# 사이트 {{SITE_ID}} · 도메인 {{DOMAIN_LIST}} · 생성 {{GENERATED_AT}}
server {
listen 80;
listen [::]:80;
server_name {{SERVER_NAMES}};
server_tokens off;
location ^~ /.well-known/acme-challenge/ { root {{ACME_ROOT}}; default_type text/plain; }
{{HTTP_BODY}}
}
{{HTTPS_SERVER}}
+9
View File
@@ -0,0 +1,9 @@
# /etc/sudoers.d/ariweb-agent — Node Agent(계정 master)가 비밀번호 없이 쓸 수 있는 최소 명령(§28·§32). 그 밖의 sudo 는 허용하지 않는다.
# 설치: sudo install -m 440 deploy/sudoers/ariweb-agent /etc/sudoers.d/ariweb-agent && sudo visudo -c
Defaults:master !requiretty
master ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload, /usr/bin/systemctl start *, /usr/bin/systemctl stop *, /usr/bin/systemctl restart *, /usr/bin/systemctl reload *, /usr/bin/systemctl enable *, /usr/bin/systemctl disable *, /usr/bin/systemctl is-active *
master ALL=(root) NOPASSWD: /usr/sbin/nginx -t, /usr/bin/certbot *, /snap/bin/certbot *
master ALL=(root) NOPASSWD: /usr/bin/tee /etc/nginx/sites-available/aw-*, /usr/bin/tee /etc/nginx/sites-enabled/aw-*, /usr/bin/tee /etc/nginx/conf.d/awstats.conf, /usr/bin/tee /etc/nginx/sites-available/ariweb-*, /usr/bin/tee /etc/systemd/system/ariweb-*, /usr/bin/tee /etc/systemd/system/webmaker.service, /usr/bin/tee /etc/systemd/system/webmaker-setup.service
master ALL=(root) NOPASSWD: /usr/bin/ln -sf /etc/nginx/sites-available/aw-* /etc/nginx/sites-enabled/*, /usr/bin/ln -sf /etc/nginx/sites-available/ariweb-* /etc/nginx/sites-enabled/*, /usr/bin/rm -f /etc/nginx/sites-enabled/aw-*, /usr/bin/rm -f /etc/nginx/sites-available/aw-*, /usr/bin/rm -f /etc/systemd/system/ariweb-site-*
master ALL=(root) NOPASSWD: /usr/bin/mkdir -p /etc/nginx/*, /usr/bin/mkdir -p /var/www/*, /usr/bin/chown master\:master /var/www/*, /usr/bin/chmod * /etc/nginx/sites-available/aw-*, /usr/bin/chmod * /etc/systemd/system/ariweb-*, /usr/bin/chmod * /etc/nginx/conf.d/awstats.conf
master ALL=(root) NOPASSWD: /usr/bin/true
+31
View File
@@ -0,0 +1,31 @@
# AriMail(도입 모듈) — 원본 deploy/systemd/mail-gateway.service.in 과 같은 내용. 서버에 이미 있는 유닛(arihakdang-mail/arimail/heart-mail)은 그대로 두고 current 심링크만 바꾸므로 보통 쓰이지 않는다.
[Unit]
Description={{UNIT}} — AriMail gateway ({{VERSION}})
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
EnvironmentFile={{ENV_FILE}}
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=8790
ExecStart={{NODE}} server/index.mjs
Restart=always
RestartSec=3
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{APP_DIR}}/data
StandardOutput=journal
StandardError=journal
SyslogIdentifier={{UNIT}}
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Ariweb Node Agent ({{VERSION}})
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=ARIWEB_AGENT_DATA={{APP_DIR}}/data
Environment=NODE_OPTIONS=--no-warnings
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/snap/bin
{{ENV_LINES}}
ExecStart={{NODE}} agent/index.mjs run
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-agent
[Install]
WantedBy=multi-user.target
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=Ariweb HAL — 제어 평면 ({{VERSION}})
Documentation=https://source.ibit.kr/OZ-Group/AriWeb-Suite
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=8900
Environment=ARIWEB_HAL_DATA={{APP_DIR}}/data
Environment=NODE_OPTIONS=--no-warnings
ExecStart={{NODE}} hal/index.mjs
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths={{APP_DIR}}
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-hal
[Install]
WantedBy=multi-user.target
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Ariweb Portal — 고객 포털 ({{VERSION}})
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=8910
Environment=ARIWEB_PORTAL_DATA={{APP_DIR}}/data
Environment=NODE_OPTIONS=--no-warnings
{{ENV_LINES}}
ExecStart={{NODE}} portal/index.mjs
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-portal
[Install]
WantedBy=multi-user.target
+25
View File
@@ -0,0 +1,25 @@
# 관리형 동적(Node) 사이트 실행 유닛 — Agent 가 site.deploy(runtime=node) 때 만든다. 사이트 번들의 ariweb.json { "start": "node server.js", "port": N } 을 따른다.
[Unit]
Description=Ariweb site {{SITE_ID}}
After=network-online.target
[Service]
Type=simple
User={{USER}}
WorkingDirectory={{SITE_DIR}}/current
Environment=NODE_ENV=production
Environment=PORT={{PORT}}
Environment=ARIWEB_SITE_DATA={{SITE_DIR}}/data
ExecStart={{NODE}} {{START}}
Restart=always
RestartSec=3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ReadWritePaths={{SITE_DIR}}/data
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ariweb-site-{{SITE_ID}}
[Install]
WantedBy=multi-user.target
+25
View File
@@ -0,0 +1,25 @@
# Webmaker(도입 모듈 WebHosting_AI_agent) — 원본 deploy/webhosting-agent.service 를 바탕으로 HAL 배치 레이아웃(releases/current)에 맞춘 유닛.
# 데이터·포트·경로 환경 변수(DATA_ROOT·DATA_DIR·SITES_ROOT·STATS_LOG·PUBLIC_BASE_URL 등)는 서버에 있던 기존 유닛의 Environment 줄({{ENV_LINES}})을 그대로 보존한다.
[Unit]
Description=AI Webmaker ({{VERSION}}, Ariweb Suite managed)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={{USER}}
Group={{USER}}
WorkingDirectory={{APP_DIR}}/current
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3080
{{ENV_LINES}}
ExecStart={{NODE}} server.js
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
SyslogIdentifier=webmaker
[Install]
WantedBy=multi-user.target
+121
View File
@@ -0,0 +1,121 @@
# Ariweb Suite v0.1.1 — 아키텍처와 개발 규약
이 문서는 Ariweb Suite 의 모든 구성요소가 따르는 공통 규약이다. 하위 시스템을 나누어 개발할 때 이 문서가 인터페이스 계약이다.
개발 지시 원문은 `Ariweb-Suitle_Dev-Order_v0.1.1.md` 이며, 절 번호(예: §13.3)는 그 문서를 가리킨다.
## 1. 구성요소와 프로세스
| 프로세스 | 진입점 | 포트(127.0.0.1) | 역할 |
|---|---|---|---|
| `ariweb-hal` | `hal/index.mjs` | 8900 | HAL 제어 평면: 릴리스 등록부, 서버·Node Agent 등록부, 내구성 작업 엔진, 모듈 배포·갱신·되돌리기, 운영자 콘솔(`hal.ariweb.net/ops`), 공개 상태 페이지(`/status`), 긴급 복구, 플랫폼 재해복구 |
| `ariweb-agent` | `agent/index.mjs` | (없음, HAL 에 long-poll) | 각 관리 서버의 Node Agent: 일회용 등록 토큰으로 HAL 에 등록, heartbeat·지표(CPU·메모리·디스크·inode·I/O·네트워크·시계 차이), HAL 이 내린 작업(모듈 설치·갱신·되돌리기·건강 확인·사이트 배포·백업·복원·이전) 실행. 외부 고객 서버에서는 **External Pull Agent** 모드(사이트 배포만) |
| `ariweb-portal` | `portal/index.mjs` | 8910 | 고객 포털(`my.ariweb.net`): 회원·사용자 그룹·상품·주문·사이트·배포·도메인·메일·백업·복원·이전·소유권·사용량·알림·감사. Webmaker·AriMail 로 가는 통합 진입점 |
| `webmaker` | `modules/webmaker/server.js` | 3080 | 도입 모듈(WebHosting_AI_agent 1.7.3). 사이트 제작·AI·도메인·DNS·TLS·Gitea·배포. 포털 SSO 로 로그인 없이 진입 |
| `webmaker-setup` | `modules/webmaker/setup-server.js` | 8795 | Webmaker 초기 설정 페이지(기존 그대로) |
| `arimail` | `modules/arimail/server/index.mjs` | 8790 + 25/110/143/465/587/993/995 | 도입 모듈(AriMail 1.0.11). 웹메일·IMAP·POP3·SMTP·사용량 API |
| nginx | — | 80/443 | 모든 공개 진입. vhost 는 HAL/Agent 가 템플릿으로 생성 |
세 신규 프로세스(`hal`·`agent`·`portal`)는 같은 저장소의 `lib/` 를 공유하고 외부 npm 의존성이 없다(Node 22.13+, `node:sqlite`).
도입 모듈(`modules/*`)은 원본 저장소의 구조를 그대로 두고, 변경은 `IMPORTED_SOURCES.md` 에 파일 단위로 기록한다.
## 2. 저장소 레이아웃
```
lib/ 공통 라이브러리(ESM). db·http·session·status·jobs·quota·notify·audit·kst·redact·i18n
hal/ HAL 서버(api, ops 콘솔 UI, 릴리스·서버·작업·복구)
agent/ Node Agent(등록·heartbeat·작업 실행기·모듈 설치기·External Pull 모드)
portal/ 고객 포털(api, 화면, 도메인 로직) — portal/<영역>/*.mjs
modules/webmaker/ 도입: WebHosting_AI_agent @3b10859 (CommonJS)
modules/arimail/ 도입: AriMail @a07f69f (ESM)
catalog/ 출시 검증용 버전 고정 상품 Catalog(JSON) — 운영자가 HAL/포털 화면에서 관리하는 ProductVersion 의 시드
deploy/ 설치 스크립트·systemd 유닛·nginx 템플릿·부트스트랩(HAL 최초 설치)
scripts/ import-sources(도입 소스 복사·manifest), release(릴리스 artifact 생성·검사)
test/ 회귀 테스트(node:test 없이 lib/test.mjs 의 작은 러너). `npm test` 가 전부 실행
docs/ 이 문서, 운영 문서, 검증 기록
VERSION_HISTORY.md, IMPORTED_SOURCES.md, README.md
```
## 3. 공통 라이브러리(lib/) 계약
- `lib/db.mjs` — `openDb(file, migrations[])`: `node:sqlite` 를 열고(WAL, foreign_keys) `migrations` 배열(`{id, sql}`)을 순서대로 적용한다. 반환 `{ db, get(sql, ...p), all(sql, ...p), run(sql, ...p), tx(fn) }`. 모든 저장은 SQLite 이며 JSON 열은 `json_*` 접미사.
- `lib/http.mjs` — `createApp()` → `{ route(method, pattern, handler), static(prefix, dir), listen(port, host) }`. 핸들러 시그니처 `(ctx) => body|Response`. `ctx = { req, res, method, path, params, query, body(json), cookies, ip, user, set(status, headers), json(obj, status), html(str), redirect(url), error(status, code, message) }`. 오류는 `{ ok:false, error:'<code>', message }` JSON. 모든 응답 `Cache-Control: no-store`(정적 제외). 신뢰 프록시(nginx) 의 `X-Real-IP` 만 믿는다.
- `lib/session.mjs` — 서명 쿠키가 아니라 **DB 세션**(기기·IP·UA·발급·만료·마지막 사용, 개별/전체 종료 §3.2). `createSession(db, {userId, kind:'customer'|'operator', ...})`, `resolve(db, token)`, `revoke`, `revokeAll`. CSRF 는 세션에 묶인 HMAC 토큰(`X-CSRF` 헤더).
- `lib/status.mjs` — §2.2 사용자용 작업 상태 어휘와 전이 규칙. `STATUS = { NOT_READY:'준비 전', NEEDS_SETUP:'설정 필요', AWAITING_APPROVAL:'승인 대기', AWAITING_PAYMENT:'결제 대기', QUEUED:'처리 대기', RUNNING:'진행 중', AWAITING_EXTERNAL:'외부 작업 대기', VERIFYING:'검증 중', PAUSED:'일시 중지', DONE:'완료', FAILED:'실패', REVERTING:'되돌리는 중', REVERTED:'되돌림 완료', NEEDS_USER:'사용자 확인 필요' }`. 코드(영문 키)로 저장하고 화면에서 한국어로 바꾼다. 서비스 상태(`SERVICE_STATE`)·작업 상태(`STATUS`)·외부 건강 상태(`HEALTH`)는 **별도 필드**로 둔다(§2.2 "합치지 않는다").
- `lib/jobs.mjs` — 내구성 작업 엔진. 작업은 `jobs` 표에 `{id, kind, subject(json), steps(json: [{id,title,state,startedAt,endedAt,detail}]), status, attempt, maxAttempts, nextRunAt, lockedBy, lockedAt, result(json), error, idempotencyKey, createdBy, groupId}` 로 저장된다. 실행기는 `registerKind(kind, { run(job, ctx), revert?(job, ctx), steps:[...] })` 로 종류를 등록하고, `ctx.step(id, fn)` 으로 단계를 기록한다(단계마다 저장 → 프로세스 재시작 뒤 이어서 실행 §29). 같은 `idempotencyKey` 의 작업은 하나만 만든다(§36). 일시 오류는 `retryable` 표시로 지수 백오프 재시도(횟수·다음 시각 표시 §2.2). `ctx.waitExternal(reason)` 은 `AWAITING_EXTERNAL` 로 둔다.
- `lib/quota.mjs` — §13 보장량·예약. 자원 종류 `RESOURCE = sites | web_disk | web_out | mail_storage | mail_msgs | mailboxes | mail_services | source | backup`. 표 `entitlements`(그룹별 구매 총량, 상품정책 버전 참조), `allocations`(서비스별 배정량), `reservations`(진행 중 작업 예약: `{id, groupId, resource, amount, state: available|reserved|in_use|returning|returned, jobId, expiresAt}`). `reserve()` 는 단일 트랜잭션에서 `구매총량 - (배정 + 예약)` 을 검사해 **동시 요청 중 하나만 성공**(§13.3, 검증 27). `commit()`→in_use, `release()`→returned. `warnLevel(used, limit)` → 0|70|80|90|95|100.
- `lib/kst.mjs` — KST 월 경계(`monthStart(at)`, `nextMonthStart(at)`), 고정 시각 입력 가능(테스트).
- `lib/redact.mjs` — 비밀로 보이는 키·값 가리기. 감사 기록·알림·작업 결과·로그 **모두** 이 함수를 거친다(§37).
- `lib/notify.mjs` — 알림 센터(§24.1): 분류 `category`, 사건 묶음 `incidentKey`(같은 원인은 하나로 갱신), 필수 알림(`mandatory`), 외부 이메일 발송 훅(`mailer`, AriMail/Resend 는 포털 설정). 링크는 조치 화면으로.
- `lib/audit.mjs` — 감사 기록(§24.2): `{at, actorType: user|operator|system, actorId, groupId, subjectType, subjectId, action, outcome, detail(redacted), immutable}`. 권한·소유권·배포·해지·복원·이전·결제 승인은 `immutable=1`(삭제 불가).
- `lib/i18n.mjs` — 한국어 문구 사전(`lang/ko.json`). 화면 문구는 코드에 박지 않고 여기서 읽는다.
- `lib/test.mjs` — 작은 테스트 러너: `test(name, fn)`, `eq`, `ok`, `rejects`, 임시 DB 생성.
## 4. 데이터 모델(포털 SQLite, `portal/schema.mjs`)
핵심 표(열은 요지만, 상세는 스키마 파일):
- `users`(id, email, email_verified_at, display_name, pass_hash(scrypt), pass_salt, mfa_json, failed_logins, lock_until, created_at) · `email_tokens`(verify/reset, 만료·사용 완료) · `sessions`
- `groups`(id, name, kind: personal|team, billing_contact, tech_contact, owner_user_id) · `memberships`(group_id, user_id, role: owner|admin|member|billing|viewer, alias `user-01`, status) · `invites`(email, role, token_hash, expires_at, accepted_at)
- `site_permissions`(site_id, user_id, perms_json: view|edit|deploy|domain_mail|billing|admin)
- `product_versions`(id, product_id, version, name, audience, billing_cycle, price, currency, tax_rate, limits_json, options_json, warn_levels_json [70,80,90,95], approval: manual, effective_from, published) — 코드에 가격을 고정하지 않는다(§6.1, §27.2)
- `orders`(id, group_id, product_version_id, items_json, amount, tax, total, currency, status: awaiting_payment|awaiting_approval|approved|rejected|expired|cancelled, payment_ref, due_at, approved_by, idempotency_key) · `entitlements`(group_id, resource, amount, source_order_id, starts_at, ends_at)
- `sites`(id, group_id, name, purpose, webmaker_project_id, lifecycle: draft|building|deploying|live|paused|cancel_scheduled|readonly_retention|pending_delete|deleted, deploy_mode: managed|external|null, primary_domain, site_count_state: none|reserved|in_use|returning, server_id, current_version, health, settings_json)
- `deployments`(id, site_id, version_ref(저장본), mode, target(server/external agent), status(job), verify_state: verified|needs_domain|public, result_json)
- `domains`(id, group_id, name, purpose: web|mail|both, site_id, mail_service_id, ownership_state, dns_state, cert_state, mail_dns_state, last_checked_at, is_primary)
- `mail_services`(id, group_id, kind: site_bound|standalone, site_id, domain, arimail_server_id, status, allocations…) · `mailboxes`(mail_service_id, address, status, quota_bytes, …) — 실제 계정·메일 데이터는 AriMail 이 가지며 포털은 상태·배정만 둔다
- `backup_options`(site_id, product_version_id, schedule_json, retention_json, status, first_backup_due_at) · `backups`(id, site_id, created_at, scope_json, verify_state, expires_at, location) · `restores`(id, site_id, backup_id, status, cost, approval…)
- `relocations`(id, subject_type, subject_id, from_server, to_server, kind: auto_static|approved|customer_paid, status, boundaries_json)
- `ownership_transfers`(id, from_group, to_group, scope_json, status: requested|accepted|reviewing|approved|completed|rejected|expired|disputed, expires_at)
- `jobs`, `reservations`, `allocations`, `notifications`, `audit_log`, `usage_samples`(site/mail 별 측정값, measured_at)
## 5. HAL ↔ Node Agent 프로토콜(`docs/HAL-AGENT.md` 에 상세)
- 등록: 운영자가 HAL 에서 서버(역할·위치) 생성 → **일회용 등록 토큰**(10분, 1회) 발급 → 서버에서 `ariweb-agent register --hal https://hal.ariweb.net --token …` → Agent 가 키쌍 생성, 공개키 등록, HAL 이 Agent 자격(agent_id + 비밀, 응답에 1회) 발급 → 운영자 승인 후 배치 가능.
- 인증: 모든 Agent 요청은 `Authorization: Bearer <agent secret>` + 요청 서명(HMAC: method|path|ts|body-sha256). 시계 차이 ±5분.
- heartbeat: `POST /agent/v1/heartbeat` 30초마다 `{version, modules:{name:{version,active,health}}, metrics:{cpu,mem,disk,inode,io,net,clockSkewMs}, jobs:{running}}`.
- 작업: Agent 가 `GET /agent/v1/jobs/next?wait=25` 로 long-poll → HAL 이 `agent_jobs` 에서 배정 → Agent 실행 → `POST /agent/v1/jobs/<id>/progress|result`. Agent 재시작 시 미완료 작업은 재개 가능한 단계부터 다시 하거나 `NEEDS_USER` 로 보고한다.
- artifact: `GET /agent/v1/releases/<ver>/artifact` (tar.gz, sha256 검증). 모듈 설치 레이아웃 `/srv/ariweb/<module>/releases/<ver>` + `current` 심링크, 유닛 `ariweb-<module>.service`(도입 모듈은 기존 유닛 이름 `webmaker`·`arimail` 호환).
## 6. 포털 ↔ HAL
포털은 관리형 작업(사이트 배포·백업·복원·이전·사용량 수집)을 HAL API(`/portal/v1/*`, 서비스 키 + HMAC)로 요청하고 작업 ID 를 받는다. 포털 `jobs` 표의 작업이 HAL 작업을 참조(`hal_job_id`)하며 상태는 HAL 이 webhook(`/internal/hal-events`)으로 밀고, 포털도 주기 조회로 맞춘다(둘 중 빠른 쪽). HAL 이 응답하지 않으면 포털은 `정보 확인 지연` 을 표시하고 신규 증가를 허용하지 않는다(§13.4, §26.2).
## 7. 포털 ↔ Webmaker / AriMail
- **Webmaker SSO**(§0.2): 포털이 사용자별 Webmaker 계정(`aw-<groupId 앞 8>-<userId 앞 8>`)을 만들어 두고, "제작 화면 열기" 때 포털 서명 티켓(`/sso/webmaker?ticket=…`)으로 Webmaker 의 `/api/auth/sso` (신규, 포털 공유 비밀로 검증)에서 세션 쿠키를 받아 들어간다. Webmaker 의 `users.json` 비밀번호는 무작위이며 사용자는 모른다(별도 로그인 없음). 현재 사용자 그룹 이름을 Webmaker 상단에 표시한다(`X-Ariweb-Group` 배너).
- **Webmaker Project ↔ Site**: 포털 `sites.webmaker_project_id` ↔ Webmaker `data/sites/<id>.json`. Webmaker 프로젝트 삭제 API 에 포털 영향 확인 훅(`beforeDelete` → 포털 `/internal/webmaker/project-impact`)을 둔다.
- **AriMail**: 관리자 API(계정 CRUD)·사용량 API(`/api/usage`)·전체 설정 API(도메인 추가)를 포털 `portal/mail/arimail.mjs` 어댑터가 호출한다. 비밀 키는 포털 vault(`lib/vault.mjs`, AES-256-GCM, 데이터 디렉터리의 `vault.key`)에만 둔다.
## 8. 보안 규칙(§37)
1. 비밀값(비밀번호·토큰·API 키·개인키·복구 코드·메일 원문)은 소스·문서·로그·테스트·커밋·보고서에 쓰지 않는다. 시험용 값도 `example`·무작위 생성만.
2. 모든 기록 경로(감사·알림·작업 결과·오류 메시지)는 `redact()` 를 거친다.
3. 비밀값은 저장 후 다시 읽을 수 없고 재발급만 제공한다(일회용 표시 뒤 해시만 저장).
4. 그룹 경계: 모든 조회 SQL 은 `group_id` 조건을 포함한다. 검색·자동완성도 같다.
5. 운영자 콘솔은 별도 계정·별도 세션(`kind='operator'`)·제한 경로(`/ops`)·허용 네트워크(사설망/VPN CIDR 또는 기기 귀속 단기 접근권) 없이는 로그인 화면도 열리지 않는다(§25).
6. 고위험 작업은 최근 재인증(10분) + 3단계 확인(영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부)을 거친다.
## 9. 코딩 규약
- ESM(`.mjs`), 외부 의존성 없음, Node 22.13+.
- 모든 파일 머리에 모듈 목적 주석, 모든 함수·클래스에 목적·매개변수·반환·비자명한 논리 이유를 **한국어**로 적는다.
- 화면 문구는 `lang/ko.json`(포털·HAL) 에 둔다. 상태는 코드로 저장하고 표시 때 번역한다.
- 각 영역은 `portal/<영역>/{model,api,ui}.mjs` 로 나눈다. `api.mjs` 는 `register(app, deps)` 를 내보낸다.
- 테스트는 `test/<영역>.test.mjs`, `npm test` 로 전부 실행. 외부 네트워크·실서버에 닿는 테스트는 `ARIWEB_LIVE=1` 일 때만.
- 날짜·시각은 ISO 8601 UTC 로 저장하고 화면에서 KST 로 표시한다. 월간 사용량 경계는 KST.
## 10. 배포 레이아웃(관리 서버)
```
/srv/ariweb/
hal/ releases/<ver>/ current -> … data/(hal.sqlite, vault.key, artifacts/)
portal/ releases/<ver>/ current data/(portal.sqlite, vault.key)
agent/ releases/<ver>/ current data/(agent.json: id·공개키·HAL 주소; 비밀은 0600)
webmaker/ (도입 모듈) releases/<ver>/ current 데이터는 기존 /data/webmaker 유지
arimail/ (도입 모듈) releases/<ver>/ current 데이터는 기존 경로 유지
backups/ 관리형 사이트 백업 저장소(운영 서버와 다른 서버가 원칙)
systemd: ariweb-hal.service · ariweb-portal.service · ariweb-agent.service · webmaker.service · arimail(또는 기존 유닛명).service
nginx: /etc/nginx/sites-enabled/ariweb-hal.conf (hal.ariweb.net) · ariweb-portal.conf (my.ariweb.net) · wh-<siteId>.conf (사이트)
```
HAL 최초 설치(부트스트랩, §32.2)만 `deploy/bootstrap-hal.sh` 로 수행한다. 그 뒤 모든 모듈은 HAL 작업으로만 설치·갱신한다.
+69
View File
@@ -0,0 +1,69 @@
# HAL ↔ Node Agent 프로토콜·설치·운영 (v0.1.1)
Dev-Order §5(아키텍처)·§28·§29·§32·§33·§34 의 백엔드 구현. 코드: `hal/`, `agent/`, `deploy/`, `scripts/release.mjs`, `portal/hal/client.mjs`.
## 1. 구성
| 구성요소 | 위치 | 역할 |
|---|---|---|
| HAL | `/srv/ariweb/hal/current`, 127.0.0.1:8900, nginx `hal.ariweb.net` | 서버·Agent 등록부, 릴리스 등록부, 내구성 작업 엔진(lib/jobs), 모듈 배포, 포털 API, 운영자 API, 긴급 복구, 재해복구, 공개 상태 |
| Node Agent | `/srv/ariweb/agent/current`, 유닛 `ariweb-agent` | heartbeat(30초, 지표·모듈 버전·시계 차이), 작업 long-poll·실행(단계 저장·재개), 모듈 설치, 사이트 배포·백업·이전 |
| External Pull Agent | 고객 외부 서버, 같은 코드 `--external --site <id>` | `site.deploy / site.remove / site.check / site.rollback` 만, 사이트 하나에 귀속 |
## 2. 인증
- Agent·포털·HAL 간 모든 요청은 `lib/serviceauth` HMAC: `X-Service-Key-Id`(`agent:<id>` / `portal` / `hal`), `X-Service-Ts`(±5분), `X-Service-Sign`.
- Agent 비밀은 등록 응답에서 한 번만 전달되어 Agent 보관소(`data/vault.json`, 키 `agent-secret`)에, HAL 쪽은 `service:agent:<id>` 로 보관. 표에는 해시만.
- 큰 전송(`POST /agent/v1/transfers/:id`)은 빈 본문 서명 + `X-Transfer-Sha256` 헤더, HAL 이 받은 뒤 해시를 다시 계산해 비교.
## 3. Agent 프로토콜(`/agent/v1`)
| 경로 | 설명 |
|---|---|
| `GET install.sh` | 설치 스크립트(deploy/agent-install.sh) |
| `GET bootstrap-artifact?module=agent&version=` + `X-Registration-Token` | 등록 전 Agent 코드 내려받기(유효 토큰 확인, 소비하지 않음) |
| `POST register {token, facts, version}` | 일회용 토큰(10분) → `{agentId, secret(1회), serverId, kind, siteId}` |
| `POST heartbeat {version, metrics, modules, clockSkewMs, facts, sitesHosted}` | 지표 저장(90일+), 서버 last_seen·용량·모듈 실제 버전 갱신. 90초 무응답 → offline |
| `GET jobs/next?wait=25` | long-poll. `{job:{id, kind, payload, halJobId}}` 또는 null |
| `GET jobs/unfinished` | 재시작 뒤 이어서 할 running 작업 목록 |
| `POST jobs/:id/progress {progress, steps, log}` → `{cancelRequested}` / `POST jobs/:id/result {status: done\|failed\|needs_user, result, error, errorCode, userMessage}` | |
| `GET releases/:version/artifact?module=` | tar.gz 스트리밍, `X-Sha256` |
| `POST/GET transfers/:id` | HAL 경유 파일 전송(이전·원격 백업), 24시간 보관 |
## 4. 포털 API(`/portal/v1`, 키 `portal`) — docs/HAL-JOBS.md
부트스트랩이 끝나기 전에는 `health` 외 503 `bootstrap_incomplete`. 작업 상태 변화는 outbox 를 통해 포털 `POST /internal/hal-events` 로 밀린다(실패 시 지수 백오프, 7일 보관).
## 5. 작업 모델
- HAL 작업(lib/jobs) 한 개가 Agent 작업(agent_jobs) 하나 이상을 배정하고 결과를 기다린다(`core.awaitAgentJob`: 진행률 반영, 취소 전달, Agent 10분 무응답 → `사용자 확인 필요`).
- 복합 작업: `backup.create`(생성 → 저장 서버로 복사 → 검증), `backup.restore`(가져오기 → 보호 시점 → 격리 확인 → 복원 → 외부 확인, 되돌리기 지원), `site.relocate`(precheck → prepare → precopy → verify_copy → final_sync → verify_new → switch → external_check → cleanup; `verify_new` 실패 시 전환하지 않음, 되돌리기 지원).
- 운영자 작업: `module.deploy`/`module.rollback`/`module.health`/`module.reconcile` 은 서버마다 자식 작업(`module.deploy.target`)을 만들고 `childrenSummary` 로 대상별 성공·실패·대기를 보인다. 일부 실패는 `partial_failure` 로 끝난다.
- 배포 순서(§32.2): 릴리스 승인 → HAL 자체 건강 → 부트스트랩 완료 → `hal` 모듈이 그 릴리스로 실행 중일 때만 다른 모듈 배포(`hal_first`).
## 6. 설치(부트스트랩)와 첫 Agent
```
# 작업 PC: main 에 병합된 커밋에서
node scripts/release.mjs build # dist/0.1.1/*.tar.gz + manifest.json (테스트 통과 필요)
scp -r dist/0.1.1 hostinger:/tmp/ariweb-dist
# 대상 서버(ariweb.net 호스트, master, sudo):
bash /tmp/ariweb-dist/… (artifact 안의 deploy/bootstrap-hal.sh) --dist /tmp/ariweb-dist --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net
```
부트스트랩은 HAL 설치·보관소·유닛·nginx·릴리스 등록(승인)·운영자 설정 토큰까지만 한다. 이어서 운영자 계정 생성 → 서버 생성·등록 토큰 → `agent-install.sh` → 서버 승인. 첫 Agent 승인 시 `bootstrap_state.completed_at` 이 기록되고 수동 설치가 잠긴다. 그 뒤 `portal`·`webmaker`·`arimail` 은 운영자 API `POST /ops/api/modules/<name>/deploy {version, serverIds}` 로 배포한다.
도입 모듈 배치: Agent facts 가 서버의 기존 유닛(`webmaker`, `arihakdang-mail`/`arimail`/`heart-mail`)과 WorkingDirectory 를 읽어 그 appDir(`/srv/webmaker`, `/srv/arihakdang-mail` …)의 `releases/<ver>` 에 풀고 `current` 를 바꾼다. Webmaker 는 원본 `remote-update.sh` 순서(데이터 백업 → 교체 → 재시작 → unit-check), AriMail 은 원본 `deploy.sh` 순서(DB 백업 → 교체 → db-migrate → 재시작 → health → haraka 재시작)를 Agent 가 수행한다.
## 7. 운영자 접근(§25)
`/ops/*` 는 허용 CIDR(`config.opsAllowCidrs`) 안이거나 접근권 쿠키(`aw_ops_grant`, 사용자·기기 귀속, 최대 72시간)가 있어야 하며 아니면 로그인 화면도 403. 접근권: `node hal/index.mjs grant --login <id> --hours 8` 또는 허용 네트워크 안의 운영자가 `POST /ops/api/access-grants`. 고위험 작업은 재인증(10분)과 3단계 확인(`confirmImpact`, `confirmIdentifiers`, `confirmExecute`).
## 8. 긴급 복구(§33)·재해복구(§34)
- `POST /ops/api/emergency` 선언(HAL 정상이고 열린 critical 사건이 없으면 거부) → `approve` 1·2·3단계(서로 다른 입력) → `credential`(최대 60분, 1회 표시) → `perform`(허용 범위·허용 행동만; 일반 배포 거부) → `verify`(자격 폐기) → `reconcile`(목표·실제 비교) → `close`.
- DR: `PUT /ops/api/dr/config`(RTO·RPO·주기·보존·외부 보관), 사본 `POST /ops/api/dr/snapshots`(VACUUM INTO + vault.json + config.json, vault.key 제외), 복원 요청→승인→시작(기존 제어 중지 확인 문구)→`deploy/hal-restore.sh`→완료 확인(운영자 로그인·HAL 제어·HTTPS·고객 서비스), 훈련 기록과 측정 RTO/RPO. `readiness.ready` 가 true 여야 정식 운영 준비 완료.
- 하나의 HAL: 기동 때 `bootstrap_state.live_instance_id/live_lease_at` 임대(20초 갱신, 60초 유효). 다른 인스턴스의 임대가 살아 있으면 기동 거부(`--takeover` 는 복원 절차에서만).
## 9. 테스트
`node --no-warnings test/hal.test.mjs`, `node --no-warnings test/agent.test.mjs`. Agent 테스트는 `ARIWEB_AGENT_STUB_CMDS=1` 과 임시 루트로 실제 시스템을 건드리지 않는다.
+41
View File
@@ -0,0 +1,41 @@
# 포털 ↔ HAL 작업 계약(HAL-JOBS)
포털 어댑터 `portal/hal/client.mjs`(deps.hal) 와 HAL `/portal/v1/*` 가 공유하는 계약. 인증은 lib/serviceauth(키 id `portal`).
## deps.hal (포털 쪽 인터페이스)
```js
hal.createJob({ kind, target, payload, idempotencyKey, groupId, subjectType, subjectId }) → { halJobId, status }
hal.getJob(halJobId) → { id, kind, status(lib/status JOB_STATUS 코드), steps:[{id,title,state,detail}], progress, result, error, errorCode, updatedAt, children? }
hal.cancelJob(halJobId) / hal.retryJob(halJobId) / hal.revertJob(halJobId) / hal.resumeJob(halJobId)
hal.listServers({ role }) → [{ id, name, role:'web'|'mail'|'backup'|'control', location, status:'ready'|'maintenance'|'isolated'|'offline', acceptsNew, health, lastSeenAt, capacity:{ diskFreeBytes, diskTotalBytes, memFreeBytes, cpuLoad, sitesHosted, reservedBytes }, version:{ agent, modules:{} } }]
hal.pickServer({ role:'web', needBytes }) → { serverId } | null // 신규 배치 허용·여유 있는 서버
hal.issueExternalAgentToken({ groupId, siteId, label }) → { agentId, token(1회), expiresAt, installCommand }
hal.getExternalAgent(agentId) → { id, status:'pending'|'connected'|'offline'|'revoked', lastSeenAt, version, lastDeploy:{...} }
hal.revokeExternalAgent(agentId)
hal.health() → { ok, version, uptime } // 실패 시 code 'service_unreachable'
```
HAL 이 응답하지 않으면 어댑터는 `service_unreachable` 을 던지고 호출자는 `정보 확인 지연` 을 표시하며 신규 증가를 허용하지 않는다.
HAL 은 작업 상태 변화를 포털 `POST /internal/hal-events` (키 id `hal`) 로 민다: `{ halJobId, status, steps, result, error, updatedAt }`. 포털은 5초 주기 조회로도 맞춘다.
## 작업 종류와 payload (target = 관리 서버 id 또는 외부 Agent id)
| kind | payload | result |
|---|---|---|
| `site.deploy` | `{ siteId, versionRef, bundleUrl(포털이 Webmaker 저장본을 tar.gz 로 내놓는 서명 URL) 또는 bundleFromWebmaker:{projectId, versionRef}, runtime:'static'|'node', env:{}, domains:[...], primaryDomain, previousVersion, external:bool }` | `{ version, deployedAt, internalCheck:{ok,status,ms}, externalCheck:{ok,url,status}, siteUrl, leftovers:[] }` |
| `site.rollback` | `{ siteId, toVersion }` | `{ version }` |
| `site.remove` | `{ siteId, keepData:bool }` | `{ removed:true, leftovers:[] }` |
| `site.check` | `{ siteId, urls:[...], expectMarker }` | `{ checks:[{url,status,ok,ms,tls:{valid,expiresAt}}] }` |
| `site.usage` | `{ siteIds:[...] }` | `{ sites:{ [siteId]: { diskBytes, dbBytes, uploadsBytes, outBytesDay, outBytesMonth, outBytesTotal, inBytesDay, inBytesMonth, inBytesTotal, req24h, pv24h, series7d:[...], series30d:[...], errorRate, p95ms, measuredAt } } }` |
| `domain.vhost` | `{ siteId, domains:[...], primaryDomain, redirectOld:bool, maintenance:bool }` | `{ applied:[...], certs:{domain:{state,expiresAt}} }` |
| `domain.cert` | `{ siteId, domains:[...], email }` | `{ issued:[...], failed:[{domain,reason}], expiresAt }` |
| `domain.probe` | `{ name, expectA:[...], expectTxt:{name,value}? }` | `{ a:[...], txtOk, resolvedAt }` |
| `backup.create` | `{ siteId, scope:{files,db,uploads,config}, storeServerId, retentionTag }` | `{ backupId, location, sizeBytes, checksum, verified:true, createdAt }` |
| `backup.verify` | `{ location, checksum }` | `{ ok }` |
| `backup.restore` | `{ siteId, location, checksum, protectPoint:true, isolatedVerify:true }` | `{ protectPointLocation, restoredVersion, externalCheck }` |
| `backup.delete` | `{ location }` | `{ deleted }` |
| `site.relocate` | `{ siteId, fromServerId, toServerId, kind:'static'|'dynamic', writeFreeze:bool, domains, primaryDomain }` | 단계: precheck → prepare → precopy → verify_copy → final_sync → verify_new → switch → external_check → cleanup → done. result `{ newServerId, switchedAt, oldRetainedUntil }` |
| `module.deploy` / `module.update` / `module.rollback` / `module.health` | HAL 내부(운영자 콘솔) | 대상별 자식 작업 |
External Pull Agent(외부 서버)는 `site.deploy`·`site.remove`·`site.check` 만 받는다.
+44
View File
@@ -0,0 +1,44 @@
# 0.1.1 배포·검증 절차(RUNBOOK)
대상: ariweb.net 호스트(`ssh hostinger`, 187.53.133.173, master 계정, sudo, Node 22.23, nginx, 와일드카드 인증서 `*.ariweb.net`).
원칙(§32): HAL 먼저, 그 뒤 모든 모듈은 HAL 작업으로만. 개별 서버에 임의 접속해 일반 배포하지 않는다.
## 0. 사전 조건(운영자 조치)
- DNS(hosting.co.kr 패널): `hal.ariweb.net A 187.53.133.173`, `my.ariweb.net A 187.53.133.173`. 레코드 전에는 `curl --resolve` 로만 확인된다.
- 검증용 메일 수신 주소(자동 응답 메일함) — 메일 활성화의 실제 왕복 확인에 필요(`PUT /internal/ops/mail/policy {verifier}`).
## 1. 릴리스 artifact(개발 PC, main 병합 뒤)
```bash
git checkout main && git pull
node scripts/release.mjs build # 전체 테스트 → dist/0.1.1/{hal,portal,agent,webmaker,arimail}-0.1.1.tar.gz + manifest.json
scp -r dist/0.1.1 hostinger:/tmp/ariweb-dist-0.1.1
```
## 2. HAL 부트스트랩(최초 1회, 서버에서)
```bash
ssh hostinger
tar -xzf /tmp/ariweb-dist-0.1.1/hal-0.1.1.tar.gz -C /tmp/ariweb-hal-src # deploy/ 스크립트를 쓰기 위해 풀어 둔다
bash /tmp/ariweb-hal-src/deploy/bootstrap-hal.sh --dist /tmp/ariweb-dist-0.1.1 --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net
```
출력된 **운영자 설정 토큰(1회)** 으로 첫 운영자를 만든다: `POST https://hal.ariweb.net/ops/api/setup {setupToken, login, password}` (허용 네트워크 밖이면 `node hal/index.mjs grant --login <id>` 로 접근권 URL 발급).
확인: `curl -s https://hal.ariweb.net/api/health` 의 `version`·`db`·`jobs`·`bootstrap.halRunningVersion`.
## 3. 첫 Node Agent(같은 서버, 역할 control+web)
운영자 콘솔 › 서버 › 새 서버(역할 control, 위치 ariweb.net) → 등록 토큰(10분·1회) →
```bash
curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https://hal.ariweb.net --token <토큰> --role control
```
운영자 승인 → `bootstrap.complete=true`, 수동 설치 잠김. 포털 연동 키: `node hal/index.mjs link-portal` (출력 값은 포털 보관소에 1회 입력).
## 4. 모듈 배포(HAL 작업)
운영자 콘솔 › 배포 릴리스 › 0.1.1 승인 확인 → 모듈 배포 순서: `portal` → `webmaker` → `arimail` → (`agent` 갱신). 각 작업의 대상별 결과·실제 버전·건강 상태를 확인한다. HAL 자체 건강이 실패하면 나머지를 배포하지 않는다.
- portal: `/srv/ariweb/portal`, 유닛 `ariweb-portal`, nginx `my.ariweb.net`(와일드카드 인증서). 포털 보관소에 `service:hal`(link-portal 출력), `sso:webmaker`, `arimail:*` 키 입력.
- webmaker: 기존 `/srv/webmaker` + `/data/webmaker` 유지(HAL 모듈 설명자가 기존 유닛명·데이터 경로를 발견해 보존), 설정 `ariweb.portalUrl`·`ariweb.secret`.
- arimail: 기존 `/srv/arihakdang-mail` 유지, 마이그레이션 `0009_ariweb_queue.sql` 적용, 테넌트 `ariweb.net` 에 `ARIWEB_SSO_SECRET`.
## 5. 검증(Release 0.1.1 완료 조건 34개)
`docs/verification/` 의 로컬 E2E 결과와 실제 환경 확인 기록을 함께 둔다. 각 흐름은 버튼 성공이 아니라 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정한다. 충족하지 못한 흐름은 완료로 표시하지 않는다.
## 6. 되돌리기
- 모듈: 운영자 콘솔 › 모듈 › 되돌리기(이전 릴리스 디렉터리 유지, 3개 보관).
- HAL: `deploy/hal-restore.sh` + 재해복구 절차(§34, 기존 제어 위치 중지 확인 뒤 `--takeover`).
+38
View File
@@ -0,0 +1,38 @@
# 검증 기록(docs/verification)
이 디렉터리에는 Ariweb Suite 의 **로컬 종단 간 검증 하네스**(`test/e2e/`)가 만든 기록이 쌓인다. 개발 지시 "Release 0.1.1 완료 조건" 34개 흐름을
실제 HAL·고객 포털·Node Agent 프로세스로 걸어 보고, 흐름마다 다음 판정과 증거(HTTP 상태·최종 화면 상태·작업 단계)를 남긴다.
| 판정 | 뜻 |
|---|---|
| 로컬 확인(verified_local) | 로컬 환경에서 끝까지 확인됨 |
| 부분 확인(partial) | 일부 단계만 확인됨(나머지는 실 인프라가 필요하거나 선행 흐름의 결함으로 막힘) |
| 로컬 확인 불가(not_verifiable_locally) | 실 DNS·메일 왕복·공인 서버 등이 필요 |
| 실패(결함)(failed) | 실제 결함으로 보이는 실패 — 보고서의 "발견한 결함" 에 재현 정보 |
**이 기록은 출시 판정이 아니다.** 개발 지시는 각 흐름을 실제 배포 환경에서 최종 화면·외부 접속·실제 메일 왕복·실제 사용량·실제 배포 버전으로 판정하도록 요구한다.
로컬 하네스는 결함을 빨리 찾고, 실서버 검증 전에 어떤 흐름이 어디까지 동작하는지 드러내는 용도다.
## 실행
```bash
node --no-warnings test/e2e.test.mjs # 전체(5분 안에 끝남). docs/verification/local-e2e-<날짜>.md/.json 생성
ARIWEB_E2E_FLOWS=30-sites,80-hal node --no-warnings test/e2e.test.mjs # 흐름 그룹 일부만
ARIWEB_E2E_STRICT=1 node --no-warnings test/e2e.test.mjs # failed 판정이 있으면 종료 코드 1
ARIWEB_E2E_KEEP=1 … # 임시 디렉터리(test/.tmp/e2e-*) 보존
ARIWEB_E2E=0 … # 건너뜀(test/run.mjs 에서 제외하고 싶을 때)
```
## 하네스 구성(test/e2e/)
- `boot.mjs` — 빈 포트·임시 데이터 디렉터리로 가짜 서비스 → HAL(설정 토큰·릴리스 등록·부트스트랩 → 서버 실행 → 운영자 최초 설정·로그인) → Node Agent 4대(control/web/backup/web, `ARIWEB_AGENT_STUB_CMDS=1` 로 systemctl/nginx/certbot 은 기록만, tar 와 파일 배치는 실제) → 고객 포털(Catalog 자동 시드, AriMail 서버 등록) 순으로 띄운다.
- `fake-mail.mjs` — Resend 호환 발신 API 포획(가입 확인·초대·필수 알림 메일에서 토큰·링크를 꺼낸다).
- `fake-webmaker.mjs` — Webmaker 의 포털 연동 면(`/api/internal/ariweb/*` 서명 검증, 프로젝트·저장본·export tar.gz, `/api/auth/sso` 1회용 티켓).
- `fake-arimail.mjs` — AriMail 의 설정·관리자·사용량 API(도메인별 보장량·경고 구간·제한 사유 계산). SMTP/IMAP 은 없다.
- `flows/*.mjs` — 흐름 그룹(번호순 실행). 각 단계는 `steps().run()` 으로 증거를 남기며 결함이 보이면 `report.bug()` 로 재현 정보를 적는다. 결함을 우회하지 않고, 막힌 뒤 흐름은 `partial`(선행 흐름 번호) 로 적는다.
- `report.mjs` — 흐름·결함·실 인프라 필요 항목을 Markdown/JSON 으로 쓴다. 비밀값은 lib/redact 로 가린다.
## 로컬에서 확인할 수 없는 것(실서버·실 DNS 필요)
도메인 소유 확인(TXT)·DNS 반영·HTTPS 발급·외부 접속 확인, 메일 DNS 와 외부 송신·수신·회신 왕복, 실제 서버 간 이전·백업 저장소, 모듈 설치(/srv/ariweb 권한), 재해복구의 실제 다른 호스트 복원.
이 항목들은 하네스가 `not_verifiable_locally` 또는 `partial` 로 적고 "실제 인프라가 필요한 항목" 절에 모은다.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
/**
* hal/app.mjs — HAL HTTP 라우트.
*
* /api/health 공개 자체 건강(버전·DB·작업 엔진·부트스트랩 상태). 비밀 없음.
* /status, /api/status 공개 상태 페이지 데이터(§31.3): 구성요소 상태·계획 유지보수·공개 사건. 내부 서버 이름·개인정보 없음.
* /agent/v1/* Node Agent 프로토콜(§5). install.sh·register 외에는 Agent HMAC 인증.
* /portal/v1/* 포털 전용 API(docs/HAL-JOBS.md). 서비스 키 'portal'. 부트스트랩 미완료면 503.
* /ops/api/* 운영자 API(§25): 네트워크 관문 → 운영자 세션. 서버·릴리스·모듈 배포·작업·사건·긴급 복구·재해복구·계정. 화면(UI)은 별도 영역(hal/ui, 후속)에서 같은 API 를 쓴다.
*/
import { createApp, HttpError, cookie, esc } from '../lib/http.mjs';
import { verifyCtx } from '../lib/serviceauth.mjs';
import * as session from '../lib/session.mjs';
import { describe, JobError } from '../lib/jobs.mjs';
import { listAudit } from '../lib/audit.mjs';
import { listNotifications } from '../lib/notify.mjs';
import { redact } from '../lib/redact.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { newId, sha256 } from '../lib/crypto.mjs';
import { createHash } from 'node:crypto';
import { createReadStream, createWriteStream, statSync, readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as core from './core.mjs';
import * as ops from './ops.mjs';
import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const bad = (e) => { if (e instanceof HttpError) throw e; if (e instanceof JobError || (e && e.code)) throw new HttpError(e.status || (e.code === 'quota_insufficient' ? 409 : 400), e.code, e.message, e.detail); throw e; };
const wrap = (fn) => async (ctx) => { try { return await fn(ctx); } catch (e) { bad(e); } };
export function buildApp(d) {
const app = createApp({ log: d.log });
const uiDir = join(ROOT, 'hal', 'ui', 'public'); if (existsSync(uiDir)) app.static('/ops/ui', uiDir);
// ---------------- 공개
app.get('/api/health', () => core.selfHealth(d));
app.get('/api/status', () => publicStatus(d));
app.get('/status', (ctx) => ctx.html(statusPage(d)));
app.get('/agent/v1/install.sh', (ctx) => { const f = join(ROOT, 'deploy', 'agent-install.sh'); return ctx.text(existsSync(f) ? readFileSync(f, 'utf8') : 'echo "install script missing"; exit 1'); });
// ---------------- Agent 프로토콜
app.post('/agent/v1/register', wrap(async (ctx) => { const b = await ctx.body(); const r = core.registerAgent(d, { token: b.token, facts: b.facts || {}, version: b.version || '' }); return { ok: true, ...r, halUrl: d.config.publicUrl, serverTime: nowIso() }; }));
/** Agent 인증 미들웨어: /agent/v1/* (register·install.sh 제외) */
const agentAuth = async (ctx) => {
const streaming = ctx.method === 'POST' && /^\/agent\/v1\/transfers\//.test(ctx.path);
const r = streaming ? verifyStreaming(ctx) : await verifyCtx(ctx, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null);
if (!r.ok) throw new HttpError(401, 'agent_auth_failed', `Agent 인증 실패(${r.reason})`);
const agent = core.getAgent(d, r.keyId.slice(6)); if (!agent || agent.status !== 'active') throw new HttpError(401, 'agent_revoked', 'Agent 가 해지되었습니다');
ctx.agent = agent; ctx.outHeaders['X-HAL-Time'] = String(Date.now());
};
function verifyStreaming(ctx) { const { verifyRequest } = d.serviceauth; return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: '' }, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null); }
/** 등록 전 Agent 코드 내려받기: 아직 서명을 만들 수 없으므로 유효한(미사용·미만료) 등록 토큰을 헤더로 확인한다. 토큰은 소비하지 않는다(등록 때 1회 소비). */
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact'].includes(ctx.path)) await agentAuth(ctx); });
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
app.post('/agent/v1/jobs/:id/result', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobResult(d, ctx.params.id, ctx.agent.id, b); if (!ok) throw new HttpError(409, 'job_not_running', '실행 중인 작업이 아닙니다'); return { ok }; }));
app.get('/agent/v1/releases/:version/artifact', wrap((ctx) => { const a = core.artifactPath(d, ctx.params.version, ctx.query.module); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
app.post('/agent/v1/transfers/:id', wrap((ctx) => new Promise((resolve, reject) => {
const id = ctx.params.id; if (!/^tr_[A-Za-z0-9_.-]{2,80}$/.test(id)) return reject(new HttpError(400, 'bad_transfer_id', '전송 id 형식이 올바르지 않습니다'));
const expected = String(ctx.headers['x-transfer-sha256'] || ''); const path = join(core.transferDir(d), id + '.bin'); const hash = createHash('sha256'); let size = 0; const limit = 20 * 1024 ** 3;
const out = createWriteStream(path, { mode: 0o600 }); ctx.req.on('data', (c) => { size += c.length; hash.update(c); if (size > limit) ctx.req.destroy(new Error('too large')); }); ctx.req.on('error', (e) => { out.destroy(); reject(new HttpError(413, 'transfer_too_large', e.message)); });
ctx.req.pipe(out); out.on('finish', () => { const sum = hash.digest('hex'); if (expected && expected !== sum) return reject(new HttpError(400, 'checksum_mismatch', '전송 파일 해시가 다릅니다')); core.createTransfer(d, { id, agentId: ctx.agent.id, sha256: sum, size, purpose: String(ctx.query.purpose || '') }); resolve({ ok: true, id, sha256: sum, size }); });
})));
app.get('/agent/v1/transfers/:id', wrap((ctx) => { const t = core.getTransfer(d, ctx.params.id); if (!t) throw new HttpError(404, 'transfer_not_found', '전송 파일이 없거나 만료되었습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': statSync(t.path).size, 'X-Sha256': t.sha256, 'Cache-Control': 'no-store' }); createReadStream(t.path).pipe(ctx.res); }));
// ---------------- 포털 API
app.use(async (ctx) => { if (!ctx.path.startsWith('/portal/v1/')) return; const r = await verifyCtx(ctx, (k) => k === 'portal' ? d.vault.get('service:portal') : null); if (!r.ok) throw new HttpError(401, 'service_auth_failed', `서비스 인증 실패(${r.reason})`); if (ctx.path !== '/portal/v1/health' && !core.bootstrapComplete(d)) throw new HttpError(503, 'bootstrap_incomplete', 'HAL 부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다'); });
app.get('/portal/v1/health', () => ({ ok: true, ...core.selfHealth(d) }));
app.post('/portal/v1/jobs', wrap(async (ctx) => { const b = await ctx.body(); if (!d.jobs.kinds().includes(b.kind) || b.kind.startsWith('module.')) throw new HttpError(400, 'bad_kind', `포털이 요청할 수 없는 작업 종류: ${b.kind}`); const { job, created } = d.jobs.enqueue({ kind: b.kind, subject: { target: b.target, payload: b.payload || {}, _portal: true }, idempotencyKey: b.idempotencyKey || null, createdBy: 'portal', actorType: 'system', groupId: b.groupId || null, subjectType: b.subjectType || null, subjectId: b.subjectId || null, label: b.label || null }); return { ok: true, halJobId: job.id, status: job.status, created }; }));
const jobView = (j) => { if (!j) throw new HttpError(404, 'job_not_found', '작업이 없습니다'); const dsc = describe(j); return { ...dsc, result: j.result, children: j.kind.startsWith('module.') ? d.jobs.childrenSummary(j.id) : undefined }; };
app.get('/portal/v1/jobs/:id', wrap((ctx) => ({ ok: true, ...jobView(d.jobs.get(ctx.params.id)) })));
for (const act of ['cancel', 'retry', 'resume', 'revert']) app.post(`/portal/v1/jobs/:id/${act}`, wrap(async (ctx) => { const j = await d.jobs[act](ctx.params.id, 'portal'); return { ok: true, ...jobView(j) }; }));
app.get('/portal/v1/servers', wrap((ctx) => ({ ok: true, servers: core.listServers(d, { role: ctx.query.role || null }).map(stripFacts) })));
app.post('/portal/v1/servers/pick', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, pick: core.pickServer(d, { role: b.role || 'web', needBytes: Number(b.needBytes || 0) }) }; }));
app.post('/portal/v1/external-agents/token', wrap(async (ctx) => { const b = await ctx.body(); if (!b.siteId || !b.groupId) throw new HttpError(400, 'site_required', 'siteId·groupId 가 필요합니다'); const t = core.issueRegistrationToken(d, { kind: 'external', groupId: b.groupId, siteId: b.siteId, label: b.label || '', by: null }); return { ok: true, agentId: null, token: t.token, expiresAt: t.expiresAt, installCommand: t.installCommand }; }));
app.get('/portal/v1/external-agents/by-site/:siteId', wrap((ctx) => { const a = d.store.get('SELECT id FROM agents WHERE kind=\'external\' AND site_id=? AND status=\'active\' ORDER BY registered_at DESC LIMIT 1', ctx.params.siteId); return { ok: true, agent: a ? core.externalAgentStatus(d, a.id) : null }; }));
app.get('/portal/v1/external-agents/:id', wrap((ctx) => { const s = core.externalAgentStatus(d, ctx.params.id); if (!s) throw new HttpError(404, 'agent_not_found', '외부 Agent 가 없습니다'); return { ok: true, agent: s }; }));
app.post('/portal/v1/external-agents/:id/revoke', wrap((ctx) => { core.revokeAgent(d, ctx.params.id, 'portal'); return { ok: true }; }));
// ---------------- 운영자 API(§25)
app.use(async (ctx) => {
if (!ctx.path.startsWith('/ops')) return;
const gate = ops.networkGate(d, ctx);
if (ctx.path === '/ops/access') { const code = ctx.query.code; try { const g = ops.bindGrant(d, code, { ua: ctx.headers['user-agent'], ip: ctx.ip }); ctx.setCookie(ops.grantCookie(g.cookie, ctx.secure)); return ctx.redirect('/ops/'); } catch (e) { throw new HttpError(403, 'access_denied', '접근할 수 없습니다'); } }
if (!gate.ok) throw new HttpError(403, 'access_denied', '접근할 수 없습니다'); // 로그인 화면도 주지 않는다(§25)
ctx.opsVia = gate.via;
const tok = ctx.cookies[session.cookieName('operator')]; ctx.session = session.resolveSession(d.store, tok, 'operator'); ctx.operator = ctx.session ? ops.publicAccount(d.store.get('SELECT * FROM ops_accounts WHERE id=?', ctx.session.user_id)) : null;
if (ctx.path.startsWith('/ops/api/') && !['/ops/api/login', '/ops/api/setup', '/ops/api/me'].includes(ctx.path)) { if (!ctx.operator) throw new HttpError(401, 'unauthenticated', '운영자 로그인이 필요합니다'); if (!['GET', 'HEAD'].includes(ctx.method) && !session.csrfOk(ctx.session, ctx.headers['x-csrf'])) throw new HttpError(403, 'csrf', '보안 토큰이 맞지 않습니다'); }
});
const requireRecent = (ctx) => { if (!session.recentlyAuthed(ctx.session)) throw new HttpError(403, 'reauth_required', '고위험 작업은 최근 10분 안의 재인증이 필요합니다', { action: 'reauth' }); };
/** 고위험 작업 3단계 확인(§25): 영향 범위 확인 → 대상 식별정보 재입력 → 최종 실행 */
const threeStep = (b, expectIds) => { if (b.confirmImpact !== true) throw new HttpError(400, 'confirm_impact_required', '1단계: 영향 범위를 확인해야 합니다'); const ids = [].concat(b.confirmIdentifiers || []).sort(); if (JSON.stringify(ids) !== JSON.stringify([].concat(expectIds).sort())) throw new HttpError(400, 'identifiers_mismatch', '2단계: 대상 식별정보가 다릅니다'); if (b.confirmExecute !== true) throw new HttpError(400, 'confirm_execute_required', '3단계: 최종 실행을 확인해야 합니다'); };
app.post('/ops/api/setup', wrap(async (ctx) => { const b = await ctx.body(); const st = core.bootstrapState(d); const tokFile = join(d.config.dataDir, 'setup.token'); const expected = existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : ''; if (core.ensureOpsAccountExists(d) || !expected || b.setupToken !== expected) throw new HttpError(403, 'setup_closed', '최초 설정은 설정 토큰으로 한 번만 할 수 있습니다'); const a = ops.createAccount(d, { login: b.login, password: b.password, displayName: b.displayName, by: null }); try { (await import('node:fs')).unlinkSync(tokFile); } catch (_) {} return { ok: true, account: a, bootstrap: st }; }));
app.post('/ops/api/login', wrap(async (ctx) => { const b = await ctx.body(); const r = ops.login(d, { login: b.login, password: b.password, totp: b.totp, ip: ctx.ip, ua: ctx.headers['user-agent'] }); if (r.mfaRequired) return { ok: true, mfaRequired: true, message: r.message }; ctx.setCookie(cookie(session.cookieName('operator'), r.session.token, { maxAge: 86400, path: '/ops', secure: ctx.secure })); return { ok: true, account: r.account, csrf: r.session.csrf, via: ctx.opsVia }; }));
app.get('/ops/api/me', (ctx) => ({ ok: true, operator: ctx.operator, csrf: ctx.session?.csrf || null, via: ctx.opsVia, recentlyAuthed: session.recentlyAuthed(ctx.session), bootstrap: core.selfHealth(d).bootstrap, setupNeeded: !core.ensureOpsAccountExists(d) }));
app.post('/ops/api/logout', (ctx) => { session.revokeSession(d.store, ctx.session.id, ctx.session.user_id); ctx.setCookie(cookie(session.cookieName('operator'), '', { path: '/ops' })); return { ok: true }; });
app.post('/ops/api/reauth', wrap(async (ctx) => { const b = await ctx.body(); ops.reauth(d, ctx.operator.id, b.password, ctx.session.id); return { ok: true }; }));
app.post('/ops/api/password', wrap(async (ctx) => { const b = await ctx.body(); ops.changePassword(d, ctx.operator.id, b.current, b.next, ctx.session.id); return { ok: true }; }));
app.post('/ops/api/mfa/begin', wrap((ctx) => ({ ok: true, ...ops.mfaBegin(d, ctx.operator.id) })));
app.post('/ops/api/mfa/confirm', wrap(async (ctx) => ({ ok: true, ...ops.mfaConfirm(d, ctx.operator.id, (await ctx.body()).code) })));
app.post('/ops/api/mfa/disable', wrap(async (ctx) => { requireRecent(ctx); ops.mfaDisable(d, ctx.operator.id, (await ctx.body()).code); return { ok: true }; }));
app.get('/ops/api/sessions', (ctx) => ({ ok: true, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })) }));
app.post('/ops/api/sessions/revoke', wrap(async (ctx) => { const b = await ctx.body(); const n = b.all ? session.revokeOtherSessions(d.store, ctx.operator.id, ctx.session.id, 'operator') : session.revokeSession(d.store, b.id, ctx.operator.id); return { ok: true, revoked: n }; }));
app.get('/ops/api/accounts', (ctx) => ({ ok: true, accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }));
app.post('/ops/api/accounts', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, account: ops.createAccount(d, { ...b, by: ctx.operator.id, mustChange: true }) }; }));
app.post('/ops/api/access-grants', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, grant: ops.issueGrant(d, { accountId: b.accountId || ctx.operator.id, hours: b.hours || 8, by: ctx.operator.id, reason: b.reason || '' }) }; }));
app.get('/ops/api/access-grants', (ctx) => ({ ok: true, grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 200') }));
app.post('/ops/api/access-grants/:id/revoke', wrap((ctx) => { ops.revokeGrant(d, ctx.params.id, ctx.operator.id); return { ok: true }; }));
// 운영 현황(§26.1)
app.get('/ops/api/overview', (ctx) => overview(d));
app.get('/ops/api/config', () => ({ ok: true, config: { publicUrl: d.config.publicUrl, portalUrl: d.config.portalUrl, opsAllowCidrs: d.config.opsAllowCidrs, metricsRetentionDays: d.config.metricsRetentionDays, offlineAfterMs: d.config.offlineAfterMs, emergencyTtlMinutes: d.config.emergencyTtlMinutes } }));
app.put('/ops/api/config', wrap(async (ctx) => { requireRecent(ctx); d.config.set(await ctx.body(), ctx.operator.id); return { ok: true }; }));
// 서버(§28)
app.get('/ops/api/servers', () => ({ ok: true, servers: core.listServers(d) }));
app.post('/ops/api/servers', wrap(async (ctx) => { const b = await ctx.body(); const s = core.createServer(d, { ...b, by: ctx.operator.id }); return { ok: true, server: core.publicServer(d, s) }; }));
app.get('/ops/api/servers/:id', wrap((ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); return { ok: true, server: core.publicServer(d, s), jobs: d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe), agentJobs: d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '') }; }));
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; }));
// 릴리스·모듈 배포(§32)
app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') }));
app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; }));
app.post('/ops/api/modules/:name/deploy', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module: ctx.params.name, version: b.version, serverIds: b.serverIds || [] }, idempotencyKey: b.idempotencyKey || `module.deploy:${ctx.params.name}:${b.version}:${(b.serverIds || []).slice().sort().join(',')}:${b.nonce || ''}`, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} ${b.version} 배포` }); return { ok: true, job: describe(r.job), created: r.created }; }));
app.post('/ops/api/modules/:name/rollback', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); threeStep(b, b.serverIds || []); const r = d.jobs.enqueue({ kind: 'module.rollback', subject: { module: ctx.params.name, toVersion: b.toVersion || null, serverIds: b.serverIds || [] }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 되돌리기` }); return { ok: true, job: describe(r.job) }; }));
app.post('/ops/api/modules/:name/health', wrap(async (ctx) => { const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.health', subject: { module: ctx.params.name, serverIds: b.serverIds || null }, createdBy: ctx.operator.id, actorType: 'operator', subjectType: 'module', subjectId: ctx.params.name, label: `${ctx.params.name} 건강 확인` }); return { ok: true, job: describe(r.job) }; }));
app.post('/ops/api/modules/reconcile', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const r = d.jobs.enqueue({ kind: 'module.reconcile', subject: { module: b.module || null }, createdBy: ctx.operator.id, actorType: 'operator', label: '목표·실제 상태 맞추기' }); return { ok: true, job: describe(r.job) }; }));
// 작업(§29)
app.get('/ops/api/jobs', (ctx) => ({ ok: true, jobs: d.jobs.list({ status: ctx.query.status ? ctx.query.status.split(',') : null, kind: ctx.query.kind || null, limit: Number(ctx.query.limit || 200) }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id })) }));
app.get('/ops/api/jobs/:id', wrap((ctx) => { const j = d.jobs.get(ctx.params.id); if (!j) throw new HttpError(404, 'job_not_found', '작업이 없습니다'); return { ok: true, job: { ...describe(j), subject: redact(j.subject), result: j.result, log: j.log, createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }, children: d.jobs.childrenSummary(j.id), agentJobs: d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id) }; }));
for (const act of ['cancel', 'retry', 'resume', 'revert', 'pause']) app.post(`/ops/api/jobs/:id/${act}`, wrap(async (ctx) => { if (act === 'revert') requireRecent(ctx); const j = await d.jobs[act](ctx.params.id, ctx.operator.id); return { ok: true, job: describe(j) }; }));
// 사건·알림(§31)
app.get('/ops/api/incidents', () => ({ ok: true, incidents: d.store.all('SELECT * FROM incidents ORDER BY started_at DESC LIMIT 200'), notifications: listNotifications(d.store, { groupId: null, limit: 200 }) }));
app.post('/ops/api/incidents', wrap(async (ctx) => { const b = await ctx.body(); const open = d.store.get('SELECT id FROM incidents WHERE key=? AND resolved_at IS NULL', b.key); if (open) { d.store.run('UPDATE incidents SET severity=?, title=?, customer_impact=?, updated_at=? WHERE id=?', b.severity || 'warning', b.title, b.customerImpact || null, nowIso(), open.id); return { ok: true, id: open.id, merged: true }; } const id = newId('inc_'); d.store.run('INSERT INTO incidents(id, key, severity, title, component, customer_impact, started_at, assignee, public, public_note, notes_json, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,\'[]\',?)', id, b.key, b.severity || 'warning', b.title, b.component || null, b.customerImpact || null, nowIso(), ctx.operator.id, b.public ? 1 : 0, b.publicNote || null, nowIso()); return { ok: true, id }; }));
app.patch('/ops/api/incidents/:id', wrap(async (ctx) => { const b = await ctx.body(); const inc = d.store.get('SELECT * FROM incidents WHERE id=?', ctx.params.id); if (!inc) throw new HttpError(404, 'not_found', '사건이 없습니다'); if (b.resolve && !b.verifiedUserPath) throw new HttpError(400, 'verify_required', '실제 사용자 경로 확인 뒤에 종료할 수 있습니다(§31.2)'); const notes = parseJson(inc.notes_json, []); if (b.note) notes.push({ at: nowIso(), by: ctx.operator.id, note: b.note }); d.store.run('UPDATE incidents SET severity=COALESCE(?, severity), assignee=COALESCE(?, assignee), public=COALESCE(?, public), public_note=COALESCE(?, public_note), notes_json=?, resolved_at=CASE WHEN ? THEN ? ELSE resolved_at END, verified_at=CASE WHEN ? THEN ? ELSE verified_at END, updated_at=? WHERE id=?', b.severity || null, b.assignee || null, b.public == null ? null : (b.public ? 1 : 0), b.publicNote || null, toJson(notes), b.resolve ? 1 : 0, nowIso(), b.resolve ? 1 : 0, nowIso(), nowIso(), inc.id); return { ok: true }; }));
app.get('/ops/api/audit', (ctx) => ({ ok: true, entries: listAudit(d.store, { action: ctx.query.action, subjectType: ctx.query.subjectType, subjectId: ctx.query.subjectId, since: ctx.query.since, limit: Number(ctx.query.limit || 200) }) }));
// 긴급 복구(§33)
app.get('/ops/api/emergency', () => ({ ok: true, sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS }));
app.post('/ops/api/emergency', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, session: emergency.declare(d, { ...b, by: ctx.operator.id }) }; }));
app.post('/ops/api/emergency/:id/approve', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, session: emergency.approve(d, ctx.params.id, { ...b, by: ctx.operator.id }) }; }));
app.post('/ops/api/emergency/:id/credential', wrap((ctx) => { requireRecent(ctx); return { ok: true, ...emergency.issueCredential(d, ctx.params.id, ctx.operator.id) }; }));
app.post('/ops/api/emergency/:id/perform', wrap(async (ctx) => { const b = await ctx.body(); return { ok: true, result: await emergency.perform(d, ctx.params.id, { ...b, by: ctx.operator.id }) }; }));
app.post('/ops/api/emergency/:id/verify', wrap(async (ctx) => ({ ok: true, session: emergency.verifyAndRevoke(d, ctx.params.id, { by: ctx.operator.id, verify: (await ctx.body()).verify }) })));
app.post('/ops/api/emergency/:id/reconcile', wrap((ctx) => ({ ok: true, result: emergency.reconcile(d, ctx.params.id, ctx.operator.id) })));
app.post('/ops/api/emergency/:id/close', wrap(async (ctx) => ({ ok: true, session: emergency.close(d, ctx.params.id, ctx.operator.id, (await ctx.body()).note) })));
// 재해복구(§34)
app.get('/ops/api/dr', () => ({ ok: true, readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50') }));
app.put('/ops/api/dr/config', wrap(async (ctx) => { requireRecent(ctx); return { ok: true, config: dr.setConfig(d, await ctx.body(), ctx.operator.id) }; }));
app.post('/ops/api/dr/snapshots', wrap((ctx) => ({ ok: true, snapshot: dr.snapshot(d, { kind: 'manual', by: ctx.operator.id }) })));
app.post('/ops/api/dr/restores', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, restore: dr.requestRestore(d, { snapshotId: b.snapshotId, by: ctx.operator.id, note: b.note }) }; }));
app.post('/ops/api/dr/restores/:id/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, restore: dr.approveRestore(d, ctx.params.id, ctx.operator.id) }; }));
app.post('/ops/api/dr/restores/:id/start', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); return { ok: true, ...dr.startRestore(d, ctx.params.id, { by: ctx.operator.id, oldControlStopped: b.oldControlStopped, confirmText: b.confirmText }) }; }));
app.post('/ops/api/dr/restores/:id/finish', wrap(async (ctx) => ({ ok: true, restore: dr.finishRestore(d, ctx.params.id, { by: ctx.operator.id, checks: (await ctx.body()).checks }) })));
app.post('/ops/api/dr/drills', wrap(async (ctx) => ({ ok: true, drill: dr.startDrill(d, { by: ctx.operator.id, snapshotId: (await ctx.body()).snapshotId }) })));
app.post('/ops/api/dr/drills/:id/finish', wrap(async (ctx) => ({ ok: true, drill: dr.finishDrill(d, ctx.params.id, { by: ctx.operator.id, ...(await ctx.body()) }) })));
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
app.route('*', '/ops/api/portal/*', wrap(async (ctx) => { const secret = d.vault.get('service:hal'); if (!secret) throw new HttpError(503, 'portal_not_linked', '포털 연동 키가 없습니다'); if (!['GET', 'HEAD'].includes(ctx.method)) requireRecent(ctx); const rest = ctx.path.replace(/^\/ops\/api\/portal/, '') + (ctx.url.search || ''); const body = ['GET', 'HEAD'].includes(ctx.method) ? undefined : { ...(await ctx.body()), _operator: { id: ctx.operator.id, login: ctx.operator.login } }; const { serviceFetch } = d.serviceauth; const r = await serviceFetch(d.config.portalUrl, 'hal', secret, ctx.method, '/internal/ops' + rest, body, { timeoutMs: 20000, fetchImpl: d.fetch || fetch }).catch((e) => { throw new HttpError(503, 'portal_unreachable', '포털에 연결할 수 없습니다 — 정보 확인 지연'); }); ctx.status = r.status; return r.json || { ok: false, error: 'portal_bad_response' }; }));
registerUi(app, d); // [ops UI 추가] /ops/* 화면 라우트(아래 기본 /ops/ 는 UI 가 없을 때의 안내용으로 남긴다)
app.get('/ops', (ctx) => ctx.redirect('/ops/')); app.get('/ops/', (ctx) => { const f = join(uiDir, 'index.html'); return ctx.html(existsSync(f) ? readFileSync(f, 'utf8') : `<!doctype html><html lang="ko"><meta charset="utf-8"><title>Ariweb HAL</title><body style="font-family:sans-serif;padding:40px"><h1>Ariweb HAL 운영자 콘솔</h1><p>화면(UI)은 아직 설치되지 않았습니다. API: /ops/api/me</p></body></html>`); });
return app;
}
function stripFacts(s) { const { facts, ...rest } = s; return rest; }
/** 운영 현황(§26.1) */
function overview(d) {
const servers = core.listServers(d); const health = core.selfHealth(d);
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
}
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
function publicStatus(d) {
const o = overview(d);
const label = { HAL: '관리 플랫폼', portal: '고객 포털', webmaker: 'Webmaker', arimail: '메일', backup: '백업', observability: '모니터링' };
return { ok: true, generatedAt: o.generatedAt, components: o.components.map((c) => ({ name: label[c.name] || c.name, status: c.status === 'ok' ? 'ok' : c.status === 'down' ? 'down' : 'unknown', label: c.status === 'ok' ? '정상' : c.status === 'down' ? '장애' : '확인 중' })), incidents: o.incidents.filter((i) => d.store.get('SELECT public FROM incidents WHERE id=?', i.id)?.public).map((i) => ({ severity: i.severity, title: i.title, startedAt: i.started_at, note: d.store.get('SELECT public_note FROM incidents WHERE id=?', i.id)?.public_note || '' })), maintenance: d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s) => ({ note: s.maintenance_note || '계획된 유지보수' })) };
}
function statusPage(d) { const s = publicStatus(d); return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c) => `<li><b class="${c.status}">${c.status === 'ok' ? '●' : c.status === 'down' ? '✘' : '◔'} ${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length ? `<ul>${s.incidents.map((i) => `<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>` : '<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length ? `<ul>${s.maintenance.map((m) => `<li>${esc(m.note)}</li>`).join('')}</ul>` : '<p>예정된 유지보수가 없습니다.</p>'}</body></html>`; }
+21
View File
@@ -0,0 +1,21 @@
/** hal/cidr.mjs — IP 허용 목록(CIDR) 검사(§25 운영자 콘솔 네트워크 제한). IPv4·IPv6·::ffff: 로 감싼 IPv4 를 다룬다. */
import { isIP } from 'node:net';
function toBytes(ip) {
let s = String(ip || '').trim(); if (s.startsWith('::ffff:') && isIP(s.slice(7)) === 4) s = s.slice(7);
if (isIP(s) === 4) return { v: 4, b: s.split('.').map(Number) };
if (isIP(s) !== 6) return null;
const [head, tail] = s.split('::'); const h = head ? head.split(':') : []; const t = tail ? tail.split(':') : []; const parts = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
const b = []; for (const p of parts) { const n = parseInt(p || '0', 16); b.push(n >> 8, n & 255); } return { v: 6, b };
}
/** ip 가 cidr 목록 중 하나에 속하면 true. 목록이 비면 false(허용 네트워크 없음). */
export function ipAllowed(ip, cidrs) {
const a = toBytes(ip); if (!a) return false;
for (const c of cidrs || []) {
const [net, lenS] = String(c).split('/'); const n = toBytes(net); if (!n || n.v !== a.v) continue;
const len = lenS === undefined ? (n.v === 4 ? 32 : 128) : Number(lenS); if (!(len >= 0 && len <= (n.v === 4 ? 32 : 128))) continue;
let ok = true; for (let i = 0; i < len; i++) { const byte = i >> 3, bit = 7 - (i & 7); if (((a.b[byte] >> bit) & 1) !== ((n.b[byte] >> bit) & 1)) { ok = false; break; } }
if (ok) return true;
}
return false;
}
+41
View File
@@ -0,0 +1,41 @@
/**
* hal/config.mjs — HAL 실행 설정.
*
* 배포 때 정해지는 값(포트·데이터 디렉터리)은 환경 변수로, 운영 중 바꾸는 값(공개 주소·포털 주소·허용 네트워크·메트릭 보존 일수)은
* data/config.json 에 둔다. 비밀값(서비스 키·Agent 비밀)은 설정이 아니라 보관소(lib/vault)에만 둔다.
*/
import { readFileSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
export function loadConfig() {
const dataDir = process.env.ARIWEB_HAL_DATA || process.env.DATA_DIR || join(process.cwd(), 'data');
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
const file = join(dataDir, 'config.json');
let values = {}; try { values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')).values || {} : {}; } catch (_) { values = {}; }
const cfg = {
dataDir, file,
port: Number(process.env.PORT || 8900), host: process.env.HOST || '127.0.0.1',
/** HAL 공개 주소(Agent 가 접속하는 주소) */
publicUrl: values.publicUrl || process.env.HAL_PUBLIC_URL || 'https://hal.ariweb.net',
/** 포털 내부 주소(작업 이벤트를 미는 곳) */
portalUrl: values.portalUrl || process.env.PORTAL_URL || 'http://127.0.0.1:8910',
/** 운영자 콘솔 접근 허용 네트워크(CIDR 목록, §25). 비어 있으면 접근권(grant) 쿠키만 허용 */
opsAllowCidrs: values.opsAllowCidrs || (process.env.OPS_ALLOW_CIDRS ? process.env.OPS_ALLOW_CIDRS.split(',').map((s) => s.trim()).filter(Boolean) : ['127.0.0.1/32', '::1/128', '10.0.0.0/8', '192.168.0.0/16']),
/** 메트릭 보존 일수(최소 90, §28.1) */
metricsRetentionDays: Math.max(90, Number(values.metricsRetentionDays || 90)),
/** heartbeat 가 이만큼 없으면 서버를 offline 으로 본다 */
offlineAfterMs: Number(values.offlineAfterMs || 90 * 1000),
/** 전송(transfer) 파일 보관 시간 */
transferTtlMs: Number(values.transferTtlMs || 24 * 3600 * 1000),
/** 긴급 자격 최대 유효 시간(분) */
emergencyTtlMinutes: Math.min(60, Number(values.emergencyTtlMinutes || 30)),
values,
/** 설정 저장(운영자 화면·CLI). 비밀값은 거부한다. */
set(patch, by = 'ops') {
for (const k of Object.keys(patch)) if (/key|secret|token|pass/i.test(k)) throw new Error(`비밀값(${k})은 설정 파일에 둘 수 없습니다 — 보관소를 쓰세요`);
Object.assign(values, patch); const tmp = file + '.tmp';
writeFileSync(tmp, JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), updatedBy: by, values }, null, 1), { mode: 0o600 }); renameSync(tmp, file); Object.assign(cfg, patch);
},
};
return cfg;
}
+256
View File
@@ -0,0 +1,256 @@
/**
* hal/core.mjs — HAL 핵심 서비스: 서버·Agent 등록부, Agent 작업 배정·대기, 릴리스 등록부, 부트스트랩 상태, 지표, 포털 이벤트 outbox.
* app.mjs(라우트)와 jobs.mjs(작업 종류)가 함께 쓴다. 모든 함수는 deps(store·vault·config·log·jobs) 를 받는다.
*/
import { hostname } from 'node:os';
import { newId, randomToken, sha256, hashPassword } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { notify } from '../lib/notify.mjs';
import { redact, safeError } from '../lib/redact.mjs';
import { serviceFetch } from '../lib/serviceauth.mjs';
import { JobError } from '../lib/jobs.mjs';
import { existsSync, statSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2)
export const SERVER_ROLES = ['web', 'mail', 'backup', 'control'];
// ------------------------------------------------------------------ 부트스트랩 상태(§32.2)
export function bootstrapState(d) { let r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); if (!r) { d.store.run('INSERT INTO bootstrap_state(id, updated_at) VALUES(1, ?)', nowIso()); r = d.store.get('SELECT * FROM bootstrap_state WHERE id=1'); } return r; }
export function setBootstrap(d, patch) { const sets = Object.keys(patch).map((k) => `${k}=?`); d.store.run(`UPDATE bootstrap_state SET ${sets.join(', ')}, updated_at=? WHERE id=1`, ...Object.values(patch), nowIso()); return bootstrapState(d); }
/** 부트스트랩 완료 여부: HAL 실행 버전 기록 + 첫 Agent 등록·승인 */
export function bootstrapComplete(d) { const b = bootstrapState(d); return !!(b.hal_running_version && b.first_agent_id && b.completed_at); }
/** HAL 기동 때: 실행 버전·인스턴스 임대 기록. 다른 인스턴스의 최근 임대가 있으면 거부(§34 "하나의 HAL 만 운영"). */
export function claimLive(d, { instanceId, version, takeover = false, now = Date.now() }) {
const b = bootstrapState(d);
// [통합 보완] 같은 호스트에서 이전 프로세스가 죽은 뒤(systemd 재시작·비정상 종료) 60초 임대가 남아 있으면 기동이 막힌다.
// 임대에 호스트·PID 를 함께 기록해 두고, 같은 호스트이며 그 PID 가 더 이상 살아 있지 않으면 같은 제어 위치의 재시작으로 보아 이어받는다.
// 다른 호스트(재해복구로 띄운 두 번째 HAL)는 여전히 거부된다(§34 "하나의 HAL 만 운영").
const sameHostDead = b.live_host && b.live_host === hostname() && b.live_pid && !pidAlive(Number(b.live_pid));
if (b.live_instance_id && b.live_instance_id !== instanceId && b.live_lease_at && now - Date.parse(b.live_lease_at) < 60000 && !takeover && !sameHostDead) {
throw Object.assign(new Error(`다른 HAL 인스턴스(${b.live_instance_id})가 ${b.live_lease_at} 에 운영 중입니다. 기존 제어 위치를 먼저 중지하세요`), { code: 'another_hal_live' });
}
return setBootstrap(d, { live_instance_id: instanceId, live_lease_at: new Date(now).toISOString(), live_host: hostname(), live_pid: String(process.pid), hal_running_version: version, hal_started_at: nowIso() });
}
/** PID 가 살아 있는지(신호 0). 권한 오류(EPERM)는 살아 있는 것으로 본다. */
function pidAlive(pid) { if (!pid) return false; try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
export function renewLease(d, instanceId) { d.store.run('UPDATE bootstrap_state SET live_lease_at=? WHERE id=1 AND live_instance_id=?', nowIso(), instanceId); }
/** 첫 Agent 가 승인되면 부트스트랩을 닫고 수동 설치를 잠근다 */
export function maybeCompleteBootstrap(d, agentId) {
const b = bootstrapState(d); if (b.completed_at) return b;
if (!b.first_agent_id) setBootstrap(d, { first_agent_id: agentId, first_agent_registered_at: nowIso() });
if (bootstrapState(d).hal_running_version) { const r = setBootstrap(d, { completed_at: nowIso(), manual_install_locked: 1, first_install: 0 }); audit(d.store, { actorType: 'system', action: 'bootstrap.complete', subjectType: 'agent', subjectId: agentId, immutable: true }); return r; }
return bootstrapState(d);
}
/** HAL 자체 건강(§32.2 4단계) */
export function selfHealth(d) {
const b = bootstrapState(d); let dbOk = true; try { d.store.get('SELECT 1 AS x'); } catch (_) { dbOk = false; }
return { ok: dbOk && !!d.jobsRunning, version: d.version, uptimeSec: Math.round(process.uptime()), db: dbOk, jobs: !!d.jobsRunning, bootstrap: { complete: bootstrapComplete(d), firstInstall: !!b.first_install, releaseVersion: b.release_version, mainCommit: b.main_commit, artifactSha256: b.artifact_sha256, halRunningVersion: b.hal_running_version, firstAgent: b.first_agent_id, manualInstallLocked: !!b.manual_install_locked }, instanceId: d.instanceId };
}
// ------------------------------------------------------------------ 서버·Agent
export function createServer(d, { name, role, location = '', by = null }) {
if (!SERVER_ROLES.includes(role)) throw new JobError('bad_role', `서버 역할은 ${SERVER_ROLES.join('/')} 중 하나입니다`);
const id = newId('srv_'); const now = nowIso();
d.store.run('INSERT INTO servers(id, name, role, location, status, created_at, updated_at) VALUES(?,?,?,?,\'pending\',?,?)', id, String(name).slice(0, 80), role, String(location).slice(0, 120), now, now);
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.create', subjectType: 'server', subjectId: id, detail: { name, role, location } });
return getServer(d, id);
}
export function getServer(d, id) { const s = d.store.get('SELECT * FROM servers WHERE id=?', id); return s ? { ...s, metrics: parseJson(s.metrics_json, null), capacity: parseJson(s.capacity_json, null), facts: parseJson(s.facts_json, {}) } : null; }
export function listServers(d, { role = null } = {}) { return d.store.all(`SELECT * FROM servers ${role ? 'WHERE role=?' : ''} ORDER BY created_at`, ...(role ? [role] : [])).map((s) => publicServer(d, s)); }
/** 화면·포털용 서버 요약(비밀 없음). offline 판정은 last_seen 기준(§28.1 heartbeat). */
export function publicServer(d, s) {
const seen = s.last_seen_at ? Date.now() - Date.parse(s.last_seen_at) : Infinity; const offline = seen > d.config.offlineAfterMs;
const status = s.status === 'pending' ? 'pending' : offline ? 'offline' : s.status;
const mods = d.store.all('SELECT module, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets WHERE server_id=?', s.id);
const capacity = parseJson(s.capacity_json, {}) || {}; const metrics = parseJson(s.metrics_json, null);
return { id: s.id, name: s.name, role: s.role, location: s.location, status, rawStatus: s.status, acceptsNew: !!s.accepts_new && status === 'ready', approvedAt: s.approved_at, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at, stale: offline, clockSkewMs: s.clock_skew_ms,
health: status === 'offline' ? 'down' : mods.some((m) => m.health === 'down') ? 'partial_outage' : mods.some((m) => m.health === 'degraded') ? 'degraded' : status === 'ready' ? 'ok' : 'unknown',
metrics, capacity: { diskFreeBytes: capacity.diskFreeBytes || 0, diskTotalBytes: capacity.diskTotalBytes || 0, memFreeBytes: capacity.memFreeBytes || 0, cpuLoad: capacity.cpuLoad || 0, sitesHosted: capacity.sitesHosted || 0, reservedBytes: capacity.reservedBytes || 0, safetyMarginBytes: capacity.safetyMarginBytes || Math.round((capacity.diskTotalBytes || 0) * 0.1), exhaustEta: capacity.exhaustEta || null },
modules: Object.fromEntries(mods.map((m) => [m.module, { target: m.target_version, actual: m.actual_version, drift: !!(m.target_version && m.actual_version && m.target_version !== m.actual_version), health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error }])),
maintenanceNote: s.maintenance_note, isolatedReason: s.isolated_reason, facts: parseJson(s.facts_json, {}) };
}
export function updateServer(d, id, patch, by = null) {
const allowed = ['name', 'location', 'status', 'accepts_new', 'maintenance_note', 'isolated_reason', 'role'];
const keys = Object.keys(patch).filter((k) => allowed.includes(k)); if (!keys.length) return getServer(d, id);
d.store.run(`UPDATE servers SET ${keys.map((k) => `${k}=?`).join(', ')}, updated_at=? WHERE id=?`, ...keys.map((k) => patch[k]), nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'server.update', subjectType: 'server', subjectId: id, detail: patch });
return getServer(d, id);
}
/** 운영자 승인(§28.2 6단계): pending → ready, 신규 배치 허용 */
export function approveServer(d, id, by) { const s = getServer(d, id); if (!s) throw new JobError('server_not_found', '서버가 없습니다'); if (!s.agent_id) throw new JobError('agent_missing', 'Agent 가 아직 연결되지 않았습니다'); updateServer(d, id, { status: 'ready', accepts_new: 1 }, by); d.store.run('UPDATE servers SET approved_at=?, approved_by=? WHERE id=?', nowIso(), by, id); maybeCompleteBootstrap(d, s.agent_id); return getServer(d, id); }
/** 신규 배치 가능 서버 고르기(§28.3): ready·accepts_new·여유 용량(안전 여유 제외) */
export function pickServer(d, { role = 'web', needBytes = 0 } = {}) {
const cands = listServers(d, { role }).filter((s) => s.acceptsNew && s.status === 'ready' && (s.capacity.diskFreeBytes - s.capacity.reservedBytes - s.capacity.safetyMarginBytes) >= needBytes);
cands.sort((a, b) => (b.capacity.diskFreeBytes - b.capacity.reservedBytes) - (a.capacity.diskFreeBytes - a.capacity.reservedBytes));
return cands[0] ? { serverId: cands[0].id, name: cands[0].name } : null;
}
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
const token = randomToken(32); const id = newId('rt_'); const expiresAt = new Date(Date.now() + ttlMs).toISOString();
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label } });
return { id, token, expiresAt, installCommand: `curl -fsSL ${d.config.publicUrl}/agent/v1/install.sh | bash -s -- --hal ${d.config.publicUrl} --token ${token}${kind === 'external' ? ` --external --site ${siteId}` : ''}` };
}
/**
* Agent 등록(토큰 1회 사용). 비밀은 보관소에 두고 응답에서 한 번만 돌려준다.
* @returns {{agentId, secret, serverId, kind}}
*/
export function registerAgent(d, { token, facts = {}, version = '' }) {
const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(String(token || '')));
if (!t) throw new JobError('bad_token', '등록 토큰이 올바르지 않습니다');
if (t.used_at) throw new JobError('token_used', '이미 사용한 등록 토큰입니다 — 새 토큰을 발급받으세요');
if (Date.parse(t.expires_at) < Date.now()) throw new JobError('token_expired', '등록 토큰이 만료되었습니다 — 새 토큰을 발급받으세요');
const agentId = newId('ag_'); const secret = randomToken(32); const now = nowIso();
return d.store.tx(() => {
const used = d.store.run('UPDATE registration_tokens SET used_at=?, used_by_agent=? WHERE id=? AND used_at IS NULL', now, agentId, t.id).changes;
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다');
let serverId = t.server_id;
if (t.kind === 'managed' && !serverId) { serverId = createServer(d, { name: facts.hostname || agentId, role: facts.role || 'web', location: facts.location || '' }).id; }
d.store.run('INSERT INTO agents(id, server_id, kind, secret_hash, status, label, group_id, site_id, version, registered_at, last_seen_at, facts_json) VALUES(?,?,?,?,\'active\',?,?,?,?,?,?,?)', agentId, serverId, t.kind, sha256(secret), t.label || facts.hostname || '', t.group_id, t.site_id, version, now, now, toJson(redact(facts)));
d.vault.set(`service:agent:${agentId}`, secret, { kind: 'agent-secret' });
if (serverId) d.store.run('UPDATE servers SET agent_id=?, last_seen_at=?, agent_version=?, facts_json=?, updated_at=? WHERE id=?', agentId, now, version, toJson(redact(facts)), now, serverId);
audit(d.store, { actorType: 'agent', actorId: agentId, action: 'agent.register', subjectType: t.kind === 'external' ? 'site' : 'server', subjectId: t.site_id || serverId, detail: { kind: t.kind, version, hostname: facts.hostname } });
return { agentId, secret, serverId, kind: t.kind, siteId: t.site_id, groupId: t.group_id };
});
}
export function getAgent(d, id) { return d.store.get('SELECT * FROM agents WHERE id=?', id) || null; }
export function revokeAgent(d, id, by = null) { d.store.run('UPDATE agents SET status=\'revoked\', revoked_at=? WHERE id=?', nowIso(), id); d.vault.remove(`service:agent:${id}`); d.store.run('UPDATE agent_jobs SET status=\'cancelled\', ended_at=? WHERE agent_id=? AND status IN (\'queued\',\'running\')', nowIso(), id); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.revoke', subjectType: 'agent', subjectId: id, immutable: true }); }
/** 외부 Agent 상태(포털 화면용, §8.1) */
export function externalAgentStatus(d, id) {
const a = getAgent(d, id); if (!a || a.kind !== 'external') return null;
const seen = a.last_seen_at ? Date.now() - Date.parse(a.last_seen_at) : Infinity;
const last = d.store.get('SELECT * FROM agent_jobs WHERE agent_id=? AND kind=\'site.deploy\' ORDER BY created_at DESC LIMIT 1', id);
const lastCheck = d.store.get('SELECT * FROM agent_jobs WHERE agent_id=? AND kind=\'site.check\' AND status=\'done\' ORDER BY created_at DESC LIMIT 1', id);
return { id: a.id, status: a.status === 'revoked' ? 'revoked' : !a.last_seen_at ? 'pending' : seen > d.config.offlineAfterMs ? 'offline' : 'connected', lastSeenAt: a.last_seen_at, version: a.version, siteId: a.site_id, facts: parseJson(a.facts_json, {}),
lastDeploy: last ? { id: last.id, status: last.status, at: last.ended_at || last.started_at || last.created_at, result: parseJson(last.result_json, null), error: last.error } : null,
lastCheck: lastCheck ? { at: lastCheck.ended_at, result: parseJson(lastCheck.result_json, null) } : null };
}
/** heartbeat 처리: 지표 저장, 서버 last_seen·시계 차이·모듈 실제 버전·용량 갱신 */
export function heartbeat(d, agent, body) {
const now = nowIso(); const m = body.metrics || {}; const skew = Number.isFinite(body.clockSkewMs) ? Math.round(body.clockSkewMs) : null;
d.store.run('UPDATE agents SET last_seen_at=?, version=?, facts_json=COALESCE(?, facts_json) WHERE id=?', now, body.version || agent.version, body.facts ? toJson(redact(body.facts)) : null, agent.id);
if (!agent.server_id) return { ok: true };
const capacity = { diskFreeBytes: m.disk ? m.disk.total - m.disk.used : 0, diskTotalBytes: m.disk?.total || 0, memFreeBytes: m.mem ? m.mem.total - m.mem.used : 0, cpuLoad: m.load1 || 0, sitesHosted: body.sitesHosted || 0, reservedBytes: body.reservedBytes || 0, exhaustEta: body.exhaustEta || null };
d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), body.facts ? toJson(redact(body.facts)) : null, now, agent.server_id);
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
for (const [name, mod] of Object.entries(body.modules || {})) {
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
}
// 시계 차이·디스크 고갈 경고(§28.1)
if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` });
else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} }
if (m.disk && m.disk.total && m.disk.used / m.disk.total >= 0.9) notify(d.store, { category: 'ops', severity: m.disk.used / m.disk.total >= 0.95 ? 'critical' : 'warning', incidentKey: `server:${agent.server_id}:disk`, title: `서버 디스크 ${Math.round(m.disk.used / m.disk.total * 100)}% 사용`, link: `/ops/servers/${agent.server_id}` });
return { ok: true, serverTime: now, pendingJobs: d.store.get('SELECT COUNT(*) AS n FROM agent_jobs WHERE agent_id=? AND status=\'queued\'', agent.id).n };
}
/** 오래 heartbeat 가 없는 서버를 offline 으로 표시(주기 작업). 변경된 서버 목록 반환 */
export function markOffline(d) {
const cut = new Date(Date.now() - d.config.offlineAfterMs).toISOString();
const rows = d.store.all('SELECT id, name FROM servers WHERE status IN (\'ready\',\'maintenance\') AND agent_id IS NOT NULL AND (last_seen_at IS NULL OR last_seen_at<?)', cut);
for (const r of rows) { d.store.run('UPDATE servers SET status=\'offline\', updated_at=? WHERE id=?', nowIso(), r.id); notify(d.store, { category: 'incident', severity: 'critical', mandatory: false, incidentKey: `server:${r.id}:offline`, title: `서버 ${r.name} 응답 없음(Node Agent heartbeat 중단)`, link: `/ops/servers/${r.id}` }); }
return rows;
}
export function purgeMetrics(d) { const cut = new Date(Date.now() - d.config.metricsRetentionDays * 86400000).toISOString(); return d.store.run('DELETE FROM metrics_samples WHERE at<?', cut).changes; }
export function metrics(d, serverId, { since = null, until = null, limit = 5000 } = {}) { return d.store.all('SELECT * FROM metrics_samples WHERE server_id=? AND at>=? AND at<=? ORDER BY at DESC LIMIT ?', serverId, since || new Date(Date.now() - 90 * 86400000).toISOString(), until || nowIso(), Math.min(limit, 50000)); }
// ------------------------------------------------------------------ Agent 작업
/**
* 재진입 가능한 배정: 'dispatch' 단계에 저장된 Agent 작업이 실패·취소로 끝났으면(재시도) 새 Agent 작업을 만든다.
* 호출자는 ctx.step('dispatch', …) 대신 이 함수를 쓴다.
*/
export async function ensureAgentJob(d, ctx, job, { stepId = 'dispatch', agentId, kind, payload, title = '대상 Agent 에 작업 배정' }) {
const prev = ctx.steps().find((x) => x.id === stepId);
if (prev && prev.state === 'done' && prev.result?.agentJobId) { const aj = getAgentJob(d, prev.result.agentJobId); if (aj && ['failed', 'cancelled'].includes(aj.status)) { prev.state = 'pending'; delete prev.result; } }
return ctx.step(stepId, () => ({ agentId, agentJobId: dispatchAgentJob(d, { halJobId: job.id, agentId, kind, payload, dedupeKey: `${job.id}:${kind}:${agentId}:${job.attempt}` }) }), title);
}
/** Agent 작업 만들기(HAL 작업의 자식). 같은 hal_job_id+kind+agent 의 미완료 작업이 있으면 재사용(재진입 멱등). */
export function dispatchAgentJob(d, { halJobId, agentId, kind, payload, dedupeKey = null }) {
const agent = getAgent(d, agentId); if (!agent || agent.status !== 'active') throw new JobError('agent_unavailable', 'Agent 가 없거나 해지되었습니다');
if (agent.kind === 'external' && !EXTERNAL_KINDS.has(kind)) throw new JobError('kind_not_allowed_external', `외부 Agent 는 ${kind} 작업을 받을 수 없습니다`);
if (agent.kind === 'external' && payload?.siteId && agent.site_id && payload.siteId !== agent.site_id) throw new JobError('site_mismatch', '이 외부 Agent 는 다른 사이트에 귀속되어 있습니다');
const key = dedupeKey || `${halJobId}:${kind}:${agentId}`;
const ex = d.store.get('SELECT * FROM agent_jobs WHERE hal_job_id=? AND kind=? AND agent_id=? AND json_extract(payload_json, \'$._dedupe\')=?', halJobId, kind, agentId, key);
if (ex) return ex.id;
const id = newId('aj_');
d.store.run('INSERT INTO agent_jobs(id, hal_job_id, agent_id, kind, payload_json, status, created_at, steps_json, log_json) VALUES(?,?,?,?,?,\'queued\',?,\'[]\',\'[]\')', id, halJobId, agentId, kind, toJson({ ...payload, _dedupe: key }), nowIso());
return id;
}
export function getAgentJob(d, id) { const r = d.store.get('SELECT * FROM agent_jobs WHERE id=?', id); return r ? { ...r, payload: parseJson(r.payload_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), log: parseJson(r.log_json, []) } : null; }
/** Agent 가 다음 작업을 가져간다(long-poll 은 라우트가 반복 호출). queued 중 가장 오래된 것 하나 → running */
export function nextAgentJob(d, agentId) {
const r = d.store.get('SELECT id FROM agent_jobs WHERE agent_id=? AND status=\'queued\' ORDER BY created_at LIMIT 1', agentId); if (!r) return null;
const got = d.store.run('UPDATE agent_jobs SET status=\'running\', assigned_at=?, started_at=?, heartbeat_at=? WHERE id=? AND status=\'queued\'', nowIso(), nowIso(), nowIso(), r.id).changes;
if (!got) return null; const j = getAgentJob(d, r.id); delete j.payload._dedupe; return { id: j.id, kind: j.kind, payload: j.payload, halJobId: j.hal_job_id };
}
/** Agent 재시작 뒤 이어서 할 작업 목록(running 으로 남은 것) */
export function unfinishedAgentJobs(d, agentId) { return d.store.all('SELECT id, kind, payload_json, steps_json FROM agent_jobs WHERE agent_id=? AND status=\'running\'', agentId).map((r) => ({ id: r.id, kind: r.kind, payload: (() => { const p = parseJson(r.payload_json, {}); delete p._dedupe; return p; })(), steps: parseJson(r.steps_json, []) })); }
export function agentJobProgress(d, id, agentId, { progress, steps, log }) { const r = d.store.run('UPDATE agent_jobs SET progress=COALESCE(?, progress), steps_json=COALESCE(?, steps_json), log_json=COALESCE(?, log_json), heartbeat_at=? WHERE id=? AND agent_id=? AND status=\'running\'', progress ?? null, steps ? toJson(redact(steps)) : null, log ? toJson(redact(log)) : null, nowIso(), id, agentId).changes; return !!r; }
export function agentJobResult(d, id, agentId, { status, result, error, errorCode, userMessage, steps, log }) {
if (!['done', 'failed', 'needs_user'].includes(status)) throw new JobError('bad_status', 'Agent 결과 상태는 done/failed/needs_user 중 하나입니다');
const ch = d.store.run('UPDATE agent_jobs SET status=?, result_json=?, error=?, error_code=?, user_message=?, steps_json=COALESCE(?, steps_json), log_json=COALESCE(?, log_json), progress=CASE WHEN ?=\'done\' THEN 100 ELSE progress END, ended_at=? WHERE id=? AND agent_id=? AND status=\'running\'',
status, toJson(redact(result ?? null)), error ? safeError({ message: error }) : null, errorCode || null, userMessage || null, steps ? toJson(redact(steps)) : null, log ? toJson(redact(log)) : null, status, nowIso(), id, agentId).changes;
return !!ch;
}
export const isAgentJobCancelled = (d, id) => !!d.store.get('SELECT cancel_requested FROM agent_jobs WHERE id=?', id)?.cancel_requested;
/**
* HAL 작업(lib/jobs run) 안에서 Agent 작업이 끝날 때까지 기다린다. 진행률·단계를 HAL 작업에 비춘다.
* Agent 가 오래 응답하지 않으면(heartbeat 5분) 계속 기다리되 사유를 기록하고, 서버가 offline 이면 needs_user 로 멈춘다(운영자가 서버를 살리고 '이어서 진행').
*/
export async function awaitAgentJob(d, ctx, agentJobId, { timeoutMs = d.config.agentWaitTimeoutMs || 6 * 3600 * 1000, pollMs = d.config.agentPollMs || 1000 } = {}) {
const t0 = Date.now(); let lastProgress = -1;
while (true) {
const j = getAgentJob(d, agentJobId); if (!j) throw new JobError('agent_job_missing', 'Agent 작업 기록이 사라졌습니다');
if (j.status === 'done') return j.result;
if (j.status === 'failed') throw new JobError(j.error_code || 'agent_failed', j.error || 'Agent 작업 실패', { retryable: j.error_code === 'transient' });
if (j.status === 'needs_user') ctx.needsUser(j.user_message || 'Agent 가 사용자/운영자 확인을 요청합니다', [{ id: 'resume', label: '확인 후 이어서 진행' }]);
if (j.status === 'cancelled') throw new JobError('agent_job_cancelled', 'Agent 작업이 취소되었습니다');
try { ctx.checkCancel(); } catch (e) { d.store.run('UPDATE agent_jobs SET cancel_requested=1 WHERE id=?', agentJobId); throw e; }
if (j.progress !== lastProgress) { lastProgress = j.progress; ctx.progress(j.progress); }
const agent = getAgent(d, j.agent_id); const srv = agent?.server_id ? getServer(d, agent.server_id) : null;
const silent = agent?.last_seen_at ? Date.now() - Date.parse(agent.last_seen_at) : Infinity;
if (silent > 10 * 60 * 1000) ctx.needsUser(`대상 서버(${srv?.name || agent?.label || j.agent_id})의 Agent 가 10분 넘게 응답하지 않습니다. 서버 상태를 확인한 뒤 이어서 진행하세요`, [{ id: 'resume', label: 'Agent 복구 후 이어서 진행' }]);
if (Date.now() - t0 > timeoutMs) throw new JobError('agent_timeout', 'Agent 작업이 제한 시간을 넘겼습니다');
await new Promise((r) => setTimeout(r, pollMs));
}
}
// ------------------------------------------------------------------ 릴리스(§32.1)
export function registerRelease(d, { version, mainCommit, builtAt, artifacts, checks = null, notes = null, approve = false, by = null }) {
const now = nowIso(); const ex = d.store.get('SELECT version FROM releases WHERE version=?', version);
if (ex) d.store.run('UPDATE releases SET main_commit=?, built_at=?, artifacts_json=?, checks_json=?, notes=COALESCE(?, notes) WHERE version=?', mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, version);
else d.store.run('INSERT INTO releases(version, main_commit, built_at, artifacts_json, checks_json, status, notes, registered_at) VALUES(?,?,?,?,?,\'registered\',?,?)', version, mainCommit, builtAt, toJson(artifacts), toJson(checks), notes, now);
if (approve) approveRelease(d, version, by);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'release.register', subjectType: 'release', subjectId: version, detail: { mainCommit, artifacts: Object.keys(artifacts || {}) } });
return getRelease(d, version);
}
export function approveRelease(d, version, by) { const r = getRelease(d, version); if (!r) throw new JobError('release_not_found', '릴리스가 없습니다'); const checks = r.checks || {}; if (checks.testsPassed === false) throw new JobError('release_checks_failed', '검사에 실패한 릴리스는 승인할 수 없습니다'); d.store.run('UPDATE releases SET status=\'approved\', approved_by=?, approved_at=? WHERE version=?', by, nowIso(), version); audit(d.store, { actorType: 'operator', actorId: by, action: 'release.approve', subjectType: 'release', subjectId: version, immutable: true }); return getRelease(d, version); }
export function getRelease(d, version) { const r = d.store.get('SELECT * FROM releases WHERE version=?', version); return r ? { ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) } : null; }
export function listReleases(d) { return d.store.all('SELECT * FROM releases ORDER BY registered_at DESC').map((r) => ({ ...r, artifacts: parseJson(r.artifacts_json, {}), checks: parseJson(r.checks_json, null) })); }
export const artifactDir = (d) => join(d.config.dataDir, 'artifacts');
/** artifact 파일 경로·존재 확인 */
export function artifactPath(d, version, module) { const r = getRelease(d, version); const a = r && r.artifacts[module]; if (!a) return null; const p = join(artifactDir(d), version, a.file); return existsSync(p) ? { path: p, sha256: a.sha256, size: statSync(p).size } : null; }
// ------------------------------------------------------------------ 전송(transfer)
export function transferDir(d) { const p = join(d.config.dataDir, 'transfers'); mkdirSync(p, { recursive: true, mode: 0o700 }); return p; }
export function createTransfer(d, { id = newId('tr_'), agentId, sha256: sum, size, purpose = '', meta = null }) { const path = join(transferDir(d), id + '.bin'); d.store.run('INSERT OR REPLACE INTO transfers(id, created_by_agent, path, size_bytes, sha256, created_at, expires_at, purpose, meta_json) VALUES(?,?,?,?,?,?,?,?,?)', id, agentId, path, size, sum, nowIso(), new Date(Date.now() + d.config.transferTtlMs).toISOString(), purpose, toJson(meta)); return { id, path }; }
export function getTransfer(d, id) { const t = d.store.get('SELECT * FROM transfers WHERE id=? AND deleted_at IS NULL', id); return t && existsSync(t.path) ? t : null; }
// ------------------------------------------------------------------ 포털 이벤트 outbox(§6)
export function queuePortalEvent(d, payload) { d.store.run('INSERT INTO portal_events_outbox(id, payload_json, created_at, next_at) VALUES(?,?,?,?)', newId('ev_'), toJson(redact(payload)), nowIso(), nowIso()); }
/** outbox 전송(주기). 실패는 지수 백오프로 재시도하고 치명적으로 다루지 않는다. */
export async function flushPortalEvents(d, { fetchImpl = fetch } = {}) {
const secret = d.vault.get('service:hal'); if (!secret) return 0;
const rows = d.store.all('SELECT * FROM portal_events_outbox WHERE delivered_at IS NULL AND next_at<=? ORDER BY created_at LIMIT 50', nowIso()); let n = 0;
for (const r of rows) {
try { const res = await serviceFetch(d.config.portalUrl, 'hal', secret, 'POST', '/internal/hal-events', parseJson(r.payload_json, {}), { timeoutMs: 8000, fetchImpl }); if (!res.ok) throw new Error(`portal ${res.status} ${res.json?.error || ''}`); d.store.run('UPDATE portal_events_outbox SET delivered_at=? WHERE id=?', nowIso(), r.id); n++; }
catch (e) { const attempts = r.attempts + 1; d.store.run('UPDATE portal_events_outbox SET attempts=?, next_at=?, last_error=? WHERE id=?', attempts, new Date(Date.now() + Math.min(600000, 5000 * 2 ** attempts)).toISOString(), safeError(e), r.id); }
}
d.store.run('DELETE FROM portal_events_outbox WHERE delivered_at IS NOT NULL AND delivered_at<?', new Date(Date.now() - 7 * 86400000).toISOString());
return n;
}
export function ensureOpsAccountExists(d) { return d.store.get('SELECT COUNT(*) AS n FROM ops_accounts').n > 0; }
export { hashPassword };
+24
View File
@@ -0,0 +1,24 @@
/**
* hal/deps.mjs — HAL 의존성 묶음(deps) 만들기. index.mjs(운영)와 테스트가 같은 함수를 쓴다.
* deps = { store, vault, config, jobs, log, version, instanceId, serviceauth, notifyLib, fetch, emergencyRunner, jobsRunning, stopping }
*/
import { openDb } from '../lib/db.mjs';
import { openVault } from '../lib/vault.mjs';
import { createJobEngine } from '../lib/jobs.mjs';
import * as serviceauth from '../lib/serviceauth.mjs';
import * as notifyLib from '../lib/notify.mjs';
import { newId } from '../lib/crypto.mjs';
import { MIGRATIONS } from './schema.mjs';
import { registerKinds } from './jobs.mjs';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
export function createHalDeps({ config, dbFile = null, log = () => {}, fetchImpl = null, version = VERSION, concurrency = 64 } = {}) {
const store = openDb(dbFile || join(config.dataDir, 'hal.sqlite'), MIGRATIONS);
const vault = openVault(config.dataDir);
const d = { store, vault, config, log, version, instanceId: newId('hal_'), serviceauth, notifyLib, fetch: fetchImpl, emergencyRunner: null, jobsRunning: false, stopping: false };
d.jobs = createJobEngine(store, { log, workerId: `hal@${process.pid}`, concurrency, pollMs: 500 });
registerKinds(d.jobs, d);
return d;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* hal/dr.mjs — 플랫폼 재해복구(§34).
*
* 복구 대상: HAL 운영 정보(hal.sqlite: 서버·Agent·릴리스·작업·모듈 배치·운영자 계정·접근 설정), 진행 중 작업, 보관소(vault.json; 키 파일은 **포함하지 않는다** — 운영자가 따로 보관), config.json, artifact 목록.
* 메일 원문·고객 사이트 데이터는 대상이 아니다(고객용 백업·메일 정책 별도).
* - 설정(dr_config): RTO·RPO·복구 사본 주기·보존 개수·외부 보관 위치. 출시 검증에서 비어 있으면 안 된다(readiness).
* - 복구 사본(snapshot): `VACUUM INTO` 로 일관된 DB 사본 + vault.json + config.json 을 tar.gz 로 묶고 sha256·읽기 검증. 외부 보관 위치로 복사(지정 시).
* - 복원(restore): 별도 승인 + "기존 제어 위치 중지" 확인이 있어야 시작. 실제 파일 복원은 deploy/hal-restore.sh 가 하고, 새 HAL 기동 때 claimLive 가 "하나의 HAL" 을 보장한다.
* 복원 뒤 checks(운영자 로그인·HAL 제어·HTTPS·고객 서비스 1개 외부 확인)와 실제 상태 비교(diff) 를 기록한다.
* - 훈련(drill): 실제 측정 RTO/RPO 와 단계 결과. 마지막 훈련이 성공해야 '정식 운영 준비 완료'.
*/
import { newId } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { JobError } from '../lib/jobs.mjs';
import { createHash } from 'node:crypto';
import { mkdirSync, existsSync, readFileSync, writeFileSync, statSync, copyFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import * as core from './core.mjs';
export function getConfig(d) { return d.store.get('SELECT * FROM dr_config WHERE id=1') || { rto_minutes: null, rpo_minutes: null, snapshot_interval_minutes: null, retention_count: null, external_store: null }; }
export function setConfig(d, patch, by) {
const c = { ...getConfig(d), ...Object.fromEntries(Object.entries(patch).filter(([k]) => ['rto_minutes', 'rpo_minutes', 'snapshot_interval_minutes', 'retention_count', 'external_store'].includes(k))) };
for (const k of ['rto_minutes', 'rpo_minutes', 'snapshot_interval_minutes', 'retention_count']) if (c[k] != null && !(Number(c[k]) > 0)) throw new JobError('bad_value', `${k} 은(는) 0보다 커야 합니다`);
d.store.run('INSERT INTO dr_config(id, rto_minutes, rpo_minutes, snapshot_interval_minutes, retention_count, external_store, updated_at, updated_by) VALUES(1,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET rto_minutes=excluded.rto_minutes, rpo_minutes=excluded.rpo_minutes, snapshot_interval_minutes=excluded.snapshot_interval_minutes, retention_count=excluded.retention_count, external_store=excluded.external_store, updated_at=excluded.updated_at, updated_by=excluded.updated_by', c.rto_minutes, c.rpo_minutes, c.snapshot_interval_minutes, c.retention_count, c.external_store, nowIso(), by);
audit(d.store, { actorType: 'operator', actorId: by, action: 'dr.config', detail: c }); return getConfig(d);
}
export const snapshotDir = (d) => { const p = join(d.config.dataDir, 'dr'); mkdirSync(p, { recursive: true, mode: 0o700 }); return p; };
const sha = (f) => createHash('sha256').update(readFileSync(f)).digest('hex');
/** 복구 사본 만들기(동기, 수 초). DB 는 VACUUM INTO 로 일관 사본. */
export function snapshot(d, { kind = 'scheduled', by = null } = {}) {
const id = newId('drs_'); const dir = snapshotDir(d); const work = join(dir, id); mkdirSync(work, { recursive: true });
try {
const dbCopy = join(work, 'hal.sqlite'); d.store.exec(`VACUUM INTO '${dbCopy.replace(/'/g, "''")}'`);
for (const f of ['vault.json', 'config.json']) { const p = join(d.config.dataDir, f); if (existsSync(p)) copyFileSync(p, join(work, f)); }
const rel = core.listReleases(d).map((r) => ({ version: r.version, main_commit: r.main_commit, artifacts: r.artifacts, status: r.status })); const meta = { id, createdAt: nowIso(), halVersion: d.version, instanceId: d.instanceId, releases: rel, note: 'vault.key 는 포함하지 않음 — 운영자가 따로 보관' };
writeFileSync(join(work, 'meta.json'), JSON.stringify(meta, null, 1));
const tar = join(dir, `${id}.tar.gz`); execFileSync('tar', ['-czf', tar, '-C', work, '.']); rmSync(work, { recursive: true, force: true });
const sum = sha(tar); const size = statSync(tar).size;
// 읽기 검증: 목록을 읽을 수 있고 meta.json 이 들어 있어야 한다
const list = execFileSync('tar', ['-tzf', tar]).toString(); const ok = list.includes('meta.json') && list.includes('hal.sqlite');
d.store.run('INSERT INTO dr_snapshots(id, created_at, location, size_bytes, sha256, verify_state, verified_at, kind) VALUES(?,?,?,?,?,?,?,?)', id, nowIso(), tar, size, sum, ok ? 'ok' : 'failed', nowIso(), kind);
const cfg = getConfig(d);
if (ok && cfg.external_store) { try { mkdirSync(cfg.external_store, { recursive: true }); const ext = join(cfg.external_store, `${id}.tar.gz`); copyFileSync(tar, ext); if (sha(ext) !== sum) throw new Error('외부 사본 해시 불일치'); d.store.run('UPDATE dr_snapshots SET external_copied_at=?, external_location=? WHERE id=?', nowIso(), ext, id); } catch (e) { d.store.run('UPDATE dr_snapshots SET error=? WHERE id=?', `외부 보관 실패: ${e.message}`, id); } }
prune(d); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'dr.snapshot', subjectType: 'dr_snapshot', subjectId: id, detail: { size, ok, kind }, automatic: !by });
return d.store.get('SELECT * FROM dr_snapshots WHERE id=?', id);
} catch (e) { rmSync(work, { recursive: true, force: true }); d.store.run('INSERT INTO dr_snapshots(id, created_at, location, verify_state, kind, error) VALUES(?,?,?,\'failed\',?,?)', id, nowIso(), '', kind, e.message); throw e; }
}
/** 보존 개수 초과 사본 삭제(외부 사본은 유지) */
export function prune(d) { const n = getConfig(d).retention_count || 14; const rows = d.store.all('SELECT id, location FROM dr_snapshots WHERE deleted_at IS NULL AND verify_state=\'ok\' ORDER BY created_at DESC, rowid DESC'); let k = 0; for (const r of rows.slice(n)) { try { rmSync(r.location, { force: true }); } catch (_) {} d.store.run('UPDATE dr_snapshots SET deleted_at=? WHERE id=?', nowIso(), r.id); k++; } return k; }
export function listSnapshots(d) { return d.store.all('SELECT * FROM dr_snapshots ORDER BY created_at DESC, rowid DESC LIMIT 100'); }
/** 복원 요청·승인·시작. 시작에는 "기존 제어 위치 중지" 확인이 필요하다. */
export function requestRestore(d, { snapshotId, by, note = '' }) { const s = d.store.get('SELECT * FROM dr_snapshots WHERE id=? AND deleted_at IS NULL', snapshotId); if (!s || s.verify_state !== 'ok') throw new JobError('snapshot_unusable', '검증된 복구 사본이 아닙니다'); const id = newId('drr_'); d.store.run('INSERT INTO dr_restores(id, snapshot_id, requested_by, status, note) VALUES(?,?,?,\'requested\',?)', id, snapshotId, by, note); audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_request', subjectType: 'dr_restore', subjectId: id, immutable: true }); return d.store.get('SELECT * FROM dr_restores WHERE id=?', id); }
export function approveRestore(d, id, by) { d.store.run('UPDATE dr_restores SET approved_by=?, approved_at=?, status=\'approved\' WHERE id=? AND status=\'requested\'', by, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_approve', subjectType: 'dr_restore', subjectId: id, immutable: true }); return d.store.get('SELECT * FROM dr_restores WHERE id=?', id); }
export function startRestore(d, id, { by, oldControlStopped, confirmText }) {
const r = d.store.get('SELECT * FROM dr_restores WHERE id=?', id); if (!r || r.status !== 'approved') throw new JobError('restore_not_approved', '복원은 별도 승인 뒤에만 시작할 수 있습니다');
if (oldControlStopped !== true || confirmText !== '기존 제어 위치를 중지했습니다') throw new JobError('old_control_not_confirmed', '기존 제어 위치(HAL)의 중지 확인이 필요합니다');
const s = d.store.get('SELECT * FROM dr_snapshots WHERE id=?', r.snapshot_id);
d.store.run('UPDATE dr_restores SET old_control_stopped_confirmed_at=?, started_at=?, status=\'started\' WHERE id=?', nowIso(), nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_start', subjectType: 'dr_restore', subjectId: id, immutable: true });
return { restore: d.store.get('SELECT * FROM dr_restores WHERE id=?', id), command: `sudo bash /srv/ariweb/hal/current/deploy/hal-restore.sh ${s.location} ${s.sha256}`, note: '새 HAL 기동 때 다른 인스턴스의 최근 임대가 있으면 기동을 거부합니다(하나의 HAL 만 운영)' };
}
/** 복원 뒤 확인·차이 기록(§34: 운영자 로그인·HAL 제어·HTTPS·고객 서비스 1개 외부 확인 + 실제 상태 비교) */
export function finishRestore(d, id, { by, checks }) {
const req = ['operatorLogin', 'halControl', 'https', 'customerService']; const missing = req.filter((k) => !checks || checks[k] == null);
if (missing.length) throw new JobError('checks_incomplete', `필수 확인 항목이 비었습니다: ${missing.join(', ')}`);
const dup = d.store.get('SELECT COUNT(*) AS n FROM jobs WHERE status=\'running\'').n; const drift = d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)');
const ok = req.every((k) => checks[k] === true) && dup === 0; const diff = { runningJobsAtFinish: dup, drift, offline: core.markOffline(d).map((x) => x.id) };
d.store.run('UPDATE dr_restores SET finished_at=?, status=?, checks_json=?, diff_json=? WHERE id=?', nowIso(), ok ? 'completed' : 'completed_with_findings', toJson(checks), toJson(diff), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.restore_finish', subjectType: 'dr_restore', subjectId: id, detail: { ok, diff }, immutable: true });
return d.store.get('SELECT * FROM dr_restores WHERE id=?', id);
}
/** 훈련 기록 */
export function startDrill(d, { by, snapshotId = null }) { const id = newId('drd_'); d.store.run('INSERT INTO dr_drills(id, started_at, snapshot_id, performed_by, steps_json, result) VALUES(?,?,?,?,\'[]\',\'running\')', id, nowIso(), snapshotId, by); return d.store.get('SELECT * FROM dr_drills WHERE id=?', id); }
export function finishDrill(d, id, { by, steps, measuredRtoMinutes, measuredRpoMinutes, findings = [] }) {
const req = ['hal', 'operatorLogin', 'vault', 'certBase', 'httpsCustomer']; const ok = req.every((k) => steps && steps[k] === true); const cfg = getConfig(d);
const within = cfg.rto_minutes && cfg.rpo_minutes ? measuredRtoMinutes <= cfg.rto_minutes && measuredRpoMinutes <= cfg.rpo_minutes : false;
const result = ok && within ? 'success' : 'failed'; const next = new Date(Date.now() + 90 * 86400000).toISOString();
d.store.run('UPDATE dr_drills SET finished_at=?, steps_json=?, measured_rto_minutes=?, measured_rpo_minutes=?, result=?, findings_json=?, next_due_at=? WHERE id=?', nowIso(), toJson(steps), measuredRtoMinutes, measuredRpoMinutes, result, toJson([...(findings || []), ...(within ? [] : ['측정값이 승인된 RTO/RPO 를 넘었거나 목표가 설정되지 않음'])]), next, id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'recovery.drill', subjectType: 'dr_drill', subjectId: id, detail: { result, measuredRtoMinutes, measuredRpoMinutes }, immutable: true });
return d.store.get('SELECT * FROM dr_drills WHERE id=?', id);
}
/** 정식 운영 준비 상태(§34): 설정 값이 모두 있고 마지막 훈련이 성공 */
export function readiness(d) {
const cfg = getConfig(d); const missing = ['rto_minutes', 'rpo_minutes', 'snapshot_interval_minutes', 'retention_count'].filter((k) => !(Number(cfg[k]) > 0));
const lastDrill = d.store.get('SELECT * FROM dr_drills WHERE result<>\'running\' ORDER BY started_at DESC, rowid DESC LIMIT 1'); const lastSnap = d.store.get('SELECT * FROM dr_snapshots WHERE verify_state=\'ok\' AND deleted_at IS NULL ORDER BY created_at DESC, rowid DESC LIMIT 1');
const ready = !missing.length && !!lastDrill && lastDrill.result === 'success' && !!lastSnap;
return { ready, missingConfig: missing, lastDrill: lastDrill ? { id: lastDrill.id, result: lastDrill.result, at: lastDrill.finished_at, measuredRtoMinutes: lastDrill.measured_rto_minutes, measuredRpoMinutes: lastDrill.measured_rpo_minutes, nextDueAt: lastDrill.next_due_at } : null, lastSnapshot: lastSnap ? { id: lastSnap.id, at: lastSnap.created_at, external: !!lastSnap.external_copied_at } : null, config: cfg, label: ready ? '정식 운영 준비 완료' : '정식 운영 준비 전(복구 훈련 또는 설정 필요)' };
}
+72
View File
@@ -0,0 +1,72 @@
/**
* hal/emergency.mjs — 긴급 복구(§33).
*
* HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용한다: 선언(incident key + 사유) 때 HAL 자체 건강이 정상이고 열린 critical 사건이 없으면 거부한다.
* 단계: declared → approved(서로 다른 내용을 확인하는 3단계: 1 영향 범위, 2 대상 식별정보 재입력, 3 최종 실행·되돌리기 가능 여부) → credential_issued(짧은 유효 시간, 1회 표시) →
* performing(허용 범위 안의 행동만: hal.restart / hal.restore_snapshot / vault.verify / cert.reissue_internal / artifact.restore / agent.reconnect) → verified → credential_revoked → reconciled → closed.
* 운영자가 한 명이어도 되지만 세 단계의 내용과 시각이 모두 기록되어야 한다. 일반 고객 서비스 배포(module.deploy 등)는 어떤 경우에도 거부한다.
*/
import { newId, randomToken, sha256, safeEqual } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { JobError } from '../lib/jobs.mjs';
import * as core from './core.mjs';
export const SCOPES = ['hal', 'hal_data', 'vault', 'cert_base', 'artifact_store', 'first_agent'];
export const ACTIONS = { 'hal.restart': 'hal', 'hal.restore_snapshot': 'hal_data', 'vault.verify': 'vault', 'cert.reissue_internal': 'cert_base', 'artifact.restore': 'artifact_store', 'agent.reconnect': 'first_agent' };
const STEP_TITLES = ['영향 범위 확인', '변경되지 않는 대상 식별정보 재확인', '최종 실행과 되돌리기 가능 여부 확인'];
function get(d, id) { const r = d.store.get('SELECT * FROM emergency_sessions WHERE id=?', id); if (!r) throw new JobError('emergency_not_found', '긴급 복구 세션이 없습니다'); return { ...r, scope: parseJson(r.scope_json, []), approvals: parseJson(r.approvals_json, []), actions: parseJson(r.actions_json, []), verify: parseJson(r.verify_json, null), reconcile: parseJson(r.reconcile_json, null) }; }
export const getSession = get;
export function listSessions(d) { return d.store.all('SELECT * FROM emergency_sessions ORDER BY declared_at DESC LIMIT 50').map((r) => get(d, r.id)); }
export function declare(d, { incidentKey, reason, scope, by, force = false }) {
if (!incidentKey || !reason) throw new JobError('declare_incomplete', '사건 번호와 복구 이유가 필요합니다');
const bad = (scope || []).filter((s) => !SCOPES.includes(s)); if (bad.length || !scope?.length) throw new JobError('bad_scope', `허용 대상은 ${SCOPES.join(', ')} 입니다`);
const health = core.selfHealth(d); const openCritical = d.store.get('SELECT id FROM incidents WHERE severity=\'critical\' AND resolved_at IS NULL');
if (health.ok && !openCritical && !force) throw new JobError('hal_healthy', 'HAL 정상 작업 기능을 쓸 수 있는 상태입니다 — 긴급 복구가 아니라 일반 작업을 쓰세요. 실제 장애면 사건을 먼저 등록하세요');
const id = newId('em_'); const now = nowIso();
d.store.run('INSERT INTO emergency_sessions(id, incident_key, reason, scope_json, status, declared_by, declared_at, hal_healthy_at_declare, updated_at) VALUES(?,?,?,?,\'declared\',?,?,?,?)', id, incidentKey, reason, toJson(scope), by, now, health.ok ? 1 : 0, now);
audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.declare', subjectType: 'emergency', subjectId: id, detail: { incidentKey, reason, scope, forced: force && health.ok }, immutable: true });
return get(d, id);
}
/** 3단계 승인. 각 단계는 서로 다른 입력을 요구한다(같은 문구 반복 불가). */
export function approve(d, id, { step, by, impactText, identifiers, finalConfirm, revertible }) {
const s = get(d, id); if (!['declared', 'approving'].includes(s.status)) throw new JobError('bad_state', `지금 상태(${s.status})에서는 승인할 수 없습니다`);
const n = s.approvals.length + 1; if (step !== n) throw new JobError('wrong_step', `지금은 ${n}단계(${STEP_TITLES[n - 1]})를 진행해야 합니다`);
let record;
if (n === 1) { if (!impactText || String(impactText).length < 10) throw new JobError('impact_required', '영향 범위를 구체적으로 적어야 합니다(10자 이상)'); record = { step: 1, title: STEP_TITLES[0], impactText, by, at: nowIso() }; }
else if (n === 2) { const need = s.scope.slice().sort(); const got = (identifiers || []).slice().sort(); if (JSON.stringify(need) !== JSON.stringify(got)) throw new JobError('identifiers_mismatch', `대상 식별정보가 선언한 범위(${need.join(', ')})와 다릅니다`); record = { step: 2, title: STEP_TITLES[1], identifiers: got, by, at: nowIso() }; }
else { if (finalConfirm !== `복구 실행 ${s.incident_key}`) throw new JobError('final_confirm_mismatch', `최종 확인 문구는 "복구 실행 ${s.incident_key}" 이어야 합니다`); if (typeof revertible !== 'boolean') throw new JobError('revertible_required', '되돌리기 가능 여부를 명시해야 합니다'); record = { step: 3, title: STEP_TITLES[2], finalConfirm: true, revertible, by, at: nowIso() }; }
const approvals = [...s.approvals, record]; const status = approvals.length === 3 ? 'approved' : 'approving';
d.store.run('UPDATE emergency_sessions SET approvals_json=?, status=?, updated_at=? WHERE id=?', toJson(approvals), status, nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: `emergency.approve_${n}`, subjectType: 'emergency', subjectId: id, detail: record, immutable: true });
return get(d, id);
}
/** 자격 발급(승인 3단계 완료 뒤, 1회 표시, 짧은 유효 시간) */
export function issueCredential(d, id, by) {
const s = get(d, id); if (s.status !== 'approved' || s.approvals.length < 3) throw new JobError('approvals_insufficient', `승인이 부족합니다(${s.approvals.length}/3)`);
const cred = randomToken(32); const exp = new Date(Date.now() + d.config.emergencyTtlMinutes * 60000).toISOString();
d.store.run('UPDATE emergency_sessions SET credential_hash=?, credential_issued_at=?, credential_expires_at=?, status=\'credential_issued\', updated_at=? WHERE id=?', sha256(cred), nowIso(), exp, nowIso(), id);
audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.credential_issue', subjectType: 'emergency', subjectId: id, detail: { expiresAt: exp }, immutable: true });
return { credential: cred, expiresAt: exp };
}
function checkCredential(s, credential) { if (!s.credential_hash) throw new JobError('no_credential', '자격이 발급되지 않았습니다'); if (s.credential_revoked_at) throw new JobError('credential_revoked', '자격이 폐기되었습니다'); if (Date.parse(s.credential_expires_at) < Date.now()) throw new JobError('credential_expired', '긴급 자격이 만료되었습니다 — 새로 승인·발급하세요'); if (!safeEqual(sha256(String(credential || '')), s.credential_hash)) throw new JobError('bad_credential', '자격이 올바르지 않습니다'); }
/** 행동 수행. 허용 범위 밖·일반 배포는 거부. 실행기(d.emergencyRunner)가 실제 동작을 하고 결과를 돌려준다(테스트는 가짜). */
export async function perform(d, id, { credential, action, target = null, by }) {
const s = get(d, id); checkCredential(s, credential);
if (!['credential_issued', 'performing'].includes(s.status)) throw new JobError('bad_state', `지금 상태(${s.status})에서는 수행할 수 없습니다`);
const scopeOf = ACTIONS[action];
if (!scopeOf) { log(d, id, { action, target, by, result: 'rejected', reason: 'not_an_emergency_action' }); throw new JobError('action_forbidden', `긴급 복구에서 허용되지 않는 행동입니다: ${action} (일반 배포는 HAL 정상화 뒤 HAL 작업으로만)`); }
if (!s.scope.includes(scopeOf)) { log(d, id, { action, target, by, result: 'rejected', reason: 'out_of_scope' }); throw new JobError('out_of_scope', `선언한 허용 대상(${s.scope.join(', ')}) 밖의 행동입니다: ${action}`); }
let result; try { result = d.emergencyRunner ? await d.emergencyRunner(action, target, s) : { ok: true, simulated: true }; } catch (e) { log(d, id, { action, target, by, result: 'failed', error: e.message }); throw e; }
log(d, id, { action, target, by, result: 'ok', detail: result }); d.store.run('UPDATE emergency_sessions SET status=\'performing\', updated_at=? WHERE id=?', nowIso(), id);
return result;
}
function log(d, id, entry) { const s = get(d, id); const actions = [...s.actions, { at: nowIso(), ...entry }]; d.store.run('UPDATE emergency_sessions SET actions_json=?, updated_at=? WHERE id=?', toJson(actions), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: entry.by, action: 'emergency.action', subjectType: 'emergency', subjectId: id, outcome: entry.result === 'ok' ? 'ok' : entry.result === 'rejected' ? 'denied' : 'failed', detail: entry, immutable: true }); }
/** 실제 결과 확인 기록 → 자격 폐기 */
export function verifyAndRevoke(d, id, { by, verify }) { const s = get(d, id); if (!['performing', 'credential_issued'].includes(s.status)) throw new JobError('bad_state', '수행 단계가 아닙니다'); const health = core.selfHealth(d); const v = { ...(verify || {}), halHealth: health.ok, at: nowIso() }; d.store.run('UPDATE emergency_sessions SET verify_json=?, credential_revoked_at=?, status=\'verified\', updated_at=? WHERE id=?', toJson(v), nowIso(), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.verify_revoke', subjectType: 'emergency', subjectId: id, detail: v, immutable: true }); return get(d, id); }
/** HAL 정상화 뒤 실제 상태와 원하는 상태 비교(모듈 목표·실제 버전 차이, 오프라인 서버) → 차이 목록 */
export function reconcile(d, id, by) { const s = get(d, id); if (s.status !== 'verified') throw new JobError('bad_state', '검증·자격 폐기 뒤에 조정할 수 있습니다'); const drift = d.store.all('SELECT module, server_id, target_version, actual_version, health FROM module_targets WHERE (target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)) OR health=\'down\''); const offline = core.markOffline(d); const r = { at: nowIso(), drift, offlineServers: offline.map((x) => x.id), halHealth: core.selfHealth(d).ok }; d.store.run('UPDATE emergency_sessions SET reconcile_json=?, status=\'reconciled\', updated_at=? WHERE id=?', toJson(r), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.reconcile', subjectType: 'emergency', subjectId: id, detail: r, immutable: true }); return r; }
export function close(d, id, by, note = '') { const s = get(d, id); if (!['reconciled', 'verified', 'declared', 'approving', 'approved'].includes(s.status)) throw new JobError('bad_state', '지금 상태에서는 종료할 수 없습니다'); if (s.credential_hash && !s.credential_revoked_at) d.store.run('UPDATE emergency_sessions SET credential_revoked_at=? WHERE id=?', nowIso(), id); d.store.run('UPDATE emergency_sessions SET status=\'closed\', closed_at=?, closed_by=?, updated_at=? WHERE id=?', nowIso(), by, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.close', subjectType: 'emergency', subjectId: id, detail: { note }, immutable: true }); return get(d, id); }
/** 만료 자격 자동 폐기(주기) */
export function expireCredentials(d) { return d.store.run('UPDATE emergency_sessions SET credential_revoked_at=?, updated_at=? WHERE credential_hash IS NOT NULL AND credential_revoked_at IS NULL AND credential_expires_at<?', nowIso(), nowIso(), nowIso()).changes; }
+56
View File
@@ -0,0 +1,56 @@
/**
* hal/index.mjs — HAL 프로세스 진입점과 CLI.
*
* node hal/index.mjs 서버 실행(기본). 기동 때 "하나의 HAL" 임대를 잡고(§34) 실행 버전을 기록한다(§32).
* node hal/index.mjs setup-token 최초 운영자 계정을 만들 설정 토큰을 발급(1회 표시, data/setup.token). 부트스트랩 스크립트가 부른다.
* node hal/index.mjs grant --login <id> [--hours 8] 허용 네트워크 밖 기기용 접근권 URL 발급(§25)
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
*/
import { loadConfig } from './config.mjs';
import { createHalDeps, VERSION } from './deps.mjs';
import { buildApp } from './app.mjs';
import * as core from './core.mjs';
import * as ops from './ops.mjs';
import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import { purgeSessions } from '../lib/session.mjs';
import { randomToken } from '../lib/crypto.mjs';
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
const log = (...a) => console.log(new Date().toISOString(), ...a);
const cfg = loadConfig(); const args = process.argv.slice(2); const cmd = args[0] && !args[0].startsWith('--') ? args[0] : 'serve';
const opt = (name, def = null) => { const i = args.indexOf('--' + name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def; };
const d = createHalDeps({ config: cfg, log });
if (cmd === 'setup-token') {
if (core.ensureOpsAccountExists(d)) { console.error('운영자 계정이 이미 있습니다 — 설정 토큰은 최초 1회만 쓸 수 있습니다'); process.exit(1); }
const t = randomToken(24); writeFileSync(join(cfg.dataDir, 'setup.token'), t, { mode: 0o600 });
console.log(`설정 토큰(1회 표시, 공유 금지): ${t}\n운영자 콘솔 ${cfg.publicUrl}/ops/ 의 최초 설정 또는 curl -X POST ${cfg.publicUrl}/ops/api/setup -d '{"setupToken":"…","login":"…","password":"…","displayName":"…"}' 로 첫 운영자 계정을 만드세요`);
} else if (cmd === 'grant') {
const g = ops.issueGrant(d, { accountId: opt('login'), hours: Number(opt('hours', 8)), by: null, reason: opt('reason', 'cli') }); console.log(`접근권 URL(1회, ${g.expiresAt} 까지): ${g.url}`);
} else if (cmd === 'register-release') {
const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8')); const dir = join(core.artifactDir(d), mf.version); mkdirSync(dir, { recursive: true });
for (const [m, a] of Object.entries(mf.artifacts)) { const src = join(dirname(opt('manifest')), a.file); if (existsSync(src)) copyFileSync(src, join(dir, basename(a.file))); }
const r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size }])), checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') });
console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`);
} else if (cmd === 'link-portal') {
// [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다.
const shared = randomToken(32); d.vault.set('service:portal', shared, { kind: 'service-key' }); d.vault.set('service:hal', shared, { kind: 'service-key' });
console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`);
} else if (cmd === 'bootstrap-release') {
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
} else if (cmd === 'serve') {
// "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만).
try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); }
const app = buildApp(d); d.jobs.start(); d.jobsRunning = true;
const every = (ms, fn) => setInterval(() => { try { const r = fn(); if (r && r.catch) r.catch((e) => log('[hal] 주기 작업 오류', e.message)); } catch (e) { log('[hal] 주기 작업 오류', e.message); } }, ms).unref();
every(20000, () => core.renewLease(d, d.instanceId)); every(30000, () => core.markOffline(d)); every(3000, () => core.flushPortalEvents(d)); every(60000, () => emergency.expireCredentials(d));
every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); });
every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); });
await app.listen(cfg.port, cfg.host);
log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`);
process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); });
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
+167
View File
@@ -0,0 +1,167 @@
/**
* hal/jobs.mjs — HAL 작업 종류(lib/jobs 엔진에 등록).
*
* 두 부류가 있다.
* 1) 포털이 요청하는 작업(docs/HAL-JOBS.md): site.*, domain.*, backup.* — 대상 서버(또는 외부 Agent)에 Agent 작업을 배정하고 결과를 기다린 뒤 돌려준다.
* 복합 작업(backup.create 의 원격 저장, site.relocate, backup.restore)은 여러 Agent 작업을 단계로 잇는다. 모든 단계는 ctx.step 으로 기록되어 HAL 재시작 뒤 이어서 실행된다.
* 2) 운영자 작업(§32·§29): module.deploy / module.rollback / module.health / module.reconcile — 대상 서버마다 자식 작업을 만들고 대상별 결과를 요약한다. 일부 실패는 'partial' 이며 성공으로 보이지 않는다.
*
* 대상(target) 해석: 서버 id(srv_…) → 그 서버의 Agent, Agent id(ag_…) → 그 Agent(외부 Agent 포함).
*/
import { JobError } from '../lib/jobs.mjs';
import { nowIso, toJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { notify } from '../lib/notify.mjs';
import { safeError } from '../lib/redact.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as core from './core.mjs';
import { descriptor, DEPLOY_ORDER, ARTIFACT_MODULES } from './modules/index.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** 대상 → Agent id */
function agentFor(d, target) {
if (!target) throw new JobError('target_required', '대상 서버 또는 Agent 가 필요합니다');
if (String(target).startsWith('ag_')) { const a = core.getAgent(d, target); if (!a || a.status !== 'active') throw new JobError('agent_unavailable', 'Agent 가 없거나 해지되었습니다'); return a.id; }
const s = core.getServer(d, target); if (!s) throw new JobError('server_not_found', `서버가 없습니다: ${target}`);
if (!s.agent_id) throw new JobError('agent_missing', `서버 ${s.name} 에 Agent 가 없습니다`);
if (s.status === 'isolated') throw new JobError('server_isolated', `서버 ${s.name} 은(는) 격리 상태입니다`);
return s.agent_id;
}
/** 외부 접속 확인(§11.2 8단계). 실제 응답을 본다. */
async function externalCheck(d, url, { expectMarker = null } = {}) {
if (!url) return { ok: false, skipped: 'no_url' };
const t0 = Date.now();
try { const r = await (d.fetch || fetch)(url, { redirect: 'follow', signal: AbortSignal.timeout(15000), headers: { 'User-Agent': 'Ariweb-HAL-check/0.1' } }); const text = expectMarker ? await r.text() : ''; return { ok: r.ok && (!expectMarker || text.includes(expectMarker)), status: r.status, ms: Date.now() - t0, url, markerFound: expectMarker ? text.includes(expectMarker) : null }; }
catch (e) { return { ok: false, error: safeError(e), ms: Date.now() - t0, url }; }
}
/** 단일 Agent 작업으로 끝나는 종류를 등록하는 도우미 */
function single(engine, d, kind, { title, agentKind = kind, steps = [], build = (s) => s.payload || {}, after = null, maxAttempts = 1, target = (s) => s.target }) {
engine.register(kind, { title, maxAttempts, steps: [{ id: 'dispatch', title: '대상 Agent 에 작업 배정' }, { id: 'await', title: '대상 서버에서 실행' }, ...steps],
async run(job, ctx) {
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, target(job.subject)), kind: agentKind, payload: build(job.subject, d) });
const result = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
return after ? after(job, ctx, result) : result;
},
retryable: (e) => !!e.retryable,
});
}
export function registerKinds(engine, d) {
// ---------------- 사이트(§11)
single(engine, d, 'site.deploy', { title: '사이트 배포', steps: [{ id: 'external', title: '외부 접속 확인' }], build: (s) => ({ ...s.payload, siteId: s.payload.siteId }),
async after(job, ctx, result) {
const ext = await ctx.step('external', () => externalCheck(d, job.subject.payload.externalCheckUrl || result?.siteUrl, { expectMarker: job.subject.payload.expectMarker }));
return { ...result, externalCheck: ext };
} });
single(engine, d, 'site.rollback', { title: '이전 버전으로 되돌리기' });
single(engine, d, 'site.remove', { title: '배포 제거' });
single(engine, d, 'site.check', { title: '사이트 확인', async after(job, ctx, result) { const urls = job.subject.payload?.urls || []; const ext = await ctx.step('external', async () => { const out = []; for (const u of urls) out.push(await externalCheck(d, u, { expectMarker: job.subject.payload?.expectMarker })); return out; }, '외부 확인'); return { ...result, external: ext }; } });
single(engine, d, 'site.usage', { title: '사용량 수집', maxAttempts: 3 });
single(engine, d, 'domain.vhost', { title: '도메인 연결 설정' });
single(engine, d, 'domain.cert', { title: 'HTTPS 인증서 발급', maxAttempts: 2 });
single(engine, d, 'domain.probe', { title: 'DNS 확인', maxAttempts: 3 });
// ---------------- 백업(§19·§20). 저장 서버가 운영 서버와 다르면 HAL 전송을 거쳐 옮긴다.
engine.register('backup.create', { title: '백업 만들기', steps: [{ id: 'create', title: '운영 서버에서 백업 생성' }, { id: 'store', title: '백업 저장소로 복사' }, { id: 'verify', title: '읽을 수 있는지 확인' }],
async run(job, ctx) {
const s = job.subject; const p = s.payload || {}; const siteAgent = agentFor(d, s.target); const storeServer = p.storeServerId && p.storeServerId !== s.target ? p.storeServerId : null;
const created = await ctx.step('create', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: siteAgent, kind: 'backup.create', payload: { ...p, uploadToHal: !!storeServer, transferId: `tr_bk_${job.id}` } }); return core.awaitAgentJob(d, ctx, id); });
let location = `${s.target}:${created.path}`; let stored = created;
if (storeServer) stored = await ctx.step('store', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, storeServer), kind: 'backup.store', payload: { siteId: p.siteId, backupId: created.backupId, transferId: created.transferId, sha256: created.checksum, sizeBytes: created.sizeBytes } }); const r = await core.awaitAgentJob(d, ctx, id); location = `${storeServer}:${r.path}`; return r; });
else await ctx.step('store', () => ({ skipped: '같은 서버에 보관(저장 서버 미지정)', warning: '운영 서버와 분리된 저장소를 권장합니다(§19.2)' }));
const verified = await ctx.step('verify', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, storeServer || s.target), kind: 'backup.verify', payload: { location: stored.path, checksum: created.checksum } }); return core.awaitAgentJob(d, ctx, id); });
if (!verified?.ok) throw new JobError('backup_unreadable', '생성한 백업을 읽을 수 없습니다');
return { backupId: created.backupId, location, sizeBytes: created.sizeBytes, checksum: created.checksum, verified: true, createdAt: created.createdAt, scope: created.scope };
} });
const splitLoc = (loc) => { const i = String(loc).indexOf(':'); return i > 0 ? { serverId: loc.slice(0, i), path: loc.slice(i + 1) } : { serverId: null, path: loc }; };
single(engine, d, 'backup.verify', { title: '백업 검증', target: (s) => splitLoc(s.payload.location).serverId || s.target, build: (s) => ({ location: splitLoc(s.payload.location).path, checksum: s.payload.checksum }) });
single(engine, d, 'backup.delete', { title: '백업 삭제', target: (s) => splitLoc(s.payload.location).serverId || s.target, build: (s) => ({ location: splitLoc(s.payload.location).path }) });
engine.register('backup.restore', { title: '사이트 복원', steps: [{ id: 'fetch', title: '백업 저장소에서 가져오기' }, { id: 'protect', title: '현재 상태 보호' }, { id: 'isolated', title: '격리된 환경에서 백업 확인' }, { id: 'restore', title: '데이터 복원' }, { id: 'external', title: '외부 접속 확인' }],
async run(job, ctx) {
const s = job.subject; const p = s.payload || {}; const loc = splitLoc(p.location); const siteAgent = agentFor(d, s.target); const remote = loc.serverId && loc.serverId !== s.target;
const fetched = await ctx.step('fetch', async () => { if (!remote) return { path: loc.path }; const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, loc.serverId), kind: 'backup.fetch', payload: { location: loc.path, transferId: `tr_rs_${job.id}`, checksum: p.checksum } }); return core.awaitAgentJob(d, ctx, id); });
const r = await ctx.step('restore', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: siteAgent, kind: 'backup.restore', payload: { ...p, location: remote ? null : fetched.path, transferId: remote ? fetched.transferId : null } }); return core.awaitAgentJob(d, ctx, id); });
await ctx.step('protect', () => ({ protectPointLocation: r.protectPointLocation })); await ctx.step('isolated', () => ({ ok: r.isolatedVerify }));
const ext = await ctx.step('external', () => externalCheck(d, p.externalCheckUrl, { expectMarker: p.expectMarker }));
if (p.externalCheckUrl && !ext.ok) throw new JobError('restore_external_check_failed', '복원 뒤 외부 접속 확인에 실패했습니다 — 보호 시점으로 되돌릴 수 있습니다', { detail: ext });
return { ...r, externalCheck: ext };
},
async revert(job, ctx) { const s = job.subject; const r = job.steps.find((x) => x.id === 'restore')?.result; if (!r?.protectPointLocation) throw new JobError('no_protect_point', '보호 시점이 없어 되돌릴 수 없습니다'); await ctx.step('revert', async () => { const id = core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, s.target), kind: 'backup.restore', payload: { siteId: s.payload.siteId, location: r.protectPointLocation, protectPoint: false, isolatedVerify: true }, dedupeKey: `${job.id}:revert` }); return core.awaitAgentJob(d, ctx, id); }, '보호 시점으로 되돌리기'); } });
// ---------------- 서버 이전(§21). 두 서버에 동시에 운영 상태가 되지 않도록 verify_new 뒤에만 switch 한다.
engine.register('site.relocate', { title: '서버 이전', steps: ['precheck', 'prepare', 'precopy', 'verify_copy', 'final_sync', 'verify_new', 'switch', 'external_check', 'cleanup'].map((id) => ({ id, title: { precheck: '조건 확인', prepare: '새 위치 준비', precopy: '데이터 사전 복사', verify_copy: '복사 결과 확인', final_sync: '최종 변경분 반영', verify_new: '새 위치 확인', switch: '연결 전환', external_check: '외부 접속 확인', cleanup: '이전 위치 정리' }[id] })),
async run(job, ctx) {
const p = job.subject.payload || {}; const from = agentFor(d, p.fromServerId); const to = agentFor(d, p.toServerId); const dyn = p.kind === 'dynamic';
await ctx.step('precheck', () => { const t = core.getServer(d, p.toServerId); const f = core.getServer(d, p.fromServerId); if (!t.accepts_new || t.status !== 'ready') throw new JobError('target_not_ready', `도착 서버 ${t.name} 이(가) 신규 배치를 받을 수 없습니다`); if (f.status === 'offline') throw new JobError('source_offline', '출발 서버가 응답하지 않습니다'); const conflict = d.store.get('SELECT id FROM jobs WHERE subject_id=? AND id<>? AND kind IN (\'site.deploy\',\'backup.restore\',\'site.relocate\') AND status IN (\'queued\',\'running\',\'awaiting_external\',\'needs_user\')', p.siteId, job.id); if (conflict) throw new JobError('conflict', `같은 사이트의 다른 작업(${conflict.id})이 진행 중입니다`); return { from: f.name, to: t.name }; });
await ctx.step('prepare', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.prepare', payload: { siteId: p.siteId } })));
const pre = await ctx.step('precopy', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.export', payload: { siteId: p.siteId, transferId: `tr_rl1_${job.id}`, includeData: true } })));
await ctx.step('verify_copy', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.import', payload: { siteId: p.siteId, transferId: pre.transferId, checksum: pre.checksum, stage: 'pre' } })));
const fin = await ctx.step('final_sync', async () => { if (dyn || p.writeFreeze) await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.freeze', payload: { siteId: p.siteId, freeze: true } })); const ex = await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.export', payload: { siteId: p.siteId, transferId: `tr_rl2_${job.id}`, includeData: true }, dedupeKey: `${job.id}:final` })); return core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.import', payload: { siteId: p.siteId, transferId: ex.transferId, checksum: ex.checksum, stage: 'final' }, dedupeKey: `${job.id}:final-import` })); });
const vn = await ctx.step('verify_new', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.stage_check', payload: { siteId: p.siteId, expectedChecksum: fin.checksum } })));
if (!vn?.ok) throw new JobError('verify_new_failed', '새 위치 확인에 실패해 전환하지 않습니다', { detail: vn });
await ctx.step('switch', async () => { ctx.checkCancel(); const a = await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: to, kind: 'site.activate', payload: { siteId: p.siteId, domains: p.domains || [], primaryDomain: p.primaryDomain, runtime: p.runtime || 'static' } })); await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: from, kind: 'site.deactivate', payload: { siteId: p.siteId, retainDays: p.retainDays || 7 } })); return { switchedAt: nowIso(), activate: a }; });
const ext = await ctx.step('external_check', () => externalCheck(d, p.externalCheckUrl, { expectMarker: p.expectMarker }));
if (p.externalCheckUrl && !ext.ok) throw new JobError('relocate_external_check_failed', '전환 뒤 외부 접속 확인에 실패했습니다 — 되돌리기를 검토하세요', { detail: ext });
const cl = await ctx.step('cleanup', () => ({ oldRetainedUntil: new Date(Date.now() + (p.retainDays || 7) * 86400000).toISOString(), note: '이전 위치 사본은 보존 기간 뒤 삭제 작업(site.remove)으로 정리' }));
return { newServerId: p.toServerId, switchedAt: job.steps?.find((x) => x.id === 'switch')?.result?.switchedAt || nowIso(), oldRetainedUntil: cl.oldRetainedUntil, externalCheck: ext };
},
async revert(job, ctx) { const p = job.subject.payload || {}; const sw = (job.steps || []).find((x) => x.id === 'switch'); if (!sw || sw.state !== 'done') { await ctx.step('revert_unfreeze', async () => core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, p.fromServerId), kind: 'site.freeze', payload: { siteId: p.siteId, freeze: false }, dedupeKey: `${job.id}:unfreeze` }))); return; }
await ctx.step('revert_switch', async () => { await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, p.fromServerId), kind: 'site.activate', payload: { siteId: p.siteId, domains: p.domains || [], primaryDomain: p.primaryDomain, runtime: p.runtime || 'static', reactivate: true }, dedupeKey: `${job.id}:revert-act` })); await core.awaitAgentJob(d, ctx, core.dispatchAgentJob(d, { halJobId: job.id, agentId: agentFor(d, p.toServerId), kind: 'site.deactivate', payload: { siteId: p.siteId, retainDays: 1 }, dedupeKey: `${job.id}:revert-deact` })); }, '이전 위치로 연결 되돌리기'); } });
// ---------------- 모듈 배포(§32)
const unitTemplate = (name) => { const f = name ? join(ROOT, 'deploy', 'units', name) : null; return f && existsSync(f) ? readFileSync(f, 'utf8') : null; };
/** 배포 전제: 릴리스 승인, artifact 존재, HAL 먼저(다른 모듈은 HAL 이 그 릴리스로 실행 중이어야), 부트스트랩 완료 */
function precheckModule(module, version) {
const rel = core.getRelease(d, version); if (!rel) throw new JobError('release_not_found', `릴리스 ${version} 이(가) 없습니다`);
if (rel.status !== 'approved') throw new JobError('release_not_approved', `릴리스 ${version} 은(는) 승인되지 않았습니다`);
if (ARTIFACT_MODULES.includes(module) && !core.artifactPath(d, version, module)) throw new JobError('artifact_missing', `릴리스 ${version} 의 ${module} artifact 가 없습니다`);
const health = core.selfHealth(d); if (!health.ok) throw new JobError('hal_unhealthy', 'HAL 자체 건강 확인에 실패해 배포하지 않습니다');
if (!core.bootstrapComplete(d)) throw new JobError('bootstrap_incomplete', '부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다');
if (module !== 'hal' && health.bootstrap.halRunningVersion !== version) throw new JobError('hal_first', `HAL 이 아직 ${version} 으로 실행되고 있지 않습니다(현재 ${health.bootstrap.halRunningVersion}). HAL 을 먼저 배포하세요`);
return rel;
}
engine.register('module.deploy.target', { title: '모듈 배포(대상)', steps: [{ id: 'dispatch', title: 'Agent 에 설치 지시' }, { id: 'await', title: '설치·재시작' }, { id: 'verify', title: '실제 버전·건강 확인' }],
async run(job, ctx) {
const { module, version, serverId, action = 'install' } = job.subject; const desc = descriptor(module); const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다');
const art = core.artifactPath(d, version, module);
d.store.run('INSERT INTO module_targets(module, server_id, target_version, last_job_id, updated_at) VALUES(?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET target_version=excluded.target_version, last_job_id=excluded.last_job_id, updated_at=excluded.updated_at', module, serverId, version, job.id, nowIso());
const facts = srv.facts || {}; const unitName = (facts.units && facts.units[module]) || desc.unit;
const disp = await core.ensureAgentJob(d, ctx, job, { agentId: agentFor(d, serverId), kind: action === 'rollback' ? 'module.rollback' : 'module.install', title: 'Agent 에 설치 지시', payload: { module, version, action, artifactUrl: art ? `/agent/v1/releases/${version}/artifact?module=${module}` : null, sha256: art?.sha256, size: art?.size, descriptor: { ...desc, unit: unitName }, unitTemplate: unitTemplate(desc.unitTemplate), appDir: (facts.appDirs && facts.appDirs[module]) || (desc.baseDir || `/srv/ariweb/${module}`), toVersion: job.subject.toVersion || null } });
const r = await ctx.step('await', () => core.awaitAgentJob(d, ctx, disp.agentJobId));
const v = await ctx.step('verify', () => {
const expected = action === 'rollback' ? (job.subject.toVersion || r.actualVersion) : version; const ok = r.actualVersion === expected && r.health?.ok !== false;
d.store.run('UPDATE module_targets SET actual_version=?, health=?, active=?, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', r.actualVersion || null, r.health?.ok === false ? 'down' : 'ok', r.active ? 1 : 0, nowIso(), ok ? null : `기대 ${expected} 실제 ${r.actualVersion}`, nowIso(), module, serverId);
if (!ok) throw new JobError('version_mismatch', `${srv.name}: 설치 뒤 실제 버전(${r.actualVersion})·건강(${r.health?.ok})이 목표와 다릅니다`, { detail: r });
if (module === 'hal') { d.store.run('UPDATE releases SET hal_deployed_at=? WHERE version=?', nowIso(), version); core.setBootstrap(d, { hal_running_version: expected, release_version: expected }); }
return { actualVersion: r.actualVersion, health: r.health, previousVersion: r.previousVersion };
});
return v;
} });
/** 부모: 대상 서버마다 자식 작업 → 대상별 결과 요약. 일부 실패는 실패로 끝낸다. */
async function fanout(job, ctx, { kind, mk, serverIds }) {
const ids = await ctx.step('fanout', () => serverIds.map((serverId) => engine.enqueue({ kind, subject: mk(serverId), parentId: job.id, groupId: null, subjectType: 'server', subjectId: serverId, idempotencyKey: `${job.id}:${serverId}`, createdBy: job.created_by, actorType: job.actor_type, label: `${job.label} — ${core.getServer(d, serverId)?.name || serverId}` }).job.id), '대상별 작업 만들기');
const t0 = Date.now(); while (true) { if (Date.now() - t0 > (d.config.fanoutTimeoutMs || 12 * 3600 * 1000)) throw new JobError('fanout_timeout', '대상별 작업이 제한 시간 안에 끝나지 않았습니다'); const sum = engine.childrenSummary(job.id); ctx.progress(Math.round(100 * (sum.byStatus.done || 0) / Math.max(1, sum.total))); if (['done', 'partial', 'failed'].includes(sum.overall) && !sum.children.some((c) => ['queued', 'running', 'reverting'].includes(c.status))) { if (sum.overall !== 'done') throw new JobError(sum.overall === 'partial' ? 'partial_failure' : 'all_failed', `${sum.total}대 중 ${sum.children.filter((c) => c.status !== 'done').length}대 실패·대기 — 대상별 결과를 확인하세요`, { detail: sum }); return sum; } ctx.checkCancel(); await new Promise((r) => setTimeout(r, d.config.agentPollMs || 1000)); }
}
engine.register('module.deploy', { title: '모듈 배포', steps: [{ id: 'precheck', title: '릴리스·HAL·순서 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
async run(job, ctx) {
const { module, version, serverIds } = job.subject; await ctx.step('precheck', () => { precheckModule(module, version); if (!serverIds?.length) throw new JobError('no_targets', '대상 서버가 없습니다'); return { order: DEPLOY_ORDER.indexOf(module) }; });
d.store.run('INSERT INTO modules(name, target_version, updated_at) VALUES(?,?,?) ON CONFLICT(name) DO UPDATE SET target_version=excluded.target_version, updated_at=excluded.updated_at', module, version, nowIso());
const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds, mk: (serverId) => ({ module, version, serverId, action: 'install' }) }));
audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.module', subjectType: 'module', subjectId: module, detail: { version, serverIds, overall: sum.overall } });
return sum;
} });
engine.register('module.rollback', { title: '모듈 되돌리기', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
async run(job, ctx) { const { module, toVersion, serverIds } = job.subject; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds, mk: (serverId) => ({ module, version: toVersion || core.getRelease(d, d.store.get('SELECT target_version FROM module_targets WHERE module=? AND server_id=?', module, serverId)?.target_version)?.version, serverId, action: 'rollback', toVersion }) })); audit(d.store, { actorType: job.actor_type, actorId: job.created_by, action: 'deploy.module_rollback', subjectType: 'module', subjectId: module, detail: { toVersion, serverIds, overall: sum.overall } }); return sum; } });
single(engine, d, 'module.health.target', { title: '모듈 건강 확인(대상)', agentKind: 'module.health', target: (s) => s.serverId, build: (s) => ({ module: s.module, descriptor: descriptor(s.module), unit: (core.getServer(d, s.serverId)?.facts?.units || {})[s.module] || descriptor(s.module).unit }),
after(job, ctx, r) { d.store.run('UPDATE module_targets SET actual_version=COALESCE(?, actual_version), health=?, active=?, last_check_at=?, last_error=?, updated_at=? WHERE module=? AND server_id=?', r.actualVersion || null, r.health?.ok ? 'ok' : 'down', r.active ? 1 : 0, nowIso(), r.health?.ok ? null : (r.health?.error || '건강 확인 실패'), nowIso(), job.subject.module, job.subject.serverId); return r; } });
engine.register('module.health', { title: '모듈 건강 확인', steps: [{ id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }], async run(job, ctx) { const { module, serverIds } = job.subject; return ctx.step('collect', () => fanout(job, ctx, { kind: 'module.health.target', serverIds: serverIds || d.store.all('SELECT server_id FROM module_targets WHERE module=?', module).map((r) => r.server_id), mk: (serverId) => ({ module, serverId }) })); } });
/** 다시 맞추기(§29·§34): 목표 버전과 실제 버전이 다른 대상만 다시 설치 */
engine.register('module.reconcile', { title: '목표·실제 상태 맞추기', steps: [{ id: 'diff', title: '차이 확인' }, { id: 'fanout', title: '대상별 작업 만들기' }, { id: 'collect', title: '대상별 결과 확인' }],
async run(job, ctx) { const diff = await ctx.step('diff', () => d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)' + (job.subject.module ? ' AND module=?' : ''), ...(job.subject.module ? [job.subject.module] : []))); if (!diff.length) return { drift: [], overall: 'done' }; const sum = await ctx.step('collect', () => fanout(job, ctx, { kind: 'module.deploy.target', serverIds: diff.map((x) => x.server_id), mk: (serverId) => { const x = diff.find((y) => y.server_id === serverId); return { module: x.module, version: x.target_version, serverId, action: 'install' }; } })); return { drift: diff, ...sum }; } });
// ---------------- 포털 이벤트: 포털이 만든 작업(subject._portal)의 상태 변화를 outbox 에 넣는다
engine.onChange((job) => { if (job.subject && job.subject._portal) core.queuePortalEvent(d, { halJobId: job.id, kind: job.kind, status: job.status, steps: job.steps, progress: job.progress, result: job.result, error: job.error, errorCode: job.error_code, userMessage: job.user_message, updatedAt: job.updated_at, subjectType: job.subject_type, subjectId: job.subject_id }); });
}
+39
View File
@@ -0,0 +1,39 @@
/**
* hal/modules/index.mjs — 모듈 기술서(descriptor) 모음(§32).
*
* HAL 이 어떤 모듈을 어떤 서버에 어떻게 설치·갱신·건강 확인하는지를 데이터로 둔다. Agent 는 이 기술서를 작업 payload 로 받아 그대로 수행하므로
* Agent 바이너리는 모듈별 지식을 갖지 않는다(새 모듈을 더할 때 HAL 만 바꾸면 된다).
*
* 기술서 모양
* name 모듈 이름(릴리스 artifact 이름과 같다: <name>-<version>.tar.gz)
* roles 설치할 수 있는 서버 역할
* layout 'releases'(= /srv/ariweb/<name>/releases/<ver> + current) — 도입 모듈도 같은 틀을 쓰되 데이터 경로는 기존 것을 유지한다
* unit systemd 유닛 이름(도입 모듈은 서버에 이미 있는 이름을 쓸 수 있어 server.facts.units 로 덮어쓴다)
* unitTemplate deploy/units/<file>.tmpl — 없으면 기존 유닛을 그대로 두고 WorkingDirectory 만 current 심링크가 가리키게 한다
* health { url, expect } 내부 건강 확인(127.0.0.1)
* version 실제 실행 버전을 읽는 방법: { file:'package.json' } (current/package.json 의 version) 또는 { http:'/api/health', field:'version' }
* preStop/postStart Agent 가 실행할 추가 절차 이름(agent/jobs/module.mjs 가 아는 것만)
* dataDirs 절대 건드리지 않는 데이터 경로(검증·백업 대상 안내용)
*/
export const MODULES = {
hal: { name: 'hal', roles: ['control'], layout: 'releases', unit: 'ariweb-hal', unitTemplate: 'ariweb-hal.service.tmpl', user: 'master', port: 8900,
health: { url: 'http://127.0.0.1:8900/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8900/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/hal/data'], selfUpdate: true },
portal: { name: 'portal', roles: ['control', 'web'], layout: 'releases', unit: 'ariweb-portal', unitTemplate: 'ariweb-portal.service.tmpl', user: 'master', port: 8910,
health: { url: 'http://127.0.0.1:8910/api/health', expect: 'ok' }, version: { http: 'http://127.0.0.1:8910/api/health', field: 'version' }, dataDirs: ['/srv/ariweb/portal/data'] },
agent: { name: 'agent', roles: ['control', 'web', 'mail', 'backup'], layout: 'releases', unit: 'ariweb-agent', unitTemplate: 'ariweb-agent.service.tmpl', user: 'master',
health: { heartbeat: true }, version: { file: 'package.json' }, dataDirs: ['/srv/ariweb/agent/data'], selfUpdate: true },
// 도입 모듈: Webmaker(WebHosting_AI_agent 1.7.3). 기존 서버 배치(/srv/webmaker/current, /data/webmaker, 유닛 webmaker, 포트 3080)를 존중한다 — install 은 releases 에 풀고 current 를 바꾸며,
// 유닛은 deploy/units/webmaker.service.tmpl 로 다시 쓰되 기존 유닛의 Environment 줄(DATA_ROOT 등)을 facts 로 읽어 유지한다. 원본 deploy/remote-update.sh 의 절차(백업→전송→교체→재시작→확인)를 Agent 가 같은 순서로 수행한다.
webmaker: { name: 'webmaker', roles: ['web'], layout: 'releases', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', user: 'master', port: 3080, baseDir: '/srv/webmaker',
health: { url: 'http://127.0.0.1:3080/api/auth/csrf', expect: 'any' }, version: { file: 'package.json' }, preStop: ['webmaker.backupData'], postStart: ['webmaker.unitCheck'], dataDirs: ['/data/webmaker', '/var/www/webmaker-sites'], extraUnits: ['webmaker-setup'] },
// 도입 모듈: AriMail 1.0.11. 서버마다 유닛 이름이 다르다(arihakdang-mail / arimail / heart-mail) — 서버 facts.arimail.unit 이 정한다. 설치는 원본 deploy/deploy.sh 와 같은 절차(테스트→릴리스 복사→DB 백업·마이그레이션→재시작→health)를 Agent 가 수행한다.
arimail: { name: 'arimail', roles: ['mail'], layout: 'releases', unit: 'arimail', unitTemplate: null, user: null, port: 8790,
health: { url: 'http://127.0.0.1:8790/api/health', expect: 'ok' }, version: { file: 'package.json' }, preStop: ['arimail.backupDb'], postStart: ['arimail.migrate', 'arimail.haraka'], dataDirs: ['<appDir>/data'], copyPaths: ['server', 'web', 'lang', 'migrations', 'package.json', 'haraka'] },
backup: { name: 'backup', roles: ['backup', 'web'], layout: 'agent-feature', unit: null, health: { feature: 'backup' }, version: { agent: true }, dataDirs: ['/srv/ariweb/backups'] },
observability: { name: 'observability', roles: ['control', 'web', 'mail', 'backup'], layout: 'agent-feature', unit: null, health: { feature: 'metrics' }, version: { agent: true }, dataDirs: [] },
};
/** 릴리스 artifact 로 배포되는 모듈(agent-feature 는 Agent 안의 기능이라 별도 artifact 가 없다) */
export const ARTIFACT_MODULES = Object.values(MODULES).filter((m) => m.layout === 'releases').map((m) => m.name);
/** HAL 이 가장 먼저이고, 그다음 agent → portal → 도입 모듈 순으로 배포한다(§32.2) */
export const DEPLOY_ORDER = ['hal', 'agent', 'portal', 'webmaker', 'arimail', 'backup', 'observability'];
export function descriptor(name) { const d = MODULES[name]; if (!d) throw Object.assign(new Error(`알 수 없는 모듈: ${name}`), { code: 'unknown_module', status: 404 }); return d; }
+69
View File
@@ -0,0 +1,69 @@
/**
* hal/ops.mjs — 운영자 계정·로그인·다중 인증·접근권(§25, §3.2, §3.3).
*
* - 운영자 계정은 고객 계정과 완전히 분리된 ops_accounts 이며 세션 종류는 'operator'(lib/session). 고객 세션으로는 /ops 를 열 수 없다.
* - 네트워크 제한: /ops/* 는 허용 CIDR(config.opsAllowCidrs) 안이거나, 유효한 접근권 쿠키(aw_ops_grant: 사용자·기기(UA 해시) 귀속, 짧은 수명)가 있어야 로그인 화면도 열린다.
* 접근권은 (1) 서버에서 CLI `node hal/index.mjs grant --login <id> --hours 8` 로, (2) 허용 네트워크 안에서 로그인한 운영자가 API 로 만든다. 일회용 코드 URL(/ops/access?code=…)을 열면 그 기기에 쿠키가 묶인다.
* - 반복 실패 잠금(5회 → 15분), 비밀번호 변경 시 세션 세대 증가 대신 다른 세션 종료, TOTP(선택) 등록·확인·해제, 복구 코드 1회 표시.
* - 고위험 작업은 recentlyAuthed(10분) 를 요구한다. 보안 수준 표시: 'password' | 'mfa'.
*/
import { newId, randomToken, sha256, hashPassword, verifyPassword, safeEqual } from '../lib/crypto.mjs';
import { nowIso, toJson, parseJson } from '../lib/db.mjs';
import { audit } from '../lib/audit.mjs';
import { JobError } from '../lib/jobs.mjs';
import * as session from '../lib/session.mjs';
import { cookie } from '../lib/http.mjs';
import { ipAllowed } from './cidr.mjs';
import { newSecret, verifyTotp, otpauthUrl } from './totp.mjs';
export const GRANT_COOKIE = 'aw_ops_grant';
const MAX_FAILS = 5, LOCK_MIN = 15;
const uaHash = (ua) => sha256(String(ua || '').slice(0, 300)).slice(0, 32);
export function createAccount(d, { login, password, displayName, by = null, mustChange = false }) {
login = String(login || '').trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9._-]{2,31}$/.test(login)) throw new JobError('bad_login', '아이디는 영문 소문자·숫자로 시작하는 3~32자여야 합니다');
if (String(password || '').length < 10) throw new JobError('weak_password', '운영자 비밀번호는 10자 이상이어야 합니다');
if (d.store.get('SELECT id FROM ops_accounts WHERE login=?', login)) throw new JobError('login_taken', '이미 있는 아이디입니다');
const { salt, hash } = hashPassword(password); const id = newId('op_'); const now = nowIso();
d.store.run('INSERT INTO ops_accounts(id, login, display_name, pass_hash, pass_salt, created_at, updated_at, must_change_password) VALUES(?,?,?,?,?,?,?,?)', id, login, String(displayName || login).slice(0, 80), hash, salt, now, now, mustChange ? 1 : 0);
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'ops.account_create', subjectType: 'ops_account', subjectId: id, detail: { login } });
return publicAccount(d.store.get('SELECT * FROM ops_accounts WHERE id=?', id));
}
export function publicAccount(a) { if (!a) return null; const mfa = parseJson(a.mfa_json, {}) || {}; return { id: a.id, login: a.login, displayName: a.display_name, status: a.status, lastLoginAt: a.last_login_at, mfaEnabled: !!mfa.enabledAt, securityLevel: mfa.enabledAt ? 'mfa' : 'password', securityHint: mfa.enabledAt ? '다중 인증 사용 중' : '다중 인증을 켜는 것을 권장합니다(§3.3)', mustChangePassword: !!a.must_change_password, lockedUntil: a.lock_until && Date.parse(a.lock_until) > Date.now() ? a.lock_until : null }; }
/** 1단계 로그인(비밀번호). MFA 가 켜져 있으면 세션을 만들지 않고 mfaRequired 와 임시 토큰을 돌려준다. */
export function login(d, { login, password, totp, ip, ua }) {
const a = d.store.get('SELECT * FROM ops_accounts WHERE login=?', String(login || '').trim().toLowerCase());
const fail = () => { if (a) { const n = a.failed_logins + 1; d.store.run('UPDATE ops_accounts SET failed_logins=?, lock_until=? WHERE id=?', n, n >= MAX_FAILS ? new Date(Date.now() + LOCK_MIN * 60000).toISOString() : null, a.id); } audit(d.store, { actorType: 'operator', action: 'ops.login', outcome: 'failed', ip, detail: { login } }); throw new JobError('bad_credentials', '아이디 또는 비밀번호가 올바르지 않습니다'); };
if (!a || a.status !== 'active') fail();
if (a.lock_until && Date.parse(a.lock_until) > Date.now()) throw Object.assign(new JobError('locked', `로그인 시도가 반복 실패해 ${LOCK_MIN}분 동안 잠겼습니다. 해제 시각: ${a.lock_until}`), { detail: { unlockAt: a.lock_until } });
if (!verifyPassword(password, a.pass_salt, a.pass_hash)) fail();
const mfa = parseJson(a.mfa_json, {}) || {};
if (mfa.enabledAt) {
const secret = d.vault.get(`ops-totp:${a.id}`); const codes = mfa.recoveryHashes || [];
const codeOk = totp && (verifyTotp(secret, totp) || codes.includes(sha256(String(totp).replace(/\s|-/g, ''))));
if (!totp) return { mfaRequired: true, message: '인증 앱의 6자리 코드를 입력하세요' };
if (!codeOk) fail();
if (!verifyTotp(secret, totp)) { mfa.recoveryHashes = codes.filter((h) => h !== sha256(String(totp).replace(/\s|-/g, ''))); d.store.run('UPDATE ops_accounts SET mfa_json=? WHERE id=?', toJson(mfa), a.id); } // 복구 코드는 1회용
}
d.store.run('UPDATE ops_accounts SET failed_logins=0, lock_until=NULL, last_login_at=? WHERE id=?', nowIso(), a.id);
const s = session.createSession(d.store, { userId: a.id, kind: 'operator', ip, ua, days: 1 });
audit(d.store, { actorType: 'operator', actorId: a.id, action: 'ops.login', ip });
return { session: s, account: publicAccount(a) };
}
export function reauth(d, accountId, password, sessionId) { const a = d.store.get('SELECT * FROM ops_accounts WHERE id=?', accountId); if (!a || !verifyPassword(password, a.pass_salt, a.pass_hash)) throw new JobError('bad_credentials', '비밀번호가 올바르지 않습니다'); session.markReauth(d.store, sessionId); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.reauth' }); return true; }
export function changePassword(d, accountId, current, next, keepSessionId) { const a = d.store.get('SELECT * FROM ops_accounts WHERE id=?', accountId); if (!verifyPassword(current, a.pass_salt, a.pass_hash)) throw new JobError('bad_credentials', '현재 비밀번호가 올바르지 않습니다'); if (String(next).length < 10) throw new JobError('weak_password', '10자 이상이어야 합니다'); const { salt, hash } = hashPassword(next); d.store.run('UPDATE ops_accounts SET pass_hash=?, pass_salt=?, must_change_password=0, updated_at=? WHERE id=?', hash, salt, nowIso(), accountId); session.revokeOtherSessions(d.store, accountId, keepSessionId, 'operator'); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.password_change' }); return true; }
/** TOTP 등록 시작: 비밀을 보관소에 임시로 두고 otpauth URL 을 돌려준다(확인 전까지 enabledAt 없음) */
export function mfaBegin(d, accountId) { const a = d.store.get('SELECT * FROM ops_accounts WHERE id=?', accountId); const secret = newSecret(); d.vault.set(`ops-totp:${accountId}`, secret, { kind: 'totp' }); const mfa = { ...(parseJson(a.mfa_json, {}) || {}), pendingAt: nowIso(), enabledAt: null }; d.store.run('UPDATE ops_accounts SET mfa_json=? WHERE id=?', toJson(mfa), accountId); return { otpauth: otpauthUrl(secret, a.login), secret }; }
/** 코드 확인 → 켜기. 복구 코드 8개를 한 번만 돌려준다. */
export function mfaConfirm(d, accountId, code) { const secret = d.vault.get(`ops-totp:${accountId}`); if (!secret || !verifyTotp(secret, code)) throw new JobError('bad_totp', '인증 코드가 맞지 않습니다'); const codes = Array.from({ length: 8 }, () => randomToken(6).replace(/[-_]/g, 'x').slice(0, 10)); const mfa = { enabledAt: nowIso(), recoveryHashes: codes.map((c) => sha256(c)) }; d.store.run('UPDATE ops_accounts SET mfa_json=? WHERE id=?', toJson(mfa), accountId); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.mfa_enable' }); return { recoveryCodes: codes }; }
export function mfaDisable(d, accountId, code) { const secret = d.vault.get(`ops-totp:${accountId}`); if (!secret || !verifyTotp(secret, code)) throw new JobError('bad_totp', '인증 코드가 맞지 않습니다'); d.vault.remove(`ops-totp:${accountId}`); d.store.run('UPDATE ops_accounts SET mfa_json=NULL WHERE id=?', accountId); audit(d.store, { actorType: 'operator', actorId: accountId, action: 'ops.mfa_disable' }); return true; }
/** 접근권 발급: 일회용 코드(URL)로 기기에 묶는다 */
export function issueGrant(d, { accountId, hours = 8, by = null, reason = '' }) { const a = d.store.get('SELECT id FROM ops_accounts WHERE id=? OR login=?', accountId, accountId); if (!a) throw new JobError('account_not_found', '운영자 계정이 없습니다'); const code = randomToken(24); const id = newId('gr_'); d.store.run('INSERT INTO ops_access_grants(id, account_id, code_hash, created_by, created_at, expires_at, reason) VALUES(?,?,?,?,?,?,?)', id, a.id, sha256(code), by, nowIso(), new Date(Date.now() + Math.min(72, Math.max(1, hours)) * 3600000).toISOString(), reason); audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'ops.grant_issue', subjectType: 'ops_account', subjectId: a.id, detail: { hours, reason } }); return { id, url: `${d.config.publicUrl}/ops/access?code=${code}`, expiresAt: new Date(Date.now() + hours * 3600000).toISOString() }; }
/** 코드로 기기 묶기 → 쿠키 값(원문). 코드는 1회용. */
export function bindGrant(d, code, { ua, ip }) { const g = d.store.get('SELECT * FROM ops_access_grants WHERE code_hash=? AND revoked_at IS NULL', sha256(String(code || ''))); if (!g || g.bound_at || Date.parse(g.expires_at) < Date.now()) throw new JobError('bad_grant', '접근 코드가 올바르지 않거나 만료·사용되었습니다'); const cookieVal = randomToken(32); d.store.run('UPDATE ops_access_grants SET bound_at=?, cookie_hash=?, ua_hash=?, ip=? WHERE id=?', nowIso(), sha256(cookieVal), uaHash(ua), ip, g.id); return { cookie: cookieVal, expiresAt: g.expires_at }; }
export function grantValid(d, cookieVal, ua) { if (!cookieVal) return null; const g = d.store.get('SELECT * FROM ops_access_grants WHERE cookie_hash=? AND revoked_at IS NULL', sha256(cookieVal)); if (!g || Date.parse(g.expires_at) < Date.now()) return null; if (!safeEqual(g.ua_hash, uaHash(ua))) return null; return g; }
export function revokeGrant(d, id, by) { d.store.run('UPDATE ops_access_grants SET revoked_at=? WHERE id=?', nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'ops.grant_revoke', subjectType: 'grant', subjectId: id }); }
/** 네트워크 관문(§25): 허용 CIDR 또는 접근권 쿠키. 아니면 '접근 불가' — 로그인 화면도 주지 않는다. */
export function networkGate(d, ctx) { if (ipAllowed(ctx.ip, d.config.opsAllowCidrs)) return { ok: true, via: 'network' }; const g = grantValid(d, ctx.cookies[GRANT_COOKIE], ctx.headers['user-agent']); return g ? { ok: true, via: 'grant', grant: g } : { ok: false, reason: 'network_not_allowed' }; }
export const grantCookie = (val, secure) => cookie(GRANT_COOKIE, val, { maxAge: 72 * 3600, path: '/ops', secure });
export { ipAllowed };
+88
View File
@@ -0,0 +1,88 @@
/**
* hal/schema.mjs — HAL DB 마이그레이션(hal.sqlite).
* lib 공통 표(sessions·jobs·notifications·audit) 뒤에 HAL 고유 표를 둔다. docs/ARCHITECTURE.md §5·§10, Dev-Order §28·§32·§33·§34.
*/
import * as session from '../lib/session.mjs';
import * as jobs from '../lib/jobs.mjs';
import * as notify from '../lib/notify.mjs';
import * as audit from '../lib/audit.mjs';
export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: `
-- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다.
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL, location TEXT, status TEXT NOT NULL DEFAULT 'pending', accepts_new INTEGER NOT NULL DEFAULT 0,
approved_at TEXT, approved_by TEXT, agent_id TEXT, last_seen_at TEXT, clock_skew_ms INTEGER, agent_version TEXT, metrics_json TEXT, capacity_json TEXT, facts_json TEXT,
maintenance_note TEXT, isolated_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL
);
-- Node Agent(관리형) 또는 External Pull Agent(고객 외부 서버, 사이트 하나에 귀속). 비밀은 보관소('service:agent:<id>')에, 여기엔 해시만.
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, server_id TEXT, kind TEXT NOT NULL DEFAULT 'managed', secret_hash TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', label TEXT,
group_id TEXT, site_id TEXT, version TEXT, registered_at TEXT NOT NULL, last_seen_at TEXT, revoked_at TEXT, facts_json TEXT
);
-- 일회용 등록 토큰(10분, 1회). 외부 Agent 토큰은 group_id/site_id 가 있다.
CREATE TABLE IF NOT EXISTS registration_tokens (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, kind TEXT NOT NULL DEFAULT 'managed', server_id TEXT, group_id TEXT, site_id TEXT, label TEXT,
created_by TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, used_at TEXT, used_by_agent TEXT
);
-- 릴리스 등록부(§32.1). artifacts_json = { 모듈: { file, sha256, size } }
CREATE TABLE IF NOT EXISTS releases (
version TEXT PRIMARY KEY, main_commit TEXT NOT NULL, built_at TEXT NOT NULL, artifacts_json TEXT NOT NULL, checks_json TEXT, status TEXT NOT NULL DEFAULT 'registered',
approved_by TEXT, approved_at TEXT, hal_deployed_at TEXT, notes TEXT, registered_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS modules (name TEXT PRIMARY KEY, target_version TEXT, updated_at TEXT NOT NULL);
-- 모듈 × 서버 배치 상태(목표 버전·실제 버전·건강·마지막 확인)
CREATE TABLE IF NOT EXISTS module_targets (
module TEXT NOT NULL, server_id TEXT NOT NULL, target_version TEXT, actual_version TEXT, health TEXT NOT NULL DEFAULT 'unknown', active INTEGER, last_check_at TEXT, last_error TEXT,
last_job_id TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(module, server_id)
);
-- Agent 에게 배정된 작업(HAL 작업의 자식). 상태: queued → running → done|failed|needs_user|cancelled
CREATE TABLE IF NOT EXISTS agent_jobs (
id TEXT PRIMARY KEY, hal_job_id TEXT, agent_id TEXT NOT NULL, kind TEXT NOT NULL, payload_json TEXT, status TEXT NOT NULL DEFAULT 'queued', progress INTEGER NOT NULL DEFAULT 0,
steps_json TEXT, log_json TEXT, result_json TEXT, error TEXT, error_code TEXT, user_message TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, assigned_at TEXT, started_at TEXT, ended_at TEXT, heartbeat_at TEXT
);
CREATE INDEX IF NOT EXISTS agent_jobs_agent ON agent_jobs(agent_id, status, created_at);
CREATE INDEX IF NOT EXISTS agent_jobs_hal ON agent_jobs(hal_job_id);
-- 서버 지표(90일 이상 보존, §28.1)
CREATE TABLE IF NOT EXISTS metrics_samples (
server_id TEXT NOT NULL, at TEXT NOT NULL, cpu REAL, mem_used REAL, mem_total REAL, disk_used REAL, disk_total REAL, inode_used REAL, inode_total REAL, io_read REAL, io_write REAL, net_in REAL, net_out REAL, clock_skew_ms INTEGER, load1 REAL,
PRIMARY KEY(server_id, at)
);
-- 사건(§31): 같은 원인은 key 로 묶는다
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY, key TEXT NOT NULL, severity TEXT NOT NULL, title TEXT NOT NULL, component TEXT, customer_impact TEXT, started_at TEXT NOT NULL, resolved_at TEXT, verified_at TEXT,
assignee TEXT, public INTEGER NOT NULL DEFAULT 0, public_note TEXT, notes_json TEXT, updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS incidents_key ON incidents(key, resolved_at);
-- 운영자 계정(§25). 고객 계정과 완전히 분리. mfa_json = { totp: { secret(보관소 참조 id), enabledAt } }
CREATE TABLE IF NOT EXISTS ops_accounts (
id TEXT PRIMARY KEY, login TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, pass_hash TEXT NOT NULL, pass_salt TEXT NOT NULL, mfa_json TEXT, status TEXT NOT NULL DEFAULT 'active',
failed_logins INTEGER NOT NULL DEFAULT 0, lock_until TEXT, last_login_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, must_change_password INTEGER NOT NULL DEFAULT 0
);
-- 짧은 수명의 사용자·기기 귀속 접근권(§25: 허용 네트워크 밖에서는 이것 없이 로그인 화면도 열리지 않음)
CREATE TABLE IF NOT EXISTS ops_access_grants (
id TEXT PRIMARY KEY, account_id TEXT NOT NULL, code_hash TEXT UNIQUE, cookie_hash TEXT UNIQUE, ua_hash TEXT, ip TEXT, created_by TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, bound_at TEXT, revoked_at TEXT, reason TEXT
);
-- 긴급 복구 세션(§33)
CREATE TABLE IF NOT EXISTS emergency_sessions (
id TEXT PRIMARY KEY, incident_key TEXT NOT NULL, reason TEXT NOT NULL, scope_json TEXT NOT NULL, status TEXT NOT NULL, declared_by TEXT NOT NULL, declared_at TEXT NOT NULL,
approvals_json TEXT NOT NULL DEFAULT '[]', credential_hash TEXT, credential_issued_at TEXT, credential_expires_at TEXT, credential_revoked_at TEXT,
actions_json TEXT NOT NULL DEFAULT '[]', verify_json TEXT, reconcile_json TEXT, closed_at TEXT, closed_by TEXT, hal_healthy_at_declare INTEGER, updated_at TEXT NOT NULL
);
-- 재해복구(§34)
CREATE TABLE IF NOT EXISTS dr_config (id INTEGER PRIMARY KEY CHECK(id=1), rto_minutes INTEGER, rpo_minutes INTEGER, snapshot_interval_minutes INTEGER, retention_count INTEGER, external_store TEXT, updated_at TEXT, updated_by TEXT);
CREATE TABLE IF NOT EXISTS dr_snapshots (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, location TEXT NOT NULL, size_bytes INTEGER, sha256 TEXT, verify_state TEXT NOT NULL DEFAULT 'pending', verified_at TEXT, external_copied_at TEXT, external_location TEXT, kind TEXT NOT NULL DEFAULT 'scheduled', error TEXT, deleted_at TEXT);
CREATE TABLE IF NOT EXISTS dr_restores (id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, requested_by TEXT NOT NULL, approved_by TEXT, approved_at TEXT, old_control_stopped_confirmed_at TEXT, started_at TEXT, finished_at TEXT, status TEXT NOT NULL, checks_json TEXT, diff_json TEXT, note TEXT);
CREATE TABLE IF NOT EXISTS dr_drills (id TEXT PRIMARY KEY, started_at TEXT NOT NULL, finished_at TEXT, snapshot_id TEXT, performed_by TEXT, steps_json TEXT NOT NULL, measured_rto_minutes REAL, measured_rpo_minutes REAL, result TEXT NOT NULL DEFAULT 'running', findings_json TEXT, next_due_at TEXT);
-- 부트스트랩·실행 상태(§32.2). 행 하나(id=1).
CREATE TABLE IF NOT EXISTS bootstrap_state (
id INTEGER PRIMARY KEY CHECK(id=1), first_install INTEGER NOT NULL DEFAULT 1, release_version TEXT, main_commit TEXT, artifact_sha256 TEXT, hal_running_version TEXT, hal_started_at TEXT,
first_agent_id TEXT, first_agent_registered_at TEXT, manual_install_locked INTEGER NOT NULL DEFAULT 0, live_instance_id TEXT, live_lease_at TEXT, completed_at TEXT, updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS transfers (id TEXT PRIMARY KEY, created_by_agent TEXT, path TEXT NOT NULL, size_bytes INTEGER, sha256 TEXT, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, purpose TEXT, meta_json TEXT, deleted_at TEXT);
CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value_json TEXT, updated_at TEXT NOT NULL, updated_by TEXT);
CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_json TEXT NOT NULL, created_at TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, next_at TEXT NOT NULL, delivered_at TEXT, last_error TEXT);
` },
// [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID
{ id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` },
];
+26
View File
@@ -0,0 +1,26 @@
/**
* hal/totp.mjs — RFC 6238 TOTP(운영자 다중 인증, §3.3·§25). 외부 의존성 없이 node:crypto 만 쓴다.
* 비밀은 base32 로 표시(인증 앱 등록용 otpauth URL)하고 저장은 보관소에만 한다.
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Encode(buf) { let bits = 0, value = 0, out = ''; for (const b of buf) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32[(value << (5 - bits)) & 31]; return out; }
export function base32Decode(str) { const s = String(str).toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, ''); let bits = 0, value = 0; const out = []; for (const c of s) { value = (value << 5) | B32.indexOf(c); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); }
/** 새 비밀(base32 32자) */
export const newSecret = () => base32Encode(randomBytes(20));
/** 코드 계산. step 30초, 6자리 */
export function totpCode(secretB32, at = Date.now(), step = 30) {
const counter = Math.floor(at / 1000 / step); const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter));
const h = createHmac('sha1', base32Decode(secretB32)).update(msg).digest(); const off = h[h.length - 1] & 0xf;
const code = ((h[off] & 0x7f) << 24 | (h[off + 1] & 0xff) << 16 | (h[off + 2] & 0xff) << 8 | (h[off + 3] & 0xff)) % 1000000;
return String(code).padStart(6, '0');
}
/** 검증(앞뒤 1단계 허용, 상수 시간 비교) */
export function verifyTotp(secretB32, code, at = Date.now()) {
const c = String(code || '').replace(/\s/g, ''); if (!/^\d{6}$/.test(c)) return false;
for (const d of [-1, 0, 1]) { const exp = Buffer.from(totpCode(secretB32, at + d * 30000)); const got = Buffer.from(c); if (exp.length === got.length && timingSafeEqual(exp, got)) return true; }
return false;
}
/** 인증 앱 등록 URL */
export const otpauthUrl = (secretB32, login, issuer = 'Ariweb HAL') => `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(login)}?secret=${secretB32}&issuer=${encodeURIComponent(issuer)}&digits=6&period=30`;
+111
View File
@@ -0,0 +1,111 @@
/**
* hal/ui.mjs — 운영자 콘솔 화면(§25~§34) 라우트. hal/app.mjs 가 registerUi(app, d) 로 부른다.
*
* 구조
* - 모든 화면은 /ops/* 아래에 있고 app.mjs 의 운영자 미들웨어(네트워크 관문 → 운영자 세션)를 먼저 거친다. 허용 네트워크·접근권이 없으면 로그인 화면도 열리지 않는다(403).
* - 운영자 세션이 없으면(고객 세션 쿠키가 있어도) 로그인 화면만 보인다. 고객 세션으로는 어떤 운영자 화면도 열 수 없다.
* - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는
* 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음).
* - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css).
*/
import { esc } from '../lib/http.mjs';
import * as session from '../lib/session.mjs';
import { describe } from '../lib/jobs.mjs';
import { listAudit } from '../lib/audit.mjs';
import { listNotifications } from '../lib/notify.mjs';
import { redact } from '../lib/redact.mjs';
import { nowIso } from '../lib/db.mjs';
import { readFileSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as core from './core.mjs';
import * as ops from './ops.mjs';
import * as emergency from './emergency.mjs';
import * as dr from './dr.mjs';
import { MODULES, DEPLOY_ORDER } from './modules/index.mjs';
import * as R from './ui/render.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
/** 메뉴(§25) */
export const MENU = [
['overview', '/ops/', '운영 현황'], ['customers', '/ops/customers', '고객과 사용자 그룹'], ['orders', '/ops/orders', '주문과 결제 승인'], ['products', '/ops/products', '상품과 정책'],
['sites', '/ops/sites', '사이트와 배포'], ['mail', '/ops/mail', '메일 서비스'], ['domains', '/ops/domains', '도메인과 인증서'], ['servers', '/ops/servers', '서버와 용량'],
['backup', '/ops/backup', '백업과 복원'], ['relocation', '/ops/relocation', '서버 이전'], ['releases', '/ops/releases', '배포 릴리스'], ['jobs', '/ops/jobs', '운영 작업'],
['incidents', '/ops/incidents', '장애와 알림'], ['audit', '/ops/audit', '감사 기록'], ['recovery', '/ops/recovery', '플랫폼 복구'], ['account', '/ops/account', '운영자 계정'],
];
export function registerUi(app, d) {
/** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */
function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) {
if (!ctx.operator) return ctx.html(loginPage(ctx, d));
const recent = session.recentlyAuthed(ctx.session); const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n;
const banner = [];
if (!recent) banner.push(`<a class="alert warning" href="#" data-act="reauth">! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증</a>`);
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
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 (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="logout">로그아웃</button></nav></header>
<div class="shell"><aside class="side" id="side"><nav aria-label="운영자 메뉴">${MENU.map(([k, href, label]) => `<a class="nav${nav === k ? ' on' : ''}" href="${href}">${esc(label)}</a>`).join('')}</nav></aside>
<main id="content" class="main" data-page="${esc(page)}" data-id="${esc(id)}"><h1>${esc(title)}</h1><div class="alerts">${banner.join('')}</div><div id="body">${body || ''}</div></main></div>
<div id="toast" class="toast" role="status" aria-live="polite"></div><div id="modal" class="modal" hidden></div>
<script type="module" src="/ops/ui/ops.js"></script>${scripts.map((s) => `<script type="module" src="${esc(s)}"></script>`).join('')}</body></html>`);
}
// ---------------- 로그인·최초 설정
app.get('/ops/login', (ctx) => ctx.operator ? ctx.redirect('/ops/') : ctx.html(loginPage(ctx, d)));
app.get('/ops/setup', (ctx) => ctx.html(loginPage(ctx, d, { setup: true })));
// ---------------- 운영 현황(§26.1)
app.get('/ops/', (ctx) => shell(ctx, { title: '운영 현황', nav: 'overview', page: 'overview', body: R.overviewView(overviewLocal(d)) }));
// ---------------- 포털 중계 화면(브라우저가 /ops/api/portal/* 로 채운다)
const relayPage = (path, nav, title, page) => app.get(path, (ctx) => shell(ctx, { title, nav, page, id: ctx.params.id || ctx.params.groupId || '', body: R.statePanel('loading', '포털에서 불러오는 중') }));
relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer');
relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products');
relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site');
relayPage('/ops/mail', 'mail', '메일 서비스', 'mail'); relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains');
relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation');
// ---------------- 서버(§28)
app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
app.get('/ops/servers/:id', (ctx) => { const s = core.getServer(d, ctx.params.id); if (!s) return shell(ctx, { title: '서버', nav: 'servers', page: 'server', body: R.statePanel('failed', '서버가 없습니다') }); const ps = core.publicServer(d, s); const jobs = d.jobs.list({ subjectType: 'server', subjectId: s.id, limit: 50 }).map(describe); const agentJobs = d.store.all('SELECT id, agent_id, kind, status, progress, created_at, ended_at, error, user_message FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50', s.agent_id || '');
const actions = `<div class="actions">${ps.status === 'pending' ? `<button class="btn primary" data-act="server-token" data-id="${esc(s.id)}">등록 정보 발급</button> <button class="btn" data-act="server-approve" data-id="${esc(s.id)}">연결 확인 후 승인</button>` : ''}${ps.rawStatus === 'maintenance' ? `<button class="btn" data-act="server-maint-end" data-id="${esc(s.id)}">유지보수 종료·신규 배치 재개</button>` : `<button class="btn" data-act="server-maint-start" data-id="${esc(s.id)}">유지보수 시작(신규 배치 중지)</button>`} <button class="btn" data-act="server-accepts" data-id="${esc(s.id)}" data-v="${ps.acceptsNew ? 0 : 1}">${ps.acceptsNew ? '신규 배치 중지' : '신규 배치 허용'}</button> <button class="btn danger" data-act="server-isolate" data-id="${esc(s.id)}">격리</button> <button class="btn danger" data-act="server-delete" data-id="${esc(s.id)}" data-hosted="${ps.capacity.sitesHosted || 0}">서버 삭제</button></div>`;
return shell(ctx, { title: `서버 · ${s.name}`, nav: 'servers', page: 'server', id: s.id, body: `${actions}${R.serverDetail(ps, { jobs, agentJobs })}<h2>지표(90일 이상 보존)</h2><div class="row"><label>지표 <select id="metric-key"><option value="cpu">CPU</option><option value="mem">메모리</option><option value="disk">디스크</option><option value="inode">inode</option><option value="net">네트워크 송신</option></select></label> <label>기간 <select id="metric-days"><option value="1">24시간</option><option value="7">7일</option><option value="30" selected>30일</option><option value="90">90일</option></select></label></div><div id="metrics">${R.statePanel('loading', '지표를 불러오는 중')}</div>` }); });
// ---------------- 릴리스(§32.1)
app.get('/ops/releases', (ctx) => shell(ctx, { title: '배포 릴리스', nav: 'releases', page: 'releases', body: R.releasesView({ releases: core.listReleases(d), running: core.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)
app.get('/ops/jobs', (ctx) => { const status = ctx.query.status ? ctx.query.status.split(',') : null; const jobs = d.jobs.list({ status, kind: ctx.query.kind || null, limit: 200 }).map((j) => ({ ...describe(j), subject: redact(j.subject), createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id }));
const filters = `<div class="row"><a class="btn${!ctx.query.status ? ' on' : ''}" href="/ops/jobs">전체</a> <a class="btn${ctx.query.status === 'running,queued,awaiting_external' ? ' on' : ''}" href="/ops/jobs?status=running,queued,awaiting_external">진행·대기</a> <a class="btn${ctx.query.status === 'needs_user,failed' ? ' on' : ''}" href="/ops/jobs?status=needs_user,failed">확인 필요·실패</a> <a class="btn${ctx.query.kind === 'module.deploy' ? ' on' : ''}" href="/ops/jobs?kind=module.deploy">모듈 배포</a></div>`;
return shell(ctx, { title: '운영 작업', nav: 'jobs', page: 'jobs', body: filters + R.table([{ l: '작업 번호', f: (j) => `<a href="/ops/jobs/${esc(j.id)}" class="mono">${esc(j.id)}</a><br><b>${esc(j.label)}</b>` }, { l: '종류', k: 'kind' }, { l: '고객·대상', f: (j) => `${esc(j.groupId || '—')}<br><span class="muted">${esc((j.subject && (j.subject.target || j.subject.module || j.subject.payload?.siteId)) || '')}</span>` }, { l: '시작 주체', f: (j) => esc(j.actorType === 'operator' ? `운영자 ${j.createdBy || ''}` : j.actorType === 'system' ? '자동' : `사용자 ${j.createdBy || ''}`) }, { l: '상태', f: (j) => R.jobBadge(j.status) + (j.stale ? ' ' + R.badge('stale', '갱신 지연') : '') }, { l: '단계·진행', f: (j) => `${esc(j.currentStep ? j.currentStep.title : '—')} · ${j.progress || 0}%` }, { l: '재시도·대기', f: (j) => `${j.maxAttempts > 1 ? `${j.attempt}/${j.maxAttempts}` : '—'}${j.waitReason ? `<br><span class="muted">${esc(j.waitReason)}</span>` : ''}` }, { l: '갱신', f: (j) => R.fmt(j.updatedAt) }, { l: '오류', f: (j) => `<span class="err">${esc(j.error || '')}</span>` }], jobs, { empty: '작업이 없습니다' }) }); });
app.get('/ops/jobs/:id', (ctx) => { const j = d.jobs.get(ctx.params.id); if (!j) return shell(ctx, { title: '작업', nav: 'jobs', page: 'job', body: R.statePanel('failed', '작업이 없습니다') }); const v = { ...describe(j), subject: redact(j.subject), result: j.result, log: j.log, createdBy: j.created_by, actorType: j.actor_type, groupId: j.group_id };
return shell(ctx, { title: `작업 · ${j.label || j.kind}`, nav: 'jobs', page: 'job', id: j.id, body: `${R.jobBlock(v)}${R.childrenBlock(d.jobs.childrenSummary(j.id))}<h3>Agent 작업</h3>${R.agentJobsTable(d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at', j.id))}${j.result ? `<h3>결과</h3><pre class="pre">${esc(JSON.stringify(redact(j.result), null, 1))}</pre>` : ''}<h3>기록</h3><pre class="pre">${esc((j.log || []).map((l) => `${l.at} ${l.line}`).join('\n') || '없음')}</pre>` }); });
// ---------------- 사건·알림(§31.2)
app.get('/ops/incidents', (ctx) => shell(ctx, { title: '장애와 알림', nav: 'incidents', page: 'incidents', body: R.incidentsView(d.store.all('SELECT * FROM incidents ORDER BY resolved_at IS NOT NULL, started_at DESC LIMIT 200'), listNotifications(d.store, { groupId: null, limit: 100 })) + `<p class="muted">공개 상태 페이지: <a href="/status" target="_blank" rel="noopener">/status</a> — 개인정보·내부 위치 없음, 고객 영향 없는 내부 경고는 올리지 않음, 복구 완료는 실제 외부 확인 뒤 게시(§31.3).</p>` }));
// ---------------- 감사(§24.2·§25)
app.get('/ops/audit', (ctx) => shell(ctx, { title: '감사 기록', nav: 'audit', page: 'audit', body: `<form class="row" method="get" action="/ops/audit"><input name="action" placeholder="행동(예: ops., release., emergency.)" value="${esc(ctx.query.action || '')}"><input name="subjectId" placeholder="대상 id" value="${esc(ctx.query.subjectId || '')}"><input type="date" name="since" value="${esc(ctx.query.since || '')}"><button class="btn">조회</button></form>` + R.auditTable(listAudit(d.store, { action: ctx.query.action || undefined, subjectId: ctx.query.subjectId || undefined, since: ctx.query.since || undefined, limit: 300 })) }));
// ---------------- 플랫폼 복구(§33·§34)
app.get('/ops/recovery', (ctx) => shell(ctx, { title: '플랫폼 복구', nav: 'recovery', page: 'recovery', body: `<div class="tabs"><a href="#emergency" class="on" data-tab="emergency">긴급 복구(§33)</a><a href="#dr" data-tab="dr">재해복구(§34)</a></div><section id="emergency">${R.emergencyView({ sessions: emergency.listSessions(d), scopes: emergency.SCOPES, actions: emergency.ACTIONS })}</section><section id="dr" hidden>${R.drView({ readiness: dr.readiness(d), snapshots: dr.listSnapshots(d), restores: d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'), drills: d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50'), hal: core.selfHealth(d) })}</section>` }));
// ---------------- 운영자 계정(§25·§3.3)
app.get('/ops/account', (ctx) => shell(ctx, { title: '운영자 계정', nav: 'account', page: 'account', body: R.accountsView({ me: ctx.operator, recentlyAuthed: session.recentlyAuthed(ctx.session), via: ctx.opsVia, sessions: session.listSessions(d.store, ctx.operator.id, 'operator').map((s) => ({ ...s, current: s.id === ctx.session.id })), grants: d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 100'), accounts: d.store.all('SELECT * FROM ops_accounts ORDER BY created_at').map(ops.publicAccount) }) }));
// [ops UI 추가] 서버 화면용 읽기 전용: 릴리스 변경 이력(VERSION_HISTORY.md 절)
app.get('/ops/api/changelog', () => ({ ok: true, changelog: changelog() }));
}
/** 로그인·최초 설정 화면(고객 로그인과 분리, §25). 네트워크 관문을 통과한 요청만 여기까지 온다. */
function loginPage(ctx, d, { setup = false } = {}) {
const needSetup = !core.ensureOpsAccountExists(d); const via = ctx.opsVia === 'grant' ? '기기 귀속 접근권' : '허용 네트워크';
const body = needSetup || setup ? `<h1>HAL 최초 설정</h1><p class="muted">bootstrap-hal.sh 가 한 번만 출력한 설정 토큰으로 첫 운영자 계정을 만듭니다. 토큰은 사용 후 폐기되며 다시 쓸 수 없습니다.</p><form id="setup-form"><label>설정 토큰<input name="setupToken" required autocomplete="off"></label><label>아이디<input name="login" required pattern="[a-z0-9][a-z0-9._-]{2,31}"></label><label>표시 이름<input name="displayName"></label><label>비밀번호(10자 이상)<input name="password" type="password" required minlength="10"></label><button class="btn primary">첫 운영자 만들기</button><p class="err" id="msg"></p></form>` : `<h1>운영자 로그인</h1><p class="muted">고객 계정으로는 로그인할 수 없습니다. 접속 경로: ${esc(via)}.</p><form id="login-form"><label>아이디<input name="login" required autocomplete="username"></label><label>비밀번호<input name="password" type="password" required autocomplete="current-password"></label><label id="totp-row" hidden>인증 코드(또는 복구 코드)<input name="totp" autocomplete="one-time-code"></label><button class="btn primary">로그인</button><p class="err" id="msg" role="alert"></p></form>`;
return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"></head><body class="login"><main class="login-box" data-page="${needSetup || setup ? 'setup' : 'login'}">${body}<p class="muted small">Ariweb HAL v${esc(d.version)} · 반복 실패 시 15분 잠금 · 세션 24시간</p></main><script type="module" src="/ops/ui/ops.js"></script></body></html>`;
}
/** 운영 현황 데이터(hal/app.mjs overview 와 같은 규칙으로 HAL 자체 데이터만 모은다) */
export function overviewLocal(d) {
const servers = core.listServers(d); const health = core.selfHealth(d);
const jobs = d.jobs.list({ status: ['queued', 'running', 'awaiting_external', 'needs_user', 'reverting'], limit: 500 });
const comp = (name, ok, extra = {}) => ({ name, status: ok === null ? 'unknown' : ok ? 'ok' : 'down', ...extra });
const modState = (m) => { const t = d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?', m); if (!t.length) return null; const stale = t.some((x) => !x.last_check_at || Date.now() - Date.parse(x.last_check_at) > 10 * 60 * 1000); return { ok: t.every((x) => x.health === 'ok'), stale, versions: [...new Set(t.map((x) => x.actual_version))] }; };
const components = ['hal', 'portal', 'webmaker', 'arimail', 'backup', 'observability'].map((m) => { if (m === 'hal') return comp('HAL', health.ok, { version: health.version }); const s = modState(m); return s ? comp(m, s.stale ? null : s.ok, { versions: s.versions, stale: s.stale }) : comp(m, null, { note: '배치 없음' }); });
return { ok: true, hal: health, components, servers: { total: servers.length, offline: servers.filter((s) => s.status === 'offline').length, pending: servers.filter((s) => s.status === 'pending').length, maintenance: servers.filter((s) => s.rawStatus === 'maintenance').length, diskWarning: servers.filter((s) => s.capacity.diskTotalBytes && (s.capacity.diskTotalBytes - s.capacity.diskFreeBytes) / s.capacity.diskTotalBytes >= 0.9).length },
jobs: { running: jobs.filter((j) => j.status === 'running').length, waiting: jobs.filter((j) => ['awaiting_external', 'queued'].includes(j.status)).length, needsUser: jobs.filter((j) => j.status === 'needs_user').length, byKind: jobs.reduce((a, j) => { a[j.kind] = (a[j.kind] || 0) + 1; return a; }, {}) },
incidents: d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'), drift: d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'), dr: dr.readiness(d), emergencyOpen: d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''), generatedAt: nowIso() };
}
/** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */
export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; }
+27
View File
@@ -0,0 +1,27 @@
/* hal/ui/public/ops.css — 운영자 콘솔 스타일. 상태는 글자+기호로 구분(색은 보조). 760px 이하에서 메뉴가 접히고 표가 카드로 바뀐다(360px 조회·승인·중지·재시도 가능). */
:root{--bg:#f3f5f8;--fg:#1b2430;--mut:#5b6672;--line:#d9dee6;--pri:#1f4fb0;--ok:#1a7f4b;--warn:#b26a00;--bad:#b3261e;--card:#fff;--rad:10px;font-family:-apple-system,"Segoe UI","Noto Sans KR",sans-serif}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font-size:14px;line-height:1.5}a{color:var(--pri)}
.skip{position:absolute;left:-999px}.skip:focus{left:8px;top:8px;background:#fff;padding:6px;z-index:99}
.top{display:flex;align-items:center;gap:12px;padding:8px 14px;background:#101826;color:#fff;position:sticky;top:0;z-index:20}.top a{color:#fff}.brand{font-weight:700;text-decoration:none}.ver{font-size:12px;opacity:.9}.menu-btn{display:none;font-size:20px;background:none;border:0;color:#fff}
.top-right{margin-left:auto;display:flex;gap:12px;align-items:center}.top-right .muted{color:#c7d0dc}
.shell{display:flex;min-height:calc(100vh - 48px)}.side{width:210px;background:#fff;border-right:1px solid var(--line);padding:10px 8px}.side .nav{display:block;padding:8px 10px;border-radius:8px;color:var(--fg);text-decoration:none}.side .nav.on{background:#e6edfb;color:var(--pri);font-weight:600}
.main{flex:1;padding:14px 18px;max-width:1400px;min-width:0}h1{font-size:20px;margin:4px 0 10px}h2{font-size:16px;margin:18px 0 8px}h3{font-size:15px;margin:14px 0 6px}
.alerts{display:flex;flex-direction:column;gap:6px;margin-bottom:10px}.alert{display:block;padding:9px 12px;border-radius:8px;border:1px solid;text-decoration:none;color:var(--fg)}.alert.warning{background:#fff7e6;border-color:#f0c36d}.alert.critical{background:#fdecea;border-color:#f3a09a}.alert.info{background:#e8effc;border-color:#b9cdf2}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--rad);padding:14px;margin-bottom:12px;min-width:0}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:12px}.card h2{margin-top:0}.comp{display:flex;flex-direction:column;gap:4px}
.kv{display:grid;grid-template-columns:max-content 1fr;gap:5px 12px;margin:8px 0}.kv dt{color:var(--mut)}.kv dd{margin:0;word-break:break-word}
.btn{display:inline-block;padding:7px 12px;border:1px solid var(--line);background:#fff;border-radius:8px;cursor:pointer;font-size:13px;color:var(--fg);text-decoration:none}.btn.primary{background:var(--pri);color:#fff;border-color:var(--pri)}.btn.danger{color:var(--bad);border-color:var(--bad)}.btn.small{padding:4px 8px;font-size:12px}.btn.on{background:#e6edfb;border-color:var(--pri)}.btn:disabled{opacity:.5;cursor:not-allowed}.actions{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.badge{display:inline-block;padding:1px 8px;border-radius:12px;font-size:12px;border:1px solid var(--line);background:#fff;white-space:nowrap}.badge.ok,.badge.done{border-color:var(--ok);color:var(--ok)}.badge.failed{border-color:var(--bad);color:var(--bad)}.badge.warn,.badge.stale{border-color:var(--warn);color:var(--warn)}.badge.running,.badge.waiting,.badge.info{border-color:var(--pri);color:var(--pri)}
table{width:100%;border-collapse:collapse;background:#fff;border:1px solid var(--line);border-radius:var(--rad);overflow:hidden;margin-bottom:10px}th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;font-size:13px}th{background:#f7f9fc;font-weight:600}tr:last-child td{border-bottom:0}
.aw-state{padding:20px;text-align:center;color:var(--mut);background:#fff;border:1px dashed var(--line);border-radius:var(--rad);margin:8px 0}.aw-state.failed,.aw-state.forbidden{border-color:var(--bad);color:var(--bad)}.aw-state.stale,.aw-state.partial{border-color:var(--warn);color:var(--warn)}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:6px 0}input,select,textarea{font:inherit;padding:7px 9px;border:1px solid var(--line);border-radius:8px;width:100%;max-width:420px}label{display:block;font-weight:600;margin:8px 0 2px}.row label{margin:0;font-weight:400}
.steps{list-style:none;padding:0;margin:6px 0}.steps li{display:flex;gap:8px;padding:4px 0;border-bottom:1px solid var(--line)}.steps .s{width:20px;text-align:center}.steps .done .s{color:var(--ok)}.steps .running .s{color:var(--pri)}.steps .failed .s{color:var(--bad)}.steps .waiting .s,.steps .needs_user .s{color:var(--warn)}
.flow{display:flex;flex-wrap:wrap;gap:4px;padding:0;list-style:none;counter-reset:f}.flow li{padding:3px 8px;border:1px solid var(--line);border-radius:8px;font-size:12px;color:var(--mut)}.flow li.done{border-color:var(--ok);color:var(--ok)}.flow li.now{border-color:var(--pri);color:var(--pri);font-weight:600}
.pre{background:#f7f9fc;border:1px solid var(--line);border-radius:8px;padding:8px;white-space:pre-wrap;word-break:break-word;font-size:12px;max-height:320px;overflow:auto}.muted{color:var(--mut)}.err{color:var(--bad)}.mono{font-family:ui-monospace,Menlo,monospace;font-size:12px}.small{font-size:12px}
.chart{width:100%;height:auto;background:#fff;border:1px solid var(--line);border-radius:8px}.chart .line{stroke:var(--pri);stroke-width:1.5}.chart .axis{stroke:var(--line)}.chart .lbl{font-size:11px;fill:var(--mut)}
.toast{position:fixed;bottom:14px;left:50%;transform:translateX(-50%);background:#101826;color:#fff;padding:10px 16px;border-radius:8px;display:none;z-index:50;max-width:92vw}.toast.show{display:block}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:40;padding:14px}.modal[hidden]{display:none}.modal .box{background:#fff;border-radius:12px;padding:18px;max-width:600px;width:100%;max-height:92vh;overflow:auto}.modal .box h2{margin-top:0}.once{background:#fff7e6;border:1px dashed var(--warn);padding:10px;border-radius:8px;word-break:break-all}
.tabs{display:flex;gap:4px;border-bottom:1px solid var(--line);margin-bottom:10px}.tabs a{padding:8px 12px;text-decoration:none;color:var(--fg);border-bottom:2px solid transparent}.tabs a.on{border-bottom-color:var(--pri);color:var(--pri);font-weight:600}
.pending{list-style:none;padding:0;display:flex;gap:14px;flex-wrap:wrap}.pending li{background:#fff;border:1px solid var(--line);border-radius:8px;padding:8px 12px}
body.login{display:grid;place-items:center;min-height:100vh}.login-box{background:#fff;border:1px solid var(--line);border-radius:12px;padding:24px;width:min(420px,94vw)}
@media (max-width:760px){.menu-btn{display:block}.side{position:fixed;left:-230px;top:48px;bottom:0;transition:left .2s;z-index:30;overflow:auto}.side.open{left:0}.main{padding:10px 12px}.top-right .muted{display:none}
table.r{display:block;border:0;background:none}table.r thead{display:none}table.r tr{display:block;background:#fff;border:1px solid var(--line);border-radius:var(--rad);margin-bottom:8px;padding:6px 10px}table.r td{display:flex;justify-content:space-between;gap:8px;border:0;padding:5px 0;flex-wrap:wrap}table.r td::before{content:attr(data-l);color:var(--mut);font-size:12px;min-width:70px}.kv{grid-template-columns:1fr}}
+144
View File
@@ -0,0 +1,144 @@
/* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트.
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 비밀번호 창 → 재시도, 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
*/
import * as R from './render.mjs';
const $ = (s, el = document) => el.querySelector(s);
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Content-Type': 'application/json', 'X-CSRF': csrf() }, body: body === undefined ? undefined : JSON.stringify(body), credentials: 'same-origin' });
let j = null; try { j = await r.json(); } catch (_) {}
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
return j;
}
/** 재인증이 필요하면 비밀번호를 받아 재인증한 뒤 한 번 더 시도 */
async function call(method, path, body) { try { return await api(method, path, body); } catch (e) { if (e.code === 'reauth_required') { if (await reauth()) return api(method, path, body); } throw e; } }
const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body);
let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); }
function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; }
/** 입력 창. fields: [{name,label,type,options,required,value,hint}] → 값 객체 또는 null */
function ask(title, fields, { okLabel = '확인', danger = false, note = '' } = {}) {
return new Promise((resolve) => {
const m = modal(`<h2>${R.esc(title)}</h2>${note ? `<p class="muted">${note}</p>` : ''}<form id="askf">${fields.map((f) => f.type === 'select' ? `<label>${R.esc(f.label)}<select name="${f.name}">${(f.options || []).map((o) => `<option value="${R.esc(o.value ?? o)}"${(o.value ?? o) == f.value ? ' selected' : ''}>${R.esc(o.label ?? o)}</option>`).join('')}</select></label>` : f.type === 'checkbox' ? `<label><input type="checkbox" name="${f.name}"${f.value ? ' checked' : ''}> ${R.esc(f.label)}</label>` : f.type === 'textarea' ? `<label>${R.esc(f.label)}<textarea name="${f.name}" rows="3">${R.esc(f.value || '')}</textarea></label>` : `<label>${R.esc(f.label)}<input name="${f.name}" type="${f.type || 'text'}" value="${R.esc(f.value || '')}"${f.required ? ' required' : ''} autocomplete="off"></label>`).join('') + (fields.some((f) => f.hint) ? `<p class="muted small">${fields.filter((f) => f.hint).map((f) => R.esc(f.hint)).join(' · ')}</p>` : '')}<div class="actions"><button type="button" class="btn" id="askc">취소</button><button class="btn ${danger ? 'danger' : 'primary'}">${R.esc(okLabel)}</button></div></form>`);
$('#askc', m.el).onclick = () => { m.close(); resolve(null); };
$('#askf', m.el).onsubmit = (e) => { e.preventDefault(); const fd = new FormData(e.target); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? fd.get(f.name) === 'on' : fd.get(f.name); m.close(); resolve(out); };
});
}
/** 1회 표시 값(등록 토큰·자격·접근권 URL·복구 코드): 닫으면 다시 볼 수 없음을 안내 */
function showOnce(title, value, note = '') { const m = modal(`<h2>${R.esc(title)}</h2><p class="err">이 값은 지금 한 번만 표시되며 다시 볼 수 없습니다. 안전한 곳에 바로 옮기세요.</p>${note ? `<p class="muted">${R.esc(note)}</p>` : ''}<pre class="once" id="once">${R.esc(value)}</pre><div class="actions"><button class="btn" id="cp">복사</button><button class="btn primary" id="ok">닫기(확인했습니다)</button></div>`); $('#cp', m.el).onclick = () => { navigator.clipboard?.writeText(value); toast('복사했습니다'); }; $('#ok', m.el).onclick = m.close; }
/** 고위험 작업 3단계 확인(§25): 영향 범위 → 대상 식별정보 재입력 → 최종 실행·되돌리기 가능 여부 */
function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌리기 가능 여부를 확인하세요' }) {
return new Promise((resolve) => {
let step = 1; const m = modal('');
const render = () => { m.el.innerHTML = `<div class="box"><h2>${R.esc(title)} — ${step}/3단계</h2>${step === 1 ? `<p><b>1단계 영향 범위 확인</b></p><ul>${impacts.map((i) => `<li>${R.esc(i)}</li>`).join('')}</ul><label><input type="checkbox" id="c1"> 위 영향 범위를 확인했습니다</label>` : step === 2 ? `<p><b>2단계 대상 식별정보 재확인</b> — 아래 식별정보를 정확히 입력하세요: <code>${identifiers.map(R.esc).join(', ')}</code></p><input id="c2" placeholder="식별정보(쉼표로 구분)" autocomplete="off">` : `<p><b>3단계 최종 실행</b></p><p>${R.esc(revertible)}</p><label><input type="checkbox" id="c3"> 최종 실행을 확인합니다</label>`}<div class="actions"><button class="btn" id="cancel">취소</button><button class="btn ${step === 3 ? 'danger' : 'primary'}" id="next">${step === 3 ? '실행' : '다음'}</button></div></div>`;
$('#cancel', m.el).onclick = () => { m.close(); resolve(null); };
$('#next', m.el).onclick = () => { if (step === 1 && !$('#c1', m.el).checked) return toast('영향 범위를 확인해야 합니다'); if (step === 2) { const got = $('#c2', m.el).value.split(',').map((s) => s.trim()).filter(Boolean).sort(); if (JSON.stringify(got) !== JSON.stringify(identifiers.slice().sort())) return toast('대상 식별정보가 다릅니다'); } if (step === 3 && !$('#c3', m.el).checked) return toast('최종 실행을 확인해야 합니다'); if (step < 3) { step++; render(); } else { m.close(); resolve({ confirmImpact: true, confirmIdentifiers: identifiers, confirmExecute: true }); } }; };
render();
});
}
async function reauth() { const v = await ask('재인증(최근 10분)', [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: '재인증' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
const reload = () => location.reload();
async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } }
// ---------------- 페이지별 데이터 로드(포털 중계 포함)
const body = () => $('#body');
async function relay(path) { try { return await api('GET', '/ops/api/portal' + path); } catch (e) { throw Object.assign(e, { relay: true }); } }
const PAGES = {
overview: async () => { try { const o = await api('GET', '/ops/api/overview'); body().innerHTML = R.overviewView(o); } catch (e) { body().innerHTML = R.relayError(e); return; }
const el = $('#pending-approvals'); if (!el) return; const partial = []; const count = async (p, pick) => { try { const r = await relay(p); return pick(r); } catch (e) { partial.push(p); return 0; } };
const [orders, restores, relocations, transfers] = await Promise.all([count('/commerce/orders?status=awaiting_approval', (r) => (r.orders || []).length), count('/backup/restores', (r) => (r.restores || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/relocation', (r) => (r.relocations || []).filter((x) => ['awaiting_approval', 'requested'].includes(x.status) || x.approval_state === 'pending').length), count('/transfer', (r) => (r.transfers || []).filter((x) => ['reviewing', 'accepted'].includes(x.status)).length)]);
el.innerHTML = partial.length === 4 ? R.relayError({ code: 'portal_unreachable' }) : R.pendingApprovals({ orders, restores, relocations, transfers, partial: partial.length ? partial : null }); },
customers: async () => { const q = new URLSearchParams(location.search).get('q') || ''; try { body().innerHTML = R.customersSearch(await relay('/customers?q=' + encodeURIComponent(q))); } catch (e) { body().innerHTML = R.relayError(e); } },
customer: async (id) => { try { body().innerHTML = R.customerView(await relay('/customers/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
orders: async () => { try { body().innerHTML = R.ordersView(await relay('/commerce/orders')); } catch (e) { body().innerHTML = R.relayError(e); } },
products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } },
sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } },
site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } },
mail: async () => { 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); } },
backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } },
relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } },
servers: async () => { try { const r = await api('GET', '/ops/api/servers'); const b = body(); const a = b.querySelector('.actions'); b.innerHTML = (a ? a.outerHTML : '') + R.serversTable(r.servers) + '<p class="muted">마지막 갱신 ' + R.fmt(new Date().toISOString()) + '</p>'; } catch (e) { toast('서버 목록 갱신 실패: ' + e.message); } },
server: async (id) => { const draw = async () => { const key = $('#metric-key')?.value || 'cpu'; const days = Number($('#metric-days')?.value || 30); try { const r = await api('GET', `/ops/api/servers/${id}/metrics?since=${encodeURIComponent(new Date(Date.now() - days * 86400000).toISOString())}&limit=5000`); $('#metrics').innerHTML = R.metricsSvg(r.samples, key); } catch (e) { $('#metrics').innerHTML = R.relayError(e); } }; $('#metric-key')?.addEventListener('change', draw); $('#metric-days')?.addEventListener('change', draw); await draw(); },
job: async (id) => { try { const r = await api('GET', '/ops/api/jobs/' + id); const j = r.job; if (!['done', 'failed', 'cancelled', 'reverted'].includes(j.status)) { const jb = $('.job', body()); if (jb) jb.outerHTML = R.jobBlock(j); } } catch (_) {} },
recovery: async () => { document.querySelectorAll('[data-tab]').forEach((a) => a.onclick = (e) => { e.preventDefault(); document.querySelectorAll('[data-tab]').forEach((x) => x.classList.remove('on')); a.classList.add('on'); document.querySelectorAll('main section').forEach((s) => { s.hidden = s.id !== a.dataset.tab; }); }); if (location.hash === '#dr') $('[data-tab=dr]')?.click(); },
login: () => { const f = $('#login-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { const r = await api('POST', '/ops/api/login', { login: fd.get('login'), password: fd.get('password'), totp: fd.get('totp') || undefined }); if (r.mfaRequired) { $('#totp-row').hidden = false; $('#msg').textContent = r.message || '인증 코드를 입력하세요'; return; } location.href = '/ops/'; } catch (err) { $('#msg').textContent = err.message; } }; },
setup: () => { const f = $('#setup-form'); if (!f) return; f.onsubmit = async (e) => { e.preventDefault(); const fd = new FormData(f); try { await api('POST', '/ops/api/setup', Object.fromEntries(fd)); toast('첫 운영자를 만들었습니다. 로그인하세요'); location.href = '/ops/login'; } catch (err) { $('#msg').textContent = err.message; } }; },
};
// ---------------- 동작(data-act)
const ACT = {
'toggle-nav': () => $('#side')?.classList.toggle('open'), logout: async () => { await api('POST', '/ops/api/logout'); location.href = '/ops/login'; }, reauth: () => reauth(),
job: async (b) => { await call('POST', `/ops/api/jobs/${b.id}/${b.op}`); toast('요청했습니다'); reload(); },
'release-approve': async (b) => { if (!(await ask('릴리스 승인', [{ name: 'ok', label: `릴리스 ${b.v} 를 승인합니다(검사 실패 릴리스는 거부됨)`, type: 'checkbox' }]))) return; await call('POST', `/ops/api/releases/${b.v}/approve`); toast('승인했습니다'); reload(); },
'module-deploy': async (b) => { const sv = await api('GET', '/ops/api/servers'); const v = await ask(`${b.m} ${b.v} 배포`, [{ name: 'serverIds', label: '대상 서버 id(쉼표, 비우면 해당 역할 전체)', hint: sv.servers.map((s) => `${s.id}=${s.name}(${s.role},${s.status})`).join(' ') }], { note: 'HAL 이 먼저 배포되어 있어야 하며(§32.2), 대상별로 결과가 따로 보입니다. 일부 실패는 전체 성공으로 보이지 않습니다.' }); if (!v) return; const r = await call('POST', `/ops/api/modules/${b.m}/deploy`, { version: b.v, serverIds: v.serverIds ? v.serverIds.split(',').map((s) => s.trim()).filter(Boolean) : [], nonce: Date.now() }); location.href = '/ops/jobs/' + r.job.id; },
'module-health': async (b) => { const r = await call('POST', `/ops/api/modules/${b.m}/health`, { serverIds: b.s ? [b.s] : null }); location.href = '/ops/jobs/' + r.job.id; },
'module-rollback': async (b) => { const c = await threeStep({ title: `${b.m} 되돌리기`, impacts: [`서버 ${b.s} 의 ${b.m} 을(를) 이전 릴리스로 되돌립니다`, '되돌리는 동안 해당 모듈이 잠시 중단될 수 있습니다', '이전 릴리스 디렉터리가 남아 있어야 합니다(최근 3개 보관)'], identifiers: [b.s], revertible: '되돌린 뒤 다시 배포하면 복구할 수 있습니다' }); if (!c) return; const r = await call('POST', `/ops/api/modules/${b.m}/rollback`, { ...c, serverIds: [b.s] }); location.href = '/ops/jobs/' + r.job.id; },
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); },
'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); },
'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); },
'server-accepts': async (b) => { await call('PATCH', `/ops/api/servers/${b.id}`, { accepts_new: Number(b.v) }); reload(); },
'server-isolate': async (b) => { const v = await ask('서버 격리', [{ name: 'reason', label: '격리 이유', required: true }], { danger: true }); if (!v) return; await call('PATCH', `/ops/api/servers/${b.id}`, { status: 'isolated', isolated_reason: v.reason }); reload(); },
'server-delete': async (b) => { const c = await threeStep({ title: '서버 삭제', impacts: [`서버 ${b.id} 와 그 Agent 를 해지합니다`, `배치된 서비스 ${b.hosted}개가 있으면 먼저 이전해야 합니다`, '지표·작업 기록은 남습니다'], identifiers: [b.id], revertible: '삭제 후 복구할 수 없습니다(다시 등록해야 함)' }); if (!c) return; await call('POST', `/ops/api/servers/${b.id}/delete`, c); location.href = '/ops/servers'; },
'incident-new': async () => { const v = await ask('사건 등록', [{ name: 'key', label: '사건 키(같은 원인은 같은 키로 묶임)', required: true }, { name: 'title', label: '제목', required: true }, { name: 'severity', label: '심각도', type: 'select', options: ['info', 'warning', 'critical'] }, { name: 'component', label: '구성요소' }, { name: 'customerImpact', label: '고객 영향(없으면 비움 — 내부 경고는 공개하지 않음)' }, { name: 'public', label: '공개 상태 페이지에 표시', type: 'checkbox' }, { name: 'publicNote', label: '고객 공지 문구' }]); if (!v) return; await call('POST', '/ops/api/incidents', v); reload(); },
'incident-note': async (b) => { const v = await ask('대응 메모', [{ name: 'note', label: '메모', type: 'textarea', required: true }, { name: 'assignee', label: '담당자' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); },
'incident-public': async (b) => { const v = await ask('고객 공지', [{ name: 'publicNote', label: '공개 문구(개인정보·내부 위치 없이)', type: 'textarea', required: true }, { name: 'public', label: '공개', type: 'checkbox', value: true }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, v); reload(); },
'incident-resolve': async (b) => { const v = await ask('사건 종료', [{ name: 'verifiedUserPath', label: '실제 사용자 경로(외부 접속·메일 왕복 등)로 복구를 확인했습니다', type: 'checkbox' }, { name: 'note', label: '복구 확인 내용', type: 'textarea' }]); if (!v) return; await call('PATCH', `/ops/api/incidents/${b.id}`, { ...v, resolve: true }); reload(); },
'em-declare': async () => { const v = await ask('긴급 복구 선언', [{ name: 'incidentKey', label: '사건 번호(키)', required: true }, { name: 'reason', label: '복구 이유', type: 'textarea', required: true }, { name: 'scope', label: '허용 대상(쉼표: hal,hal_data,vault,cert_base,artifact_store,first_agent)', required: true }], { danger: true, note: 'HAL 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. HAL 이 정상이면 거부됩니다.' }); if (!v) return; await call('POST', '/ops/api/emergency', { ...v, scope: v.scope.split(',').map((s) => s.trim()).filter(Boolean) }); reload(); },
'em-approve': async (b) => { const step = Number(b.step); const fields = step === 1 ? [{ name: 'impactText', label: '1단계 — 영향 범위를 적으세요', type: 'textarea', required: true }] : step === 2 ? [{ name: 'identifiers', label: '2단계 — 변경되지 않는 대상 식별정보(쉼표)', required: true }] : [{ name: 'finalConfirm', label: '3단계 — 최종 실행을 확인합니다', type: 'checkbox' }, { name: 'revertible', label: '되돌리기 가능', type: 'checkbox' }]; const v = await ask(`승인 ${step}단계`, fields, { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/approve`, { step, ...v, identifiers: v.identifiers ? v.identifiers.split(',').map((s) => s.trim()) : undefined }); reload(); },
'em-credential': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/credential`); showOnce('긴급 자격(짧은 유효 시간)', r.credential, `만료 ${R.fmt(r.expiresAt)} · 정상화 후 폐기됩니다`); setTimeout(reload, 500); },
'em-perform': async (b) => { const v = await ask('긴급 작업 수행', [{ name: 'credential', label: '긴급 자격', type: 'password', required: true }, { name: 'action', label: '작업', type: 'select', options: ['hal.restart', 'hal.restore_snapshot', 'vault.verify', 'cert.reissue_internal', 'artifact.restore', 'agent.reconnect'] }, { name: 'target', label: '대상(선택)' }], { danger: true, note: '허용 범위 밖 작업·만료 자격·일반 배포는 거부됩니다.' }); if (!v) return; try { const r = await call('POST', `/ops/api/emergency/${b.id}/perform`, v); toast('수행 결과: ' + JSON.stringify(r.result).slice(0, 200)); } catch (e) { toast('거부: ' + e.message); } reload(); },
'em-verify': async (b) => { const v = await ask('실제 결과 확인·자격 폐기', [{ name: 'note', label: '실제 확인 내용(운영자 로그인·HAL 제어·HTTPS·고객 서비스 외부 확인)', type: 'textarea', required: true }]); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/verify`, { verify: { note: v.note } }); reload(); },
'em-reconcile': async (b) => { const r = await call('POST', `/ops/api/emergency/${b.id}/reconcile`); toast(`차이 ${(r.result.drift || []).length}건`); reload(); },
'em-close': async (b) => { const v = await ask('사건 종료', [{ name: 'note', label: '종료 메모', type: 'textarea' }], { danger: true }); if (!v) return; await call('POST', `/ops/api/emergency/${b.id}/close`, v); reload(); },
'dr-config': async () => { const v = await ask('재해복구 설정', [{ name: 'rto_minutes', label: '복구 목표 시간 RTO(분)', type: 'number', required: true }, { name: 'rpo_minutes', label: '허용 데이터 시점 RPO(분)', type: 'number', required: true }, { name: 'snapshot_interval_minutes', label: '복구 사본 생성 주기(분)', type: 'number', required: true }, { name: 'retention_count', label: '보존 개수', type: 'number', required: true }, { name: 'external_store', label: '외부 보관 위치' }]); if (!v) return; await call('PUT', '/ops/api/dr/config', Object.fromEntries(Object.entries(v).map(([k, x]) => [k, k === 'external_store' ? x : Number(x)]))); reload(); },
'dr-snapshot': async () => { await call('POST', '/ops/api/dr/snapshots'); toast('복구 사본을 만들었습니다'); reload(); },
'dr-drill': async (b) => { await call('POST', '/ops/api/dr/drills', { snapshotId: b.id || null }); toast('훈련을 시작했습니다 — 실제 복구 뒤 결과를 입력하세요'); reload(); },
'dr-drill-finish': async (b) => { const v = await ask('훈련 결과', [{ name: 'result', label: '결과', type: 'select', options: ['success', 'failed'] }, { name: 'measuredRtoMinutes', label: '측정 RTO(분)', type: 'number', required: true }, { name: 'measuredRpoMinutes', label: '측정 RPO(분)', type: 'number', required: true }, { name: 'findings', label: '실패·조치 항목(줄바꿈)', type: 'textarea' }, { name: 'steps', label: '수행 단계(쉼표)', value: '운영자 로그인,HAL 제어,HTTPS,고객 서비스 외부 확인' }]); if (!v) return; await call('POST', `/ops/api/dr/drills/${b.id}/finish`, { result: v.result, measuredRtoMinutes: Number(v.measuredRtoMinutes), measuredRpoMinutes: Number(v.measuredRpoMinutes), findings: v.findings.split('\n').filter(Boolean), steps: v.steps.split(',').map((s) => ({ id: s.trim(), ok: v.result === 'success' })) }); reload(); },
'dr-restore': async (b) => { const v = await ask('복구 요청', [{ name: 'note', label: '이유', type: 'textarea', required: true }], { danger: true, note: '복구 시작에는 별도 승인과 기존 제어 위치 중지 확인이 필요합니다. 복구 후 HAL 하나만 운영 상태여야 합니다(§34).' }); if (!v) return; await call('POST', '/ops/api/dr/restores', { snapshotId: b.id, note: v.note }); reload(); },
'dr-approve': async (b) => { await call('POST', `/ops/api/dr/restores/${b.id}/approve`); reload(); },
'dr-start': async (b) => { const v = await ask('복구 시작', [{ name: 'oldControlStopped', label: '기존 제어 위치(이전 HAL)를 중지했음을 확인합니다', type: 'checkbox' }, { name: 'confirmText', label: '확인 문구 입력: 복구 시작', required: true }], { danger: true }); if (!v) return; const r = await call('POST', `/ops/api/dr/restores/${b.id}/start`, v); toast(r.message || '복구를 시작했습니다'); reload(); },
'dr-finish': async (b) => { const v = await ask('복구 완료 — 실제 확인 입력', [{ name: 'login', label: '실제 운영자 로그인 확인', type: 'checkbox' }, { name: 'control', label: 'HAL 제어 확인', type: 'checkbox' }, { name: 'https', label: 'HTTPS 확인', type: 'checkbox' }, { name: 'customer', label: '고객 서비스 한 개 이상 외부 확인', type: 'checkbox' }]); if (!v) return; await call('POST', `/ops/api/dr/restores/${b.id}/finish`, { checks: v }); reload(); },
password: async () => { const v = await ask('비밀번호 변경', [{ name: 'current', label: '현재 비밀번호', type: 'password', required: true }, { name: 'next', label: '새 비밀번호(10자 이상)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/password', v); toast('바꿨습니다(다른 세션 종료)'); reload(); },
'mfa-begin': async () => { const r = await call('POST', '/ops/api/mfa/begin'); const v = await ask('MFA 등록', [{ name: 'code', label: `인증 앱에 추가(otpauth): ${r.otpauth} — 수동 키: ${r.secret}. 앱의 6자리 코드를 입력하세요`, required: true }]); if (!v) return; const c = await call('POST', '/ops/api/mfa/confirm', { code: v.code }); showOnce('복구 코드(1회만 표시)', c.recoveryCodes.join('\n'), '각 코드는 한 번만 쓸 수 있습니다.'); setTimeout(reload, 300); },
'mfa-disable': async () => { const v = await ask('MFA 해제', [{ name: 'code', label: '인증 코드', required: true }], { danger: true }); if (!v) return; await call('POST', '/ops/api/mfa/disable', v); reload(); },
'session-revoke': async (b) => { await call('POST', '/ops/api/sessions/revoke', { id: b.id }); reload(); }, 'session-revoke-all': async () => { await call('POST', '/ops/api/sessions/revoke', { all: true }); reload(); },
'grant-issue': async () => { const v = await ask('접근권 발급', [{ name: 'accountId', label: '운영자 id(비우면 나)' }, { name: 'hours', label: '유효 시간(1~72시간)', type: 'number', value: 8 }, { name: 'reason', label: '사유', required: true }]); if (!v) return; const r = await call('POST', '/ops/api/access-grants', { accountId: v.accountId || undefined, hours: Number(v.hours), reason: v.reason }); showOnce('접근권 URL(1회)', r.grant.url, `허용 네트워크 밖 기기에서 이 URL 을 열면 그 기기에 묶입니다. 만료 ${R.fmt(r.grant.expiresAt)}`); },
'grant-revoke': async (b) => { await call('POST', `/ops/api/access-grants/${b.id}/revoke`); reload(); },
'account-new': async () => { const v = await ask('운영자 추가', [{ name: 'login', label: '아이디', required: true }, { name: 'displayName', label: '표시 이름' }, { name: 'password', label: '초기 비밀번호(첫 로그인 뒤 변경 강제)', type: 'password', required: true }]); if (!v) return; await call('POST', '/ops/api/accounts', v); reload(); },
// ---- 포털 중계 동작(§26.2·§27·§30)
'cust-change': async (b) => { const v = await ask('고객 대신 변경', [{ name: 'field', label: '항목', type: 'select', options: ['group.name', 'group.contact_email', 'group.contact_phone', 'site.name', 'site.maintenance', 'site.search_visible'] }, { name: 'subjectId', label: '사이트 id(사이트 항목일 때)' }, { name: 'value', label: '변경 후 값(참/거짓 항목은 1/0)', required: true }, { name: 'reason', label: '사유', required: true }, { name: 'approver', label: '승인자' }]); if (!v) return; const r = await portal('POST', `/customers/${b.g}/change`, { ...v, value: /\.(maintenance|search_visible)$/.test(v.field) ? v.value === '1' : v.value }); toast(`변경: ${JSON.stringify(r.change.before)} → ${JSON.stringify(r.change.after)}`); reload(); },
'support-issue': async (b) => { const v = await ask('고객 지원용 일시 접근', [{ name: 'targetType', label: '대상 종류', type: 'select', options: ['group', 'site', 'mail_service'] }, { name: 'targetId', label: '대상 id' }, { name: 'reason', label: '이유', required: true }, { name: 'hours', label: '만료(시간, 최대 24)', type: 'number', value: 4 }, { name: 'scope', label: '허용 범위(쉼표: view,change)', value: 'view' }, { name: 'approver', label: '승인자' }]); if (!v) return; await portal('POST', '/support-access', { ...v, groupId: b.g, hours: Number(v.hours), scope: v.scope.split(',').map((s) => s.trim()) }); reload(); },
'support-revoke': async (b) => { await portal('POST', `/support-access/${b.id}/revoke`, {}); reload(); },
'order-paid': async (b) => { const v = await ask('입금 확인', [{ name: 'ref', label: '입금 확인 참조(입금자·시각 등)', required: true }]); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/paid`, v); reload(); },
'order-approve': async (b) => { const o = await portal('GET', `/commerce/orders/${b.id}`); const od = o.order || {}; const v = await ask('주문 승인', [{ name: 'ok', label: `대상 ${od.group?.name || od.group_id} · 금액 ${od.total} ${od.currency} · 권리 변화 ${(od.entitlementDelta || []).map((e) => e.resource + '+' + e.amount).join(', ')} — 확인했습니다`, type: 'checkbox' }]); if (!v || !v.ok) return; const r = await portal('POST', `/commerce/orders/${b.id}/approve`, {}); toast(r.already ? '이미 승인된 주문입니다(권리 중복 생성 없음)' : `승인 — 권리 ${r.granted}건 활성화`); reload(); },
'order-reject': async (b) => { const v = await ask('주문 거절', [{ name: 'reason', label: '사유(고객에게 표시)', required: true }], { danger: true }); if (!v) return; await portal('POST', `/commerce/orders/${b.id}/reject`, v); reload(); },
'order-refund': async (b) => { const v = await ask('환불·크레딧', [{ name: 'kind', label: '종류', type: 'select', options: ['refund', 'credit', 'adjust'] }, { name: 'amount', label: '금액', type: 'number', required: true }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', '/commerce/credits', { groupId: b.g, orderId: b.id, kind: v.kind, amount: Number(v.amount), reason: v.reason }); reload(); },
'product-new-version': async (b) => { const v = await ask('새 상품 버전', [{ name: 'patch', label: '변경 내용(JSON, 예 {"price": 12000})', type: 'textarea', required: true }], { note: '판매된 버전은 바꾸지 않고 새 버전을 만듭니다. 기존 고객은 자기 버전을 유지합니다.' }); if (!v) return; let patch; try { patch = JSON.parse(v.patch); } catch (_) { return toast('JSON 형식이 아닙니다'); } await portal('POST', '/commerce/products', { productId: b.p, baseVersionId: b.id, patch }); reload(); },
'product-publish': async (b) => { await portal('POST', `/commerce/products/${b.id}/publish`, { published: b.pub === '1' }); reload(); },
'catalog-seed': async () => { await portal('POST', '/commerce/catalog/seed', {}); reload(); },
'site-refresh': async (b) => { await portal('POST', `/sites/${b.id}/refresh`, {}); reload(); },
'mail-rates': async (b) => { const v = await ask('발송 제한·제출량 조회', [{ name: 'domain', label: '도메인(선택)' }, { name: 'reason', label: '접근 사유(기록됨)', required: true }]); if (!v) return; const r = await portal('GET', `/mail/servers/${b.id}/rates?domain=${encodeURIComponent(v.domain || '')}`); modal(`<h2>발송 제한·제출량</h2><pre class="pre">${R.esc(JSON.stringify(r, null, 1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`); },
'mail-test': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/test`, {}); toast('외부 송수신 시험을 시작했습니다' + (r.job ? ' — 작업 ' + r.job.id : '')); },
'mail-security': async (b) => { const r = await portal('POST', `/mail/services/${b.id}/security`, {}); modal(`<h2>DNS 보안 확인</h2><pre class="pre">${R.esc(JSON.stringify(r, null, 1))}</pre><div class="actions"><button class="btn" onclick="document.getElementById('modal').hidden=true">닫기</button></div>`); },
'mail-operating': async (b) => { const v = await ask('운영 상태(이전 중 두 서버 동시 처리 방지)', [{ name: 'operating', label: '상태', type: 'select', options: ['active', 'migrating_out', 'migrating_in', 'frozen'] }, { name: 'reason', label: '사유', required: true }]); if (!v) return; await portal('POST', `/mail/services/${b.id}/operating`, v); reload(); },
'mail-cancel': async (b) => { const v = await ask('메일 서비스 정지·해지', [{ name: 'reason', label: '사유', required: true }, { name: 'ok', label: '송수신이 즉시 중지되고 30일 읽기·내보내기 뒤 삭제됩니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/mail/services/${b.id}/cancel`, { reason: v.reason }); reload(); },
'domains-sweep': async () => { await portal('POST', '/domains/sweep', {}); await portal('POST', '/source/sweep', {}).catch(() => {}); reload(); },
'backup-retry': async (b) => { await portal('POST', `/backup/options/${b.id}/retry`, {}); reload(); }, 'backup-override': async (b) => { const v = await ask('같은 서버 저장 허용(예외)', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/options/${b.id}/override-same-server`, v); reload(); },
'restore-approve': async (b) => { const d = await portal('GET', `/backup/restores/${b.id}`); const r = d.restore || d; const fc = d.finalConfirmation || {}; const v = await ask('복원 승인 — 최종 확인', [{ name: 'ok', label: `고객 그룹 ${fc.groupName || r.group_name} · 사이트 ${fc.siteName || r.site_name} · 도메인 ${fc.primaryDomain || r.primary_domain || '—'} · 백업 시각 ${R.fmt(fc.backupAt || r.backup_created_at)} — 위 내용을 최종 확인합니다`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/backup/restores/${b.id}/approve`, { confirm: fc }); reload(); },
'restore-reject': async (b) => { const v = await ask('복원 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/backup/restores/${b.id}/reject`, v); reload(); }, 'restore-retry': async (b) => { await portal('POST', `/backup/restores/${b.id}/retry`, {}); reload(); },
'reloc-approve': async (b) => { const d = await portal('GET', `/relocation/${b.id}`); const fc = d.finalConfirmation || { subjectId: d.relocation?.subject_id, toServerId: d.relocation?.to_server_id }; const v = await ask('이전 승인 — 최종 확인', [{ name: 'scheduledAt', label: '작업 시간(ISO, 비우면 즉시)' }, { name: 'ok', label: `대상 ${fc.subjectName || fc.subjectId} → 서버 ${fc.toServerId} · 전환 직전 조건이 달라지면 자동 중지됩니다 — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/approve`, { scheduledAt: v.scheduledAt || null, confirm: fc }); reload(); },
'reloc-reject': async (b) => { const v = await ask('이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/relocation/${b.id}/reject`, v); reload(); },
'reloc-retry': async (b) => { await portal('POST', `/relocation/${b.id}/retry`, {}); reload(); }, 'reloc-resume': async (b) => { await portal('POST', `/relocation/${b.id}/resume`, {}); reload(); }, 'reloc-revert': async (b) => { const v = await ask('이전 되돌리기', [{ name: 'ok', label: '새 위치를 운영 위치로 확정하지 않고 이전 위치로 돌립니다 — 확인', type: 'checkbox' }], { danger: true }); if (!v || !v.ok) return; await portal('POST', `/relocation/${b.id}/revert`, {}); reload(); },
'reloc-new': async () => { const v = await ask('플랫폼 사유 이전', [{ name: 'subjectType', label: '대상 종류', type: 'select', options: ['site', 'mail'] }, { name: 'subjectId', label: '대상 id', required: true }, { name: 'toServerId', label: '도착 서버 id(비우면 자동 선택)' }, { name: 'reason', label: '이유(장애 회피·용량 조정 — 고객 과금 없음)', required: true }]); if (!v) return; await portal('POST', '/relocation', v); reload(); },
'transfer-approve': async (b) => { const d = await portal('GET', `/transfer/${b.id}`); const fc = d.finalConfirmation || {}; const v = await ask('소유권 이전 승인', [{ name: 'proof', label: '소유 증빙 확인 내용', required: true }, { name: 'domains', label: '도메인 확인 결과', required: true }, { name: 'ok', label: `${fc.fromGroupId} → ${fc.toGroupId} 전체가 한 번에 이전됩니다(일부만 이전되지 않음) — 확인`, type: 'checkbox' }]); if (!v || !v.ok) return; await portal('POST', `/transfer/${b.id}/approve`, { review: { proof: v.proof, domains: v.domains }, confirm: fc }); reload(); },
'transfer-reject': async (b) => { const v = await ask('소유권 이전 거절', [{ name: 'reason', label: '사유', required: true }], { danger: true }); if (!v) return; await portal('POST', `/transfer/${b.id}/reject`, v); reload(); },
'transfer-resolve': async (b) => { const v = await ask('분쟁 해결', [{ name: 'resolution', label: '결정', type: 'select', options: ['continue', 'cancel'] }, { name: 'note', label: '근거', required: true }]); if (!v) return; await portal('POST', `/transfer/${b.id}/resolve-dispute`, v); reload(); },
};
document.addEventListener('click', (e) => { const el = e.target.closest('[data-act]'); if (!el) return; e.preventDefault(); const fn = ACT[el.dataset.act]; if (fn) guarded(() => fn(el.dataset)); });
document.addEventListener('submit', (e) => { const f = e.target.closest('[data-form=customer-search]'); if (f) { e.preventDefault(); location.href = '/ops/customers?q=' + encodeURIComponent(new FormData(f).get('q') || ''); } });
// ---------------- 부팅
const main = document.querySelector('[data-page]'); const page = main?.dataset.page; const id = main?.dataset.id;
if (page && PAGES[page]) { guarded(() => PAGES[page](id)); if (['overview', 'jobs', 'job', 'servers'].includes(page)) setInterval(() => { if (!document.hidden) guarded(() => PAGES[page](id)); }, 15000); }
+1
View File
@@ -0,0 +1 @@
../render.mjs
+167
View File
@@ -0,0 +1,167 @@
/**
* hal/ui/render.mjs — 운영자 콘솔 화면 조각 렌더러(순수 함수, Node·브라우저 공용).
*
* 서버(hal/ui.mjs)는 HAL 자체 데이터로 첫 화면을 그리고, 브라우저(ops.js)는 같은 함수로 /ops/api 응답을 다시 그린다.
* 그래서 이 파일은 node 모듈을 import 하지 않는다(브라우저가 /ops/ui/render.mjs 로 그대로 받는다).
* 규칙(§2.2·§2.3): 상태는 글자+기호로(색은 보조), 오래된 정보는 '정보 지연', 일부 실패는 '부분 실패'로 드러내고 전체 성공처럼 보이지 않는다.
*/
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 시각을 KST 로 */
export function fmt(iso) { if (!iso) return '—'; const t = Date.parse(iso); if (isNaN(t)) return esc(iso); const k = new Date(t + 9 * 3600 * 1000); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; }
export const ago = (iso) => { if (!iso) return '확인 전'; const s = Math.round((Date.now() - Date.parse(iso)) / 1000); return s < 60 ? `${s}초 전` : s < 3600 ? `${Math.round(s / 60)}분 전` : s < 86400 ? `${Math.round(s / 3600)}시간 전` : `${Math.round(s / 86400)}일 전`; };
export const bytes = (n) => { n = Number(n || 0); if (!n) return '0'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(i ? 1 : 0)} ${u[i]}`; };
export const pct = (a, b) => (b > 0 ? Math.round((a / b) * 100) : null);
const JOB_LABEL = { not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중', awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료', needs_user: '사용자 확인 필요', cancelled: '취소됨' };
const HEALTH_LABEL = { ok: '정상', degraded: '성능 저하', partial_outage: '일부 장애', down: '중단', unknown: '확인 전', stale: '정보 확인 지연', not_provided: '제공되지 않음' };
const SERVER_LABEL = { ready: '배치 가능', pending: '등록 대기', maintenance: '유지보수', isolated: '격리', offline: '응답 없음' };
export const jobLabel = (s) => JOB_LABEL[s] || s || '';
/** 상태 배지(글자+기호) */
export function badge(kind, label) { const sym = { ok: '●', done: '✔', failed: '✘', warn: '!', info: 'ⓘ', running: '◌', waiting: '◔', paused: '⏸', neutral: '○', stale: '⧖' }[kind] || '○'; return `<span class="badge ${esc(kind)}"><span aria-hidden="true">${sym}</span> ${esc(label)}</span>`; }
export const jobBadge = (status) => badge(status === 'done' ? 'done' : status === 'failed' ? 'failed' : ['running', 'verifying', 'reverting'].includes(status) ? 'running' : ['awaiting_external', 'needs_user', 'awaiting_approval', 'awaiting_payment', 'queued'].includes(status) ? 'waiting' : status === 'paused' ? 'paused' : 'neutral', jobLabel(status));
export const healthBadge = (h) => badge(h === 'ok' ? 'ok' : h === 'down' ? 'failed' : h === 'stale' || h === 'unknown' ? 'stale' : 'warn', HEALTH_LABEL[h] || h);
/** 공통 상태 패널 */
export function statePanel(kind, text, { href, label } = {}) { const L = { loading: '불러오는 중', empty: '비어 있음', stale: '정보 지연', partial: '부분 실패', failed: '전체 실패', forbidden: '권한 없음' }; return `<div class="aw-state ${esc(kind)}" role="status"><b>${esc(L[kind] || kind)}</b>${text ? `<p>${esc(text)}</p>` : ''}${href ? `<a class="btn" href="${esc(href)}">${esc(label || '이동')}</a>` : ''}</div>`; }
/** 중계(포털) 호출 실패 → 정보 지연. 포털 데이터로 정상 판정하지 않는다. */
export function relayError(e) { const code = e && (e.code || e.error) || ''; if (code === 'portal_unreachable' || code === 'portal_not_linked' || code === 'service_unreachable') return statePanel('stale', `포털에서 정보를 받지 못했습니다(${code}). 마지막으로 확인한 정보가 없어 상태를 판정할 수 없습니다 — 연결 복구 후 다시 확인하세요`); if (code === 'unauthenticated' || code === 'access_denied') return statePanel('forbidden', e.message || '권한이 없습니다'); if (code === 'reauth_required') return statePanel('forbidden', '최근 재인증이 필요합니다(10분). 상단의 "재인증"을 누르세요'); return statePanel('failed', (e && e.message) || '오류'); }
/** 표. cols: [{k, l, f?(row)}]. 360px 에서는 table.r 로 카드처럼 접힌다. */
export function table(cols, rows, { empty = '항목이 없습니다', cls = '' } = {}) { if (!rows || !rows.length) return statePanel('empty', empty); return `<table class="r ${esc(cls)}"><thead><tr>${cols.map((c) => `<th>${esc(c.l)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${cols.map((c) => `<td data-l="${esc(c.l)}">${c.f ? c.f(r) : esc(r[c.k])}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
export function kv(pairs) { return `<dl class="kv">${pairs.filter((p) => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v == null || v === '' ? '—' : v}</dd>`).join('')}</dl>`; }
const stepSym = { done: '✔', running: '◌', failed: '✘', waiting: '◔', needs_user: '!', pending: '○' };
/** 작업 상세 블록(§29): 번호·종류·대상·시작 주체·현재 단계·진행률·시각·외부 대기·재시도·되돌리기·오류 */
export function jobBlock(j, { actions = true } = {}) {
if (!j) return statePanel('empty', '작업이 없습니다');
const subj = j.subject || {}; const target = subj.target || subj.module || (subj.payload && (subj.payload.siteId || subj.payload.domain)) || j.subjectId || '';
return `<div class="job" data-job="${esc(j.id)}"><div class="row">${jobBadge(j.status)} <b>${esc(j.label || j.kind)}</b> <span class="mono muted">${esc(j.id)}</span>${j.stale ? ' ' + badge('stale', '정보 확인 지연') : ''}</div>
${kv([['종류', esc(j.kind)], ['대상', esc(target)], ['시작 주체', esc(j.actorType === 'operator' ? `운영자 ${j.createdBy || ''}` : j.actorType === 'system' ? `자동(${j.createdBy || 'system'})` : `사용자 ${j.createdBy || ''}`)], ['고객 그룹', esc(j.groupId || '—')], ['현재 단계', j.currentStep ? esc(j.currentStep.title) + (j.currentStep.detail ? ` <span class="muted">— ${esc(j.currentStep.detail)}</span>` : '') : '—'], ['진행률', `${j.progress || 0}%`], ['시작·갱신', `${fmt(j.startedAt)} · ${fmt(j.updatedAt)}`], ['예상 완료', j.etaAt ? fmt(j.etaAt) : esc(j.etaUnknownReason || '산정할 수 없음')], j.waitReason ? ['외부 대기', esc(j.waitReason)] : null, j.maxAttempts > 1 ? ['자동 재시도', `${j.attempt}/${j.maxAttempts}회${j.nextRunAt && j.status === 'queued' ? ' · 다음 ' + fmt(j.nextRunAt) : ''}`] : null, j.userMessage ? ['필요한 조치', esc(j.userMessage)] : null, j.error ? ['오류 요약', `<span class="err">${esc(j.error)}</span> <span class="muted">(${esc(j.errorCode || '')})</span>`] : null])}
<ul class="steps">${(j.steps || []).map((s) => `<li class="${esc(s.state)}"><span class="s" aria-hidden="true">${stepSym[s.state] || '○'}</span><span>${esc(s.title)}${s.detail ? ` <span class="muted">— ${esc(s.detail)}</span>` : ''}${s.error ? ` <span class="err">— ${esc(s.error)}</span>` : ''}</span></li>`).join('')}</ul>
${actions ? `<div class="actions">${j.canRetry ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="retry">다시 시도</button>` : ''}${j.canResume ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="resume">이어서 진행</button>` : ''}${j.canCancel ? `<button class="btn danger" data-act="job" data-id="${esc(j.id)}" data-op="cancel">취소</button>` : ''}${['done', 'failed', 'needs_user'].includes(j.status) ? `<button class="btn danger" data-act="job" data-id="${esc(j.id)}" data-op="revert">되돌리기</button>` : ''}${['queued', 'awaiting_external'].includes(j.status) ? `<button class="btn" data-act="job" data-id="${esc(j.id)}" data-op="pause">일시 중지</button>` : ''}</div>` : ''}</div>`;
}
/** 자식(대상별) 결과: 일부 실패는 '부분 실패' */
export function childrenBlock(c) { if (!c || !c.total) return ''; const overall = c.overall === 'done' ? badge('done', '모든 대상 완료') : c.overall === 'partial' ? badge('warn', '부분 실패 — 대상별 확인 필요') : c.overall === 'failed' ? badge('failed', '모든 대상 실패') : badge('running', '대상별 진행 중'); return `<h3>대상별 결과 ${overall}</h3>${table([{ l: '대상', f: (k) => `<a href="/ops/jobs/${esc(k.id)}">${esc(k.label || k.id)}</a><br><span class="muted mono">${esc((k.subject && (k.subject.serverId || k.subject.target)) || '')}</span>` }, { l: '상태', f: (k) => jobBadge(k.status) }, { l: '시도', f: (k) => `${k.attempt || 0}${k.next_run_at ? ' · 다음 ' + fmt(k.next_run_at) : ''}` }, { l: '오류', f: (k) => `<span class="err">${esc(k.error || '')}</span>` }], c.children)}`; }
export function agentJobsTable(rows) { return table([{ l: 'Agent 작업', f: (r) => `<span class="mono">${esc(r.id)}</span><br>${esc(r.kind)}` }, { l: 'Agent', f: (r) => `<span class="mono">${esc(r.agent_id || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) }, { l: '진행', f: (r) => `${r.progress || 0}%` }, { l: '오류·조치', f: (r) => `<span class="err">${esc(r.error || '')}</span> ${esc(r.user_message || '')}` }, { l: '시각', f: (r) => `${fmt(r.created_at)} → ${fmt(r.ended_at)}` }], rows, { empty: 'Agent 작업이 없습니다' }); }
/** 서버 표(§28.1) */
export function serversTable(servers) {
return table([
{ l: '서버', f: (s) => `<a href="/ops/servers/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.role)} · ${esc(s.location || '위치 미입력')}</span>` },
{ l: '연결', f: (s) => `${badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : s.status === 'pending' ? 'waiting' : 'warn', SERVER_LABEL[s.status] || s.status)}<br><span class="muted">마지막 확인 ${esc(ago(s.lastSeenAt))}${s.stale ? ' · 정보 지연' : ''}</span>` },
{ l: '건강', f: (s) => healthBadge(s.stale ? 'stale' : s.health) },
{ l: 'CPU·메모리', f: (s) => s.metrics ? `${esc(String(s.metrics.cpu ?? '—'))}% · ${esc(String(s.metrics.memPct ?? pct(s.metrics.memUsed, s.metrics.memTotal) ?? '—'))}%` : '정보 없음' },
{ l: '디스크·inode', f: (s) => s.capacity.diskTotalBytes ? `${pct(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes, s.capacity.diskTotalBytes)}% (${bytes(s.capacity.diskFreeBytes)} 여유)${s.metrics && s.metrics.inodePct != null ? ` · inode ${s.metrics.inodePct}%` : ''}${s.capacity.exhaustEta ? `<br><span class="err">고갈 예상 ${fmt(s.capacity.exhaustEta)}</span>` : ''}` : '정보 없음' },
{ l: 'I/O·네트워크', f: (s) => s.metrics ? `${bytes(s.metrics.ioRead)}/${bytes(s.metrics.ioWrite)} · ↓${bytes(s.metrics.netIn)} ↑${bytes(s.metrics.netOut)}` : '정보 없음' },
{ l: '배치·예약', f: (s) => `${s.capacity.sitesHosted || 0}개 · 예약 ${bytes(s.capacity.reservedBytes)} · 안전 여유 ${bytes(s.capacity.safetyMarginBytes)}` },
{ l: '신규 배치', f: (s) => s.acceptsNew ? badge('ok', '허용') : badge('warn', '중지') + (s.rawStatus === 'maintenance' ? `<br><span class="muted">${esc(s.maintenanceNote || '유지보수')}</span>` : s.rawStatus === 'isolated' ? `<br><span class="err">격리: ${esc(s.isolatedReason || '')}</span>` : '') },
{ l: '시계·인증서', f: (s) => `${s.clockSkewMs == null ? '—' : Math.abs(s.clockSkewMs) > 60000 ? `<span class="err">${s.clockSkewMs}ms</span>` : `${s.clockSkewMs}ms`}<br>${s.facts && s.facts.certExpiresAt ? '인증서 ' + fmt(s.facts.certExpiresAt) : '인증서 정보 없음'}` },
{ l: '모듈 버전', f: (s) => Object.entries(s.modules || {}).map(([m, v]) => `${esc(m)} ${v.drift ? `<span class="err">${esc(v.actual || '없음')}→${esc(v.target)}</span>` : esc(v.actual || v.target || '—')} ${healthBadge(v.health)}`).join('<br>') || '<span class="muted">배치 없음</span>' },
], servers, { empty: '등록된 서버가 없습니다' });
}
export function serverDetail(s, { jobs = [], agentJobs = [] } = {}) {
return `<div class="cards"><div class="card"><h2>연결·역할</h2>${kv([['역할·위치', `${esc(s.role)} · ${esc(s.location || '—')}`], ['연결 상태', badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : 'warn', SERVER_LABEL[s.status] || s.status)], ['마지막 heartbeat', `${fmt(s.lastSeenAt)} (${esc(ago(s.lastSeenAt))})`], ['Agent', `<span class="mono">${esc(s.agentId || '미연결')}</span> ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}</div>
<div class="card"><h2>용량</h2>${kv([['디스크', s.capacity.diskTotalBytes ? `${bytes(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes)} / ${bytes(s.capacity.diskTotalBytes)} (여유 ${bytes(s.capacity.diskFreeBytes)})` : '정보 없음'], ['예약량', bytes(s.capacity.reservedBytes)], ['안전 여유', bytes(s.capacity.safetyMarginBytes)], ['배치 서비스', `${s.capacity.sitesHosted || 0}개`], ['고갈 예상', s.capacity.exhaustEta ? fmt(s.capacity.exhaustEta) : '예상 없음'], ['백업 저장소', s.facts && s.facts.backupStore ? `${bytes(s.facts.backupStore.usedBytes)} / ${bytes(s.facts.backupStore.totalBytes)} · 최근 검증 ${fmt(s.facts.backupStore.lastVerifiedAt)}` : '해당 없음']])}</div>
<div class="card"><h2>모듈(목표 vs 실제)</h2>${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `<span class="err">${esc(m.actual || '없음')}</span>` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `<span class="err">${esc(m.lastError || '')}</span>` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}</div></div>
<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`;
}
/** 90일 지표 SVG(§28.1). samples 는 최신순. key: 'cpu'|'disk'|'mem'|'net'. */
export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
const rows = (samples || []).slice().reverse(); if (!rows.length) return statePanel('empty', '지표가 아직 없습니다');
const val = (r) => key === 'cpu' ? Number(r.cpu || 0) : key === 'mem' ? (r.mem_total ? (r.mem_used / r.mem_total) * 100 : 0) : key === 'disk' ? (r.disk_total ? (r.disk_used / r.disk_total) * 100 : 0) : key === 'inode' ? (r.inode_total ? (r.inode_used / r.inode_total) * 100 : 0) : Number(r.net_out || 0);
const vals = rows.map(val); const max = key === 'net' ? Math.max(1, ...vals) : 100; const t0 = Date.parse(rows[0].at); const t1 = Date.parse(rows[rows.length - 1].at) || t0 + 1;
const pts = rows.map((r, i) => `${(((Date.parse(r.at) - t0) / Math.max(1, t1 - t0)) * (w - 40) + 30).toFixed(1)},${(h - 20 - (vals[i] / max) * (h - 40)).toFixed(1)}`).join(' ');
const label = { cpu: 'CPU %', mem: '메모리 %', disk: '디스크 %', inode: 'inode %', net: '송신 바이트' }[key] || key;
return `<svg viewBox="0 0 ${w} ${h}" class="chart" role="img" aria-label="${esc(label)} 추이 ${rows.length}개 표본"><text x="4" y="12" class="lbl">${esc(label)}</text><line x1="30" y1="${h - 20}" x2="${w - 10}" y2="${h - 20}" class="axis"/><line x1="30" y1="20" x2="30" y2="${h - 20}" class="axis"/><text x="2" y="24" class="lbl">${key === 'net' ? bytes(max) : '100%'}</text><polyline points="${pts}" fill="none" class="line"/><text x="30" y="${h - 4}" class="lbl">${fmt(rows[0].at)}</text><text x="${w - 150}" y="${h - 4}" class="lbl">${fmt(rows[rows.length - 1].at)}</text></svg>`;
}
/** 릴리스 화면(§32.1) */
export function releasesView(data, changelog = {}) {
const running = data.running || {}; const targets = data.targets || [];
const byModule = (v) => (data.modules || []).map((m) => { const ts = targets.filter((t) => t.module === m); const onV = ts.filter((t) => t.actual_version === v).length; return `${esc(m)}: ${ts.length ? `${onV}/${ts.length}` : '<span class="muted">배치 없음</span>'}`; }).join(' · ');
return `<p class="muted">배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')} — <b>HAL 을 먼저 배포하고 HAL 자체 건강·실제 실행 버전을 확인한 뒤에만 나머지를 배포합니다(§32.2)</b>. HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'}</p>
${table([
{ l: '버전', f: (r) => `<b>${esc(r.version)}</b>${r.version === running.hal_running_version ? ' ' + badge('ok', 'HAL 실행 중') : ''}` }, { l: 'main 커밋', f: (r) => `<span class="mono">${esc(String(r.main_commit).slice(0, 12))}</span>` }, { l: '빌드', f: (r) => fmt(r.built_at) },
{ l: '검사', f: (r) => r.checks ? (r.checks.testsPassed === false ? badge('failed', '테스트 실패') : r.checks.testsPassed ? badge('ok', '테스트 통과') : badge('neutral', '검사 없음')) + (r.checks.dirty ? ' ' + badge('warn', '수정본(dirty)') : '') : badge('neutral', '검사 없음') },
{ l: '승인', f: (r) => r.status === 'approved' ? badge('ok', `승인 ${r.approved_by || ''}`) + `<br><span class="muted">${fmt(r.approved_at)}</span>` : badge('waiting', '승인 대기') + ` <button class="btn" data-act="release-approve" data-v="${esc(r.version)}">승인</button>` },
{ l: 'HAL 배포', f: (r) => r.hal_deployed_at ? badge('done', fmt(r.hal_deployed_at)) : badge('neutral', '아직') },
{ l: '모듈별 배치', f: (r) => byModule(r.version) },
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">VERSION_HISTORY 항목 없음</span>' },
{ l: '배포', f: (r) => r.status === 'approved' ? (data.modules || []).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' })}
<h2>모듈 배치 상태(대상별)</h2>${table([{ l: '모듈', k: 'module' }, { l: '서버', f: (t) => `<a href="/ops/servers/${esc(t.server_id)}" class="mono">${esc(t.server_id)}</a>` }, { l: '목표', k: 'target_version' }, { l: '실제', f: (t) => t.target_version && t.actual_version !== t.target_version ? `<span class="err">${esc(t.actual_version || '없음')}</span>` : esc(t.actual_version || '—') }, { l: '건강', f: (t) => healthBadge(t.health) }, { l: '마지막 확인', f: (t) => fmt(t.last_check_at) }, { l: '오류', f: (t) => `<span class="err">${esc(t.last_error || '')}</span>` }, { l: '', f: (t) => `<button class="btn small" data-act="module-health" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">건강 확인</button> <button class="btn small danger" data-act="module-rollback" data-m="${esc(t.module)}" data-s="${esc(t.server_id)}">되돌리기</button>` }], targets, { empty: '배치된 모듈이 없습니다' })}
<div class="actions"><button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>`;
}
export function incidentsView(incidents, notifications = []) {
return `<h2>사건(같은 원인은 하나로 묶임)</h2>${table([{ l: '사건', f: (i) => `<b>${esc(i.title)}</b><br><span class="mono muted">${esc(i.key)}</span>` }, { l: '심각도', f: (i) => badge(i.severity === 'critical' ? 'failed' : i.severity === 'warning' ? 'warn' : 'info', i.severity) }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음(내부)') }, { l: '시작·종료', f: (i) => `${fmt(i.started_at)}<br>${i.resolved_at ? '종료 ' + fmt(i.resolved_at) + (i.verified_at ? ' (사용자 경로 확인)' : '') : badge('running', '대응 중')}` }, { l: '담당', k: 'assignee' }, { l: '공개', f: (i) => i.public ? badge('info', '공개 상태 페이지') + `<br><span class="muted">${esc(i.public_note || '')}</span>` : '비공개' }, { l: '', f: (i) => i.resolved_at ? '' : `<button class="btn small" data-act="incident-note" data-id="${esc(i.id)}">메모</button> <button class="btn small" data-act="incident-public" data-id="${esc(i.id)}">고객 공지</button> <button class="btn small" data-act="incident-resolve" data-id="${esc(i.id)}">종료(사용자 경로 확인 후)</button>` }], incidents, { empty: '진행 중이거나 기록된 사건이 없습니다' })}
<div class="actions"><button class="btn primary" data-act="incident-new">사건 등록</button></div>
<h2>운영자 알림</h2>${table([{ l: '분류', k: 'category' }, { l: '내용', f: (n) => `<b>${esc(n.title)}</b>${n.count > 1 ? ` <span class="muted">×${n.count}</span>` : ''}<br>${esc(n.body || '')}` }, { l: '심각도', f: (n) => badge(n.severity === 'critical' ? 'failed' : n.severity === 'warning' ? 'warn' : 'info', n.severity) }, { l: '최근', f: (n) => fmt(n.last_at) }, { l: '상태', f: (n) => n.state === 'open' ? badge('running', '열림') : badge('done', '해결') }], notifications, { empty: '운영자 알림이 없습니다' })}`;
}
export function auditTable(entries) { return table([{ l: '시각', f: (a) => fmt(a.at) }, { l: '행위자', f: (a) => `${esc(a.actor_type)} ${esc(a.actor_id || '')}${a.automatic ? ' (자동)' : ''}` }, { l: '행동', f: (a) => `<b>${esc(a.action)}</b>${a.immutable ? ' <span class="muted">(삭제 불가)</span>' : ''}` }, { l: '대상', f: (a) => `${esc(a.subject_type || '')} <span class="mono">${esc(a.subject_id || '')}</span>` }, { l: '결과', f: (a) => a.outcome === 'ok' ? badge('ok', '성공') : badge('failed', a.outcome) }, { l: '상세', f: (a) => a.detail ? `<details><summary>보기</summary><pre class="pre">${esc(JSON.stringify(a.detail, null, 1))}</pre></details>` : '' }], entries, { empty: '기록이 없습니다' }); }
/** 긴급 복구(§33) */
export function emergencyView(data) {
const steps = ['declared', 'approving', 'approved', 'credential_issued', 'performing', 'verified', 'reconciled', 'closed'];
const L = { declared: '선언', approving: '승인 중', approved: '승인 완료', credential_issued: '자격 발급', performing: '수행 중', verified: '결과 확인·자격 폐기', reconciled: 'HAL 상태 조정', closed: '사건 종료' };
const sess = (s) => { const idx = steps.indexOf(s.status); const approvals = s.approvals || []; return `<div class="card emergency"><div class="row"><b>${esc(s.id)}</b> ${badge(s.status === 'closed' ? 'done' : 'warn', L[s.status] || s.status)} <span class="muted">사건 ${esc(s.incident_key)} · 선언 ${fmt(s.declared_at)} (${esc(s.declared_by)})</span></div>
<ol class="flow">${steps.map((st, i) => `<li class="${i < idx ? 'done' : i === idx ? 'now' : ''}">${esc(L[st])}</li>`).join('')}</ol>
${kv([['이유', esc(s.reason)], ['허용 대상', (s.scope || []).map(esc).join(', ')], ['승인 단계', approvals.length ? approvals.map((a) => `${a.step}단계 ${esc(a.by)} ${fmt(a.at)} — ${esc(a.impactText || a.finalConfirm != null ? (a.impactText || (a.revertible ? '되돌리기 가능' : '되돌리기 불가')) : (a.identifiers || []).join(','))}`).join('<br>') : '없음 (3단계 모두 필요)'], ['자격', s.credential_issued_at ? `발급 ${fmt(s.credential_issued_at)} · 만료 ${fmt(s.credential_expires_at)} · ${s.credential_revoked_at ? '폐기 ' + fmt(s.credential_revoked_at) : '<span class="err">유효</span>'}` : '미발급(한 번만 표시되며 다시 볼 수 없음)'], ['수행 기록', (s.actions || []).length ? (s.actions || []).map((a) => `${fmt(a.at)} ${esc(a.action)} ${esc(a.target || '')} → ${a.ok ? '성공' : `<span class="err">거부/실패: ${esc(a.error || '')}</span>`}`).join('<br>') : '없음'], ['결과 확인', s.verify ? `HAL 건강 ${s.verify.halHealth ? '정상' : '<span class="err">비정상</span>'} ${fmt(s.verify.at)}` : '—'], ['상태 조정', s.reconcile ? `차이 ${(s.reconcile.drift || []).length}건 · 응답 없는 서버 ${(s.reconcile.offlineServers || []).length}대` : '—']])}
<div class="actions">${s.status === 'declared' || s.status === 'approving' ? `<button class="btn primary" data-act="em-approve" data-id="${esc(s.id)}" data-step="${approvals.length + 1}">${approvals.length + 1}단계 승인</button>` : ''}${s.status === 'approved' ? `<button class="btn primary" data-act="em-credential" data-id="${esc(s.id)}">자격 발급(1회 표시)</button>` : ''}${['credential_issued', 'performing'].includes(s.status) ? `<button class="btn" data-act="em-perform" data-id="${esc(s.id)}">작업 수행</button> <button class="btn" data-act="em-verify" data-id="${esc(s.id)}">결과 확인·자격 폐기</button>` : ''}${s.status === 'verified' ? `<button class="btn" data-act="em-reconcile" data-id="${esc(s.id)}">HAL 상태 조정</button>` : ''}${s.status !== 'closed' ? `<button class="btn danger" data-act="em-close" data-id="${esc(s.id)}">사건 종료</button>` : ''}</div></div>`; };
return `<p class="muted">HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용됩니다. 허용 대상: ${(data.scopes || []).map(esc).join(', ')} · 허용 작업: ${Object.keys(data.actions || {}).map(esc).join(', ')} — 일반 고객 서비스 배포는 금지(거부됨).</p><div class="actions"><button class="btn danger" data-act="em-declare">긴급 복구 선언</button></div>${(data.sessions || []).length ? data.sessions.map(sess).join('') : statePanel('empty', '긴급 복구 세션이 없습니다')}`;
}
/** 재해복구(§34) */
export function drView(data) {
const r = data.readiness || {}; const cfg = r.config || {};
return `<div class="cards"><div class="card"><h2>준비 상태</h2>${r.ready ? badge('ok', r.label) : badge('warn', r.label)}${kv([['목표 복구 시간(RTO)', cfg.rto_minutes ? `${cfg.rto_minutes}분` : '<span class="err">미설정</span>'], ['허용 데이터 시점(RPO)', cfg.rpo_minutes ? `${cfg.rpo_minutes}분` : '<span class="err">미설정</span>'], ['복구 사본 주기·보존', cfg.snapshot_interval_minutes ? `${cfg.snapshot_interval_minutes}분 · ${cfg.retention_count}개` : '<span class="err">미설정</span>'], ['외부 보관', esc(cfg.external_store || '미설정')], ['최근 훈련', r.lastDrill ? `${r.lastDrill.result === 'success' ? badge('ok', '성공') : badge('failed', r.lastDrill.result)} ${fmt(r.lastDrill.at)} · 측정 RTO ${r.lastDrill.measuredRtoMinutes ?? '—'}분 / RPO ${r.lastDrill.measuredRpoMinutes ?? '—'}분 (목표 ${cfg.rto_minutes ?? '—'}/${cfg.rpo_minutes ?? '—'})` : '<span class="err">실제 복구 훈련 전 — 정식 운영 준비 완료로 표시하지 않음</span>'], ['다음 훈련', r.lastDrill && r.lastDrill.nextDueAt ? fmt(r.lastDrill.nextDueAt) : '예정 없음'], ['운영 중 HAL', data.hal && data.hal.bootstrap ? `<span class="mono">${esc(data.hal.bootstrap.live_instance_id || '')}</span> ${badge('ok', 'HAL 하나만 운영 상태')}` : '—']])}<div class="actions"><button class="btn" data-act="dr-config">설정 변경</button> <button class="btn" data-act="dr-snapshot">지금 복구 사본 만들기</button> <button class="btn" data-act="dr-drill">복구 훈련 시작</button></div></div></div>
<h2>복구 사본</h2>${table([{ l: '사본', f: (s) => `<span class="mono">${esc(s.id)}</span><br>${esc(s.kind)}` }, { l: '생성', f: (s) => fmt(s.created_at) }, { l: '크기', f: (s) => bytes(s.size_bytes) }, { l: '검증', f: (s) => s.verify_state === 'ok' ? badge('ok', `검증 ${fmt(s.verified_at)}`) : badge('failed', s.verify_state) }, { l: '외부 보관', f: (s) => s.external_copied_at ? badge('ok', fmt(s.external_copied_at)) : badge('warn', '외부 사본 없음') }, { l: '', f: (s) => s.verify_state === 'ok' && !s.deleted_at ? `<button class="btn small danger" data-act="dr-restore" data-id="${esc(s.id)}">복구 요청</button> <button class="btn small" data-act="dr-drill" data-id="${esc(s.id)}">이 사본으로 훈련</button>` : '' }], data.snapshots || [], { empty: '복구 사본이 없습니다' })}
<h2>복구 요청(별도 승인 + 기존 제어 위치 중지 확인)</h2>${table([{ l: '요청', f: (x) => `<span class="mono">${esc(x.id)}</span><br>사본 ${esc(x.snapshot_id)}` }, { l: '상태', f: (x) => jobBadge(x.status === 'requested' ? 'awaiting_approval' : x.status === 'approved' ? 'queued' : x.status === 'running' ? 'running' : x.status) }, { l: '승인', f: (x) => x.approved_at ? `${esc(x.approved_by)} ${fmt(x.approved_at)}` : '대기' }, { l: '기존 제어 중지 확인', f: (x) => x.old_control_stopped_confirmed_at ? fmt(x.old_control_stopped_confirmed_at) : '<span class="err">미확인</span>' }, { l: '확인·차이', f: (x) => x.checks_json ? `<pre class="pre">${esc(x.checks_json)}</pre>` : '—' }, { l: '', f: (x) => x.status === 'requested' ? `<button class="btn small" data-act="dr-approve" data-id="${esc(x.id)}">승인</button>` : x.status === 'approved' ? `<button class="btn small danger" data-act="dr-start" data-id="${esc(x.id)}">복구 시작</button>` : x.status === 'running' ? `<button class="btn small" data-act="dr-finish" data-id="${esc(x.id)}">실제 확인 입력·완료</button>` : '' }], data.restores || [], { empty: '복구 요청이 없습니다' })}
<h2>복구 훈련</h2>${table([{ l: '훈련', f: (x) => `<span class="mono">${esc(x.id)}</span>` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` <button class="btn small" data-act="dr-drill-finish" data-id="${esc(x.id)}">결과 입력</button>` : x.result === 'success' ? badge('ok', '성공') : badge('failed', x.result) }, { l: '측정 RTO/RPO', f: (x) => `${x.measured_rto_minutes ?? '—'} / ${x.measured_rpo_minutes ?? '—'}분` }, { l: '실패·조치 항목', f: (x) => x.findings_json ? `<pre class="pre">${esc(x.findings_json)}</pre>` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`;
}
export function accountsView(data) {
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '재인증 필요(고위험 작업)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
<div class="card"><h2>로그인 세션</h2>${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' <b>(현재)</b>' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `<button class="btn small" data-act="session-revoke" data-id="${esc(s.id)}">종료</button>` }], data.sessions || [])}<div class="actions"><button class="btn" data-act="session-revoke-all">다른 모든 세션 종료</button></div></div></div>
<h2>접근권(허용 네트워크 밖 기기)</h2>${table([{ l: '계정', k: 'account_id' }, { l: '발급', f: (g) => `${esc(g.created_by || '')} ${fmt(g.created_at)}` }, { l: '만료', f: (g) => fmt(g.expires_at) }, { l: '기기 묶임', f: (g) => g.bound_at ? `${fmt(g.bound_at)} (${esc(g.ip || '')})` : '아직' }, { l: '상태', f: (g) => g.revoked_at ? badge('neutral', '회수') : Date.parse(g.expires_at) < Date.now() ? badge('neutral', '만료') : badge('ok', '유효') }, { l: '사유', k: 'reason' }, { l: '', f: (g) => g.revoked_at ? '' : `<button class="btn small" data-act="grant-revoke" data-id="${esc(g.id)}">회수</button>` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}<div class="actions"><button class="btn" data-act="grant-issue">접근권 발급(URL 1회 표시)</button></div>
<h2>운영자 계정</h2>${table([{ l: '아이디', f: (a) => `<b>${esc(a.login)}</b> ${esc(a.displayName)}` }, { l: '보안', f: (a) => a.mfaEnabled ? badge('ok', 'MFA') : badge('warn', '비밀번호만') }, { l: '상태', f: (a) => a.lockedUntil ? badge('warn', `잠김 ${fmt(a.lockedUntil)}`) : badge('ok', a.status) }, { l: '마지막 로그인', f: (a) => fmt(a.lastLoginAt) }], data.accounts || [])}<div class="actions"><button class="btn" data-act="account-new">운영자 추가</button></div>`;
}
/** 운영 현황(§26.1) */
export function overviewView(o) {
const comp = (c) => `<div class="card comp"><b>${esc(c.name)}</b> ${c.stale ? badge('stale', '정보 지연') : c.status === 'ok' ? badge('ok', '정상') : c.status === 'down' ? badge('failed', '장애') : badge('warn', '확인 전')}<br><span class="muted">${c.version ? 'v' + esc(c.version) : (c.versions || []).map(esc).join(', ') || esc(c.note || '')}</span></div>`;
const jobs = o.jobs || {}; const srv = o.servers || {};
return `<div class="cards">${(o.components || []).map(comp).join('')}</div>
<div class="cards"><div class="card"><h2>서버</h2>${kv([['등록', `${srv.total || 0}대`], ['응답 없음', srv.offline ? `<span class="err">${srv.offline}대</span>` : '0대'], ['등록 대기', `${srv.pending || 0}대`], ['유지보수', `${srv.maintenance || 0}대`], ['디스크 90% 이상', srv.diskWarning ? `<span class="err">${srv.diskWarning}대</span>` : '없음']])}<a class="btn" href="/ops/servers">서버와 용량</a></div>
<div class="card"><h2>진행 중 작업</h2>${kv([['실행 중', `${jobs.running || 0}`], ['대기·외부 대기', `${jobs.waiting || 0}`], ['사용자 확인 필요', jobs.needsUser ? `<span class="err">${jobs.needsUser}</span>` : '0'], ['종류별', Object.entries(jobs.byKind || {}).map(([k, n]) => `${esc(k)} ${n}`).join(', ') || '없음']])}<a class="btn" href="/ops/jobs">작업 화면</a></div>
<div class="card"><h2>목표·실제 차이</h2>${(o.drift || []).length ? table([{ l: '모듈', k: 'module' }, { l: '서버', k: 'server_id' }, { l: '목표', k: 'target_version' }, { l: '실제', k: 'actual_version' }], o.drift) : badge('ok', '차이 없음')}</div>
<div class="card"><h2>재해복구·긴급</h2>${o.dr ? (o.dr.ready ? badge('ok', o.dr.label) : badge('warn', o.dr.label)) : ''}<br>${(o.emergencyOpen || []).length ? `<span class="err">긴급 복구 세션 ${o.emergencyOpen.length}건 진행 중</span>` : '<span class="muted">긴급 복구 없음</span>'}<br><a class="btn" href="/ops/recovery">플랫폼 복구</a></div></div>
<h2>장애·유지보수</h2>${(o.incidents || []).length ? table([{ l: '사건', f: (i) => `<a href="/ops/incidents">${esc(i.title)}</a>` }, { l: '심각도', k: 'severity' }, { l: '고객 영향', f: (i) => esc(i.customer_impact || '없음') }, { l: '시작', f: (i) => fmt(i.started_at) }], o.incidents) : badge('ok', '진행 중인 사건 없음')}
<h2>처리 대기 승인(포털)</h2><div id="pending-approvals">${statePanel('loading', '포털에서 승인 대기 항목을 불러오는 중')}</div><p class="muted">HAL 자체 상태 ${fmt(o.generatedAt)} 기준. 오래된 정보는 정상으로 표시하지 않습니다.</p>`;
}
/** 처리 대기 승인 요약(포털 중계 결과) */
export function pendingApprovals(p) { const row = (label, n, href) => `<li><a href="${href}">${esc(label)}</a> <b>${n}</b>건</li>`; return `<ul class="pending">${row('주문 승인', p.orders, '/ops/orders')}${row('복원 승인', p.restores, '/ops/backup')}${row('서버 이전 승인', p.relocations, '/ops/relocation')}${row('소유권 이전 심사', p.transfers, '/ops/relocation?tab=transfer')}</ul>${p.partial ? statePanel('partial', `일부 항목을 받지 못했습니다: ${p.partial.join(', ')}`) : ''}`; }
// ---------------- 포털 중계 화면(§26.2·§27·§30)
export function customersSearch(d) { return `<form class="row" data-form="customer-search"><input name="q" placeholder="이메일·이름·그룹명·id" value="${esc(d.q || '')}"><button class="btn">검색</button></form><h2>사용자 그룹</h2>${table([{ l: '그룹', f: (g) => `<a href="/ops/customers/${esc(g.id)}"><b>${esc(g.name)}</b></a> <span class="muted">${g.kind === 'personal' ? '개인' : '팀'}</span>` }, { l: '구성원', k: 'members' }, { l: '연락처', f: (g) => esc(g.contactEmail || '—') }, { l: '상태', f: (g) => badge(g.status === 'active' ? 'ok' : 'warn', g.status) }, { l: '가입', f: (g) => fmt(g.createdAt) }], d.groups || [], { empty: '검색 결과가 없습니다' })}<h2>사용자</h2>${table([{ l: '이메일', f: (u) => `${esc(u.email)}<br><span class="muted">${esc(u.displayName)}</span>` }, { l: '확인', f: (u) => u.verified ? badge('ok', '이메일 확인') : badge('warn', '미확인') }, { l: 'MFA', f: (u) => u.mfa ? '사용' : '—' }, { l: '잠김', f: (u) => u.locked ? badge('warn', '잠김') : '—' }, { l: '마지막 로그인', f: (u) => fmt(u.lastLoginAt) }], d.users || [], { empty: '검색 결과가 없습니다' })}`; }
export function customerView(c) {
const g = c.group || {}; const SL = { draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약', readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류' };
return `<div class="cards"><div class="card"><h2>${esc(g.name)} <span class="muted">(${g.kind === 'personal' ? '개인' : '팀'})</span></h2>${kv([['id', `<span class="mono">${esc(g.id)}</span>`], ['연락처', `${esc(g.contactEmail || '—')} ${esc(g.contactPhone || '')}`], ['상태', badge(g.status === 'active' ? 'ok' : 'warn', g.status)], ['조회 기준', fmt(c.generatedAt)]])}<div class="actions"><button class="btn" data-act="cust-change" data-g="${esc(g.id)}">고객 대신 변경</button> <button class="btn" data-act="support-issue" data-g="${esc(g.id)}">일시 접근 발급</button></div></div>
<div class="card"><h2>활성 상품·권리</h2>${table([{ l: '자원', k: 'resource' }, { l: '양', k: 'amount' }, { l: '출처', f: (e) => `${esc(e.source_type || '')} ${esc(e.source_id || '')}` }, { l: '기간', f: (e) => `${fmt(e.starts_at)} ~ ${e.ends_at ? fmt(e.ends_at) : '무기한'}` }, { l: '', f: (e) => e.active ? badge('ok', '활성') : badge('neutral', '비활성') }], c.entitlements || [], { empty: '활성 상품이 없습니다' })}</div></div>
<h2>구성원</h2>${table([{ l: '이메일', f: (m) => `${esc(m.email)}<br><span class="muted">${esc(m.displayName)} · ${esc(m.alias || '')}</span>` }, { l: '역할', k: 'role' }, { l: '상태', f: (m) => m.status === 'suspended' ? badge('warn', `정지 ${fmt(m.suspendedAt)}`) : badge('ok', m.status) }, { l: '확인·MFA', f: (m) => `${m.verified ? '이메일 확인' : '<span class="err">미확인</span>'}${m.locked ? ' · 잠김' : ''}` }, { l: '마지막 로그인', f: (m) => fmt(m.lastLoginAt) }], c.members || [])}
<h2>사이트</h2>${table([{ l: '사이트', f: (s) => `<a href="/ops/sites/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.primary_domain || '대표 도메인 없음')}</span>` }, { l: '상태', f: (s) => badge(s.lifecycle === 'live' ? 'ok' : 'neutral', SL[s.lifecycle] || s.lifecycle) }, { l: '배포', f: (s) => `${esc(s.deploy_mode || '미선택')} · ${esc(s.current_version || '—')}` }, { l: '사이트 수', k: 'site_count_state' }, { l: '건강', f: (s) => healthBadge(s.health) }, { l: '보존·해지', f: (s) => s.cancel_at || s.retention_until || s.delete_after ? `${s.cancel_at ? '해지 ' + fmt(s.cancel_at) : ''} ${s.retention_until ? '보존 ' + fmt(s.retention_until) : ''} ${s.delete_after ? '삭제 ' + fmt(s.delete_after) : ''}` : '—' }], c.sites || [], { empty: '사이트가 없습니다' })}
<div class="cards"><div class="card"><h2>메일</h2>${table([{ l: '서비스', f: (m) => `<b>${esc(m.name)}</b><br><span class="muted">${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'}</span>` }, { l: '상태', f: (m) => jobBadge(m.status) }, { l: 'DNS·송수신', f: (m) => `${esc(m.dns_state || '—')} · ${esc(m.send_state || '—')}/${esc(m.receive_state || '—')}` }, { l: '보존', f: (m) => m.retention_until ? fmt(m.retention_until) : '—' }], c.mail || [], { empty: '메일 서비스 없음' })}</div>
<div class="card"><h2>도메인</h2>${table([{ l: '도메인', f: (d) => `<b>${esc(d.name)}</b>${d.is_primary ? ' (대표)' : ''}` }, { l: '소유·DNS', f: (d) => `${esc(d.ownership_state)} · ${esc(d.dns_state)}` }, { l: '인증서', f: (d) => `${esc(d.cert_state)} ${d.cert_expires_at ? fmt(d.cert_expires_at) : ''}` }, { l: '메일 보안', f: (d) => esc(d.mail_dns_state || '—') }], c.domains || [], { empty: '도메인 없음' })}</div>
<div class="card"><h2>백업</h2>${table([{ l: '사이트', k: 'site_id' }, { l: '상태', k: 'status' }, { l: '최근 성공·실패', f: (b) => `${fmt(b.last_success_at)} / ${fmt(b.last_failure_at)} (연속 실패 ${b.consecutive_failures || 0})` }, { l: '다음', f: (b) => fmt(b.next_run_at) }], c.backups || [], { empty: '백업 옵션 없음' })}</div>
<div class="card"><h2>사용량·제한</h2>${table([{ l: '자원', k: 'label' }, { l: '구매', k: 'purchased' }, { l: '배정', k: 'allocated' }, { l: '예약', k: 'reserved' }, { l: '사용', k: 'used' }, { l: '미배정', k: 'unallocated' }], c.usage || [])}${(c.limits || []).length ? `<p class="err">제한 적용: ${c.limits.map((l) => `${esc(l.resource)} ${l.level}%`).join(', ')}</p>` : ''}</div></div>
<h2>주문·결제</h2>${table([{ l: '주문', f: (o) => `<b>${esc(o.order_no)}</b> <span class="muted">${esc(o.kind)}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) }, { l: '금액', f: (o) => `${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)}` }, { l: '기한·입금·승인', f: (o) => `${fmt(o.due_at)} / ${fmt(o.paid_at)} / ${fmt(o.approved_at)}` }, { l: '거절 사유', k: 'rejected_reason' }], c.orders || [], { empty: '주문 없음' })}
<h2>정지·보존</h2>${kv([['정지된 구성원', `${(c.suspensions && c.suspensions.members.length) || 0}명`], ['일시 중지·유지보수 사이트', `${(c.suspensions && c.suspensions.sites.length) || 0}개`], ['보존 중 사이트·메일·백업', `${(c.retention && c.retention.sites.length) || 0} · ${(c.retention && c.retention.mail.length) || 0} · ${(c.retention && c.retention.backups.length) || 0}`]])}
<h2>고객 화면 오류·실패 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '오류', f: (j) => `<span class="err">${esc(j.error || j.userMessage || '')}</span>` }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], c.failedJobs || [], { empty: '실패한 작업 없음' })}${auditTable(c.errors || [])}
<h2>일시 접근 기록</h2>${table([{ l: '대상', f: (a) => `${esc(a.target_type)} ${esc(a.target_id || '')}` }, { l: '이유', k: 'reason' }, { l: '범위', f: (a) => (a.scope || []).map(esc).join(', ') }, { l: '요청·승인', f: (a) => `${esc(a.requested_by)} / ${esc(a.approved_by || '대기')}` }, { l: '만료', f: (a) => fmt(a.expires_at) }, { l: '상태', f: (a) => a.state === 'active' ? badge('ok', '유효') : badge('neutral', a.state) }, { l: '', f: (a) => a.state === 'active' ? `<button class="btn small" data-act="support-revoke" data-id="${esc(a.id)}">회수</button>` : '' }], c.supportAccess || [], { empty: '일시 접근 기록 없음' })}
<h2>운영자 대신 변경 기록</h2>${table([{ l: '시각', f: (x) => fmt(x.created_at) }, { l: '항목', f: (x) => `${esc(x.field)} <span class="mono muted">${esc(x.subject_id)}</span>` }, { l: '변경 전 → 후', f: (x) => `${esc(JSON.stringify(x.before))} → ${esc(JSON.stringify(x.after))}` }, { l: '사유', k: 'reason' }, { l: '운영자·승인자', f: (x) => `${esc(x.operator)} / ${esc(x.approver || '—')}` }], c.changes || [], { empty: '변경 기록 없음' })}`;
}
export function ordersView(d) { return table([{ l: '주문', f: (o) => `<b>${esc(o.order_no)}</b><br><span class="muted">${esc(o.group && o.group.name)} · ${esc(o.product && o.product.name)} v${esc(o.product && o.product.version)}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.statusLabel ? `<br><span class="muted">${esc(o.statusLabel)}</span>` : '') }, { l: '대상·금액', f: (o) => `${esc(o.target_type || '그룹')} ${esc(o.target_id || '')}<br><b>${Number(o.total).toLocaleString('ko-KR')} ${esc(o.currency)}</b> <span class="muted">(세금 ${Number(o.tax).toLocaleString('ko-KR')})</span>` }, { l: '권리 변화', f: (o) => (o.entitlementDelta || []).map((e) => `${esc(e.label || e.resource)} +${e.amount}${e.target ? ' → ' + esc(e.target) : ''}`).join('<br>') || '—' }, { l: '현재 사용량', f: (o) => (o.currentUsage || []).filter((u) => u.purchased || u.used).map((u) => `${esc(u.label)} ${u.used}/${u.purchased}`).join('<br>') || '—' }, { l: '기한·입금', f: (o) => `${fmt(o.due_at)}<br>${o.paid_at ? '입금 ' + fmt(o.paid_at) + ' ' + esc(o.paid_ref || '') : '<span class="err">입금 미확인</span>'}` }, { l: '', f: (o) => o.status === 'awaiting_payment' ? `<button class="btn small" data-act="order-paid" data-id="${esc(o.id)}">입금 확인</button> <button class="btn small danger" data-act="order-reject" data-id="${esc(o.id)}">거절</button>` : o.status === 'awaiting_approval' ? `<button class="btn small primary" data-act="order-approve" data-id="${esc(o.id)}">승인</button> <button class="btn small danger" data-act="order-reject" data-id="${esc(o.id)}">거절</button>` : o.status === 'approved' ? `<button class="btn small" data-act="order-refund" data-id="${esc(o.id)}" data-g="${esc(o.group_id)}">환불·크레딧</button>` : '' }], d.orders || [], { empty: '주문이 없습니다' }); }
export function productsView(d) { return `${table([{ l: '상품', f: (p) => `<b>${esc(p.name)}</b> <span class="muted">${esc(p.product_id)} v${p.version}</span>` }, { l: '공개', f: (p) => p.published ? badge('ok', '공개') : badge('neutral', '비공개') }, { l: '적용 기간', f: (p) => `${fmt(p.effective_from)} ~ ${p.effective_to ? fmt(p.effective_to) : ''}` }, { l: '가격', f: (p) => `${Number(p.price).toLocaleString('ko-KR')} ${esc(p.currency)} / ${esc(p.billing_cycle)}` }, { l: '가입자', k: 'subscribers' }, { l: '미확정 정책', f: (p) => (p.deferred || []).length ? `<span class="err">${p.deferred.map((x) => esc(x.key || x)).join(', ')}</span>` : '없음' }, { l: '', f: (p) => `<button class="btn small" data-act="product-new-version" data-id="${esc(p.id)}" data-p="${esc(p.product_id)}">새 버전 만들기</button> <button class="btn small" data-act="product-publish" data-id="${esc(p.id)}" data-pub="${p.published ? 0 : 1}">${p.published ? '비공개' : '공개'}</button>` }], d.products || [], { empty: '상품이 없습니다 — "검증 Catalog 넣기"로 시작' })}<p class="muted">이미 판매된 조건은 바꾸지 않고 새 버전을 만듭니다(§27.2). 기존 고객은 자기 버전을 유지합니다.</p><div class="actions"><button class="btn" data-act="catalog-seed">검증 Catalog 넣기</button></div>`; }
export function sitesView(d) { return table([{ l: '사이트', f: (s) => `<a href="/ops/sites/${esc(s.id)}"><b>${esc(s.name)}</b></a><br><span class="muted">${esc(s.primaryDomain || s.primary_domain || '—')} · 그룹 ${esc(s.groupId || '')}</span>` }, { l: '상태', f: (s) => jobBadge(s.lifecycle || s.status) }, { l: '배포', f: (s) => `${esc(s.deployMode || s.deploy_mode || '미선택')} · ${esc(s.currentVersion || s.current_version || '—')}` }, { l: '건강', f: (s) => healthBadge(s.health) }, { l: '서버', f: (s) => esc(s.serverId || '—') }, { l: '조치 필요', f: (s) => s.actionNeeded ? `<span class="err">${esc(s.actionNeeded)}</span>` : '—' }], d.sites || [], { empty: '사이트가 없습니다' }); }
export function siteDetailView(d) { const s = d.site || {}; return `<div class="cards"><div class="card"><h2>${esc(s.name)}</h2>${kv([['그룹', `<a href="/ops/customers/${esc(s.group_id)}">${esc(s.group_id)}</a>`], ['상태', jobBadge(s.lifecycle)], ['배포', `${esc(s.deploy_mode || '미선택')} · ${esc(s.current_version || '—')} · ${esc(s.deploy_verify || '')}`], ['서버', esc(s.server_id || '—')], ['대표 도메인', esc(s.primary_domain || '—')], ['사이트 수', esc(s.site_count_state)], ['건강', healthBadge(s.health) + ' ' + fmt(s.health_checked_at)]])}<div class="actions"><button class="btn" data-act="site-refresh" data-id="${esc(s.id)}">상태 다시 확인</button></div></div>
<div class="card"><h2>외부 Agent</h2>${d.externalAgent ? kv([['상태', esc(d.externalAgent.status)], ['마지막 확인', fmt(d.externalAgent.lastSeenAt)], ['버전', esc(d.externalAgent.version || '')]]) : '<span class="muted">관리형 또는 미연결</span>'}</div></div>
<h2>배포 이력</h2>${table([{ l: '배포', f: (x) => `<span class="mono">${esc(x.id)}</span><br>${esc(x.version_label || x.version_ref || '')}` }, { l: '상태', f: (x) => jobBadge(x.status) + (x.verify_state ? ` <span class="muted">${esc(x.verify_state)}</span>` : '') }, { l: '시각', f: (x) => `${fmt(x.created_at)} → ${fmt(x.ended_at)}` }], d.deployments || [], { empty: '배포 이력 없음' })}
<h2>도메인</h2>${table([{ l: '도메인', f: (x) => `${esc(x.name)}${x.is_primary ? ' (대표)' : ''}` }, { l: '소유·DNS·인증서', f: (x) => `${esc(x.ownership_state)} · ${esc(x.dns_state)} · ${esc(x.cert_state)}` }, { l: '마지막 확인', f: (x) => fmt(x.last_checked_at) }], d.domains || [], { empty: '도메인 없음' })}
<h2>작업</h2>${(d.jobs || []).map((j) => jobBlock(j, { actions: false })).join('') || statePanel('empty', '작업 없음')}<h2>감사 기록</h2>${auditTable(d.audit || [])}`; }
export function mailView(d) { return `<h2>메일 서버</h2>${table([{ l: '서버', f: (s) => `<b>${esc(s.name || s.id)}</b><br><span class="muted">${esc(s.baseUrl || s.base_url || '')}</span>` }, { l: '연결', f: (s) => s.ok === false || s.error ? badge('failed', '연결 실패') + ` <span class="err">${esc(s.error || '')}</span>` : s.ok ? badge('ok', `정상 v${esc(s.version || '')}`) : badge('stale', '확인 전') }, { l: '도메인·계정', f: (s) => s.totals ? `${s.totals.domains}개 · ${s.totals.accounts}계정 · ${bytes(s.totals.storageBytes)}` : '—' }, { l: '', f: (s) => `<button class="btn small" data-act="mail-rates" data-id="${esc(s.id)}">발송 제한·제출량</button>` }], d.servers || [], { empty: '등록된 메일 서버가 없습니다' })}
<h2>메일 서비스</h2>${table([{ l: '서비스', f: (m) => `<b>${esc(m.name)}</b><br><span class="muted">${esc(m.domain || '')} · ${m.kind === 'site_bound' ? '사이트 대표' : '독립'} · 그룹 ${esc(m.group_id)}</span>` }, { l: '상태', f: (m) => jobBadge(m.status) + (m.migrating ? ' ' + badge('warn', '이전 중') : '') }, { l: 'DNS·송신·수신', f: (m) => `${esc(m.dns_state || '—')} · ${esc(m.send_state || '—')} · ${esc(m.receive_state || '—')}` }, { l: '메일함·공간·통수', f: (m) => m.figures ? Object.values(m.figures).map((f) => `${esc(f.label || f.resource)} ${f.used ?? 0}/${f.allocated ?? 0}`).join('<br>') : '—' }, { l: '보존', f: (m) => m.retention_until ? `${fmt(m.retention_until)} · 삭제 ${fmt(m.delete_after)}` : '—' }, { l: '', f: (m) => `<button class="btn small" data-act="mail-test" data-id="${esc(m.id)}">외부 송수신 시험</button> <button class="btn small" data-act="mail-security" data-id="${esc(m.id)}">DNS 보안 확인</button> <button class="btn small" data-act="mail-operating" data-id="${esc(m.id)}">운영 상태</button> <button class="btn small danger" data-act="mail-cancel" data-id="${esc(m.id)}">정지·해지</button>` }], d.services || [], { empty: '메일 서비스가 없습니다' })}<p class="muted">메일 원문은 운영 화면에서 열람하지 않습니다. 진단 조회는 접근 사유를 기록합니다(§30.2).</p>`; }
export function domainsView(d, src) { return `<h2>도메인·인증서(§30.1)</h2>${table([{ l: '도메인', f: (x) => `<b>${esc(x.name)}</b>${x.is_primary ? ' (대표)' : ''}<br><span class="muted">${esc(x.purpose)} · 그룹 ${esc(x.group_id)} · 사이트 ${esc(x.site_id || '—')}</span>` }, { l: '소유 확인', f: (x) => x.ownership_state === 'verified' ? badge('ok', '확인') : badge('waiting', x.ownership_state) }, { l: 'DNS', f: (x) => x.dns_state === 'ok' ? badge('ok', '반영') : badge('warn', x.dns_state) }, { l: '인증서', f: (x) => (x.cert_state === 'ok' || x.cert_state === 'issued' ? badge('ok', '유효') : x.cert_state === 'failed' ? badge('failed', '실패') : badge('neutral', x.cert_state)) + (x.cert_expires_at ? `<br><span class="${Date.parse(x.cert_expires_at) - Date.now() < 14 * 86400000 ? 'err' : 'muted'}">만료 ${fmt(x.cert_expires_at)}</span>` : '') }, { l: '메일 보안', f: (x) => esc(x.mail_dns_state || '—') }, { l: '마지막 외부 확인', f: (x) => fmt(x.last_checked_at) }, { l: '반복 실패', f: (x) => x.failures ? `<span class="err">${x.failures}회</span>` : '—' }], (d.domains || []), { empty: '도메인이 없습니다' })}${d.duplicates && d.duplicates.length ? `<p class="err">여러 고객에게 연결된 도메인(활성화 차단): ${d.duplicates.map(esc).join(', ')}</p>` : ''}<div class="actions"><button class="btn" data-act="domains-sweep">전체 다시 확인</button></div>
<h2>Source/Gitea(§30.5)</h2>${table([{ l: '사이트', f: (o) => `<b>${esc(o.site_name || o.site_id)}</b> <span class="mono muted">${esc(o.site_id)}</span>` }, { l: '상태', f: (o) => jobBadge(o.state || o.status) }, { l: '저장소', f: (o) => esc(o.repo || o.remote || '—') }, { l: '사용량·한도', f: (o) => o.usage ? `${bytes(o.usage.bytes)} / ${bytes(o.usage.limit)}` : '—' }, { l: '만료·삭제 예정', f: (o) => `${fmt(o.expires_at || o.readonly_until)} · ${fmt(o.delete_after)}` }, { l: '최근 배포 revision', f: (o) => `<span class="mono">${esc(String(o.last_deployed_revision || '').slice(0, 10))}</span>` }], (src && src.options) || [], { empty: 'Source 옵션이 없습니다' })}`; }
export function backupView(d) { return `<h2>복원 승인(§30.3)</h2>${table([{ l: '요청', f: (r) => `<span class="mono">${esc(r.id)}</span><br><b>${esc(r.site_name)}</b> ${esc(r.primary_domain || '')}<br><span class="muted">${esc(r.group_name)} · 백업 ${fmt(r.backup_created_at || r.backupAt)}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">${esc(r.approval_state)}</span>` : '') }, { l: '비용·결제', f: (r) => `${Number(r.cost || 0).toLocaleString('ko-KR')}원 · ${esc(r.payment_state || r.order_id || '—')}` }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%` : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="restore-approve" data-id="${esc(r.id)}">승인(최종 확인)</button> <button class="btn small danger" data-act="restore-reject" data-id="${esc(r.id)}">거절</button>` : r.status === 'failed' ? `<button class="btn small" data-act="restore-retry" data-id="${esc(r.id)}">재시도</button>` : '' }], d.restores || [], { empty: '복원 요청이 없습니다' })}
<h2>백업 옵션</h2>${table([{ l: '사이트', f: (o) => `<b>${esc(o.site_name || o.site_id)}</b> <span class="muted">${esc(o.group_name || '')}</span>` }, { l: '상태', f: (o) => jobBadge(o.status) + (o.paused_reason ? ` <span class="err">${esc(o.paused_reason)}</span>` : '') }, { l: '최근 성공·실패', f: (o) => `${fmt(o.last_success_at)}<br>${o.consecutive_failures ? `<span class="err">연속 실패 ${o.consecutive_failures}</span>` : '실패 없음'}` }, { l: '다음 예정', f: (o) => fmt(o.next_run_at) }, { l: '만료·삭제', f: (o) => `${fmt(o.ends_at)} · ${fmt(o.delete_after)}` }, { l: '', f: (o) => `<button class="btn small" data-act="backup-retry" data-id="${esc(o.id)}">재시도</button> <button class="btn small" data-act="backup-override" data-id="${esc(o.id)}">같은 서버 허용</button>` }], d.options || [], { empty: '활성 백업 옵션이 없습니다' })}
<h2>백업 목록</h2>${table([{ l: '백업', f: (b) => `<span class="mono">${esc(b.id)}</span><br>${esc(b.site_name || b.site_id)}` }, { l: '상태', f: (b) => jobBadge(b.status) }, { l: '검증', f: (b) => b.verify_state === 'ok' || b.verify_state === 'verified' ? badge('ok', '검증') : badge('warn', b.verify_state) }, { l: '생성·만료', f: (b) => `${fmt(b.created_at)} · ${fmt(b.expires_at)}` }, { l: '크기·위치', f: (b) => `${bytes(b.size_bytes)} <span class="muted">${esc(b.location || '')}</span>` }], d.backups || [], { empty: '백업이 없습니다' })}`; }
export function relocationView(d, tr) { return `<h2>서버 이전(§30.4)</h2>${table([{ l: '대상', f: (r) => `<b>${esc(r.subjectName || r.subject_id)}</b> <span class="muted">${esc(r.subject_type)} · ${esc(r.group_name || '')}</span><br>${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}<br><span class="muted">${esc(r.reason || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">승인 ${esc(r.approval_state)}</span>` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `<br><span class="muted">${esc(r.job.waitReason)}</span>` : ''}` : '—' }, { l: '전환 직전 재점검', f: (r) => r.precheckChanged || (r.boundaries && r.boundaries.autoStopped) ? badge('warn', '조건 변경 — 자동 중지') : r.boundaries && r.boundaries.switchAt ? `전환 ${fmt(r.boundaries.switchAt)}` : '—' }, { l: '고객 통지', f: (r) => r.notifiedAt || (r.boundaries && r.boundaries.notifiedAt) ? fmt(r.notifiedAt || r.boundaries.notifiedAt) : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="reloc-approve" data-id="${esc(r.id)}">승인</button> <button class="btn small danger" data-act="reloc-reject" data-id="${esc(r.id)}">거절</button>` : ['failed', 'paused'].includes(r.status) ? `<button class="btn small" data-act="reloc-retry" data-id="${esc(r.id)}">재시도</button> <button class="btn small" data-act="reloc-resume" data-id="${esc(r.id)}">이어서</button> <button class="btn small danger" data-act="reloc-revert" data-id="${esc(r.id)}">되돌리기</button>` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}<div class="actions"><button class="btn" data-act="reloc-new">플랫폼 사유 이전 만들기</button></div>
<h2>소유권 이전 심사(§22)</h2>${table([{ l: '요청', f: (t) => `<span class="mono">${esc(t.id)}</span><br>${esc(t.fromGroupName || t.from_group_id)} → ${esc(t.toGroupName || t.to_group_id || t.to_email || '')}` }, { l: '상태', f: (t) => jobBadge(t.status) }, { l: '범위', f: (t) => t.scope ? Object.entries(t.scope).map(([k, v]) => `${esc(k)}: ${Array.isArray(v) ? v.length : esc(String(v))}`).join(', ') : '—' }, { l: '만료', f: (t) => fmt(t.expires_at) }, { l: '', f: (t) => ['reviewing', 'accepted'].includes(t.status) ? `<button class="btn small primary" data-act="transfer-approve" data-id="${esc(t.id)}">승인</button> <button class="btn small danger" data-act="transfer-reject" data-id="${esc(t.id)}">거절</button>` : t.status === 'disputed' ? `<button class="btn small" data-act="transfer-resolve" data-id="${esc(t.id)}">분쟁 해결</button>` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }
+12
View File
@@ -0,0 +1,12 @@
{
"auth": {
"login": { "title": "로그인", "email": "이메일", "password": "비밀번호", "submit": "로그인", "failed": "이메일 또는 비밀번호가 올바르지 않습니다", "locked": "로그인 시도가 너무 많습니다. {until} 이후에 다시 시도하세요", "noAccount": "계정이 없으신가요?", "forgot": "비밀번호를 잊으셨나요?", "operatorNote": "운영자는 별도의 운영자 콘솔(hal.ariweb.net)에서 로그인합니다. 이 화면은 고객 전용입니다.", "mfaTitle": "인증 코드 입력", "mfaHint": "인증 앱의 6자리 코드 또는 복구 코드를 입력하세요", "mfaExpired": "인증 단계가 만료되었습니다. 처음부터 다시 로그인하세요" },
"signup": { "title": "회원가입", "displayName": "표시 이름", "submit": "가입하기", "passwordHint": "8자 이상. 다른 서비스와 같은 비밀번호는 피하세요", "terms": "이용약관에 동의합니다 (필수)", "privacy": "개인정보 처리방침에 동의합니다 (필수)", "marketing": "소식·혜택 안내 수신에 동의합니다 (선택)", "dupe": "이 이메일로는 가입을 진행할 수 없습니다. 이미 계정이 있다면 로그인하거나 비밀번호 재설정을 이용하세요", "emailSent": "확인 메일을 보냈습니다. 받은 편지함의 링크를 눌러 이메일을 확인하세요", "consentRequired": "필수 약관에 동의해야 가입할 수 있습니다", "policyNote": "가격·SLA·환불·개인정보·백업·AUP·정지 및 해지 정책은 승인된 문구만 표시됩니다. 미확정 정책은 '확정 전' 으로 표시됩니다" },
"verify": { "title": "이메일 확인", "ok": "이메일이 확인되었습니다", "invalid": "확인 링크가 유효하지 않거나 만료되었습니다. 다시 보내기를 눌러 새 링크를 받으세요", "used": "이미 사용한 링크입니다", "resend": "확인 메일 다시 보내기", "banner": "이메일 확인 전에는 유료 상품 구매와 운영 서비스 생성을 할 수 없습니다" },
"reset": { "title": "비밀번호 재설정", "request": "재설정 링크 보내기", "requested": "계정이 있다면 재설정 안내 메일을 보냈습니다. 받은 편지함을 확인하세요", "newPassword": "새 비밀번호", "submit": "비밀번호 바꾸기", "done": "비밀번호를 바꿨습니다. 다른 기기의 로그인은 모두 종료되었습니다", "expired": "재설정 링크가 만료되었습니다 (유효 시간 1시간)", "used": "이미 사용한 재설정 링크입니다", "state": "링크 상태" },
"password": { "tooShort": "비밀번호는 8자 이상이어야 합니다", "tooLong": "비밀번호는 128자 이하여야 합니다", "same": "새 비밀번호가 현재 비밀번호와 같습니다", "wrongCurrent": "현재 비밀번호가 올바르지 않습니다" },
"mfa": { "title": "다중 인증(MFA)", "enabled": "사용 중", "disabled": "사용 안 함", "setup": "인증 앱 등록", "manualKey": "수동 입력 키", "enable": "코드 확인 후 켜기", "disable": "끄기", "disableNeeds": "끄려면 비밀번호와 현재 코드가 필요합니다", "badCode": "인증 코드가 올바르지 않습니다", "recovery": "복구 코드", "recoveryOnce": "복구 코드는 지금 한 번만 표시됩니다. 안전한 곳에 보관하세요. 각 코드는 한 번만 쓸 수 있습니다", "regenerate": "복구 코드 다시 만들기", "level": "현재 보안 수준", "levelMfa": "비밀번호 + 인증 앱", "levelPw": "비밀번호만 (인증 앱 등록을 권장합니다)" },
"sessions": { "title": "로그인 기기", "current": "현재 세션", "revoke": "종료", "revokeOthers": "다른 모든 세션 종료", "lastSeen": "마지막 사용", "created": "로그인 시각", "device": "기기" },
"account": { "title": "내 계정", "profile": "프로필", "security": "보안", "changePassword": "비밀번호 변경", "currentPassword": "현재 비밀번호", "saved": "저장했습니다" }
}
}
+1
View File
@@ -0,0 +1 @@
{ "backup": { "title": "백업", "restore": { "title": "복원 요청" }, "option": { "active": "활성", "paused": "중지됨", "ended": "종료됨" }, "notProvidedExternal": "외부 배포 사이트에는 백업 옵션을 제공하지 않습니다", "mailExcluded": "메일 원문은 고객용 백업·복원에 포함되지 않습니다" } }
+11
View File
@@ -0,0 +1,11 @@
{
"commerce": {
"orderStatus": { "awaiting_payment": "결제 대기", "awaiting_approval": "승인 대기", "approved": "승인 완료", "rejected": "거절됨", "expired": "기한 만료", "cancelled": "취소됨" },
"subStatus": { "active": "사용 중", "awaiting_renewal_payment": "갱신 결제 대기", "restricted": "미납 제한", "cancel_scheduled": "해지 예약", "cancelled": "해지됨" },
"manualOnly": "버전 0.1.1 은 수동 결제 확인(계좌 이체)만 지원합니다. 자동 결제수단은 아직 제공하지 않습니다.",
"deferred": "미확정 정책",
"domainExternal": "도메인 등록 기능이 연결되어 있지 않습니다. 외부 등록기관에서 등록한 뒤 도메인 화면에서 연결하세요.",
"walletSeparate": "AI Wallet 은 제작(AI) 기능에만 쓰이며 호스팅 디스크·트래픽·메일 사용량과 별도입니다.",
"overageOff": "자동 초과 사용과 자동 추가 과금은 기본으로 꺼져 있습니다."
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"app": { "name": "Ariweb Suite", "portal": "Ariweb 고객 포털", "ops": "Ariweb 운영자 콘솔", "hal": "HAL" },
"nav": { "dashboard": "대시보드", "sites": "사이트", "mail": "메일", "domains": "도메인", "backup": "백업", "billing": "상품 및 결제", "usage": "사용량", "group": "사용자 그룹 설정", "notifications": "알림", "help": "도움말", "account": "내 계정", "logout": "로그아웃" },
"state": { "loading": "불러오는 중", "empty": "비어 있음", "stale": "정보 확인 지연", "partial": "부분 실패", "failed": "전체 실패", "forbidden": "권한 없음", "notProvided": "제공되지 않음" },
"common": { "save": "저장", "cancel": "취소", "confirm": "확인", "retry": "다시 시도", "back": "뒤로", "next": "다음", "close": "닫기", "delete": "삭제", "edit": "수정", "create": "만들기", "refresh": "새로 고침", "lastUpdated": "마지막 갱신", "updatedAt": "갱신 시각", "actions": "작업", "status": "상태", "name": "이름", "more": "더 보기", "none": "없음", "yes": "예", "no": "아니요", "required": "필수", "optional": "선택", "help": "도움말", "copy": "복사", "copied": "복사했습니다" },
"error": { "generic": "일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요", "forbidden": "이 작업을 할 권한이 없습니다. 필요한 역할: {role}", "notFound": "요청한 항목을 찾을 수 없습니다", "csrf": "보안 토큰이 맞지 않습니다. 화면을 새로 고친 뒤 다시 시도하세요", "unauthenticated": "로그인이 필요합니다", "groupRequired": "사용자 그룹을 먼저 선택하세요" }
}
+9
View File
@@ -0,0 +1,9 @@
{
"dashboard": {
"title": "대시보드", "sites": "운영 사이트", "sitesOf": "구매 {purchased}개 중", "reserved": "예약됨", "inUse": "사용 중", "drafts": "초안·제작 중", "health": "사이트 상태", "healthOk": "정상", "healthWarn": "경고", "healthBad": "장애", "deploy": "배포 방식", "managed": "관리형", "external": "외부", "domains": "도메인", "domainsAction": "조치 필요", "mail": "메일 서비스", "mailboxes": "메일함", "backup": "백업 옵션 사이트", "jobs": "진행 중 작업", "cost": "이번 달 예상 비용", "awaitingPayment": "결제 대기 금액",
"attention": "주의 항목", "recent": "최근 활동", "auto": "자동", "user": "사용자", "noAttention": "지금 조치할 항목이 없습니다", "noActivity": "최근 활동이 없습니다"
},
"notifications": { "title": "알림", "all": "전체", "unread": "읽지 않음", "markRead": "읽음으로 표시", "mandatory": "필수", "count": "{n}회 반복", "resolved": "해결됨", "categories": { "billing": "결제와 주문", "site": "사이트와 배포", "domain": "도메인과 인증서", "mail": "메일", "usage": "사용량", "backup": "백업과 복원", "relocation": "서버 이전", "security": "보안", "incident": "장애와 유지보수", "membership": "구성원", "ops": "운영" } },
"activity": { "title": "활동 기록", "who": "누가", "when": "언제", "what": "무엇을", "target": "대상", "result": "결과", "auto": "자동 작업", "manual": "사용자 작업", "immutable": "삭제 불가 기록" },
"help": { "title": "도움말" }
}
+4
View File
@@ -0,0 +1,4 @@
{ "domains": { "title": "도메인", "connect": "도메인 연결", "detail": "도메인 상세", "empty": "연결된 도메인이 없습니다. 도메인을 연결하면 소유 확인 → DNS → HTTPS → 외부 확인 순서로 진행합니다",
"purpose": { "web": "웹", "mail": "메일", "both": "웹 + 메일" },
"ownership": { "pending": "확인 전", "verified": "확인됨" }, "dns": { "pending": "미반영", "wrong": "잘못된 값", "ok": "정상", "customer": "고객 관리", "n/a": "해당 없음" },
"cert": { "none": "없음", "issuing": "발급 중", "valid": "정상", "expiring_soon": "만료 임박", "failed": "실패", "customer_managed": "고객 관리" } } }
+10
View File
@@ -0,0 +1,10 @@
{
"groups": {
"title": "사용자 그룹 설정", "name": "그룹 이름", "contact": "연락처", "billingContact": "결제 담당자", "techContact": "기술 담당자", "members": "구성원", "invites": "초대 대기", "invite": "이메일로 초대", "resend": "재발송", "cancelInvite": "초대 취소", "expired": "만료됨", "role": "역할", "alias": "표시 별칭", "aliasHint": "화면 표시용 별칭입니다. 로그인과 권한의 기준이 아닙니다",
"roles": { "owner": "소유자", "admin": "관리자", "billing": "결제 담당", "member": "구성원", "viewer": "조회 전용" },
"suspend": "정지", "unsuspend": "정지 해제", "remove": "제거", "leave": "그룹 탈퇴", "transferOwner": "소유권 이전", "suspendImpact": "정지하면 이 구성원의 로그인 세션과 아직 실행되지 않은 작업만 중지됩니다. 그룹의 상품·사이트·도메인과 다른 구성원의 작업은 유지됩니다",
"ownerCannotLeave": "소유자는 먼저 소유권을 다른 구성원에게 이전해야 탈퇴할 수 있습니다", "lastOwner": "소유자는 그룹마다 한 명이며 제거할 수 없습니다", "switched": "사용자 그룹을 바꿨습니다",
"sitePerms": { "title": "사이트별 권한", "view": "조회", "edit": "콘텐츠 편집", "deploy": "배포", "domain_mail": "도메인과 메일 설정", "billing": "결제와 해지", "admin": "전체 관리" },
"inviteAccepted": "그룹에 참여했습니다", "inviteInvalid": "초대 링크가 유효하지 않거나 만료되었습니다", "inviteNeedsLogin": "초대를 수락하려면 초대받은 이메일로 로그인하거나 가입하세요"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"hal": {
"name": "HAL",
"menu": { "overview": "운영 현황", "customers": "고객과 사용자 그룹", "orders": "주문과 결제 승인", "products": "상품과 정책", "sites": "사이트와 배포", "mail": "메일 서비스", "domains": "도메인과 인증서", "servers": "서버와 용량", "backup": "백업과 복원", "relocation": "서버 이전", "releases": "배포 릴리스", "incidents": "장애와 알림", "audit": "감사 기록", "recovery": "플랫폼 복구", "emergency": "긴급 복구" },
"server": { "status": { "pending": "등록 대기", "ready": "배치 가능", "maintenance": "유지보수", "isolated": "격리", "offline": "응답 없음" }, "role": { "web": "웹 호스팅", "mail": "메일", "backup": "백업 저장소", "control": "제어(HAL·포털)" }, "acceptsNew": "신규 배치 허용", "clockSkew": "서버 시계 차이", "lastSeen": "마지막 확인", "drift": "목표 버전과 실제 버전이 다릅니다" },
"bootstrap": { "incomplete": "부트스트랩(HAL 설치·첫 Agent 등록)이 끝나지 않았습니다", "locked": "첫 Agent 등록이 끝나 일반 모듈의 수동 설치는 잠겼습니다 — HAL 작업으로만 배포합니다", "halFirst": "HAL 을 먼저 배포하고 건강 확인이 끝난 뒤 나머지 모듈을 배포합니다" },
"release": { "registered": "등록됨", "approved": "승인됨", "halDeployed": "HAL 배포 완료", "checksFailed": "검사 실패" },
"job": { "partial": "일부 대상 실패 — 대상별 결과를 확인하세요", "allFailed": "모든 대상 실패", "agentSilent": "대상 서버의 Agent 가 응답하지 않습니다" },
"emergency": { "status": { "declared": "선언됨", "approving": "승인 진행 중", "approved": "승인 완료", "credential_issued": "자격 발급됨", "performing": "수행 중", "verified": "결과 확인·자격 폐기", "reconciled": "상태 조정 완료", "closed": "종료" }, "steps": ["영향 범위 확인", "변경되지 않는 대상 식별정보 재확인", "최종 실행과 되돌리기 가능 여부 확인"], "forbidden": "긴급 복구에서는 일반 고객 서비스 배포를 할 수 없습니다" },
"dr": { "ready": "정식 운영 준비 완료", "notReady": "정식 운영 준비 전(복구 훈련 또는 설정 필요)", "rto": "복구 목표 시간(분)", "rpo": "허용 가능한 데이터 시점(분)", "interval": "복구 사본 생성 주기(분)", "retention": "보존 개수", "oldControlConfirm": "기존 제어 위치를 중지했습니다" },
"access": { "denied": "접근할 수 없습니다", "reauth": "고위험 작업은 최근 10분 안의 재인증이 필요합니다" }
}
}
+1
View File
@@ -0,0 +1 @@
{ "lifecycle": { "title": "설정·생명주기", "relocation": "서버 이전", "transfer": "소유권 이전", "state": { "draft": "초안", "live": "운영 중", "paused": "일시 중지", "cancel_scheduled": "해지 예약", "readonly_retention": "읽기 전용 보존", "pending_delete": "삭제 대기", "deleted": "삭제 완료", "delete_hold": "삭제 보류" }, "siteCountReturn": "운영 사이트 수 반환" } }
+15
View File
@@ -0,0 +1,15 @@
{
"mail": {
"title": "메일", "list": "메일 서비스", "new": "메일 서비스 만들기", "kind": { "site_bound": "사이트 대표 메일", "standalone": "독립 메일 서비스" },
"status": { "needs_setup": "설정 필요", "activating": "활성화 중", "active": "운영 중", "cancelled": "읽기 전용 보존", "deleted": "삭제 완료", "suspended": "일시 중지" },
"tabs": { "overview": "개요", "mailboxes": "메일함", "usage": "사용량", "security": "도메인 보안", "settings": "설정", "convert": "독립 전환", "lifecycle": "생명주기" },
"noBackup": "메일 원문은 고객용 백업·복원에 포함되지 않으며, 메일 백업 기능을 제공하지 않습니다.",
"retention": "해지 뒤 30일 동안 기존 메일 읽기·내보내기가 가능하며 그 뒤 삭제됩니다.",
"counting": "월간 통수는 송신과 수신의 합계이며 최종 수신자 한 명당 한 통으로 셉니다.",
"limit100": { "storage": "저장 공간 100%: 기존 메일 조회·삭제는 유지되고 새 수신은 임시 실패로 응답해 발신 서버가 재시도합니다.", "monthly": "월간 통수 100%: 새 발신을 막고 새 수신은 임시 실패로 응답합니다. 다음 KST 월 시작 또는 추가 용량 적용 후 복구됩니다.", "mailboxes": "메일함 수 100%: 기존 메일함은 유지되고 새 메일함 생성이 막힙니다." },
"sendLimit": "초당 10건 이상의 SMTP 제출은 자동 제한·경고·악용 확인 대상이며, 실제 외부 전달은 도메인과 서버 각각 초당 5건을 넘지 않습니다. 정상적인 대량 발송 상품이 아닙니다.",
"policyDeferred": "후속 상품정책에서 정합니다", "suspendedCounts": "정지된 메일함도 삭제 전까지 메일함 수와 저장 공간을 소비합니다.",
"initialPasswordOnce": "초기 비밀번호는 지금 한 번만 보입니다. 첫 로그인에서 비밀번호를 바꿔야 합니다.",
"protocolImpact": "POP3 또는 IMAP 을 끄면 그 방식으로 연결된 메일 프로그램(Outlook·Thunderbird·휴대전화 메일 등)이 즉시 접속에 실패합니다. 웹메일은 영향이 없습니다."
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ops": {
"title": "Ariweb HAL 운영자 콘솔",
"menu": { "overview": "운영 현황", "customers": "고객과 사용자 그룹", "orders": "주문과 결제 승인", "products": "상품과 정책", "sites": "사이트와 배포", "mail": "메일 서비스", "domains": "도메인과 인증서", "servers": "서버와 용량", "backup": "백업과 복원", "relocation": "서버 이전", "releases": "배포 릴리스", "jobs": "운영 작업", "incidents": "장애와 알림", "audit": "감사 기록", "recovery": "플랫폼 복구", "account": "운영자 계정" },
"gate": { "denied": "허용 네트워크(VPN·사설망) 또는 기기 귀속 접근권 없이는 로그인 화면에도 접근할 수 없습니다", "grantExpires": "접근권 만료 {at}", "reauth": "고위험 작업은 최근 10분 안의 재인증이 필요합니다", "customerSession": "고객 세션으로는 운영자 화면을 열 수 없습니다" },
"threeStep": { "impact": "1단계 — 영향 범위 확인", "identifiers": "2단계 — 변경되지 않는 대상 식별정보 재입력", "execute": "3단계 — 최종 실행과 되돌리기 가능 여부 확인" },
"state": { "stale": "정보 지연 — 포털 또는 Agent 에서 최신 정보를 받지 못했습니다. 정상으로 판정하지 않습니다", "partial": "부분 실패 — 대상별 결과를 확인하세요" }
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"sites": {
"title": "사이트", "new": "새 사이트", "detail": "사이트 상세", "empty": "아직 사이트가 없습니다. 새 사이트를 만들어 Webmaker 에서 제작을 시작하세요",
"filters": { "all": "전체", "draft": "초안", "building": "제작 중", "deploying": "배포 중", "live": "운영 중", "paused": "중지", "cancel_pending": "해지 대기" },
"tabs": { "overview": "개요", "build": "제작", "source": "Source", "deploy": "배포", "domains": "도메인", "mail": "메일", "backup": "백업", "usage": "사용량 및 통계", "activity": "활동 기록", "settings": "설정" },
"create": { "name": "사이트 이름", "purpose": "용도", "project": "Webmaker 프로젝트", "projectNew": "새 프로젝트 만들기", "projectExisting": "기존 프로젝트 연결", "projectNone": "나중에 연결", "template": "시작 템플릿", "lang": "기본 언어", "timezone": "시간대", "deployMode": "배포 방식", "modeLater": "나중에 선택", "noSiteCount": "초안 사이트를 만드는 것만으로는 운영 사이트 수를 쓰지 않습니다. 최초 공개 배포 직전에 사이트 수를 확보합니다" },
"deploy": { "title": "배포", "start": "배포 시작", "precheck": "배포 전 확인", "version": "배포할 저장본", "current": "현재 저장 상태", "rollback": "이전 버전으로 되돌리기", "cleanup": "지금 정리하고 반환", "verified": "배포 검증 완료", "needs_domain": "도메인 설정 필요", "public": "공개 운영 중", "rollbackImpact": "되돌리면 지금 배포된 파일이 이전 배포본으로 바뀝니다. 업로드 데이터와 데이터베이스는 그대로 유지되며, 되돌린 뒤 실제 외부 접속을 확인한 다음 완료로 표시합니다" },
"mode": { "managed": "관리형 배포", "external": "외부 배포", "managedDesc": "Ariweb 가 서버 배치와 운영을 관리합니다", "externalDesc": "고객이 준비한 Linux 서버에 External Pull Agent 를 설치해 배포합니다" },
"external": { "title": "외부 서버 배포", "issue": "연결 토큰 발급", "connected": "연결됨", "pending": "연결 대기", "offline": "응답 없음", "revoked": "해제됨" }
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"usage": {
"allocState": { "available": "사용 가능", "reserved": "예약됨", "in_use": "사용 중", "returning": "반환 중", "returned": "반환됨" },
"warn": { "70": "사전 안내", "80": "경고", "90": "긴급 경고", "95": "최종 경고", "100": "제한 적용" },
"stale": "사용량 정보가 오래되어 배정을 늘릴 수 없습니다. 계측이 갱신된 뒤 다시 시도하세요.",
"belowUsage": "현재 사용량보다 작게 줄일 수 없습니다.",
"monthReset": "월간 항목은 다음 KST 월 시작에 초기화됩니다."
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* lib/audit.mjs — 감사 기록(§24.2).
*
* 누가(actor)·언제·어떤 그룹과 대상(subject)에서·무엇을(action)·자동/사용자 작업인지·결과(outcome)를 남긴다.
* 비밀값은 redact() 로 거른다. 권한·소유권·배포·해지·복원·이전·결제 승인은 immutable=1 로 두어 삭제 API 가 거부한다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_audit_0001', sql: `
CREATE TABLE IF NOT EXISTS audit_log (
id TEXT PRIMARY KEY, at TEXT NOT NULL, actor_type TEXT NOT NULL, actor_id TEXT, actor_label TEXT, group_id TEXT,
subject_type TEXT, subject_id TEXT, action TEXT NOT NULL, outcome TEXT NOT NULL, automatic INTEGER NOT NULL DEFAULT 0,
immutable INTEGER NOT NULL DEFAULT 0, detail_json TEXT, ip TEXT
);
CREATE INDEX IF NOT EXISTS audit_group ON audit_log(group_id, at);
CREATE INDEX IF NOT EXISTS audit_subject ON audit_log(subject_type, subject_id, at);` }];
/** 삭제할 수 없는 기록으로 남기는 행동 접두어 */
const IMMUTABLE_PREFIX = /^(permission|ownership|deploy|cancel|terminate|restore|relocate|order\.approve|order\.reject|payment|refund|member\.role|site_count|emergency|recovery)/;
/**
* 기록 남기기.
* @param {object} store lib/db
* @param {object} e { actorType: 'user'|'operator'|'system'|'agent', actorId, actorLabel, groupId, subjectType, subjectId, action, outcome: 'ok'|'failed'|'cancelled'|'denied', automatic, detail, ip }
*/
export function audit(store, e) {
const id = newId('a_');
store.run('INSERT INTO audit_log(id, at, actor_type, actor_id, actor_label, group_id, subject_type, subject_id, action, outcome, automatic, immutable, detail_json, ip) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, nowIso(), e.actorType || 'system', e.actorId || null, e.actorLabel || null, e.groupId || null, e.subjectType || null, e.subjectId || null, e.action, e.outcome || 'ok',
e.automatic ? 1 : 0, e.immutable || IMMUTABLE_PREFIX.test(e.action) ? 1 : 0, toJson(redact(e.detail || null)), e.ip || null);
return id;
}
/** 조회(그룹·대상·행동·기간 필터) */
export function listAudit(store, { groupId, subjectType, subjectId, action, since, until, limit = 200 } = {}) {
const w = []; const p = [];
if (groupId) { w.push('group_id=?'); p.push(groupId); }
if (subjectType) { w.push('subject_type=?'); p.push(subjectType); }
if (subjectId) { w.push('subject_id=?'); p.push(subjectId); }
if (action) { w.push('action LIKE ?'); p.push(action + '%'); }
if (since) { w.push('at>=?'); p.push(since); }
if (until) { w.push('at<=?'); p.push(until); }
return store.all(`SELECT * FROM audit_log ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
+29
View File
@@ -0,0 +1,29 @@
/**
* lib/crypto.mjs — 비밀번호 해시·토큰·HMAC 공통.
*
* - 비밀번호: scrypt(N=2^15) + 16바이트 salt. Webmaker 와 같은 계열의 알고리즘이라 SSO 계정 동기화 때 혼동이 없다.
* - 토큰: 32바이트 무작위 base64url. 저장은 항상 sha256 해시만(원문은 발급 응답에 한 번).
* - HMAC: Agent 요청 서명·SSO 티켓 서명. 비교는 항상 timingSafeEqual.
*/
import { scryptSync, randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
/** 비밀번호 해시. @returns {{salt:string, hash:string}} */
export function hashPassword(password) { const salt = randomBytes(16).toString('hex'); return { salt, hash: scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }).toString('hex') }; }
/** 비밀번호 검증(상수 시간). */
export function verifyPassword(password, salt, hash) {
if (!salt || !hash) return false;
const a = scryptSync(String(password), salt, 64, { N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); const b = Buffer.from(hash, 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}
/** 무작위 토큰(원문). bytes 기본 32. */
export const randomToken = (bytes = 32) => randomBytes(bytes).toString('base64url');
/** 짧은 식별자(ULID 대용): 시각 정렬 가능한 16진 26자 — 앞 10자는 ms 시각. */
export function newId(prefix = '') { const t = Date.now().toString(16).padStart(12, '0'); return prefix + t + randomBytes(7).toString('hex'); }
/** 토큰 저장용 해시 */
export const sha256 = (s) => createHash('sha256').update(String(s)).digest('hex');
/** HMAC-SHA256 (base64url) */
export const hmac = (secret, data) => createHmac('sha256', secret).update(String(data)).digest('base64url');
/** 문자열 상수 시간 비교 */
export function safeEqual(a, b) { const x = Buffer.from(String(a || '')); const y = Buffer.from(String(b || '')); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); }
/** 6자리 숫자 코드(이메일 확인·MFA 복구 등) */
export const numericCode = (digits = 6) => String(parseInt(randomBytes(4).toString('hex'), 16) % 10 ** digits).padStart(digits, '0');
+74
View File
@@ -0,0 +1,74 @@
/**
* lib/db.mjs — SQLite 저장소 공통 계층 (node:sqlite, 외부 의존성 없음).
*
* 목적
* HAL·포털·Agent 가 모두 같은 방식으로 DB 를 열고 마이그레이션을 적용하게 한다.
* - WAL 모드: 읽기와 쓰기가 서로 막지 않아 화면 조회 중 작업 엔진이 기록해도 된다.
* - foreign_keys ON: 그룹·사이트·작업 사이의 참조 무결성을 DB 가 지킨다.
* - busy_timeout: 짧은 잠금 경쟁(두 요청이 동시에 예약을 시도)은 기다렸다가 처리한다(§13.3 동시 요청).
*
* 사용
* const store = openDb('/srv/ariweb/portal/data/portal.sqlite', [{ id: '0001_init', sql: '...' }]);
* store.get('SELECT * FROM users WHERE id=?', id) → 한 행 또는 undefined
* store.all(...) → 행 배열
* store.run(...) → { changes, lastInsertRowid }
* store.tx(() => { ... }) → BEGIN IMMEDIATE … COMMIT (예외 시 ROLLBACK). 중첩되면 바깥 트랜잭션에 합류한다.
*
* 마이그레이션
* `_migrations` 표에 적용한 id 를 남기고, 아직 없는 것만 순서대로 적용한다. 각 모듈(lib/session·jobs·quota·notify·audit)은
* 자기 표의 마이그레이션을 `MIGRATIONS` 로 내보내고, 각 프로세스의 schema 파일이 그것을 모아 openDb 에 넘긴다.
*/
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
/**
* DB 를 열고 마이그레이션을 적용한다.
* @param {string} file 파일 경로. ':memory:' 면 메모리 DB(테스트).
* @param {Array<{id:string, sql:string}>} migrations 적용할 마이그레이션(순서대로)
* @returns {{db: DatabaseSync, get: Function, all: Function, run: Function, exec: Function, tx: Function, close: Function, file: string}}
*/
export function openDb(file, migrations = []) {
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
const db = new DatabaseSync(file);
// 운영 안정성 설정. WAL 은 파일 DB 에서만 의미가 있다.
db.exec('PRAGMA foreign_keys = ON');
if (file !== ':memory:') { db.exec('PRAGMA journal_mode = WAL'); db.exec('PRAGMA synchronous = NORMAL'); }
db.exec('PRAGMA busy_timeout = 5000');
db.exec('CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
const applied = new Set(db.prepare('SELECT id FROM _migrations').all().map((r) => r.id));
for (const m of migrations) {
if (applied.has(m.id)) continue;
// 한 마이그레이션은 한 트랜잭션: 중간에 실패하면 전부 되돌려 반쪽 스키마가 남지 않게 한다.
db.exec('BEGIN');
try { db.exec(m.sql); db.prepare('INSERT INTO _migrations(id, applied_at) VALUES(?, ?)').run(m.id, new Date().toISOString()); db.exec('COMMIT'); }
catch (e) { db.exec('ROLLBACK'); throw new Error(`마이그레이션 실패 (${m.id}): ${e.message}`); }
}
let depth = 0; // 중첩 tx 깊이 — 안쪽 tx 는 BEGIN 을 다시 하지 않는다
const store = {
db, file,
get: (sql, ...p) => db.prepare(sql).get(...p),
all: (sql, ...p) => db.prepare(sql).all(...p),
run: (sql, ...p) => db.prepare(sql).run(...p),
exec: (sql) => db.exec(sql),
/**
* 즉시 잠금 트랜잭션. 쓰기 경쟁(예약·주문 중복)을 DB 수준에서 직렬화한다.
* 동기 함수만 받는다(node:sqlite 는 동기 API 라 await 사이에 잠금을 쥐고 있으면 안 된다).
*/
tx(fn) {
if (depth > 0) return fn(store);
db.exec('BEGIN IMMEDIATE'); depth++;
try { const r = fn(store); db.exec('COMMIT'); depth--; return r; }
catch (e) { depth--; try { db.exec('ROLLBACK'); } catch (_) {} throw e; }
},
close: () => db.close(),
};
return store;
}
/** JSON 열 읽기 도우미: 깨진 값은 fallback(화면이 깨지지 않게). */
export function parseJson(text, fallback = null) { if (text == null || text === '') return fallback; try { return JSON.parse(text); } catch (_) { return fallback; } }
/** JSON 열 쓰기 도우미 */
export const toJson = (v) => (v === undefined ? null : JSON.stringify(v));
/** 현재 시각(ISO, UTC) */
export const nowIso = () => new Date().toISOString();
+157
View File
@@ -0,0 +1,157 @@
/**
* lib/http.mjs — 의존성 없는 작은 HTTP 앱 프레임워크(라우터·JSON·쿠키·정적 파일·오류 형식).
*
* 왜 직접 만드나: 형제 제품(Webmaker·AriMail)과 같이 외부 npm 의존성 없이 운영하기 위해서다. 필요한 기능은
* 경로 매개변수(`/api/sites/:id`), JSON 본문, 쿠키, 신뢰 프록시 IP, 정적 파일, 일관된 오류 응답뿐이다.
*
* 핸들러: async (ctx) => 반환값
* - 객체를 돌려주면 JSON(200). `ctx.json(obj, status)`·`ctx.html(str)`·`ctx.redirect(url)` 로 다른 응답.
* - 예외: `ctx.error(status, code, message)` 가 만든 HttpError → { ok:false, error, message, detail? }. 그 밖의 예외는 500 + 기록.
* 미들웨어: app.use(async (ctx) => { ... }) — 모든 라우트 전에 실행(세션 해석 등). 반환값이 있으면 그것으로 응답(차단).
* 보안 기본값: 모든 동적 응답 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`, `X-Frame-Options: SAMEORIGIN`.
* 접속 IP: nginx 가 붙인 `X-Real-IP` 를 소켓 주소가 루프백일 때만 믿는다(클라이언트가 넣은 헤더를 믿지 않음).
*/
import http from 'node:http';
import { readFileSync, statSync, createReadStream, existsSync } from 'node:fs';
import { join, normalize, extname } from 'node:path';
export class HttpError extends Error {
/** @param {number} status @param {string} code 기계용 오류 코드 @param {string} message 사용자용 설명 @param {object} [detail] 화면이 쓸 추가 정보(비밀 없음) */
constructor(status, code, message, detail) { super(message || code); this.status = status; this.code = code; this.detail = detail; }
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8', '.woff2': 'font/woff2' };
const MAX_BODY = 2 * 1024 * 1024; // JSON 본문 상한(2MB). 파일 업로드는 별도 스트리밍 라우트가 맡는다.
/** 쿠키 헤더 파싱 */
export function parseCookies(header) { const out = {}; for (const part of String(header || '').split(';')) { const i = part.indexOf('='); if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); } return out; }
/** Set-Cookie 문자열 만들기 */
export function cookie(name, value, { maxAge = 0, path = '/', secure = true, sameSite = 'Lax', httpOnly = true } = {}) {
return `${name}=${encodeURIComponent(value)}; Path=${path}; ${httpOnly ? 'HttpOnly; ' : ''}SameSite=${sameSite}${secure ? '; Secure' : ''}${maxAge ? `; Max-Age=${maxAge}` : '; Max-Age=0'}`;
}
/** 경로 패턴(`/a/:id/b`)을 정규식으로 */
function compile(pattern) {
const keys = []; const re = new RegExp('^' + pattern.replace(/\/+$/, '').replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '(.*)').replace(/:([a-zA-Z_]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; }) + '/?$');
return { re, keys };
}
/**
* 앱 만들기.
* @param {{ log?: Function, trustProxy?: boolean, onError?: Function }} opts
*/
export function createApp(opts = {}) {
const routes = []; const middlewares = []; const statics = [];
const log = opts.log || ((...a) => console.log(...a));
const app = {
/** 라우트 등록. method 는 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'|'*' */
route(method, pattern, handler) { routes.push({ method: method.toUpperCase(), ...compile(pattern), pattern, handler }); return app; },
get: (p, h) => app.route('GET', p, h), post: (p, h) => app.route('POST', p, h), put: (p, h) => app.route('PUT', p, h), patch: (p, h) => app.route('PATCH', p, h), delete: (p, h) => app.route('DELETE', p, h),
use(fn) { middlewares.push(fn); return app; },
/** 정적 파일: prefix 아래 요청을 dir 에서 낸다(경로 탈출 차단). */
static(prefix, dir) { statics.push({ prefix: prefix.replace(/\/$/, ''), dir }); return app; },
/** 요청 하나 처리(테스트에서도 직접 부른다). */
async handle(req, res) {
const started = Date.now();
const url = new URL(req.url, 'http://localhost');
const ctx = makeCtx(req, res, url, opts);
try {
for (const s of statics) {
if (ctx.method === 'GET' && (url.pathname === s.prefix || url.pathname.startsWith(s.prefix + '/'))) {
const rel = normalize(url.pathname.slice(s.prefix.length) || '/').replace(/^(\.\.(\/|\\|$))+/, '');
const file = join(s.dir, rel);
if (!file.startsWith(s.dir)) throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
if (existsSync(file) && statSync(file).isFile()) return sendFile(res, file);
}
}
for (const m of middlewares) { const r = await m(ctx); if (r !== undefined) return finish(ctx, r); if (ctx.ended) return; }
for (const r of routes) {
if (r.method !== '*' && r.method !== ctx.method) continue;
const m = r.re.exec(url.pathname); if (!m) continue;
r.keys.forEach((k, i) => { ctx.params[k] = decodeURIComponent(m[i + 1]); });
const out = await r.handler(ctx);
return finish(ctx, out);
}
throw new HttpError(404, 'not_found', '페이지를 찾을 수 없습니다');
} catch (e) { return sendError(ctx, e, log, opts); }
finally { if (opts.access) log(`${ctx.method} ${url.pathname} ${res.statusCode} ${Date.now() - started}ms`); }
},
/** 서버 시작 */
listen(port, host = '127.0.0.1') { const server = http.createServer((req, res) => app.handle(req, res)); server.keepAliveTimeout = 65000; return new Promise((resolve) => server.listen(port, host, () => resolve(server))); },
routes,
};
return app;
}
/** 요청 컨텍스트 */
function makeCtx(req, res, url, opts) {
const sock = req.socket?.remoteAddress || '';
const loop = sock === '127.0.0.1' || sock === '::1' || sock === '::ffff:127.0.0.1';
const ip = (opts.trustProxy !== false && loop && req.headers['x-real-ip']) ? String(req.headers['x-real-ip']) : sock.replace(/^::ffff:/, '');
let bodyCache;
const ctx = {
req, res, method: req.method.toUpperCase(), path: url.pathname, url, params: {}, query: Object.fromEntries(url.searchParams), cookies: parseCookies(req.headers.cookie), ip, headers: req.headers,
user: null, session: null, ended: false, status: 200, outHeaders: {},
secure: req.headers['x-forwarded-proto'] === 'https' || !!req.socket?.encrypted,
/** JSON 본문(한 번만 읽고 캐시). 비어 있으면 {} */
async body() {
if (bodyCache !== undefined) return bodyCache;
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > MAX_BODY) throw new HttpError(413, 'body_too_large', '요청 본문이 너무 큽니다'); chunks.push(c); }
const text = Buffer.concat(chunks).toString('utf8');
if (!text) return (bodyCache = {});
const ct = String(req.headers['content-type'] || '');
if (ct.includes('application/x-www-form-urlencoded')) return (bodyCache = Object.fromEntries(new URLSearchParams(text)));
try { return (bodyCache = JSON.parse(text)); } catch (_) { throw new HttpError(400, 'bad_json', '요청 본문이 올바른 JSON 이 아닙니다'); }
},
set(status, headers = {}) { ctx.status = status; Object.assign(ctx.outHeaders, headers); },
setCookie(str) { const prev = ctx.outHeaders['Set-Cookie']; ctx.outHeaders['Set-Cookie'] = prev ? [].concat(prev, str) : str; },
json(obj, status = 200) { ctx.status = status; return { __json: obj }; },
html(str, status = 200) { ctx.status = status; return { __html: str }; },
text(str, status = 200) { ctx.status = status; return { __text: str }; },
redirect(url, status = 302) { ctx.status = status; ctx.outHeaders.Location = url; return { __text: '' }; },
error(status, code, message, detail) { return new HttpError(status, code, message, detail); },
};
return ctx;
}
function baseHeaders(ctx) { return { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'X-Frame-Options': 'SAMEORIGIN', ...ctx.outHeaders }; }
function finish(ctx, out) {
if (ctx.ended) return; ctx.ended = true;
const h = baseHeaders(ctx);
if (out && out.__html !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/html; charset=utf-8', ...h }); return ctx.res.end(out.__html); }
if (out && out.__text !== undefined) { ctx.res.writeHead(ctx.status, { 'Content-Type': 'text/plain; charset=utf-8', ...h }); return ctx.res.end(out.__text); }
const body = out && out.__json !== undefined ? out.__json : out;
ctx.res.writeHead(ctx.status, { 'Content-Type': 'application/json; charset=utf-8', ...h });
ctx.res.end(JSON.stringify(body === undefined ? { ok: true } : body));
}
function sendError(ctx, e, log, opts) {
if (ctx.ended) return;
const status = e instanceof HttpError ? e.status : (e && e.status) || 500;
const code = e instanceof HttpError ? e.code : (e && e.code) || 'server_error';
if (status >= 500) { log(`[http] ${ctx.method} ${ctx.path} 500: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); if (opts.onError) try { opts.onError(e, ctx); } catch (_) {} }
ctx.ended = true;
ctx.res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...baseHeaders(ctx) });
ctx.res.end(JSON.stringify({ ok: false, error: code, message: status >= 500 ? '일시적인 오류가 발생했습니다. 잠시 후 다시 시도하세요' : (e.message || code), ...(e.detail ? { detail: e.detail } : {}) }));
}
function sendFile(res, file) {
const type = MIME[extname(file).toLowerCase()] || 'application/octet-stream';
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache', 'X-Content-Type-Options': 'nosniff' });
createReadStream(file).pipe(res);
}
/** 간단한 HTML 이스케이프 */
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** 테스트용: 앱에 가짜 요청을 보내고 { status, headers, json, text } 를 돌려준다(소켓 없이). */
export async function invoke(app, method, path, { body, headers = {}, cookies = {}, ip = '127.0.0.1' } = {}) {
const { PassThrough } = await import('node:stream');
const req = new PassThrough(); req.method = method; req.url = path;
req.headers = { 'content-type': 'application/json', ...headers };
if (Object.keys(cookies).length) req.headers.cookie = Object.entries(cookies).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('; ');
req.socket = { remoteAddress: ip };
if (body !== undefined) req.end(typeof body === 'string' ? body : JSON.stringify(body)); else req.end();
const out = { status: 0, headers: {}, chunks: [] };
const res = { writeHead(s, h) { out.status = s; out.headers = h || {}; }, end(d) { if (d) out.chunks.push(Buffer.from(d)); out.done = true; resolve(); }, write(d) { out.chunks.push(Buffer.from(d)); }, statusCode: 200, on() {}, once() {}, emit() {} };
let resolve; const p = new Promise((r) => { resolve = r; });
await app.handle(req, res); await p;
const text = Buffer.concat(out.chunks).toString('utf8');
let json = null; try { json = JSON.parse(text); } catch (_) {}
const setCookie = [].concat(out.headers['Set-Cookie'] || []);
return { status: out.status, headers: out.headers, text, json, cookies: Object.fromEntries(setCookie.map((c) => { const [kv] = c.split(';'); const i = kv.indexOf('='); return [kv.slice(0, i), decodeURIComponent(kv.slice(i + 1))]; })) };
}
+27
View File
@@ -0,0 +1,27 @@
/**
* lib/i18n.mjs — 화면 문구 사전. 문구를 코드에 박지 않고 lang/ko.json 에서 읽는다(§9 규약).
* t('auth.login.failed') → 문자열. {name} 자리표시자는 vars 로 치환. 없는 키는 키 그대로 돌려줘 누락이 화면에 드러난다.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
let dict = null; let loadedAt = 0;
/** lang/ko/*.json 을 모두 읽어 깊게 합친다(영역마다 파일을 나눠 충돌 없이 관리). 운영 중 수정은 5초 안에 반영. */
function load() {
if (dict && Date.now() - loadedAt < 5000) return dict;
const dir = join(ROOT, 'lang', 'ko'); const merged = {};
const deepMerge = (a, b) => { for (const [k, v] of Object.entries(b)) { if (v && typeof v === 'object' && !Array.isArray(v) && a[k] && typeof a[k] === 'object') deepMerge(a[k], v); else a[k] = v; } return a; };
try { for (const f of readdirSync(dir).filter((x) => x.endsWith('.json')).sort()) { try { deepMerge(merged, JSON.parse(readFileSync(join(dir, f), 'utf8'))); } catch (e) { console.error(`[i18n] ${f} 읽기 실패: ${e.message}`); } } } catch (_) {}
dict = merged; loadedAt = Date.now(); return dict;
}
/** 점(.)으로 중첩 키를 찾는다 */
function lookup(obj, key) { return key.split('.').reduce((o, k) => (o && typeof o === 'object' ? o[k] : undefined), obj); }
export function t(key, vars = {}) {
const v = lookup(load(), key);
const s = typeof v === 'string' ? v : key;
return s.replace(/\{(\w+)\}/g, (_, k) => (vars[k] !== undefined ? String(vars[k]) : `{${k}}`));
}
/** 사전 전체(화면 JS 에 내려보낼 때) */
export function all() { return load(); }
+214
View File
@@ -0,0 +1,214 @@
/**
* lib/jobs.mjs — 내구성 작업 엔진(§2.2·§29·§36).
*
* 생각의 틀
* 배포·복원·이전·백업·주문 처리처럼 "오래 걸리고 중간에 프로세스가 죽을 수 있는" 일은 모두 작업(job)이다.
* 작업은 DB 행이며 단계(step)마다 결과를 저장한다. 프로세스가 재시작되면 끝난 단계는 건너뛰고 다음 단계부터 이어서 실행한다.
* 완료되지 않은 작업을 실패로 숨기지 않고, 재시도 횟수·다음 시각·외부 대기 사유·사용자 확인 필요를 상태로 드러낸다.
*
* 핵심 규약
* - register(kind, def): def = { title, steps:[{id,title}], run(job, ctx), revert?(job, ctx), maxAttempts?, retryable?(err) }
* - run() 은 **재진입 가능**해야 한다: 같은 작업으로 여러 번 불릴 수 있으므로 부수 효과가 있는 일은 ctx.step(id, fn) 안에서 한다.
* ctx.step 은 이미 done 인 단계면 저장된 결과를 돌려주고 fn 을 실행하지 않는다.
* - 같은 idempotencyKey 로 enqueue 하면 새 작업을 만들지 않고 기존 작업을 돌려준다(버튼 연타·새로 고침 §36).
* - 일시 오류(err.retryable=true 또는 def.retryable(err)) 는 지수 백오프로 재시도하고 attempt·next_run_at 을 보인다.
* - ctx.waitExternal(reason, {resumeAt}) → AWAITING_EXTERNAL. ctx.needsUser(message, actions) → NEEDS_USER. resume(id) 로 다시 QUEUED.
* - 되돌리기: revert(id) 는 def.revert 를 실행하며 REVERTING → REVERTED(실패하면 FAILED + error). 되돌리기 전 데이터 영향은 호출자가 안내한다.
* - 부모/자식: parentId 로 대상별 작업(여러 서버 모듈 갱신 §29)을 묶고 childrenSummary 로 대상별 성공·실패·대기를 보인다. 일부 실패를 전체 성공으로 보이지 않는다.
*/
import { newId } from './crypto.mjs';
import { redact, safeError } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
import { JOB_STATUS as S, TERMINAL, CANCELLABLE } from './status.mjs';
export const MIGRATIONS = [{ id: 'lib_jobs_0001', sql: `
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, status TEXT NOT NULL, label TEXT, subject_json TEXT, steps_json TEXT, attempt INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 1,
next_run_at TEXT, locked_by TEXT, locked_at TEXT, result_json TEXT, error TEXT, error_code TEXT, wait_reason TEXT, user_message TEXT, actions_json TEXT, eta_at TEXT, progress INTEGER NOT NULL DEFAULT 0,
idempotency_key TEXT UNIQUE, created_by TEXT, actor_type TEXT, group_id TEXT, subject_type TEXT, subject_id TEXT, parent_id TEXT, external_ref TEXT, cancel_requested INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, started_at TEXT, ended_at TEXT, log_json TEXT
);
CREATE INDEX IF NOT EXISTS jobs_status ON jobs(status, next_run_at);
CREATE INDEX IF NOT EXISTS jobs_group ON jobs(group_id, created_at);
CREATE INDEX IF NOT EXISTS jobs_subject ON jobs(subject_type, subject_id, created_at);
CREATE INDEX IF NOT EXISTS jobs_parent ON jobs(parent_id);` }];
/** run() 안에서 흐름을 바꾸는 신호(예외로 전달) */
class Signal extends Error { constructor(type, payload) { super(type); this.signal = type; this.payload = payload || {}; } }
export class JobError extends Error { constructor(code, message, { retryable = false, detail, status = 400 } = {}) { super(message || code); this.code = code; this.retryable = retryable; this.detail = detail; this.status = status; /* API 로 새면 400 계열 — 500 으로 숨기지 않는다 */ } }
const BACKOFF_BASE_MS = 30 * 1000; const BACKOFF_MAX_MS = 60 * 60 * 1000; const STALE_LOCK_MS = 10 * 60 * 1000;
/**
* 작업 엔진 만들기.
* @param {object} store lib/db
* @param {{workerId?:string, pollMs?:number, log?:Function}} opts
*/
export function createJobEngine(store, opts = {}) {
const kinds = new Map(); const listeners = new Set();
const workerId = opts.workerId || `${process.pid}@${(await0() || 'host')}`; const log = opts.log || (() => {});
let timer = null; let running = 0; let stopped = true; const concurrency = opts.concurrency || 4;
function row2job(r) { if (!r) return null; return { ...r, subject: parseJson(r.subject_json, {}), steps: parseJson(r.steps_json, []), result: parseJson(r.result_json, null), actions: parseJson(r.actions_json, null), log: parseJson(r.log_json, []) }; }
function emit(job) { for (const fn of listeners) { try { fn(job); } catch (e) { log('[jobs] listener error', e.message); } } }
function save(id, patch) {
const sets = []; const vals = [];
for (const [k, v] of Object.entries(patch)) { sets.push(`${k}=?`); vals.push(v && typeof v === 'object' ? toJson(v) : v); }
sets.push('updated_at=?'); vals.push(nowIso());
store.run(`UPDATE jobs SET ${sets.join(', ')} WHERE id=?`, ...vals, id);
const j = engine.get(id); emit(j); return j;
}
/** 실행 가능한 작업 하나를 잠근다(큐 → 실행 중). 없으면 null */
function claim() {
if (running >= concurrency) return null;
const now = nowIso();
const cand = store.get('SELECT id FROM jobs WHERE status=? AND (next_run_at IS NULL OR next_run_at<=?) ORDER BY next_run_at ASC, created_at ASC LIMIT 1', S.QUEUED, now);
if (!cand) return null;
const got = store.run('UPDATE jobs SET status=?, locked_by=?, locked_at=?, started_at=COALESCE(started_at, ?), attempt=attempt+1, updated_at=? WHERE id=? AND status=?', S.RUNNING, workerId, now, now, now, cand.id, S.QUEUED).changes;
return got ? cand.id : null;
}
const engine = {
workerId,
register(kind, def) { kinds.set(kind, { maxAttempts: 1, steps: [], ...def }); return engine; },
kinds: () => [...kinds.keys()],
onChange(fn) { listeners.add(fn); return () => listeners.delete(fn); },
/**
* 작업 넣기. idempotencyKey 가 같은 작업이 있으면 그것을 돌려준다(created=false).
* @returns {{job:object, created:boolean}}
*/
enqueue({ kind, subject = {}, idempotencyKey = null, createdBy = null, actorType = 'user', groupId = null, subjectType = null, subjectId = null, label = null, parentId = null, runAt = null, maxAttempts = null, initialStatus = null }) {
const def = kinds.get(kind); if (!def) throw new JobError('unknown_job_kind', `알 수 없는 작업 종류: ${kind}`);
return store.tx(() => {
if (idempotencyKey) { const ex = store.get('SELECT * FROM jobs WHERE idempotency_key=?', idempotencyKey); if (ex) return { job: row2job(ex), created: false }; }
const id = newId('j_'); const now = nowIso();
const steps = (def.steps || []).map((s) => ({ id: s.id, title: s.title, state: 'pending' }));
store.run('INSERT INTO jobs(id, kind, status, label, subject_json, steps_json, attempt, max_attempts, next_run_at, idempotency_key, created_by, actor_type, group_id, subject_type, subject_id, parent_id, created_at, updated_at, log_json) VALUES(?,?,?,?,?,?,0,?,?,?,?,?,?,?,?,?,?,?,?)',
id, kind, initialStatus || S.QUEUED, label || def.title || kind, toJson(subject), toJson(steps), maxAttempts || def.maxAttempts || 1, runAt || now, idempotencyKey, createdBy, actorType, groupId, subjectType, subjectId, parentId, now, now, '[]');
const job = engine.get(id); emit(job); return { job, created: true };
});
},
get: (id) => row2job(store.get('SELECT * FROM jobs WHERE id=?', id)),
list({ groupId, status, kind, subjectType, subjectId, parentId, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId) { w.push('group_id=?'); p.push(groupId); } if (status) { w.push(Array.isArray(status) ? `status IN (${status.map(() => '?').join(',')})` : 'status=?'); p.push(...[].concat(status)); }
if (kind) { w.push('kind=?'); p.push(kind); } if (subjectType) { w.push('subject_type=?'); p.push(subjectType); } if (subjectId) { w.push('subject_id=?'); p.push(subjectId); } if (parentId) { w.push('parent_id=?'); p.push(parentId); }
return store.all(`SELECT * FROM jobs ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY created_at DESC LIMIT ?`, ...p, Math.min(limit, 1000)).map(row2job);
},
/** 자식 작업 요약(대상별 성공·실패·대기). 일부 실패는 'partial' */
childrenSummary(parentId) {
const kids = engine.list({ parentId, limit: 1000 }); const by = {}; for (const k of kids) by[k.status] = (by[k.status] || 0) + 1;
const failed = kids.filter((k) => ['failed', 'needs_user'].includes(k.status)).length; const done = kids.filter((k) => k.status === 'done').length;
return { total: kids.length, byStatus: by, overall: kids.length === 0 ? 'none' : done === kids.length ? 'done' : failed && done ? 'partial' : failed === kids.length ? 'failed' : 'running', children: kids.map((k) => ({ id: k.id, label: k.label, status: k.status, subject: k.subject, error: k.error, attempt: k.attempt, next_run_at: k.next_run_at })) };
},
/** 외부 대기·사용자 확인·일시 중지 상태의 작업을 다시 큐에 넣는다 */
resume(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.AWAITING_EXTERNAL, S.NEEDS_USER, S.PAUSED, S.AWAITING_APPROVAL, S.AWAITING_PAYMENT, S.NOT_READY, S.NEEDS_SETUP].includes(j.status)) throw new JobError('job_not_resumable', `지금 상태(${j.status})에서는 이어서 할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), wait_reason: null, user_message: null, actions_json: null, log_json: appendLog(j, `재개 (${by || 'system'})`) }); },
/** 실패한 작업 재시도(사용자·운영자 명시) */
retry(id, by = null) { const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다'); if (![S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_retryable', `지금 상태(${j.status})에서는 재시도할 수 없습니다`); return save(id, { status: S.QUEUED, next_run_at: nowIso(), error: null, error_code: null, max_attempts: j.attempt + 1, log_json: appendLog(j, `재시도 요청 (${by || 'system'})`) }); },
pause(id, by = null) { const j = engine.get(id); if (!j || ![S.QUEUED, S.AWAITING_EXTERNAL].includes(j.status)) throw new JobError('job_not_pausable', '지금 상태에서는 일시 중지할 수 없습니다'); return save(id, { status: S.PAUSED, log_json: appendLog(j, `일시 중지 (${by || 'system'})`) }); },
/** 취소: 대기 상태면 즉시, 실행 중이면 취소 요청 표시(ctx.checkCancel 에서 멈춤) */
cancel(id, by = null) {
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
if (CANCELLABLE.has(j.status)) return save(id, { status: S.CANCELLED, ended_at: nowIso(), log_json: appendLog(j, `취소 (${by || 'system'})`) });
if (j.status === S.RUNNING) return save(id, { cancel_requested: 1, log_json: appendLog(j, `취소 요청 (${by || 'system'})`) });
throw new JobError('job_not_cancellable', `지금 상태(${j.status})에서는 취소할 수 없습니다`);
},
/** 되돌리기 */
async revert(id, by = null) {
const j = engine.get(id); if (!j) throw new JobError('job_not_found', '작업이 없습니다');
const def = kinds.get(j.kind); if (!def || !def.revert) throw new JobError('job_not_revertible', '이 작업은 되돌리기를 지원하지 않습니다');
if (![S.DONE, S.FAILED, S.NEEDS_USER].includes(j.status)) throw new JobError('job_not_revertible', `지금 상태(${j.status})에서는 되돌릴 수 없습니다`);
save(id, { status: S.REVERTING, log_json: appendLog(j, `되돌리기 시작 (${by || 'system'})`) });
const ctx = makeCtx(engine.get(id), { reverting: true });
try { await def.revert(engine.get(id), ctx); return save(id, { status: S.REVERTED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs() }); }
catch (e) { return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'revert_failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso() }); }
},
/** 외부(HAL 등)에서 진행 상태를 밀어 넣을 때 */
update(id, patch) { return save(id, patch); },
/** 프로세스 시작 시: 이전 실행에서 RUNNING 으로 남은 작업(잠금 소유자 불문, 단일 프로세스 운영)을 QUEUED 로 되돌려 이어서 실행한다 */
recover() {
const stale = store.all('SELECT * FROM jobs WHERE status=?', S.RUNNING);
for (const r of stale) { const j = row2job(r); save(j.id, { status: S.QUEUED, locked_by: null, locked_at: null, next_run_at: nowIso(), log_json: appendLog(j, '프로세스 재시작 — 끝난 단계 다음부터 이어서 실행') }); }
return stale.length;
},
/** 실행 가능한 작업 하나를 집어 실행(테스트에서도 직접 부름). 돌아온 값: 실행한 작업 또는 null */
async tick() { const id = claim(); if (!id) return null; running++; try { return await execute(id); } finally { running--; } },
/** 루프용: 작업을 집어 **기다리지 않고** 실행을 시작한다(느린 작업이 다른 작업을 막지 않게, 부모/자식 작업 교착 방지). 집은 작업 id 또는 null */
dispatch() { const id = claim(); if (!id) return null; running++; execute(id).catch((e) => log('[jobs] execute error', safeError(e))).finally(() => { running--; }); return id; },
/** 모든 큐 작업을 비울 때까지 tick(테스트용) */
async drain(max = 100) { let n = 0; while (n < max) { const r = await engine.tick(); if (!r) break; n++; } return n; },
start() { stopped = false; engine.recover(); const loop = () => { if (stopped) return; try { let n = 0; while (running < concurrency && n < concurrency && engine.dispatch()) n++; } catch (e) { log('[jobs] dispatch error', safeError(e)); } timer = setTimeout(loop, opts.pollMs || 1000); }; loop(); return engine; },
stop() { stopped = true; if (timer) clearTimeout(timer); },
};
function appendLog(job, line) { const l = (job.log || []).slice(-200); l.push({ at: nowIso(), line: String(line).slice(0, 500) }); return l; }
function makeCtx(job, { reverting = false } = {}) {
const steps = (job.steps || []).map((s) => ({ ...s })); const logs = (job.log || []).slice(-200);
const ctx = {
job, reverting, workerId,
/** 단계 실행. 이미 done 이면 저장된 결과를 돌려준다. 결과는 JSON 직렬화 가능해야 한다. */
async step(id, fn, title) {
let s = steps.find((x) => x.id === id); if (!s) { s = { id, title: title || id, state: 'pending' }; steps.push(s); }
if (s.state === 'done' && !reverting) return s.result;
s.state = 'running'; s.startedAt = nowIso(); delete s.error; ctx.flush();
try { const result = await fn(); s.state = 'done'; s.endedAt = nowIso(); s.result = result === undefined ? null : redact(result); ctx.flush(); return result; }
catch (e) { if (e instanceof Signal) { s.state = e.signal === 'wait' ? 'waiting' : e.signal === 'needs_user' ? 'needs_user' : 'pending'; s.detail = e.payload.reason || e.payload.message || ''; ctx.flush(); throw e; } s.state = 'failed'; s.endedAt = nowIso(); s.error = safeError(e); ctx.flush(); throw e; }
},
log(line) { logs.push({ at: nowIso(), line: String(line).slice(0, 500) }); ctx.flush(); },
progress(pct) { store.run('UPDATE jobs SET progress=?, updated_at=? WHERE id=?', Math.max(0, Math.min(100, Math.round(pct))), nowIso(), job.id); },
eta(at) { store.run('UPDATE jobs SET eta_at=?, updated_at=? WHERE id=?', at || null, nowIso(), job.id); },
/** 외부 작업(사용자 DNS 변경·운영자 승인 등)을 기다린다. run() 은 여기서 끝나고 resume() 뒤 다시 불린다. */
waitExternal(reason, { resumeAt = null } = {}) { throw new Signal('wait', { reason, resumeAt }); },
/** 사용자 확인이 필요해 멈춘다. actions: [{id,label,description}] */
needsUser(message, actions = null) { throw new Signal('needs_user', { message, actions }); },
/** 일시 오류로 나중에 다시(지수 백오프 대신 지정 시각) */
retryLater(ms, reason) { throw new Signal('retry', { ms, reason }); },
/** 취소 요청이 들어왔으면 멈춘다 */
checkCancel() { const r = store.get('SELECT cancel_requested FROM jobs WHERE id=?', job.id); if (r && r.cancel_requested) throw new Signal('cancel', {}); },
steps: () => steps, logs: () => logs,
flush() { store.run('UPDATE jobs SET steps_json=?, log_json=?, updated_at=? WHERE id=?', toJson(steps), toJson(logs), nowIso(), job.id); },
};
return ctx;
}
async function execute(id) {
const job = engine.get(id); const def = kinds.get(job.kind);
if (!def) return save(id, { status: S.FAILED, error: `알 수 없는 작업 종류: ${job.kind}`, error_code: 'unknown_job_kind', ended_at: nowIso() });
const ctx = makeCtx(job);
try {
const result = await def.run(job, ctx);
return save(id, { status: S.DONE, result_json: redact(result === undefined ? null : result), progress: 100, steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null, error: null, error_code: null });
} catch (e) {
if (e instanceof Signal) {
// 단계 밖에서 대기·확인 신호가 나면 다음 미완료 단계에 그 상태를 표시해 화면이 "현재 단계" 를 보일 수 있게 한다
if (['wait', 'needs_user'].includes(e.signal)) { const st = ctx.steps(); if (!st.some((x) => ['waiting', 'needs_user'].includes(x.state))) { const nx = st.find((x) => x.state === 'pending'); if (nx) { nx.state = e.signal === 'wait' ? 'waiting' : 'needs_user'; nx.detail = e.payload.reason || e.payload.message || ''; } } }
if (e.signal === 'wait') return save(id, { status: S.AWAITING_EXTERNAL, wait_reason: e.payload.reason || null, eta_at: e.payload.resumeAt || null, next_run_at: e.payload.resumeAt || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
if (e.signal === 'needs_user') return save(id, { status: S.NEEDS_USER, user_message: e.payload.message || null, actions_json: e.payload.actions || null, steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
if (e.signal === 'cancel') return save(id, { status: S.CANCELLED, ended_at: nowIso(), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null, cancel_requested: 0 });
if (e.signal === 'retry') { const at = new Date(Date.now() + (e.payload.ms || BACKOFF_BASE_MS)).toISOString(); return save(id, { status: S.QUEUED, next_run_at: at, wait_reason: e.payload.reason || null, max_attempts: Math.max(job.max_attempts, job.attempt + 1), steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null }); }
}
const retryable = !!(e && e.retryable) || (def.retryable ? !!def.retryable(e) : false);
const fresh = engine.get(id);
if (retryable && fresh.attempt < fresh.max_attempts) {
const backoff = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** (fresh.attempt - 1));
return save(id, { status: S.QUEUED, next_run_at: new Date(Date.now() + backoff).toISOString(), error: safeError(e), error_code: e.code || 'transient', steps_json: ctx.steps(), log_json: ctx.logs(), locked_by: null });
}
log(`[jobs] ${job.kind} ${id} 실패: ${safeError(e)}`);
return save(id, { status: S.FAILED, error: safeError(e), error_code: e.code || 'failed', steps_json: ctx.steps(), log_json: ctx.logs(), ended_at: nowIso(), locked_by: null });
}
}
return engine;
}
function await0() { try { return (process.env.HOSTNAME || '').slice(0, 20); } catch (_) { return ''; } }
/** 화면용 요약: 현재 단계·완료 단계·다음 단계·재시도 정보(§2.2) */
export function describe(job) {
if (!job) return null;
const steps = job.steps || []; const doneSteps = steps.filter((s) => s.state === 'done'); const current = steps.find((s) => ['running', 'waiting', 'needs_user', 'failed'].includes(s.state)) || steps.find((s) => s.state === 'pending') || null;
const idx = current ? steps.indexOf(current) : steps.length; const next = steps[idx + 1] || null;
return {
id: job.id, kind: job.kind, label: job.label, status: job.status, progress: job.progress, attempt: job.attempt, maxAttempts: job.max_attempts, nextRunAt: job.next_run_at, etaAt: job.eta_at || null,
etaUnknownReason: job.eta_at ? null : '외부 반영 시간에 따라 달라져 산정할 수 없습니다', updatedAt: job.updated_at, startedAt: job.started_at, endedAt: job.ended_at,
currentStep: current ? { id: current.id, title: current.title, state: current.state, detail: current.detail || current.error || null } : null,
completedSteps: doneSteps.map((s) => ({ id: s.id, title: s.title })), nextStep: next ? { id: next.id, title: next.title } : null, steps,
waitReason: job.wait_reason, userMessage: job.user_message, actions: job.actions, error: job.error, errorCode: job.error_code,
canCancel: CANCELLABLE.has(job.status) || job.status === 'running', canRetry: ['failed', 'needs_user'].includes(job.status), canResume: ['awaiting_external', 'needs_user', 'paused'].includes(job.status), terminal: TERMINAL.has(job.status),
stale: job.status === 'running' && Date.now() - Date.parse(job.updated_at) > 10 * 60 * 1000,
};
}
+25
View File
@@ -0,0 +1,25 @@
/**
* lib/kst.mjs — 한국 표준시(KST, UTC+9) 월 경계 계산.
*
* 월간 사용량(웹 아웃바운드·메일 통수)은 "KST 월" 기준으로 집계·초기화한다(§13.3·§13.4·§17.2).
* UTC 로 저장된 시각을 KST 월로 나누려면 9시간을 더해 날짜를 구하고, 월 시작을 다시 UTC 로 돌린다.
* 테스트가 고정 시각으로 검증할 수 있게 모든 함수는 `at` 을 인자로 받는다(AriMail usage.test 의 월 경계 오류 재발 방지).
*/
const KST_OFFSET_MS = 9 * 3600 * 1000;
/** at 이 속한 KST 월의 시작(UTC ISO). 예: 2026-10-31T16:00:00Z(=11-01 01:00 KST) → 2026-10-31T15:00:00.000Z */
export function monthStart(at = new Date()) {
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), 1) - KST_OFFSET_MS).toISOString();
}
/** 다음 KST 월 시작(UTC ISO) = 월간 사용량 초기화 시각 */
export function nextMonthStart(at = new Date()) {
const k = new Date(new Date(at).getTime() + KST_OFFSET_MS);
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth() + 1, 1) - KST_OFFSET_MS).toISOString();
}
/** KST 기준 YYYY-MM-DD */
export function kstDate(at = new Date()) { return new Date(new Date(at).getTime() + KST_OFFSET_MS).toISOString().slice(0, 10); }
/** KST 기준 YYYY-MM */
export function kstMonth(at = new Date()) { return kstDate(at).slice(0, 7); }
/** 화면 표시용 "YYYY-MM-DD HH:mm (KST)" */
export function fmtKst(at) { if (!at) return ''; const k = new Date(new Date(at).getTime() + KST_OFFSET_MS); return k.toISOString().slice(0, 16).replace('T', ' ') + ' KST'; }
+59
View File
@@ -0,0 +1,59 @@
/**
* lib/notify.mjs — 알림 센터(§24.1).
*
* 분류(category): billing | site | domain | mail | usage | backup | relocation | security | incident | membership | ops
* 중요도(severity): info | warning | critical. mandatory=1 은 필수 알림(서비스 중단·보안·결제·데이터 삭제 예정) → 외부 이메일도 보낸다.
* 같은 원인의 반복 알림은 incident_key 로 하나의 사건으로 묶어 count·last_at·title/body 를 갱신한다(§24.1).
* link 는 사용자가 조치할 정확한 화면(§2.3). group_id 가 없으면 운영자 알림.
* 외부 이메일은 setMailer(fn) 로 끼운 발송기(포털이 AriMail/Resend 로 보냄)가 담당하며, 실패해도 알림 자체는 남는다.
*/
import { newId } from './crypto.mjs';
import { redact } from './redact.mjs';
import { nowIso, toJson, parseJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_notify_0001', sql: `
CREATE TABLE IF NOT EXISTS notifications (
id TEXT PRIMARY KEY, group_id TEXT, user_id TEXT, category TEXT NOT NULL, severity TEXT NOT NULL DEFAULT 'info', mandatory INTEGER NOT NULL DEFAULT 0,
incident_key TEXT, title TEXT NOT NULL, body TEXT, link TEXT, subject_type TEXT, subject_id TEXT, action_required TEXT,
state TEXT NOT NULL DEFAULT 'open', count INTEGER NOT NULL DEFAULT 1, first_at TEXT NOT NULL, last_at TEXT NOT NULL, read_at TEXT, resolved_at TEXT, email_sent_at TEXT, email_error TEXT, detail_json TEXT
);
CREATE INDEX IF NOT EXISTS notif_group ON notifications(group_id, state, last_at);
CREATE INDEX IF NOT EXISTS notif_incident ON notifications(incident_key, state);` }];
let mailer = null;
/** 외부 이메일 발송기 등록: async ({groupId, userId, title, body, link}) => void */
export function setMailer(fn) { mailer = fn; }
/**
* 알림 만들기(또는 같은 사건 갱신).
* @returns {{id:string, merged:boolean}}
*/
export function notify(store, n) {
const now = nowIso(); const detail = toJson(redact(n.detail || null));
if (n.incidentKey) {
const open = store.get('SELECT id, count FROM notifications WHERE incident_key=? AND state=\'open\'', n.incidentKey);
if (open) { store.run('UPDATE notifications SET count=count+1, last_at=?, title=?, body=?, link=COALESCE(?, link), severity=?, action_required=?, detail_json=?, read_at=NULL WHERE id=?', now, n.title, n.body || null, n.link || null, n.severity || 'info', n.actionRequired || null, detail, open.id); return { id: open.id, merged: true }; }
}
const id = newId('n_');
store.run('INSERT INTO notifications(id, group_id, user_id, category, severity, mandatory, incident_key, title, body, link, subject_type, subject_id, action_required, state, count, first_at, last_at, detail_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,\'open\',1,?,?,?)',
id, n.groupId || null, n.userId || null, n.category, n.severity || 'info', n.mandatory ? 1 : 0, n.incidentKey || null, n.title, n.body || null, n.link || null, n.subjectType || null, n.subjectId || null, n.actionRequired || null, now, now, detail);
// 필수 알림·critical 은 외부 이메일(§24.1). 발송 실패는 email_error 에 남긴다(알림은 유지).
if ((n.mandatory || n.severity === 'critical' || n.email) && mailer) {
Promise.resolve().then(() => mailer({ groupId: n.groupId, userId: n.userId, title: n.title, body: n.body, link: n.link }))
.then(() => store.run('UPDATE notifications SET email_sent_at=? WHERE id=?', nowIso(), id))
.catch((e) => { try { store.run('UPDATE notifications SET email_error=? WHERE id=?', String(e.message || e).slice(0, 300), id); } catch (_) {} });
}
return { id, merged: false };
}
/** 사건 종료(같은 incident_key 의 열린 알림을 resolved 로) */
export function resolveIncident(store, incidentKey, note = '') { return store.run('UPDATE notifications SET state=\'resolved\', resolved_at=?, body=CASE WHEN ?<>\'\' THEN ? ELSE body END WHERE incident_key=? AND state=\'open\'', nowIso(), note, note, incidentKey).changes; }
/** 목록(그룹 기준, 최신순). unreadOnly / category 필터 */
export function listNotifications(store, { groupId, userId, category, unreadOnly = false, limit = 100 } = {}) {
const w = []; const p = [];
if (groupId !== undefined) { w.push(groupId === null ? 'group_id IS NULL' : '(group_id=? OR (group_id IS NULL AND user_id=?))'); if (groupId !== null) p.push(groupId, userId || ''); }
if (category) { w.push('category=?'); p.push(category); }
if (unreadOnly) w.push('read_at IS NULL');
return store.all(`SELECT * FROM notifications ${w.length ? 'WHERE ' + w.join(' AND ') : ''} ORDER BY last_at DESC LIMIT ?`, ...p, Math.min(limit, 500)).map((r) => ({ ...r, detail: parseJson(r.detail_json, null) }));
}
export function markRead(store, id, groupId) { return store.run('UPDATE notifications SET read_at=? WHERE id=? AND (group_id=? OR group_id IS NULL) AND read_at IS NULL', nowIso(), id, groupId || null).changes; }
export function unreadCount(store, groupId) { return store.get('SELECT COUNT(*) AS n FROM notifications WHERE group_id=? AND read_at IS NULL AND state=\'open\'', groupId)?.n || 0; }
+150
View File
@@ -0,0 +1,150 @@
/**
* lib/quota.mjs — 상품 권리(entitlement)·서비스별 배정(allocation)·진행 중 예약(reservation) (§13·§14·§6.4).
*
* 세 층
* entitlement 그룹이 구매해 활성화된 총량(상품정책 버전 참조). 결제·승인 전에는 만들지 않는다(§6.2).
* allocation 사이트·메일 서비스에 배정된 양(사용 중). 실제 사용량(usage)보다 작게 줄일 수 없다.
* reservation 진행 중 작업(최초 공개 배포·메일 생성 등)이 잡아 둔 양. 성공하면 commit → allocation, 실패·취소·되돌리기면 release → 반환.
* 불변식: Σ(allocation) + Σ(reserved) ≤ Σ(active entitlement). reserve/commit/setAllocation 은 BEGIN IMMEDIATE 트랜잭션 안에서 검사해
* 마지막 남은 단위를 두 작업이 동시에 요청하면 하나만 성공한다(§13.3, 검증 27).
* 자원 종류(RESOURCES): sites(개) web_disk(바이트) web_out(바이트/월) mail_storage(바이트) mail_msgs(통/월) mailboxes(개) mail_services(개) source(개) backup(개)
*/
import { newId } from './crypto.mjs';
import { nowIso } from './db.mjs';
export const RESOURCES = Object.freeze({
sites: { unit: 'count', label: '운영 사이트 수' }, web_disk: { unit: 'bytes', label: '웹 디스크' }, web_out: { unit: 'bytes_month', label: '월간 웹 아웃바운드' },
mail_storage: { unit: 'bytes', label: '메일 저장 공간' }, mail_msgs: { unit: 'count_month', label: '월간 메일 통수(송신+수신)' }, mailboxes: { unit: 'count', label: '메일함 수' },
mail_services: { unit: 'count', label: '독립 메일 서비스' }, source: { unit: 'count', label: 'Source 옵션' }, backup: { unit: 'count', label: '사이트 백업 옵션' },
});
export const DEFAULT_WARN_LEVELS = [70, 80, 90, 95];
export const MIGRATIONS = [{ id: 'lib_quota_0001', sql: `
CREATE TABLE IF NOT EXISTS quota_entitlements (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, source_type TEXT, source_id TEXT, product_version_id TEXT,
starts_at TEXT NOT NULL, ends_at TEXT, note TEXT, created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS qe_group ON quota_entitlements(group_id, resource);
CREATE TABLE IF NOT EXISTS quota_allocations (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, amount INTEGER NOT NULL,
state TEXT NOT NULL DEFAULT 'in_use', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, return_due_at TEXT,
UNIQUE(group_id, resource, subject_type, subject_id)
);
CREATE TABLE IF NOT EXISTS quota_reservations (
id TEXT PRIMARY KEY, group_id TEXT NOT NULL, resource TEXT NOT NULL, amount INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'reserved',
subject_type TEXT, subject_id TEXT, job_id TEXT, reason TEXT, created_at TEXT NOT NULL, expires_at TEXT, committed_at TEXT, released_at TEXT, return_due_at TEXT
);
CREATE INDEX IF NOT EXISTS qr_group ON quota_reservations(group_id, resource, state);
CREATE TABLE IF NOT EXISTS quota_usage (
group_id TEXT NOT NULL, resource TEXT NOT NULL, subject_type TEXT NOT NULL, subject_id TEXT NOT NULL, used INTEGER NOT NULL, period_start TEXT, measured_at TEXT NOT NULL, source TEXT,
PRIMARY KEY(group_id, resource, subject_type, subject_id)
);` }];
export class QuotaError extends Error { constructor(code, message, detail) { super(message); this.code = code; this.status = 409; this.detail = detail; } }
/** 권리 부여(주문 승인 후). ends_at 이 없으면 무기한(갱신 때 갱신). */
export function grantEntitlement(store, { groupId, resource, amount, sourceType = 'order', sourceId = null, productVersionId = null, startsAt = null, endsAt = null, note = null }) {
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
const id = newId('qe_'); store.run('INSERT INTO quota_entitlements(id, group_id, resource, amount, source_type, source_id, product_version_id, starts_at, ends_at, note, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, groupId, resource, Math.round(amount), sourceType, sourceId, productVersionId, startsAt || nowIso(), endsAt, note, nowIso()); return id;
}
/** 권리 종료(해지·축소 적용) */
export function endEntitlement(store, id, at = nowIso()) { return store.run('UPDATE quota_entitlements SET ends_at=? WHERE id=? AND (ends_at IS NULL OR ends_at>?)', at, id, at).changes; }
const sumSql = { purchased: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_entitlements WHERE group_id=? AND resource=? AND starts_at<=? AND (ends_at IS NULL OR ends_at>?)', allocated: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_allocations WHERE group_id=? AND resource=? AND state IN (\'in_use\',\'returning\')', reserved: 'SELECT COALESCE(SUM(amount),0) AS n FROM quota_reservations WHERE group_id=? AND resource=? AND state=\'reserved\' AND (expires_at IS NULL OR expires_at>?)' };
/** 자원 하나의 수치 */
export function figures(store, groupId, resource, at = nowIso()) {
const purchased = store.get(sumSql.purchased, groupId, resource, at, at).n; const allocated = store.get(sumSql.allocated, groupId, resource).n; const reserved = store.get(sumSql.reserved, groupId, resource, at).n;
const used = store.get('SELECT COALESCE(SUM(used),0) AS n FROM quota_usage WHERE group_id=? AND resource=?', groupId, resource).n;
return { resource, unit: RESOURCES[resource].unit, label: RESOURCES[resource].label, purchased, allocated, reserved, unallocated: Math.max(0, purchased - allocated - reserved), used };
}
/** 그룹 전체 요약(§13.2) */
export function summary(store, groupId, at = nowIso()) { return Object.keys(RESOURCES).map((r) => figures(store, groupId, r, at)); }
/**
* 예약(§13.3). 트랜잭션 안에서 가용량을 검사한다. 부족하면 QuotaError('quota_insufficient', shortage).
* 같은 subject·resource 의 활성 예약이 있으면 그것을 돌려준다(중복 예약 금지).
*/
export function reserve(store, { groupId, resource, amount, subjectType = null, subjectId = null, jobId = null, reason = null, ttlMs = 24 * 3600 * 1000 }) {
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
amount = Math.round(amount); if (amount <= 0) throw new QuotaError('bad_amount', '예약량은 0보다 커야 합니다');
return store.tx(() => {
if (subjectId) { const ex = store.get('SELECT * FROM quota_reservations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=? AND state=\'reserved\'', groupId, resource, subjectType, subjectId); if (ex) return { ...ex, existing: true }; }
const f = figures(store, groupId, resource);
if (amount > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label}이(가) 부족합니다 (필요 ${amount}, 사용 가능 ${f.unallocated})`, { resource, requested: amount, available: f.unallocated, shortage: amount - f.unallocated, purchased: f.purchased, allocated: f.allocated, reserved: f.reserved });
const id = newId('qr_'); const now = nowIso();
store.run('INSERT INTO quota_reservations(id, group_id, resource, amount, state, subject_type, subject_id, job_id, reason, created_at, expires_at) VALUES(?,?,?,?,\'reserved\',?,?,?,?,?,?)', id, groupId, resource, amount, subjectType, subjectId, jobId, reason, now, ttlMs ? new Date(Date.now() + ttlMs).toISOString() : null);
return store.get('SELECT * FROM quota_reservations WHERE id=?', id);
});
}
/** 예약 확정 → 배정(in_use). 배정이 이미 있으면 양을 더한다. */
export function commit(store, reservationId) {
return store.tx(() => {
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) throw new QuotaError('reservation_not_found', '예약이 없습니다');
if (r.state === 'in_use') return r;
if (r.state !== 'reserved') throw new QuotaError('reservation_not_active', `예약 상태(${r.state})에서는 확정할 수 없습니다`);
const now = nowIso();
store.run('UPDATE quota_reservations SET state=\'in_use\', committed_at=? WHERE id=?', now, r.id);
const a = store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id);
if (a) store.run('UPDATE quota_allocations SET amount=amount+?, state=\'in_use\', updated_at=? WHERE id=?', r.amount, now, a.id);
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), r.group_id, r.resource, r.subject_type || 'none', r.subject_id || r.id, r.amount, now, now);
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
});
}
/** 예약 반환(실패·취소·되돌리기). returning=true 면 정리 전 '반환 중' 으로 두고 returnDueAt 을 표시한다(§11.2 "반환 예정 시각"). */
export function release(store, reservationId, { returning = false, returnDueAt = null } = {}) {
const r = store.get('SELECT * FROM quota_reservations WHERE id=?', reservationId); if (!r) return null;
if (r.state === 'returned') return r;
if (returning) store.run('UPDATE quota_reservations SET state=\'returning\', return_due_at=? WHERE id=?', returnDueAt, r.id);
else store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', nowIso(), r.id);
return store.get('SELECT * FROM quota_reservations WHERE id=?', r.id);
}
/** 만료된 예약 자동 반환(주기 작업). 반환된 예약 목록을 돌려줘 호출자가 알림을 만든다. */
export function expireReservations(store, at = nowIso()) {
const rows = store.all('SELECT * FROM quota_reservations WHERE state=\'reserved\' AND expires_at IS NOT NULL AND expires_at<=?', at);
for (const r of rows) store.run('UPDATE quota_reservations SET state=\'returned\', released_at=? WHERE id=?', at, r.id);
return rows;
}
/** 대상의 배정 읽기 */
export function allocationOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_allocations WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
/**
* 배정량 변경(§13.3). 늘릴 때는 미배정량 안에서, 줄일 때는 실제 사용량(usedNow)과 이번 달 누적(monthUsed, 월간 자원)보다 작게 할 수 없다.
* @returns {{allocation:object, before:number, after:number}}
*/
export function setAllocation(store, { groupId, resource, subjectType, subjectId, amount, usedNow = 0, monthUsed = 0 }) {
if (!RESOURCES[resource]) throw new QuotaError('unknown_resource', `알 수 없는 자원: ${resource}`);
amount = Math.round(amount); if (amount < 0) throw new QuotaError('bad_amount', '배정량은 0 이상이어야 합니다');
return store.tx(() => {
const a = allocationOf(store, groupId, resource, subjectType, subjectId); const before = a ? a.amount : 0; const f = figures(store, groupId, resource);
const floor = RESOURCES[resource].unit.endsWith('_month') ? Math.max(usedNow, monthUsed) : usedNow;
if (amount < floor) throw new QuotaError('allocation_below_usage', `현재 사용량(${floor})보다 작게 줄일 수 없습니다. 먼저 ${floor - amount} 만큼 정리하세요`, { resource, requested: amount, floor, cleanupNeeded: floor - amount });
const delta = amount - before;
if (delta > f.unallocated) throw new QuotaError('quota_insufficient', `${f.label} 미배정량(${f.unallocated})을 넘습니다`, { resource, requested: delta, available: f.unallocated, shortage: delta - f.unallocated });
const now = nowIso();
if (a) store.run('UPDATE quota_allocations SET amount=?, state=\'in_use\', updated_at=? WHERE id=?', amount, now, a.id);
else store.run('INSERT INTO quota_allocations(id, group_id, resource, subject_type, subject_id, amount, state, created_at, updated_at) VALUES(?,?,?,?,?,?,\'in_use\',?,?)', newId('qa_'), groupId, resource, subjectType, subjectId, amount, now, now);
return { allocation: allocationOf(store, groupId, resource, subjectType, subjectId), before, after: amount };
});
}
/** 배정 반환 시작(반환 중) / 완료(삭제). §14 사이트 수 반환은 모든 조건이 충족된 뒤 finish 를 부른다. */
export function beginReturn(store, allocationId, returnDueAt = null) { return store.run('UPDATE quota_allocations SET state=\'returning\', return_due_at=?, updated_at=? WHERE id=? AND state=\'in_use\'', returnDueAt, nowIso(), allocationId).changes; }
export function finishReturn(store, allocationId) { return store.run('DELETE FROM quota_allocations WHERE id=?', allocationId).changes; }
export function cancelReturn(store, allocationId) { return store.run('UPDATE quota_allocations SET state=\'in_use\', return_due_at=NULL, updated_at=? WHERE id=? AND state=\'returning\'', nowIso(), allocationId).changes; }
/** 실제 사용량 기록(계측 결과). 월간 자원은 period_start(KST 월 시작) 를 함께 둔다. */
export function recordUsage(store, { groupId, resource, subjectType, subjectId, used, periodStart = null, source = null, measuredAt = nowIso() }) {
store.run('INSERT INTO quota_usage(group_id, resource, subject_type, subject_id, used, period_start, measured_at, source) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(group_id, resource, subject_type, subject_id) DO UPDATE SET used=excluded.used, period_start=excluded.period_start, measured_at=excluded.measured_at, source=excluded.source', groupId, resource, subjectType, subjectId, Math.round(used), periodStart, measuredAt, source);
}
export function usageOf(store, groupId, resource, subjectType, subjectId) { return store.get('SELECT * FROM quota_usage WHERE group_id=? AND resource=? AND subject_type=? AND subject_id=?', groupId, resource, subjectType, subjectId) || null; }
/** 서비스별 배정·사용 목록(화면: 사이트별 배정량·사용량) */
export function listAllocations(store, groupId, resource = null) {
return store.all(`SELECT a.*, u.used, u.measured_at FROM quota_allocations a LEFT JOIN quota_usage u ON u.group_id=a.group_id AND u.resource=a.resource AND u.subject_type=a.subject_type AND u.subject_id=a.subject_id WHERE a.group_id=? ${resource ? 'AND a.resource=?' : ''} ORDER BY a.resource, a.created_at`, ...(resource ? [groupId, resource] : [groupId]));
}
export function listReservations(store, groupId, { active = true } = {}) { return store.all(`SELECT * FROM quota_reservations WHERE group_id=? ${active ? 'AND state IN (\'reserved\',\'returning\')' : ''} ORDER BY created_at DESC`, groupId); }
/**
* 경고 구간(§13.4). 넘은 가장 높은 구간을 돌려준다(0 = 정상, 100 = 제한).
* @param {number} used @param {number} limit 0 이면 제한 없음 → 0
*/
export function warnLevel(used, limit, levels = DEFAULT_WARN_LEVELS) {
if (!limit || limit <= 0) return 0; const pct = (used / limit) * 100; if (pct >= 100) return 100;
let lvl = 0; for (const l of levels) if (pct >= l) lvl = l; return lvl;
}
export const percent = (used, limit) => (limit > 0 ? Math.min(999, Math.round((used / limit) * 1000) / 10) : 0);
+45
View File
@@ -0,0 +1,45 @@
/**
* lib/redact.mjs — 비밀값 가리기(§37).
*
* 감사 기록·알림·작업 결과·오류 메시지·로그에 들어가는 모든 객체는 이 함수를 거친다.
* - 키 이름이 비밀로 보이면(password, secret, token, key, credential, cookie, authorization, recovery, otp, privkey …) 값을 '[redacted]' 로 바꾼다.
* - 문자열 값이 비밀 형태(sk-…, re_…, au_…, JWT, 40자 이상 base64url/hex, PEM 블록)면 가린다.
* - 깊이 6 까지 재귀. 배열·객체·문자열 모두.
*/
const KEY_RE = /pass(word|wd)?|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|session|vault|dkim|\bkey\b/i;
const VALUE_RES = [
/^(sk|rk|re|au|aw|ag|ghp|gho|xox[a-z])[-_][A-Za-z0-9_-]{16,}$/, // 서비스 키 접두어 형식
/^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$/, // JWT
/-----BEGIN [A-Z ]*PRIVATE KEY-----/, // PEM 개인키
/^[A-Za-z0-9_-]{40,}$/, // 긴 무작위 토큰
/^[a-f0-9]{48,}$/i, // 긴 hex 비밀
];
const KEEP_KEYS = /^(id|_id|site_id|group_id|user_id|job_id|idempotency_?key|incident_?key|public_?key_?fingerprint|key_?name|keyName|kind|resource)$/i;
/** 값 하나가 비밀 형태인지 */
export function looksSecret(v) { if (typeof v !== 'string' || v.length < 12) return false; return VALUE_RES.some((re) => re.test(v)); }
/**
* 객체/배열/문자열에서 비밀값을 가린다. 원본은 바꾸지 않는다.
* @param {*} v
* @param {number} depth 내부 재귀 깊이
*/
export function redact(v, depth = 0) {
if (depth > 6) return '[depth]';
if (v == null) return v;
if (typeof v === 'string') return looksSecret(v) ? '[redacted]' : v;
if (Array.isArray(v)) return v.map((x) => redact(x, depth + 1));
if (typeof v === 'object') {
const out = {};
for (const [k, val] of Object.entries(v)) {
if (!KEEP_KEYS.test(k) && KEY_RE.test(k)) { out[k] = val == null || val === '' ? val : '[redacted]'; continue; }
out[k] = redact(val, depth + 1);
}
return out;
}
return v;
}
/** 오류 객체를 기록용 안전 문자열로 */
export function safeError(e) { const m = e && e.message ? String(e.message) : String(e || ''); return redact(m.replace(/(Bearer\s+)[A-Za-z0-9._-]+/g, '$1[redacted]')); }
/** 비밀값의 앞뒤만 보이는 힌트(예: au_12…9f) — 화면에 "등록됨" 을 보여 주되 값은 못 알아보게 */
export function hint(secret) { const s = String(secret || ''); if (s.length < 10) return s ? '••••' : ''; return `${s.slice(0, 4)}…${s.slice(-2)}`; }
+49
View File
@@ -0,0 +1,49 @@
/**
* lib/serviceauth.mjs — 서비스 간 호출 인증(포털 ↔ HAL, HAL ↔ Agent 공통).
*
* 요청 헤더
* X-Service-Key-Id : 키 식별자(예: 'portal', 'hal', 'agent:<agentId>')
* X-Service-Ts : 요청 시각(ms). ±5분 밖이면 거부(재전송 공격 완화)
* X-Service-Sign : HMAC-SHA256(secret, `${method}|${path}|${ts}|${sha256(body)}`) base64url
* 비밀은 양쪽 보관소(lib/vault)에 'service:<keyId>' 로 둔다. 검증은 상수 시간 비교.
*/
import { hmac, sha256, safeEqual } from './crypto.mjs';
const SKEW_MS = 5 * 60 * 1000;
/** 서명 문자열 */
export const signingString = (method, path, ts, body) => `${String(method).toUpperCase()}|${path}|${ts}|${sha256(body == null ? '' : typeof body === 'string' ? body : JSON.stringify(body))}`;
/** 보낼 요청의 헤더 만들기 */
export function signHeaders(keyId, secret, method, path, body) { const ts = String(Date.now()); return { 'X-Service-Key-Id': keyId, 'X-Service-Ts': ts, 'X-Service-Sign': hmac(secret, signingString(method, path, ts, body)), 'Content-Type': 'application/json' }; }
/**
* 받은 요청 검증. secretOf(keyId) → secret | null. rawBody 는 본문 원문(문자열).
* @returns {{ok:boolean, keyId?:string, reason?:string}}
*/
export function verifyRequest({ method, path, headers, rawBody }, secretOf) {
const keyId = String(headers['x-service-key-id'] || ''); const ts = Number(headers['x-service-ts'] || 0); const sign = String(headers['x-service-sign'] || '');
if (!keyId || !ts || !sign) return { ok: false, reason: 'missing_headers' };
if (Math.abs(Date.now() - ts) > SKEW_MS) return { ok: false, reason: 'clock_skew' };
const secret = secretOf(keyId); if (!secret) return { ok: false, reason: 'unknown_key' };
return safeEqual(hmac(secret, signingString(method, path, String(ts), rawBody || '')), sign) ? { ok: true, keyId } : { ok: false, reason: 'bad_signature' };
}
/**
* 서명된 fetch. 응답은 { status, json }. 네트워크 오류는 code='service_unreachable' 로 던진다(호출자가 '정보 확인 지연' 표시).
*/
export async function serviceFetch(baseUrl, keyId, secret, method, path, body, { timeoutMs = 20000, fetchImpl = fetch } = {}) {
const text = body == null ? undefined : JSON.stringify(body);
let r; try { r = await fetchImpl(baseUrl.replace(/\/$/, '') + path, { method, headers: signHeaders(keyId, secret, method, path, text), body: text, signal: AbortSignal.timeout(timeoutMs) }); }
catch (e) { const err = new Error(`서비스에 연결할 수 없습니다 (${path})`); err.code = 'service_unreachable'; err.cause = e; throw err; }
let json = null; try { json = await r.json(); } catch (_) {}
return { status: r.status, json, ok: r.ok && !(json && json.ok === false) };
}
/** lib/http ctx 용: 본문 원문을 읽어 검증한다(본문은 ctx.body() 가 다시 쓸 수 있게 캐시). */
export async function verifyCtx(ctx, secretOf) {
const raw = await readRaw(ctx);
return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: raw }, secretOf);
}
async function readRaw(ctx) {
if (ctx._raw !== undefined) return ctx._raw;
const chunks = []; for await (const c of ctx.req) chunks.push(c); ctx._raw = Buffer.concat(chunks).toString('utf8');
const parsed = ctx._raw ? JSON.parse(ctx._raw) : {};
ctx.body = async () => parsed; // 이후 핸들러는 캐시된 본문을 본다
return ctx._raw;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* lib/session.mjs — DB 세션(기기 목록·개별/전체 종료 §3.2)과 CSRF.
*
* 쿠키에는 무작위 토큰만 두고 서버는 sha256 해시로 찾는다(DB 가 유출돼도 세션을 위조할 수 없다).
* kind: 'customer'(고객 포털) | 'operator'(운영자 콘솔) | 'setup'. 종류가 다른 세션으로는 상대편 화면을 열 수 없다(§25).
* CSRF: 세션마다 무작위 csrf 값을 두고 변경 요청은 `X-CSRF` 헤더로 보낸다(상수 시간 비교).
* 재인증(§25 고위험 작업): reauth_at 을 두고 requireRecentAuth(maxAgeMs) 로 검사한다.
*/
import { randomToken, sha256, safeEqual, newId } from './crypto.mjs';
import { nowIso, parseJson, toJson } from './db.mjs';
export const MIGRATIONS = [{ id: 'lib_sessions_0001', sql: `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL, kind TEXT NOT NULL,
csrf TEXT NOT NULL, created_at TEXT NOT NULL, expires_at TEXT NOT NULL, last_seen_at TEXT NOT NULL, reauth_at TEXT,
ip TEXT, ua TEXT, label TEXT, revoked_at TEXT, data_json TEXT
);
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id, kind);` }];
const COOKIE = { customer: 'aw_session', operator: 'aw_ops', setup: 'aw_setup' };
export const cookieName = (kind) => COOKIE[kind] || 'aw_session';
/** 세션 만들기. 반환 { id, token(원문, 쿠키용), csrf, expiresAt } */
export function createSession(store, { userId, kind = 'customer', ip = '', ua = '', days = 30, data = null, reauth = true }) {
const token = randomToken(32); const id = newId('s_'); const now = nowIso();
const expiresAt = new Date(Date.now() + days * 86400000).toISOString(); const csrf = randomToken(24);
store.run('INSERT INTO sessions(id, token_hash, user_id, kind, csrf, created_at, expires_at, last_seen_at, reauth_at, ip, ua, label, data_json) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
id, sha256(token), userId, kind, csrf, now, expiresAt, now, reauth ? now : null, ip, String(ua || '').slice(0, 200), deviceLabel(ua), toJson(data));
return { id, token, csrf, expiresAt };
}
/** 토큰(쿠키 값)으로 세션을 찾는다. 만료·종료된 세션은 null. 찾으면 last_seen 을 갱신(1분에 한 번만). */
export function resolveSession(store, token, kind) {
if (!token) return null;
const s = store.get('SELECT * FROM sessions WHERE token_hash=? AND kind=?', sha256(token), kind);
if (!s || s.revoked_at || Date.parse(s.expires_at) < Date.now()) return null;
if (Date.now() - Date.parse(s.last_seen_at) > 60000) store.run('UPDATE sessions SET last_seen_at=? WHERE id=?', nowIso(), s.id);
return { ...s, data: parseJson(s.data_json, {}) };
}
/** 세션 데이터(현재 그룹 등) 갱신 */
export function updateSessionData(store, id, patch) { const s = store.get('SELECT data_json FROM sessions WHERE id=?', id); const d = { ...parseJson(s?.data_json, {}), ...patch }; store.run('UPDATE sessions SET data_json=? WHERE id=?', toJson(d), id); return d; }
/** 재인증 기록(고위험 작업 전 비밀번호 재확인) */
export function markReauth(store, id) { store.run('UPDATE sessions SET reauth_at=? WHERE id=?', nowIso(), id); }
/** 최근 재인증 여부 */
export function recentlyAuthed(session, maxAgeMs = 10 * 60 * 1000) { return !!(session && session.reauth_at && Date.now() - Date.parse(session.reauth_at) < maxAgeMs); }
/** 개별 세션 종료 */
export function revokeSession(store, id, userId) { return store.run('UPDATE sessions SET revoked_at=? WHERE id=? AND user_id=? AND revoked_at IS NULL', nowIso(), id, userId).changes; }
/** 다른 모든 세션 종료(현재 세션 제외 가능). kind 를 주면 그 종류만 */
export function revokeOtherSessions(store, userId, keepId = null, kind = null) {
return store.run(`UPDATE sessions SET revoked_at=? WHERE user_id=? AND revoked_at IS NULL ${keepId ? 'AND id<>?' : ''} ${kind ? 'AND kind=?' : ''}`, ...[nowIso(), userId, ...(keepId ? [keepId] : []), ...(kind ? [kind] : [])]).changes;
}
/** 사용자의 활성 세션 목록(기기 화면용, 토큰 없음) */
export function listSessions(store, userId, kind) {
return store.all('SELECT id, created_at, last_seen_at, expires_at, ip, label FROM sessions WHERE user_id=? AND kind=? AND revoked_at IS NULL AND expires_at>? ORDER BY last_seen_at DESC', userId, kind, nowIso());
}
/** CSRF 검사 */
export function csrfOk(session, token) { return !!(session && token && safeEqual(session.csrf, token)); }
/** UA 에서 기기 표시 이름 (대략) */
function deviceLabel(ua) {
const s = String(ua || ''); const os = /Windows/.test(s) ? 'Windows' : /Android/.test(s) ? 'Android' : /iPhone|iPad/.test(s) ? 'iOS' : /Mac OS/.test(s) ? 'macOS' : /Linux/.test(s) ? 'Linux' : '기기';
const br = /Edg\//.test(s) ? 'Edge' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /Firefox\//.test(s) ? 'Firefox' : '브라우저';
return `${os} · ${br}`;
}
/** 만료·종료된 세션 정리(주기 작업) */
export function purgeSessions(store, olderThanDays = 7) { const cut = new Date(Date.now() - olderThanDays * 86400000).toISOString(); return store.run('DELETE FROM sessions WHERE expires_at<? OR (revoked_at IS NOT NULL AND revoked_at<?)', cut, cut).changes; }
+53
View File
@@ -0,0 +1,53 @@
/**
* lib/status.mjs — 사용자용 상태 어휘(§2.2)와 전이 규칙.
*
* 세 종류의 상태를 분리한다(§2.2 "하나의 상태로 합치지 않는다").
* JOB_STATUS 작업(배포·복원·이전·주문…)의 진행 상태 — 작업 엔진(lib/jobs)이 쓴다
* SERVICE_STATE 서비스(사이트·메일)의 생명주기 상태(§23.3)
* HEALTH 외부 확인 건강 상태(§31)
* 코드(영문)로 저장하고 화면은 label() 로 한국어를 얻는다.
*/
export const JOB_STATUS = Object.freeze({
NOT_READY: 'not_ready', NEEDS_SETUP: 'needs_setup', AWAITING_APPROVAL: 'awaiting_approval', AWAITING_PAYMENT: 'awaiting_payment',
QUEUED: 'queued', RUNNING: 'running', AWAITING_EXTERNAL: 'awaiting_external', VERIFYING: 'verifying', PAUSED: 'paused',
DONE: 'done', FAILED: 'failed', REVERTING: 'reverting', REVERTED: 'reverted', NEEDS_USER: 'needs_user', CANCELLED: 'cancelled',
});
export const JOB_LABEL = Object.freeze({
not_ready: '준비 전', needs_setup: '설정 필요', awaiting_approval: '승인 대기', awaiting_payment: '결제 대기', queued: '처리 대기', running: '진행 중',
awaiting_external: '외부 작업 대기', verifying: '검증 중', paused: '일시 중지', done: '완료', failed: '실패', reverting: '되돌리는 중', reverted: '되돌림 완료',
needs_user: '사용자 확인 필요', cancelled: '취소됨',
});
/** 끝난 상태(더 바뀌지 않음) */
export const TERMINAL = new Set(['done', 'failed', 'reverted', 'cancelled']);
/** 사용자가 취소할 수 있는 상태 */
export const CANCELLABLE = new Set(['not_ready', 'needs_setup', 'awaiting_approval', 'awaiting_payment', 'queued', 'awaiting_external', 'needs_user', 'paused']);
export const SERVICE_STATE = Object.freeze({
DRAFT: 'draft', BUILDING: 'building', DEPLOYING: 'deploying', LIVE: 'live', NEEDS_DOMAIN: 'needs_domain', PAUSED: 'paused',
CANCEL_SCHEDULED: 'cancel_scheduled', READONLY_RETENTION: 'readonly_retention', PENDING_DELETE: 'pending_delete', DELETED: 'deleted', DELETE_HOLD: 'delete_hold',
});
export const SERVICE_LABEL = Object.freeze({
draft: '초안', building: '제작 중', deploying: '배포 중', live: '운영 중', needs_domain: '도메인 설정 필요', paused: '일시 중지', cancel_scheduled: '해지 예약',
readonly_retention: '읽기 전용 보존', pending_delete: '삭제 대기', deleted: '삭제 완료', delete_hold: '삭제 보류',
});
export const HEALTH = Object.freeze({ OK: 'ok', DEGRADED: 'degraded', PARTIAL: 'partial_outage', DOWN: 'down', UNKNOWN: 'unknown', STALE: 'stale', NOT_PROVIDED: 'not_provided' });
export const HEALTH_LABEL = Object.freeze({ ok: '정상', degraded: '성능 저하', partial_outage: '일부 장애', down: '중단', unknown: '확인 전', stale: '정보 확인 지연', not_provided: '제공되지 않음' });
/** 배포 완료 세분(§11.2) */
export const DEPLOY_VERIFY = Object.freeze({ VERIFIED: 'verified', NEEDS_DOMAIN: 'needs_domain', PUBLIC: 'public' });
export const DEPLOY_VERIFY_LABEL = Object.freeze({ verified: '배포 검증 완료', needs_domain: '도메인 설정 필요', public: '공개 운영 중' });
/** 할당 상태(§13.3) */
export const ALLOC_LABEL = Object.freeze({ available: '사용 가능', reserved: '예약됨', in_use: '사용 중', returning: '반환 중', returned: '반환됨' });
/** 코드 → 한국어. 알 수 없는 코드는 그대로(화면에서 눈에 띄게) */
export function label(kind, code) {
const map = { job: JOB_LABEL, service: SERVICE_LABEL, health: HEALTH_LABEL, deploy: DEPLOY_VERIFY_LABEL, alloc: ALLOC_LABEL }[kind] || {};
return map[code] || code || '';
}
/**
* 상태 정보가 오래됐는지(§2.2 `정보 확인 지연`). 마지막 갱신으로부터 staleAfterMs 가 지났으면 true.
* @param {string} updatedAt ISO
* @param {number} staleAfterMs 기본 5분
*/
export function isStale(updatedAt, staleAfterMs = 5 * 60 * 1000, now = Date.now()) { if (!updatedAt) return true; return now - Date.parse(updatedAt) > staleAfterMs; }
+26
View File
@@ -0,0 +1,26 @@
/**
* lib/test.mjs — 작은 테스트 러너(의존성 없음). 형제 제품(AriMail)과 같은 방식: 파일을 node 로 직접 실행하면 순서대로 돈다.
* import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs';
* await test('이름', async () => { ... }); 실패하면 즉시 요약을 내고 종료 코드 1.
* done(); 마지막에 요약 출력.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
let passed = 0; let failed = 0; const dirs = [];
export async function test(name, fn) {
const t0 = Date.now();
try { await fn(); passed++; console.log(` ✔ ${name} (${Date.now() - t0}ms)`); }
catch (e) { failed++; console.error(` ✘ ${name}\n ${e && e.stack ? e.stack.split('\n').slice(0, 6).join('\n ') : e}`); process.exitCode = 1; }
}
export function eq(a, b, msg = '') { const sa = JSON.stringify(a); const sb = JSON.stringify(b); if (sa !== sb) throw new Error(`${msg ? msg + ': ' : ''}기대 ${sb} 실제 ${sa}`); }
export function ok(v, msg = '') { if (!v) throw new Error(`${msg || '참이어야 합니다'} (실제 ${JSON.stringify(v)})`); }
export async function rejects(fn, codeOrRe, msg = '') {
try { await fn(); } catch (e) { if (codeOrRe == null) return e; const s = `${e.code || ''} ${e.message || ''}`; if (codeOrRe instanceof RegExp ? codeOrRe.test(s) : (e.code === codeOrRe || String(e.message).includes(codeOrRe))) return e; throw new Error(`${msg || '다른 오류'}: 기대 ${codeOrRe} 실제 ${s}`); }
throw new Error(`${msg || '예외가 나야 합니다'}`);
}
/** 테스트용 임시 디렉터리(종료 시 삭제) */
export function tmpDir(prefix = 'aw-test-') { const d = mkdtempSync(join(tmpdir(), prefix)); dirs.push(d); return d; }
export function done() { for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch (_) {} } console.log(`${failed ? '✘' : '✔'} 통과 ${passed} · 실패 ${failed}`); if (failed) process.exit(1); }
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+41
View File
@@ -0,0 +1,41 @@
/**
* lib/vault.mjs — 비밀값 보관소(AES-256-GCM).
*
* 서비스 키·API 키·Agent 비밀·SSO 공유 비밀처럼 "저장 후 다시 볼 수 없고 재발급만 되는" 값을 둔다(§37).
* - 암호화 키: 데이터 디렉터리의 `vault.key`(32바이트, 0600). 없으면 처음 열 때 만든다. 환경 변수 ARIWEB_VAULT_KEY(hex) 가 있으면 그것을 쓴다(재해복구 때 같은 키로 복원).
* - 저장 파일: `vault.json` — { items: { name: { iv, tag, data, hint, updatedAt, kind } } }. 원자적 쓰기(tmp → rename).
* - API: set(name, secret, {kind}) / get(name) / has(name) / remove(name) / list() (이름·힌트·시각만, 값 없음)
*/
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { hint } from './redact.mjs';
export function openVault(dir) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
const keyFile = join(dir, 'vault.key'); const file = join(dir, 'vault.json');
let key;
if (process.env.ARIWEB_VAULT_KEY) key = Buffer.from(process.env.ARIWEB_VAULT_KEY, 'hex');
else if (existsSync(keyFile)) key = Buffer.from(readFileSync(keyFile, 'utf8').trim(), 'hex');
else { key = randomBytes(32); writeFileSync(keyFile, key.toString('hex'), { mode: 0o600 }); }
if (key.length !== 32) throw new Error('vault 키 길이가 올바르지 않습니다(32바이트 hex 필요)');
const load = () => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch (_) { return { items: {} }; } };
const save = (d) => { const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(d, null, 1), { mode: 0o600 }); renameSync(tmp, file); try { chmodSync(file, 0o600); } catch (_) {} };
return {
dir, file,
set(name, secret, { kind = 'secret' } = {}) {
const iv = randomBytes(12); const c = createCipheriv('aes-256-gcm', key, iv);
const data = Buffer.concat([c.update(String(secret), 'utf8'), c.final()]);
const d = load(); d.items[name] = { kind, iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64'), hint: hint(secret), updatedAt: new Date().toISOString() }; save(d);
return { name, hint: d.items[name].hint };
},
get(name) {
const it = load().items[name]; if (!it) return null;
const dc = createDecipheriv('aes-256-gcm', key, Buffer.from(it.iv, 'base64')); dc.setAuthTag(Buffer.from(it.tag, 'base64'));
return Buffer.concat([dc.update(Buffer.from(it.data, 'base64')), dc.final()]).toString('utf8');
},
has: (name) => !!load().items[name],
remove(name) { const d = load(); const had = !!d.items[name]; delete d.items[name]; save(d); return had; },
list() { return Object.entries(load().items).map(([name, it]) => ({ name, kind: it.kind, hint: it.hint, updatedAt: it.updatedAt })); },
};
}
+68
View File
@@ -0,0 +1,68 @@
# AriMail — mail.arihakdang.com / mail.heart-m.com
**AriMail**(1.0.0 까지의 이름은 mail-gateway, 저장소 https://source.ibit.kr/OZ-Group/AriMail)은 웹 메일함과 IMAP·POP3·SMTP 서버를 갖춘 메일 게이트웨이입니다.
수신·발신은 **Resend** 또는 **내장 Haraka**(직접 수신 MX / 직접 발송) 또는 외부 SMTP 중계 중에서 설정으로 고릅니다. www.arihakdang.com 관리자 메일함(`server/mailbox.mjs`)을 참고해
별도 사이트로 분리했고, 다음을 더합니다.
- **설정 파일과 초기 설정 페이지**(1.0.0~): Resend 키·중계 서버·Haraka·사이트 정보 같은 운영 설정은 `data/config.json` 에 두고, 배포 뒤 전용 포트(8791, 토큰)의 초기 설정 페이지나 `/admin → 메일 서비스 설정` 에서 채웁니다(재시작 없음). 환경 파일에는 배포에 필요한 값만 남습니다.
- **발송 모듈화**(1.0.0~): `server/providers/`(resend · smtp 중계 · 내장 Haraka)와 `sendMail()` 한 지점. 새 서비스는 모듈 하나를 더해 끼웁니다.
- **자동 설정·DNS 모듈**(1.0.5~): 설정 화면의 "자동 설정" 이 Resend 도메인 등록·검증·웹훅(비밀키 자동 저장), DNS 레코드(Google Cloud DNS / AWS Route 53 자동 적용, 그 밖에는 안내), 내장 Haraka 준비(DKIM·PTR·설치)를 진단·실행하고 상황별 안내 끝에 "메일 사용 준비 완료" 를 알립니다.
- **내장 Haraka**(1.0.0~): `haraka/`(run.mjs 관리 프로세스 + arimail 플러그인)와 `deploy/haraka-apply.sh`. 수신(25 → 게이트웨이 저장)과 발신(로컬 제출 포트 → MX 직접 배달, DKIM 서명)을 설정으로 켭니다.
- **이중 수신**: Resend 웹훅(`email.received`) + 1시간마다 수신 목록 동기화(웹 메일함을 열어 두면 1분마다 빠른 확인). 같은 메일은 한 번만 저장.
- **원본 보관**: Resend 가 서명 URL 로 주는 원본 `.eml` 을 그대로 저장(없으면 본문·헤더·첨부로 재구성). 첨부 바이트도 함께 보관.
- **웹 메일함**: 3단(폴더·목록·읽기 창) 레이아웃, 중요 표시·읽지 않음·첨부 필터, 일괄 삭제/이동/읽음, 답장·전체답장·전달, 원본 다운로드, 첨부.
- **HTML 편집기**: 글꼴·크기·색·강조·정렬·목록·링크·인용·구분선·본문 이미지(cid 인라인 첨부로 발송)·HTML 소스 편집, 임시 저장 자동화.
- **관리자 페이지** `/admin`(0.8.4~): 별도 로그인(같은 관리자 계정)을 거치는 고정 주소. 게이트웨이 사용자 계정(로그인 ID·이름·비밀번호·역할·상태·POP/IMAP 허용)을 추가·수정·삭제하고 서버 상태를 봅니다. 메일함 주소는 `로그인ID@도메인` 으로 자동 지정. 웹·POP·IMAP·SMTP 동일 인증.
- **IMAP**: `mail.arihakdang.com:993`(TLS)/`143`(STARTTLS). INBOX/Sent/Drafts/Trash 가 웹 메일함과 같은 저장소. 읽음·플래그·이동·삭제 공유, IDLE·MOVE·UIDPLUS·APPEND.
- **POP3**: `mail.arihakdang.com:995`(TLS)/`110`(STLS, 관리자 설정에 따라 일반 접속 로그인도 허용 — Gmail 의 POP 가져오기용). 계정은 www 관리자 계정 그대로. 한 번 내려준 메일은 다시 주지 않음.
- **SMTP 제출**(0.8.1~): `mail.arihakdang.com:465`(TLS)/`587`(STARTTLS). 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송은 서버 내부의 Resend API 키로 합니다(키는 사용자에게 노출되지 않음). 보낸 메일은 보낸 메일함에 바로 저장.
- **기한 없는 보관**(0.8.0~): 메일은 휴지통에서 영구 삭제할 때만 원본·첨부와 함께 지우고, 지운 메일은 재수신하지 않음.
- **설정 화면**: IMAP·SMTP·POP 메일 프로그램 설정 안내, 화면 기본값, 계정별 POP/IMAP 접속 허용 스위치. 로그인 화면의 (?) 접속 안내. 화면 상단에 버전 표시.
- **메일 전달·자동 응답**(0.8.16~): 설정 화면에서 받은 메일을 지정 주소(최대 5개)로 전부 전달하고, 지정한 메시지로 자동 회신(기한 없음 또는 날짜·시간 단위 기간)합니다.
되풀이 방지(전달 표시 헤더·`Auto-Submitted`), 같은 상대 24시간 1회, 자동 발송 메일 제외, 처리 기록은 메일 상세에 표시.
- **독립 설치**(0.8.18~): `WWW_DB_FILE` 을 비우면 www 사이트 없이 게이트웨이 계정만으로 동작합니다(첫 관리자 `admin`/`adminpasswd` 자동 생성(0.8.24~), `server/user-cli.mjs` 로 계정 추가·비밀번호 재설정, `SITE_TITLE`/`SITE_BRAND` 로 화면 이름 변경).
- **비밀번호 정책**(0.8.24~): 관리자 페이지에서 최소 글자 수(6~64, 기본 6)와 대·소문자 혼용·숫자·특수문자 요구를 켜고 끕니다. 예전의 10자 고정 규칙을 대체.
- **비밀번호 변경 보호·도메인 기반 서비스 이름·자동 새로고침**(0.8.25~): 자기 비밀번호 변경은 현재 비밀번호 확인 + 두 번 입력 + 보기 단추. 왼쪽 위 이름은 메일 도메인에서(`heart-m.com` → "HEART-M 메일"). 배포 뒤 열어 둔 화면은 `X-App-Version` 을 보고 스스로 새로 고침.
배포 파일은 `deploy/sites/<이름>.env` 사이트 설정 + `.in` 템플릿이며, 현재 mail.arihakdang.com(www 연동)과 mail.heart-m.com(독립) 두 사이트가 있습니다.
의존성 없음(Node 22.13+ 의 `node:sqlite`, `node:tls`). 문구는 `lang/ko.json` 한 곳에 둡니다.
```bash
npm test # 인증·웹훅·동기화·보관·발송·POP3·IMAP·SMTP·설정/발송 모듈/Haraka 수신/초기 설정 회귀 테스트
DB_FILE=./data/mail-gateway.sqlite MAILBOX=admin@example.test node server/index.mjs # 운영 설정은 ./data/config.json 과 초기 설정 페이지(8791)
```
설치·운영은 [docs/SETUP.md](docs/SETUP.md), 배포 파일은 `deploy/`, 변경 이력은 [CHANGELOG.md](CHANGELOG.md) 를 보세요.
## 구조
| 파일 | 역할 |
|---|---|
| `server/index.mjs` | HTTP(127.0.0.1:8790) + POP3 + 주기 작업 |
| `server/api.mjs` | 로그인, 메일함 API, 설정 API, Resend 웹훅 |
| `server/store.mjs` | 수신 저장(웹훅/동기화), 원본, 시각 정규화, 세션 정리, 초안·발송 |
| `server/automation.mjs` | 수신 메일 자동 처리(0.8.16~): 메일 전달·자동 응답, 되풀이 방지 규칙, 처리 기록(auto_actions) |
| `server/smtp.mjs` | SMTP 제출 서버(EHLO/STARTTLS/AUTH PLAIN·LOGIN/MAIL/RCPT/DATA), 로그인 인증 후 내부 키로 Resend 발송·보낸 메일함 저장 |
| `server/pop3.mjs` | POP3 서버(USER/PASS, AUTH PLAIN, STLS, UIDL, TOP), 전달 기록 |
| `server/imap.mjs` | IMAP4rev1 서버(SELECT/FETCH/STORE/SEARCH/MOVE/COPY/EXPUNGE/APPEND/IDLE, STARTTLS) |
| `server/imap-mime.mjs` | 원본 MIME 해석: ENVELOPE, BODYSTRUCTURE, 섹션 추출, APPEND 요약 |
| `server/auth.mjs` | www 관리자 인증(PBKDF2, 별칭 admin) + 게이트웨이 계정(mail_users) 관리(회원 가입·승인 대기 포함), 세션, 계정별 설정(prefs: 외부 이미지·POP/IMAP·전달·자동 응답) |
| `server/files.mjs` | 0.9.3: 대용량 첨부파일 — 디스크 스트리밍 업로드, 발송 시 토큰 링크(/dl/<토큰>)와 본문 블록, 랜딩 페이지·횟수 차감 다운로드, 기한·횟수 만료 삭제·정리, 시스템 상한(LIMIT_LARGE_FILE_*)·관리자 설정(50% 기본) |
| `server/limits.mjs` | 0.9.0: 시스템 제한(환경 변수 LIMIT_*)·관리자 제한·계정별 메일함 크기, 사용량(MIME 기준)·발송/수신 제한 검사, POP/IMAP 서비스 스위치, 회원 가입 설정, 메일 현황 집계(일·주·월) |
| `server/user-cli.mjs` | 계정 명령줄 도구(0.8.18~): 독립 설치의 첫 관리자 생성, 비밀번호 재설정, 목록 |
| `server/config.mjs` | 1.0.0: 운영 설정 파일(config.json) — 스키마, 환경 변수 가져오기·우선순위, 저장 즉시 적용, 비밀값 가림, 초기 설정 토큰, DKIM 키·DNS 레코드 |
| `server/domains.mjs` | 1.0.7: 다중 도메인 — 도메인 등록부(data/domains.json)와 테넌트(도메인별 env: DB·설정·파일), Host·로그인 ID·주소로 도메인 고르기, 추가·중지·삭제, "메일 서비스 없음" 안내 화면 |
| `server/usage.mjs` | 1.0.9: 메일 사용량 정보 제공 API(`/api/usage`) — 도메인·계정별 디스크 사용량·메일 수·발송량(일/주/월·일별 추이), 비밀 키(Bearer)·허용 IP·시도 제한으로 보호된 읽기 전용([docs/USAGE-API.md](docs/USAGE-API.md)) |
| `server/webmaker.mjs`, `server/webmaker-snapshot.mjs` | 1.0.8: webmaker 인증 모드 — 웹메이커(WebHosting_AI_agent) 데이터 디렉터리를 읽기 전용 스냅샷으로 읽어(직접 또는 sudo 도우미) 같은 scrypt 로 비밀번호 검사, 도메인 ↔ 사이트 대응(사이트 사용자·소유자·웹메이커 관리자 → `아이디@도메인`), 관리자 페이지의 읽기 전용 행 |
| `server/setup.mjs` | 1.0.0: 초기 설정 페이지 API(/api/setup/*)와 전용 포트(8791, 토큰) 서버, Haraka 상태 읽기. 1.0.7: 전체 설정(도메인 목록·추가·중지, ?domain= 으로 도메인별 설정) |
| `server/dns/` | 1.0.5: DNS 모듈 — `index.mjs`(모듈 선택·필요한 레코드 계산·SPF 병합·적용·반영 확인), `google.mjs`(Cloud DNS, 서비스 계정 JWT), `route53.mjs`(Route 53, SigV4), `common.mjs`(공개 조회·PTR) |
| `server/provision.mjs` | 1.0.5: 자동 설정 계획 — 단계별 진단(buildPlan)과 실행(runPlan): Resend 도메인·검증·웹훅, DNS 적용, Haraka DKIM·설치·PTR, SMTP 중계 확인 |
| `server/providers/` | 1.0.0: 발송·수신 서비스 모듈 — `index.mjs`(sendMail 선택·상태), `resend.mjs`(Resend API, 옛 server/resend.mjs; 1.0.11: 발신 도메인 검증 상태 진단 `mail_domain_unverified`/`mail_domain_missing` — 레코드별 없음/값 다름, 자동 재검증), `smtp.mjs` + `smtp-client.mjs`(SMTP 중계·내장 Haraka 제출) |
| `haraka/` | 1.0.0: 내장 Haraka — `package.json`(Haraka + dkim 플러그인), `run.mjs`(설정 파일을 보고 Haraka 설정 생성·실행·상태 기록), `plugins/arimail.js`(게이트웨이 연결: relay 판정·RCPT 확인·원본 전달) |
| `web/setup.*` | 1.0.0: 메일 서비스 설정 화면(전용 포트와 /setup 공용). 1.0.7: 전용 포트에서는 전체 설정(도메인 카드 + 고른 도메인의 설정) |
| `server/mime.mjs` | RFC 5322 메시지 생성, POP 전송 정규화 |
| `server/sanitize.mjs` | 수신 HTML 정제(www 의 sanitize_mail.py 와 같은 허용 목록) |
| `web/` | 웹 메일 화면(로그인·회원 가입·접속 안내, 3단 메일함, HTML 편집기(8색 팔레트), 설정(비밀번호 변경·사용량·전달·자동 응답))과 관리자 페이지(/admin: 계정 관리·회원 가입/제한/서비스 설정, 서버 상태·메일 현황) |
| `deploy/` | 사이트 설정(`sites/*.env`, `new-site.sh` 로 생성), `nginx-apply.sh`(vhost 만 다시 적용, 0.9.5), `haraka-apply.sh`(내장 Haraka npm 설치·서비스 등록, 1.0.0), `domain-nginx.sh`(추가 도메인 호스트의 nginx vhost·인증서, 1.0.7), `webmaker-access.sh`(웹메이커 계정 읽기 도우미·sudoers, 1.0.8), `site-mail-path.sh`(사이트의 `/mail` 을 웹 메일로 — `nginx/mail-path.conf.in`, 1.0.8)과 템플릿(systemd·nginx·certbot 훅·환경 파일 `.in`), setup/deploy 스크립트(끝에 초기 설정 페이지 주소 출력), 인증서 자동 발급 타이머(`tls-when-ready.sh`). 설치 옵션 `LOGIN_MODE`(standalone 기본 / www / webmaker)와 시스템 제한 `LIMIT_*` |
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 배포 — 서버에서 실행 계정으로 실행: sudo -u <APP_USER> env SOURCE_BUNDLE=<번들> <APP_DIR>/repo/deploy/deploy.sh
# 1. 소스 갱신(번들 또는 원격) 2. 테스트 3. 릴리스 복사 4. DB 백업·마이그레이션 5. 서비스 재시작(실패 시 이전 릴리스로 복귀)
# 사이트(서비스 이름·계정·경로·포트)는 <APP_DIR>/site.env(setup.sh 가 둠)에서 읽습니다. 없으면 arihakdang 설정으로 동작합니다(0.8.17 까지의 서버 호환).
set -euo pipefail
. "$(dirname "$0")/lib.sh"
APP="$(cd "$DEPLOY_DIR/../.." && pwd)" # <APP_DIR>/repo/deploy → <APP_DIR>
if [ -f "$APP/site.env" ]; then load_site "$APP/site.env"; else load_site "$DEPLOY_DIR/sites/arihakdang.env"; fi
APP="$APP_DIR"; REPO="$APP/repo"; RELEASES="$APP/releases"
[ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node)"
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
export PATH="$(dirname "$NODE_BIN"):$PATH"
STAMP="$(date +%Y%m%d-%H%M%S)"; TARGET="$RELEASES/$STAMP"
exec 9>"$APP/deploy.lock"; flock -n 9 || { echo '다른 배포가 진행 중입니다' >&2; exit 1; }
echo "▶ 소스 갱신 ($SITE: $MAIL_HOST)"; cd "$REPO"
# SOURCE_BUNDLE=<git bundle 경로> 를 주면 원격 자격 증명 없이 번들에서 main 을 받습니다(작업 PC 에서 scp 로 전달).
# 번들의 main 이 서버가 아는 origin/main 과 이어지지 않아도(릴리스 역사를 다시 쓴 경우) 받도록 강제(+) 갱신합니다. 어차피 아래에서 origin/main 으로 reset 합니다.
if [ -n "${SOURCE_BUNDLE:-}" ]; then git bundle verify "$SOURCE_BUNDLE" >/dev/null 2>&1; git fetch --quiet --force "$SOURCE_BUNDLE" +main:refs/remotes/origin/main; else git fetch --quiet origin; fi
git reset --hard --quiet origin/main; echo " $(git log --oneline -1)"
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage ariweb; do "$NODE_BIN" "test/$t.test.mjs"; done
echo "▶ 릴리스 배치"; mkdir -p "$TARGET"
for path in server web lang migrations package.json haraka; do cp -r "$REPO/$path" "$TARGET/$path"; done
git rev-parse HEAD > "$TARGET/REVISION"
DB="$APP/data/mail-gateway.sqlite"; mkdir -p "$APP/data/backups"
if [ -f "$DB" ]; then python3 - "$DB" "$APP/data/backups/pre-$STAMP.sqlite" <<'PY'
import sqlite3,sys
s=sqlite3.connect('file:'+sys.argv[1]+'?mode=ro',uri=True);b=sqlite3.connect(sys.argv[2]);s.backup(b);b.close();s.close()
PY
fi
DB_FILE="$DB" "$NODE_BIN" "$TARGET/server/db-migrate.mjs"
# 첫 배포에는 current 가 없습니다. -e 는 존재하는 경로만 돌려주므로 자기 자신을 가리키는 링크를 만들지 않습니다.
PREVIOUS="$(readlink -e "$APP/current" 2>/dev/null || true)"
rollback() { trap - ERR; if [ -n "$PREVIOUS" ] && [ -d "$PREVIOUS" ] && [ "$PREVIOUS" != "$TARGET" ]; then ln -sfn "$PREVIOUS" "$APP/current.rollback"; mv -Tf "$APP/current.rollback" "$APP/current"; sudo systemctl restart "$APP_NAME"; fi; echo "배포 실패 — 이전 릴리스로 복귀" >&2; }
trap rollback ERR
ln -sfn "$TARGET" "$APP/current.tmp"; mv -Tf "$APP/current.tmp" "$APP/current"; echo " current → $TARGET"
# sync: 릴리스 파일이 디스크에 완전히 쓰인 뒤 재시작합니다(1.0.5 — 0.9.7 배포 때 첫 시작이 빈 package.json 을 읽은 기록).
sync
echo "▶ 서비스 재시작"; sudo systemctl restart "$APP_NAME"; sleep 2
systemctl is-active --quiet "$APP_NAME" || { rollback; exit 1; }
curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null && echo " health OK"
trap - ERR
ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
# 내장 Haraka(1.0.0): 관리 서비스가 등록되어 있으면 새 릴리스의 run.mjs 로 다시 띄웁니다(sudoers 에 없으면 건너뜀 — haraka-apply.sh 가 다음 실행에서 맞춤).
# 의존성 설치는 root 가 하는 haraka-apply.sh 의 몫이지만, 이미 설치된 디렉터리가 있고 package.json 이 바뀌었으면 여기서 실행 계정으로 갱신을 시도합니다(실패해도 배포는 성공).
if [ -f "$APP/haraka/package.json" ] && ! cmp -s "$REPO/haraka/package.json" "$APP/haraka/package.json"; then
NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)"
if [ -x "$NPM_BIN" ]; then cp "$REPO/haraka/package.json" "$APP/haraka/package.json"; (cd "$APP/haraka" && HOME="$APP/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --loglevel=error) || echo " ⚠️ Haraka 의존성 갱신 실패(인터넷?) — sudo bash $REPO/deploy/haraka-apply.sh 로 다시 시도" >&2; fi
fi
if systemctl is-enabled --quiet "$APP_NAME-haraka" 2>/dev/null; then sudo -n systemctl restart "$APP_NAME-haraka" 2>/dev/null && echo " Haraka 관리 서비스 재시작" || echo " (Haraka 관리 서비스는 root 가 재시작해야 합니다: sudo systemctl restart $APP_NAME-haraka)"; fi
echo "✔ 배포 완료: $STAMP"
# 초기 설정 페이지(1.0.0): 서버가 시작하며 데이터 디렉터리에 접속 주소(토큰 포함)를 남깁니다. 설치 직후 운영 설정(Resend 키 등)을 여기서 채웁니다.
sleep 1; if [ -f "$APP/data/setup-url.txt" ]; then echo "▶ 초기 설정 페이지(토큰 포함 — 공유하지 마세요):"; sed 's/^/ /' "$APP/data/setup-url.txt"; echo " 또는 https://$MAIL_HOST/admin → 메일 서비스 설정"; fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 추가 도메인의 웹 메일 호스트에 nginx vhost 와 Let's Encrypt 인증서를 준비합니다(1.0.7, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/domain-nginx.sh mail.example.com [site.env 경로]
#
# 왜 필요한가
# 도메인 자체는 전체 설정 화면(기본 도메인의 8791 포트)에서 추가하면 게이트웨이가 바로 받기 시작하지만, 브라우저가 https://mail.example.com/ 으로
# 오려면 nginx 가 그 이름(server_name)과 인증서를 알아야 합니다. 이 스크립트는 기존 사이트의 nginx 템플릿(deploy/nginx/*.in)을 그 호스트 이름으로 채워
# 같은 게이트웨이 포트(PORT)로 넘기는 vhost 를 만들고 certbot 으로 인증서를 받습니다. 게이트웨이 설정·환경 파일·데이터는 건드리지 않습니다.
#
# 하는 일
# 1) 인증서가 없으면 80 전용(ACME 챌린지) vhost 로 시작하고, DNS(호스트 → 이 서버 IP)가 맞으면 certbot certonly --webroot 로 발급합니다.
# DNS 가 아직 안 바뀌었으면 안내만 하고 끝납니다(DNS 가 바뀐 뒤 다시 실행).
# 2) 인증서가 있으면 443 vhost(mail.conf.in)로 바꾸고 nginx 를 다시 읽습니다. 갱신 뒤 nginx 재적재용 certbot 훅도 둡니다.
# 참고: 메일 프로그램 포트(IMAP/POP/SMTP)의 인증서는 기본 도메인 것 하나이므로, 추가 도메인의 사용자도 메일 프로그램에는 기본 도메인의 호스트 이름으로 접속합니다
# (웹 메일의 "메일 프로그램 설정" 화면이 그 이름을 안내합니다).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
TARGET_HOST="$(printf '%s' "${1:?웹 메일 호스트 이름(예: mail.example.com)}" | tr 'A-Z' 'a-z')"
printf '%s' "$TARGET_HOST" | grep -Eq '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' || { echo "호스트 이름 형식이 아닙니다: $TARGET_HOST" >&2; exit 1; }
SITE_FILE="${2:-}"
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
[ "$TARGET_HOST" != "$MAIL_HOST" ] || { echo "$TARGET_HOST 는 기본 도메인의 호스트입니다(setup.sh / nginx-apply.sh 가 관리)." >&2; exit 1; }
# 템플릿의 @MAIL_HOST@ 자리를 이 호스트로 채웁니다(나머지 변수 — 포트·로그 이름·ACME 경로 — 는 사이트 설정 그대로).
MAIL_HOST="$TARGET_HOST"; export MAIL_HOST
TARGET="/etc/nginx/sites-available/$TARGET_HOST.conf"
apply() { render "$1" "$TARGET"; chmod 644 "$TARGET"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$TARGET_HOST.conf"; nginx -t; systemctl reload nginx; }
echo "▶ 추가 도메인 호스트: $TARGET_HOST (사이트 $SITE, 게이트웨이 포트 $PORT)"
if [ ! -d "/etc/letsencrypt/live/$TARGET_HOST" ]; then
apply "$DEPLOY_DIR/nginx/mail.http-only.conf.in"
echo " 80 포트(ACME) 설정 적용. 인증서를 발급합니다…"
RESOLVED="$(getent ahostsv4 "$TARGET_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)"
MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)"
MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done
if [ -z "$MATCH" ]; then
echo " $TARGET_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE)."
echo " DNS 의 A 레코드를 이 서버로 바꾼 뒤 같은 명령을 다시 실행하세요. 그때까지 https 는 열리지 않습니다."
exit 0
fi
mkdir -p "$ACME_WEBROOT"
ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email"
certbot certonly --webroot -w "$ACME_WEBROOT" -d "$TARGET_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG
fi
apply "$DEPLOY_DIR/nginx/mail.conf.in"
# 갱신 훅: 이 호스트의 인증서가 갱신되면 nginx 만 다시 읽습니다(메일 포트는 기본 도메인 인증서를 쓰므로 게이트웨이 재시작 불필요).
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh" <<HOOK
#!/bin/sh
# AriMail 추가 도메인($TARGET_HOST) 인증서 갱신 뒤 nginx 재적재
case "\${RENEWED_LINEAGE:-}" in */$TARGET_HOST*) systemctl reload nginx || true ;; esac
HOOK
chmod 755 "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh"
echo "✔ https://$TARGET_HOST/ 준비 완료. 전체 설정 화면에서 이 호스트를 가진 도메인을 추가(또는 호스트 목록에 넣기)했는지 확인하세요."
+65
View File
@@ -0,0 +1,65 @@
# @ETC_DIR@/gateway.env — 소유자만 읽게 하세요 (chmod 600, chown @APP_USER@). setup.sh 가 사이트 설정(deploy/sites/@SITE@.env)으로 만든 파일입니다.
#
# 1.0.0: 이 파일에는 "배포에 꼭 필요한 값" 만 남깁니다. 운영 설정(수·발신 방식, Resend API 키·웹훅 비밀키, SMTP 중계 서버, 내장 Haraka, 발신 표시 이름,
# 탭 제목, 통계 시간대, 동기화 주기)은 데이터 디렉터리의 설정 파일(@APP_DIR@/data/config.json)에 있고, 설치 뒤 초기 설정 페이지
# (배포 스크립트가 출력하는 http(s)://<서버>:8791/?token=… 또는 https://@MAIL_HOST@/admin → 메일 서비스 설정)에서 채우면 재시작 없이 적용됩니다.
# 예전 서버에서 이 파일에 남아 있는 RESEND_API_KEY 등은 1.0.0 첫 시작 때 설정 파일로 가져가며, 그 뒤로는 설정 파일 값이 우선합니다.
#
# 게이트웨이가 다루는 기본 메일함. 계정의 메일함은 항상 "로그인 ID@(기본 메일함의 도메인)" 입니다.
MAILBOX=@MAILBOX@
PUBLIC_HOST=@MAIL_HOST@
# 운영 설정 파일 위치(비우면 DB 파일 옆의 config.json). 초기 설정 페이지 전용 포트(0 이면 끔)와 접속 허용 주소.
CONFIG_FILE=
SETUP_PORT=8791
SETUP_HOST=0.0.0.0
# 내장 Haraka 설치 디렉터리(deploy/haraka-apply.sh 가 npm 으로 설치, 서비스 @APP_NAME@-haraka). 비우면 @APP_DIR@/haraka.
HARAKA_DIR=@APP_DIR@/haraka
# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본 설치 형태, 첫 관리자 admin/adminpasswd 자동 생성)
# www = www 사이트 DB(WWW_DB_FILE)로 관리자를 인증. standalone 이면 WWW_DB_FILE 은 무시됩니다.
LOGIN_MODE=@LOGIN_MODE@
# www 사이트 DB(관리자 계정 인증용, 읽기 전용). LOGIN_MODE=www 일 때만 씁니다.
WWW_DB_FILE=@WWW_DB_FILE@
# 웹메이커 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커 데이터 디렉터리(DATA_ROOT), sudo 로 읽을 계정(선택, deploy/webmaker-access.sh), 사이트 id(선택).
WEBMAKER_DIR=@WEBMAKER_DIR@
WEBMAKER_USER=@WEBMAKER_USER@
WEBMAKER_SITE=@WEBMAKER_SITE@
# 시스템 제한(0.9.0, 메일 시스템 전체의 상위 설정 — 관리자 페이지에서 바꿀 수 없고 관리자 설정은 이 값 이내로만 가능). 모두 0 이면 제한 없음.
# 크기는 바이트 또는 K/M/G 접미사(예: 500M, 2G)이며 MIME 변환 뒤의 크기(첨부는 base64 로 약 1.37배) 기준입니다.
# (systemd 환경 파일은 값 뒤의 주석을 지원하지 않으므로 주석은 따로 둡니다.)
# 허용 가능한 메일 계정 수
LIMIT_ACCOUNTS=@LIMIT_ACCOUNTS@
# 메일함 전체 최대 용량
LIMIT_TOTAL_BYTES=@LIMIT_TOTAL_BYTES@
# 개별 메일함 최대 용량
LIMIT_MAILBOX_BYTES=@LIMIT_MAILBOX_BYTES@
# 첨부 파일 한 개 최대 크기
LIMIT_ATTACHMENT_BYTES=@LIMIT_ATTACHMENT_BYTES@
# 한 메일의 받는 사람 수(참조·숨은 참조 포함, 내장 상한 50)
LIMIT_RECIPIENTS=@LIMIT_RECIPIENTS@
# 월간(1일~말일) 최대 수발신 통수
LIMIT_MONTHLY_MESSAGES=@LIMIT_MONTHLY_MESSAGES@
# 대용량 첨부파일 시스템 상한(0.9.3): 관리자 설정의 상한이며 무제한으로 둘 수 없습니다(비우거나 0 이면 기본값 1G / 30 / 20 적용).
# 파일 한 개 최대 크기(기본 1G)
LIMIT_LARGE_FILE_BYTES=@LIMIT_LARGE_FILE_BYTES@
# 보관 최대 기간(일, 기본 30)
LIMIT_LARGE_FILE_DAYS=@LIMIT_LARGE_FILE_DAYS@
# 다운로드 최대 횟수(기본 20)
LIMIT_LARGE_FILE_DOWNLOADS=@LIMIT_LARGE_FILE_DOWNLOADS@
# 대용량 파일을 둘 디렉터리(비우면 DB 파일 옆의 files/). 서비스가 쓸 수 있는 경로여야 합니다(systemd ReadWritePaths).
FILES_DIR=
# 게이트웨이 저장소(SQLite). 설정 파일·초기 설정 토큰·Haraka 상태 파일도 같은 디렉터리에 둡니다.
DB_FILE=@APP_DIR@/data/mail-gateway.sqlite
# POP3·IMAP·SMTP TLS: 인증서 사본 경로(certbot 갱신 훅이 여기에 복사). 없으면 995/993/465 를 열지 않고 평문 포트는 STARTTLS 없이 로그인 거부.
POP_TLS_CERT=@ETC_DIR@/tls/fullchain.pem
POP_TLS_KEY=@ETC_DIR@/tls/privkey.pem
POP_PORT=110
POPS_PORT=995
# IMAP: 993(TLS) / 143(STARTTLS). 인증서는 POP 와 같은 파일을 씁니다.
IMAP_PORT=143
IMAPS_PORT=993
# SMTP 제출(보내는 메일 서버): 587(STARTTLS) / 465(TLS). 로그인 비밀번호로 인증하고 위 RESEND_API_KEY 로 발송합니다.
SMTP_PORT=587
SMTPS_PORT=465
# 110/143 포트의 TLS 없는 로그인: 0.9.7 부터 관리자 페이지(POP / IMAP 서비스 → 일반 접속 로그인 허용, 기본 켬)에서 정합니다.
# 1 로 두면 관리자 설정과 무관하게 항상 허용(진단용). 0 이나 빈 값은 관리자 설정을 따릅니다(예전처럼 "항상 거부" 가 아님).
POP_ALLOW_PLAINTEXT=0
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# 내장 Haraka 준비(1.0.0) — root 로 실행: sudo bash <APP_DIR>/repo/deploy/haraka-apply.sh [site.env 경로]
#
# 하는 일(환경 파일·데이터는 건드리지 않습니다)
# 1) <APP_DIR>/haraka 디렉터리(실행 계정 소유)에 haraka/package.json 을 두고 npm 으로 Haraka 와 DKIM 플러그인을 설치합니다(인터넷 필요, 몇 분).
# 2) systemd 서비스 <APP_NAME>-haraka 를 등록·시작합니다. 이 서비스(haraka/run.mjs)는 설정 파일(config.json)의 수·발신 방식이 haraka 일 때만
# Haraka 를 띄우므로, 지금 Resend 만 쓰고 있어도 켜 두어도 아무 포트를 열지 않습니다(설정 화면에서 바꾸는 순간 동작).
# setup.sh 가 새 사이트에 자동으로 부르고, 기존 사이트는 한 번 직접 실행합니다. 다시 실행해도 안전합니다(설치된 버전이 같으면 npm 이 금방 끝남).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
SITE_FILE="${1:-}"
# 사이트 파일: 인자 → <APP_DIR>/site.env(0.8.18 이후 setup.sh 가 둠) → deploy/sites/<SITE>.env(0.8.17 이전에 설치한 서버는 site.env 사본이 없음, deploy.sh 와 같은 폴백).
if [ -n "$SITE_FILE" ] && [ ! -f "$SITE_FILE" ]; then echo "사이트 설정 파일이 없어 저장소의 기본 파일을 씁니다: $SITE_FILE" >&2; SITE_FILE=""; fi
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
echo "▶ Haraka 준비: $SITE ($APP_DIR/haraka, 서비스 $APP_NAME-haraka)"
[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN" >&2; exit 1; }
NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)"
REPO_HARAKA="$APP_DIR/repo/haraka"; [ -f "$REPO_HARAKA/package.json" ] || REPO_HARAKA="$DEPLOY_DIR/../haraka"
mkdir -p "$APP_DIR/haraka"; chown "$APP_USER:$APP_USER" "$APP_DIR/haraka"
install -o "$APP_USER" -g "$APP_USER" -m 644 "$REPO_HARAKA/package.json" "$APP_DIR/haraka/package.json"
if [ -x "$NPM_BIN" ]; then
echo "▶ npm 설치 ($NPM_BIN) — 인터넷이 필요합니다"
# 실행 계정으로 설치해 node_modules 소유자를 맞춥니다. 실패해도(오프라인 등) 서비스는 등록하고 안내만 남깁니다.
if sudo -u "$APP_USER" env PATH="$(dirname "$NODE_BIN"):$PATH" HOME="$APP_DIR/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --prefix "$APP_DIR/haraka" --loglevel=error; then
echo " 설치됨: Haraka $("$NODE_BIN" -e "console.log(require('$APP_DIR/haraka/node_modules/Haraka/package.json').version)")"
else echo " ⚠️ npm 설치 실패 — 인터넷 연결을 확인하고 다시 실행하세요: sudo bash $0 $SITE_FILE" >&2; fi
else echo " ⚠️ npm 을 찾지 못했습니다($NODE_BIN 옆에 없음). Node 배포판의 npm 을 두고 다시 실행하세요." >&2; fi
REPO_DEPLOY="$APP_DIR/repo/deploy"; [ -f "$REPO_DEPLOY/systemd/mail-haraka.service.in" ] || REPO_DEPLOY="$DEPLOY_DIR"
render "$REPO_DEPLOY/systemd/mail-haraka.service.in" "/etc/systemd/system/$APP_NAME-haraka.service"; chmod 644 "/etc/systemd/system/$APP_NAME-haraka.service"
# 실행 계정이 deploy.sh 에서 이 서비스를 재시작할 수 있게 sudoers 를 최신 규칙으로 다시 씁니다(setup.sh 와 같은 한 줄).
sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q
systemctl daemon-reload
# 첫 배포 전(current 없음)에는 시작하지 않고 등록만 합니다(deploy.sh 가 끝나면 다음 부팅·수동 시작 때 뜸). current 가 있으면 바로 (재)시작.
systemctl enable -q "$APP_NAME-haraka"
if [ -e "$APP_DIR/current/haraka/run.mjs" ]; then systemctl restart "$APP_NAME-haraka"; sleep 1; systemctl is-active --quiet "$APP_NAME-haraka" && echo " 서비스 실행 중: $APP_NAME-haraka" || echo " ⚠️ 서비스가 뜨지 않았습니다: journalctl -u $APP_NAME-haraka -n 50" >&2
else echo " (아직 배포된 릴리스가 없어 서비스는 등록만 했습니다. deploy.sh 뒤 자동으로 시작합니다)"; fi
cat <<MSG
완료. 설정 화면(초기 설정 페이지 또는 /admin → 메일 서비스 설정)에서 수신/발신 방식을 "내장 Haraka" 로 고르면 몇 초 안에 Haraka 가 뜹니다.
- 수신(MX)으로 쓰려면 방화벽에서 25 포트를 열고 DNS 의 MX 를 이 서버로 바꾸세요. 예) sudo ufw allow 25/tcp
- 발신으로 쓰려면 설정 화면에서 DKIM 키를 만들고 안내된 SPF·DKIM TXT 레코드를 DNS 에 올리세요. 서버 IP 의 역방향 DNS(PTR)도 필요합니다.
- 로그: journalctl -u $APP_NAME-haraka -f
MSG
@@ -0,0 +1,12 @@
#!/bin/sh
# certbot 갱신 훅(@MAIL_HOST@): 인증서 사본을 서비스 계정 전용 경로에 두고 nginx·게이트웨이에 반영합니다.
# certbot 의 개인키는 root 만 읽으므로 게이트웨이(@APP_USER@)가 읽을 사본이 필요합니다.
set -e
LINEAGE="${RENEWED_LINEAGE:-/etc/letsencrypt/live/@MAIL_HOST@}"
case "$LINEAGE" in */@MAIL_HOST@*) ;; *) exit 0 ;; esac
mkdir -p @ETC_DIR@/tls
install -o @APP_USER@ -g @APP_USER@ -m 640 "$LINEAGE/fullchain.pem" @ETC_DIR@/tls/fullchain.pem
install -o @APP_USER@ -g @APP_USER@ -m 640 "$LINEAGE/privkey.pem" @ETC_DIR@/tls/privkey.pem
[ "${SKIP_RESTART:-0}" = 1 ] && exit 0
systemctl reload nginx || true
systemctl restart @APP_NAME@ || true
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# setup.sh / deploy.sh / tls-when-ready.sh 가 공유하는 함수(0.8.18~). 직접 실행하지 않고 source 합니다.
#
# load_site <파일> : 사이트 설정(deploy/sites/<이름>.env 또는 $APP_DIR/site.env)을 읽고 필수 값을 확인합니다.
# render <템플릿> <출력> : 템플릿의 @변수@ 자리를 사이트 설정 값으로 바꿔 씁니다(systemd·nginx·certbot 훅·환경 파일).
#
# 예전(0.8.17 까지)에는 모든 파일에 mail.arihakdang.com / arihakdang 계정 / /srv/arihakdang-mail 이 박혀 있어 다른 도메인에 설치할 수 없었습니다.
# 이제 사이트마다 변수 파일 하나로 서비스 이름·계정·경로·호스트·메일함을 정하고, 같은 템플릿으로 설정 파일을 만듭니다.
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 템플릿이 치환하는 변수 목록. 새 변수를 쓰려면 여기와 sites/*.env 에 함께 더합니다.
# 0.9.0: LOGIN_MODE(설치 옵션: standalone | www)와 시스템 제한 LIMIT_*(0 = 제한 없음, 크기는 500M/2G 같은 접미사 가능)을 더했습니다.
SITE_VARS=(SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS)
load_site() {
local file="$1"
[ -f "$file" ] || { echo "사이트 설정 파일이 없습니다: $file" >&2; return 1; }
# shellcheck disable=SC1090
set -a; . "$file"; set +a
for v in SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX; do
[ -n "${!v:-}" ] || { echo "사이트 설정에 $v 가 없습니다: $file" >&2; return 1; }
done
: "${MAIL_FROM_NAME:=$SITE}" "${SITE_TITLE:=}" "${SITE_BRAND:=}" "${WWW_DB_FILE:=}" "${WEBMAKER_DIR:=}" "${WEBMAKER_USER:=}" "${WEBMAKER_SITE:=}" "${EXTRA_RW:=}" "${ACME_WEBROOT:=/var/www/letsencrypt}" "${CF_CERT:=}" "${CF_KEY:=}" "${REPO_URL:=https://source.ibit.kr/OZ-Group/AriMail.git}"
: "${LIMIT_ACCOUNTS:=0}" "${LIMIT_TOTAL_BYTES:=0}" "${LIMIT_MAILBOX_BYTES:=0}" "${LIMIT_ATTACHMENT_BYTES:=0}" "${LIMIT_RECIPIENTS:=0}" "${LIMIT_MONTHLY_MESSAGES:=0}"
# 대용량 첨부파일 시스템 상한(0.9.3): 무제한이 없으므로 기본값(1G / 30일 / 20회)을 둡니다.
: "${LIMIT_LARGE_FILE_BYTES:=1G}" "${LIMIT_LARGE_FILE_DAYS:=30}" "${LIMIT_LARGE_FILE_DOWNLOADS:=20}"
# 로그인 방식(0.9.0 설치 옵션). 비워 두면 WWW_DB_FILE 유무로 정합니다(0.8.x 사이트 파일 호환). standalone 이 기본 설치 형태입니다.
# standalone : www 사이트 없이 게이트웨이 계정만 씀(WWW_DB_FILE 은 비움) www : www 사이트 DB 로 관리자를 인증(WWW_DB_FILE 필수)
: "${LOGIN_MODE:=}"
if [ -z "$LOGIN_MODE" ]; then if [ -n "$WWW_DB_FILE" ]; then LOGIN_MODE=www; else LOGIN_MODE=standalone; fi; fi
# webmaker(1.0.8): 웹메이커 데이터 디렉터리(WEBMAKER_DIR)로 계정을 연동. WEBMAKER_USER(sudo 로 읽을 계정)·WEBMAKER_SITE(사이트 id)는 선택.
case "$LOGIN_MODE" in
standalone) WWW_DB_FILE=""; WEBMAKER_DIR="" ;;
www) WEBMAKER_DIR=""; [ -n "$WWW_DB_FILE" ] || { echo "LOGIN_MODE=www 이면 WWW_DB_FILE(www 사이트 DB 경로)이 필요합니다: $file" >&2; return 1; } ;;
webmaker) WWW_DB_FILE=""; [ -n "$WEBMAKER_DIR" ] || { echo "LOGIN_MODE=webmaker 이면 WEBMAKER_DIR(웹메이커 데이터 디렉터리)이 필요합니다: $file" >&2; return 1; } ;;
*) echo "LOGIN_MODE 는 standalone, www, webmaker 중 하나여야 합니다: $LOGIN_MODE ($file)" >&2; return 1 ;;
esac
export MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS
}
# sed 치환에서 특별한 뜻을 갖는 글자(구분자 | 와 &, \)를 피합니다.
_sed_escape() { local s="$1"; s="${s//\\/\\\\}"; s="${s//|/\\|}"; s="${s//&/\\&}"; printf '%s' "$s"; }
render() {
local template="$1" out="$2" expr=()
for v in "${SITE_VARS[@]}"; do expr+=(-e "s|@${v}@|$(_sed_escape "${!v:-}")|g"); done
sed "${expr[@]}" "$template" > "$out"
# 치환되지 않은 자리가 남으면 템플릿과 변수 목록이 어긋난 것이므로 바로 알립니다.
if grep -q '@[A-Z_]\{3,\}@' "$out"; then echo "템플릿에 알 수 없는 변수가 있습니다: $template → $(grep -o '@[A-Z_]\{3,\}@' "$out" | sort -u | tr '\n' ' ')" >&2; return 1; fi
}
# 실행 계정이 서비스를 재시작하고 nginx 를 다시 읽을 수 있게 하는 sudoers 규칙 한 줄.
# 1.0.0: 내장 Haraka 관리 서비스(<APP_NAME>-haraka)의 재시작도 허용합니다(deploy.sh 가 새 릴리스로 다시 띄움).
sudoers_line() { printf '%s ALL=(root) NOPASSWD: /usr/bin/systemctl restart %s, /usr/bin/systemctl restart %s-haraka, /usr/bin/systemctl reload nginx\n' "$APP_USER" "$APP_NAME" "$APP_NAME"; }
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# 새 사이트 설정 파일 만들기(0.9.0 설치 옵션) — 작업 PC 에서: bash deploy/new-site.sh <사이트이름> --host mail.example.com --mailbox admin@example.com [옵션]
#
# deploy/sites/<사이트이름>.env 를 만듭니다. setup.sh 는 이 파일로 디렉터리·환경 파일·systemd·nginx 를 준비합니다.
# 로그인 방식은 --login standalone(기본: www 사이트 없이 게이트웨이 계정만 사용) 또는 --login www --www-db <www DB 경로>(www 사이트 관리자로 인증) 중 고릅니다.
# 시스템 제한(--limit-*)은 설치 시 gateway.env 에 들어가는 상위 설정이며, 0(기본)은 제한 없음입니다. 크기는 500M, 2G 처럼 적습니다.
#
# 옵션: --user <실행 계정>(기본 사이트이름) --app-name <서비스 이름>(기본 <사이트이름>-mail) --app-dir <경로>(기본 /srv/<서비스 이름>) --etc-dir <경로>(기본 /etc/<서비스 이름>)
# --node <node 경로>(기본 /opt/<서비스 이름>-node/bin/node) --port <포트>(기본 8790) --from-name <발신 표시 이름> --title <탭 제목>
# --login standalone|www --www-db <경로> --extra-rw <경로> --repo <저장소 URL> --force(있는 파일 덮어쓰기)
# --limit-accounts N --limit-total 2G --limit-mailbox 500M --limit-attachment 20M --limit-recipients N --limit-monthly N
# --large-bytes 1G --large-days 30 --large-downloads 20 (대용량 첨부파일 시스템 상한, 0.9.3 — 무제한 불가, 기본 1G/30/20)
set -euo pipefail
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SITE="${1:-}"; [ -n "$SITE" ] && [ "${SITE#-}" = "$SITE" ] || { sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 1; }
shift
[[ "$SITE" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "사이트 이름은 영문 소문자·숫자·하이픈이어야 합니다: $SITE" >&2; exit 1; }
MAIL_HOST=""; MAILBOX=""; APP_USER="$SITE"; APP_NAME="$SITE-mail"; APP_DIR=""; ETC_DIR=""; NODE_BIN=""; PORT=8790; MAIL_FROM_NAME=""; SITE_TITLE=""
LOGIN_MODE=standalone; WWW_DB_FILE=""; EXTRA_RW=""; REPO_URL="https://source.ibit.kr/OZ-Group/AriMail.git"; FORCE=0
LIMIT_ACCOUNTS=0; LIMIT_TOTAL_BYTES=0; LIMIT_MAILBOX_BYTES=0; LIMIT_ATTACHMENT_BYTES=0; LIMIT_RECIPIENTS=0; LIMIT_MONTHLY_MESSAGES=0
LIMIT_LARGE_FILE_BYTES=1G; LIMIT_LARGE_FILE_DAYS=30; LIMIT_LARGE_FILE_DOWNLOADS=20
while [ $# -gt 0 ]; do
case "$1" in
--host) MAIL_HOST="$2"; shift 2 ;; --mailbox) MAILBOX="$2"; shift 2 ;; --user) APP_USER="$2"; shift 2 ;; --app-name) APP_NAME="$2"; shift 2 ;;
--app-dir) APP_DIR="$2"; shift 2 ;; --etc-dir) ETC_DIR="$2"; shift 2 ;; --node) NODE_BIN="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;;
--from-name) MAIL_FROM_NAME="$2"; shift 2 ;; --title) SITE_TITLE="$2"; shift 2 ;; --login) LOGIN_MODE="$2"; shift 2 ;; --www-db) WWW_DB_FILE="$2"; shift 2 ;;
--extra-rw) EXTRA_RW="$2"; shift 2 ;; --repo) REPO_URL="$2"; shift 2 ;; --force) FORCE=1; shift ;;
--limit-accounts) LIMIT_ACCOUNTS="$2"; shift 2 ;; --limit-total) LIMIT_TOTAL_BYTES="$2"; shift 2 ;; --limit-mailbox) LIMIT_MAILBOX_BYTES="$2"; shift 2 ;;
--limit-attachment) LIMIT_ATTACHMENT_BYTES="$2"; shift 2 ;; --limit-recipients) LIMIT_RECIPIENTS="$2"; shift 2 ;; --limit-monthly) LIMIT_MONTHLY_MESSAGES="$2"; shift 2 ;;
--large-bytes) LIMIT_LARGE_FILE_BYTES="$2"; shift 2 ;; --large-days) LIMIT_LARGE_FILE_DAYS="$2"; shift 2 ;; --large-downloads) LIMIT_LARGE_FILE_DOWNLOADS="$2"; shift 2 ;;
*) echo "알 수 없는 옵션: $1" >&2; exit 1 ;;
esac
done
[ -n "$MAIL_HOST" ] && [ -n "$MAILBOX" ] || { echo "--host 와 --mailbox 는 꼭 필요합니다" >&2; exit 1; }
[[ "$MAILBOX" == *@*.* ]] || { echo "--mailbox 는 이메일 주소여야 합니다: $MAILBOX" >&2; exit 1; }
case "$LOGIN_MODE" in
standalone) WWW_DB_FILE="" ;;
www) [ -n "$WWW_DB_FILE" ] || { echo "--login www 에는 --www-db <www 사이트 DB 경로> 가 필요합니다" >&2; exit 1; }; [ -n "$EXTRA_RW" ] || EXTRA_RW="$(dirname "$WWW_DB_FILE")" ;;
*) echo "--login 은 standalone 또는 www 입니다" >&2; exit 1 ;;
esac
for v in LIMIT_ACCOUNTS LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES; do [[ "${!v}" =~ ^[0-9]+$ ]] || { echo "$v 는 0 이상의 정수여야 합니다: ${!v}" >&2; exit 1; }; done
for v in LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS; do [[ "${!v}" =~ ^[1-9][0-9]*$ ]] || { echo "$v 는 1 이상의 정수여야 합니다(무제한 불가): ${!v}" >&2; exit 1; }; done
[[ "$LIMIT_LARGE_FILE_BYTES" =~ ^[1-9][0-9]*(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "LIMIT_LARGE_FILE_BYTES 는 0 이 아닌 크기(예: 1G)여야 합니다: $LIMIT_LARGE_FILE_BYTES" >&2; exit 1; }
for v in LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES; do [[ "${!v}" =~ ^[0-9]+(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "$v 는 크기(예: 500M, 2G, 0)여야 합니다: ${!v}" >&2; exit 1; }; done
: "${APP_DIR:=/srv/$APP_NAME}" "${ETC_DIR:=/etc/$APP_NAME}" "${NODE_BIN:=/opt/$APP_NAME-node/bin/node}" "${MAIL_FROM_NAME:=$SITE}"
OUT="$DEPLOY_DIR/sites/$SITE.env"
[ "$FORCE" = 1 ] || [ ! -e "$OUT" ] || { echo "이미 있습니다: $OUT (덮어쓰려면 --force)" >&2; exit 1; }
cat > "$OUT" <<ENV
# 사이트 설정: $MAIL_HOST (deploy/new-site.sh 가 $(date +%Y-%m-%d) 에 만듦) — 로그인 방식 $LOGIN_MODE
# setup.sh / deploy.sh / tls-when-ready.sh 가 읽는 셸 변수입니다. 설치: sudo env SITE=$SITE SOURCE_BUNDLE=<번들> bash deploy/setup.sh
SITE=$SITE
APP_NAME=$APP_NAME
APP_USER=$APP_USER
APP_DIR=$APP_DIR
ETC_DIR=$ETC_DIR
NODE_BIN=$NODE_BIN
PORT=$PORT
MAIL_HOST=$MAIL_HOST
MAILBOX=$MAILBOX
MAIL_FROM_NAME="$MAIL_FROM_NAME"
SITE_TITLE="$SITE_TITLE"
SITE_BRAND=""
# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본) / www = www 사이트 DB 로 관리자 인증(WWW_DB_FILE 필수)
LOGIN_MODE=$LOGIN_MODE
WWW_DB_FILE=$WWW_DB_FILE
EXTRA_RW=$EXTRA_RW
ACME_WEBROOT=/var/www/letsencrypt
CF_CERT=
CF_KEY=
REPO_URL=$REPO_URL
# 시스템 제한(0.9.0): 메일 시스템 전체의 상위 설정. 0 = 제한 없음. 크기는 500M/2G 식(MIME 변환 뒤 크기 기준).
LIMIT_ACCOUNTS=$LIMIT_ACCOUNTS
LIMIT_TOTAL_BYTES=$LIMIT_TOTAL_BYTES
LIMIT_MAILBOX_BYTES=$LIMIT_MAILBOX_BYTES
LIMIT_ATTACHMENT_BYTES=$LIMIT_ATTACHMENT_BYTES
LIMIT_RECIPIENTS=$LIMIT_RECIPIENTS
LIMIT_MONTHLY_MESSAGES=$LIMIT_MONTHLY_MESSAGES
# 대용량 첨부파일 시스템 상한(0.9.3): 무제한 불가.
LIMIT_LARGE_FILE_BYTES=$LIMIT_LARGE_FILE_BYTES
LIMIT_LARGE_FILE_DAYS=$LIMIT_LARGE_FILE_DAYS
LIMIT_LARGE_FILE_DOWNLOADS=$LIMIT_LARGE_FILE_DOWNLOADS
ENV
echo "✔ 만들었습니다: $OUT"
echo "다음: 서버에서 sudo env SITE=$SITE SOURCE_BUNDLE=/tmp/arimail.bundle bash <저장소>/deploy/setup.sh"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# nginx 설정만 다시 적용(0.9.5~) — root 로: sudo bash deploy/nginx-apply.sh <APP_DIR>/site.env
#
# setup.sh 전체를 다시 돌리지 않고(환경 파일·인증서 사본은 건드리지 않음) 저장소의 nginx 템플릿을 사이트 설정으로 채워
# 기존 vhost 파일을 바꿔 쓰고 nginx -t 로 검사한 뒤 reload 합니다. 템플릿은 setup.sh 와 같은 규칙으로 고릅니다
# (Let's Encrypt 인증서 → mail.conf.in, Cloudflare 오리진 인증서 → mail.cloudflare.conf.in, 둘 다 없으면 80 전용).
# vhost 파일 경로: sites-enabled 에서 이 호스트(server_name)를 쓰는 파일이 있으면 그 실제 파일(예전 이름 mail.arihakdang.conf 도 포함), 없으면 sites-available/<MAIL_HOST>.conf.
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
load_site "${1:?site.env 경로}"
REPO_DEPLOY="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.conf.in"
elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.cloudflare.conf.in"
else TEMPLATE="$REPO_DEPLOY/nginx/mail.http-only.conf.in"; fi
TARGET=""
for f in /etc/nginx/sites-enabled/*; do [ -f "$f" ] || continue; if grep -qE "^\s*server_name\s+$MAIL_HOST;" "$f"; then TARGET="$(readlink -f "$f")"; break; fi; done
[ -n "$TARGET" ] || { TARGET="/etc/nginx/sites-available/$MAIL_HOST.conf"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$MAIL_HOST.conf"; }
BACKUP="$TARGET.bak-$(date +%Y%m%d-%H%M%S)"; [ -f "$TARGET" ] && cp "$TARGET" "$BACKUP"
render "$TEMPLATE" "$TARGET"; chmod 644 "$TARGET"
if nginx -t; then systemctl reload nginx; echo "✔ nginx 적용: $TARGET ($(basename "$TEMPLATE")), 이전 설정 $BACKUP"
else echo "nginx 검사 실패 — 이전 설정으로 되돌립니다" >&2; [ -f "$BACKUP" ] && cp "$BACKUP" "$TARGET"; exit 1; fi
@@ -0,0 +1,27 @@
# AriMail 경로 접두사(1.0.8, 요구사항 1): 이 사이트의 @MAIL_PATH@/ 아래를 게이트웨이(127.0.0.1:@PORT@)로 넘깁니다. deploy/site-mail-path.sh 가 만들어
# /etc/nginx/snippets/ 에 두고, 사이트의 server(443) 블록에 include 합니다. 서브 도메인 사이트(site.example.com)처럼 메일용 호스트 이름을 따로 두기 어려울 때
# https://site.example.com@MAIL_PATH@/ 가 그 도메인의 웹 메일이 됩니다(전체 설정 화면의 호스트 항목은 "site.example.com@MAIL_PATH@").
#
# - proxy_pass 끝의 "/" 가 @MAIL_PATH@/ 를 떼고 넘기므로 게이트웨이는 늘 / 기준 경로를 봅니다. 대신 X-Forwarded-Prefix 로 접두사를 알려 주면 화면(<base>)·링크가 그 아래로 만들어집니다.
# - ^~ : 사이트 설정의 정규식 location(예: 숨김 파일 차단)이 이 경로를 가로채지 못하게 합니다.
# - 대용량 첨부파일(@MAIL_PATH@/api/files)은 본문 크기 제한 없이 버퍼링 없이 흘려보냅니다(mail.conf.in 과 같은 이유).
location = @MAIL_PATH@ { return 301 @MAIL_PATH@/; }
location ^~ @MAIL_PATH@/ {
proxy_pass http://127.0.0.1:@PORT@/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Prefix @MAIL_PATH@;
proxy_set_header CF-Connecting-IP "";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_no_cache 1;
proxy_cache_bypass 1;
# add_header 를 두지 않는 이유: location 안의 add_header 는 사이트 server 블록의 헤더(HSTS 등) 상속을 끊습니다. 캐시 금지·보안 헤더는 게이트웨이가 응답마다 붙입니다.
}
@@ -0,0 +1,76 @@
# @MAIL_HOST@ 가 Cloudflare 프록시(주황 구름) 뒤에 있을 때의 443 설정.
# Cloudflare 오리진 인증서(@CF_CERT@)를 써서 웹 화면과 웹훅을 바로 제공합니다.
# POP3·IMAP·SMTP 는 프록시를 지나지 못하므로, 메일 프로그램을 쓰려면 DNS 를 회색 구름으로 바꾸고
# Let's Encrypt 발급 후 setup.sh 로 mail.conf 로 교체합니다.
server {
listen 80; listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 301 https://@MAIL_HOST@$request_uri; }
}
server {
listen 443 ssl http2; listen [::]:443 ssl http2;
server_name @MAIL_HOST@;
ssl_certificate @CF_CERT@;
ssl_certificate_key @CF_KEY@;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off; charset utf-8; client_max_body_size 32m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cache-Control "no-store, no-transform" always;
# 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다.
# 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다.
# 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다.
location = /api/files {
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다.
location /dl/ {
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
location / {
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_read_timeout 180s;
proxy_no_cache 1; proxy_cache_bypass 1;
}
access_log /var/log/nginx/@APP_NAME@.access.log;
error_log /var/log/nginx/@APP_NAME@.error.log;
}

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