mars: add generic Gitea webhook relay #2
@@ -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
|
||||||
|
|||||||
@@ -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" ];
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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" ];
|
||||||
|
|||||||
Reference in New Issue
Block a user