feat: add scoped image sync endpoint
This commit is contained in:
@@ -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
|
GITEA_WEBHOOK_SECRET_FILE=./secrets/gitea_webhook_secret
|
||||||
IMAGE_ADMIN_SECRET_FILE=./secrets/image_admin_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
|
||||||
|
|||||||
@@ -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
|
writer. The legacy PHP files remain in `hook/` for an explicit rollback, but
|
||||||
PHP and Node must never mutate the checkout concurrently.
|
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
|
Legacy HTTP mutation is disabled by default. An emergency PHP rollback must
|
||||||
first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel
|
first stop `image-hook`, then create the ignored `hook/legacy-enabled` sentinel
|
||||||
in the legacy checkout before restoring its Caddy/Gitea route. Remove the
|
in the legacy checkout before restoring its Caddy/Gitea route. Remove the
|
||||||
|
|||||||
+9
-2
@@ -4,7 +4,7 @@ services:
|
|||||||
image-hook:
|
image-hook:
|
||||||
build:
|
build:
|
||||||
context: ./node-hook
|
context: ./node-hook
|
||||||
image: sam-image-hook:1.0.0
|
image: sam-image-hook:1.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}"
|
user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}"
|
||||||
read_only: true
|
read_only: true
|
||||||
@@ -20,6 +20,7 @@ services:
|
|||||||
IMAGE_STATE_PATH: /var/lib/image-hook/state.json
|
IMAGE_STATE_PATH: /var/lib/image-hook/state.json
|
||||||
GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret
|
GITEA_WEBHOOK_SECRET_FILE: /run/secrets/gitea_webhook_secret
|
||||||
IMAGE_ADMIN_SECRET_FILE: /run/secrets/image_admin_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:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ${IMAGE_REPOSITORY_PATH:-.}
|
source: ${IMAGE_REPOSITORY_PATH:-.}
|
||||||
@@ -28,6 +29,8 @@ services:
|
|||||||
secrets:
|
secrets:
|
||||||
- gitea_webhook_secret
|
- gitea_webhook_secret
|
||||||
- image_admin_secret
|
- image_admin_secret
|
||||||
|
- image_sync_core_secret
|
||||||
|
- image_sync_core2026_secret
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=16m,mode=1777
|
- /tmp:size=16m,mode=1777
|
||||||
cap_drop: [ALL]
|
cap_drop: [ALL]
|
||||||
@@ -47,7 +50,7 @@ services:
|
|||||||
image-web:
|
image-web:
|
||||||
build:
|
build:
|
||||||
context: ./deploy/nginx
|
context: ./deploy/nginx
|
||||||
image: sam-image-web:1.0.0
|
image: sam-image-web:1.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
image-hook:
|
image-hook:
|
||||||
@@ -88,3 +91,7 @@ secrets:
|
|||||||
file: ${GITEA_WEBHOOK_SECRET_FILE:-./secrets/gitea_webhook_secret}
|
file: ${GITEA_WEBHOOK_SECRET_FILE:-./secrets/gitea_webhook_secret}
|
||||||
image_admin_secret:
|
image_admin_secret:
|
||||||
file: ${IMAGE_ADMIN_SECRET_FILE:-./secrets/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;
|
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 ^~ /v1/admin/ { return 404; }
|
||||||
location = /image { return 404; }
|
location = /image { return 404; }
|
||||||
location = /image/ { return 404; }
|
location = /image/ { return 404; }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ state_dir="$repository_dir/runtime-data"
|
|||||||
umask 077
|
umask 077
|
||||||
mkdir -p "$secret_dir"
|
mkdir -p "$secret_dir"
|
||||||
mkdir -p "$state_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"
|
path="$secret_dir/$name"
|
||||||
if [ ! -e "$path" ]; then
|
if [ ! -e "$path" ]; then
|
||||||
openssl rand -hex 32 > "$path"
|
openssl rand -hex 32 > "$path"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sam-image-hook",
|
"name": "sam-image-hook",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -18,6 +18,35 @@ function secret(name, fileName) {
|
|||||||
return readFileSync(path, 'utf8').trim();
|
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() {
|
export function loadConfig() {
|
||||||
const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE');
|
const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE');
|
||||||
const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE');
|
const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE');
|
||||||
@@ -44,6 +73,7 @@ export function loadConfig() {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
webhookSecret,
|
webhookSecret,
|
||||||
adminSecret,
|
adminSecret,
|
||||||
|
syncClientSecrets: syncClientSecrets(),
|
||||||
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
|
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
async #deploy({ branch, expectedCommit, allowUnrelated }) {
|
||||||
this.#validateBranch(branch);
|
this.#validateBranch(branch);
|
||||||
await this.#assertClean();
|
await this.#assertClean();
|
||||||
@@ -163,6 +179,7 @@ export class GitService {
|
|||||||
activeBranch: saved.activeBranch ?? this.config.defaultBranch,
|
activeBranch: saved.activeBranch ?? this.config.defaultBranch,
|
||||||
deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [],
|
deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [],
|
||||||
adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [],
|
adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [],
|
||||||
|
syncRequests: Array.isArray(saved.syncRequests) ? saved.syncRequests : [],
|
||||||
lastSuccess: saved.lastSuccess ?? null,
|
lastSuccess: saved.lastSuccess ?? null,
|
||||||
lastError: saved.lastError ?? null,
|
lastError: saved.lastError ?? null,
|
||||||
};
|
};
|
||||||
@@ -174,6 +191,7 @@ export class GitService {
|
|||||||
activeBranch: this.config.defaultBranch,
|
activeBranch: this.config.defaultBranch,
|
||||||
deliveries: [],
|
deliveries: [],
|
||||||
adminRequests: [],
|
adminRequests: [],
|
||||||
|
syncRequests: [],
|
||||||
lastSuccess: null,
|
lastSuccess: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ function parseJson(body) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createApp(config = loadConfig()) {
|
export async function createApp(config = loadConfig(), dependencies = {}) {
|
||||||
const service = new GitService(config);
|
const service = dependencies.service ?? new GitService(config);
|
||||||
await service.initialize();
|
await service.initialize();
|
||||||
|
|
||||||
const server = createServer(async (request, response) => {
|
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 });
|
const result = await service.deployAdmin({ requestId, branch: payload.branch, expectedCommit: payload.commit });
|
||||||
return json(response, 200, { ok: true, ...result });
|
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' });
|
return json(response, 404, { ok: false, reason: 'not found' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await service.recordError(error).catch(() => undefined);
|
await service.recordError(error).catch(() => undefined);
|
||||||
|
|||||||
@@ -124,3 +124,19 @@ test('same-branch force pushes and payload SHA mismatches are rejected', async (
|
|||||||
/Payload commit does not match remote branch tip/,
|
/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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user