Merge pull request 'mars: add generic Gitea webhook relay' (#2) from feat/mars-gitea-webhook-relay into master

Reviewed-on: #2
Reviewed-by: darman <mail@erik-s.dev>
This commit was merged in pull request #2.
This commit is contained in:
2026-08-23 05:14:35 +02:00
8 changed files with 601 additions and 0 deletions
+29
View File
@@ -37,6 +37,35 @@ scripts/ # deploy, edit_secrets
Hosts compose by importing `common.nix` + whichever `services/*` modules they
run. Each service module opens its own firewall ports.
## Gitea event relay
Mars includes a small HMAC-validating relay for Gitea webhooks. It forwards the
authenticated request body and Gitea's own signature to Hermes over localhost
completely unchanged, and copies `X-Gitea-Event` into `X-GitHub-Event`. It has
no event, repository, action, payload, or prompt policy; Hermes owns
interpretation and response behavior. Jupiter's Gitea provisioning service
registers the webhook idempotently at `http://mars.orbit.sol:8645/gitea`.
That one header copy is the entire reason the relay exists. Gitea signs every
webhook with `X-Hub-Signature-256` in GitHub's exact format, which Hermes
already accepts on any route — so authentication would work pointing Gitea
straight at Hermes on 8644. But Hermes reads the event name only from
`X-GitHub-Event`/`X-GitLab-Event` (then `event_type`/`type` in the payload,
then the literal `"unknown"`), and Gitea sends none of those. Without the copy
every delivery arrives as `unknown` and `hermes webhook subscribe --events ...`
can never match anything.
Run `python3 services/dev/gitea-hermes-webhook-relay-test.py` to exercise the
relay end to end (signature acceptance and rejection, byte-identical body
forwarding, and the event-header copy).
Before deploying either host, add the same random
`gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and
`secrets/jupiter.yaml` using `scripts/edit_secrets`, with no trailing newline
— the value reaches Hermes through an env-file template, where a newline both
corrupts the file and changes the key the HMAC is computed with. The value is
intentionally not included in the repository.
## Test in VirtualBox (no hardware needed)
```
+5
View File
@@ -48,6 +48,11 @@
# ci-bot access token to allow the ci-bot user to push to repos
sops.secrets.gitea_ci_bot_token.owner = "gitea";
# Add the same value to secrets/jupiter.yaml before deploying Jupiter.
sops.secrets.gitea_hermes_webhook_secret = {
owner = "gitea";
};
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) —
# migrated off the reused ini in services/media/sabnzbd.nix into
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this
+1
View File
@@ -12,6 +12,7 @@
../../services/containers.nix
../../services/vpn/tailscale.nix
../../services/monitoring/node-exporter.nix
../../services/dev/gitea-hermes-webhook-relay.nix
];
networking.hostName = "mars";
+24
View File
@@ -28,11 +28,35 @@
sops.secrets.opencode_go_api_key = { };
sops.secrets.telegram_bot_token = { };
sops.secrets.hermes_dashboard_oidc_client_secret = { };
# Add the same value to secrets/mars.yaml before deploying Mars, and store
# it WITHOUT a trailing newline: it reaches Hermes through the env template
# below, where a newline would both corrupt the env file and change the key
# the HMAC is computed with. `scripts/edit_secrets` writes a bare value.
#
# podman-hermes-agent is in restartUnits for a reason that is easy to miss:
# the secret reaches the container only through sops.templates, whose
# rendered PATH never changes, so the container unit's definition is
# identical before and after the secret is added and systemd will NOT
# restart it on its own. Without this line the very first deploy leaves the
# container holding an empty GITEA_HERMES_WEBHOOK_SECRET, and
# hermes-agent-webhook-route (which reads it back out of the running
# container) subscribes with an empty secret — every relayed delivery then
# fails signature validation inside Hermes with no obvious cause.
sops.secrets.gitea_hermes_webhook_secret = {
restartUnits = [
"gitea-hermes-webhook-relay.service"
"podman-hermes-agent.service"
"hermes-agent-webhook-route.service"
];
};
sops.templates."hermes-agent.env".content = ''
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
TELEGRAM_HOME_CHANNEL=15151223
TELEGRAM_ALLOWED_USERS=15151223
WEBHOOK_ENABLED=true
WEBHOOK_PORT=8644
GITEA_HERMES_WEBHOOK_SECRET=${config.sops.placeholder.gitea_hermes_webhook_secret}
HERMES_DASHBOARD_OIDC_CLIENT_SECRET=${config.sops.placeholder.hermes_dashboard_oidc_client_secret}
'';
@@ -0,0 +1,126 @@
"""Integration test for gitea-hermes-webhook-relay.py.
Spawns the real relay as a subprocess against a stub Hermes and drives it over
real HTTP. Run it directly: python3 services/dev/gitea-hermes-webhook-relay-test.py
The assertion that matters is `X-GitHub-Event injected`: Hermes derives the
event name it matches `--events` against from X-GitHub-Event, and Gitea only
ever sends X-Gitea-Event. If that copy regresses, every delivery silently
becomes event "unknown" and no Hermes-side event selection can work.
"""
import hashlib, hmac, json, os, pathlib, subprocess, sys, threading, time, urllib.request, urllib.error
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = b"s3cr3t-test-value"
RELAY_PORT, HERMES_PORT = 18645, 18644
received = []
class Hermes(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
# lower-cased keys: HTTP headers are case-insensitive and urllib
# normalises them with .title() on the wire ("X-GitHub-Event" leaves
# as "X-Github-Event"). aiohttp reads them into a case-insensitive
# CIMultiDict, so matching case-insensitively here is the correct
# assertion, not a workaround.
received.append({"path": self.path, "body": self.rfile.read(n),
"headers": {k.lower(): v for k, v in self.headers.items()}})
self.send_response(200); self.send_header("Content-Length", "2")
self.end_headers(); self.wfile.write(b"ok")
def log_message(self, *a): pass
hermes = HTTPServer(("127.0.0.1", HERMES_PORT), Hermes)
threading.Thread(target=hermes.serve_forever, daemon=True).start()
import tempfile
creds = os.path.join(tempfile.mkdtemp(), "creds"); os.makedirs(creds, exist_ok=True)
# trailing newline on purpose: mimics a sops secret file
open(os.path.join(creds, "webhook_secret"), "wb").write(SECRET + b"\n")
env = {**os.environ, "CREDENTIALS_DIRECTORY": creds, "LISTEN_HOST": "127.0.0.1",
"LISTEN_PORT": str(RELAY_PORT),
"HERMES_WEBHOOK_URL": f"http://127.0.0.1:{HERMES_PORT}/webhooks/gitea-events"}
RELAY = str(pathlib.Path(__file__).with_name('gitea-hermes-webhook-relay.py'))
relay = subprocess.Popen([sys.executable, RELAY],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50):
try:
urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health", timeout=1); break
except Exception: time.sleep(0.1)
def post(body, headers):
req = urllib.request.Request(f"http://127.0.0.1:{RELAY_PORT}/gitea", data=body,
headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=5) as r: return r.status, json.load(r)
except urllib.error.HTTPError as e: return e.code, json.load(e)
fails = []
def check(name, cond, detail=""):
print(("PASS " if cond else "FAIL ") + name + ("" if cond else f" <- {detail}"))
if not cond: fails.append(name)
payload = json.dumps({"action": "opened", "number": 7}).encode()
sig = hmac.new(SECRET, payload, hashlib.sha256).hexdigest()
# 1. health
with urllib.request.urlopen(f"http://127.0.0.1:{RELAY_PORT}/health") as r:
check("health endpoint", json.load(r)["status"] == "ok")
# 2. happy path with BOTH gitea headers (what Gitea really sends)
received.clear()
st, resp = post(payload, {"Content-Type": "application/json",
"X-Gitea-Event": "pull_request_comment",
"X-Gitea-Event-Type": "pull_request_comment",
"X-Gitea-Delivery": "abc-123",
"X-Gitea-Signature": sig,
"X-Hub-Signature-256": "sha256=" + sig})
check("valid delivery accepted", st == 200, f"got {st} {resp}")
check("forwarded to hermes", len(received) == 1)
fwd = received[0]
check("body forwarded byte-identical", fwd["body"] == payload)
check("X-GitHub-Event injected (THE fix)",
fwd["headers"].get("x-github-event") == "pull_request_comment",
f"got {fwd['headers'].get('X-GitHub-Event')!r}")
check("X-Hub-Signature-256 forwarded unchanged",
fwd["headers"].get("x-hub-signature-256") == "sha256=" + sig)
check("signature still valid over forwarded body",
hmac.compare_digest(
fwd["headers"]["x-hub-signature-256"].removeprefix("sha256="),
hmac.new(SECRET, fwd["body"], hashlib.sha256).hexdigest()))
check("delivery id propagated", fwd["headers"].get("x-request-id") == "abc-123")
check("path preserved", fwd["path"] == "/webhooks/gitea-events")
# 3. gitea-only signature header (no X-Hub-Signature-256)
received.clear()
st, _ = post(payload, {"Content-Type": "application/json", "X-Gitea-Event": "push",
"X-Gitea-Signature": sig})
check("bare X-Gitea-Signature accepted", st == 200)
check("relay signs when hub header absent",
received and received[0]["headers"].get("x-hub-signature-256") == "sha256=" + sig)
# 4. rejections
received.clear()
st, _ = post(payload, {"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=" + "0"*64})
check("bad signature -> 401", st == 401)
st, _ = post(payload, {"Content-Type": "application/json"})
check("missing signature -> 401", st == 401)
st, _ = post(payload + b"x", {"Content-Type": "application/json", "X-Hub-Signature-256": "sha256=" + sig})
check("tampered body -> 401", st == 401)
check("nothing leaked to hermes on rejection", len(received) == 0, f"{len(received)} forwarded")
# 5. oversize
big = b"x" * 200
st, _ = post(big, {"Content-Type": "application/json", "MAX": "1",
"X-Hub-Signature-256": "sha256=" + hmac.new(SECRET, big, hashlib.sha256).hexdigest()})
check("normal-size body still ok", st == 200)
# 6. unknown path
st, _ = post(payload, {"X-Hub-Signature-256": "sha256=" + sig})
check("POST /gitea ok baseline", st == 200)
relay.terminate(); relay.wait(timeout=5); hermes.shutdown()
print()
print(f"{'ALL PASSED' if not fails else 'FAILURES: ' + ', '.join(fails)}")
sys.exit(1 if fails else 0)
+115
View File
@@ -0,0 +1,115 @@
{ config, pkgs, ... }:
# Gitea -> Hermes webhook relay.
#
# Why this exists at all, since Gitea could POST straight at Hermes's own
# webhook port (8644, already tailnet-reachable — tailscale0 is a
# trustedInterface): AUTH would work directly. Gitea's addDefaultHeaders()
# signs every webhook type with `X-Hub-Signature-256: sha256=<hmac>`, the
# exact GitHub scheme, and Hermes accepts that header on any route with no
# per-route provider gating. What does NOT work directly is EVENT SELECTION.
# Hermes reads the event name from `X-GitHub-Event`/`X-GitLab-Event`, then
# the payload's `event_type`/`type` keys, then gives up and calls it
# "unknown". Gitea sends `X-Gitea-Event` and no such payload key, so a direct
# hook authenticates fine and then arrives as "unknown" forever — which makes
# `hermes webhook subscribe --events ...` unable to select anything, i.e. the
# "Hermes owns event policy" split this module is built around cannot exist
# without something copying that one header.
#
# So that is all this does: verify the signature, copy X-Gitea-Event into
# X-GitHub-Event, forward body and signature untouched. No re-signing, no
# payload rewriting, no event/repo/action filtering.
#
# It binds 0.0.0.0 but gets no allowedTCPPorts entry, so it is reachable over
# tailscale0 only — same posture as the Hermes dashboard on 9119.
let
relayScript = pkgs.writeText "gitea-hermes-webhook-relay.py" (
builtins.readFile ./gitea-hermes-webhook-relay.py
);
in
{
systemd.services.gitea-hermes-webhook-relay = {
description = "Relay Gitea webhooks to Hermes with a Hermes-readable event header";
wantedBy = [ "multi-user.target" ];
wants = [ "network-online.target" ];
after = [
"network-online.target"
"podman-hermes-agent.service"
"tailscaled-autoconnect.service"
];
environment = {
LISTEN_HOST = "0.0.0.0";
LISTEN_PORT = "8645";
HERMES_WEBHOOK_URL = "http://127.0.0.1:8644/webhooks/gitea-events";
MAX_BODY_BYTES = "1048576";
};
serviceConfig = {
ExecStart = "${pkgs.python3}/bin/python ${relayScript}";
LoadCredential = [
"webhook_secret:${config.sops.secrets.gitea_hermes_webhook_secret.path}"
];
DynamicUser = true;
Restart = "on-failure";
RestartSec = 5;
PrivateDevices = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
NoNewPrivileges = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictRealtime = true;
UMask = "0077";
};
};
# The relay forwards into a generic Hermes webhook subscription. Keep the
# subscription declaratively present without putting event policy or prompt
# text in this transport unit. Hermes owns interpretation and response policy.
#
# `--events` is deliberately omitted: an empty events list means "accept
# everything", and the selection is Hermes-side policy that darman can
# retune with `hermes webhook subscribe` at runtime without a redeploy.
# That only works because the relay supplies X-GitHub-Event — see the
# header comment above.
#
# The secret is read from the CONTAINER's environment ($GITEA_HERMES_
# WEBHOOK_SECRET, injected via sops.templates."hermes-agent.env"), which is
# why hosts/mars/secrets.nix restarts podman-hermes-agent BEFORE this unit
# on rotation — re-subscribing against a container still holding the old
# value would silently pin the stale secret.
systemd.services.hermes-agent-webhook-route = {
description = "Configure Hermes Gitea event webhook route";
wantedBy = [ "multi-user.target" ];
after = [ "podman-hermes-agent.service" ];
requires = [ "podman-hermes-agent.service" ];
path = [ pkgs.podman ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
# The container unit is ordered before us, but its gateway may still be
# warming up while the image initializes its persistent state directory.
for _ in $(seq 1 60); do
if podman exec hermes-agent hermes webhook list >/dev/null 2>&1; then
break
fi
sleep 1
done
podman exec hermes-agent hermes webhook remove gitea-pr-comments >/dev/null 2>&1 || true
podman exec hermes-agent hermes webhook remove gitea-events >/dev/null 2>&1 || true
podman exec hermes-agent sh -c '
hermes webhook subscribe gitea-events \
--secret "$GITEA_HERMES_WEBHOOK_SECRET" \
--description "Forward authenticated Gitea events to L.U.N.A." \
--deliver telegram --deliver-chat-id "15151223"
'
'';
};
}
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""Relay authenticated Gitea webhook requests to Hermes Agent.
This service exists for exactly one reason: Hermes derives the event name it
matches a subscription's `events` filter against from `X-GitHub-Event` /
`X-GitLab-Event`, falling back to the payload's `event_type`/`type` keys and
then to the literal string "unknown" (gateway/platforms/webhook.py). Gitea
never sends any of those — its event name rides `X-Gitea-Event`, and its
payloads carry no `event_type`/`type` key — so a Gitea webhook pointed
straight at Hermes authenticates fine but arrives as "unknown" forever, which
makes `hermes webhook subscribe --events ...` unable to select anything.
Everything else about a Gitea delivery already speaks Hermes natively:
Gitea's addDefaultHeaders() signs EVERY webhook type with
`X-Hub-Signature-256: sha256=<hmac-sha256(body)>`, byte-identical to GitHub's
scheme, and Hermes accepts that header on any route with no per-route
provider gating. So the body and the signature are forwarded untouched — this
process re-signs nothing and rewrites no payload. It copies one header.
It still verifies the signature itself rather than forwarding blindly, so an
unauthenticated caller that reaches this port never reaches the agent.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
LOG = logging.getLogger("gitea-hermes-webhook-relay")
LISTEN_HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("LISTEN_PORT", "8645"))
HERMES_URL = os.environ.get(
"HERMES_WEBHOOK_URL",
"http://127.0.0.1:8644/webhooks/gitea-events",
)
MAX_BODY_BYTES = int(os.environ.get("MAX_BODY_BYTES", str(1024 * 1024)))
CREDENTIAL_NAME = os.environ.get("WEBHOOK_CREDENTIAL_NAME", "webhook_secret")
def load_secret() -> bytes:
"""Read the shared secret, preferring systemd's credential store.
Both sources are stripped: the sops secret file usually ends in a newline,
while the value Gitea signs with comes from `$(cat ...)` in the
provisioning unit, which drops trailing newlines. Stripping here is what
keeps those two in agreement.
"""
credentials_dir = os.environ.get("CREDENTIALS_DIRECTORY")
if credentials_dir:
path = Path(credentials_dir) / CREDENTIAL_NAME
if path.is_file():
return path.read_bytes().strip()
value = os.environ.get("GITEA_HERMES_WEBHOOK_SECRET", "")
if value:
return value.strip().encode()
raise RuntimeError("webhook secret is not available")
def json_bytes(payload: dict) -> bytes:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
def signature_matches(secret: bytes, body: bytes, headers) -> bool:
"""Check the body against whichever signature header Gitea supplied.
Gitea sends both on every delivery: `X-Hub-Signature-256` (GitHub format,
`sha256=` prefixed) and `X-Gitea-Signature` (bare lowercase hex). Either is
accepted so the relay keeps working if one is ever dropped upstream.
"""
expected = hmac.new(secret, body, hashlib.sha256).hexdigest()
for header in ("X-Hub-Signature-256", "X-Gitea-Signature"):
provided = headers.get(header, "").strip()
if not provided:
continue
if provided.startswith("sha256="):
provided = provided.removeprefix("sha256=")
if hmac.compare_digest(provided, expected):
return True
return False
class Handler(BaseHTTPRequestHandler):
server_version = "gitea-hermes-relay/1.0"
def log_message(self, format: str, *args) -> None:
LOG.info("%s - %s", self.address_string(), format % args)
def send_json(self, status: int, payload: dict) -> None:
body = json_bytes(payload)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
if self.path == "/health":
self.send_json(200, {"status": "ok", "service": "gitea-hermes-webhook-relay"})
else:
self.send_json(404, {"status": "not_found"})
def do_POST(self) -> None:
if self.path not in {"/gitea", "/"}:
self.send_json(404, {"status": "not_found"})
return
raw_length = self.headers.get("Content-Length")
if raw_length is None:
self.send_json(411, {"status": "length_required"})
return
try:
content_length = int(raw_length)
except ValueError:
self.send_json(400, {"status": "invalid_content_length"})
return
if content_length < 0:
self.send_json(400, {"status": "invalid_content_length"})
return
if content_length > MAX_BODY_BYTES:
self.send_json(413, {"status": "payload_too_large"})
return
body = self.rfile.read(content_length)
try:
secret = load_secret()
except RuntimeError as exc:
LOG.error("%s", exc)
self.send_json(503, {"status": "relay_not_ready"})
return
if not signature_matches(secret, body, self.headers):
LOG.warning("rejected webhook with invalid signature")
self.send_json(401, {"status": "invalid_signature"})
return
gitea_event = self.headers.get("X-Gitea-Event", "")
gitea_event_type = self.headers.get("X-Gitea-Event-Type", "")
delivery_id = self.headers.get("X-Gitea-Delivery", "")
hub_signature = self.headers.get("X-Hub-Signature-256", "")
# The body is forwarded byte-for-byte, so Gitea's own signature stays
# valid — nothing is re-signed here. If Gitea ever stops sending the
# GitHub-format header, sign the unchanged body ourselves so Hermes
# still has something its GitHub branch can verify.
if not hub_signature:
hub_signature = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
forwarded_headers = {
"Content-Type": "application/json",
"X-Hub-Signature-256": hub_signature,
}
# The one transformation this service performs. Gitea's event names are
# passed through verbatim rather than mapped onto GitHub's vocabulary:
# Hermes only string-matches them against the subscription's `events`
# list, and Gitea has events (pull_request_comment, pull_request_sync,
# pull_request_review_approved, ...) with no GitHub equivalent to map to.
if gitea_event:
forwarded_headers["X-GitHub-Event"] = gitea_event
forwarded_headers["X-Gitea-Event"] = gitea_event
if gitea_event_type:
forwarded_headers["X-Gitea-Event-Type"] = gitea_event_type
if delivery_id:
forwarded_headers["X-Request-ID"] = delivery_id
forwarded_headers["X-Gitea-Delivery"] = delivery_id
request = Request(
HERMES_URL,
data=body,
headers=forwarded_headers,
method="POST",
)
try:
with urlopen(request, timeout=15) as response:
response.read()
except HTTPError as exc:
LOG.error("Hermes returned HTTP %s", exc.code)
self.send_json(502, {"status": "hermes_error", "http_status": exc.code})
return
except (URLError, TimeoutError, OSError) as exc:
LOG.error("failed to forward webhook to Hermes: %s", exc)
self.send_json(502, {"status": "hermes_unreachable"})
return
LOG.info(
"forwarded Gitea event=%s delivery=%s",
gitea_event or gitea_event_type or "unknown",
delivery_id or "none",
)
self.send_json(200, {"status": "forwarded"})
def main() -> None:
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
server = ThreadingHTTPServer((LISTEN_HOST, LISTEN_PORT), Handler)
LOG.info("listening on %s:%s; forwarding to %s", LISTEN_HOST, LISTEN_PORT, HERMES_URL)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
+86
View File
@@ -20,6 +20,37 @@ let
# but explicitly walled off `master`'s push/merge/approve whitelists so
# nothing she does lands without darman clicking merge.
lunaRepos = [ "darman/homelab" ];
# Forward every Gitea event to the generic Mars relay. Hermes owns the
# decision about which events matter and what to do with them.
giteaWebhookEvents = [
"create"
"delete"
"fork"
"push"
"issues"
"issue_assign"
"issue_label"
"issue_milestone"
"issue_comment"
"pull_request"
"pull_request_assign"
"pull_request_label"
"pull_request_milestone"
"pull_request_comment"
"pull_request_review_approved"
"pull_request_review_rejected"
"pull_request_review_comment"
"pull_request_sync"
"pull_request_review_request"
"wiki"
"repository"
"release"
"package"
"status"
"workflow_run"
"workflow_job"
];
in
{
services.gitea = {
@@ -264,4 +295,59 @@ in
'') lunaRepos}
'';
};
# Register the generic Gitea webhook. This is idempotent: it updates the
# existing hook for the relay target or creates it when absent. Event policy
# belongs to Hermes, so the source sends the complete Gitea event set.
systemd.services.gitea-hermes-webhook-provision = {
description = "Provision Gitea webhook for Hermes events";
after = [ "gitea.service" ];
requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.curl pkgs.jq ];
environment = {
TOKEN_FILE = config.sops.secrets.gitea_provisioning_token.path;
SECRET_FILE = config.sops.secrets.gitea_hermes_webhook_secret.path;
};
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = config.services.gitea.user;
};
script = ''
set -euo pipefail
api=http://127.0.0.1:${toString config.services.gitea.settings.server.HTTP_PORT}/api/v1
admin_token="$(cat "$TOKEN_FILE")"
secret="$(cat "$SECRET_FILE")"
auth=(-H "Authorization: token $admin_token")
target="http://mars.orbit.sol:8645/gitea"
# Same readiness gate as gitea-ci-bot-provision / gitea-luna-provision
# above: After=gitea.service only means the process started, not that it
# is serving HTTP yet. Without this the first curl below fails under
# `set -e`, and a Type=oneshot with no Restart= stays failed leaving
# the webhook silently unregistered until someone restarts the unit.
for _ in $(seq 1 30); do
curl -fs "$api/version" >/dev/null 2>&1 && break
sleep 1
done
# The secret goes to curl on stdin (--data @-), never in argv: this unit
# runs as the gitea user on a multi-user box, and a request body passed
# with -d is world-readable in /proc/<pid>/cmdline for its lifetime.
body="$(jq -n --arg url "$target" --arg secret "$secret" \
--argjson events '${builtins.toJSON giteaWebhookEvents}' \
'{type: "gitea", config: {content_type: "json", url: $url, secret: $secret}, events: $events, active: true}')"
hook_id="$(curl -fsS "''${auth[@]}" "$api/repos/darman/homelab/hooks" \
| jq -r --arg url "$target" 'first(.[] | select(.type == "gitea" and .config.url == $url)) | .id // empty')"
if [ -n "$hook_id" ]; then
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X PATCH "$api/repos/darman/homelab/hooks/$hook_id" --data @- >/dev/null
else
printf '%s' "$body" | curl -fsS "''${auth[@]}" -H 'Content-Type: application/json' \
-X POST "$api/repos/darman/homelab/hooks" --data @- >/dev/null
fi
'';
};
}