Add secure Node image webhook service
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
IMAGE_BIND_ADDRESS=0.0.0.0
|
||||||
|
IMAGE_PORT=8191
|
||||||
|
IMAGE_REPOSITORY_PATH=/home/letrhee/sam_rebuild/image
|
||||||
|
IMAGE_UID=1000
|
||||||
|
IMAGE_GID=1000
|
||||||
|
CADDY_SOURCE_CIDR=172.30.1.75/32
|
||||||
|
|
||||||
|
IMAGE_REMOTE_URL=https://gitea.hided.net/devsam/image.git
|
||||||
|
IMAGE_REPOSITORY_FULL_NAME=devsam/image
|
||||||
|
IMAGE_DEFAULT_BRANCH=master
|
||||||
|
IMAGE_ALLOWED_BRANCHES=master
|
||||||
|
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
|
||||||
@@ -2,3 +2,8 @@
|
|||||||
/hook/logs.txt
|
/hook/logs.txt
|
||||||
/hook/list.json
|
/hook/list.json
|
||||||
/hook/HashKey.php
|
/hook/HashKey.php
|
||||||
|
/hook/inventory.v2.json
|
||||||
|
/.env
|
||||||
|
/secrets/*
|
||||||
|
!/secrets/.gitkeep
|
||||||
|
/runtime-data/
|
||||||
|
|||||||
@@ -16,3 +16,104 @@ The core2026 repository owns `resources/general-icons.json` and
|
|||||||
`tools/manage-general-icons.mjs`. Use that tool with this repository as
|
`tools/manage-general-icons.mjs`. Use that tool with this repository as
|
||||||
`--image-root` to synchronize aliases, scenario paths, and verify that every
|
`--image-root` to synchronize aliases, scenario paths, and verify that every
|
||||||
catalog source exists and has identical bytes.
|
catalog source exists and has identical bytes.
|
||||||
|
|
||||||
|
## Public URLs
|
||||||
|
|
||||||
|
The same tracked files are exposed through both URL contracts:
|
||||||
|
|
||||||
|
- `https://sam.hided.net/image/game/...` and `/image/icons/...`
|
||||||
|
- `https://sam-image.hided.net/game/...` and `/icons/...`
|
||||||
|
- `https://sam-image.hided.net/image/...` is a compatibility alias.
|
||||||
|
|
||||||
|
Do not redirect the old `/image/*` URLs to the dedicated domain. Existing PHP
|
||||||
|
and core2026 clients use same-origin relative paths and can move independently.
|
||||||
|
The image server never exposes the repository root, `.git`, PHP sources, or
|
||||||
|
directory listings.
|
||||||
|
|
||||||
|
## Node webhook deployment service
|
||||||
|
|
||||||
|
`compose.yaml` runs a read-only Nginx static edge and an internal Node service.
|
||||||
|
Only the edge publishes port 8191. The Node service verifies the raw Gitea
|
||||||
|
`X-Gitea-Signature`, accepts push events for `devsam/image`, fetches the exact
|
||||||
|
remote branch tip, rejects dirty or non-fast-forward updates, and serializes all
|
||||||
|
Git changes. The initial and only allowed branch is `master` unless
|
||||||
|
`IMAGE_ALLOWED_BRANCHES` is explicitly expanded.
|
||||||
|
|
||||||
|
The current Caddy host reaches this server from `172.30.1.75`. Keep the observed
|
||||||
|
source in the untracked `.env`; re-check it whenever Caddy networking changes.
|
||||||
|
The published port binds all server interfaces, so production also needs a
|
||||||
|
host `DOCKER-USER` (or equivalent) firewall rule allowing that source CIDR to
|
||||||
|
TCP 8191 and rejecting other sources. Nginx applies the same source allowlist.
|
||||||
|
|
||||||
|
### Prepare
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
./deploy/scripts/init-secrets.sh
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply and inspect the dedicated Docker ingress chain with root privileges:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo env CADDY_SOURCE_CIDR=172.30.1.75/32 IMAGE_PORT=8191 \
|
||||||
|
./deploy/scripts/firewall-8191.sh apply
|
||||||
|
sudo ./deploy/scripts/firewall-8191.sh check
|
||||||
|
```
|
||||||
|
|
||||||
|
The script only owns the `SAM_IMAGE_INGRESS` chain and its port-8191 jump from
|
||||||
|
`DOCKER-USER`; it does not flush shared firewall chains.
|
||||||
|
|
||||||
|
Secret values are generated under ignored `secrets/` files with mode 0600 and
|
||||||
|
are never printed. Put the contents of `secrets/gitea_webhook_secret` into the
|
||||||
|
Gitea webhook configuration. Configure a JSON push webhook targeting:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://sam-image.hided.net/v1/hooks/gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Start and verify
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose ps
|
||||||
|
curl -fsS https://sam-image.hided.net/healthz
|
||||||
|
curl -fsS https://sam-image.hided.net/v1/status
|
||||||
|
curl -fsS https://sam-image.hided.net/game/back.jpg -o /dev/null
|
||||||
|
curl -fsS https://sam-image.hided.net/image/icons/default.jpg -o /dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx serves only `game/`, `icons/`, `hook/list.json`, and
|
||||||
|
`hook/inventory.v2.json`. The API inventory additionally reports the deployed
|
||||||
|
branch, full commit, generation time, asset list, and both public base URLs.
|
||||||
|
|
||||||
|
### Explicit branch deployment
|
||||||
|
|
||||||
|
Automatic pushes only update the active branch. Add a branch to
|
||||||
|
`IMAGE_ALLOWED_BRANCHES`, recreate `image-hook`, and switch it with the internal
|
||||||
|
signed administration command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./deploy/scripts/admin-deploy.sh <branch> [expected-commit]
|
||||||
|
```
|
||||||
|
|
||||||
|
The administration route is not proxied through Nginx or Caddy.
|
||||||
|
|
||||||
|
### Tests and rollback
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t sam-image-hook:test node-hook
|
||||||
|
docker run --rm sam-image-hook:test npm test
|
||||||
|
docker compose exec -T image-web nginx -t -c /tmp/nginx.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Stopping `image-hook` does not remove the last checked-out files from the
|
||||||
|
static service. For a full rollback, stop Node mutation first, restore the old
|
||||||
|
Caddy/PHP webhook route, and only then enable the legacy writer. Preserve both
|
||||||
|
checkouts until public file hashes and representative browser requests have
|
||||||
|
been verified.
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: sam-image
|
||||||
|
|
||||||
|
services:
|
||||||
|
image-hook:
|
||||||
|
build:
|
||||||
|
context: ./node-hook
|
||||||
|
image: sam-image-hook:1.0.0
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${IMAGE_UID:-1000}:${IMAGE_GID:-1000}"
|
||||||
|
read_only: true
|
||||||
|
init: true
|
||||||
|
environment:
|
||||||
|
PORT: "8081"
|
||||||
|
IMAGE_REPOSITORY_PATH: /data/image
|
||||||
|
IMAGE_REMOTE_URL: ${IMAGE_REMOTE_URL:-https://gitea.hided.net/devsam/image.git}
|
||||||
|
IMAGE_REPOSITORY_FULL_NAME: ${IMAGE_REPOSITORY_FULL_NAME:-devsam/image}
|
||||||
|
IMAGE_DEFAULT_BRANCH: ${IMAGE_DEFAULT_BRANCH:-master}
|
||||||
|
IMAGE_ALLOWED_BRANCHES: ${IMAGE_ALLOWED_BRANCHES:-master}
|
||||||
|
IMAGE_PUBLIC_BASES: ${IMAGE_PUBLIC_BASES:-https://sam.hided.net/image,https://sam-image.hided.net}
|
||||||
|
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
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${IMAGE_REPOSITORY_PATH:-.}
|
||||||
|
target: /data/image
|
||||||
|
- ./runtime-data:/var/lib/image-hook
|
||||||
|
secrets:
|
||||||
|
- gitea_webhook_secret
|
||||||
|
- image_admin_secret
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=16m,mode=1777
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 64
|
||||||
|
mem_limit: 256m
|
||||||
|
cpus: 1.0
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, node, -e, "fetch('http://127.0.0.1:8081/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
start_period: 10s
|
||||||
|
networks: [image-internal, image-egress]
|
||||||
|
|
||||||
|
image-web:
|
||||||
|
build:
|
||||||
|
context: ./deploy/nginx
|
||||||
|
image: sam-image-web:1.0.0
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
image-hook:
|
||||||
|
condition: service_healthy
|
||||||
|
read_only: true
|
||||||
|
environment:
|
||||||
|
CADDY_SOURCE_CIDR: ${CADDY_SOURCE_CIDR:?Set CADDY_SOURCE_CIDR to the direct Caddy source CIDR}
|
||||||
|
ports:
|
||||||
|
- "${IMAGE_BIND_ADDRESS:-0.0.0.0}:${IMAGE_PORT:-8191}:8080"
|
||||||
|
volumes:
|
||||||
|
- type: bind
|
||||||
|
source: ${IMAGE_REPOSITORY_PATH:-.}
|
||||||
|
target: /srv/image
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=8m,mode=1777
|
||||||
|
cap_drop: [ALL]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
pids_limit: 32
|
||||||
|
mem_limit: 64m
|
||||||
|
cpus: 0.5
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, wget, -q, -O, /dev/null, http://127.0.0.1:8080/healthz]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 6
|
||||||
|
networks: [image-internal, image-edge]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
image-internal:
|
||||||
|
internal: true
|
||||||
|
image-egress:
|
||||||
|
image-edge:
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
gitea_webhook_secret:
|
||||||
|
file: ${GITEA_WEBHOOK_SECRET_FILE:-./secrets/gitea_webhook_secret}
|
||||||
|
image_admin_secret:
|
||||||
|
file: ${IMAGE_ADMIN_SECRET_FILE:-./secrets/image_admin_secret}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FROM nginx:1.29.5-alpine@sha256:1eff5a5f3fcf8431a0abb7eddf5471fec24e5e1905a2581aeacdb07a4479b92b
|
||||||
|
|
||||||
|
COPY templates/default.conf.template /etc/image/default.conf.template
|
||||||
|
COPY entrypoint.sh /usr/local/bin/image-web-entrypoint
|
||||||
|
RUN chmod 0555 /usr/local/bin/image-web-entrypoint
|
||||||
|
|
||||||
|
USER nginx
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/usr/local/bin/image-web-entrypoint"]
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ -z "${CADDY_SOURCE_CIDR:-}" ]; then
|
||||||
|
echo "CADDY_SOURCE_CIDR is required" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
envsubst '${CADDY_SOURCE_CIDR}' \
|
||||||
|
< /etc/image/default.conf.template \
|
||||||
|
> /tmp/nginx.conf
|
||||||
|
|
||||||
|
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
error_log /dev/stderr notice;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 256;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
access_log /dev/stdout combined;
|
||||||
|
client_body_temp_path /tmp/client_body;
|
||||||
|
proxy_temp_path /tmp/proxy;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi;
|
||||||
|
scgi_temp_path /tmp/scgi;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
server_tokens off;
|
||||||
|
root /srv/image;
|
||||||
|
disable_symlinks on;
|
||||||
|
|
||||||
|
allow 127.0.0.1;
|
||||||
|
allow ::1;
|
||||||
|
allow ${CADDY_SOURCE_CIDR};
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
proxy_pass http://image-hook:8081/healthz;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /v1/status {
|
||||||
|
proxy_pass http://image-hook:8081/v1/status;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /v1/inventory {
|
||||||
|
proxy_pass http://image-hook:8081/v1/inventory;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_buffering off;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /v1/hooks/gitea {
|
||||||
|
limit_except POST { deny all; }
|
||||||
|
client_max_body_size 1m;
|
||||||
|
proxy_pass http://image-hook:8081/v1/hooks/gitea;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Gitea-Signature $http_x_gitea_signature;
|
||||||
|
proxy_set_header X-Gitea-Event $http_x_gitea_event;
|
||||||
|
proxy_set_header X-Gitea-Delivery $http_x_gitea_delivery;
|
||||||
|
proxy_request_buffering on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /v1/admin/ { return 404; }
|
||||||
|
location = /image { return 404; }
|
||||||
|
location = /image/ { return 404; }
|
||||||
|
location ^~ /image/ { rewrite ^/image/(.*)$ /$1 last; }
|
||||||
|
|
||||||
|
location ^~ /game/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /icons/ {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /hook/list.json {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /hook/inventory.v2.json {
|
||||||
|
try_files $uri =404;
|
||||||
|
add_header Access-Control-Allow-Origin "*" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ (^|/)\. { return 404; }
|
||||||
|
location ~ \.php$ { return 404; }
|
||||||
|
location / { return 404; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
|
||||||
|
echo "Usage: $0 <branch> [commit]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
repository_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd)
|
||||||
|
branch=$1
|
||||||
|
commit=${2:-}
|
||||||
|
set -- deploy --branch "$branch"
|
||||||
|
if [ -n "$commit" ]; then
|
||||||
|
set -- "$@" --commit "$commit"
|
||||||
|
fi
|
||||||
|
exec docker compose --project-directory "$repository_dir" exec -T image-hook node src/admin-cli.mjs "$@"
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
action=${1:-check}
|
||||||
|
source_cidr=${CADDY_SOURCE_CIDR:-}
|
||||||
|
image_port=${IMAGE_PORT:-8191}
|
||||||
|
chain=SAM_IMAGE_INGRESS
|
||||||
|
|
||||||
|
case "$image_port" in
|
||||||
|
''|*[!0-9]*) echo "IMAGE_PORT must be numeric" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
require_root() {
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Run this action as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
check)
|
||||||
|
iptables -S DOCKER-USER 2>/dev/null | grep -F "$chain" || true
|
||||||
|
iptables -S "$chain" 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
apply)
|
||||||
|
require_root
|
||||||
|
if [ -z "$source_cidr" ]; then
|
||||||
|
echo "CADDY_SOURCE_CIDR is required" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
iptables -n -L DOCKER-USER >/dev/null
|
||||||
|
iptables -n -L "$chain" >/dev/null 2>&1 || iptables -N "$chain"
|
||||||
|
iptables -F "$chain"
|
||||||
|
iptables -A "$chain" -s "$source_cidr" -j ACCEPT
|
||||||
|
iptables -A "$chain" -j DROP
|
||||||
|
iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" 2>/dev/null \
|
||||||
|
|| iptables -I DOCKER-USER 1 -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain"
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
require_root
|
||||||
|
while iptables -C DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain" 2>/dev/null; do
|
||||||
|
iptables -D DOCKER-USER -p tcp -m conntrack --ctorigdstport "$image_port" -j "$chain"
|
||||||
|
done
|
||||||
|
if iptables -n -L "$chain" >/dev/null 2>&1; then
|
||||||
|
iptables -F "$chain"
|
||||||
|
iptables -X "$chain"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [check|apply|remove]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
repository_dir=$(CDPATH= cd -- "$script_dir/../.." && pwd)
|
||||||
|
secret_dir="$repository_dir/secrets"
|
||||||
|
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
|
||||||
|
path="$secret_dir/$name"
|
||||||
|
if [ ! -e "$path" ]; then
|
||||||
|
openssl rand -hex 32 > "$path"
|
||||||
|
fi
|
||||||
|
chmod 600 "$path"
|
||||||
|
done
|
||||||
|
chmod 700 "$state_dir"
|
||||||
|
|
||||||
|
echo "Secret files are ready in $secret_dir (values not printed)."
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates=20230311+deb12u1 \
|
||||||
|
git=1:2.39.5-0+deb12u3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY test ./test
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 8081
|
||||||
|
CMD ["node", "src/server.mjs"]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "sam-image-hook",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.mjs",
|
||||||
|
"test": "node --test"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { adminSignature } from './auth.mjs';
|
||||||
|
|
||||||
|
const branchIndex = process.argv.indexOf('--branch');
|
||||||
|
const commitIndex = process.argv.indexOf('--commit');
|
||||||
|
if (process.argv[2] !== 'deploy' || branchIndex === -1 || !process.argv[branchIndex + 1]) {
|
||||||
|
console.error('Usage: node src/admin-cli.mjs deploy --branch <name> [--commit <sha>]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretPath = process.env.IMAGE_ADMIN_SECRET_FILE ?? '/run/secrets/image_admin_secret';
|
||||||
|
const secret = (await readFile(secretPath, 'utf8')).trim();
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
branch: process.argv[branchIndex + 1],
|
||||||
|
commit: commitIndex === -1 ? undefined : process.argv[commitIndex + 1],
|
||||||
|
}));
|
||||||
|
const timestamp = String(Date.now());
|
||||||
|
const requestId = randomUUID();
|
||||||
|
const response = await fetch('http://127.0.0.1:8081/v1/admin/deploy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-image-timestamp': timestamp,
|
||||||
|
'x-image-request-id': requestId,
|
||||||
|
'x-image-signature': adminSignature(secret, timestamp, requestId, payload),
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
console.log(await response.text());
|
||||||
|
if (!response.ok) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export function hmacHex(secret, body) {
|
||||||
|
return createHmac('sha256', secret).update(body).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyHexHmac(secret, body, supplied) {
|
||||||
|
if (typeof supplied !== 'string' || !/^[0-9a-f]{64}$/i.test(supplied)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = Buffer.from(hmacHex(secret, body), 'hex');
|
||||||
|
const actual = Buffer.from(supplied, 'hex');
|
||||||
|
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminSignature(secret, timestamp, requestId, body) {
|
||||||
|
return hmacHex(secret, Buffer.concat([
|
||||||
|
Buffer.from(`${timestamp}.${requestId}.`, 'utf8'),
|
||||||
|
body,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyAdminSignature({ secret, timestamp, requestId, body, supplied, now = Date.now() }) {
|
||||||
|
if (!/^\d{10,13}$/.test(String(timestamp ?? '')) || !/^[A-Za-z0-9._:-]{8,128}$/.test(requestId ?? '')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericTimestamp = Number(timestamp);
|
||||||
|
const milliseconds = numericTimestamp < 10_000_000_000 ? numericTimestamp * 1000 : numericTimestamp;
|
||||||
|
if (!Number.isFinite(milliseconds) || Math.abs(now - milliseconds) > 300_000) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return verifyHexHmac(secret, Buffer.concat([
|
||||||
|
Buffer.from(`${timestamp}.${requestId}.`, 'utf8'),
|
||||||
|
body,
|
||||||
|
]), supplied);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
function text(name, fallback) {
|
||||||
|
const value = process.env[name];
|
||||||
|
return value === undefined || value === '' ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secret(name, fileName) {
|
||||||
|
const direct = process.env[name];
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = process.env[fileName];
|
||||||
|
if (!path) {
|
||||||
|
throw new Error(`${name} or ${fileName} is required`);
|
||||||
|
}
|
||||||
|
return readFileSync(path, 'utf8').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig() {
|
||||||
|
const webhookSecret = secret('GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_SECRET_FILE');
|
||||||
|
const adminSecret = secret('IMAGE_ADMIN_SECRET', 'IMAGE_ADMIN_SECRET_FILE');
|
||||||
|
if (webhookSecret.length < 32 || adminSecret.length < 32) {
|
||||||
|
throw new Error('Webhook and admin secrets must be at least 32 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedBranches = text('IMAGE_ALLOWED_BRANCHES', 'master')
|
||||||
|
.split(',')
|
||||||
|
.map((branch) => branch.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
port: Number(text('PORT', '8081')),
|
||||||
|
repositoryPath: text('IMAGE_REPOSITORY_PATH', '/data/image'),
|
||||||
|
remoteUrl: text('IMAGE_REMOTE_URL', 'https://gitea.hided.net/devsam/image.git'),
|
||||||
|
repositoryFullName: text('IMAGE_REPOSITORY_FULL_NAME', 'devsam/image'),
|
||||||
|
defaultBranch: text('IMAGE_DEFAULT_BRANCH', 'master'),
|
||||||
|
allowedBranches,
|
||||||
|
statePath: text('IMAGE_STATE_PATH', '/var/lib/image-hook/state.json'),
|
||||||
|
publicBases: text('IMAGE_PUBLIC_BASES', 'https://sam.hided.net/image,https://sam-image.hided.net')
|
||||||
|
.split(',')
|
||||||
|
.map((base) => base.trim().replace(/\/$/, ''))
|
||||||
|
.filter(Boolean),
|
||||||
|
webhookSecret,
|
||||||
|
adminSecret,
|
||||||
|
maxBodyBytes: Number(text('MAX_BODY_BYTES', '1048576')),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { writeInventories } from './inventory.mjs';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const BRANCH_PATTERN = /^(?!\/)(?!.*(?:\.\.|\/\/|@\{|\\))[A-Za-z0-9._/-]{1,200}$/;
|
||||||
|
const ZERO_SHA = /^0{40,64}$/;
|
||||||
|
|
||||||
|
export class DeploymentError extends Error {
|
||||||
|
constructor(message, status = 409) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'DeploymentError';
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GitService {
|
||||||
|
constructor(config, { run = execFileAsync, now = () => new Date() } = {}) {
|
||||||
|
this.config = config;
|
||||||
|
this.run = run;
|
||||||
|
this.now = now;
|
||||||
|
this.queue = Promise.resolve();
|
||||||
|
this.state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
await mkdir(dirname(this.config.statePath), { recursive: true });
|
||||||
|
this.state = await this.#loadState();
|
||||||
|
await this.#assertRepository();
|
||||||
|
await this.#assertClean();
|
||||||
|
if (!this.config.allowedBranches.includes(this.state.activeBranch)) {
|
||||||
|
throw new Error(`Active branch ${this.state.activeBranch} is not allowed`);
|
||||||
|
}
|
||||||
|
await this.refreshInventory();
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(operation) {
|
||||||
|
const result = this.queue.then(operation, operation);
|
||||||
|
this.queue = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployWebhook({ deliveryId, branch, after }) {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
if (!deliveryId || deliveryId.length > 128) {
|
||||||
|
throw new DeploymentError('Invalid delivery ID', 400);
|
||||||
|
}
|
||||||
|
if (this.state.deliveries.includes(deliveryId)) {
|
||||||
|
return { duplicate: true, ...this.publicStatus() };
|
||||||
|
}
|
||||||
|
if (branch !== this.state.activeBranch) {
|
||||||
|
return { ignored: true, reason: 'inactive branch', ...this.publicStatus() };
|
||||||
|
}
|
||||||
|
if (ZERO_SHA.test(after)) {
|
||||||
|
return { ignored: true, reason: 'deleted ref', ...this.publicStatus() };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.#deploy({ branch, expectedCommit: after, allowUnrelated: false });
|
||||||
|
this.state.deliveries = [...this.state.deliveries.slice(-199), deliveryId];
|
||||||
|
await this.#saveState();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployAdmin({ requestId, branch, expectedCommit }) {
|
||||||
|
return this.enqueue(async () => {
|
||||||
|
if (this.state.adminRequests.includes(requestId)) {
|
||||||
|
return { duplicate: true, ...this.publicStatus() };
|
||||||
|
}
|
||||||
|
const result = await this.#deploy({ branch, expectedCommit, allowUnrelated: branch !== this.state.activeBranch });
|
||||||
|
this.state.activeBranch = branch;
|
||||||
|
this.state.adminRequests = [...this.state.adminRequests.slice(-199), requestId];
|
||||||
|
await this.#saveState();
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #deploy({ branch, expectedCommit, allowUnrelated }) {
|
||||||
|
this.#validateBranch(branch);
|
||||||
|
await this.#assertClean();
|
||||||
|
const remoteRef = `refs/remotes/image-hook/${branch}`;
|
||||||
|
await this.#git('fetch', '--no-tags', '--prune', this.config.remoteUrl, `+refs/heads/${branch}:${remoteRef}`);
|
||||||
|
const target = await this.#gitText('rev-parse', '--verify', `${remoteRef}^{commit}`);
|
||||||
|
if (expectedCommit && target !== expectedCommit) {
|
||||||
|
throw new DeploymentError('Payload commit does not match remote branch tip');
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await this.#gitText('rev-parse', 'HEAD');
|
||||||
|
if (!allowUnrelated && current !== target) {
|
||||||
|
const ancestor = await this.#gitExit('merge-base', '--is-ancestor', current, target);
|
||||||
|
if (ancestor !== 0) {
|
||||||
|
throw new DeploymentError('Non-fast-forward deployment rejected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current !== target) {
|
||||||
|
await this.#git('checkout', '--detach', target);
|
||||||
|
}
|
||||||
|
this.state.lastSuccess = {
|
||||||
|
branch,
|
||||||
|
commit: target,
|
||||||
|
at: this.now().toISOString(),
|
||||||
|
};
|
||||||
|
this.state.lastError = null;
|
||||||
|
const inventory = await this.refreshInventory(branch, target);
|
||||||
|
await this.#saveState();
|
||||||
|
return { changed: current !== target, inventoryAssets: inventory.assets.length, ...this.publicStatus() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshInventory(branch = this.state.activeBranch, commit) {
|
||||||
|
const resolvedCommit = commit ?? await this.#gitText('rev-parse', 'HEAD');
|
||||||
|
const output = await this.#gitRaw('ls-files', '-z', '--', 'game', 'icons');
|
||||||
|
const paths = output.split('\0').filter(Boolean);
|
||||||
|
return writeInventories({
|
||||||
|
repositoryPath: this.config.repositoryPath,
|
||||||
|
paths,
|
||||||
|
branch,
|
||||||
|
commit: resolvedCommit,
|
||||||
|
publicBases: this.config.publicBases,
|
||||||
|
generatedAt: this.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publicStatus() {
|
||||||
|
return {
|
||||||
|
activeBranch: this.state.activeBranch,
|
||||||
|
lastSuccess: this.state.lastSuccess,
|
||||||
|
lastError: this.state.lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordError(error) {
|
||||||
|
this.state.lastError = { message: error.message, at: this.now().toISOString() };
|
||||||
|
await this.#saveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
#validateBranch(branch) {
|
||||||
|
if (!BRANCH_PATTERN.test(branch) || !this.config.allowedBranches.includes(branch)) {
|
||||||
|
throw new DeploymentError('Branch is not allowed', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #assertRepository() {
|
||||||
|
const inside = await this.#gitText('rev-parse', '--is-inside-work-tree');
|
||||||
|
if (inside !== 'true') {
|
||||||
|
throw new Error('IMAGE_REPOSITORY_PATH is not a Git worktree');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #assertClean() {
|
||||||
|
const status = await this.#gitRaw('status', '--porcelain=v1', '--untracked-files=no');
|
||||||
|
if (status.trim()) {
|
||||||
|
throw new DeploymentError('Tracked worktree changes prevent deployment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadState() {
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(await readFile(this.config.statePath, 'utf8'));
|
||||||
|
return {
|
||||||
|
activeBranch: saved.activeBranch ?? this.config.defaultBranch,
|
||||||
|
deliveries: Array.isArray(saved.deliveries) ? saved.deliveries : [],
|
||||||
|
adminRequests: Array.isArray(saved.adminRequests) ? saved.adminRequests : [],
|
||||||
|
lastSuccess: saved.lastSuccess ?? null,
|
||||||
|
lastError: saved.lastError ?? null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
activeBranch: this.config.defaultBranch,
|
||||||
|
deliveries: [],
|
||||||
|
adminRequests: [],
|
||||||
|
lastSuccess: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #saveState() {
|
||||||
|
const temporary = `${this.config.statePath}.tmp-${process.pid}`;
|
||||||
|
await writeFile(temporary, JSON.stringify(this.state, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||||
|
await rename(temporary, this.config.statePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #git(...args) {
|
||||||
|
await this.run('git', ['-C', this.config.repositoryPath, ...args], { maxBuffer: 16 * 1024 * 1024 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async #gitRaw(...args) {
|
||||||
|
const { stdout } = await this.run('git', ['-C', this.config.repositoryPath, ...args], {
|
||||||
|
encoding: 'buffer',
|
||||||
|
maxBuffer: 64 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return Buffer.isBuffer(stdout) ? stdout.toString('utf8') : stdout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #gitText(...args) {
|
||||||
|
return (await this.#gitRaw(...args)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #gitExit(...args) {
|
||||||
|
try {
|
||||||
|
await this.#git(...args);
|
||||||
|
return 0;
|
||||||
|
} catch (error) {
|
||||||
|
return error.code ?? 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { basename, dirname, extname } from 'node:path/posix';
|
||||||
|
import { rename, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export function buildLegacyInventory(paths) {
|
||||||
|
const inventory = {};
|
||||||
|
for (const fullPath of paths) {
|
||||||
|
if (!fullPath.startsWith('icons/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relative = fullPath.slice('icons/'.length);
|
||||||
|
const directory = dirname(relative);
|
||||||
|
const file = basename(relative);
|
||||||
|
const extension = extname(file);
|
||||||
|
const name = extension ? file.slice(0, -extension.length) : file;
|
||||||
|
inventory[directory] ??= {};
|
||||||
|
inventory[directory][name] = file;
|
||||||
|
}
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function atomicJson(path, value) {
|
||||||
|
const temporary = `${path}.tmp-${process.pid}`;
|
||||||
|
await writeFile(temporary, JSON.stringify(value), { encoding: 'utf8', mode: 0o644 });
|
||||||
|
await rename(temporary, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeInventories({ repositoryPath, paths, branch, commit, publicBases, generatedAt = new Date() }) {
|
||||||
|
const legacy = buildLegacyInventory(paths);
|
||||||
|
const versioned = {
|
||||||
|
version: 2,
|
||||||
|
branch,
|
||||||
|
commit,
|
||||||
|
generatedAt: generatedAt.toISOString(),
|
||||||
|
publicBases,
|
||||||
|
assets: paths.filter((path) => path.startsWith('game/') || path.startsWith('icons/')),
|
||||||
|
directories: legacy,
|
||||||
|
};
|
||||||
|
|
||||||
|
await atomicJson(join(repositoryPath, 'hook', 'list.json'), legacy);
|
||||||
|
await atomicJson(join(repositoryPath, 'hook', 'inventory.v2.json'), versioned);
|
||||||
|
return versioned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { loadConfig } from './config.mjs';
|
||||||
|
import { verifyAdminSignature, verifyHexHmac } from './auth.mjs';
|
||||||
|
import { DeploymentError, GitService } from './git-service.mjs';
|
||||||
|
|
||||||
|
function json(response, status, value) {
|
||||||
|
const body = JSON.stringify(value);
|
||||||
|
response.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-length': Buffer.byteLength(body),
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
});
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(request, limit) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const chunk of request) {
|
||||||
|
size += chunk.length;
|
||||||
|
if (size > limit) {
|
||||||
|
throw new DeploymentError('Request body too large', 413);
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(body) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(body.toString('utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new DeploymentError('Invalid JSON body', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApp(config = loadConfig()) {
|
||||||
|
const service = new GitService(config);
|
||||||
|
await service.initialize();
|
||||||
|
|
||||||
|
const server = createServer(async (request, response) => {
|
||||||
|
const url = new URL(request.url, 'http://image-hook');
|
||||||
|
try {
|
||||||
|
if (request.method === 'GET' && url.pathname === '/healthz') {
|
||||||
|
return json(response, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && url.pathname === '/v1/status') {
|
||||||
|
return json(response, 200, service.publicStatus());
|
||||||
|
}
|
||||||
|
if (request.method === 'GET' && url.pathname === '/v1/inventory') {
|
||||||
|
const inventory = JSON.parse(await readFile(`${config.repositoryPath}/hook/inventory.v2.json`, 'utf8'));
|
||||||
|
return json(response, 200, inventory);
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && url.pathname === '/v1/hooks/gitea') {
|
||||||
|
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);
|
||||||
|
if (!verifyHexHmac(config.webhookSecret, body, request.headers['x-gitea-signature'])) {
|
||||||
|
return json(response, 401, { ok: false, reason: 'invalid signature' });
|
||||||
|
}
|
||||||
|
if (request.headers['x-gitea-event'] !== 'push') {
|
||||||
|
return json(response, 202, { ok: true, ignored: true, reason: 'unsupported event' });
|
||||||
|
}
|
||||||
|
const payload = parseJson(body);
|
||||||
|
if (payload.repository?.full_name !== config.repositoryFullName) {
|
||||||
|
return json(response, 403, { ok: false, reason: 'unexpected repository' });
|
||||||
|
}
|
||||||
|
if (typeof payload.ref !== 'string' || !payload.ref.startsWith('refs/heads/')) {
|
||||||
|
return json(response, 202, { ok: true, ignored: true, reason: 'non-branch ref' });
|
||||||
|
}
|
||||||
|
if (typeof payload.after !== 'string' || !/^[0-9a-f]{40,64}$/i.test(payload.after)) {
|
||||||
|
throw new DeploymentError('Invalid target commit', 400);
|
||||||
|
}
|
||||||
|
const result = await service.deployWebhook({
|
||||||
|
deliveryId: request.headers['x-gitea-delivery'],
|
||||||
|
branch: payload.ref.slice('refs/heads/'.length),
|
||||||
|
after: payload.after,
|
||||||
|
});
|
||||||
|
return json(response, 200, { ok: true, ...result });
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && url.pathname === '/v1/admin/deploy') {
|
||||||
|
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 timestamp = request.headers['x-image-timestamp'];
|
||||||
|
const requestId = request.headers['x-image-request-id'];
|
||||||
|
if (!verifyAdminSignature({
|
||||||
|
secret: config.adminSecret,
|
||||||
|
timestamp,
|
||||||
|
requestId,
|
||||||
|
body,
|
||||||
|
supplied: request.headers['x-image-signature'],
|
||||||
|
})) {
|
||||||
|
return json(response, 401, { ok: false, reason: 'invalid admin signature' });
|
||||||
|
}
|
||||||
|
const payload = parseJson(body);
|
||||||
|
const result = await service.deployAdmin({ requestId, branch: payload.branch, 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);
|
||||||
|
console.error(JSON.stringify({ level: 'error', message: error.message, at: new Date().toISOString() }));
|
||||||
|
return json(response, error instanceof DeploymentError ? error.status : 500, {
|
||||||
|
ok: false,
|
||||||
|
reason: error instanceof DeploymentError ? error.message : 'internal error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { server, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === new URL(import.meta.url).pathname) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const { server } = await createApp(config);
|
||||||
|
server.listen(config.port, '0.0.0.0', () => {
|
||||||
|
console.log(JSON.stringify({ level: 'info', message: 'image hook listening', port: config.port }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { adminSignature, hmacHex, verifyAdminSignature, verifyHexHmac } from '../src/auth.mjs';
|
||||||
|
|
||||||
|
test('Gitea HMAC verifies the exact raw body', () => {
|
||||||
|
const body = Buffer.from('{"ref":"refs/heads/master"}');
|
||||||
|
const signature = hmacHex('a'.repeat(32), body);
|
||||||
|
assert.equal(verifyHexHmac('a'.repeat(32), body, signature), true);
|
||||||
|
assert.equal(verifyHexHmac('a'.repeat(32), Buffer.from(`${body} `), signature), false);
|
||||||
|
assert.equal(verifyHexHmac('a'.repeat(32), body, 'not-a-signature'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin signatures bind timestamp, request ID, and body with a five-minute window', () => {
|
||||||
|
const secret = 'b'.repeat(32);
|
||||||
|
const now = 1_786_013_000_000;
|
||||||
|
const timestamp = String(now);
|
||||||
|
const requestId = 'request-1234';
|
||||||
|
const body = Buffer.from('{"branch":"master"}');
|
||||||
|
const supplied = adminSignature(secret, timestamp, requestId, body);
|
||||||
|
assert.equal(verifyAdminSignature({ secret, timestamp, requestId, body, supplied, now }), true);
|
||||||
|
assert.equal(verifyAdminSignature({ secret, timestamp: String(now - 300_001), requestId, body, supplied, now }), false);
|
||||||
|
assert.equal(verifyAdminSignature({ secret, timestamp, requestId: 'changed-id', body, supplied, now }), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { DeploymentError, GitService } from '../src/git-service.mjs';
|
||||||
|
|
||||||
|
const exec = promisify(execFile);
|
||||||
|
|
||||||
|
async function git(cwd, ...args) {
|
||||||
|
const { stdout } = await exec('git', ['-C', cwd, ...args]);
|
||||||
|
return stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture() {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), 'image-hook-test-'));
|
||||||
|
const remote = join(root, 'remote.git');
|
||||||
|
const seed = join(root, 'seed');
|
||||||
|
const deployed = join(root, 'deployed');
|
||||||
|
await exec('git', ['init', '--bare', remote]);
|
||||||
|
await exec('git', ['init', '-b', 'master', seed]);
|
||||||
|
await git(seed, 'config', 'user.email', 'test@example.invalid');
|
||||||
|
await git(seed, 'config', 'user.name', 'Image Test');
|
||||||
|
await mkdir(join(seed, 'icons'), { recursive: true });
|
||||||
|
await mkdir(join(seed, 'hook'), { recursive: true });
|
||||||
|
await writeFile(join(seed, 'icons', 'first.jpg'), 'first');
|
||||||
|
await writeFile(join(seed, 'hook', '.keep'), '');
|
||||||
|
await git(seed, 'add', '.');
|
||||||
|
await git(seed, 'commit', '-m', 'initial');
|
||||||
|
await git(seed, 'remote', 'add', 'origin', remote);
|
||||||
|
await git(seed, 'push', '-u', 'origin', 'master');
|
||||||
|
await exec('git', ['clone', remote, deployed]);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
repositoryPath: deployed,
|
||||||
|
remoteUrl: remote,
|
||||||
|
repositoryFullName: 'devsam/image',
|
||||||
|
defaultBranch: 'master',
|
||||||
|
allowedBranches: ['master', 'preview'],
|
||||||
|
statePath: join(root, 'state', 'state.json'),
|
||||||
|
publicBases: ['https://sam.hided.net/image', 'https://sam-image.hided.net'],
|
||||||
|
};
|
||||||
|
const service = new GitService(config, { now: () => new Date('2026-08-06T00:00:00Z') });
|
||||||
|
await service.initialize();
|
||||||
|
return { root, remote, seed, deployed, service };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('push deployment fast-forwards, writes inventory, and deduplicates delivery IDs', async (t) => {
|
||||||
|
const f = await fixture();
|
||||||
|
t.after(() => rm(f.root, { recursive: true, force: true }));
|
||||||
|
await writeFile(join(f.seed, 'icons', '둘째.png'), 'second');
|
||||||
|
await git(f.seed, 'add', '.');
|
||||||
|
await git(f.seed, 'commit', '-m', 'second');
|
||||||
|
await git(f.seed, 'push', 'origin', 'master');
|
||||||
|
const target = await git(f.seed, 'rev-parse', 'HEAD');
|
||||||
|
|
||||||
|
const result = await f.service.deployWebhook({ deliveryId: 'delivery-0001', branch: 'master', after: target });
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(await git(f.deployed, 'rev-parse', 'HEAD'), target);
|
||||||
|
const inventory = JSON.parse(await readFile(join(f.deployed, 'hook', 'inventory.v2.json'), 'utf8'));
|
||||||
|
assert.equal(inventory.directories['.']['둘째'], '둘째.png');
|
||||||
|
assert.equal((await f.service.deployWebhook({ deliveryId: 'delivery-0001', branch: 'master', after: target })).duplicate, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inactive branches are ignored and tracked changes block deployment', async (t) => {
|
||||||
|
const f = await fixture();
|
||||||
|
t.after(() => rm(f.root, { recursive: true, force: true }));
|
||||||
|
assert.equal((await f.service.deployWebhook({ deliveryId: 'delivery-0002', branch: 'preview', after: 'a'.repeat(40) })).ignored, true);
|
||||||
|
await writeFile(join(f.deployed, 'icons', 'first.jpg'), 'dirty');
|
||||||
|
await assert.rejects(
|
||||||
|
f.service.deployWebhook({ deliveryId: 'delivery-0003', branch: 'master', after: await git(f.seed, 'rev-parse', 'HEAD') }),
|
||||||
|
(error) => error instanceof DeploymentError && /Tracked worktree/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin deploy explicitly switches to an allowlisted unrelated branch', async (t) => {
|
||||||
|
const f = await fixture();
|
||||||
|
t.after(() => rm(f.root, { recursive: true, force: true }));
|
||||||
|
await git(f.seed, 'checkout', '--orphan', 'preview');
|
||||||
|
await git(f.seed, 'rm', '-rf', '.');
|
||||||
|
await mkdir(join(f.seed, 'icons'), { recursive: true });
|
||||||
|
await mkdir(join(f.seed, 'hook'), { recursive: true });
|
||||||
|
await writeFile(join(f.seed, 'icons', 'preview.jpg'), 'preview');
|
||||||
|
await writeFile(join(f.seed, 'hook', '.keep'), '');
|
||||||
|
await git(f.seed, 'add', '.');
|
||||||
|
await git(f.seed, 'commit', '-m', 'preview');
|
||||||
|
await git(f.seed, 'push', 'origin', 'preview');
|
||||||
|
const target = await git(f.seed, 'rev-parse', 'HEAD');
|
||||||
|
|
||||||
|
const result = await f.service.deployAdmin({ requestId: 'admin-request-1', branch: 'preview', expectedCommit: target });
|
||||||
|
assert.equal(result.changed, true);
|
||||||
|
assert.equal(f.service.publicStatus().activeBranch, 'preview');
|
||||||
|
await assert.rejects(
|
||||||
|
f.service.deployAdmin({ requestId: 'admin-request-2', branch: 'forbidden' }),
|
||||||
|
/Branch is not allowed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-branch force pushes and payload SHA mismatches are rejected', async (t) => {
|
||||||
|
const f = await fixture();
|
||||||
|
t.after(() => rm(f.root, { recursive: true, force: true }));
|
||||||
|
const initial = await git(f.seed, 'rev-parse', 'HEAD');
|
||||||
|
await writeFile(join(f.seed, 'icons', 'second.jpg'), 'second');
|
||||||
|
await git(f.seed, 'add', '.');
|
||||||
|
await git(f.seed, 'commit', '-m', 'second');
|
||||||
|
await git(f.seed, 'push', 'origin', 'master');
|
||||||
|
const second = await git(f.seed, 'rev-parse', 'HEAD');
|
||||||
|
await f.service.deployWebhook({ deliveryId: 'delivery-0004', branch: 'master', after: second });
|
||||||
|
|
||||||
|
await git(f.seed, 'reset', '--hard', initial);
|
||||||
|
await writeFile(join(f.seed, 'icons', 'forced.jpg'), 'forced');
|
||||||
|
await git(f.seed, 'add', '.');
|
||||||
|
await git(f.seed, 'commit', '-m', 'forced replacement');
|
||||||
|
await git(f.seed, 'push', '--force', 'origin', 'master');
|
||||||
|
const forced = await git(f.seed, 'rev-parse', 'HEAD');
|
||||||
|
await assert.rejects(
|
||||||
|
f.service.deployWebhook({ deliveryId: 'delivery-0005', branch: 'master', after: forced }),
|
||||||
|
/Non-fast-forward deployment rejected/,
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
f.service.deployWebhook({ deliveryId: 'delivery-0006', branch: 'master', after: 'f'.repeat(40) }),
|
||||||
|
/Payload commit does not match remote branch tip/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildLegacyInventory } from '../src/inventory.mjs';
|
||||||
|
|
||||||
|
test('legacy inventory preserves dirname to stem to basename shape', () => {
|
||||||
|
assert.deepEqual(buildLegacyInventory([
|
||||||
|
'game/back.jpg',
|
||||||
|
'icons/1047.jpg',
|
||||||
|
'icons/장수/유표.jpg',
|
||||||
|
'icons/장수/유표1.png',
|
||||||
|
]), {
|
||||||
|
'.': { '1047': '1047.jpg' },
|
||||||
|
'장수': { '유표': '유표.jpg', '유표1': '유표1.png' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy duplicate stems retain the last Git path for PHP compatibility', () => {
|
||||||
|
assert.deepEqual(buildLegacyInventory(['icons/a/same.jpg', 'icons/a/same.png']), {
|
||||||
|
a: { same: 'same.png' },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user