High-performance HTTP proxy, API gateway & SIP/RTP media proxy — built on Bun
Midleman is a self-hosted middleware layer that sits between your services and the outside world. It handles HTTP credential injection, multi-target webhook routing, SIP/VoIP protocol proxying with full RTP media relay, and real-time traffic inspection — all managed through a built-in web dashboard.
- Proxy Profiles — Inject API credentials server-side so they are never exposed to clients. Share protected upstream APIs via public or key-gated links.
- Webhook Fan-out — Receive a single inbound webhook and dispatch it asynchronously to N destinations with body templates, custom headers, and method overrides.
- Retry Engine — Configurable per-distributor and per-destination retry with exponential or fixed backoff. Includes a retry until 2xx mode.
- Dead Letter Queue — Failed deliveries are persisted to disk (
dlq.json) and survive restarts. Full replay from the dashboard.
- Multi-listener — A single profile can accept UDP, TCP, and TLS simultaneously on auto-assigned ports. Each transport gets its own port.
- TLS termination — Terminate SIPS/TLS inbound connections (e.g. Meta WhatsApp Business Calling) and forward to FusionPBX/FreeSWITCH over plain UDP/TCP — no PBX reconfiguration needed.
- TLS upstream — Optionally forward to an upstream that also uses TLS (cloud PBX). Self-signed cert support configurable per profile.
- SIP Via / Record-Route rewriting — RFC 3261 compliant stateful proxy. Rewrites Via and Record-Route headers so responses and in-dialog requests always route back through Midleman.
- Transaction table — Maps outgoing branch IDs to inbound sockets (TCP/TLS or UDP) for correct response routing.
- RTP Media Relay — Rewrites SDP (c= and m= lines) in INVITE/200 OK so all audio flows through Midleman. Enables FusionPBX on a private network to receive calls from the internet.
- Worker threads for RTP — Distributes RTP relay across multiple CPU cores via Bun Worker threads. Each worker handles its own port range independently. Scales to 1000+ simultaneous calls.
- Auto TLS certificate — Let's Encrypt via ACME HTTP-01 challenge. Certificates are issued and renewed automatically. Hot-reload after renewal with no call drops.
- Web Dashboard — Full CRUD for HTTP proxies, TCP/UDP proxies, webhooks, users, and invites. Real-time request logs, charts, and DLQ management.
- Traffic Logging — SQLite-backed request/response capture with configurable retention.
- TOTP 2FA — First-run setup wizard with QR code. All admin routes are session-protected.
- OAuth 2.0 / OIDC Provider — Built-in authorization server. Issue OAuth clients for third-party apps (Portainer, Supabase, Grafana, etc.) and have them authenticate users against Midleman.
- IP Allowlists — Per-profile CIDR/wildcard allowlists for both HTTP and SIP listeners.
- OpenTelemetry — Optional traces and metrics via OTLP. Compatible with Jaeger, Grafana, Prometheus.
- Bun >= 1.1
git clone https://github.com/Biscatos/Midleman.git
cd Midleman
bun install
cp .env.example .env
bun run devOpen http://localhost:3000/dashboard to complete the initial TOTP setup.
docker-compose up -dMap
./dataas a volume — all configuration, logs, certificates, and auth state persist there.
All options are set via environment variables.
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Admin dashboard port |
DATA_DIR |
./data |
Persistent storage directory |
PORT_RANGE_START |
4000 |
Starting port for auto-assigned profiles |
PROXY_HOST |
— | Public IP/hostname used as fallback in SIP Via headers |
PROXY_INFOBIP_URL=https://api.infobip.com
PROXY_INFOBIP_KEY=your_api_key
PROXY_INFOBIP_HEADER=Authorization
PROXY_INFOBIP_PREFIX=App
PROXY_INFOBIP_ACCESS=public_link_key # optional: protect the public link
PROXY_INFOBIP_BLOCKED=.exe,.bat # optional: block file extensions| Variable | Default | Description |
|---|---|---|
REQUEST_LOG_ENABLED |
true |
Enable SQLite request logging |
REQUEST_LOG_RETENTION_DAYS |
7 |
Auto-purge after N days (overridable from the dashboard) |
REQUEST_LOG_MAX_BODY_SIZE |
65536 |
Max bytes captured per request body |
Retention and capture mode can also be changed at runtime in Settings → Log Storage (persisted to data/log-settings.json, takes precedence over the env var). The same page shows the database size, lets you delete rows now (optionally keeping failed requests as evidence) and compact the file (VACUUM).
Capture modes — global default plus a per-resource override on every HTTP proxy, webhook and connector:
| Mode | Behaviour |
|---|---|
full |
Log every request (default) |
errors-only |
Keep only rows with status ≥ 400 or a network error, including the request body, so they can be inspected or re-sent |
off |
Log nothing for that resource (replaces the old "Disable Request Logs" checkbox) |
Purges run hourly in 5 000-row batches and never block traffic. Deleted rows only free space inside the file; run Compact once (off-peak, it blocks the server) to shrink it on disk and enable incremental auto-vacuum from then on.
| Variable | Default | Description |
|---|---|---|
OTEL_ENABLED |
false |
Enable telemetry |
OTEL_ENDPOINT |
— | OTLP HTTP endpoint |
OTEL_SERVICE_NAME |
midleman |
Service name in spans/metrics |
OTEL_METRICS_INTERVAL |
15000 |
Metrics export interval (ms) |
Midleman can terminate TLS from cloud carriers (e.g. Meta WhatsApp Business Calling API) and forward SIP signalling to a private FusionPBX/FreeSWITCH with full RTP media relay.
Internet Midleman (public IP) LAN
─────────────────────────────────────────────────────────────
Meta WhatsApp
SIP/TLS ──────────────► :PORT_TLS ──────────────► FusionPBX :5060
RTP/SRTP ──────────────► :PORT_RTP_B FusionPBX :20000
(relay) ──────────────►
SIP phones
SIP/UDP ──────────────► :PORT_UDP
SIP/TCP ──────────────► :PORT_TCP
All ports are auto-assigned from the port range. FusionPBX never needs a public IP.
# Profile name: meta
TCPUDP_META_UPSTREAM_HOST=192.168.1.100 # FusionPBX private IP
TCPUDP_META_UPSTREAM_PORT=5060
TCPUDP_META_UPSTREAM_TRANSPORT=udp # udp | tcp | tls
# Inbound listeners (each gets an auto-assigned port)
TCPUDP_META_LISTENERS=udp,tls # accepts: udp, tcp, tls
# TLS inbound certificate (manual)
TCPUDP_META_TLS_CERT=/etc/ssl/certs/sip.pem
TCPUDP_META_TLS_KEY=/etc/ssl/private/sip.key
# Or: automatic certificate via Let's Encrypt
TCPUDP_META_ACME_DOMAIN=sip.empresa.com
TCPUDP_META_ACME_EMAIL=[email protected]
# SIP routing — public address for Via/Record-Route headers
TCPUDP_META_SIP_PUBLIC_HOST=sip.empresa.com
# RTP media relay
TCPUDP_META_RTP_RELAY=true
TCPUDP_META_RTP_PORT_START=40000
TCPUDP_META_RTP_PORT_END=44001 # 4001 ports = ~1000 calls
TCPUDP_META_RTP_WORKERS=4 # worker threads (auto = CPU cores - 1)
# Upstream TLS options (when UPSTREAM_TRANSPORT=tls)
TCPUDP_META_ALLOW_SELF_SIGNED_UPSTREAM=false
# Access control
TCPUDP_META_ALLOWED_IPS=203.0.113.0/24,198.51.100.*All TCP/UDP proxy settings are also configurable from the dashboard (Proxies → + TCP/UDP Proxy):
| Section | Fields |
|---|---|
| Identity | Profile name (auto-assigns all ports) |
| Upstream | Host, Port, Transport (UDP / TCP / TLS) |
| Inbound Listeners | Checkboxes: ☑ UDP ☑ TCP ☑ TLS |
| TLS Certificate | Manual paths or Let's Encrypt ACME |
| SIP Routing | Public Host (for Via/Record-Route headers) |
| RTP Media Relay | Enable, Port range, Worker threads |
| Access Control | IP allowlist |
When using TLS with Let's Encrypt and SIP Via headers, three addresses are involved:
| Address | Purpose | Example |
|---|---|---|
acmeDomain |
Domain for the TLS certificate. Must resolve publicly to this server's IP. Port 80 must be accessible for ACME HTTP-01. | sip.empresa.com |
sipPublicHost |
Hostname placed in SIP Via and Record-Route headers. FusionPBX sends responses here. Should match acmeDomain when using ACME. |
sip.empresa.com |
upstreamHost |
FusionPBX private IP/hostname. Never needs to be public. | 192.168.1.100 |
1. INVITE (Meta → Midleman TLS:PORT_TLS)
SDP: c=meta_ip m=audio meta_rtp_port
↓ SDP rewritten by Midleman
2. INVITE (Midleman → FusionPBX UDP:5060)
SDP: c=midleman_ip m=audio PORT_A
Via: SIP/2.0/UDP midleman_ip:PORT_A;branch=z9hG4bKxxx
Record-Route: <sip:midleman_ip:PORT_TLS;lr;transport=tls>
3. 200 OK (FusionPBX → Midleman)
SDP: c=fusionpbx_ip m=audio fusionpbx_rtp_port
↓ SDP rewritten by Midleman
4. 200 OK (Midleman → Meta)
SDP: c=midleman_ip m=audio PORT_B
5. RTP audio flows:
Meta ──SRTP──► Midleman:PORT_B ──RTP──► FusionPBX:fusionpbx_rtp_port
Meta ◄──RTP── Midleman:PORT_A ◄──RTP── FusionPBX
6. BYE → relay sockets closed, ports returned to pool
Each call uses 4 UDP ports (RTP + RTCP × 2 directions). Worker threads enable parallel packet processing across CPU cores:
| Workers | Port range | Max concurrent calls | Network requirement |
|---|---|---|---|
| 0 (single thread) | 50000–51000 | ~250 | ~32 Mbps (G.711) |
| 1 | 40000–41000 | ~250 | ~32 Mbps |
| 4 | 40000–44001 | ~1000 | ~128 Mbps |
| 8 | 40000–48001 | ~2000 | ~256 Mbps |
Workers are auto-detected from CPU cores when rtpWorkers is not set (uses hardwareConcurrency - 1).
OS tuning for high call volumes:
# File descriptors (2 sockets per call)
ulimit -n 65536
# UDP kernel buffers
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216
sysctl -w net.core.netdev_max_backlog=5000Webhooks are configured from the dashboard. Each distributor listens on its own port.
{
"name": "payments",
"port": 4010,
"retry": {
"maxRetries": 5,
"retryDelayMs": 1000,
"backoff": "exponential",
"retryUntilSuccess": true
},
"targets": [
"https://service-a.internal/hook",
{
"url": "https://service-b.internal/notify",
"method": "POST",
"bodyTemplate": "{\"id\": \"{{order.id}}\", \"amount\": {{order.total}}}",
"retry": { "maxRetries": 10, "retryUntilSuccess": true }
}
]
}| Field | Description |
|---|---|
maxRetries |
Max attempts after the first failure |
retryDelayMs |
Base delay between retries (ms) |
backoff |
exponential (default) or fixed |
retryOn |
HTTP status codes that trigger retry (default: [429, 502, 503, 504]) |
retryUntilSuccess |
Retry on any non-2xx response (respects maxRetries) |
Failed deliveries are captured in the Dead Letter Queue (data/dlq.json) and can be replayed from the dashboard.
- Set the Callback URL in Meta's App Dashboard to
https://midleman.example.com/webhook?token=your_token - Set the Verify Token in Meta to match the Auth Token in Midleman
- Midleman handles the
hub.challengehandshake automatically
All resources available via REST under /admin (requires session cookie):
| Method | Path | Description |
|---|---|---|
GET |
/admin/profiles |
List HTTP proxy profiles |
POST |
/admin/profiles |
Create / update a profile |
DELETE |
/admin/profiles/:name |
Delete a profile |
POST |
/admin/profiles/:name/restart |
Restart a profile server |
GET |
/admin/tcpudp |
List TCP/UDP proxy profiles |
POST |
/admin/tcpudp |
Create / update a TCP/UDP profile |
DELETE |
/admin/tcpudp/:name |
Delete a TCP/UDP profile |
POST |
/admin/tcpudp/:name/restart |
Restart a TCP/UDP profile |
GET |
/admin/webhooks |
List webhook distributors |
POST |
/admin/webhooks |
Create / update a distributor |
DELETE |
/admin/webhooks/:name |
Delete a distributor |
GET |
/admin/webhooks/dlq |
List failed deliveries |
POST |
/admin/webhooks/dlq/retry-all |
Retry all failed deliveries |
POST |
/admin/webhooks/dlq/:id/retry |
Retry one delivery |
DELETE |
/admin/webhooks/dlq/:id |
Dismiss a failed delivery |
GET |
/admin/requests |
Query request logs |
GET |
/health |
Health check + status of all servers |
src/
├── index.ts # Entry point — HTTP server, routing, admin API
├── core/
│ ├── config.ts # Environment config loader (HTTP + TCP/UDP profiles)
│ ├── store.ts # JSON persistence (profiles, webhooks, DLQ)
│ ├── types.ts # TypeScript interfaces
│ └── ip-filter.ts # CIDR/wildcard IP allowlist
├── servers/
│ ├── proxy-server.ts # HTTP proxy profile servers
│ ├── sip-server.ts # TCP/UDP/SIP proxy (multi-listener + RTP relay)
│ ├── webhook-server.ts # Webhook fan-out + DLQ + retry engine
│ └── port-manager.ts # Dynamic port allocation for all server types
├── proxy/
│ └── proxy.ts # HTTP request forwarding logic
├── sip/
│ ├── message.ts # SIP message types, Via parsing/serialization
│ ├── parser.ts # SipTcpParser (Content-Length framing) + UDP parser
│ ├── headers.ts # Via/Record-Route rewriting, SIP forward/response logic
│ ├── transaction.ts # Transaction table (branch → return socket/address)
│ ├── sdp.ts # SDP parser and rewriter (c=, m=audio)
│ ├── rtp-relay.ts # RTP media relay manager (single-thread + worker mode)
│ ├── rtp-worker.ts # Bun Worker thread for parallel RTP packet relay
│ └── acme.ts # Let's Encrypt ACME HTTP-01 certificate lifecycle
├── auth/
│ └── auth.ts # TOTP, sessions, JWT, proxy users, rate limiting
├── telemetry/
│ ├── telemetry.ts # OpenTelemetry setup
│ └── request-log.ts # SQLite request/response logging
└── views/ # Dashboard HTML/CSS/JS
├── dashboard.html
├── partials/
│ ├── _app.html # Main app shell + all modals
│ ├── _login.html # Login panel
│ └── _setup.html # First-run setup wizard
├── js/
│ ├── dashboard-app.js # Auth, navigation, theme
│ └── dashboard-data.js # API calls, rendering, CRUD logic
└── css/
└── dashboard.css
Midleman ships with an OAuth 2.0 + OIDC authorization server. Register a client in the dashboard (Users → OAuth Clients) and point any OAuth-aware application at the endpoints below to delegate authentication to Midleman.
| Endpoint | Path |
|---|---|
| Authorization | /oauth/authorize |
| Token | /oauth/token |
| Userinfo | /oauth/userinfo |
| Logout (RP-Initiated) | /oauth/logout |
| JWKS | /.well-known/jwks.json |
| OIDC Discovery | /.well-known/openid-configuration |
openid profile email offline_access
Unknown scopes are silently dropped. Scopes are space-separated (RFC 6749) — not comma-separated. The openid scope is always added if missing.
{
"sub": "<uuid>", // Stable opaque ID. Derived from the internal user ID.
"preferred_username": "<login>", // The username the user logs in with. Human-readable.
"email": "<email>",
"email_verified": true, // True if an email is set on the account.
"name": "<fullName or username>", // Display name. Falls back to username if no full name.
"midleman_uid": 42 // Midleman's internal integer user ID. Non-standard.
}Picking the right "user identifier" in your client app Most apps expose a "User identifier" field to choose which claim uniquely identifies a user. Use
preferred_usernameunless you specifically want an opaque ID (sub). Do not change this after users have logged in — the client app stores the identifier and you will create orphans on the next login.
- PKCE S256 required by default. Disable per-client only for legacy apps that cannot send a
code_challenge(toggle in the OAuth Client modal). Disabling removes a defense against authorization-code interception. redirect_uriis matched exact-string. No normalization, no wildcards.- Fragments (
#...) are rejected at save time. Browsers strip the fragment before sending the request, so the server would never see it — registration would silently never match. The client app can navigate to the fragment client-side after the callback. - Authorization codes: single-use, 90-second TTL, bound to
(client_id, redirect_uri, code_challenge). - Refresh tokens: rotation + family revocation. Presenting a revoked token kills the entire family.
client_secretis stored as a bcrypt hash. It is shown once at client creation and cannot be retrieved later.- Changing a client's
redirect_urisrevokes every outstanding refresh token for that client.
Settings → Authentication → Custom OAuth:
| Field | Value |
|---|---|
| Authorization URL | https://<midleman-host>/oauth/authorize |
| Access token URL | https://<midleman-host>/oauth/token |
| Resource URL (userinfo) | https://<midleman-host>/oauth/userinfo |
| Redirect URL | https://<your-portainer-host>/ (no fragment, no #!/home) |
| Logout URL | https://<midleman-host>/oauth/logout |
| User identifier | preferred_username |
| Scopes | openid profile email |
| Use PKCE | ✓ (enable on Portainer 2.20+) |
Contributions are welcome. Please open an issue before submitting a large pull request.