From 5cf7799b51030eb6b161bb339e3471605e9f8e47 Mon Sep 17 00:00:00 2001 From: zzabisagent-lab Date: Mon, 26 Oct 2026 11:05:43 +0000 Subject: [PATCH] =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B0=84=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=ED=8F=AC=ED=84=B8=E2=86=94HAL=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EA=B3=84=EC=95=BD=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/HAL-JOBS.md | 41 +++++++++++++++++++++++++++++++++++++ lib/serviceauth.mjs | 49 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 docs/HAL-JOBS.md create mode 100644 lib/serviceauth.mjs diff --git a/docs/HAL-JOBS.md b/docs/HAL-JOBS.md new file mode 100644 index 0000000..1fc6c50 --- /dev/null +++ b/docs/HAL-JOBS.md @@ -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` 만 받는다. diff --git a/lib/serviceauth.mjs b/lib/serviceauth.mjs new file mode 100644 index 0000000..e1e2393 --- /dev/null +++ b/lib/serviceauth.mjs @@ -0,0 +1,49 @@ +/** + * lib/serviceauth.mjs — 서비스 간 호출 인증(포털 ↔ HAL, HAL ↔ Agent 공통). + * + * 요청 헤더 + * X-Service-Key-Id : 키 식별자(예: 'portal', 'hal', 'agent:') + * X-Service-Ts : 요청 시각(ms). ±5분 밖이면 거부(재전송 공격 완화) + * X-Service-Sign : HMAC-SHA256(secret, `${method}|${path}|${ts}|${sha256(body)}`) base64url + * 비밀은 양쪽 보관소(lib/vault)에 'service:' 로 둔다. 검증은 상수 시간 비교. + */ +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; +}