Compare commits

..
Author SHA1 Message Date
oz ac072e6f12 Redirect HAL root to operator console 2026-10-27 04:42:45 +00:00
zzabisagent-labandClaude Fable 5.1 83e3604b24 bear hal21.ariweb.net HAL 전용 부트스트랩 기록: RUNBOOK §7(절차·검증·되돌리기), VERSION_HISTORY, 로컬 E2E 보고서 갱신(빌드 ab75b7d)
- docs/RUNBOOK-0.1.1.md §7: 호스트별 certbot(webroot) 인증서, --acme /var/www/html --portal-host none 부트스트랩, 검증 결과, 되돌리기
- VERSION_HISTORY.md: 배포 artifact(main ab75b7d, hal sha256 f574d875…)와 5단계 거짓 경고 결함 기록

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 03:12:18 +00:00
zzabisagent-labandClaude Fable 5.1 ab75b7db4f bootstrap-hal.sh --portal-host none: HAL 전용 호스트(bear hal21.ariweb.net)에서 포털 nginx 접근 경로를 만들지 않음, 로컬 E2E 보고서(2026-10-27) 추가
- deploy/bootstrap-hal.sh: --portal-host none 이면 my.ariweb.net vhost 를 만들지 않는다(기본 동작 유지)
- docs/RUNBOOK-0.1.1.md, VERSION_HISTORY.md: HAL 전용 호스트 부트스트랩 옵션과 호스트별 certbot(webroot) 인증서 경로

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 03:04:43 +00:00
7 changed files with 4162 additions and 5 deletions
+6
View File
@@ -113,6 +113,12 @@
- **초안 사이트의 백업 중지 사유**(`portal/backup/service.mjs`): 미배포(초안) 사이트의 옵션을 정기 실행이 `site_cancelled`(해지) 로 중지해 표시가 틀렸다. `site_not_deployed`(사이트 미배포) 로 구분한다.
- 회귀 테스트: `test/agent.test.mjs` 에 실행 계정 모드(도우미 스크립트 실제 실행·경로 제한 거부)·REVISION 버전·기존 유닛 보존 검사를 추가했다.
### HAL 전용 호스트 부트스트랩(2026-10-27, bear `hal21.ariweb.net`)
- **`deploy/bootstrap-hal.sh --portal-host none`**: 포털을 다른 호스트에서 운영하는 HAL 전용 호스트에는 `my.ariweb.net` nginx 접근 경로를 만들지 않는다(이전에는 항상 만들어 포털이 없는 호스트에도 죽은 vhost 가 남았다). 기본값(`my.ariweb.net`)의 동작은 그대로다.
- bear(Ubuntu 24.04, nginx 1.24, Node 24.22, certbot nginx 플러그인 운영)에 HAL 만 최초 부트스트랩: 인증서는 호스트별 certbot(webroot `/var/www/html`, ECDSA, 갱신 훅 `systemctl reload nginx`) — `--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html`. 기존 Webmaker·Gitea·Matrix·기본 서버는 건드리지 않았다. 절차·검증·되돌리기는 `docs/RUNBOOK-0.1.1.md` §7. 배포 artifact: main `ab75b7d`, hal sha256 `f574d875…`(deploy/ 변경으로 ariweb.net 호스트의 `e7741899…` 와 다름). 알려진 사소한 결함: 부트스트랩 5단계의 HTTPS 자체 확인이 nginx reload 직후 실행되어 거짓 경고가 날 수 있다(결과에 영향 없음).
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
### 도입 소스
- 가져오기: `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) 를 설치 시점에 내려받는 래퍼만 포함.
+8 -4
View File
@@ -4,7 +4,8 @@
# 그 뒤 모든 모듈(포털·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]
# bash deploy/bootstrap-hal.sh --dist <dist/<version> 디렉터리(manifest.json·artifact)> --host hal.ariweb.net --cert /etc/letsencrypt/live/wh-wild-ariweb.net [--portal-host my.ariweb.net | none] [--app-dir /srv/ariweb/hal]
# --portal-host none : 포털 접근 경로(nginx my.ariweb.net → 127.0.0.1:8910)를 만들지 않는다 — HAL 만 두는 호스트(예: hal21.ariweb.net)용.
# 출력: 운영자 최초 설정 토큰(1회)과 첫 Agent 설치 명령. 비밀값은 그 밖에 출력하지 않는다.
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)"
@@ -37,13 +38,16 @@ 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 · $PORTAL_HOST (인증서 $CERT)"
echo "== 4/6 nginx $HOST$([ "$PORTAL_HOST" != none ] && echo " · $PORTAL_HOST") (인증서 $CERT)"
sudo -n test -f "$CERT/fullchain.pem" || { echo "인증서 $CERT/fullchain.pem 이 없습니다" >&2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
sed -e "s#{{HAL_HOST}}#$HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8900#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-hal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-hal.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/sites-enabled/ariweb-hal.conf; sudo -n cp deploy/nginx/awstats.conf /etc/nginx/conf.d/awstats.conf
# 포털 접근 경로(my.ariweb.net → 127.0.0.1:8910)도 여기서 준비한다 — 포털 자체는 HAL 작업으로 설치되지만 nginx 접근 경로는 "접근 경로 준비"(§32.2 2단계)에 속한다(실제 환경 결함 수정: 이전에는 어디서도 만들지 않았다).
sed -e "s#{{PORTAL_HOST}}#$PORTAL_HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8910#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-portal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-portal.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
# --portal-host none 이면 포털 접근 경로를 만들지 않는다(HAL 만 두는 호스트 — 포털은 다른 호스트에서 운영).
if [ "$PORTAL_HOST" != "none" ]; then
sed -e "s#{{PORTAL_HOST}}#$PORTAL_HOST#g" -e "s#{{CERT_DIR}}#$CERT#g" -e "s#{{PORT}}#8910#g" -e "s#{{ACME_ROOT}}#$ACME#g" deploy/nginx/ariweb-portal.conf.tmpl | sudo -n tee /etc/nginx/sites-available/ariweb-portal.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/ariweb-portal.conf /etc/nginx/sites-enabled/ariweb-portal.conf
fi
sudo -n nginx -t && sudo -n systemctl reload nginx
echo "== 5/6 HAL 자체 건강 확인"
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))})"
+32 -1
View File
@@ -24,7 +24,7 @@ bash /tmp/ariweb-hal-src/deploy/bootstrap-hal.sh --dist /tmp/ariweb-dist-0.1.1 -
확인: `curl -s https://hal.ariweb.net/api/health` 의 `version`·`db`·`jobs`·`bootstrap.halRunningVersion`.
DNS 레코드가 아직 없으면 같은 서버의 Agent·포털이 `https://hal.ariweb.net` 에 닿도록 `/etc/hosts` 에 `127.0.0.1 hal.ariweb.net my.ariweb.net` 을 임시로 둔다(cloud-init 이 `/etc/hosts` 를 관리하는 호스트는 재부팅 때 지워질 수 있다 — DNS 레코드가 생기면 필요 없다).
부트스트랩은 `my.ariweb.net` nginx 접근 경로(→ 127.0.0.1:8910)도 함께 만든다.
부트스트랩은 `my.ariweb.net` nginx 접근 경로(→ 127.0.0.1:8910)도 함께 만든다. HAL 만 두는 호스트(예: bear `hal21.ariweb.net`, 포털은 다른 호스트)는 `--portal-host none` 으로 포털 접근 경로를 만들지 않고, 인증서가 호스트별 certbot(webroot `/var/www/html`)이면 `--cert /etc/letsencrypt/live/<호스트> --acme /var/www/html` 을 준다(§7 참조).
## 3. 첫 Node Agent(같은 서버, 역할 control)
운영자 콘솔 › 서버 › 새 서버(역할 control, 위치 ariweb.net) → 등록 토큰(10분·1회) →
@@ -54,3 +54,34 @@ curl -fsSL https://hal.ariweb.net/agent/v1/install.sh | bash -s -- --hal https:/
## 6. 되돌리기
- 모듈: 운영자 콘솔 › 모듈 › 되돌리기(이전 릴리스 디렉터리 유지, 3개 보관).
- HAL: `deploy/hal-restore.sh` + 재해복구 절차(§34, 기존 제어 위치 중지 확인 뒤 `--takeover`).
## 7. HAL 전용 호스트 부트스트랩 — bear `hal21.ariweb.net` (2026-10-27)
대상: bear(`ssh bear`, 192.168.20.23 / 공인 121.152.149.45, master 계정, sudo NOPASSWD, Ubuntu 24.04, nginx 1.24, Node v24.22(엔진 요구 ≥22.13 충족), certbot 2.9 nginx 플러그인·`certbot.timer`). 기존 운영(Webmaker `webmaker.ibit.kr`·`hs-company.ariweb.net`, Gitea `source.ibit.kr`, Matrix, board/file/office/xmap, 기본 서버 `wh-default.conf`)은 건드리지 않는다. 이 호스트에는 **HAL 만** 둔다(포털·Webmaker·AriMail·Agent 는 다음 단계).
```bash
# 개발 PC: main 이 깨끗한 상태(미추적 파일 없음)에서 빌드 — 전체 테스트 뒤 dist/0.1.1 (manifest.mainCommit = HEAD, dirty=false)
node scripts/release.mjs build && node scripts/release.mjs verify --dist dist/0.1.1
scp -r dist/0.1.1 bear:ariweb-deploy-0.1.1/dist
# bear: 호스트별 인증서(기존 인증서는 ibit.kr 7개뿐, ariweb.net 와일드카드 없음). 기본 서버가 /.well-known/acme-challenge/ 를 /var/www/html 로 서비스하므로 webroot 로 발급한다(nginx 설정을 건드리지 않음)
sudo certbot certonly --webroot -w /var/www/html -d hal21.ariweb.net --non-interactive --keep-until-expiring
sudo certbot reconfigure --cert-name hal21.ariweb.net --deploy-hook "systemctl reload nginx" # 갱신 뒤 reload(installer 가 없으므로)
# bear: 부트스트랩(출력에 1회 설정 토큰이 있으므로 0600 파일로만 받는다)
cd ~/ariweb-deploy-0.1.1 && mkdir -p -m 700 src && tar -xzf dist/hal-0.1.1.tar.gz -C src
umask 077; bash src/deploy/bootstrap-hal.sh --dist ~/ariweb-deploy-0.1.1/dist --host hal21.ariweb.net \
--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html --portal-host none > bootstrap.log 2>&1
```
- `--acme /var/www/html`: HAL vhost 의 acme 경로를 webroot 발급 경로와 같게 둔다(아니면 vhost 가 생긴 뒤 갱신 실패).
- 만든 것: `/srv/ariweb/hal/{releases/0.1.1,current,data(0700)}`, `/var/www/ariweb-sites`, 유닛 `ariweb-hal`(enabled, 127.0.0.1:8900), nginx `sites-available/ariweb-hal.conf`(+enabled 링크), `conf.d/awstats.conf`(log_format awstats — 기존 wmstats 와 이름이 다름), `/etc/sudoers.d/ariweb-agent`(master 는 이미 NOPASSWD ALL 이라 추가 권한 없음). 포털 vhost 는 만들지 않았다.
- 비밀값: 설정 토큰은 `/srv/ariweb/hal/data/setup.token`(0600)과 `~/ariweb-deploy-0.1.1/bootstrap.log`(0600)에만 있다. 첫 운영자 생성(`POST /ops/api/setup`)은 이 단계에서 하지 않았다 — `/ops` 는 허용 네트워크(기본 사설망 CIDR) 또는 접근권이 필요하다.
- 알려진 사소한 결함: 5단계의 `https://<host>` 자체 확인은 `nginx reload` 직후 바로 실행되어 새 vhost 가 아직 적용되기 전에 "unrecognized name" 경고가 날 수 있다(거짓 경고, 몇 초 뒤 정상). 다음 릴리스에서 reload 뒤 짧은 대기를 넣는다.
검증(2026-10-27 03:08~03:12 UTC, 모두 통과): `systemctl is-active/is-enabled ariweb-hal` active/enabled · 127.0.0.1:8900 리슨(MainPID) · `nginx -t` 성공 후 reload, 기존 nginx 설정 파일 12개 sha256 변화 없음 · `https://hal21.ariweb.net/api/health` 외부(LAN 밖 fetch·LAN NAT 양쪽)에서 200, 인증서 CN=hal21.ariweb.net(Let's Encrypt, 2027-01-25 만료), HTTP→HTTPS 301 · 건강 응답 version 0.1.1·mainCommit ab75b7d·artifactSha256 f574d875…·bootstrap.complete=false(첫 Agent 전) · `systemctl restart ariweb-hal` 뒤 재기동·건강 ok(NRestarts 0, enabled 로 재부팅 지속; 서버 재부팅은 기존 서비스 중단이라 하지 않음) · 기존 서비스 회귀: nginx/webmaker/webmaker-setup/webmaker-gitea-reverse-tunnel/docker/certbot.timer active, 기존 vhost 8개 응답 코드 배포 전과 동일 · `certbot reconfigure` 의 모의 갱신 성공, `renew_hook = systemctl reload nginx`.
되돌리기(HAL 만 제거, 기존 서비스 영향 없음):
```bash
sudo systemctl disable --now ariweb-hal && sudo rm /etc/systemd/system/ariweb-hal.service && sudo systemctl daemon-reload
sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariweb-hal.conf /etc/nginx/conf.d/awstats.conf && sudo nginx -t && sudo systemctl reload nginx
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
```
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2
View File
@@ -36,6 +36,8 @@ export function buildApp(d) {
const uiDir = join(ROOT, 'hal', 'ui', 'public'); if (existsSync(uiDir)) app.static('/ops/ui', uiDir);
// ---------------- 공개
/** 기본 주소는 운영자가 실제로 사용하는 콘솔 경로로 안내한다. 접근 통제는 이동 후 /ops 미들웨어가 동일하게 적용한다. */
app.get('/', (ctx) => ctx.redirect('/ops/'));
app.get('/api/health', () => core.selfHealth(d));
app.get('/api/status', () => publicStatus(d));
app.get('/status', (ctx) => ctx.html(statusPage(d)));
+6
View File
@@ -22,6 +22,12 @@ function makeHal(over = {}) {
/** 운영자 계정 + 로그인 세션 */
async function loginOp(h) { ops.createAccount(h.d, { login: 'ozops', password: 'password-example-1', displayName: '오즈' }); const r = await invoke(h.app, 'POST', '/ops/api/login', { body: { login: 'ozops', password: 'password-example-1' } }); ok(r.json.ok, JSON.stringify(r.json)); return { cookie: r.cookies.aw_ops, csrf: r.json.csrf, as: (method, path, body, extra = {}) => invoke(h.app, method, path, { body, cookies: { aw_ops: r.cookies.aw_ops, ...(extra.cookies || {}) }, headers: { 'x-csrf': r.json.csrf, ...(extra.headers || {}) }, ip: extra.ip }) }; }
await test('HAL 루트 주소는 운영자 콘솔 기본 경로로 안내한다', async () => {
const h = makeHal();
const r = await invoke(h.app, 'GET', '/');
eq(r.status, 302, '루트 주소는 404가 아니라 운영자 콘솔로 이동');
eq(r.headers.Location, '/ops/', '운영자 콘솔의 표준 경로로 이동');
});
await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠키 → 로그인 화면, 운영자 세션 → 콘솔(메뉴 14개 + 계정)', async () => {
const h = makeHal();
let r = await invoke(h.app, 'GET', '/ops/', { ip: '203.0.113.9' }); eq(r.status, 403); eq(r.json.error, 'access_denied');