summaryrefslogtreecommitdiff
path: root/docs/handbook/ofborg/webhook-receiver.md
blob: 7eddf7173bf32eb2a344fee6ddf67eddcb740d9f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
# Tickborg — Webhook Receiver

## Overview

The **GitHub Webhook Receiver** (`github-webhook-receiver`) is the entry point
for all GitHub events into the tickborg system. It is an HTTP server that:

1. Listens for incoming POST requests from GitHub's webhook delivery system.
2. Validates the HMAC-SHA256 signature of every payload.
3. Extracts the event type from the `X-Github-Event` header.
4. Parses the payload to determine the target repository.
5. Publishes the raw payload to the `github-events` RabbitMQ topic exchange.
6. Declares and binds the downstream queues that other workers consume from.

**Source file:** `tickborg/src/bin/github-webhook-receiver.rs`

---

## HTTP Server

The webhook receiver uses **hyper 1.0** directly — no web framework is
involved. The server is configured to listen on the address specified in the
configuration file:

```rust
let addr: SocketAddr = listen.parse().expect("Invalid listen address");
let listener = TcpListener::bind(addr).await?;
```

The main accept loop:

```rust
loop {
    let (stream, _) = listener.accept().await?;
    let io = TokioIo::new(stream);

    let secret = webhook_secret.clone();
    let chan = chan.clone();

    tokio::task::spawn(async move {
        let service = service_fn(move |req| {
            handle_request(req, secret.clone(), chan.clone())
        });
        http1::Builder::new().serve_connection(io, service).await
    });
}
```

Each incoming connection is spawned as an independent tokio task. The service
function (`handle_request`) processes one request at a time per connection.

---

## Request Handling

### HTTP Method Validation

```rust
if req.method() != Method::POST {
    return Ok(empty_response(StatusCode::METHOD_NOT_ALLOWED));
}
```

Only `POST` requests are accepted. Any other method receives a `405 Method Not
Allowed`.

### Header Extraction

Three headers are extracted before consuming the request body:

```rust
let sig_header = req.headers().get("X-Hub-Signature-256")
    .and_then(|v| v.to_str().ok())
    .map(|s| s.to_string());

let event_type = req.headers().get("X-Github-Event")
    .and_then(|v| v.to_str().ok())
    .map(|s| s.to_string());

let content_type = req.headers().get("Content-Type")
    .and_then(|v| v.to_str().ok())
    .map(|s| s.to_string());
```

### Body Collection

```rust
let raw = match req.collect().await {
    Ok(collected) => collected.to_bytes(),
    Err(e) => {
        warn!("Failed to read body from client: {e}");
        return Ok(response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to read body"));
    }
};
```

The full body is collected into a `Bytes` buffer using `http-body-util`'s
`BodyExt::collect()`.

---

## HMAC-SHA256 Signature Verification

GitHub sends a `X-Hub-Signature-256` header with the format:

```
sha256=<hex-encoded HMAC-SHA256>
```

The webhook receiver verifies this signature against the configured webhook
secret:

### Step 1: Parse the signature header

```rust
let Some(sig) = sig_header else {
    return Ok(response(StatusCode::BAD_REQUEST, "Missing signature header"));
};

let mut components = sig.splitn(2, '=');
let Some(algo) = components.next() else {
    return Ok(response(StatusCode::BAD_REQUEST, "Signature hash method missing"));
};
let Some(hash) = components.next() else {
    return Ok(response(StatusCode::BAD_REQUEST, "Signature hash missing"));
};
let Ok(hash) = hex::decode(hash) else {
    return Ok(response(StatusCode::BAD_REQUEST, "Invalid signature hash hex"));
};
```

### Step 2: Validate the algorithm

```rust
if algo != "sha256" {
    return Ok(response(StatusCode::BAD_REQUEST, "Invalid signature hash method"));
}
```

Only SHA-256 is accepted. GitHub also supports SHA-1 (`X-Hub-Signature`) but
tickborg does not accept it.

### Step 3: Compute and compare

```rust
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(webhook_secret.as_bytes()) else {
    error!("Unable to create HMAC from secret");
    return Ok(response(StatusCode::INTERNAL_SERVER_ERROR, "Internal error"));
};

mac.update(&raw);

if mac.verify_slice(&hash).is_err() {
    return Ok(response(StatusCode::FORBIDDEN, "Signature verification failed"));
}
```

