summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.dockerignore8
-rw-r--r--.gitignore1
-rw-r--r--.rules/changelog/2026-03/18/01.md16
-rw-r--r--.rules/plan/webhook-forwarder.md160
-rw-r--r--Cargo.lock323
-rw-r--r--Cargo.toml11
-rw-r--r--Dockerfile19
-rwxr-xr-xbin/setup.sh18
-rwxr-xr-xbin/test.sh35
-rw-r--r--src/main.rs147
10 files changed, 738 insertions, 0 deletions
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 /<token>` → `{DOKPLOY_BASE_URL}/api/deploy/<token>`
+ - `POST /compose/<token>` → `{DOKPLOY_BASE_URL}/api/deploy/compose/<token>`
+ - 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/<token>` | `POST {DOKPLOY_BASE_URL}/api/deploy/<token>` |
+| `POST webhook.catgirls.rodeo/compose/<token>` | `POST {DOKPLOY_BASE_URL}/api/deploy/compose/<token>` |
+
+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:
+ - `/<token>` → forward to `/api/deploy/<token>`
+ - `/compose/<token>` → forward to `/api/deploy/compose/<token>`
+ - 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/<token>`
+- Compose deploys: `https://webhook.catgirls.rodeo/compose/<token>`
+
+---
+
+### 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 `/<token>` or `/compose/<token>`
+ - 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 /<token> 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/<token> 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<dyn std::error::Error + Send + Sync>;
+
+/// Parse the incoming path into the Dokploy deploy API path.
+/// - "/<token>" -> "/api/deploy/<token>"
+/// - "/compose/<token>" -> "/api/deploy/compose/<token>"
+/// Returns None if the path doesn't match.
+fn map_path(path: &str) -> Option<String> {
+ 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<Incoming>,
+) -> Result<Response<Full<Bytes>>, 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);
+ }
+ });
+ }
+}