swactor-development-history/ci/CI_RELAY.md

520 lines
17 KiB
Markdown

# CI Webhook Relay via Iroh — Development History
> Covers the implementation of `ci-relay` and the iroh webhook receiver in
> `local-runner`, enabling Forgejo webhooks to reach a NAT'd CI runner via
> iroh's QUIC transport with automatic NAT traversal.
>
> ~3 files created · ~2 files modified · ~350 insertions
>
> *Branch: `spot-instance`*
---
## Table of Contents
1. [Problem & Motivation](#1-problem--motivation)
2. [Architecture](#2-architecture)
3. [What Was Built](#3-what-was-built)
4. [ci-relay Binary](#4-ci-relay-binary)
5. [local-runner Iroh Receiver](#5-local-runner-iroh-receiver)
6. [Wire Protocol](#6-wire-protocol)
7. [Connection Flow](#7-connection-flow)
8. [Design Decisions & Tradeoffs](#8-design-decisions--tradeoffs)
9. [Manual Testing Guide](#9-manual-testing-guide)
10. [Known Gaps & Future Improvements](#10-known-gaps--future-improvements)
---
## 1. Problem & Motivation
The CI runner (`local-runner`) was designed for same-LAN usage: Forgejo sends
webhooks over HTTP to the runner's listen port. In the real deployment:
- **Forgejo** runs on a VPS (`zachery.lol` / `139.59.195.69`)
- **CI runner** runs on a Thinkpad at home (`192.168.1.102`), behind NAT
The VPS cannot reach the Thinkpad directly — no inbound port is open, no
static IP, no UPnP. Traditional solutions (SSH reverse tunnel, VPN, port
forwarding on router) all require ongoing configuration and are fragile.
iroh is already integrated in swactor's distribution layer (`iroh_driver.rs`)
for SWIM protocol traffic. It provides QUIC connections with automatic NAT
traversal via relay servers — exactly what's needed to bridge the webhook gap.
### Why Not Just SSH Tunnel?
An SSH tunnel (`ssh -R 8787:localhost:8787 zachery.lol`) would work, but:
- Tunnels drop on network changes (laptop suspend, WiFi roaming)
- Requires autossh or systemd to keep alive
- Another moving part to debug when CI stops working
- Doesn't reuse any existing infrastructure
iroh handles reconnection, relay fallback, and NAT traversal automatically.
The implementation reuses the same tagged-message-over-QUIC-stream pattern
already proven in `iroh_driver.rs`.
---
## 2. Architecture
```
┌─────────────────────────────┐ ┌──────────────────────────────────┐
│ VPS (zachery.lol) │ │ Thinkpad (192.168.1.102) │
│ │ iroh │ │
│ Forgejo ──webhook──► Relay ├───────►│ local-runner │
│ :8787 │ QUIC │ (coordinator, runner, reporter) │
│ │ │ │
└─────────────────────────────┘ └──────────────────────────────────┘
```
**VPS side** — `ci-relay` binary:
- HTTP listener receives webhook POSTs from Forgejo (localhost only)
- iroh endpoint accepts the runner's inbound connection
- Forwards parsed `WebhookEvent` payloads over iroh uni streams
**Thinkpad side** — `local-runner` with `--relay-node-id`:
- Connects to the VPS relay's iroh endpoint on startup
- Receives `WebhookEvent` over iroh uni streams
- Feeds events into `LocalCoordinator` via existing `Webhook` message
- Status updates go directly Thinkpad → Forgejo API over HTTPS (no relay needed)
The relay is intentionally minimal — it's a bridge, not a CI component. All CI
logic stays in `local-runner`.
---
## 3. What Was Built
| Component | Location | Nature |
|-----------|----------|--------|
| ci-relay binary | `crates/ci-relay/Cargo.toml`, `src/main.rs` | **New** — VPS webhook relay |
| Iroh receiver | `crates/local-runner/src/main.rs` | **Modified** — iroh webhook source |
| Dependencies | `crates/local-runner/Cargo.toml` | **Modified** — added iroh, tokio, serde_json |
| Workspace | `Cargo.toml` | **Modified** — added ci-relay to members |
---
## 4. ci-relay Binary
### `crates/ci-relay/src/main.rs`
The relay runs two subsystems on a single process:
1. **iroh acceptor** (tokio task): accepts inbound connections from the runner,
caches the most recent one in `Arc<TokioMutex<Option<Connection>>>`
2. **HTTP listener** (main thread, blocking `tiny_http`): receives Forgejo
webhook POSTs, verifies HMAC, parses event, forwards over iroh
### Webhook Handling
Reuses the same verification and parsing logic as `webhook_server.rs`:
- HMAC-SHA256 verification via `X-Forgejo-Signature` header (skippable with empty secret)
- Event type from `X-Forgejo-Event` header: `push` → `Push`, `create` → `Tag`, `pull_request` → `Merge`
- JSON parsing via `parse_webhook_json()` (re-exported from `swactor-ci`)
The relay uses `parse_webhook_json` directly rather than duplicating parsing
logic. This keeps webhook interpretation consistent between HTTP and iroh paths.
### Forwarding
On webhook receipt, the relay:
1. Serializes the `WebhookEvent` to JSON
2. Opens a unidirectional QUIC stream on the cached connection
3. Writes the tagged message (`ci::WebhookEvent` tag + JSON payload)
4. Finishes the stream
If no runner is connected, the relay returns HTTP 502 to Forgejo. Forgejo will
retry the webhook per its configured retry policy.
### CLI
```
ci-relay [OPTIONS]
Options:
--port <PORT> HTTP port for Forgejo webhooks [default: 8787]
--secret <SECRET> HMAC-SHA256 secret [default: "" (no verification)]
```
On startup, the relay prints its iroh Node ID — this is the value the runner
needs for `--relay-node-id`.
---
## 5. local-runner Iroh Receiver
### New CLI Flag
```
--relay-node-id <HEX> Iroh Node ID of the VPS ci-relay
```
When `--relay-node-id` is provided:
- The HTTP webhook listener is **not started** (no port conflict, no exposure)
- An `iroh-receiver` thread starts instead
When omitted, behavior is unchanged — the HTTP listener starts on `--port`
as before.
### `start_iroh_receiver()`
Spawns a dedicated thread (`iroh-receiver`) with its own single-threaded tokio
runtime:
1. Creates an iroh `Endpoint` with ALPN `b"swactor/ci/1"`
2. Connects to the relay's `PublicKey` (parsed from the hex flag)
3. Enters a receive loop:
- `conn.accept_uni()` with 1-second timeout
- On stream: reads tagged message, deserializes `WebhookEvent`
- Sends `LocalCoordinatorMsg::Webhook(event)` to the coordinator via the swactor runtime
- On timeout: checks the `stop` flag (for graceful shutdown via Ctrl-C)
- On connection error: breaks and exits
The thread respects the same `AtomicBool` stop flag as the main loop, so
Ctrl-C cleanly shuts down both the swactor runtime and the iroh connection.
---
## 6. Wire Protocol
### ALPN
```rust
const CI_ALPN: &[u8] = b"swactor/ci/1";
```
Distinct from SWIM traffic (`b"swactor/swim/1"`). This allows both protocols
to coexist on the same iroh endpoint in the future if needed.
### Frame Format
Same tagged-message format as `iroh_driver.rs`:
```
[4 bytes: tag_len (big-endian u32)]
[tag_len bytes: tag string]
[remaining bytes: payload]
```
For webhook events:
- Tag: `"ci::WebhookEvent"` (17 bytes)
- Payload: JSON-serialized `WebhookEvent`
### Transport
Each webhook is one unidirectional QUIC stream. The relay opens the stream,
writes the tagged message, and finishes. The runner reads the message and the
stream closes. No persistent framing or multiplexing needed — QUIC streams
are lightweight.
---
## 7. Connection Flow
```
1. VPS starts ci-relay
→ iroh Endpoint binds
→ prints Node ID (ed25519 public key, hex)
→ HTTP listener starts on --port
→ waits for runner connection
2. Thinkpad starts local-runner --relay-node-id <hex>
→ iroh Endpoint binds
→ connects to relay's PublicKey
→ iroh handles NAT traversal (direct or via relay server)
→ relay logs "Runner connected: <runner-node-id>"
3. Forgejo sends webhook POST to localhost:8787 on VPS
→ relay verifies HMAC, parses event
→ relay opens uni stream on cached connection
→ writes tagged WebhookEvent
→ runner receives, deserializes, dispatches to coordinator
4. Coordinator triggers pipeline
→ StatusReporter posts status to Forgejo API directly
(Thinkpad → zachery.lol over HTTPS, no relay involvement)
```
The iroh connection is initiated by the runner (outbound from NAT), so no port
forwarding is needed. iroh's relay servers handle the initial rendezvous, then
attempt direct QUIC hole-punching for subsequent traffic.
---
## 8. Design Decisions & Tradeoffs
### 8.1 Separate Binary vs. Library Module
**Choice**: `ci-relay` is a standalone binary, not a module in `swactor-ci`.
**Why**: The relay runs on the VPS, which doesn't need swactor's runtime,
actors, or any CI execution logic. A small binary with minimal dependencies
deploys easily. It only depends on `swactor-ci` for `parse_webhook_json` and
the `WebhookEvent`/`EventType` types.
**Tradeoff**: Two binaries to build and deploy instead of one. Acceptable
given they run on different machines.
### 8.2 Runner Connects to Relay (Not Vice Versa)
**Choice**: The runner initiates the iroh connection to the relay.
**Why**: The runner is behind NAT. iroh can traverse NAT for established
connections, but the initial rendezvous requires at least one side to be
reachable. The VPS relay has a public IP and gets a stable relay URL from iroh's
infrastructure. The runner connects outbound, which always works regardless of
NAT type.
### 8.3 Single Cached Connection (Not Connection Pool)
**Choice**: The relay caches exactly one runner connection in
`Arc<TokioMutex<Option<Connection>>>`.
**Why**: There's one runner. If a new connection arrives (e.g., runner
restarts), it replaces the old one. No pool management needed.
**Tradeoff**: If multiple runners were needed, this would need a map. For
single-runner use, the simplicity is worth it.
### 8.4 Own Tokio Runtime Per Thread
**Choice**: The iroh-receiver thread creates its own single-threaded tokio
runtime rather than sharing the swactor runtime or the main thread's runtime.
**Why**: swactor's runtime is not tokio — it's a custom actor scheduler. The
iroh receiver needs async for QUIC operations. A dedicated single-threaded
runtime keeps the iroh I/O isolated from actor scheduling. Same pattern as
`IrohDriver` in the distribution layer (which owns a multi-thread runtime).
### 8.5 HTTP 502 When No Runner Connected
**Choice**: If Forgejo sends a webhook but no runner is connected, the relay
returns HTTP 502 (Bad Gateway).
**Why**: 502 tells Forgejo the upstream is unavailable. Forgejo will retry
the webhook according to its retry policy. This is better than 200 (silently
dropping) or 500 (suggesting a relay bug). When the runner reconnects, the
next webhook will succeed.
---
## 9. Manual Testing Guide
### Prerequisites
Build both binaries:
```bash
cargo build -p ci-relay -p local-runner
```
### 9.1 Local Smoke Test (Single Machine)
This tests the full relay path without needing two machines or Forgejo.
**Terminal 1 — Start the relay:**
```bash
./target/debug/ci-relay --port 9787
```
Output:
```
ci-relay started
Iroh Node ID: <NODE_ID_HEX>
Webhook HTTP: http://0.0.0.0:9787
Waiting for runner to connect...
Listening for webhooks...
```
Copy the Node ID.
**Terminal 2 — Start the runner:**
You need a `.ci.yml` file. Create a minimal one:
```yaml
# /tmp/test-ci.yml
pipelines:
test:
triggers:
- event: push
branches: ["*"]
jobs:
hello:
run: echo "hello from CI"
```
Then start:
```bash
./target/debug/local-runner \
--relay-node-id <NODE_ID_HEX> \
--yaml /tmp/test-ci.yml \
--work-dir /tmp/ci-work-test
```
You should see:
```
Iroh local ID: <RUNNER_ID>
Connecting to relay <NODE_ID>...
Connected to relay!
Local CI runner started
Webhook: via iroh relay
```
And in Terminal 1:
```
Runner connected: <RUNNER_ID>
```
**Terminal 3 — Send a fake webhook:**
```bash
curl -X POST http://localhost:9787 \
-H "Content-Type: application/json" \
-H "X-Forgejo-Event: push" \
-d '{
"ref": "refs/heads/main",
"after": "abc123def456789012345678901234567890abcd",
"repository": {
"name": "test-repo",
"owner": { "login": "testuser" }
}
}'
```
Expected output:
- **curl** returns: `ok`
- **Terminal 1** (relay):
```
webhook: abc123de main on testuser/test-repo
→ forwarded to runner
```
- **Terminal 2** (runner):
```
iroh: received webhook abc123de on main
```
The runner will also try to post status to Forgejo and log URL errors (since
we didn't pass `--forgejo-url`) — that's expected and confirms the event
reached the coordinator.
### 9.2 HMAC Verification Test
Start the relay with a secret:
```bash
./target/debug/ci-relay --port 9787 --secret mysecret
```
**Without signature — should be rejected (401):**
```bash
curl -v -X POST http://localhost:9787 \
-H "X-Forgejo-Event: push" \
-d '{"ref":"refs/heads/main","after":"abc123","repository":{"name":"r","owner":{"login":"u"}}}'
```
**With correct signature:**
```bash
# Compute HMAC-SHA256
BODY='{"ref":"refs/heads/main","after":"abc123","repository":{"name":"r","owner":{"login":"u"}}}'
SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "mysecret" | awk '{print $2}')
curl -X POST http://localhost:9787 \
-H "X-Forgejo-Event: push" \
-H "X-Forgejo-Signature: $SIG" \
-d "$BODY"
```
Should return `ok` and forward to the runner.
### 9.3 Runner Reconnection Test
1. Start relay and runner as in 9.1
2. Kill the runner (Ctrl-C in Terminal 2)
3. Restart the runner with the same `--relay-node-id`
4. The relay should log `Runner connected: <ID>` again
5. Send another webhook — it should flow through
### 9.4 No Runner Connected Test
1. Start the relay only (no runner)
2. Send a webhook via curl
3. Should get HTTP 502 and relay logs: `forward failed: no runner connected`
### 9.5 Full End-to-End with Forgejo
For a real deployment:
**On VPS:**
```bash
./ci-relay --port 8787 --secret <your-webhook-secret>
```
**On Thinkpad:**
```bash
./local-runner \
--relay-node-id <NODE_ID_FROM_VPS> \
--forgejo-url https://zachery.lol \
--forgejo-token <your-forgejo-api-token> \
--yaml .ci.yml \
--work-dir ~/ci-work \
--repo-url https://zachery.lol/<owner>/<repo>.git
```
**In Forgejo (repo settings → Webhooks):**
- Target URL: `http://localhost:8787`
- Secret: `<your-webhook-secret>`
- Events: Push, Create (tags), Pull Request
Push a commit and watch:
1. Relay logs the webhook and forwards it
2. Runner logs the received event and starts a pipeline
3. Forgejo shows commit status checks (pending → success/failure)
### 9.6 Inspecting Iroh Connectivity
Both binaries print their iroh Node ID on startup. To verify they're using
relay servers (expected when both are behind NAT or on different networks),
look for connection timing:
- **Fast connection (~1-3s)**: direct QUIC hole-punch succeeded
- **Slower connection (~5-10s)**: using iroh relay server fallback
If connection hangs indefinitely, check that both machines have internet
access and can reach iroh's relay servers (`https://relay.iroh.network`).
---
## 10. Known Gaps & Future Improvements
| Gap | Effort | Impact | Notes |
|-----|--------|--------|-------|
| Reconnection on runner side | Small | High | If the iroh connection drops mid-operation, the runner currently exits the receive loop. Should retry with backoff. |
| Multiple runner support | Medium | Medium | Relay caches one connection. For running CI on multiple machines, need a connection map keyed by runner identity. |
| Health check / heartbeat | Small | Medium | Neither side detects a silently dead connection until the next webhook. A periodic ping would surface stale connections faster. |
| Relay authentication | Small | Medium | Any iroh endpoint can connect to the relay. Should verify the runner's public key against an allowlist. |
| Binary size | Small | Low | ci-relay pulls in `swactor-ci` (which includes all CI types). A slimmer dependency with just `WebhookEvent` + `parse_webhook_json` would reduce the VPS binary. |
| Logging | Small | Low | Both binaries use `eprintln!`. Structured logging (tracing) would help in production. |
---
## Files Created/Modified
| Action | File | Purpose |
|--------|------|---------|
| Created | `crates/ci-relay/Cargo.toml` | Relay binary manifest |
| Created | `crates/ci-relay/src/main.rs` | Webhook relay: HTTP → iroh |
| Modified | `crates/local-runner/Cargo.toml` | Added iroh, tokio, serde_json deps |
| Modified | `crates/local-runner/src/main.rs` | Added `--relay-node-id` flag and iroh receiver |
| Modified | `Cargo.toml` (workspace root) | Added ci-relay to workspace members |