The HMAC is computed using `hmac::Hmac<sha2::Sha256>` from the `hmac` and `sha2`
crates. `verify_slice` performs a constant-time comparison to prevent timing
attacks.

---

## Event Type Routing

After signature verification, the event type and repository are determined:

```rust
let event_type = event_type.unwrap_or_else(|| "unknown".to_owned());

let body_json: GenericWebhook = match serde_json::from_slice(&raw) {
    Ok(webhook) => webhook,
    Err(_) => {
        // If we can't parse the body, route to the unknown queue
        // ...
    }
};

let routing_key = format!("{}.{}", event_type, body_json.repository.full_name);
```

The `GenericWebhook` struct is minimal — it only extracts the `repository`
field:

```rust
// ghevent/common.rs
#[derive(Serialize, Deserialize, Debug)]
pub struct GenericWebhook {
    pub repository: Repository,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct Repository {
    pub owner: User,
    pub name: String,
    pub full_name: String,
    pub clone_url: String,
}
```

### Routing Key Format

```
{event_type}.{owner}/{repo}
```

Examples:
- `pull_request.project-tick/Project-Tick`
- `issue_comment.project-tick/Project-Tick`
- `push.project-tick/Project-Tick`
- `unknown.project-tick/Project-Tick`

---

## AMQP Setup

The `setup_amqp` function declares the exchange and all downstream queues:

### Exchange Declaration

```rust
chan.declare_exchange(easyamqp::ExchangeConfig {
    exchange: "github-events".to_owned(),
    exchange_type: easyamqp::ExchangeType::Topic,
    passive: false,
    durable: true,
    auto_delete: false,
    no_wait: false,
    internal: false,
}).await?;
```

The `github-events` exchange is a **topic** exchange. This means routing keys
are matched against binding patterns using `.`-separated segments and `*`/`#`
wildcards.

### Queue Declarations and Bindings

| Queue | Binding Pattern | Consumer |
|-------|----------------|----------|
| `build-inputs` | `issue_comment.*` | github-comment-filter |
| `github-events-unknown` | `unknown.*` | (monitoring/debugging) |
| `mass-rebuild-check-inputs` | `pull_request.*` | evaluation-filter |
| `push-build-inputs` | `push.*` | push-filter |

Each queue is declared with:

```rust
chan.declare_queue(easyamqp::QueueConfig {
    queue: queue_name.clone(),
    passive: false,
    durable: true,     // survive broker restart
    exclusive: false,   // accessible by other connections
    auto_delete: false, // don't delete when last consumer disconnects
    no_wait: false,
}).await?;
```

And bound to the exchange:

```rust
chan.bind_queue(easyamqp::BindQueueConfig {
    queue: queue_name.clone(),
    exchange: "github-events".to_owned(),
    routing_key: Some(String::from("issue_comment.*")),
    no_wait: false,
}).await?;
```

---

## Message Publishing

After validation and routing key construction, the raw GitHub payload is
published:

```rust
let props = BasicProperties::default()
    .with_content_type("application/json".into())
    .with_delivery_mode(2);  // persistent

chan.lock().await.basic_publish(
    "github-events".into(),
    routing_key.into(),
    BasicPublishOptions::default(),
    &raw,
    props,
).await?;
```

Key properties:
- **delivery_mode = 2**: Message is persisted to disk by RabbitMQ.
- **content_type**: `application/json` — the raw GitHub payload.
- The **entire raw body** is published, not a parsed/re-serialized version.
  This preserves all fields that downstream consumers might need, even if the
  webhook receiver itself doesn't parse them.

---

## Configuration

The webhook receiver reads from the `github_webhook_receiver` section of the
config:

```rust
#[derive(Serialize, Deserialize, Debug)]
pub struct GithubWebhookConfig {
    pub listen: String,
    pub webhook_secret_file: String,
    pub rabbitmq: RabbitMqConfig,
}
```

Example configuration:

```json
{
    "github_webhook_receiver": {
        "listen": "0.0.0.0:9899",
        "webhook_secret_file": "/run/secrets/tickborg/webhook-secret",
        "rabbitmq": {
            "ssl": false,
            "host": "rabbitmq:5672",
            "virtualhost": "tickborg",
            "username": "tickborg",
            "password_file": "/run/secrets/tickborg/rabbitmq-password"
        }
    }
}
```

