서비스 간 인증 라이브러리와 포털↔HAL 작업 계약 문서

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:05:43 +00:00
co-authored by Claude Fable 5.1
parent 8607f19e62
commit 5cf7799b51
2 changed files with 90 additions and 0 deletions
+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` 만 받는다.
+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;
}