diff --git a/.env.example b/.env.example index 20c3a53..53591f5 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,5 @@ IMAGE_PUBLIC_BASES=https://sam.hided.net/image,https://sam-image.hided.net GITEA_WEBHOOK_SECRET_FILE=./secrets/gitea_webhook_secret IMAGE_ADMIN_SECRET_FILE=./secrets/image_admin_secret +IMAGE_SYNC_CORE_SECRET_FILE=./secrets/image_sync_core_secret +IMAGE_SYNC_CORE2026_SECRET_FILE=./secrets/image_sync_core2026_secret diff --git a/README.md b/README.md index 048aaac..4dbec1d 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,30 @@ Use branch filter `master`. Disable the old PHP webhook before enabling the new writer. The legacy PHP files remain in `hook/` for an explicit rollback, but PHP and Node must never mutate the checkout concurrently. +### Fallback sync callers + +If Gitea webhook delivery is missed, Core and Core2026 can request a restricted +reconciliation through `POST /v1/sync`. This endpoint cannot select or change a +branch: it only fetches the current active branch and applies the same clean +worktree and fast-forward checks as a webhook deployment. + +Each caller has a separate secret: + +- `secrets/image_sync_core_secret` for legacy Core +- `secrets/image_sync_core2026_secret` for Core2026 + +The caller sends its name, a timestamp, a unique request ID, and an HMAC-SHA256 +signature over `timestamp.request-id.`. Requests expire after +five minutes and successful request IDs are persisted for replay protection. +The body is either `{}` or `{ "commit": "" }`; all other +fields are rejected. The optional commit asserts the expected remote tip and +does not grant checkout selection. + +Distribute only the matching caller secret through an ignored secret file. +Never give either caller `image_admin_secret`, which also authorizes explicit +branch changes. This fallback handles webhook delivery outages; if the image +service itself is stopped, restore it and run the caller command again. + Legacy HTTP mutation is disabled by default. An emergency PHP rollback must first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel in the legacy checkout before restoring its Caddy/Gitea route. Remove the diff --git a/compose.yaml b/compose.yaml index 85629e0..644a20c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ services: image-hook: build: context: ./node-hook - image: sam-image-hook:1.0.0 + image: sam-image-hook:1.1.0 restart: unless-stopped user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}" read_only: true @@ -20,6 +20,7 @@ services: IMAGE_STATE_PATH: /var/lib/image-hook/state.json GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret IMAGE_ADMIN_SECRET_FILE: /run/secrets/image_admin_secret + IMAGE_SYNC_CLIENT_SECRET_FILES: core=/run/secrets/image_sync_core_secret,core2026=/run/secrets/image_sync_core2026_secret volumes: - type: bind source: ${IMAGE_REPOSITORY_PATH:-.} @@ -28,6 +29,8 @@ services: secrets: - gitea_webhook_secret - image_admin_secret + - image_sync_core_secret + - image_sync_core2026_secret tmpfs: - /tmp:size=16m,mode=1777 cap_drop: [ALL] @@ -47,7 +50,7 @@ services: image-web: build: context: ./deploy/nginx - image: sam-image-web:1.0.0 + image: sam-image-web:1.1.0 restart: unless-stopped depends_on: image-hook: @@ -88,3 +91,7 @@ secrets: file: ${GITEA_WEBHOOK_SECRET_FILE:-./secrets/gitea_webhook_secret} image_admin_secret: file: ${IMAGE_ADMIN_SECRET_FILE:-./secrets/image_admin_secret} + image_sync_core_secret: + file: ${IMAGE_SYNC_CORE_SECRET_FILE:-./secrets/image_sync_core_secret} + image_sync_core2026_secret: + file: ${IMAGE_SYNC_CORE2026_SECRET_FILE:-./secrets/image_sync_core2026_secret} diff --git a/deploy/nginx/templates/default.conf.template b/deploy/nginx/templates/default.conf.template index b2526c1..acbea67 100644 --- a/deploy/nginx/templates/default.conf.template +++ b/deploy/nginx/templates/default.conf.template @@ -57,6 +57,18 @@ http { proxy_request_buffering on; } + location = /v1/sync { + limit_except POST { deny all; } + client_max_body_size 4k; + proxy_pass http://image-hook:8081/v1/sync; + proxy_set_header Host $host; + proxy_set_header X-Image-Client $http_x_image_client; + proxy_set_header X-Image-Timestamp $http_x_image_timestamp; + proxy_set_header X-Image-Request-Id $http_x_image_request_id; + proxy_set_header X-Image-Signature $http_x_image_signature; + proxy_request_buffering on; + } + location ^~ /v1/admin/ { return 404; } location = /image { return 404; } location = /image/ { return 404; } diff --git a/deploy/scripts/init-secrets.sh b/deploy/scripts/init-secrets.sh index 5c6ed33..91fbbf6 100755 --- a/deploy/scripts/init-secrets.sh +++ b/deploy/scripts/init-secrets.sh @@ -9,7 +9,7 @@ state_dir="$repository_dir/runtime-data" umask 077 mkdir -p "$secret_dir" mkdir -p "$state_dir" -for name in gitea_webhook_secret image_admin_secret; do +for name in gitea_webhook_secret image_admin_secret image_sync_core_secret image_sync_core2026_secret; do path="$secret_dir/$name" if [ ! -e "$path" ]; then openssl rand -hex 32 > "$path" diff --git a/node-hook/package.json b/node-hook/package.json index d8f186e..1f9f673 100644 --- a/node-hook/package.json +++ b/node-hook/package.json @@ -1,6 +1,6 @@ { "name": "sam-image-hook", - "version": "1.0.0", + "version": "1.1.0", "private": true, "type": "module", "engines": { diff --git a/node-hook/src/config.mjs b/node-hook/src/config.mjs index 96afd76..a40dae0 100644 --- a/node-hook/src/config.mjs +++ b/node-hook/src/config.mjs @@ -18,6 +18,35 @@ function secret(name, fileName) { return readFileSync(path, 'utf8').trim(); } +function syncClientSecrets() { + const entries = text('IMAGE_SYNC_CLIENT_SECRET_FILES', '') + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.length === 0) { + throw new Error('IMAGE_SYNC_CLIENT_SECRET_FILES is required'); + } + + const result = Object.create(null); + for (const entry of entries) { + const separator = entry.indexOf('='); + const client = entry.slice(0, separator); + const path = entry.slice(separator + 1); + if (separator < 1 || !/^[a-z0-9][a-z0-9_-]{1,31}$/.test(client) || !path) { + throw new Error(`Invalid image sync client entry: ${entry}`); + } + const value = readFileSync(path, 'utf8').trim(); + if (value.length < 32) { + throw new Error(`Image sync secret for ${client} must be at least 32 characters`); + } + if (result[client]) { + throw new Error(`Duplicate image sync client: ${client}`); + } + result[client] = value; + } + return result; +} + export function loadConfig() { const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE'); const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE'); @@ -44,6 +73,7 @@ export function loadConfig() { .filter(Boolean), webhookSecret, adminSecret, + syncClientSecrets: syncClientSecrets(), maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')), }; } diff --git a/node-hook/src/git-service.mjs b/node-hook/src/git-service.mjs index dc85b8c..863cf65 100644 --- a/node-hook/src/git-service.mjs +++ b/node-hook/src/git-service.mjs @@ -77,6 +77,22 @@ export class GitService { }); } + async deploySync({ requestKey, expectedCommit }) { + return this.enqueue(async () => { + if (this.state.syncRequests.includes(requestKey)) { + return { duplicate: true, ...this.publicStatus() }; + } + const result = await this.#deploy({ + branch: this.state.activeBranch, + expectedCommit, + allowUnrelated: false, + }); + this.state.syncRequests = [...this.state.syncRequests.slice(-199), requestKey]; + await this.#saveState(); + return result; + }); + } + async #deploy({ branch, expectedCommit, allowUnrelated }) { this.#validateBranch(branch); await this.#assertClean(); @@ -163,6 +179,7 @@ export class GitService { activeBranch: saved.activeBranch ?? this.config.defaultBranch, deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [], adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [], + syncRequests: Array.isArray(saved.syncRequests) ? saved.syncRequests : [], lastSuccess: saved.lastSuccess ?? null, lastError: saved.lastError ?? null, }; @@ -174,6 +191,7 @@ export class GitService { activeBranch: this.config.defaultBranch, deliveries: [], adminRequests: [], + syncRequests: [], lastSuccess: null, lastError: null, }; diff --git a/node-hook/src/server.mjs b/node-hook/src/server.mjs index a65c4ad..512c8eb 100644 --- a/node-hook/src/server.mjs +++ b/node-hook/src/server.mjs @@ -35,8 +35,8 @@ function parseJson(body) { } } -export async function createApp(config = loadConfig()) { - const service = new GitService(config); +export async function createApp(config = loadConfig(), dependencies = {}) { + const service = dependencies.service ?? new GitService(config); await service.initialize(); const server = createServer(async (request, response) => { @@ -100,6 +100,41 @@ export async function createApp(config = loadConfig()) { const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit }); return json(response, 200, { ok: true, ...result }); } + if (request.method === 'POST' && url.pathname === '/v1/sync') { + if (!request.headers['content-type']?.toLowerCase().startsWith('application/json')) { + throw new DeploymentError('Content-Type must be application/json', 415); + } + const body = await readBody(request, config.maxBodyBytes); + const client = request.headers['x-image-client']; + const timestamp = request.headers['x-image-timestamp']; + const requestId = request.headers['x-image-request-id']; + const knownClient = typeof client === 'string' + && Object.hasOwn(config.syncClientSecrets, client); + const signatureValid = verifyAdminSignature({ + secret: knownClient ? config.syncClientSecrets[client] : 'invalid-client-secret'.padEnd(32, '!'), + timestamp, + requestId, + body, + supplied: request.headers['x-image-signature'], + }); + if (!knownClient || !signatureValid) { + return json(response, 401, { ok: false, reason: 'invalid sync signature' }); + } + const payload = parseJson(body); + if (!payload || Array.isArray(payload) || typeof payload !== 'object' + || Object.keys(payload).some((key) => key !== 'commit')) { + throw new DeploymentError('Sync body may only contain commit', 400); + } + if (payload.commit !== undefined + && (typeof payload.commit !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.commit))) { + throw new DeploymentError('Invalid target commit', 400); + } + const result = await service.deploySync({ + requestKey: `${client}:${requestId}`, + expectedCommit: payload.commit, + }); + return json(response, 200, { ok: true, ...result }); + } return json(response, 404, { ok: false, reason: 'not found' }); } catch (error) { await service.recordError(error).catch(() => undefined); diff --git a/node-hook/test/git-service.test.mjs b/node-hook/test/git-service.test.mjs index 593b59e..521978b 100644 --- a/node-hook/test/git-service.test.mjs +++ b/node-hook/test/git-service.test.mjs @@ -124,3 +124,19 @@ test('same-branch force pushes and payload SHA mismatches are rejected', async ( /Payload commit does not match remote branch tip/, ); }); + +test('signed sync callers can only fast-forward the active branch and requests are idempotent', async (t) => { + const f = await fixture(); + t.after(() => rm(f.root, { recursive: true, force: true })); + await writeFile(join(f.seed, 'icons', 'sync.jpg'), 'sync'); + await git(f.seed, 'add', '.'); + await git(f.seed, 'commit', '-m', 'sync target'); + await git(f.seed, 'push', 'origin', 'master'); + const target = await git(f.seed, 'rev-parse', 'HEAD'); + + const result = await f.service.deploySync({ requestKey: 'core:sync-request-1', expectedCommit: target }); + assert.equal(result.changed, true); + assert.equal(await git(f.deployed, 'rev-parse', 'HEAD'), target); + assert.equal((await f.service.deploySync({ requestKey: 'core:sync-request-1' })).duplicate, true); + assert.equal(f.service.publicStatus().activeBranch, 'master'); +}); diff --git a/node-hook/test/server.test.mjs b/node-hook/test/server.test.mjs new file mode 100644 index 0000000..c8f0fbb --- /dev/null +++ b/node-hook/test/server.test.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import test from 'node:test'; +import { adminSignature } from '../src/auth.mjs'; +import { createApp } from '../src/server.mjs'; + +test('sync endpoint authenticates a scoped caller and passes only an optional commit', async (t) => { + const calls = []; + const service = { + async initialize() {}, + async deploySync(value) { + calls.push(value); + return { changed: false }; + }, + async recordError() {}, + }; + const secret = 's'.repeat(32); + const { server } = await createApp({ + maxBodyBytes: 4096, + syncClientSecrets: { core: secret }, + }, { service }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => server.close()); + const address = server.address(); + const body = Buffer.from(JSON.stringify({ commit: 'a'.repeat(40) })); + const timestamp = String(Date.now()); + const requestId = 'sync-request-1234'; + const response = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-image-client': 'core', + 'x-image-timestamp': timestamp, + 'x-image-request-id': requestId, + 'x-image-signature': adminSignature(secret, timestamp, requestId, body), + }, + body, + }); + + assert.equal(response.status, 200); + assert.deepEqual(calls, [{ requestKey: `core:${requestId}`, expectedCommit: 'a'.repeat(40) }]); +}); + +test('sync endpoint rejects unknown callers and body fields outside the sync contract', async (t) => { + const service = { + async initialize() {}, + async deploySync() { throw new Error('must not deploy'); }, + async recordError() {}, + }; + const secret = 's'.repeat(32); + const { server } = await createApp({ maxBodyBytes: 4096, syncClientSecrets: { core: secret } }, { service }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + t.after(() => server.close()); + const address = server.address(); + const body = Buffer.from(JSON.stringify({ branch: 'preview' })); + const timestamp = String(Date.now()); + const requestId = 'sync-request-5678'; + const signedHeaders = { + 'content-type': 'application/json', + 'x-image-timestamp': timestamp, + 'x-image-request-id': requestId, + 'x-image-signature': adminSignature(secret, timestamp, requestId, body), + }; + + const unknown = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, { + method: 'POST', headers: { ...signedHeaders, 'x-image-client': 'unknown' }, body, + }); + assert.equal(unknown.status, 401); + const prototypeName = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, { + method: 'POST', headers: { ...signedHeaders, 'x-image-client': 'toString' }, body, + }); + assert.equal(prototypeName.status, 401); + const extraField = await fetch(`http://127.0.0.1:${address.port}/v1/sync`, { + method: 'POST', headers: { ...signedHeaders, 'x-image-client': 'core' }, body, + }); + assert.equal(extraField.status, 400); +});