From 8f01a0ea4b38bc210e5eb5aeba6c05d226de16db Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 5 Apr 2026 16:44:26 -0400 Subject: [PATCH] feat(trogon-source-linear): add Linear webhook receiver that sinks events to NATS JetStream Signed-off-by: Yordis Prieto --- devops/docker/compose/.env.example | 9 + devops/docker/compose/compose.yml | 31 + .../services/trogon-source-linear/Dockerfile | 45 + .../services/trogon-source-linear/README.md | 72 ++ rsworkspace/Cargo.lock | 912 +++++++++++++++++- .../crates/acp-telemetry/src/service_name.rs | 10 + .../crates/trogon-source-linear/Cargo.toml | 35 + .../crates/trogon-source-linear/src/config.rs | 362 +++++++ .../trogon-source-linear/src/constants.rs | 11 + .../crates/trogon-source-linear/src/lib.rs | 40 + .../crates/trogon-source-linear/src/main.rs | 47 + .../crates/trogon-source-linear/src/server.rs | 245 +++++ .../trogon-source-linear/src/signature.rs | 100 ++ 13 files changed, 1883 insertions(+), 36 deletions(-) create mode 100644 devops/docker/compose/services/trogon-source-linear/Dockerfile create mode 100644 devops/docker/compose/services/trogon-source-linear/README.md create mode 100644 rsworkspace/crates/trogon-source-linear/Cargo.toml create mode 100644 rsworkspace/crates/trogon-source-linear/src/config.rs create mode 100644 rsworkspace/crates/trogon-source-linear/src/constants.rs create mode 100644 rsworkspace/crates/trogon-source-linear/src/lib.rs create mode 100644 rsworkspace/crates/trogon-source-linear/src/main.rs create mode 100644 rsworkspace/crates/trogon-source-linear/src/server.rs create mode 100644 rsworkspace/crates/trogon-source-linear/src/signature.rs diff --git a/devops/docker/compose/.env.example b/devops/docker/compose/.env.example index 7d7cbbd61..99341ef7a 100644 --- a/devops/docker/compose/.env.example +++ b/devops/docker/compose/.env.example @@ -10,6 +10,15 @@ # GITHUB_NATS_ACK_TIMEOUT_SECS=10 # GITHUB_MAX_BODY_SIZE=26214400 +# --- Linear Source --- +# LINEAR_WEBHOOK_SECRET= +# LINEAR_WEBHOOK_PORT=8080 +# LINEAR_SUBJECT_PREFIX=linear +# LINEAR_STREAM_NAME=LINEAR +# LINEAR_STREAM_MAX_AGE_SECS=604800 +# LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS=60 +# LINEAR_NATS_ACK_TIMEOUT_MS=10000 + # --- Slack Source --- # SLACK_SIGNING_SECRET= # SLACK_WEBHOOK_PORT=3000 diff --git a/devops/docker/compose/compose.yml b/devops/docker/compose/compose.yml index 3a281e9a3..a51d13925 100644 --- a/devops/docker/compose/compose.yml +++ b/devops/docker/compose/compose.yml @@ -42,6 +42,31 @@ services: start_period: 10s retries: 3 + trogon-source-linear: + build: + context: ../../../rsworkspace + dockerfile: ../devops/docker/compose/services/trogon-source-linear/Dockerfile + environment: + NATS_URL: "nats:4222" + LINEAR_WEBHOOK_SECRET: "${LINEAR_WEBHOOK_SECRET}" + LINEAR_WEBHOOK_PORT: "${LINEAR_WEBHOOK_PORT:-8080}" + LINEAR_SUBJECT_PREFIX: "${LINEAR_SUBJECT_PREFIX:-linear}" + LINEAR_STREAM_NAME: "${LINEAR_STREAM_NAME:-LINEAR}" + LINEAR_STREAM_MAX_AGE_SECS: "${LINEAR_STREAM_MAX_AGE_SECS:-604800}" + LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS: "${LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS:-60}" + LINEAR_NATS_ACK_TIMEOUT_MS: "${LINEAR_NATS_ACK_TIMEOUT_MS:-10000}" + RUST_LOG: "${RUST_LOG:-info}" + depends_on: + nats: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:${LINEAR_WEBHOOK_PORT:-8080}/health"] + interval: 10s + timeout: 3s + start_period: 10s + retries: 3 + trogon-source-slack: build: context: ../../../rsworkspace @@ -73,6 +98,7 @@ services: environment: NGROK_AUTHTOKEN: "${NGROK_AUTHTOKEN:-}" GITHUB_ADDR: "trogon-source-github:${GITHUB_WEBHOOK_PORT:-8080}" + LINEAR_ADDR: "trogon-source-linear:${LINEAR_WEBHOOK_PORT:-8080}" SLACK_ADDR: "trogon-source-slack:${SLACK_WEBHOOK_PORT:-3000}" entrypoint: - /bin/sh @@ -84,6 +110,9 @@ services: github: addr: $${GITHUB_ADDR} proto: http + linear: + addr: $${LINEAR_ADDR} + proto: http slack: addr: $${SLACK_ADDR} proto: http @@ -92,6 +121,8 @@ services: depends_on: trogon-source-github: condition: service_healthy + trogon-source-linear: + condition: service_healthy trogon-source-slack: condition: service_healthy restart: unless-stopped diff --git a/devops/docker/compose/services/trogon-source-linear/Dockerfile b/devops/docker/compose/services/trogon-source-linear/Dockerfile new file mode 100644 index 000000000..e1b52e52a --- /dev/null +++ b/devops/docker/compose/services/trogon-source-linear/Dockerfile @@ -0,0 +1,45 @@ +# ── Stage 1: chef — generate dependency recipe ────────────────────────────── +FROM rust:1.93-slim AS chef + +RUN cargo install cargo-chef --locked + +WORKDIR /build + +# ── Stage 2: planner — capture dependency graph ───────────────────────────── +FROM chef AS planner + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo chef prepare --recipe-path recipe.json + +# ── Stage 3: builder — cached dependency build + final compile ────────────── +FROM chef AS builder + +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json -p trogon-source-linear + +COPY Cargo.toml Cargo.lock ./ +COPY crates/ crates/ + +RUN cargo build --release -p trogon-source-linear && \ + strip target/release/trogon-source-linear + +# ── Stage 4: runtime ──────────────────────────────────────────────────────── +FROM debian:bookworm-20250317-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --no-create-home --shell /usr/sbin/nologin trogon + +COPY --from=builder /build/target/release/trogon-source-linear /usr/local/bin/trogon-source-linear + +USER trogon + +EXPOSE 8080 + +STOPSIGNAL SIGTERM + +ENTRYPOINT ["/usr/local/bin/trogon-source-linear"] diff --git a/devops/docker/compose/services/trogon-source-linear/README.md b/devops/docker/compose/services/trogon-source-linear/README.md new file mode 100644 index 000000000..83688877c --- /dev/null +++ b/devops/docker/compose/services/trogon-source-linear/README.md @@ -0,0 +1,72 @@ +# Receiving Linear Webhooks Locally + +## Prerequisites + +- Docker Compose +- A [Linear](https://linear.app) workspace with admin access +- An [ngrok](https://ngrok.com) account (free tier works) + +## 1. Get your ngrok tunnel URL + +Start only ngrok and NATS to obtain a public URL before configuring Linear: + +```bash +docker compose --profile dev up ngrok nats +``` + +Find the tunnel URL in the ngrok container logs: + +```bash +docker compose logs ngrok +``` + +Look for the `linear` tunnel URL (e.g. `https://abc123.ngrok-free.app`). + +## 2. Configure your Linear webhook + +1. Go to **Settings → API → Webhooks** +2. Click **New webhook** +3. Set: + - **URL**: `https:///webhook` +4. Select the resource types you want to receive events for +5. Save +6. Copy the **signing secret** that Linear generates + +## 3. Start the webhook receiver + +Set `LINEAR_WEBHOOK_SECRET` in your `.env` to the signing secret from step 2, +then bring up the full stack: + +```bash +docker compose --profile dev up +``` + +## 4. Verify + +Trigger an event in Linear (e.g. create an issue). You should see: + +- The webhook receiver log the incoming event +- The event published to NATS on `linear.{type}.{action}` + +You can inspect NATS with: + +```bash +nats sub -s nats://nats.trogonai.orb.local:4222 "linear.>" +``` + +Without `--profile dev`, ngrok is excluded and only the core services start. + +## Environment variables + +| Variable | Required | Default | Description | +|---|---|---|---| +| `LINEAR_WEBHOOK_SECRET` | yes | — | Signing secret from Linear's webhook settings | +| `NGROK_AUTHTOKEN` | yes (dev profile) | — | ngrok auth token | +| `LINEAR_WEBHOOK_PORT` | no | `8080` | HTTP port for the webhook receiver | +| `LINEAR_SUBJECT_PREFIX` | no | `linear` | NATS subject prefix | +| `LINEAR_STREAM_NAME` | no | `LINEAR` | JetStream stream name | +| `LINEAR_STREAM_MAX_AGE_SECS` | no | `604800` | Max message age in seconds (7 days) | +| `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS` | no | `60` | Replay-attack window in seconds (0 to disable) | +| `LINEAR_NATS_ACK_TIMEOUT_MS` | no | `10000` | JetStream ACK timeout in milliseconds | +| `NATS_URL` | no | `localhost:4222` | NATS server URL(s) | +| `RUST_LOG` | no | `info` | Log level | diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index ab112c00b..0da57058f 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -126,7 +126,7 @@ checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2" dependencies = [ "anyhow", "derive_more", - "schemars", + "schemars 1.2.1", "serde", "serde_json", "strum", @@ -141,6 +141,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -228,7 +237,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-util", "memchr", @@ -238,9 +247,9 @@ dependencies = [ "rand 0.8.5", "regex", "ring", - "rustls-native-certs", + "rustls-native-certs 0.8.3", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "serde", "serde_json", "serde_nanos", @@ -248,7 +257,7 @@ dependencies = [ "thiserror 1.0.69", "time", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-stream", "tokio-util", "tokio-websockets", @@ -287,7 +296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -335,6 +344,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -362,6 +377,56 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bollard" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aed08d3adb6ebe0eff737115056652670ae290f177759aac19c30456135f94c" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls 0.26.0", + "hyper-util", + "hyperlocal-next", + "log", + "pin-project-lite", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.44.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709d9aa1c37abb89d40f19f5d0ad6f0d88cb1581264e571c9350fc5bb89cf1c5" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -385,9 +450,9 @@ checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[package]] name = "cc" -version = "1.2.58" +version = "1.2.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", "shlex", @@ -405,6 +470,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -487,6 +564,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -512,6 +599,30 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -554,6 +665,40 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -615,6 +760,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -626,6 +792,17 @@ dependencies = [ "syn", ] +[[package]] +name = "docker_credential" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d89dfcba45b4afad7450a99b39e751590463e45c04728cf555d36bb66940de8" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -660,6 +837,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -709,6 +907,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -862,6 +1066,31 @@ dependencies = [ "wasip3", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -889,6 +1118,52 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "hmac" version = "0.12.1" @@ -898,6 +1173,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -953,6 +1237,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -964,6 +1249,40 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" +dependencies = [ + "futures-util", + "http", + "hyper", + "hyper-util", + "log", + "rustls 0.22.4", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tower-service", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -973,12 +1292,13 @@ dependencies = [ "http", "hyper", "hyper-util", - "rustls", - "rustls-native-certs", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -987,7 +1307,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1004,6 +1324,45 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal-next" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf569d43fa9848e510358c07b80f4adf34084ddc28c6a4a651ee8474c070dcc" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1092,6 +1451,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1113,6 +1478,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.1" @@ -1125,6 +1501,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1192,6 +1581,15 @@ version = "0.2.184" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + [[package]] name = "litemap" version = "0.8.2" @@ -1257,6 +1655,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "nkeys" version = "0.4.5" @@ -1301,6 +1716,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -1308,6 +1727,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1377,7 +1802,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry", "opentelemetry_sdk", @@ -1405,6 +1830,12 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking" version = "2.2.1" @@ -1434,6 +1865,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1584,7 +2040,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.37", "socket2", "thiserror 2.0.18", "tokio", @@ -1604,7 +2060,7 @@ dependencies = [ "rand 0.9.2", "ring", "rustc-hash", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1722,7 +2178,18 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", ] [[package]] @@ -1780,31 +2247,36 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", + "hickory-resolver", "http", "http-body", "http-body-util", "hyper", - "hyper-rustls", + "hyper-rustls 0.27.7", "hyper-util", "js-sys", "log", + "mime", + "once_cell", "percent-encoding", "pin-project-lite", "quinn", - "rustls", - "rustls-native-certs", + "rustls 0.23.37", + "rustls-native-certs 0.8.3", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -1812,8 +2284,15 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.6", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -1843,6 +2322,20 @@ dependencies = [ "semver", ] +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1852,21 +2345,43 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", ] [[package]] @@ -1879,6 +2394,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.10" @@ -1911,6 +2437,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -1942,6 +2480,19 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1949,7 +2500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1967,9 +2518,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -2068,6 +2619,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.1", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2181,6 +2763,29 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "strum" version = "0.28.0" @@ -2239,6 +2844,50 @@ dependencies = [ "syn", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "testcontainers" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "725cbe485aafddfd8b2d01665937c95498d894c07fabd9c4e06a53c7da4ccc56" +dependencies = [ + "async-trait", + "bollard", + "bollard-stubs", + "bytes", + "dirs", + "docker_credential", + "either", + "futures", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "reqwest", + "serde", + "serde_json", + "serde_with", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a433ba83c79b59254a8a712c2c435750272574ddbc57091b69724d2696dc57d" +dependencies = [ + "testcontainers", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2372,13 +3021,24 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.37", "tokio", ] @@ -2436,7 +3096,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-sink", @@ -2446,7 +3106,7 @@ dependencies = [ "ring", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tokio-util", "webpki-roots 0.26.11", ] @@ -2458,7 +3118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "http", "http-body", @@ -2661,6 +3321,27 @@ dependencies = [ "trogon-std", ] +[[package]] +name = "trogon-source-linear" +version = "0.1.0" +dependencies = [ + "acp-telemetry", + "async-nats", + "axum", + "bytes", + "futures-util", + "hex", + "hmac", + "reqwest", + "serde_json", + "sha2", + "testcontainers-modules", + "tokio", + "tracing", + "trogon-nats", + "trogon-std", +] + [[package]] name = "trogon-source-slack" version = "0.1.0" @@ -2788,6 +3469,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2936,7 +3618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.13.1", "wasm-encoder", "wasmparser", ] @@ -2949,7 +3631,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.13.1", "semver", ] @@ -2991,12 +3673,113 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3024,6 +3807,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3057,6 +3855,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3069,6 +3873,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3081,6 +3891,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3105,6 +3921,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3117,6 +3939,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3129,6 +3957,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3141,6 +3975,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3181,7 +4021,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.13.1", "prettyplease", "syn", "wasm-metadata", @@ -3212,7 +4052,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.13.1", "log", "serde", "serde_derive", @@ -3231,7 +4071,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.13.1", "log", "semver", "serde", diff --git a/rsworkspace/crates/acp-telemetry/src/service_name.rs b/rsworkspace/crates/acp-telemetry/src/service_name.rs index a33e2ef23..a7df0dd15 100644 --- a/rsworkspace/crates/acp-telemetry/src/service_name.rs +++ b/rsworkspace/crates/acp-telemetry/src/service_name.rs @@ -6,6 +6,7 @@ pub enum ServiceName { AcpNatsStdio, AcpNatsWs, TrogonSourceGithub, + TrogonSourceLinear, TrogonSourceSlack, } @@ -15,6 +16,7 @@ impl ServiceName { Self::AcpNatsStdio => "acp-nats-stdio", Self::AcpNatsWs => "acp-nats-ws", Self::TrogonSourceGithub => "trogon-source-github", + Self::TrogonSourceLinear => "trogon-source-linear", Self::TrogonSourceSlack => "trogon-source-slack", } } @@ -38,6 +40,10 @@ mod tests { ServiceName::TrogonSourceGithub.as_str(), "trogon-source-github" ); + assert_eq!( + ServiceName::TrogonSourceLinear.as_str(), + "trogon-source-linear" + ); assert_eq!( ServiceName::TrogonSourceSlack.as_str(), "trogon-source-slack" @@ -52,6 +58,10 @@ mod tests { format!("{}", ServiceName::TrogonSourceGithub), "trogon-source-github" ); + assert_eq!( + format!("{}", ServiceName::TrogonSourceLinear), + "trogon-source-linear" + ); assert_eq!( format!("{}", ServiceName::TrogonSourceSlack), "trogon-source-slack" diff --git a/rsworkspace/crates/trogon-source-linear/Cargo.toml b/rsworkspace/crates/trogon-source-linear/Cargo.toml new file mode 100644 index 000000000..18daf6343 --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "trogon-source-linear" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[[bin]] +name = "trogon-source-linear" +path = "src/main.rs" + +[dependencies] +acp-telemetry = { workspace = true } +async-nats = { workspace = true, features = ["ring", "jetstream"] } +axum = { workspace = true } +bytes = { workspace = true } +hex = "0.4" +hmac = "0.12" +sha2 = "0.10" +serde_json = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +trogon-nats = { workspace = true } +trogon-std = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +trogon-std = { workspace = true, features = ["test-support"] } +testcontainers-modules = { version = "0.8", features = ["nats"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +futures-util = { workspace = true } +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" diff --git a/rsworkspace/crates/trogon-source-linear/src/config.rs b/rsworkspace/crates/trogon-source-linear/src/config.rs new file mode 100644 index 000000000..541cd4662 --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/src/config.rs @@ -0,0 +1,362 @@ +use std::time::Duration; + +use trogon_nats::{NatsConfig, NatsToken}; +use trogon_std::env::ReadEnv; + +use crate::constants::{ + DEFAULT_NATS_ACK_TIMEOUT_MS, DEFAULT_PORT, DEFAULT_STREAM_MAX_AGE_SECS, DEFAULT_STREAM_NAME, + DEFAULT_SUBJECT_PREFIX, DEFAULT_TIMESTAMP_TOLERANCE_SECS, +}; + +/// Configuration for the Linear webhook server. +/// +/// Resolved from environment variables: +/// - `LINEAR_WEBHOOK_SECRET`: signing secret from Linear's webhook settings (required) +/// - `LINEAR_WEBHOOK_PORT`: HTTP listening port (default: 8080) +/// - `LINEAR_SUBJECT_PREFIX`: NATS subject prefix (default: `linear`) +/// - `LINEAR_STREAM_NAME`: JetStream stream name (default: `LINEAR`) +/// - `LINEAR_STREAM_MAX_AGE_SECS`: max age of messages in the JetStream stream in seconds (default: 604800 / 7 days) +/// - `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS`: replay-attack window in seconds (default: 60, set to 0 to disable) +/// - `LINEAR_NATS_ACK_TIMEOUT_MS`: how long to wait for a JetStream ACK in milliseconds (default: 10000) +/// - Standard `NATS_*` variables for NATS connection (see `trogon-nats`) +pub struct LinearConfig { + pub webhook_secret: String, + pub port: u16, + pub subject_prefix: NatsToken, + pub stream_name: NatsToken, + pub stream_max_age: Duration, + /// How far in the past a `webhookTimestamp` may be before the request is + /// rejected as a potential replay. `None` disables the check entirely + /// (set `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS=0`). + pub timestamp_tolerance: Option, + /// How long to wait for a JetStream ACK before declaring it timed out. + pub nats_ack_timeout: Duration, + pub nats: NatsConfig, +} + +impl LinearConfig { + pub fn from_env(env: &E) -> Self { + let tolerance_secs: u64 = env + .var("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_TIMESTAMP_TOLERANCE_SECS); + + Self { + webhook_secret: env + .var("LINEAR_WEBHOOK_SECRET") + .ok() + .filter(|s| !s.is_empty()) + .expect("LINEAR_WEBHOOK_SECRET is required"), + port: env + .var("LINEAR_WEBHOOK_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(DEFAULT_PORT), + subject_prefix: NatsToken::new( + env.var("LINEAR_SUBJECT_PREFIX") + .unwrap_or_else(|_| DEFAULT_SUBJECT_PREFIX.to_string()), + ) + .expect("LINEAR_SUBJECT_PREFIX is not a valid NATS token"), + stream_name: NatsToken::new( + env.var("LINEAR_STREAM_NAME") + .unwrap_or_else(|_| DEFAULT_STREAM_NAME.to_string()), + ) + .expect("LINEAR_STREAM_NAME is not a valid NATS token"), + stream_max_age: Duration::from_secs( + env.var("LINEAR_STREAM_MAX_AGE_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_STREAM_MAX_AGE_SECS), + ), + timestamp_tolerance: (tolerance_secs > 0).then(|| Duration::from_secs(tolerance_secs)), + nats_ack_timeout: Duration::from_millis( + env.var("LINEAR_NATS_ACK_TIMEOUT_MS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_NATS_ACK_TIMEOUT_MS), + ), + nats: NatsConfig::from_env(env), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use trogon_std::env::InMemoryEnv; + + fn env_with_secret() -> InMemoryEnv { + let env = InMemoryEnv::new(); + env.set("LINEAR_WEBHOOK_SECRET", "test-secret"); + env + } + + #[test] + #[should_panic(expected = "LINEAR_WEBHOOK_SECRET is required")] + fn panics_when_webhook_secret_missing() { + let env = InMemoryEnv::new(); + let _config = LinearConfig::from_env(&env); + } + + #[test] + #[should_panic(expected = "LINEAR_WEBHOOK_SECRET is required")] + fn panics_when_webhook_secret_empty() { + let env = InMemoryEnv::new(); + env.set("LINEAR_WEBHOOK_SECRET", ""); + let _config = LinearConfig::from_env(&env); + } + + #[test] + fn defaults_when_only_secret_set() { + let env = env_with_secret(); + let config = LinearConfig::from_env(&env); + + assert_eq!(config.webhook_secret, "test-secret"); + assert_eq!(config.port, 8080); + assert_eq!(config.subject_prefix.as_str(), "linear"); + assert_eq!(config.stream_name.as_str(), "LINEAR"); + assert_eq!(config.stream_max_age, Duration::from_secs(7 * 24 * 60 * 60)); + assert_eq!(config.timestamp_tolerance, Some(Duration::from_secs(60))); + assert_eq!( + config.nats_ack_timeout, + Duration::from_millis(DEFAULT_NATS_ACK_TIMEOUT_MS) + ); + } + + #[test] + fn reads_all_env_vars() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_SECRET", "my-secret"); + env.set("LINEAR_WEBHOOK_PORT", "9090"); + env.set("LINEAR_SUBJECT_PREFIX", "lin"); + env.set("LINEAR_STREAM_NAME", "LIN_EVENTS"); + env.set("LINEAR_STREAM_MAX_AGE_SECS", "3600"); + env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "120"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.webhook_secret, "my-secret"); + assert_eq!(config.port, 9090); + assert_eq!(config.subject_prefix.as_str(), "lin"); + assert_eq!(config.stream_name.as_str(), "LIN_EVENTS"); + assert_eq!(config.stream_max_age, Duration::from_secs(3600)); + assert_eq!(config.timestamp_tolerance, Some(Duration::from_secs(120))); + } + + #[test] + #[should_panic(expected = "LINEAR_SUBJECT_PREFIX is not a valid NATS token")] + fn empty_subject_prefix_panics() { + let env = env_with_secret(); + env.set("LINEAR_SUBJECT_PREFIX", ""); + let _config = LinearConfig::from_env(&env); + } + + #[test] + #[should_panic(expected = "LINEAR_STREAM_NAME is not a valid NATS token")] + fn empty_stream_name_panics() { + let env = env_with_secret(); + env.set("LINEAR_STREAM_NAME", ""); + let _config = LinearConfig::from_env(&env); + } + + #[test] + #[should_panic(expected = "LINEAR_SUBJECT_PREFIX is not a valid NATS token")] + fn subject_prefix_with_dots_panics() { + let env = env_with_secret(); + env.set("LINEAR_SUBJECT_PREFIX", "linear.events"); + let _config = LinearConfig::from_env(&env); + } + + #[test] + fn timestamp_tolerance_zero_disables_check() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "0"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.timestamp_tolerance, None); + } + + #[test] + fn invalid_port_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_PORT", "not-a-number"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.port, 8080); + } + + #[test] + fn invalid_max_age_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_STREAM_MAX_AGE_SECS", "not-a-number"); + + let config = LinearConfig::from_env(&env); + + assert_eq!( + config.stream_max_age, + Duration::from_secs(DEFAULT_STREAM_MAX_AGE_SECS) + ); + } + + #[test] + fn invalid_tolerance_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "not-a-number"); + + let config = LinearConfig::from_env(&env); + + assert_eq!( + config.timestamp_tolerance, + Some(Duration::from_secs(DEFAULT_TIMESTAMP_TOLERANCE_SECS)) + ); + } + + #[test] + fn port_overflow_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_PORT", "65536"); // u16::MAX is 65535 + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.port, DEFAULT_PORT); + } + + #[test] + fn stream_max_age_zero_produces_zero_duration() { + let env = env_with_secret(); + env.set("LINEAR_STREAM_MAX_AGE_SECS", "0"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.stream_max_age, Duration::from_secs(0)); + } + + #[test] + fn negative_port_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_PORT", "-1"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.port, DEFAULT_PORT); + } + + #[test] + fn float_port_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_PORT", "8080.5"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.port, DEFAULT_PORT); + } + + #[test] + fn port_with_trailing_chars_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_PORT", "8080abc"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.port, DEFAULT_PORT); + } + + #[test] + fn negative_max_age_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_STREAM_MAX_AGE_SECS", "-1"); + + let config = LinearConfig::from_env(&env); + + assert_eq!( + config.stream_max_age, + Duration::from_secs(DEFAULT_STREAM_MAX_AGE_SECS) + ); + } + + #[test] + fn float_max_age_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_STREAM_MAX_AGE_SECS", "3600.5"); + + let config = LinearConfig::from_env(&env); + + assert_eq!( + config.stream_max_age, + Duration::from_secs(DEFAULT_STREAM_MAX_AGE_SECS) + ); + } + + #[test] + fn tolerance_secs_one_is_minimum_non_zero() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "1"); + + let config = LinearConfig::from_env(&env); + + assert_eq!(config.timestamp_tolerance, Some(Duration::from_secs(1))); + } + + #[test] + fn negative_tolerance_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "-1"); + + let config = LinearConfig::from_env(&env); + + assert_eq!( + config.timestamp_tolerance, + Some(Duration::from_secs(DEFAULT_TIMESTAMP_TOLERANCE_SECS)) + ); + } + + #[test] + fn float_tolerance_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS", "60.5"); + + let config = LinearConfig::from_env(&env); + + assert_eq!( + config.timestamp_tolerance, + Some(Duration::from_secs(DEFAULT_TIMESTAMP_TOLERANCE_SECS)) + ); + } + + #[test] + fn defaults_nats_ack_timeout_to_10_seconds() { + let env = env_with_secret(); + let config = LinearConfig::from_env(&env); + assert_eq!(config.nats_ack_timeout, Duration::from_secs(10)); + } + + #[test] + fn reads_nats_ack_timeout_from_env() { + let env = env_with_secret(); + env.set("LINEAR_NATS_ACK_TIMEOUT_MS", "500"); + let config = LinearConfig::from_env(&env); + assert_eq!(config.nats_ack_timeout, Duration::from_millis(500)); + } + + #[test] + fn invalid_nats_ack_timeout_falls_back_to_default() { + let env = env_with_secret(); + env.set("LINEAR_NATS_ACK_TIMEOUT_MS", "not-a-number"); + let config = LinearConfig::from_env(&env); + assert_eq!( + config.nats_ack_timeout, + Duration::from_millis(DEFAULT_NATS_ACK_TIMEOUT_MS) + ); + } + + #[test] + fn zero_nats_ack_timeout_is_valid() { + let env = env_with_secret(); + env.set("LINEAR_NATS_ACK_TIMEOUT_MS", "0"); + let config = LinearConfig::from_env(&env); + assert_eq!(config.nats_ack_timeout, Duration::ZERO); + } +} diff --git a/rsworkspace/crates/trogon-source-linear/src/constants.rs b/rsworkspace/crates/trogon-source-linear/src/constants.rs new file mode 100644 index 000000000..6ebea1a33 --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/src/constants.rs @@ -0,0 +1,11 @@ +use std::time::Duration; + +pub const DEFAULT_PORT: u16 = 8080; +pub const DEFAULT_SUBJECT_PREFIX: &str = "linear"; +pub const DEFAULT_STREAM_NAME: &str = "LINEAR"; +pub const DEFAULT_STREAM_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60; // 7 days +/// Default replay-attack tolerance: 60 seconds (as recommended by Linear). +pub const DEFAULT_TIMESTAMP_TOLERANCE_SECS: u64 = 60; +/// Default JetStream ACK timeout: 10 seconds. +pub const DEFAULT_NATS_ACK_TIMEOUT_MS: u64 = 10_000; +pub const DEFAULT_NATS_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); diff --git a/rsworkspace/crates/trogon-source-linear/src/lib.rs b/rsworkspace/crates/trogon-source-linear/src/lib.rs new file mode 100644 index 000000000..4679e5ece --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/src/lib.rs @@ -0,0 +1,40 @@ +//! # trogon-source-linear +//! +//! Linear webhook receiver that publishes events to NATS JetStream. +//! +//! ## How it works +//! +//! 1. Linear sends `POST /webhook` with a `linear-signature` header plus a JSON payload. +//! 2. The server validates the HMAC-SHA256 signature against `LINEAR_WEBHOOK_SECRET`. +//! 3. Events are published to NATS JetStream on `{prefix}.{type}.{action}` subjects +//! (e.g. `linear.Issue.create`, `linear.Comment.update`). +//! 4. The JetStream stream (`LINEAR` by default, capturing `linear.>`) is created +//! automatically on startup if it doesn't exist. +//! +//! ## NATS message format +//! +//! - **Subject**: `{LINEAR_SUBJECT_PREFIX}.{type}.{action}` (e.g. `linear.Issue.create`) +//! - **Headers**: `Nats-Msg-Id` (set to Linear's `webhookId` for dedup) +//! - **Payload**: raw JSON body from Linear +//! +//! ## Configuration (env vars) +//! +//! | Variable | Default | Description | +//! |---|---|---| +//! | `LINEAR_WEBHOOK_SECRET` | — | Signing secret from Linear's webhook settings (required) | +//! | `LINEAR_WEBHOOK_PORT` | `8080` | HTTP listening port | +//! | `LINEAR_SUBJECT_PREFIX` | `linear` | NATS subject prefix | +//! | `LINEAR_STREAM_NAME` | `LINEAR` | JetStream stream name | +//! | `LINEAR_STREAM_MAX_AGE_SECS` | `604800` | Max age of messages in JetStream (seconds, default 7 days) | +//! | `LINEAR_WEBHOOK_TIMESTAMP_TOLERANCE_SECS` | `60` | Replay-attack window in seconds (set to 0 to disable) | +//! | `LINEAR_NATS_ACK_TIMEOUT_MS` | `10000` | How long to wait for a JetStream ACK (milliseconds) | +//! | `NATS_URL` | `localhost:4222` | NATS server URL(s) | + +pub mod config; +pub mod constants; +pub mod server; +pub mod signature; + +pub use config::LinearConfig; +#[cfg(not(coverage))] +pub use server::serve; diff --git a/rsworkspace/crates/trogon-source-linear/src/main.rs b/rsworkspace/crates/trogon-source-linear/src/main.rs new file mode 100644 index 000000000..8a222ff1b --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/src/main.rs @@ -0,0 +1,47 @@ +#[cfg(not(coverage))] +use { + acp_telemetry::ServiceName, tracing::error, tracing::info, trogon_nats::connect, + trogon_nats::jetstream::NatsJetStreamClient, trogon_source_linear::LinearConfig, + trogon_source_linear::constants::DEFAULT_NATS_CONNECT_TIMEOUT, trogon_std::env::SystemEnv, + trogon_std::fs::SystemFs, +}; + +#[cfg(not(coverage))] +#[tokio::main] +async fn main() -> Result<(), Box> { + let config = LinearConfig::from_env(&SystemEnv); + + acp_telemetry::init_logger( + ServiceName::TrogonSourceLinear, + &config.subject_prefix, + &SystemEnv, + &SystemFs, + ); + + info!("Linear webhook server starting"); + + let nats = connect(&config.nats, DEFAULT_NATS_CONNECT_TIMEOUT).await?; + let js = NatsJetStreamClient::new(async_nats::jetstream::new(nats)); + let result = trogon_source_linear::serve(js, config).await; + + if let Err(ref e) = result { + error!(error = %e, "Linear webhook server stopped with error"); + } else { + info!("Linear webhook server stopped"); + } + + acp_telemetry::shutdown_otel(); + + result.map_err(|e| Box::new(e) as Box) +} + +#[cfg(coverage)] +fn main() {} + +#[cfg(all(coverage, test))] +mod tests { + #[test] + fn coverage_stub() { + super::main(); + } +} diff --git a/rsworkspace/crates/trogon-source-linear/src/server.rs b/rsworkspace/crates/trogon-source-linear/src/server.rs new file mode 100644 index 000000000..01498e4bf --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/src/server.rs @@ -0,0 +1,245 @@ +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::time::Duration; + +use crate::config::LinearConfig; +use crate::signature; +#[cfg(not(coverage))] +use async_nats::jetstream::context::CreateStreamError; +use axum::{ + Router, body::Bytes, extract::State, http::HeaderMap, http::StatusCode, routing::get, + routing::post, +}; +use tracing::{info, instrument, warn}; +use trogon_nats::NatsToken; +#[cfg(not(coverage))] +use trogon_nats::jetstream::JetStreamContext; +use trogon_nats::jetstream::{JetStreamPublisher, PublishOutcome, publish_event}; + +#[cfg(not(coverage))] +#[derive(Debug)] +#[non_exhaustive] +pub enum ServeError { + Provision(CreateStreamError), + Io(std::io::Error), +} + +#[cfg(not(coverage))] +impl fmt::Display for ServeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Provision(e) => write!(f, "stream provisioning failed: {e}"), + Self::Io(e) => write!(f, "IO error: {e}"), + } + } +} + +#[cfg(not(coverage))] +impl std::error::Error for ServeError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Provision(e) => Some(e), + Self::Io(e) => Some(e), + } + } +} + +#[cfg(not(coverage))] +impl From for ServeError { + fn from(e: std::io::Error) -> Self { + ServeError::Io(e) + } +} + +fn outcome_to_status(outcome: PublishOutcome) -> StatusCode { + if outcome.is_ok() { + info!("Published Linear event to NATS"); + StatusCode::OK + } else { + outcome.log_on_error("linear"); + StatusCode::INTERNAL_SERVER_ERROR + } +} + +#[derive(Clone)] +struct AppState { + js: P, + webhook_secret: String, + subject_prefix: NatsToken, + timestamp_tolerance: Option, + nats_ack_timeout: Duration, +} + +#[cfg(not(coverage))] +pub async fn provision(js: &C, config: &LinearConfig) -> Result<(), C::Error> { + js.get_or_create_stream(async_nats::jetstream::stream::Config { + name: config.stream_name.to_string(), + subjects: vec![format!("{}.>", config.subject_prefix)], + max_age: config.stream_max_age, + ..Default::default() + }) + .await?; + + let max_age_secs = config.stream_max_age.as_secs(); + info!( + stream = config.stream_name.as_str(), + max_age_secs, "JetStream stream ready" + ); + Ok(()) +} + +pub fn router(js: P, config: &LinearConfig) -> Router { + let state = AppState { + js, + webhook_secret: config.webhook_secret.clone(), + subject_prefix: config.subject_prefix.clone(), + timestamp_tolerance: config.timestamp_tolerance, + nats_ack_timeout: config.nats_ack_timeout, + }; + + Router::new() + .route("/webhook", post(handle_webhook::

)) + .route("/health", get(handle_health)) + .with_state(state) +} + +#[cfg(not(coverage))] +pub async fn serve(js: J, config: LinearConfig) -> Result<(), ServeError> +where + J: JetStreamContext + JetStreamPublisher, +{ + provision(&js, &config) + .await + .map_err(ServeError::Provision)?; + + let app = router(js, &config); + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], config.port)); + info!(addr = %addr, "Linear webhook server listening"); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app) + .with_graceful_shutdown(acp_telemetry::signal::shutdown_signal()) + .await?; + + info!("Linear webhook server shut down"); + Ok(()) +} + +async fn handle_health() -> StatusCode { + StatusCode::OK +} + +fn handle_webhook( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Pin + Send>> { + Box::pin(handle_webhook_inner(state, headers, body)) +} + +#[instrument( + name = "linear.webhook", + skip_all, + fields( + event_type = tracing::field::Empty, + action = tracing::field::Empty, + webhook_id = tracing::field::Empty, + subject = tracing::field::Empty, + ) +)] +async fn handle_webhook_inner( + state: AppState

, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let sig = headers + .get("linear-signature") + .and_then(|v| v.to_str().ok()); + + match sig { + Some(sig) if signature::verify(&state.webhook_secret, &body, sig) => {} + Some(_) => { + warn!("Invalid Linear webhook signature"); + return StatusCode::UNAUTHORIZED; + } + None => { + warn!("Missing linear-signature header"); + return StatusCode::UNAUTHORIZED; + } + } + + let parsed: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(e) => { + warn!(error = %e, "Failed to parse Linear webhook body as JSON"); + return StatusCode::BAD_REQUEST; + } + }; + + if let Some(tolerance) = state.timestamp_tolerance { + let Some(ts_ms) = parsed.get("webhookTimestamp").and_then(|v| v.as_u64()) else { + warn!("Missing or malformed 'webhookTimestamp' field"); + return StatusCode::BAD_REQUEST; + }; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let age_ms = now_ms.saturating_sub(ts_ms); + if age_ms > tolerance.as_millis() as u64 { + warn!( + age_ms, + tolerance_ms = tolerance.as_millis() as u64, + "Stale webhookTimestamp — potential replay attack" + ); + return StatusCode::BAD_REQUEST; + } + } + + let Some(raw_type) = parsed.get("type").and_then(|v| v.as_str()) else { + warn!("Missing 'type' field in Linear webhook payload"); + return StatusCode::BAD_REQUEST; + }; + let Ok(event_type) = NatsToken::new(raw_type) else { + warn!("Invalid 'type' field in Linear webhook payload"); + return StatusCode::BAD_REQUEST; + }; + + let Some(raw_action) = parsed.get("action").and_then(|v| v.as_str()) else { + warn!("Missing 'action' field in Linear webhook payload"); + return StatusCode::BAD_REQUEST; + }; + let Ok(action) = NatsToken::new(raw_action) else { + warn!("Invalid 'action' field in Linear webhook payload"); + return StatusCode::BAD_REQUEST; + }; + + let webhook_id = parsed + .get("webhookId") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_owned(); + + let subject = format!("{}.{}.{}", state.subject_prefix, event_type, action); + + let span = tracing::Span::current(); + span.record("event_type", event_type.as_str()); + span.record("action", action.as_str()); + span.record("webhook_id", &webhook_id); + span.record("subject", &subject); + + let mut nats_headers = async_nats::HeaderMap::new(); + nats_headers.insert("Nats-Msg-Id", webhook_id.as_str()); + + let outcome = publish_event( + &state.js, + subject, + nats_headers, + body, + state.nats_ack_timeout, + ) + .await; + + outcome_to_status(outcome) +} diff --git a/rsworkspace/crates/trogon-source-linear/src/signature.rs b/rsworkspace/crates/trogon-source-linear/src/signature.rs new file mode 100644 index 000000000..0da0600ca --- /dev/null +++ b/rsworkspace/crates/trogon-source-linear/src/signature.rs @@ -0,0 +1,100 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// Verifies a Linear webhook signature using constant-time comparison. +/// +/// Linear sends `linear-signature: ` — a raw lowercase hex-encoded +/// HMAC-SHA256 of the request body, with no prefix. +pub fn verify(secret: &str, body: &[u8], signature_header: &str) -> bool { + let Ok(expected) = hex::decode(signature_header) else { + return false; + }; + + let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else { + return false; + }; + + mac.update(body); + mac.verify_slice(&expected).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn compute_sig(secret: &str, body: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + hex::encode(mac.finalize().into_bytes()) + } + + #[test] + fn valid_signature_passes() { + let sig = compute_sig("test-secret", b"hello world"); + assert!(verify("test-secret", b"hello world", &sig)); + } + + #[test] + fn wrong_secret_fails() { + let sig = compute_sig("correct-secret", b"body"); + assert!(!verify("wrong-secret", b"body", &sig)); + } + + #[test] + fn tampered_body_fails() { + let sig = compute_sig("secret", b"original body"); + assert!(!verify("secret", b"tampered body", &sig)); + } + + #[test] + fn invalid_hex_fails() { + assert!(!verify("secret", b"body", "not-valid-hex!")); + } + + #[test] + fn empty_body_with_valid_sig_passes() { + let sig = compute_sig("secret", b""); + assert!(verify("secret", b"", &sig)); + } + + #[test] + fn uppercase_hex_signature_passes() { + let sig = compute_sig("secret", b"body"); + assert!(verify("secret", b"body", &sig.to_uppercase())); + } + + #[test] + fn truncated_signature_fails() { + let sig = compute_sig("secret", b"body"); + // Remove last two hex chars (one byte) — wrong length, verify_slice must reject + let truncated = &sig[..sig.len() - 2]; + assert!(!verify("secret", b"body", truncated)); + } + + /// An odd-length hex string consists only of valid hex characters but + /// cannot be decoded (each byte needs exactly two nibbles). + /// `hex::decode` returns an error → `verify` must return false. + #[test] + fn odd_length_hex_fails() { + assert!(!verify("secret", b"body", "abc")); // 3 valid hex chars + assert!(!verify("secret", b"body", "abcde")); // 5 valid hex chars + assert!(!verify("secret", b"body", "a")); // 1 valid hex char + } + + /// HMAC-SHA256 is well-defined for an empty key. `verify` must succeed + /// when both sides use the same (empty) secret. + #[test] + fn empty_secret_computes_and_verifies() { + let sig = compute_sig("", b"hello"); + assert!(verify("", b"hello", &sig)); + } + + /// A wrong body with an empty secret must still fail. + #[test] + fn empty_secret_wrong_body_fails() { + let sig = compute_sig("", b"original"); + assert!(!verify("", b"tampered", &sig)); + } +}