18 KiB
ReadeckProxy
Customizable HTTP/HTTPS MITM proxy service built with Swift and Vapor. Intercepts HTTP and HTTPS requests, injects headers based on configurable rules, forwards to upstream servers, and logs all activity to PostgreSQL. Runs entirely in Docker.
Tech Stack
- Swift 6.3 with Vapor 4 framework
- SwiftNIO + NIOSSL for high-performance proxy I/O
- swift-certificates (X509) + swift-crypto for CA and per-host TLS certificate generation
- Fluent ORM with PostgreSQL 18.3 driver
- Leaf templating + Bootstrap 5 for admin UI
- Docker + Docker Compose
Project Structure
ReadeckProxy/
├── Package.swift
├── docker-compose.yml
├── .env # Environment configuration (gitignored)
├── .env.example # Environment template
├── docker/
│ ├── app/
│ │ ├── Dockerfile # Development container (Swift 6.3)
│ │ ├── Dockerfile.release # Production multi-stage build
│ │ └── watch.sh # File watcher for hot-reload
│ └── db/
│ └── Dockerfile # PostgreSQL 18.3 database container
├── scripts/
│ └── release.sh # Build & push release image to Nexus
├── Sources/App/
│ ├── entrypoint.swift
│ ├── configure.swift
│ ├── routes.swift
│ ├── Controllers/
│ │ ├── AuthController.swift
│ │ └── AdminController.swift
│ ├── Models/
│ │ ├── ProxyLog.swift
│ │ ├── User.swift
│ │ ├── NetworkSetting.swift
│ │ └── HostRule.swift
│ ├── Migrations/
│ │ ├── CreateProxyLog.swift
│ │ ├── CreateUser.swift
│ │ ├── CreateNetworkSetting.swift
│ │ ├── CreateHostRule.swift
│ │ └── AddUpdatedAtToHostRule.swift
│ ├── Middleware/
│ │ └── AdminAuthMiddleware.swift
│ └── Proxy/
│ ├── RulesCache.swift # Thread-safe rule cache with wildcard matching
│ ├── ProxyServer.swift # NIO ServerBootstrap listener management
│ ├── ProxyChannelHandler.swift # HTTP/HTTPS proxy + MITM pipeline
│ ├── CertificateManager.swift # CA generation, per-host cert signing
│ └── ProxyLifecycleHandler.swift # Vapor lifecycle integration
├── Resources/Views/
│ ├── base.leaf
│ ├── login.leaf
│ └── settings/
│ ├── index.leaf
│ ├── show.leaf
│ └── rules/
│ ├── create.leaf
│ └── edit.leaf
├── Public/ # Static files served by FileMiddleware
└── Tests/AppTests/
└── AppTests.swift
Prerequisites
Getting Started
Configure environment
cp .env.example .env
Edit .env to customize ports, database credentials, etc.
Start all services
docker compose up --build
This will:
- Build the Swift application container (
swift:6.3) with pre-resolved dependencies - Build the PostgreSQL container (
postgres:18.3) - Wait for the database to be healthy
- Seed the build cache (if empty) from the pre-resolved image layer, then build and start the app
The app is available at http://localhost:8080. The admin panel is at http://localhost:8080/admin.
Verify it works
- Check the health endpoint:
curl http://localhost:8080/health
Should return 200 OK.
- Open the admin panel in your browser:
http://localhost:8080/admin
On the first visit you will see the Setup page — create your admin account.
-
Create a Network Setting with listen port
9080and add a Host Rule forhttpbin.orgwith headerX-Custom: test123. -
Test the HTTP proxy:
curl -x http://localhost:9080 http://httpbin.org/headers
You should see X-Custom: test123 in the response headers.
- Check proxy logs:
curl http://localhost:8080/proxy/logs
View logs
docker compose logs -f app
Stop services
docker compose down
Stop and remove all data
docker compose down -v
HTTPS Proxy (SSL MITM)
ReadeckProxy supports HTTPS interception via Man-in-the-Middle (MITM) proxying. When a client sends a CONNECT request, the proxy:
- Responds with
200 Connection Established - Generates a TLS certificate for the target host (signed by the proxy's CA)
- Terminates TLS from the client, decrypts the request
- Matches rules and injects headers (same as HTTP)
- Connects to the upstream server over TLS and forwards the request
- Relays the response back to the client through TLS
How it works
Client ──CONNECT host:443──► ReadeckProxy
◄── 200 Connection Established ──
Client ──TLS handshake──► Proxy (using generated cert for host)
Client ──decrypted HTTP──► Proxy
→ matches rules, injects headers
→ connects to upstream over TLS
◄── relays response back through TLS ──
The proxy generates a self-signed CA certificate on first startup. All per-host certificates are dynamically generated and signed by this CA. The CA certificate and key are persisted to disk (in CA_CERT_DIR) so they survive restarts.
Step-by-step tutorial
1. Start the proxy
docker compose up --build
2. Download the CA certificate
curl -o readeck-proxy-ca.pem http://localhost:8080/ca/cert.pem
Or download it from the admin panel — the Settings page has download buttons for PEM and DER formats.
3. Install the CA certificate
You must install and trust the CA certificate so your client (browser, curl, Docker container, etc.) trusts the proxy's generated certificates.
macOS (system-wide):
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain readeck-proxy-ca.pem
Linux (system-wide):
sudo cp readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
sudo update-ca-certificates
Windows:
certutil -addstore -f "ROOT" readeck-proxy-ca.pem
Firefox (uses its own cert store): Settings > Privacy & Security > Certificates > View Certificates > Authorities > Import > select the PEM file > check "Trust this CA to identify websites"
4. Configure rules
In the admin panel (http://localhost:8080/admin):
- Create a Network Setting with listen port
9080 - Add a Host Rule for
httpbin.orgwith headerAuthorization: Bearer my-secret-token
5. Test HTTPS proxying
With the CA installed system-wide:
curl -x http://localhost:9080 https://httpbin.org/headers
Or using the CA cert file directly:
curl --cacert readeck-proxy-ca.pem -x http://localhost:9080 https://httpbin.org/headers
You should see your injected Authorization header in the response.
6. Check logs
curl http://localhost:8080/proxy/logs
HTTPS requests are logged with the https:// scheme.
Adding the CA certificate to Docker containers
If other Docker containers need to make HTTPS requests through the proxy, they must trust the proxy's CA certificate.
Option A: Mount the CA cert at build time (Dockerfile)
Download the CA cert from the running proxy and add it to your image:
FROM ubuntu:noble
# Copy the proxy CA certificate
COPY readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
RUN update-ca-certificates
# Your application setup...
Option B: Mount the CA cert at runtime (docker-compose)
Share the CA data volume with other containers:
services:
app:
# ... ReadeckProxy config ...
volumes:
- ca-data:/app/data/ca
my-service:
image: my-app:latest
environment:
# Tell your app to use the proxy
HTTP_PROXY: http://app:9080
HTTPS_PROXY: http://app:9080
volumes:
- ca-data:/certs/proxy-ca:ro
# Install the CA cert on startup
entrypoint: >
sh -c "
cp /certs/proxy-ca/ca-cert.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt &&
update-ca-certificates &&
exec my-app
"
volumes:
ca-data:
Option C: Use environment variables for specific tools
Some tools support specifying a CA cert via environment variable:
services:
my-service:
environment:
HTTP_PROXY: http://app:9080
HTTPS_PROXY: http://app:9080
# For curl
CURL_CA_BUNDLE: /certs/proxy-ca/ca-cert.pem
# For Node.js
NODE_EXTRA_CA_CERTS: /certs/proxy-ca/ca-cert.pem
# For Python requests
REQUESTS_CA_BUNDLE: /certs/proxy-ca/ca-cert.pem
# For SSL_CERT_FILE (OpenSSL-based tools)
SSL_CERT_FILE: /certs/proxy-ca/ca-cert.pem
volumes:
- ca-data:/certs/proxy-ca:ro
Option D: Alpine-based containers
Alpine uses a different certificate path:
FROM alpine:3.20
COPY readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
RUN apk add --no-cache ca-certificates && update-ca-certificates
CA certificate endpoints
| Endpoint | Format | Content-Type |
|---|---|---|
GET /ca/cert.pem |
PEM (Base64) | application/x-pem-file |
GET /ca/cert.der |
DER (Binary) | application/x-x509-ca-cert |
Admin Panel
The admin panel is available at /admin and provides a web UI for configuring proxy behavior.
First Time Setup
On first visit to /admin, you will be prompted to create an admin account. Enter a username and password — this becomes the only account that can log in.
Network Settings
After logging in, the main page shows a list of port mappings. Each port mapping represents a port that the proxy listens on for incoming requests from internal services.
Click on any entry to open its detail page.
Host Rules
Each port mapping contains a list of host rules. When a request comes through the proxy:
- The proxy receives the request on the configured listen port
- It inspects the destination host
- If the host matches a rule, the configured headers are added or overwritten
- The modified request is forwarded to the destination
Each rule defines:
- Host Pattern — the destination host to match (e.g.
api.example.comor*.example.comfor wildcards). Protocol prefixes (http://,https://) and trailing slashes are stripped automatically. - Headers — key/value pairs of headers to add or rewrite on matching requests
Rules are managed through dedicated create and edit pages with dynamic header input fields. The edit page shows existing header names but hides values for security — you must re-enter values when updating. A "Last Edited" timestamp is shown in the rules list and on the edit page.
Static Files
Static files placed in the Public/ directory are served automatically via Vapor's FileMiddleware. Any file in this directory is accessible at the root URL path (e.g. Public/style.css is served at /style.css).
API Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
No | Health check — returns 200 OK |
| GET | /proxy/logs |
No | Last 100 proxy logs (JSON) |
| GET | /ca/cert.pem |
No | Download CA certificate (PEM format) |
| GET | /ca/cert.der |
No | Download CA certificate (DER format) |
| GET | /admin/login |
No | Login page |
| POST | /admin/login |
No | Login / first-time setup |
| GET | /admin/settings |
Yes | List network settings |
| POST | /admin/settings |
Yes | Create network setting |
| GET | /admin/settings/:id |
Yes | View setting + rules |
| POST | /admin/settings/:id/delete |
Yes | Delete setting |
| GET | /admin/settings/:id/rules/create |
Yes | Create rule page |
| POST | /admin/settings/:id/rules |
Yes | Create rule |
| GET | /admin/settings/:id/rules/:ruleId/edit |
Yes | Edit rule page |
| POST | /admin/settings/:id/rules/:ruleId/update |
Yes | Update rule |
| POST | /admin/settings/:id/rules/:ruleId/delete |
Yes | Delete rule |
Development in Docker
All development happens inside the Docker container. The source code is mounted as a volume, so you edit files on your host machine and the container sees the changes immediately.
How it works
┌─────────────────────────────────────────────────┐
│ Host machine │
│ │
│ Sources/ ──volume mount──► /app/Sources/ │
│ Resources/ ─volume mount──► /app/Resources/ │
│ Public/ ─volume mount──► /app/Public/ │
│ Package.swift ─vol mount──► /app/Package.swift│
│ │
│ .build/ is stored in a named Docker volume │
│ (swift-build) so build cache persists between │
│ container restarts. Dependencies are pre- │
│ resolved in the Docker image layer and seeded │
│ into the volume on first run. │
└─────────────────────────────────────────────────┘
Typical workflow
- Start the environment:
docker compose up --build
-
Edit source files in your editor on the host machine.
-
Restart the app container to pick up changes:
docker compose restart app
- Watch app logs in real time:
docker compose logs -f app
- Open a shell inside the running container for ad-hoc commands:
docker compose exec app bash
Build and run tests inside the container
docker compose exec app swift build
docker compose exec app swift test
Rebuild after changing Package.swift
When you add or update dependencies in Package.swift, rebuild the image so the new dependencies are resolved and cached in the image layer:
docker compose up --build
The Dockerfile pre-resolves dependencies during the image build. Docker layer caching ensures this step is skipped when Package.swift and Package.resolved haven't changed.
Reset everything
Stop all containers and remove volumes (database data + build cache + CA certificates):
docker compose down -v
Release
Build and push a release image
The script scripts/release.sh builds a production image using a multi-stage Dockerfile (docker/app/Dockerfile.release) and pushes it to the Nexus registry at registry.kshaitry.com.
The production image:
- Compiles the Swift binary in release mode with static stdlib
- Copies only the binary, Leaf templates, and static files into a minimal Ubuntu runtime image
- Runs as a non-root user
./scripts/release.sh <version>
Example:
./scripts/release.sh 1.0.0
This will:
- Build
registry.kshaitry.com/readeck-proxy:1.0.0 - Tag it as
registry.kshaitry.com/readeck-proxy:latest - Push both tags to the Nexus registry
Make sure you are logged in to the registry before running:
docker login registry.kshaitry.com
Environment Variables
All settings are configured via the .env file (see .env.example for the template). Docker Compose reads this file automatically.
| Variable | Default | Description |
|---|---|---|
APP_PORT |
8080 |
Application port (admin panel + API) |
PROXY_PORTS |
9080 |
Proxy listener port(s) exposed to host |
CA_CERT_DIR |
data/ca |
Directory for CA certificate persistence |
POSTGRES_USER |
readeck |
Database user |
POSTGRES_PASSWORD |
readeck_pass |
Database password |
POSTGRES_DB |
readeck_proxy |
Database name |
POSTGRES_PORT |
5432 |
PostgreSQL port exposed to host |
Docker Volumes
| Volume | Purpose |
|---|---|
db_data |
Persists PostgreSQL data between restarts |
swift-build |
Caches .build directory for faster rebuilds |
ca-data |
Persists CA certificate and key between restarts |
Database Schema
All tables are auto-migrated on startup.
users
| Column | Type | Description |
|---|---|---|
id |
UUID |
Primary key |
username |
String |
Unique username |
password_hash |
String |
Bcrypt password hash |
created_at |
DateTime |
Creation timestamp |
network_settings
| Column | Type | Description |
|---|---|---|
id |
UUID |
Primary key |
name |
String |
Display name |
listen_port |
Int |
Port the proxy listens on |
created_at |
DateTime |
Creation timestamp |
host_rules
| Column | Type | Description |
|---|---|---|
id |
UUID |
Primary key |
network_setting_id |
UUID |
FK to network_settings (cascade delete) |
host_pattern |
String |
Destination host to match |
headers |
JSON |
Headers to add/rewrite |
created_at |
DateTime |
Creation timestamp |
updated_at |
DateTime |
Last update timestamp |
proxy_logs
| Column | Type | Description |
|---|---|---|
id |
UUID |
Primary key |
method |
String |
HTTP method used |
url |
String |
Target URL (http:// or https://) |
status_code |
Int |
Response status code |
request_headers |
JSON |
Injected headers |
response_size |
Int |
Response body size in bytes |
created_at |
DateTime |
Timestamp of the request |