The webhook secret is read from a file (not inline in the config) to prevent
accidental exposure in version control.

---

## Response Codes

| Code | Meaning |
|------|---------|
| `200 OK` | Webhook received and published successfully |
| `400 Bad Request` | Missing or malformed signature header |
| `403 Forbidden` | Signature verification failed |
| `405 Method Not Allowed` | Non-POST request |
| `500 Internal Server Error` | Body read failure or HMAC creation failure |

---

## GitHub Webhook Configuration

### Required Events

The GitHub App or webhook should be configured to send:

| Event | Used By |
|-------|---------|
| `pull_request` | evaluation-filter (auto-eval on PR open/sync) |
| `issue_comment` | github-comment-filter (@tickbot commands) |
| `push` | push-filter (branch push CI) |
| `check_run` | (optional, for re-run triggers) |

### Required Permissions (GitHub App)

| Permission | Level | Purpose |
|------------|-------|---------|
| Pull requests | Read & Write | Read PR details, post comments |
| Commit statuses | Read & Write | Set commit status checks |
| Issues | Read & Write | Read comments, manage labels |
| Contents | Read | Clone repository, read files |
| Checks | Read & Write | Create/update check runs |

### Webhook URL

```
https://<your-domain>:9899/github-webhooks
```

The receiver accepts POSTs on any path — the path segment is not validated.
However, conventionally `/github-webhooks` is used.

---

## Security Considerations

### Signature Verification

**Every** request must have a valid `X-Hub-Signature-256` header. Requests
without this header, or with an invalid signature, are rejected before any
processing occurs. The HMAC comparison uses `verify_slice` which is
constant-time.

### Secret File

The webhook secret is read from a file rather than an environment variable or
inline config value. This:
- Prevents accidental exposure in process listings (`/proc/*/environ`)
- Allows secrets management via Docker secrets, Kubernetes secrets, or
  NixOS `sops-nix`

### No Path Traversal

The webhook receiver does not serve files or interact with the filesystem beyond
reading the config and secret files. There is no path traversal risk.

### Rate Limiting

The webhook receiver does **not** implement application-level rate limiting.
This should be handled by:
- An upstream reverse proxy (nginx, Caddy)
- GitHub's own delivery rate limiting
- RabbitMQ's flow control mechanisms

---

## Deployment

### Docker Compose

```yaml
webhook-receiver:
    build:
        context: .
        dockerfile: Dockerfile
    command: ["github-webhook-receiver", "/etc/tickborg/config.json"]
    ports:
        - "9899:9899"
    volumes:
        - ./config.json:/etc/tickborg/config.json:ro
        - ./secrets:/run/secrets/tickborg:ro
    depends_on:
        rabbitmq:
            condition: service_healthy
    restart: unless-stopped
```

### NixOS (`service.nix`)

```nix
systemd.services."tickborg-webhook-receiver" = mkTickborgService "Webhook Receiver" {
    binary = "github_webhook_receiver";
};
```

Note: The binary name uses underscores (`github_webhook_receiver`) while the
Cargo target uses hyphens (`github-webhook-receiver`). Cargo generates both
forms but the NixOS service uses the underscore variant.

---

## Monitoring

The webhook receiver logs:
- Every accepted webhook (event type, routing key)
- Signature verification failures (at `warn` level)
- AMQP publish errors (at `error` level)
- Body read failures (at `warn` level)

Check the `github-events-unknown` queue for events that couldn't be routed to
a handler — these indicate new event types that may need new consumers.

---

## Event Type Reference

| GitHub Event | Routing Key Pattern | Queue | Handler |
|-------------|--------------------|---------|---------| 
| `pull_request` | `pull_request.{owner}/{repo}` | `mass-rebuild-check-inputs` | evaluation-filter |
| `issue_comment` | `issue_comment.{owner}/{repo}` | `build-inputs` | github-comment-filter |
| `push` | `push.{owner}/{repo}` | `push-build-inputs` | push-filter |
| (any other) | `unknown.{owner}/{repo}` | `github-events-unknown` | none (monitoring) |