feat: add scoped image sync endpoint

This commit is contained in:
2026-08-06 14:58:41 +00:00
parent 5078f6f3b1
commit 2fb618aa5c
11 changed files with 229 additions and 6 deletions
+2
View File
@@ -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
+24
View File
@@ -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.<exact JSON body>`. Requests expire after
five minutes and successful request IDs are persisted for replay protection.
The body is either `{}` or `{ "commit": "<full-image-commit-sha>" }`; 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
+9 -2
View File
@@ -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}
@@ -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; }
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sam-image-hook",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"engines": {
+30
View File
@@ -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')),
};
}
+18
View File
@@ -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,
};
+37 -2
View File
@@ -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);
+16
View File
@@ -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');
});
+79
View File
@@ -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);
});