Certificate greneration

This commit is contained in:
2026-04-22 22:34:03 +03:00
parent 929cb89ba7
commit 2996f2691d
22 changed files with 1475 additions and 157 deletions
+2
View File
@@ -1,5 +1,7 @@
# Application
APP_PORT=8080
PROXY_PORTS=9080
CA_CERT_DIR=/app/data/ca
# Database
POSTGRES_USER=readeck
+1
View File
@@ -7,3 +7,4 @@ Package.resolved
*.xcworkspace/
DerivedData/
.env
data/
+358
View File
@@ -0,0 +1,358 @@
# ReadeckProxy — Full Development Log
**Project:** ReadeckProxy — Customizable HTTP/HTTPS Proxy with Admin Panel
**Framework:** Swift 6.0 / Vapor 4 / SwiftNIO / PostgreSQL / Leaf Templates
**Last Updated:** 2026-04-22
---
## Table of Contents
1. [Session 1: Initial Project Creation](#session-1-initial-project-creation)
2. [Session 2: Refactor to Real HTTP Forward Proxy](#session-2-refactor-to-real-http-forward-proxy)
3. [Session 3: Admin Panel Rule Pages Improvements](#session-3-admin-panel-rule-pages-improvements)
4. [Session 4: Miscellaneous Improvements](#session-4-miscellaneous-improvements)
5. [Session 5: Add HTTPS MITM Proxy Support](#session-5-add-https-mitm-proxy-support)
---
## Session 1: Initial Project Creation
**Session ID:** `08f79888-c135-4941-8645-8907fb4e88b5`
**Task:** Create new project using Swift/Vapor for a customizable proxy network request service.
### What was built:
- Vapor 4 project with PostgreSQL (Fluent ORM) and Leaf templates
- **Models:** `User`, `NetworkSetting`, `HostRule`, `ProxyLog`
- **Database Migrations:** For all models with proper constraints (unique username, unique listen_port, cascade delete on host_rules)
- **Admin Panel:** Session-based auth with Bcrypt, login/setup page, CRUD for network settings and host rules
- **Proxy Controller:** JSON-based API (`POST /proxy/forward`) that accepted method/url/headers/body and forwarded requests
- **Docker Setup:** docker-compose with app + PostgreSQL services, development Dockerfile with hot-reload watch script, production Dockerfile with multi-stage build
- **Templates:** `base.leaf`, `login.leaf`, `settings/index.leaf`, `settings/show.leaf`
### Architecture:
```
Client → POST /proxy/forward (JSON body) → ProxyController → fetch upstream → return response
Admin → /admin/settings → CRUD NetworkSettings + HostRules
```
---
## Session 2: Refactor to Real HTTP Forward Proxy
**Session ID:** `c9f5a089-02a1-4134-9ff7-04f7493d4f94`
**Task:** Replace JSON-based proxy API with a real HTTP forward proxy using SwiftNIO.
### Plan:
The JSON-based `POST /proxy/forward` API was replaced with a real transparent HTTP forward proxy. Each NetworkSetting opens a TCP listener on its `listenPort`, intercepts plain HTTP requests, matches the destination host against HostRules, injects configured headers, and forwards to the destination.
### Steps Executed:
1. **Explored codebase** — Read all existing files (Package.swift, configure.swift, routes.swift, models, controllers, docker configs)
2. **Updated Package.swift** — Added explicit NIO product dependencies:
- `NIOCore`, `NIOPosix`, `NIOHTTP1`, `NIOConcurrencyHelpers` from `swift-nio`
3. **Deleted old files:**
- `Sources/App/Controllers/ProxyController.swift`
- `Sources/App/DTOs/ProxyRequestDTO.swift`
- Empty `DTOs/` directory
4. **Created 4 new files under `Sources/App/Proxy/`:**
- **`RulesCache.swift`** — Thread-safe cache `[listenPort: [CachedRule]]` using `NIOLockedValueBox`, with exact + wildcard (`*.domain.com`) host matching
- **`ProxyServer.swift`** — Manages NIO `ServerBootstrap` listeners per port, with `start()`/`shutdown()`
- **`ProxyChannelHandler.swift`** — NIO `ChannelInboundHandler` that parses absolute URIs or `Host` headers, injects matched headers, forwards via `ClientBootstrap`, relays responses back with DB logging
- **`ProxyLifecycleHandler.swift`** — Vapor `LifecycleHandler` that starts proxy on boot and stops on shutdown
5. **Modified existing files:**
- `configure.swift` — Added `StorageKey` types + `Application` extensions for `rulesCache`/`proxyServer`, creates instances after migrations, registers lifecycle handler
- `routes.swift` — Removed `ProxyController`, kept `GET /proxy/logs` as inline closure
- `AdminController.swift` — Added `reloadProxy()` helper called after create, delete, createRule, updateRule, deleteRule
- `docker-compose.yml` — Added `PROXY_PORTS` port mapping
- `.env` / `.env.example` — Added `PROXY_PORTS=9080`
### Build Issues Fixed:
- **Missing import:** `NIOLockedValueBox` needed `NIOConcurrencyHelpers` import in `ProxyServer.swift`
- **Missing dependency:** Added `NIOConcurrencyHelpers` product to `Package.swift`
- **Sendable warnings:** Marked channel handlers with `@unchecked Sendable` (NIO event loop bound)
- **Capture warnings:** Pre-captured `self` properties into local `let` bindings before closures
### Post-implementation review:
- Found `RulesCache` only tracked ports with rules — fixed to also register ports from NetworkSettings without rules (so empty listeners still bind)
### Final Architecture:
```
HTTP Client → curl -x http://localhost:9080 http://example.com
→ ProxyServer (NIO ServerBootstrap on port 9080)
→ ProxyChannelHandler (parse request, match rules, inject headers)
→ ClientBootstrap → upstream server
→ ProxyResponseRelayHandler → relay response + log to DB
```
---
## Session 3: Admin Panel Rule Pages Improvements
**Session ID:** `88f7b6da-d966-44e2-a036-57c4cbfc7334` (first part)
**Task:** Improve admin panel rule management UX.
### Changes:
- Replaced modal-based rule creation with dedicated pages
- Added `settings/rules/create.leaf` — Host input with protocol stripping, dynamic header name/value pairs, add/remove buttons
- Added `settings/rules/edit.leaf` — Pre-filled host, existing header names shown (values hidden for security), last edit date
- Added `updated_at` timestamp to `HostRule` model + migration (`AddUpdatedAtToHostRule`)
- Updated `AdminController` with `createRulePage`, `editRulePage`, `updateRule` handlers
- Updated routes with new GET/POST endpoints for rule create/edit
- Added Bootstrap Icons CDN to `base.leaf`
- Updated `settings/show.leaf` — trash icons, clickable host patterns, "Last Edited" column
---
## Session 4: Miscellaneous Improvements
**Session ID:** `88f7b6da-d966-44e2-a036-57c4cbfc7334` (continued)
### Docker build optimization:
- Updated Dockerfile to cache `swift package resolve` in an image layer (only re-fetches when Package.swift/Package.resolved change)
- Added `swift-build` named volume for persistent `.build` directory
### Attempted and rolled back:
- **Move Vapor app into `api/` folder** — moved then rolled back per user request
- **Create separate `client/` (front) app** — created then rolled back per user request
- Instead, just added `FileMiddleware` for static content serving from `Public/` directory
### Architecture discussion:
- Explained the full proxy flow to user (how ProxyChannelHandler receives connections, matches rules, injects headers, forwards upstream)
---
## Session 5: Add HTTPS MITM Proxy Support
**Session ID:** `b76a2b92-067c-4107-9390-6a42f2c15886`
**Date:** 2026-04-22
**Task:** Add HTTPS MITM (Man-in-the-Middle) support so the proxy can intercept HTTPS traffic.
### Plan
The proxy previously handled HTTP only. The plan added HTTPS MITM support:
- Handle `CONNECT` method (used by clients for HTTPS proxy tunneling)
- Generate per-host TLS certificates signed by a self-signed CA
- Perform TLS termination (decrypt client traffic) and re-encryption (connect upstream over TLS)
- Apply same rule-based header injection as HTTP
### HTTPS MITM Flow
```
Client --CONNECT host:443--> ProxyChannelHandler
--> sends "200 Connection Established"
--> removes HTTP handlers + self from pipeline
--> adds NIOSSLServerHandler (with cert for host, signed by CA)
--> adds HTTP codecs + new ProxyChannelHandler (MITM mode)
Client --TLS handshake--> NIOSSLServerHandler
Client --decrypted HTTP--> ProxyChannelHandler (MITM mode)
--> matches rules, injects headers
--> connects upstream via ClientBootstrap + NIOSSLClientHandler
--> relays response back through TLS to client
```
### Implementation Steps
#### Step 1: Explore Codebase
- Used Agent tool to explore full project structure
- Read all 11 files that would be modified: Package.swift, ProxyChannelHandler.swift, ProxyServer.swift, configure.swift, routes.swift, settings/index.leaf, docker-compose.yml, .env, .env.example, .gitignore, Dockerfile.release
#### Step 2: Update Package.swift (Task #1)
Added three new dependencies:
```swift
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.25.0"),
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
```
And product targets: `NIOSSL`, `X509`, `Crypto`
#### Step 3: Create CertificateManager.swift (Task #2)
Created `Sources/App/Proxy/CertificateManager.swift` — thread-safe `Sendable` class:
- **Init:** Loads CA from disk (`ca-cert.pem` + `ca-key.pem`) or generates new CA (P256/ECDSA, 10-year validity)
- **`serverTLSContext(for host:)`:** Generates leaf cert for host (P256, 30-day, SAN=host, signed by CA), creates `NIOSSLContext`, caches in `NIOLockedValueBox`
- **`caCertificatePEM()` / `caCertificateDER()`:** Export CA cert for client download/installation
- Uses `swift-certificates` (X509) for cert creation, bridges to NIOSSL via DER serialization
#### Step 4: Update ProxyChannelHandler.swift (Task #3)
Major rewrite of the channel handler:
- Added `import NIOSSL`
- Added MITM-mode properties: `fixedTargetHost: String?`, `fixedTargetPort: Int?`, `useUpstreamTLS: Bool`, `certManager: CertificateManager?`
- Added second init for MITM mode (sets fixed target + TLS flag)
- **CONNECT handling:** Detects `CONNECT` method in `channelRead`, calls `handleConnect()`
- **`handleConnect()`:** Sends `200 Connection Established`, then calls `upgradePipelineToMITM()`
- **`upgradePipelineToMITM()`:** Removes self + HTTP codecs from pipeline, adds `NIOSSLServerHandler` (with generated cert for host), re-adds HTTP codecs + new `ProxyChannelHandler` in MITM mode
- **`processRequest()`:** In MITM mode, uses fixed target instead of parsing URI; creates `NIOSSLClientHandler` for upstream TLS connection
- **`parseAuthority()`:** Updated to accept `defaultPort` parameter (443 for CONNECT, 80 for HTTP)
- URL logging now uses `https://` scheme when in MITM mode
#### Step 5: Update ProxyServer.swift (Task #4 part 1)
- Added `certManager: CertificateManager?` property
- Pass it to `ProxyChannelHandler` in `bindPort()`
#### Step 6: Update configure.swift (Task #4 part 2)
- Added `CertificateManagerKey` storage key + `Application.certificateManager` extension
- Creates `CertificateManager` on startup using `CA_CERT_DIR` env var (default: `data/ca`)
- Passes `certManager` to `ProxyServer` init
- Logs warning if `CertificateManager` fails to initialize (HTTPS MITM disabled gracefully)
#### Step 7: Update routes.swift (Task #4 part 3)
Added two new endpoints:
- `GET /ca/cert.pem` — Downloads CA cert in PEM format (`application/x-pem-file`)
- `GET /ca/cert.der` — Downloads CA cert in DER format (`application/x-x509-ca-cert`)
#### Step 8: Update Leaf template (Task #5 part 1)
Added CA certificate download card at top of `settings/index.leaf`:
```html
<div class="card mb-4 border-info">
<div class="card-body">
<h5>HTTPS MITM — CA Certificate</h5>
<p>To intercept HTTPS traffic, install the proxy's CA certificate...</p>
<a href="/ca/cert.pem">Download PEM</a>
<a href="/ca/cert.der">Download DER</a>
</div>
</div>
```
#### Step 9: Update Docker, env, gitignore (Task #5 part 2)
- `docker-compose.yml` — Added `ca-data:/app/data/ca` named volume, `CA_CERT_DIR` env var
- `.env` — Added `CA_CERT_DIR=data/ca`
- `.env.example` — Added `CA_CERT_DIR=/app/data/ca`
- `.gitignore` — Added `data/`
- `Dockerfile.release` — Added `RUN mkdir -p /app/data/ca` (after USER appuser)
#### Step 10: Build & Fix Errors (Task #6)
**Package resolution:** `swift package resolve` — resolved:
- swift-nio-ssl: 2.37.0
- swift-certificates: 1.19.0
- swift-crypto: 3.15.1
- swift-asn1: 1.7.0
**First build attempt:** 3 compilation errors
**Error 1: `SubjectKeyIdentifier` initializer**
```
error: cannot convert value of type 'SHA256.Digest' to expected argument type 'Certificate.PublicKey'
```
- **Location:** `CertificateManager.swift:55`
- **Bad:** `SubjectKeyIdentifier(hash: SHA256.hash(data: key.publicKey.derRepresentation))`
- **Fix:** `SubjectKeyIdentifier(hash: X509.Certificate.PublicKey(key.publicKey))`
- **Reason:** The `SubjectKeyIdentifier(hash:)` initializer expects a `Certificate.PublicKey` and performs SHA-1 hashing internally per RFC 5280 Section 4.2.1.2.
**Error 2: `Certificate.pemEncoded` does not exist**
```
error: value of type 'Certificate' has no member 'pemEncoded'
```
- **Location:** `CertificateManager.swift:78, 151`
- **Bad:** `cert.pemEncoded`
- **Fix:** `try cert.serializeAsPEM().pemString`
- **Reason:** In swift-certificates 1.x, `Certificate` conforms to `PEMSerializable` which provides `serializeAsPEM() -> PEMDocument`, and `PEMDocument` has `.pemString`.
**Error 3: Type inference failure (side effect of Error 2)**
```
error: cannot infer contextual base in reference to member '.utf8'
```
- **Location:** `CertificateManager.swift:81`
- **Fix:** Same as Error 2 — once `certPEM` was correctly a `String`, the `.write(toFile:atomically:encoding:)` call compiled.
**Also fixed:** Updated `routes.swift` to handle the now-throwing `caCertificatePEM()`.
**API investigation:** Used Agent tool to search `swift-certificates` source code at `.build/checkouts/swift-certificates/Sources/`:
- Found `SubjectKeyIdentifier.swift` — confirmed `init(hash: Certificate.PublicKey)` signature
- Found `Certificate.swift` — confirmed `PEMRepresentable` conformance with `serializeAsPEM()` method
- Found `PEMDocument.swift` in swift-asn1 — confirmed `.pemString` property
**Second build:** `Build complete! (4.83s)` — success.
---
## Verification Steps
1. `swift build`**Compiles successfully** (verified 2026-04-22)
2. `docker compose up --build`
3. Download CA cert: `curl -o ca.pem http://localhost:8080/ca/cert.pem`
4. Install CA cert in system/browser trust store
5. Create NetworkSetting with listenPort 9080 + HostRule for target host with desired headers
6. Test HTTP (existing): `curl -x http://localhost:9080 http://httpbin.org/headers`
7. Test HTTPS MITM: `curl --cacert ca.pem -x http://localhost:9080 https://httpbin.org/headers` — verify injected headers appear
8. Check `/proxy/logs` shows HTTPS entries
---
## Final Project Structure
```
ReadeckProxy/
├── Package.swift # Dependencies: Vapor, Fluent, Leaf, NIO, NIOSSL, X509, Crypto
├── Sources/App/
│ ├── entrypoint.swift # @main entry point
│ ├── configure.swift # DB, migrations, RulesCache, CertificateManager, ProxyServer setup
│ ├── routes.swift # Health, proxy logs, CA cert download, admin routes
│ ├── Controllers/
│ │ ├── AuthController.swift # Login/logout/setup
│ │ └── AdminController.swift # CRUD for NetworkSettings + HostRules
│ ├── Middleware/
│ │ └── AdminAuthMiddleware.swift # Session-based auth guard
│ ├── Models/
│ │ ├── User.swift
│ │ ├── NetworkSetting.swift
│ │ ├── HostRule.swift
│ │ └── ProxyLog.swift
│ ├── Migrations/
│ │ ├── CreateUser.swift
│ │ ├── CreateNetworkSetting.swift
│ │ ├── CreateHostRule.swift
│ │ ├── CreateProxyLog.swift
│ │ └── AddUpdatedAtToHostRule.swift
│ └── Proxy/
│ ├── RulesCache.swift # Thread-safe rule cache with wildcard matching
│ ├── ProxyServer.swift # NIO ServerBootstrap listener management
│ ├── ProxyChannelHandler.swift # HTTP/HTTPS proxy + MITM pipeline
│ ├── ProxyResponseRelayHandler # (in ProxyChannelHandler.swift) Response relay + logging
│ ├── CertificateManager.swift # CA generation, per-host cert signing, TLS contexts
│ └── ProxyLifecycleHandler.swift # Vapor lifecycle integration
├── Resources/Views/
│ ├── base.leaf
│ ├── login.leaf
│ └── settings/
│ ├── index.leaf # Network settings + CA cert download card
│ ├── show.leaf # Port detail + rules table
│ └── rules/
│ ├── create.leaf # Dynamic header form
│ └── edit.leaf # Edit with hidden values
├── docker-compose.yml # app + db + volumes (db_data, swift-build, ca-data)
├── docker/
│ ├── app/
│ │ ├── Dockerfile # Development with hot-reload
│ │ ├── Dockerfile.release # Production multi-stage
│ │ └── watch.sh # File watcher for dev
│ └── db/
│ └── Dockerfile # PostgreSQL 18.3
├── .env / .env.example # APP_PORT, PROXY_PORTS, CA_CERT_DIR, DB config
└── .gitignore # .build, .swiftpm, .env, data/
```
---
## Key API Endpoints
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | No | Health check |
| GET | `/proxy/logs` | No | Last 100 proxy logs (JSON) |
| GET | `/ca/cert.pem` | No | Download CA certificate (PEM) |
| GET | `/ca/cert.der` | No | Download CA certificate (DER) |
| 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 |
+11
View File
@@ -11,6 +11,10 @@ let package = Package(
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.8.0"),
.package(url: "https://github.com/vapor/leaf.git", from: "4.3.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.25.0"),
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"),
],
targets: [
.executableTarget(
@@ -20,6 +24,13 @@ let package = Package(
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
.product(name: "Leaf", package: "leaf"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOConcurrencyHelpers", package: "swift-nio"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
.testTarget(
+239 -87
View File
@@ -1,10 +1,12 @@
# ReadeckProxy
Customizable HTTP proxy service built with Swift and Vapor. Forwards HTTP requests to target URLs, returns responses, and logs all activity to PostgreSQL. Runs entirely in Docker.
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**
@@ -20,7 +22,8 @@ ReadeckProxy/
├── docker/
│ ├── app/
│ │ ├── Dockerfile # Development container (Swift 6.3)
│ │ ── Dockerfile.release # Production multi-stage build
│ │ ── Dockerfile.release # Production multi-stage build
│ │ └── watch.sh # File watcher for hot-reload
│ └── db/
│ └── Dockerfile # PostgreSQL 18.3 database container
├── scripts/
@@ -30,7 +33,6 @@ ReadeckProxy/
│ ├── configure.swift
│ ├── routes.swift
│ ├── Controllers/
│ │ ├── ProxyController.swift
│ │ ├── AuthController.swift
│ │ └── AdminController.swift
│ ├── Models/
@@ -46,8 +48,12 @@ ReadeckProxy/
│ │ └── AddUpdatedAtToHostRule.swift
│ ├── Middleware/
│ │ └── AdminAuthMiddleware.swift
│ └── DTOs/
── ProxyRequestDTO.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
@@ -109,15 +115,17 @@ http://localhost:8080/admin
On the first visit you will see the **Setup** page — create your admin account.
3. Test the proxy by forwarding a request:
3. Create a Network Setting with listen port `9080` and add a Host Rule for `httpbin.org` with header `X-Custom: test123`.
4. Test the HTTP proxy:
```bash
curl -X POST http://localhost:8080/proxy/forward \
-H "Content-Type: application/json" \
-d '{"method": "GET", "url": "https://httpbin.org/get"}'
curl -x http://localhost:9080 http://httpbin.org/headers
```
4. Check proxy logs:
You should see `X-Custom: test123` in the response headers.
5. Check proxy logs:
```bash
curl http://localhost:8080/proxy/logs
@@ -141,6 +149,193 @@ docker compose down
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:
1. Responds with `200 Connection Established`
2. Generates a TLS certificate for the target host (signed by the proxy's CA)
3. Terminates TLS from the client, decrypts the request
4. Matches rules and injects headers (same as HTTP)
5. Connects to the upstream server over TLS and forwards the request
6. 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
```bash
docker compose up --build
```
#### 2. Download the CA certificate
```bash
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):**
```bash
sudo security add-trusted-cert -d -r trustRoot \
-k /Library/Keychains/System.keychain readeck-proxy-ca.pem
```
**Linux (system-wide):**
```bash
sudo cp readeck-proxy-ca.pem /usr/local/share/ca-certificates/readeck-proxy-ca.crt
sudo update-ca-certificates
```
**Windows:**
```powershell
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`):
1. Create a Network Setting with listen port `9080`
2. Add a Host Rule for `httpbin.org` with header `Authorization: Bearer my-secret-token`
#### 5. Test HTTPS proxying
With the CA installed system-wide:
```bash
curl -x http://localhost:9080 https://httpbin.org/headers
```
Or using the CA cert file directly:
```bash
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
```bash
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:
```dockerfile
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:
```yaml
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:
```yaml
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:
```dockerfile
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.
@@ -157,15 +352,15 @@ Click on any entry to open its detail page.
### Host Rules
Each port mapping contains a list of **host rules**. When an internal service sends a request through the proxy:
Each port mapping contains a list of **host rules**. When a request comes through the proxy:
1. The proxy receives the request on the configured listen port
2. It inspects the destination host
3. If the host matches a rule, the configured headers are added or overwritten
4. The modified request is forwarded to the internet
4. The modified request is forwarded to the destination
Each rule defines:
- **Host Pattern** — the destination host to match (e.g. `api.example.com`). Protocol prefixes (`http://`, `https://`) and trailing slashes are stripped automatically.
- **Host Pattern** — the destination host to match (e.g. `api.example.com` or `*.example.com` for 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.
@@ -176,69 +371,23 @@ Static files placed in the `Public/` directory are served automatically via Vapo
## API Endpoints
### Health Check
```
GET /health
```
Returns `200 OK` if the service is running.
### Forward a Proxy Request
```
POST /proxy/forward
Content-Type: application/json
```
**Request body:**
| Field | Type | Required | Description |
|-----------|---------------------|----------|--------------------------------------|
| `method` | `String` | Yes | HTTP method (`GET`, `POST`, etc.) |
| `url` | `String` | Yes | Target URL to forward the request to |
| `headers` | `{String: String}` | No | Custom headers to include |
| `body` | `String` | No | Request body to forward |
**Example:**
```bash
curl -X POST http://localhost:8080/proxy/forward \
-H "Content-Type: application/json" \
-d '{
"method": "GET",
"url": "https://httpbin.org/get",
"headers": {
"Accept": "application/json"
}
}'
```
The response is the proxied target's response with its original status code, headers, and body.
### View Request Logs
```
GET /proxy/logs
```
Returns the last 100 proxied requests, sorted by most recent first.
**Response example:**
```json
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"method": "GET",
"url": "https://httpbin.org/get",
"status_code": 200,
"request_headers": {"Accept": "application/json"},
"response_size": 1234,
"created_at": "2026-04-19T20:00:00Z"
}
]
```
| 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
@@ -310,7 +459,7 @@ The Dockerfile pre-resolves dependencies during the image build. Docker layer ca
### Reset everything
Stop all containers and remove volumes (database data + build cache):
Stop all containers and remove volumes (database data + build cache + CA certificates):
```bash
docker compose down -v
@@ -352,20 +501,23 @@ docker login registry.kshaitry.com
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 (host and container)|
| `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 |
| 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. Seeded from pre-resolved image layer on first run. |
| `swift-build` | Caches `.build` directory for faster rebuilds |
| `ca-data` | Persists CA certificate and key between restarts |
## Database Schema
@@ -406,8 +558,8 @@ All tables are auto-migrated on startup.
|-------------------|------------|--------------------------------|
| `id` | `UUID` | Primary key |
| `method` | `String` | HTTP method used |
| `url` | `String` | Target URL |
| `url` | `String` | Target URL (http:// or https://)|
| `status_code` | `Int` | Response status code |
| `request_headers` | `JSON` | Headers sent with the request |
| `request_headers` | `JSON` | Injected headers |
| `response_size` | `Int` | Response body size in bytes |
| `created_at` | `DateTime` | Timestamp of the request |
+9
View File
@@ -1,5 +1,14 @@
#extend("base"):
#export("content"):
<div class="card mb-4 border-info">
<div class="card-body">
<h5 class="card-title">HTTPS MITM — CA Certificate</h5>
<p class="card-text">To intercept HTTPS traffic, install the proxy's CA certificate in your browser or system trust store.</p>
<a href="/ca/cert.pem" class="btn btn-outline-info btn-sm me-2">Download PEM</a>
<a href="/ca/cert.der" class="btn btn-outline-info btn-sm">Download DER</a>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Network Settings</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createModal">
+2 -2
View File
@@ -41,8 +41,8 @@
const row = document.createElement('div');
row.className = 'input-group mb-2 header-row';
row.innerHTML = `
<input type="text" class="form-control header-name" placeholder="Header name" value="${escapeHtml(name)}" ${nameReadonly ? 'readonly' : ''}>
<input type="text" class="form-control header-value" placeholder="Header value" value="${escapeHtml(value)}">
<input type="text" class="form-control header-name" placeholder="Header name" value="${escapeHtml(name)}" autocomplete="off" ${nameReadonly ? 'readonly' : ''}>
<input type="text" class="form-control header-value" placeholder="Header value" value="${escapeHtml(value)}" autocomplete="off">
<button type="button" class="btn btn-outline-danger remove-header-btn"><i class="bi bi-trash"></i></button>
`;
row.querySelector('.remove-header-btn').addEventListener('click', () => row.remove());
+2 -2
View File
@@ -45,8 +45,8 @@
const row = document.createElement('div');
row.className = 'input-group mb-2 header-row';
row.innerHTML = `
<input type="text" class="form-control header-name" placeholder="Header name" value="${escapeHtml(name)}" ${nameReadonly ? 'readonly' : ''}>
<input type="text" class="form-control header-value" placeholder="${nameReadonly ? 'Enter new value' : 'Header value'}" value="${escapeHtml(value)}">
<input type="text" class="form-control header-name" placeholder="Header name" value="${escapeHtml(name)}" autocomplete="off" ${nameReadonly ? 'readonly' : ''}>
<input type="text" class="form-control header-value" placeholder="${nameReadonly ? 'Enter new value' : 'Header value'}" value="${escapeHtml(value)}" autocomplete="off">
<button type="button" class="btn btn-outline-danger remove-header-btn"><i class="bi bi-trash"></i></button>
`;
row.querySelector('.remove-header-btn').addEventListener('click', () => row.remove());
@@ -92,6 +92,16 @@ private func formatDate(_ date: Date?) -> String? {
struct AdminController: Sendable {
private func reloadProxy(req: Request) async throws {
let app = req.application
let rulesCache = app.rulesCache
let proxyServer = app.proxyServer
await proxyServer.shutdown()
try await rulesCache.reload(from: req.db)
try await proxyServer.start()
req.logger.info("Proxy reloaded")
}
func index(req: Request) async throws -> View {
let settings = try await NetworkSetting.query(on: req.db)
.with(\.$rules)
@@ -119,6 +129,7 @@ struct AdminController: Sendable {
let input = try req.content.decode(CreateSettingInput.self)
let setting = NetworkSetting(name: input.name, listenPort: input.listenPort)
try await setting.save(on: req.db)
try await reloadProxy(req: req)
return req.redirect(to: "/admin/settings")
}
@@ -159,6 +170,7 @@ struct AdminController: Sendable {
throw Abort(.notFound)
}
try await setting.delete(on: req.db)
try await reloadProxy(req: req)
return req.redirect(to: "/admin/settings")
}
@@ -196,6 +208,7 @@ struct AdminController: Sendable {
headers: headers
)
try await rule.save(on: req.db)
try await reloadProxy(req: req)
return req.redirect(to: "/admin/settings/\(setting.id!)")
}
@@ -251,6 +264,7 @@ struct AdminController: Sendable {
rule.hostPattern = normalizeHost(input.hostPattern)
rule.headers = headers
try await rule.save(on: req.db)
try await reloadProxy(req: req)
return req.redirect(to: "/admin/settings/\(settingId)")
}
@@ -267,6 +281,7 @@ struct AdminController: Sendable {
}
try await rule.delete(on: req.db)
try await reloadProxy(req: req)
return req.redirect(to: "/admin/settings/\(settingId)")
}
}
@@ -1,52 +0,0 @@
import Vapor
import Fluent
struct ProxyController: Sendable {
func forward(req: Request) async throws -> Response {
let input = try req.content.decode(ProxyRequestDTO.self)
let method = HTTPMethod(rawValue: input.method.uppercased())
let uri = URI(string: input.url)
let clientResponse = try await req.client.send(method, to: uri) { clientReq in
if let headers = input.headers {
for (key, value) in headers {
clientReq.headers.add(name: key, value: value)
}
}
if let body = input.body {
clientReq.body = ByteBuffer(string: body)
}
}
let log = ProxyLog(
method: input.method.uppercased(),
url: input.url,
statusCode: Int(clientResponse.status.code),
requestHeaders: input.headers,
responseSize: clientResponse.body?.readableBytes
)
try await log.save(on: req.db)
let responseBody: Response.Body
if let buffer = clientResponse.body {
responseBody = .init(buffer: buffer)
} else {
responseBody = .empty
}
return Response(
status: clientResponse.status,
headers: clientResponse.headers,
body: responseBody
)
}
func logs(req: Request) async throws -> [ProxyLog] {
try await ProxyLog.query(on: req.db)
.sort(\.$createdAt, .descending)
.limit(100)
.all()
}
}
-8
View File
@@ -1,8 +0,0 @@
import Vapor
struct ProxyRequestDTO: Content, Sendable {
let method: String
let url: String
let headers: [String: String]?
let body: String?
}
+158
View File
@@ -0,0 +1,158 @@
import Foundation
import NIOSSL
import NIOConcurrencyHelpers
@preconcurrency import Crypto
import X509
import SwiftASN1
import Logging
final class CertificateManager: @unchecked Sendable {
private let caKey: P256.Signing.PrivateKey
private let caCert: X509.Certificate
private let caDirectory: String
private let logger: Logger
// Cache: hostname -> NIOSSLContext
private let cache = NIOLockedValueBox<[String: NIOSSLContext]>([:])
init(caDirectory: String, logger: Logger) throws {
self.caDirectory = caDirectory
self.logger = logger
let fm = FileManager.default
if !fm.fileExists(atPath: caDirectory) {
try fm.createDirectory(atPath: caDirectory, withIntermediateDirectories: true)
}
let certPath = (caDirectory as NSString).appendingPathComponent("ca-cert.pem")
let keyPath = (caDirectory as NSString).appendingPathComponent("ca-key.pem")
if fm.fileExists(atPath: certPath) && fm.fileExists(atPath: keyPath) {
// Load existing CA
logger.info("Loading existing CA certificate from \(caDirectory)")
let keyPEM = try String(contentsOfFile: keyPath, encoding: .utf8)
let certPEM = try String(contentsOfFile: certPath, encoding: .utf8)
self.caKey = try P256.Signing.PrivateKey(pemRepresentation: keyPEM)
self.caCert = try X509.Certificate(pemEncoded: certPEM)
} else {
// Generate new CA
logger.info("Generating new CA certificate in \(caDirectory)")
let key = P256.Signing.PrivateKey()
let now = Date()
let tenYears = now.addingTimeInterval(10 * 365.25 * 24 * 3600)
let subject = try DistinguishedName {
CommonName("ReadeckProxy CA")
OrganizationName("ReadeckProxy")
}
let extensions = try X509.Certificate.Extensions {
Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0))
Critical(KeyUsage(keyCertSign: true, cRLSign: true))
SubjectKeyIdentifier(
hash: X509.Certificate.PublicKey(key.publicKey)
)
}
let cert = try X509.Certificate(
version: .v3,
serialNumber: .init(),
publicKey: .init(key.publicKey),
notValidBefore: now,
notValidAfter: tenYears,
issuer: subject,
subject: subject,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: .init(key)
)
self.caKey = key
self.caCert = cert
// Persist to disk
let certPEM = try cert.serializeAsPEM().pemString
let keyPEM = key.pemRepresentation
try certPEM.write(toFile: certPath, atomically: true, encoding: .utf8)
try keyPEM.write(toFile: keyPath, atomically: true, encoding: .utf8)
logger.info("CA certificate saved to \(certPath)")
}
}
/// Returns an NIOSSLContext configured with a leaf cert for the given host, signed by our CA.
func serverTLSContext(for host: String) throws -> NIOSSLContext {
// Check cache first
if let cached = cache.withLockedValue({ $0[host] }) {
return cached
}
let leafKey = P256.Signing.PrivateKey()
let now = Date()
let thirtyDays = now.addingTimeInterval(30 * 24 * 3600)
let subject = try DistinguishedName {
CommonName(host)
}
let issuer = caCert.subject
let extensions = try X509.Certificate.Extensions {
Critical(KeyUsage(digitalSignature: true))
SubjectAlternativeNames([.dnsName(host)])
}
let leafCert = try X509.Certificate(
version: .v3,
serialNumber: .init(),
publicKey: .init(leafKey.publicKey),
notValidBefore: now,
notValidAfter: thirtyDays,
issuer: issuer,
subject: subject,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: .init(caKey)
)
// Serialize to DER for NIOSSL
var certSerializer = DER.Serializer()
try leafCert.serialize(into: &certSerializer)
let leafCertDER = certSerializer.serializedBytes
var caSerializer = DER.Serializer()
try caCert.serialize(into: &caSerializer)
let caCertDER = caSerializer.serializedBytes
let sslCert = try NIOSSLCertificate(bytes: leafCertDER, format: .der)
let caSslCert = try NIOSSLCertificate(bytes: caCertDER, format: .der)
let sslKey = try NIOSSLPrivateKey(bytes: Array(leafKey.derRepresentation), format: .der)
var tlsConfig = TLSConfiguration.makeServerConfiguration(
certificateChain: [.certificate(sslCert), .certificate(caSslCert)],
privateKey: .privateKey(sslKey)
)
tlsConfig.minimumTLSVersion = .tlsv12
let context = try NIOSSLContext(configuration: tlsConfig)
// Cache it
cache.withLockedValue { $0[host] = context }
return context
}
/// Returns the CA certificate in PEM format for client download/installation.
func caCertificatePEM() throws -> String {
return try caCert.serializeAsPEM().pemString
}
/// Returns the CA certificate in DER format for client download/installation.
func caCertificateDER() throws -> [UInt8] {
var serializer = DER.Serializer()
try caCert.serialize(into: &serializer)
return serializer.serializedBytes
}
}
+377
View File
@@ -0,0 +1,377 @@
@preconcurrency import NIOCore
import NIOPosix
@preconcurrency import NIOHTTP1
@preconcurrency import NIOSSL
import Logging
import Fluent
final class ProxyChannelHandler: ChannelInboundHandler, RemovableChannelHandler, @unchecked Sendable {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart
private let port: Int
private let rulesCache: RulesCache
private let db: any Database
private let logger: Logger
private let certManager: CertificateManager?
// MITM mode properties (set when handler is re-added after CONNECT)
private let fixedTargetHost: String?
private let fixedTargetPort: Int?
private let useUpstreamTLS: Bool
private var requestHead: HTTPRequestHead?
private var requestBody = ByteBuffer()
/// Standard mode init used for initial client connections.
init(port: Int, rulesCache: RulesCache, db: any Database, logger: Logger, certManager: CertificateManager?) {
self.port = port
self.rulesCache = rulesCache
self.db = db
self.logger = logger
self.certManager = certManager
self.fixedTargetHost = nil
self.fixedTargetPort = nil
self.useUpstreamTLS = false
}
/// MITM mode init used after CONNECT tunnel is established.
init(port: Int, rulesCache: RulesCache, db: any Database, logger: Logger, certManager: CertificateManager?,
targetHost: String, targetPort: Int) {
self.port = port
self.rulesCache = rulesCache
self.db = db
self.logger = logger
self.certManager = certManager
self.fixedTargetHost = targetHost
self.fixedTargetPort = targetPort
self.useUpstreamTLS = true
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)
switch part {
case .head(let head):
if head.method == .CONNECT {
handleConnect(context: context, head: head)
return
}
requestHead = head
requestBody.clear()
case .body(var buf):
requestBody.writeBuffer(&buf)
case .end:
guard let head = requestHead else {
sendError(context: context, status: .badRequest, message: "Missing request head")
return
}
processRequest(context: context, head: head, body: requestBody)
requestHead = nil
requestBody.clear()
}
}
// MARK: - CONNECT / MITM
private func handleConnect(context: ChannelHandlerContext, head: HTTPRequestHead) {
let (host, connectPort) = parseAuthority(head.uri, defaultPort: 443)
guard let certManager = certManager else {
logger.warning("CONNECT received but no CertificateManager configured; rejecting")
sendError(context: context, status: .badGateway, message: "HTTPS MITM not configured")
return
}
let sslContext: NIOSSLContext
do {
sslContext = try certManager.serverTLSContext(for: host)
} catch {
logger.error("Failed to create TLS context for \(host): \(error)")
sendError(context: context, status: .internalServerError, message: "TLS certificate generation failed")
return
}
// Send 200 Connection Established, then upgrade the pipeline
let responseHead = HTTPResponseHead(version: .http1_1, status: .ok)
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
let flushFuture = context.writeAndFlush(wrapOutboundOut(.end(nil)))
nonisolated(unsafe) let ctx = context
flushFuture.whenSuccess { [self] in
self.upgradePipelineToMITM(context: ctx, host: host, port: connectPort, sslContext: sslContext)
}
}
private func upgradePipelineToMITM(context: ChannelHandlerContext, host: String, port: Int, sslContext: NIOSSLContext) {
let pipeline = context.pipeline
do {
let sync = pipeline.syncOperations
// Remove self (ProxyChannelHandler) first
_ = sync.removeHandler(self)
// Remove HTTP server handlers ignore errors if already removed
if let encoder = try? sync.handler(type: HTTPResponseEncoder.self) {
_ = sync.removeHandler(encoder)
}
if let decoder = try? sync.handler(type: ByteToMessageHandler<HTTPRequestDecoder>.self) {
_ = sync.removeHandler(decoder)
}
if let pipeliner = try? sync.handler(type: HTTPServerPipelineHandler.self) {
_ = sync.removeHandler(pipeliner)
}
// Add TLS server handler
let sslHandler = NIOSSLServerHandler(context: sslContext)
try sync.addHandler(sslHandler)
// Re-add HTTP server codecs + new MITM ProxyChannelHandler
// configureHTTPServerPipeline is async-only, so use the future API for this last part
nonisolated(unsafe) let ctx = context
pipeline.configureHTTPServerPipeline().flatMap {
pipeline.addHandler(
ProxyChannelHandler(
port: self.port,
rulesCache: self.rulesCache,
db: self.db,
logger: self.logger,
certManager: self.certManager,
targetHost: host,
targetPort: port
)
)
}.whenFailure { error in
self.logger.error("Failed to configure MITM HTTP pipeline for \(host): \(error)")
ctx.close(promise: nil)
}
} catch {
logger.error("Failed to set up MITM pipeline for \(host): \(error)")
context.close(promise: nil)
}
}
// MARK: - Request Processing
private func processRequest(context: ChannelHandlerContext, head: HTTPRequestHead, body: ByteBuffer) {
let host: String
let destPort: Int
let path: String
if let fixedHost = fixedTargetHost, let fixedPort = fixedTargetPort {
// MITM mode target is fixed from CONNECT
host = fixedHost
destPort = fixedPort
path = head.uri // Already relative in MITM mode
} else {
// Standard proxy mode
guard let parsed = parseDestination(head: head) else {
sendError(context: context, status: .badRequest, message: "Cannot determine destination host")
return
}
host = parsed.host
destPort = parsed.port
path = parsed.path
}
var modifiedHead = head
modifiedHead.uri = path
// Look up matching rules and inject headers
let matchedRules = rulesCache.matchingRules(port: self.port, host: host)
var injectedHeaders: [String: String] = [:]
for rule in matchedRules {
for (name, value) in rule.headers {
modifiedHead.headers.replaceOrAdd(name: name, value: value)
injectedHeaders[name] = value
}
}
// Ensure Host header is set
if !modifiedHead.headers.contains(name: "Host") {
modifiedHead.headers.add(name: "Host", value: host)
}
// Remove proxy-specific headers
modifiedHead.headers.remove(name: "Proxy-Connection")
let method = String(describing: head.method)
let scheme = useUpstreamTLS ? "https" : "http"
let url = "\(scheme)://\(host):\(destPort)\(path)"
let db = self.db
let logger = self.logger
let clientChannel = context.channel
let finalInjectedHeaders = injectedHeaders.isEmpty ? nil : injectedHeaders
let finalHead = modifiedHead
let connectWithTLS = self.useUpstreamTLS
// Connect to destination
let bootstrap = ClientBootstrap(group: context.eventLoop)
.channelInitializer { channel in
if connectWithTLS {
do {
var tlsConfig = TLSConfiguration.makeClientConfiguration()
tlsConfig.certificateVerification = .none
let sslContext = try NIOSSLContext(configuration: tlsConfig)
let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: host)
try channel.pipeline.syncOperations.addHandler(sslHandler)
} catch {
return channel.eventLoop.makeFailedFuture(error)
}
}
return channel.pipeline.addHTTPClientHandlers().flatMap {
channel.pipeline.addHandler(
ProxyResponseRelayHandler(
clientChannel: clientChannel,
method: method,
url: url,
injectedHeaders: finalInjectedHeaders,
db: db,
logger: logger
)
)
}
}
nonisolated(unsafe) let ctx = context
bootstrap.connect(host: host, port: destPort).whenComplete { [self] result in
switch result {
case .success(let upstreamChannel):
// Forward the request to the destination
upstreamChannel.write(HTTPClientRequestPart.head(finalHead), promise: nil)
if body.readableBytes > 0 {
upstreamChannel.write(HTTPClientRequestPart.body(.byteBuffer(body)), promise: nil)
}
upstreamChannel.writeAndFlush(HTTPClientRequestPart.end(nil), promise: nil)
case .failure(let error):
logger.error("Failed to connect to \(host):\(destPort): \(error)")
self.sendError(context: ctx, status: .badGateway, message: "Failed to connect to upstream: \(error)")
}
}
}
// MARK: - Destination Parsing
private func parseDestination(head: HTTPRequestHead) -> (host: String, port: Int, path: String)? {
// Absolute URI form: GET http://host:port/path HTTP/1.1
if head.uri.lowercased().hasPrefix("http://") {
let withoutScheme = String(head.uri.dropFirst(7))
let slashIndex = withoutScheme.firstIndex(of: "/") ?? withoutScheme.endIndex
let authority = String(withoutScheme[..<slashIndex])
let path = slashIndex < withoutScheme.endIndex ? String(withoutScheme[slashIndex...]) : "/"
let (host, port) = parseAuthority(authority, defaultPort: 80)
return (host, port, path)
}
// Relative URI use Host header
if let hostHeader = head.headers.first(name: "Host") {
let (host, port) = parseAuthority(hostHeader, defaultPort: 80)
return (host, port, head.uri)
}
return nil
}
private func parseAuthority(_ authority: String, defaultPort: Int) -> (host: String, port: Int) {
if let colonIndex = authority.lastIndex(of: ":") {
let host = String(authority[..<colonIndex])
let portStr = String(authority[authority.index(after: colonIndex)...])
let port = Int(portStr) ?? defaultPort
return (host, port)
}
return (authority, defaultPort)
}
// MARK: - Helpers
private func sendError(context: ChannelHandlerContext, status: HTTPResponseStatus, message: String) {
var headers = HTTPHeaders()
let body = ByteBuffer(string: message)
headers.add(name: "Content-Type", value: "text/plain")
headers.add(name: "Content-Length", value: "\(body.readableBytes)")
headers.add(name: "Connection", value: "close")
let head = HTTPResponseHead(version: .http1_1, status: status, headers: headers)
context.write(wrapOutboundOut(.head(head)), promise: nil)
context.write(wrapOutboundOut(.body(.byteBuffer(body))), promise: nil)
nonisolated(unsafe) let ctx = context
context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in
ctx.close(promise: nil)
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
logger.error("ProxyChannelHandler error: \(error)")
context.close(promise: nil)
}
}
// MARK: - Response Relay Handler
final class ProxyResponseRelayHandler: ChannelInboundHandler, RemovableChannelHandler, @unchecked Sendable {
typealias InboundIn = HTTPClientResponsePart
typealias OutboundOut = HTTPClientRequestPart
private let clientChannel: Channel
private let method: String
private let url: String
private let injectedHeaders: [String: String]?
private let db: any Database
private let logger: Logger
private var responseHead: HTTPResponseHead?
private var responseSize: Int = 0
init(clientChannel: Channel, method: String, url: String, injectedHeaders: [String: String]?, db: any Database, logger: Logger) {
self.clientChannel = clientChannel
self.method = method
self.url = url
self.injectedHeaders = injectedHeaders
self.db = db
self.logger = logger
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let part = unwrapInboundIn(data)
switch part {
case .head(let head):
responseHead = head
let clientHead = HTTPResponseHead(version: .http1_1, status: head.status, headers: head.headers)
clientChannel.write(HTTPServerResponsePart.head(clientHead), promise: nil)
case .body(let buf):
responseSize += buf.readableBytes
clientChannel.write(HTTPServerResponsePart.body(.byteBuffer(buf)), promise: nil)
case .end:
clientChannel.writeAndFlush(HTTPServerResponsePart.end(nil)).whenComplete { _ in
self.clientChannel.close(promise: nil)
}
// Log to DB
let statusCode = Int(responseHead?.status.code ?? 0)
let log = ProxyLog(
method: method,
url: url,
statusCode: statusCode,
requestHeaders: injectedHeaders,
responseSize: responseSize
)
let db = self.db
let logger = self.logger
Task {
do {
try await log.save(on: db)
} catch {
logger.error("Failed to save proxy log: \(error)")
}
}
// Close upstream connection
context.close(promise: nil)
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
logger.error("ProxyResponseRelayHandler error: \(error)")
clientChannel.close(promise: nil)
context.close(promise: nil)
}
}
@@ -0,0 +1,18 @@
import Vapor
struct ProxyLifecycleHandler: LifecycleHandler {
func didBootAsync(_ app: Application) async throws {
let rulesCache = app.rulesCache
let proxyServer = app.proxyServer
try await rulesCache.reload(from: app.db)
try await proxyServer.start()
app.logger.info("Proxy server started")
}
func shutdownAsync(_ app: Application) async {
await app.proxyServer.shutdown()
app.logger.info("Proxy server stopped")
}
}
+84
View File
@@ -0,0 +1,84 @@
import NIOCore
import NIOPosix
import NIOHTTP1
import NIOConcurrencyHelpers
import Logging
import Fluent
final class ProxyServer: Sendable {
private let group: EventLoopGroup
private let rulesCache: RulesCache
private let db: any Database
private let logger: Logger
private let certManager: CertificateManager?
private let channels = NIOLockedValueBox<[Int: Channel]>([:])
init(group: EventLoopGroup, rulesCache: RulesCache, db: any Database, logger: Logger, certManager: CertificateManager? = nil) {
self.group = group
self.rulesCache = rulesCache
self.db = db
self.logger = logger
self.certManager = certManager
}
func start() async throws {
let ports = rulesCache.configuredPorts()
guard !ports.isEmpty else {
logger.info("No proxy ports configured")
return
}
for port in ports {
do {
let channel = try await bindPort(port)
channels.withLockedValue { $0[port] = channel }
logger.info("Proxy listening on port \(port)")
} catch {
logger.error("Failed to bind proxy on port \(port): \(error)")
}
}
}
func shutdown() async {
let currentChannels = channels.withLockedValue { chans in
let copy = chans
chans.removeAll()
return copy
}
for (port, channel) in currentChannels {
do {
try await channel.close()
logger.info("Proxy listener on port \(port) closed")
} catch {
logger.error("Error closing proxy on port \(port): \(error)")
}
}
}
private func bindPort(_ port: Int) async throws -> Channel {
let rulesCache = self.rulesCache
let db = self.db
let logger = self.logger
let certManager = self.certManager
let bootstrap = ServerBootstrap(group: group)
.serverChannelOption(.backlog, value: 256)
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)
.childChannelInitializer { channel in
channel.pipeline.configureHTTPServerPipeline().flatMap {
channel.pipeline.addHandler(
ProxyChannelHandler(
port: port,
rulesCache: rulesCache,
db: db,
logger: logger,
certManager: certManager
)
)
}
}
.childChannelOption(.socketOption(.so_reuseaddr), value: 1)
return try await bootstrap.bind(host: "0.0.0.0", port: port).get()
}
}
+46
View File
@@ -0,0 +1,46 @@
import NIOConcurrencyHelpers
import Fluent
struct CachedRule: Sendable {
let hostPattern: String
let headers: [String: String]
}
final class RulesCache: Sendable {
private let storage = NIOLockedValueBox<[Int: [CachedRule]]>([:])
func reload(from db: any Database) async throws {
let settings = try await NetworkSetting.query(on: db)
.with(\.$rules)
.all()
var newCache: [Int: [CachedRule]] = [:]
for setting in settings {
let rules = setting.rules.map { rule in
CachedRule(hostPattern: rule.hostPattern, headers: rule.headers)
}
let existing = newCache[setting.listenPort, default: []]
newCache[setting.listenPort] = existing + rules
}
storage.withLockedValue { $0 = newCache }
}
func matchingRules(port: Int, host: String) -> [CachedRule] {
let rules = storage.withLockedValue { $0[port] ?? [] }
let normalizedHost = host.lowercased()
return rules.filter { matchesHost(pattern: $0.hostPattern.lowercased(), host: normalizedHost) }
}
func configuredPorts() -> [Int] {
storage.withLockedValue { Array($0.keys) }
}
private func matchesHost(pattern: String, host: String) -> Bool {
if pattern == host { return true }
if pattern.hasPrefix("*.") {
let suffix = String(pattern.dropFirst(1)) // ".domain.com"
return host.hasSuffix(suffix) || host == String(pattern.dropFirst(2))
}
return false
}
}
+66
View File
@@ -3,6 +3,47 @@ import Fluent
import FluentPostgresDriver
import Leaf
// MARK: - Storage Keys
private struct RulesCacheKey: StorageKey {
typealias Value = RulesCache
}
private struct ProxyServerKey: StorageKey {
typealias Value = ProxyServer
}
private struct CertificateManagerKey: StorageKey {
typealias Value = CertificateManager
}
extension Application {
var rulesCache: RulesCache {
get {
guard let cache = storage[RulesCacheKey.self] else {
fatalError("RulesCache not configured. Call configure() first.")
}
return cache
}
set { storage[RulesCacheKey.self] = newValue }
}
var proxyServer: ProxyServer {
get {
guard let server = storage[ProxyServerKey.self] else {
fatalError("ProxyServer not configured. Call configure() first.")
}
return server
}
set { storage[ProxyServerKey.self] = newValue }
}
var certificateManager: CertificateManager? {
get { storage[CertificateManagerKey.self] }
set { storage[CertificateManagerKey.self] = newValue }
}
}
func configure(_ app: Application) async throws {
app.views.use(.leaf)
@@ -30,5 +71,30 @@ func configure(_ app: Application) async throws {
try await app.autoMigrate()
// Set up certificate manager for HTTPS MITM
let caDir = Environment.get("CA_CERT_DIR") ?? "data/ca"
var certManager: CertificateManager? = nil
do {
certManager = try CertificateManager(caDirectory: caDir, logger: app.logger)
app.certificateManager = certManager
app.logger.info("HTTPS MITM support enabled (CA dir: \(caDir))")
} catch {
app.logger.error("Failed to initialize CertificateManager: \(error). HTTPS MITM disabled.")
}
// Set up proxy infrastructure
let rulesCache = RulesCache()
let proxyServer = ProxyServer(
group: app.eventLoopGroup,
rulesCache: rulesCache,
db: app.db,
logger: app.logger,
certManager: certManager
)
app.rulesCache = rulesCache
app.proxyServer = proxyServer
app.lifecycle.use(ProxyLifecycleHandler())
try routes(app)
}
+30 -5
View File
@@ -1,15 +1,40 @@
import Vapor
import Fluent
func routes(_ app: Application) throws {
let proxyController = ProxyController()
app.get("health") { req async in
HTTPStatus.ok
}
let proxyGroup = app.grouped("proxy")
proxyGroup.post("forward", use: proxyController.forward)
proxyGroup.get("logs", use: proxyController.logs)
app.get("proxy", "logs") { req async throws -> [ProxyLog] in
try await ProxyLog.query(on: req.db)
.sort(\.$createdAt, .descending)
.limit(100)
.all()
}
// CA certificate download endpoints
app.get("ca", "cert.pem") { req -> Response in
guard let certManager = req.application.certificateManager else {
throw Abort(.notFound, reason: "CA certificate not available")
}
let pem = try certManager.caCertificatePEM()
var headers = HTTPHeaders()
headers.add(name: "Content-Type", value: "application/x-pem-file")
headers.add(name: "Content-Disposition", value: "attachment; filename=\"readeck-proxy-ca.pem\"")
return Response(status: .ok, headers: headers, body: .init(string: pem))
}
app.get("ca", "cert.der") { req -> Response in
guard let certManager = req.application.certificateManager else {
throw Abort(.notFound, reason: "CA certificate not available")
}
let derBytes = try certManager.caCertificateDER()
var headers = HTTPHeaders()
headers.add(name: "Content-Type", value: "application/x-x509-ca-cert")
headers.add(name: "Content-Disposition", value: "attachment; filename=\"readeck-proxy-ca.der\"")
return Response(status: .ok, headers: headers, body: .init(data: Data(derBytes)))
}
// Admin panel
let authController = AuthController()
+4
View File
@@ -6,8 +6,10 @@ services:
volumes:
- .:/app
- swift-build:/app/.build
- ca-data:/app/data/ca
ports:
- "${APP_PORT}:${APP_PORT}"
- "${PROXY_PORTS}:${PROXY_PORTS}"
depends_on:
db:
condition: service_healthy
@@ -18,6 +20,7 @@ services:
DATABASE_USERNAME: ${POSTGRES_USER}
DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
DATABASE_NAME: ${POSTGRES_DB}
CA_CERT_DIR: /app/data/ca
db:
build:
@@ -40,3 +43,4 @@ services:
volumes:
db_data:
swift-build:
ca-data:
+5 -1
View File
@@ -2,6 +2,7 @@ FROM registry-group.kshaitry.com/library/swift:6.3
RUN apt-get update && apt-get install -y \
libcurl4-openssl-dev \
inotify-tools \
&& rm -rf /var/lib/apt/lists/*
# Pre-resolve dependencies (cached in Docker image layer)
@@ -11,6 +12,9 @@ RUN swift package resolve
WORKDIR /app
COPY docker/app/watch.sh /usr/local/bin/watch.sh
RUN chmod +x /usr/local/bin/watch.sh
EXPOSE ${APP_PORT:-8080}
CMD sh -c 'if [ ! -d .build/repositories ]; then cp -a /pkg-cache/.build/. .build/; fi && swift run App serve --env development --hostname 0.0.0.0 --port ${APP_PORT:-8080}'
CMD sh -c 'if [ ! -d .build/repositories ]; then cp -a /pkg-cache/.build/. .build/; fi && /usr/local/bin/watch.sh'
+2
View File
@@ -26,6 +26,8 @@ COPY --from=builder /build/.build/release/App .
COPY --from=builder /build/Resources/ Resources/
COPY --from=builder /build/Public/ Public/
RUN mkdir -p /app/data/ca
EXPOSE 8080
CMD ["./App", "serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
set -e
APP_PID=""
build_and_run() {
echo "==> Building..."
if swift build 2>&1; then
echo "==> Build succeeded, starting app..."
if [ -n "$APP_PID" ] && kill -0 "$APP_PID" 2>/dev/null; then
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
fi
swift run App serve --env development --hostname 0.0.0.0 --port "${APP_PORT:-8080}" &
APP_PID=$!
echo "==> App running (PID: $APP_PID)"
else
echo "==> Build failed, keeping previous version running"
fi
}
cleanup() {
echo "==> Shutting down..."
if [ -n "$APP_PID" ]; then
kill "$APP_PID" 2>/dev/null || true
wait "$APP_PID" 2>/dev/null || true
fi
exit 0
}
trap cleanup SIGTERM SIGINT
# Initial build
build_and_run
# Watch for changes in Sources and Resources
echo "==> Watching for file changes..."
while true; do
inotifywait -r -e modify,create,delete,move \
--include '\.(swift|leaf)$' \
Sources/ Resources/ Package.swift 2>/dev/null
echo "==> Change detected, rebuilding..."
sleep 1 # debounce rapid saves
build_and_run
done