Certificate greneration
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Application
|
||||
APP_PORT=8080
|
||||
PROXY_PORTS=9080
|
||||
CA_CERT_DIR=/app/data/ca
|
||||
|
||||
# Database
|
||||
POSTGRES_USER=readeck
|
||||
|
||||
@@ -7,3 +7,4 @@ Package.resolved
|
||||
*.xcworkspace/
|
||||
DerivedData/
|
||||
.env
|
||||
data/
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Vapor
|
||||
|
||||
struct ProxyRequestDTO: Content, Sendable {
|
||||
let method: String
|
||||
let url: String
|
||||
let headers: [String: String]?
|
||||
let body: String?
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user