From ef77f6d2d6ebe11d2c1afd93314d44840f6c7d77 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 18 Mar 2026 18:39:14 +0900 Subject: init --- .dockerignore | 8 + .gitignore | 1 + .rules/changelog/2026-03/18/01.md | 16 ++ .rules/plan/webhook-forwarder.md | 160 +++++++++++++++++++ Cargo.lock | 323 ++++++++++++++++++++++++++++++++++++++ Cargo.toml | 11 ++ Dockerfile | 19 +++ bin/setup.sh | 18 +++ bin/test.sh | 35 +++++ src/main.rs | 147 +++++++++++++++++ 10 files changed, 738 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 .rules/changelog/2026-03/18/01.md create mode 100644 .rules/plan/webhook-forwarder.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100755 bin/setup.sh create mode 100755 bin/test.sh create mode 100644 src/main.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..be633cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.rules/ +.ask_user.md +bin/ +*.md +README* +LICENSE* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/.rules/changelog/2026-03/18/01.md b/.rules/changelog/2026-03/18/01.md new file mode 100644 index 0000000..5f700ca --- /dev/null +++ b/.rules/changelog/2026-03/18/01.md @@ -0,0 +1,16 @@ +### Webhook Forwarder — Initial Implementation + +- Created Rust project with `hyper`, `tokio`, `http-body-util`, `hyper-util`, `bytes` dependencies +- Implemented `src/main.rs`: minimal HTTP server that forwards webhook POST requests + - `POST /` → `{DOKPLOY_BASE_URL}/api/deploy/` + - `POST /compose/` → `{DOKPLOY_BASE_URL}/api/deploy/compose/` + - Forwards all headers (except Host) and body to upstream + - Returns upstream response (status + body) to caller + - Rejects non-POST with 405, invalid paths with 404 + - Logs all requests to stderr + - Configurable via `DOKPLOY_BASE_URL` (default: `http://100.102.55.49:3000`) and `PORT` (default: `8080`) +- Created `Dockerfile`: multi-stage build (`rust:1-alpine3.21` → `scratch`) for minimal image (~3-5 MB) +- Created `.dockerignore` to minimize Docker build context +- Created `bin/setup.sh` for project scaffolding +- Created `bin/test.sh` for local testing +- Created `.rules/plan/webhook-forwarder.md` with full implementation plan diff --git a/.rules/plan/webhook-forwarder.md b/.rules/plan/webhook-forwarder.md new file mode 100644 index 0000000..75b6cc4 --- /dev/null +++ b/.rules/plan/webhook-forwarder.md @@ -0,0 +1,160 @@ +### Webhook Forwarder — Implementation Plan + +--- + +### Overview + +A minimal Rust HTTP server that accepts GitHub webhook POST requests on a public domain (`webhook.catgirls.rodeo`) and forwards them to Dokploy's internal deploy API. This avoids exposing Dokploy's panel to the public internet. + +--- + +### URL Routing + +| Incoming Request | Forwarded To | +|---|---| +| `POST webhook.catgirls.rodeo/` | `POST {DOKPLOY_BASE_URL}/api/deploy/` | +| `POST webhook.catgirls.rodeo/compose/` | `POST {DOKPLOY_BASE_URL}/api/deploy/compose/` | + +All other methods/paths return `405 Method Not Allowed` or `404 Not Found`. + +--- + +### Configuration + +| Variable | Default | Description | +|---|---|---| +| `DOKPLOY_BASE_URL` | `http://100.102.55.49:3000` | Internal Dokploy panel URL | +| `PORT` | `8080` | Port the forwarder listens on | + +No secrets are stored in this app. The deploy tokens live in GitHub's webhook config and Dokploy. + +--- + +### Behavior + +1. Accept only `POST` requests. Return `405` for all other methods. +2. Match paths: + - `/` → forward to `/api/deploy/` + - `/compose/` → forward to `/api/deploy/compose/` + - Everything else → `404` +3. Forward the full request body and all headers from the incoming request to Dokploy. +4. Forward Dokploy's response (status code + body) back to the caller. +5. Log each request to stdout: timestamp, method, path, upstream status code. +6. On upstream connection failure, return `502 Bad Gateway`. + +--- + +### Technology Stack + +- **Language**: Rust +- **HTTP server**: `hyper` (minimal async HTTP library, no full framework overhead) +- **HTTP client**: `hyper` + `hyper-util` (reuse the same library for outbound requests) +- **Async runtime**: `tokio` (minimal feature set: `rt`, `net`, `macros`) +- **No other dependencies** unless strictly necessary + +This yields a single static binary of ~2–4 MB with musl libc. + +--- + +### File Structure + +``` +webhook-forwarder/ +├── .dockerignore +├── .gitignore +├── Cargo.toml +├── Cargo.lock +├── Dockerfile +├── src/ +│ └── main.rs +└── .rules/ + └── plan/ + └── webhook-forwarder.md (this file) +``` + +Total: ~5 meaningful files. Minimal git repo. + +--- + +### Dockerfile Strategy + +Multi-stage build for smallest possible image: + +``` +Stage 1: rust:alpine (build with musl for static binary) + - cargo build --release --target x86_64-unknown-linux-musl + +Stage 2: scratch (empty image — just the binary) + - COPY binary from stage 1 + - EXPOSE 8080 + - ENTRYPOINT ["./webhook-forwarder"] +``` + +Final image: ~3–5 MB total (just the static binary, no OS, no shell). + +--- + +### Networking + +**Primary approach (Option A):** Use Docker bridge gateway IP to reach Dokploy. + +The container accesses Dokploy via the Docker host's bridge IP (typically `172.17.0.1` on default bridge, or the gateway of whatever network Dokploy uses). Set `DOKPLOY_BASE_URL=http://172.17.0.1:3000` in Dokploy's environment config for this app. + +However, the default value is the Tailscale IP `http://100.102.55.49:3000` for direct use if networking allows. + +**Fallback (Option B):** If the Docker bridge approach doesn't work (e.g., Dokploy's firewall blocks it), options include: +- `network_mode: host` in compose to give the container access to the host's Tailscale interface +- Running a Tailscale sidecar container +- Adding the container to Dokploy's own Docker network + +We'll try Option A first since it's simplest. + +--- + +### Dokploy Deployment + +1. Push this repo to GitHub +2. In Dokploy, create a new **Application** (not Compose — single container, no need for compose) +3. Set source to the GitHub repo, branch `main` +4. Build type: **Dockerfile** +5. Environment variable: `DOKPLOY_BASE_URL=http://172.17.0.1:3000` (or whatever internal IP works) +6. Domain: `webhook.catgirls.rodeo`, port `8080`, HTTPS on, letsencrypt +7. Deploy + +Then update GitHub webhook URLs for your other repos: +- Application deploys: `https://webhook.catgirls.rodeo/` +- Compose deploys: `https://webhook.catgirls.rodeo/compose/` + +--- + +### Implementation Steps + +1. **Initialize Cargo project**: `Cargo.toml` with minimal deps (`hyper`, `tokio`, `http-body-util`, `hyper-util`) +2. **Write `src/main.rs`**: + - Read `DOKPLOY_BASE_URL` and `PORT` from env (with defaults) + - Start hyper HTTP server on `0.0.0.0:{PORT}` + - Route handler: + - Reject non-POST → 405 + - Parse path: extract `/` or `/compose/` + - Build upstream URL: `{base}/api/deploy/{path}` + - Forward headers + body via hyper client + - Return upstream response to caller + - Log to stdout +3. **Write `Dockerfile`**: multi-stage, musl static build, `FROM scratch` +4. **Write `.dockerignore`**: exclude `target/`, `.git/`, `.rules/`, `*.md` +5. **Write `.gitignore`**: standard Rust ignores (`/target`) +6. **Test locally**: `cargo run`, then `curl -X POST localhost:8080/test-token` +7. **Push to GitHub, deploy via Dokploy** +8. **Test end-to-end**: update one GitHub repo's webhook URL, push, verify deploy triggers + +--- + +### Resource Footprint Estimate + +| Metric | Estimate | +|---|---| +| Docker image size | ~3–5 MB | +| RAM (idle) | ~1–2 MB | +| RAM (under load) | ~3–5 MB | +| CPU (idle) | ~0% | +| Git repo size | < 50 KB (excluding target/) | diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3553fef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,323 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webhook-forwarder" +version = "0.1.0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "tokio", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3b83602 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "webhook-forwarder" +version = "0.1.0" +edition = "2024" + +[dependencies] +bytes = "1.11.1" +http-body-util = "0.1.3" +hyper = { version = "1.8.1", features = ["server", "client", "http1"] } +hyper-util = { version = "0.1.20", features = ["tokio", "client-legacy", "http1"] } +tokio = { version = "1.50.0", features = ["rt-multi-thread", "net", "macros"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..25622d4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Build stage +FROM rust:1-alpine3.21 AS builder + +RUN apk add --no-cache musl-dev + +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ + +RUN cargo build --release + +# Production stage +FROM scratch + +COPY --from=builder /app/target/release/webhook-forwarder /webhook-forwarder + +EXPOSE 8080 + +ENTRYPOINT ["/webhook-forwarder"] diff --git a/bin/setup.sh b/bin/setup.sh new file mode 100755 index 0000000..3b99a4e --- /dev/null +++ b/bin/setup.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -euo pipefail + +cd /home/tradam/projects/webhook-forwarder + +# Initialize Rust project (skips if Cargo.toml already exists) +if [ ! -f Cargo.toml ]; then + cargo init --name webhook-forwarder +fi + +# Add dependencies +cargo add hyper --features server,http1 +cargo add hyper-util --features tokio +cargo add http-body-util +cargo add tokio --features rt-multi-thread,net,macros +cargo add bytes + +echo "Done! Rust project scaffolded successfully." diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 0000000..ca305aa --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +# Test the webhook forwarder locally. +# Start the server first with: cargo run +# Then run this script in another terminal. + +BASE="http://localhost:8080" + +echo "=== Test 1: GET should return 405 ===" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/test-token") +echo "GET /test-token -> $STATUS (expect 405)" + +echo "" +echo "=== Test 2: POST to / should forward ===" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/test-token" -H "Content-Type: application/json" -d '{"ref":"refs/heads/main"}') +echo "POST /test-token -> $STATUS (expect 502 if Dokploy unreachable, or upstream status)" + +echo "" +echo "=== Test 3: POST to /compose/ should forward ===" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/compose/test-token" -H "Content-Type: application/json" -d '{"ref":"refs/heads/main"}') +echo "POST /compose/test-token -> $STATUS (expect 502 if Dokploy unreachable, or upstream status)" + +echo "" +echo "=== Test 4: POST to invalid path should return 404 ===" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/") +echo "POST / -> $STATUS (expect 404)" + +echo "" +echo "=== Test 5: POST to nested invalid path should return 404 ===" +STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/foo/bar/baz") +echo "POST /foo/bar/baz -> $STATUS (expect 404)" + +echo "" +echo "Done!" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1f336ae --- /dev/null +++ b/src/main.rs @@ -0,0 +1,147 @@ +use std::env; +use std::net::SocketAddr; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Method, Request, Response, StatusCode, Uri}; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use tokio::net::TcpListener; + +const DEFAULT_BASE_URL: &str = "http://100.102.55.49:3000"; +const DEFAULT_PORT: u16 = 8080; + +type BoxError = Box; + +/// Parse the incoming path into the Dokploy deploy API path. +/// - "/" -> "/api/deploy/" +/// - "/compose/" -> "/api/deploy/compose/" +/// Returns None if the path doesn't match. +fn map_path(path: &str) -> Option { + let trimmed = path.trim_start_matches('/'); + if trimmed.is_empty() { + return None; + } + + if let Some(token) = trimmed.strip_prefix("compose/") { + if !token.is_empty() && !token.contains('/') { + return Some(format!("/api/deploy/compose/{}", token)); + } + return None; + } + + if !trimmed.contains('/') { + return Some(format!("/api/deploy/{}", trimmed)); + } + + None +} + +async fn handle( + base_url: String, + req: Request, +) -> Result>, BoxError> { + // Only allow POST + if req.method() != Method::POST { + eprintln!("{} {} -> 405 Method Not Allowed", req.method(), req.uri().path()); + return Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Full::new(Bytes::from("Method Not Allowed\n"))) + .unwrap()); + } + + let path = req.uri().path().to_string(); + + // Map the path + let upstream_path = match map_path(&path) { + Some(p) => p, + None => { + eprintln!("POST {} -> 404 Not Found", path); + return Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Full::new(Bytes::from("Not Found\n"))) + .unwrap()); + } + }; + + let upstream_uri: Uri = format!("{}{}", base_url, upstream_path).parse()?; + eprintln!("POST {} -> forwarding to {}", path, upstream_uri); + + // Build upstream request preserving headers and body + let mut builder = Request::builder() + .method(Method::POST) + .uri(&upstream_uri); + + // Copy headers (skip Host, it should match the upstream) + for (name, value) in req.headers() { + if name != hyper::header::HOST { + builder = builder.header(name, value); + } + } + + // Collect the incoming body + let body_bytes = req.into_body().collect().await?.to_bytes(); + let upstream_req = builder.body(Full::new(body_bytes))?; + + // Send to Dokploy + let client = Client::builder(TokioExecutor::new()).build_http(); + let upstream_resp = match client.request(upstream_req).await { + Ok(resp) => resp, + Err(e) => { + eprintln!("POST {} -> 502 upstream error: {}", path, e); + return Ok(Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Full::new(Bytes::from(format!("Bad Gateway: {}\n", e)))) + .unwrap()); + } + }; + + // Forward the upstream response back + let status = upstream_resp.status(); + let resp_body = upstream_resp.into_body().collect().await?.to_bytes(); + + eprintln!("POST {} -> upstream responded {}", path, status); + + Ok(Response::builder() + .status(status) + .body(Full::new(resp_body)) + .unwrap()) +} + +#[tokio::main] +async fn main() -> Result<(), BoxError> { + let base_url = env::var("DOKPLOY_BASE_URL") + .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()) + .trim_end_matches('/') + .to_string(); + + let port: u16 = env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(DEFAULT_PORT); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = TcpListener::bind(addr).await?; + eprintln!("webhook-forwarder listening on {}", addr); + eprintln!("forwarding to {}", base_url); + + loop { + let (stream, _) = listener.accept().await?; + let base_url = base_url.clone(); + + tokio::task::spawn(async move { + let io = hyper_util::rt::TokioIo::new(stream); + let service = service_fn(move |req| { + let base_url = base_url.clone(); + handle(base_url, req) + }); + + if let Err(e) = http1::Builder::new().serve_connection(io, service).await { + eprintln!("connection error: {}", e); + } + }); + } +} -- cgit v1.2.3