2026-10-26 11:58:15 +00:00
#!/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 "
2026-10-26 12:02:07 +00:00
$NODE_BIN hal/index.mjs register-release --manifest " $DIST /manifest.json" --approve --by bootstrap # hal artifact 에는 scripts/ 가 없으므로 HAL CLI 로 등록·artifact 복사
2026-10-26 11:58:15 +00:00
$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; }
2026-10-26 13:38:45 +00:00
echo "== 4/6 nginx $HOST · $PORTAL_HOST (인증서 $CERT )"
2026-10-26 13:43:51 +00:00
sudo -n test -f " $CERT /fullchain.pem" || { echo "인증서 $CERT /fullchain.pem 이 없습니다" >& 2; exit 1; } # /etc/letsencrypt/live 는 root 만 읽을 수 있어 sudo 로 확인한다(실제 환경 결함 수정)
2026-10-26 11:58:15 +00:00
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
2026-10-26 13:38:45 +00:00
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
sudo -n nginx -t && sudo -n systemctl reload nginx
2026-10-26 11:58:15 +00:00
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