mars: add generic Gitea webhook relay #2
@@ -37,6 +37,35 @@ scripts/ # deploy, edit_secrets
|
|||||||
Hosts compose by importing `common.nix` + whichever `services/*` modules they
|
Hosts compose by importing `common.nix` + whichever `services/*` modules they
|
||||||
run. Each service module opens its own firewall ports.
|
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)
|
## Test in VirtualBox (no hardware needed)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
# ci-bot access token to allow the ci-bot user to push to repos
|
# ci-bot access token to allow the ci-bot user to push to repos
|
||||||
sops.secrets.gitea_ci_bot_token.owner = "gitea";
|
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) —
|
# SABnzbd credentials (web UI login, API keys, eweka.nl usenet server) —
|
||||||
# migrated off the reused ini in services/media/sabnzbd.nix into
|
# migrated off the reused ini in services/media/sabnzbd.nix into
|
||||||
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this
|
# services.sabnzbd.settings + secretValues. sabnzbd_api_key predates this
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
../../services/containers.nix
|
../../services/containers.nix
|
||||||
../../services/vpn/tailscale.nix
|
../../services/vpn/tailscale.nix
|
||||||
../../services/monitoring/node-exporter.nix
|
../../services/monitoring/node-exporter.nix
|
||||||
|
../../services/dev/gitea-hermes-webhook-relay.nix
|
||||||
];
|
];
|
||||||
|
|
||||||
networking.hostName = "mars";
|
networking.hostName = "mars";
|
||||||
|
|||||||
@@ -28,11 +28,35 @@
|
|||||||
sops.secrets.opencode_go_api_key = { };
|
sops.secrets.opencode_go_api_key = { };
|
||||||
sops.secrets.telegram_bot_token = { };
|
sops.secrets.telegram_bot_token = { };
|
||||||
sops.secrets.hermes_dashboard_oidc_client_secret = { };
|
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 = ''
|
sops.templates."hermes-agent.env".content = ''
|
||||||
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
|
OPENCODE_GO_API_KEY=${config.sops.placeholder.opencode_go_api_key}
|
||||||
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
|
TELEGRAM_BOT_TOKEN=${config.sops.placeholder.telegram_bot_token}
|
||||||
TELEGRAM_HOME_CHANNEL=15151223
|
TELEGRAM_HOME_CHANNEL=15151223
|
||||||
TELEGRAM_ALLOWED_USERS=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}
|
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)
|
||||||
@@ -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"
|
||||||
|
'
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -20,6 +20,37 @@ let
|
|||||||
# but explicitly walled off `master`'s push/merge/approve whitelists so
|
# but explicitly walled off `master`'s push/merge/approve whitelists so
|
||||||
# nothing she does lands without darman clicking merge.
|
# nothing she does lands without darman clicking merge.
|
||||||
lunaRepos = [ "darman/homelab" ];
|
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
|
in
|
||||||
{
|
{
|
||||||
services.gitea = {
|
services.gitea = {
|
||||||
@@ -264,4 +295,59 @@ in
|
|||||||
'') lunaRepos}
|
'') 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
|
||||||
|
'';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user