mars: add generic Gitea webhook relay #2

Merged
darman merged 4 commits from feat/mars-gitea-webhook-relay into master 2026-08-23 05:14:36 +02:00
4 changed files with 26 additions and 39 deletions
Showing only changes of commit 6a037d557c - Show all commits
+7 -6
View File
@@ -37,13 +37,14 @@ 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 PR comment relay ## Gitea event relay
Mars includes a small HMAC-validating relay for Gitea webhooks. It normalizes Mars includes a small HMAC-validating relay for Gitea webhooks. It forwards the
Gitea headers and forwards every authenticated JSON event to Hermes over authenticated request body unchanged, along with Gitea event and delivery
localhost; Hermes owns event selection, repository policy, and response headers, to Hermes over localhost. The relay has no event, repository, action,
behavior. Jupiter's Gitea provisioning service registers the webhook payload, or prompt policy; Hermes owns interpretation and response behavior.
idempotently at `http://mars.orbit.sol:8645/gitea`. Jupiter's Gitea provisioning service registers the webhook idempotently at
`http://mars.orbit.sol:8645/gitea`.
Before deploying either host, add the same random Before deploying either host, add the same random
`gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and `gitea_hermes_webhook_secret` value to both `secrets/mars.yaml` and
+4 -5
View File
@@ -42,12 +42,11 @@ in
}; };
}; };
# The relay forwards into a named Hermes webhook subscription. Keep the # The relay forwards into a generic Hermes webhook subscription. Keep the
# subscription declaratively present without replacing the rest of Hermes's # subscription declaratively present without putting event policy or prompt
# runtime-managed webhook state. The secret is already in the container's # text in this transport unit. Hermes owns interpretation and response policy.
# environment file, but never appears in this unit or the Nix store.
systemd.services.hermes-agent-webhook-route = { systemd.services.hermes-agent-webhook-route = {
description = "Configure Hermes Gitea PR comment webhook route"; description = "Configure Hermes Gitea event webhook route";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "podman-hermes-agent.service" ]; after = [ "podman-hermes-agent.service" ];
requires = [ "podman-hermes-agent.service" ]; requires = [ "podman-hermes-agent.service" ];
+14 -27
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Normalize authenticated Gitea PR webhooks for Hermes Agent.""" """Relay authenticated Gitea webhook requests to Hermes Agent."""
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
@@ -40,13 +40,6 @@ def json_bytes(payload: dict) -> bytes:
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode() return json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()
def normalized_event(headers, payload: dict) -> str:
event = headers.get("X-Gitea-Event-Type", "") or headers.get("X-Gitea-Event", "")
if event == "issue_comment" and payload.get("is_pull") is True:
return "pull_request_comment"
return event or "unknown"
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "gitea-hermes-relay/1.0" server_version = "gitea-hermes-relay/1.0"
@@ -82,15 +75,6 @@ class Handler(BaseHTTPRequestHandler):
return return
body = self.rfile.read(content_length) body = self.rfile.read(content_length)
try:
payload = json.loads(body)
except json.JSONDecodeError:
self.send_json(400, {"status": "invalid_json"})
return
if not isinstance(payload, dict):
self.send_json(400, {"status": "invalid_payload"})
return
try: try:
secret = load_secret() secret = load_secret()
except RuntimeError as exc: except RuntimeError as exc:
@@ -107,22 +91,26 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(401, {"status": "invalid_signature"}) self.send_json(401, {"status": "invalid_signature"})
return return
event = normalized_event(self.headers, payload) # Keep the incoming body unchanged. Event interpretation and policy
normalized = dict(payload) # belong to Hermes, not to this transport service.
normalized["event_type"] = event forwarded_body = body
normalized["relay_source"] = "gitea"
forwarded_body = json_bytes(normalized)
forwarded_signature = hmac.new( forwarded_signature = hmac.new(
secret, forwarded_body, hashlib.sha256 secret, forwarded_body, hashlib.sha256
).hexdigest() ).hexdigest()
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", "") delivery_id = self.headers.get("X-Gitea-Delivery", "")
forwarded_headers = { forwarded_headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-GitHub-Event": event,
"X-Webhook-Signature": forwarded_signature, "X-Webhook-Signature": forwarded_signature,
} }
if 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: if delivery_id:
forwarded_headers["X-Request-ID"] = delivery_id forwarded_headers["X-Request-ID"] = delivery_id
forwarded_headers["X-Gitea-Delivery"] = delivery_id
request = Request( request = Request(
HERMES_URL, HERMES_URL,
@@ -148,12 +136,11 @@ class Handler(BaseHTTPRequestHandler):
return return
LOG.info( LOG.info(
"forwarded %s action=%s delivery=%s", "forwarded Gitea event=%s delivery=%s",
event, gitea_event or gitea_event_type or "unknown",
payload.get("action", ""),
delivery_id or "none", delivery_id or "none",
) )
self.send_json(200, {"status": "forwarded", "event": event}) self.send_json(200, {"status": "forwarded"})
def main() -> None: def main() -> None:
+1 -1
View File
@@ -300,7 +300,7 @@ in
# existing hook for the relay target or creates it when absent. Event policy # 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. # belongs to Hermes, so the source sends the complete Gitea event set.
systemd.services.gitea-hermes-webhook-provision = { systemd.services.gitea-hermes-webhook-provision = {
description = "Provision Gitea webhook for Hermes PR comments"; description = "Provision Gitea webhook for Hermes events";
after = [ "gitea.service" ]; after = [ "gitea.service" ];
requires = [ "gitea.service" ]; requires = [ "gitea.service" ];
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];