first commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
2303
Cargo.lock
generated
Normal file
2303
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
Cargo.toml
Normal file
28
Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "server18004"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "server18004"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.8.9"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
fast_qr = { version = "0.13.1", features = ["image"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.149"
|
||||||
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
|
toml = "1.1.2"
|
||||||
|
tracing = "0.1.44"
|
||||||
|
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||||
|
png = "0.17.16"
|
||||||
|
ravif = "0.11.11"
|
||||||
|
flate2 = "1.0.35"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
http-body-util = "0.1"
|
||||||
|
tempfile = "3"
|
||||||
272
README.md
Normal file
272
README.md
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
# server18004 🚀
|
||||||
|
|
||||||
|
A high-performance, production-ready QR code generation server written in Rust. It utilizes the [Axum](https://github.com/tokio-rs/axum) web framework and is highly optimized for maximum execution speed, minimal memory footprint, and low-latency rendering.
|
||||||
|
|
||||||
|
The server leverages a multi-port paradigm to separate public restricted QR rendering from internal/unrestricted administration and generation APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌟 Key Features
|
||||||
|
|
||||||
|
- **Multi-Port Isolation**:
|
||||||
|
- **Port 4081 (SVG)**: Restricted vector QR generation via catch-all/fallback handler.
|
||||||
|
- **Port 4082 (PNG)**: Restricted high-speed raster QR generation.
|
||||||
|
- **Port 4084 (AVIF)**: Restricted high-speed modern AVIF QR generation.
|
||||||
|
- **Port 4083 (API)**: Unrestricted administration API for domain management and custom generation.
|
||||||
|
- **High-Performance Graphic Encoders**:
|
||||||
|
- **PNG Generator**: Features custom 4x manual bit-multiplication scaling to a 1-bit Grayscale raster buffer, completely bypassing expensive image scaling filters. Leverages the standard `png` crate with `Compression::Fast` and `FilterType::NoFilter` for low-latency output.
|
||||||
|
- **AVIF Generator**: Leverages the speed of the `ravif` encoder operating in Speed 10 (Fastest) mode with Lossless quality, backed by a custom 4x RGBA scaling loop.
|
||||||
|
- **Smart Reverse-Proxy Support**:
|
||||||
|
- Resolves client hostnames by inspecting `X-Forwarded-Host`, `X-Real-Host`, and standard `Host` headers. Compatible with Nginx, HAProxy, and Apache reverse-proxy setups.
|
||||||
|
- Detects request schema (HTTP vs HTTPS) using the `X-Forwarded-Proto` header.
|
||||||
|
- **Domain Restricted Generation**:
|
||||||
|
- Fallback endpoints validate the requester's base domain against an allowlist.
|
||||||
|
- A designated default domain (e.g., `18004.pro`) is always allowed.
|
||||||
|
- **Runtime Domain Management**:
|
||||||
|
- Read-optimized, thread-safe memory storage (`Arc<RwLock<HashSet<String>>>`).
|
||||||
|
- Dynamic API additions/removals with immediate, safe file persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Port & Routing Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Restricted Ports (Allowlist Validation)
|
||||||
|
P1[Port 4081: SVG] -->|Catch-all| H_SVG[Handle SVG]
|
||||||
|
P2[Port 4082: PNG] -->|Catch-all| H_PNG[Handle PNG]
|
||||||
|
P4[Port 4084: AVIF] -->|Catch-all| H_AVIF[Handle AVIF]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Unrestricted Port
|
||||||
|
P3[Port 4083: API] -->|POST /generate| API_GEN[Custom QR Generation]
|
||||||
|
P3 -->|POST /domains/add| API_ADD[Add Domain]
|
||||||
|
P3 -->|POST /domains/remove| API_REM[Remove Domain]
|
||||||
|
P3 -->|GET /domains| API_LST[List Domains]
|
||||||
|
P3 -->|GET /health| API_HLT[Health Check]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1. Restricted Endpoints (Ports 4081, 4082, 4084)
|
||||||
|
These ports use fallback routes (catch-all). Any request path and query string will be automatically translated into a QR code pointing to the origin host & path.
|
||||||
|
* **How it works**:
|
||||||
|
1. The server reads the hostname from the incoming headers (checking `X-Forwarded-Host`, then `X-Real-Host`, then `Host`).
|
||||||
|
2. The first subdomain is stripped (e.g., `qr.example.com` becomes `example.com`).
|
||||||
|
3. The base domain is checked against the allowlist. If it's not present (and doesn't match the default domain), a `403 Forbidden` response is returned.
|
||||||
|
4. If allowed, it generates a QR code encoding `<scheme>://<base-domain><path><query>` (e.g., `https://example.com/some/path?ref=123`).
|
||||||
|
|
||||||
|
### 2. Unrestricted Endpoints (Port 4083)
|
||||||
|
A standard REST API for programmatic QR generation (without domain restrictions) and runtime control over the allowed domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Installation & Setup
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Rust (Cargo) 1.70+
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
To run the server locally:
|
||||||
|
```bash
|
||||||
|
cargo run -- --config-path ./server.conf --domains-path ./domains.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
The project comes with a robust installer (`install.sh`) that builds the release binary, creates a dedicated, unprivileged system user (`qrserver`), registers config templates, and configures a `systemd` service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build and install with root/sudo privileges
|
||||||
|
sudo ./install.sh
|
||||||
|
|
||||||
|
# 2. Start and enable the service
|
||||||
|
sudo systemctl enable --now server18004
|
||||||
|
|
||||||
|
# 3. Verify the status and view logs
|
||||||
|
sudo systemctl status server18004
|
||||||
|
sudo journalctl -u server18004 -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Configuration
|
||||||
|
|
||||||
|
### Server Configuration (`/etc/server18004/server.conf`)
|
||||||
|
An easy-to-use TOML file configuring server ports and the default domain:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Port for restricted SVG QR code generation
|
||||||
|
port_svg = 4081
|
||||||
|
|
||||||
|
# Port for restricted PNG QR code generation
|
||||||
|
port_png = 4082
|
||||||
|
|
||||||
|
# Port for unrestricted JSON API
|
||||||
|
port_api = 4083
|
||||||
|
|
||||||
|
# Port for restricted AVIF QR code generation
|
||||||
|
port_avif = 4084
|
||||||
|
|
||||||
|
# Default domain that is always allowed on restricted ports
|
||||||
|
default_domain = "example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domains Allowlist (`/etc/server18004/domains.conf`)
|
||||||
|
A simple line-based text file. Comments starting with `#` and blank lines are ignored.
|
||||||
|
```text
|
||||||
|
# Allowed base domains
|
||||||
|
example.com
|
||||||
|
mycompany.org
|
||||||
|
testdomain.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Sample Usage & API Specifications
|
||||||
|
|
||||||
|
### 1. Fallback QR Code Generation (Restricted Ports)
|
||||||
|
|
||||||
|
When navigating to or requesting any path from the restricted ports, a QR code is returned directly.
|
||||||
|
|
||||||
|
#### SVG Format (Port 4081)
|
||||||
|
Assuming `example.com` is in the allowed domain list:
|
||||||
|
```bash
|
||||||
|
curl -i -H "Host: qr.example.com" http://localhost:4081/welcome?user=john
|
||||||
|
```
|
||||||
|
* **Result**: Returns an `image/svg+xml` payload containing a QR code that redirects to `https://example.com/welcome?user=john`.
|
||||||
|
|
||||||
|
#### PNG Format (Port 4082)
|
||||||
|
```bash
|
||||||
|
curl -i -H "Host: qr.example.com" http://localhost:4082/app/download
|
||||||
|
```
|
||||||
|
* **Result**: Returns an `image/png` payload containing a QR code encoding `https://example.com/app/download`.
|
||||||
|
|
||||||
|
#### AVIF Format (Port 4084)
|
||||||
|
```bash
|
||||||
|
curl -i -H "Host: qr.example.com" http://localhost:4084/promo
|
||||||
|
```
|
||||||
|
* **Result**: Returns an `image/avif` payload containing a QR code encoding `https://example.com/promo`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Programmatic API (Port 4083)
|
||||||
|
|
||||||
|
The API port allows custom QR generation with flexible format settings and base64 options.
|
||||||
|
|
||||||
|
#### POST `/generate`
|
||||||
|
Generates a QR code for arbitrary text content.
|
||||||
|
|
||||||
|
##### Payload Schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Your string here",
|
||||||
|
"ecl": "L", // Error Correction Level: L, M, Q, H (Default: L)
|
||||||
|
"format": "svg", // Output: svg, png, avif, base64, base64url (Default: svg)
|
||||||
|
"base64_source": "png", // For base64: png, svg, avif (Default: png)
|
||||||
|
"module_size": 10 // Optional custom scaling factor
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Example 1: Direct PNG Output
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:4083/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"text": "https://rust-lang.org",
|
||||||
|
"format": "png",
|
||||||
|
"ecl": "M"
|
||||||
|
}' --output qr.png
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Example 2: Base64 Encoded JSON Response
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:4083/generate \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"text": "Hello World",
|
||||||
|
"format": "base64",
|
||||||
|
"base64_source": "svg"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
**Response JSON**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzMyAzMyIgc2hhcGUtcmVuZGVyaW5nPSJjcmlzcEVkZ2VzIj4...",
|
||||||
|
"format": "base64",
|
||||||
|
"source": "svg"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Domain Management API (Port 4083)
|
||||||
|
|
||||||
|
#### GET `/domains`
|
||||||
|
Retrieves a list of all currently allowed domains.
|
||||||
|
```bash
|
||||||
|
curl http://localhost:4083/domains
|
||||||
|
```
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "3 domain(s) configured",
|
||||||
|
"domains": ["example.com", "mycompany.org", "testdomain.dev"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST `/domains/add`
|
||||||
|
Adds a domain to the allowlist and immediately persists it to the configuration file on disk.
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:4083/domains/add \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"domain": "newdomain.com"}'
|
||||||
|
```
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Domain 'newdomain.com' added successfully",
|
||||||
|
"domains": ["example.com", "mycompany.org", "testdomain.dev", "newdomain.com"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST `/domains/remove`
|
||||||
|
Removes a domain from the allowlist and updates the file on disk.
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:4083/domains/remove \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"domain": "newdomain.com"}'
|
||||||
|
```
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Domain 'newdomain.com' removed successfully",
|
||||||
|
"domains": ["example.com", "mycompany.org", "testdomain.dev"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET `/health`
|
||||||
|
Simple health check endpoint for proxy checking or system monitors.
|
||||||
|
```bash
|
||||||
|
curl -i http://localhost:4083/health
|
||||||
|
```
|
||||||
|
**Response**:
|
||||||
|
```http
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
content-type: text/plain; charset=utf-8
|
||||||
|
content-length: 2
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
The codebase has a comprehensive suite of unit tests verifying domain manipulation, Host/IP extraction, and QR encoders.
|
||||||
|
|
||||||
|
To run the tests:
|
||||||
|
```bash
|
||||||
|
cargo test
|
||||||
|
```
|
||||||
|
```
|
||||||
15
config/domains.conf.example
Normal file
15
config/domains.conf.example
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Domain Allowlist for 18004 Server
|
||||||
|
# One domain per line. Lines starting with # are comments.
|
||||||
|
# These domains are allowed on the restricted SVG/PNG ports.
|
||||||
|
#
|
||||||
|
# The server strips the first subdomain from incoming requests:
|
||||||
|
# Request to qr.example.com -> checks "example.com" against this list
|
||||||
|
# Request to png.mysite.org -> checks "mysite.org" against this list
|
||||||
|
#
|
||||||
|
# Domains can also be added/removed at runtime via the API port:
|
||||||
|
# POST /domains/add {"domain": "newsite.com"}
|
||||||
|
# POST /domains/remove {"domain": "oldsite.com"}
|
||||||
|
# Runtime changes are persisted back to this file.
|
||||||
|
|
||||||
|
example.com
|
||||||
|
mysite.org
|
||||||
18
config/server.conf.example
Normal file
18
config/server.conf.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 18004 Server Configuration
|
||||||
|
# Copy this file to /etc/server18004/server.conf
|
||||||
|
|
||||||
|
# Port for restricted SVG QR code generation
|
||||||
|
port_svg = 4081
|
||||||
|
|
||||||
|
# Port for restricted PNG QR code generation
|
||||||
|
port_png = 4082
|
||||||
|
|
||||||
|
# Port for unrestricted JSON API (generate, domain management)
|
||||||
|
port_api = 4083
|
||||||
|
|
||||||
|
# Port for restricted AVIF QR code generation
|
||||||
|
port_avif = 4084
|
||||||
|
|
||||||
|
# Default domain that is always allowed on restricted ports
|
||||||
|
# (does not need to be in the domains allowlist)
|
||||||
|
default_domain = "example.com"
|
||||||
56
config/server18004.service
Normal file
56
config/server18004.service
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=QR Code Generation Server (ISO 18004)
|
||||||
|
Documentation=https://github.com/your-org/server18004
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=qrserver
|
||||||
|
Group=qrserver
|
||||||
|
|
||||||
|
# Binary location (adjust after cargo build --release)
|
||||||
|
ExecStart=/usr/local/bin/server18004 \
|
||||||
|
--config-path /etc/server18004/server.conf \
|
||||||
|
--domains-path /etc/server18004/domains.conf
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
StartLimitBurst=5
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
Environment=RUST_LOG=info
|
||||||
|
# Uncomment for debug logging:
|
||||||
|
# Environment=RUST_LOG=debug
|
||||||
|
|
||||||
|
# Security hardening
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateDevices=true
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
LockPersonality=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
|
||||||
|
# Allow writing to config directory (for domain persistence)
|
||||||
|
ReadWritePaths=/etc/server18004
|
||||||
|
|
||||||
|
# Resource limits
|
||||||
|
LimitNOFILE=65536
|
||||||
|
LimitNPROC=4096
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=server18004
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
77
install.sh
Executable file
77
install.sh
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# QR Server Installation Script
|
||||||
|
# Run as root or with sudo
|
||||||
|
|
||||||
|
BINARY_NAME="server18004"
|
||||||
|
INSTALL_DIR="/usr/local/bin"
|
||||||
|
CONFIG_DIR="/etc/server18004"
|
||||||
|
SERVICE_USER="qrserver"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/server18004.service"
|
||||||
|
|
||||||
|
echo "=== server18004 Installer ==="
|
||||||
|
|
||||||
|
# 1. Build release binary
|
||||||
|
echo "[1/5] Building release binary..."
|
||||||
|
if command -v cargo &>/dev/null; then
|
||||||
|
cargo build --release
|
||||||
|
echo " ✓ Built target/release/${BINARY_NAME}"
|
||||||
|
elif [ -f "target/release/${BINARY_NAME}" ]; then
|
||||||
|
echo " ✓ Found existing binary in target/release/${BINARY_NAME}, skipping build"
|
||||||
|
else
|
||||||
|
echo " ✗ Error: 'cargo' not found and no existing binary in target/release/"
|
||||||
|
echo " Please run 'cargo build --release' as your normal user first,"
|
||||||
|
echo " then run this script with sudo."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Create service user
|
||||||
|
echo "[2/5] Creating service user..."
|
||||||
|
if ! id -u "${SERVICE_USER}" &>/dev/null; then
|
||||||
|
useradd --system --no-create-home --shell /usr/sbin/nologin "${SERVICE_USER}"
|
||||||
|
echo " ✓ Created user ${SERVICE_USER}"
|
||||||
|
else
|
||||||
|
echo " ✓ User ${SERVICE_USER} already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Install binary
|
||||||
|
echo "[3/5] Installing binary..."
|
||||||
|
cp "target/release/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}"
|
||||||
|
chmod 755 "${INSTALL_DIR}/${BINARY_NAME}"
|
||||||
|
echo " ✓ Installed to ${INSTALL_DIR}/${BINARY_NAME}"
|
||||||
|
|
||||||
|
# 4. Install config files
|
||||||
|
echo "[4/5] Installing configuration..."
|
||||||
|
mkdir -p "${CONFIG_DIR}"
|
||||||
|
if [ ! -f "${CONFIG_DIR}/server.conf" ]; then
|
||||||
|
cp config/server.conf.example "${CONFIG_DIR}/server.conf"
|
||||||
|
echo " ✓ Installed server.conf"
|
||||||
|
else
|
||||||
|
echo " ⚠ server.conf already exists, skipping (see config/server.conf.example)"
|
||||||
|
fi
|
||||||
|
if [ ! -f "${CONFIG_DIR}/domains.conf" ]; then
|
||||||
|
cp config/domains.conf.example "${CONFIG_DIR}/domains.conf"
|
||||||
|
echo " ✓ Installed domains.conf"
|
||||||
|
else
|
||||||
|
echo " ⚠ domains.conf already exists, skipping (see config/domains.conf.example)"
|
||||||
|
fi
|
||||||
|
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${CONFIG_DIR}"
|
||||||
|
echo " ✓ Set ownership to ${SERVICE_USER}"
|
||||||
|
|
||||||
|
# 5. Install systemd service
|
||||||
|
echo "[5/5] Installing systemd service..."
|
||||||
|
cp config/server18004.service "${SERVICE_FILE}"
|
||||||
|
systemctl daemon-reload
|
||||||
|
echo " ✓ Service installed"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " 1. Edit config: sudo nano ${CONFIG_DIR}/server.conf"
|
||||||
|
echo " 2. Edit domains: sudo nano ${CONFIG_DIR}/domains.conf"
|
||||||
|
echo " 3. Start service: sudo systemctl start server18004"
|
||||||
|
echo " 4. Enable on boot: sudo systemctl enable server18004"
|
||||||
|
echo " 5. Check status: sudo systemctl status server18004"
|
||||||
|
echo " 6. View logs: sudo journalctl -u server18004 -f"
|
||||||
87
src/config.rs
Normal file
87
src/config.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug, Clone)]
|
||||||
|
#[command(author, version, about, long_about = None)]
|
||||||
|
pub struct Cli {
|
||||||
|
#[arg(short, long, default_value = "/etc/server18004/server.conf")]
|
||||||
|
pub config_path: String,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "/etc/server18004/domains.conf")]
|
||||||
|
pub domains_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
pub port_svg: u16,
|
||||||
|
pub port_png: u16,
|
||||||
|
pub port_api: u16,
|
||||||
|
pub port_avif: u16,
|
||||||
|
pub default_domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServerConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
port_svg: 4081,
|
||||||
|
port_png: 4082,
|
||||||
|
port_api: 4083,
|
||||||
|
port_avif: 4084,
|
||||||
|
default_domain: "example.com".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerConfig {
|
||||||
|
pub fn load(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
|
let content = std::fs::read_to_string(path)?;
|
||||||
|
let config: ServerConfig = toml::from_str(&content)?;
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_default(path: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let default_config = ServerConfig::default();
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let content = toml::to_string_pretty(&default_config)?;
|
||||||
|
std::fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_domains(path: &str) -> std::io::Result<HashSet<String>> {
|
||||||
|
let file = match File::open(path) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(HashSet::new());
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
|
||||||
|
let reader = BufReader::new(file);
|
||||||
|
let mut domains = HashSet::new();
|
||||||
|
for line in reader.lines() {
|
||||||
|
let line = line?;
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if !trimmed.is_empty() && !trimmed.starts_with('#') {
|
||||||
|
domains.insert(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(domains)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_domains(path: &str, domains: &HashSet<String>) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let mut file = File::create(path)?;
|
||||||
|
for domain in domains {
|
||||||
|
writeln!(file, "{}", domain)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
328
src/handlers/api.rs
Normal file
328
src/handlers/api.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
use crate::qr;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use fast_qr::ECL;
|
||||||
|
|
||||||
|
// ─── QR Generation Request ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct GenerateRequest {
|
||||||
|
/// The text content to encode in the QR code.
|
||||||
|
pub text: String,
|
||||||
|
|
||||||
|
/// Error correction level: "L", "M", "Q", or "H". Defaults to "L".
|
||||||
|
#[serde(default = "default_ecl")]
|
||||||
|
pub ecl: String,
|
||||||
|
|
||||||
|
/// Output format: "svg", "png", "base64", or "base64url". Defaults to "svg".
|
||||||
|
#[serde(default = "default_format")]
|
||||||
|
pub format: String,
|
||||||
|
|
||||||
|
/// For base64/base64url: the underlying image format to encode.
|
||||||
|
/// Either "png" or "svg". Defaults to "png".
|
||||||
|
#[serde(default = "default_base64_source")]
|
||||||
|
pub base64_source: String,
|
||||||
|
|
||||||
|
/// Module size (pixels per block). Defaults to 10.
|
||||||
|
pub module_size: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_ecl() -> String {
|
||||||
|
"L".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_format() -> String {
|
||||||
|
"svg".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_base64_source() -> String {
|
||||||
|
"png".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Base64Response {
|
||||||
|
pub data: String,
|
||||||
|
pub format: String,
|
||||||
|
pub source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ErrorResponse {
|
||||||
|
pub error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /generate — Generate a custom QR code.
|
||||||
|
pub async fn generate_qr(
|
||||||
|
Json(req): Json<GenerateRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let ecl = qr::parse_ecl(&req.ecl);
|
||||||
|
|
||||||
|
match req.format.to_lowercase().as_str() {
|
||||||
|
"svg" => match qr::generate_svg(&req.text, ecl, req.module_size) {
|
||||||
|
Ok(svg) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "image/svg+xml")],
|
||||||
|
svg,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
"png" => match qr::generate_png(&req.text, ecl, req.module_size) {
|
||||||
|
Ok(png) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "image/png")],
|
||||||
|
png,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
"avif" => match qr::generate_avif(&req.text, ecl, req.module_size) {
|
||||||
|
Ok(avif) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "image/avif")],
|
||||||
|
avif,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e }),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
},
|
||||||
|
"base64" => generate_base64(&req.text, ecl, &req.base64_source, req.module_size, false),
|
||||||
|
"base64url" => generate_base64(&req.text, ecl, &req.base64_source, req.module_size, true),
|
||||||
|
other => (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(ErrorResponse {
|
||||||
|
error: format!("Unknown format '{}'. Use: svg, png, base64, base64url", other),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_base64(
|
||||||
|
text: &str,
|
||||||
|
ecl: ECL,
|
||||||
|
source: &str,
|
||||||
|
module_size: Option<u32>,
|
||||||
|
url_safe: bool,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
let (data_bytes, source_name) = match source.to_lowercase().as_str() {
|
||||||
|
"svg" => match qr::generate_svg(text, ecl, module_size) {
|
||||||
|
Ok(svg) => (svg.into_bytes(), "svg"),
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e }),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"avif" => match qr::generate_avif(text, ecl, module_size) {
|
||||||
|
Ok(avif) => (avif, "avif"),
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e }),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => match qr::generate_png(text, ecl, module_size) {
|
||||||
|
Ok(png) => (png, "png"),
|
||||||
|
Err(e) => {
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(ErrorResponse { error: e }),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let encoded = if url_safe {
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.encode(&data_bytes)
|
||||||
|
} else {
|
||||||
|
general_purpose::STANDARD.encode(&data_bytes)
|
||||||
|
};
|
||||||
|
|
||||||
|
let format_name = if url_safe { "base64url" } else { "base64" };
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(Base64Response {
|
||||||
|
data: encoded,
|
||||||
|
format: format_name.to_string(),
|
||||||
|
source: source_name.to_string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Domain Management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct DomainRequest {
|
||||||
|
pub domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DomainResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub domains: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /domains/add — Add a domain to the allowlist and persist to disk.
|
||||||
|
pub async fn add_domain(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<DomainRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let domain = req.domain.trim().to_lowercase();
|
||||||
|
if domain.is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: false,
|
||||||
|
message: "Domain cannot be empty".to_string(),
|
||||||
|
domains: vec![],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety: DNS max length is 253. Only allow valid domain characters to prevent injection/file corruption.
|
||||||
|
if domain.len() > 253 || !domain.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: false,
|
||||||
|
message: "Invalid domain format (ASCII alphanumeric, dots, and hyphens only)".to_string(),
|
||||||
|
domains: vec![],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut domains = state.domains.write().await;
|
||||||
|
let was_new = domains.insert(domain.clone());
|
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
if let Err(e) = config::save_domains(&state.domains_path, &domains) {
|
||||||
|
tracing::error!(error = %e, "Failed to persist domains to disk");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Domain added to memory but failed to persist: {}", e),
|
||||||
|
domains: domains.iter().cloned().collect(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = if was_new {
|
||||||
|
format!("Domain '{}' added successfully", domain)
|
||||||
|
} else {
|
||||||
|
format!("Domain '{}' was already in the list", domain)
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(%domain, "Domain added to allowlist");
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
domains: domains.iter().cloned().collect(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /domains/remove — Remove a domain from the allowlist and persist to disk.
|
||||||
|
pub async fn remove_domain(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<DomainRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let domain = req.domain.trim().to_lowercase();
|
||||||
|
if domain.is_empty() {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: false,
|
||||||
|
message: "Domain cannot be empty".to_string(),
|
||||||
|
domains: vec![],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut domains = state.domains.write().await;
|
||||||
|
let was_present = domains.remove(&domain);
|
||||||
|
|
||||||
|
// Persist to disk
|
||||||
|
if let Err(e) = config::save_domains(&state.domains_path, &domains) {
|
||||||
|
tracing::error!(error = %e, "Failed to persist domains to disk");
|
||||||
|
return (
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: false,
|
||||||
|
message: format!("Domain removed from memory but failed to persist: {}", e),
|
||||||
|
domains: domains.iter().cloned().collect(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = if was_present {
|
||||||
|
format!("Domain '{}' removed successfully", domain)
|
||||||
|
} else {
|
||||||
|
format!("Domain '{}' was not in the list", domain)
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!(%domain, "Domain removed from allowlist");
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
domains: domains.iter().cloned().collect(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /domains — List all currently allowed domains.
|
||||||
|
pub async fn list_domains(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let domains = state.domains.read().await;
|
||||||
|
let list: Vec<String> = domains.iter().cloned().collect();
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(DomainResponse {
|
||||||
|
success: true,
|
||||||
|
message: format!("{} domain(s) configured", list.len()),
|
||||||
|
domains: list,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /health — Simple health check endpoint.
|
||||||
|
pub async fn health() -> impl IntoResponse {
|
||||||
|
(StatusCode::OK, "OK")
|
||||||
|
}
|
||||||
2
src/handlers/mod.rs
Normal file
2
src/handlers/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod restricted;
|
||||||
|
pub mod api;
|
||||||
252
src/handlers/restricted.rs
Normal file
252
src/handlers/restricted.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, StatusCode, Uri},
|
||||||
|
response::IntoResponse,
|
||||||
|
};
|
||||||
|
use crate::qr;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use fast_qr::ECL;
|
||||||
|
|
||||||
|
/// Extracts the effective host from proxy headers.
|
||||||
|
/// Priority: X-Forwarded-Host > X-Real-Host > Host header.
|
||||||
|
/// Compatible with HAProxy and Apache reverse proxy setups.
|
||||||
|
fn extract_host(headers: &HeaderMap) -> Option<String> {
|
||||||
|
// X-Forwarded-Host may contain multiple hosts (comma-separated), take the first
|
||||||
|
if let Some(val) = headers.get("x-forwarded-host") {
|
||||||
|
if let Ok(s) = val.to_str() {
|
||||||
|
let host = s.split(',').next().unwrap_or("").trim();
|
||||||
|
if !host.is_empty() {
|
||||||
|
// Strip port if present
|
||||||
|
return Some(strip_port(host).to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Real-Host (sometimes used by nginx/haproxy)
|
||||||
|
if let Some(val) = headers.get("x-real-host") {
|
||||||
|
if let Ok(s) = val.to_str() {
|
||||||
|
let host = s.trim();
|
||||||
|
if !host.is_empty() {
|
||||||
|
return Some(strip_port(host).to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard Host header
|
||||||
|
if let Some(val) = headers.get("host") {
|
||||||
|
if let Ok(s) = val.to_str() {
|
||||||
|
let host = s.trim();
|
||||||
|
if !host.is_empty() {
|
||||||
|
return Some(strip_port(host).to_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips port number from a host string (e.g., "example.com:8080" -> "example.com", "[::1]:8080" -> "[::1]").
|
||||||
|
fn strip_port(host: &str) -> &str {
|
||||||
|
if let Some(idx) = host.rfind(']') {
|
||||||
|
// It's an IPv6 address, return the part inside and including brackets
|
||||||
|
return &host[..=idx];
|
||||||
|
}
|
||||||
|
// For regular hostnames, split at the first colon
|
||||||
|
host.split(':').next().unwrap_or(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips the first subdomain from a hostname.
|
||||||
|
/// e.g., "qr.example.com" -> "example.com"
|
||||||
|
/// e.g., "sub.deep.example.com" -> "deep.example.com"
|
||||||
|
/// If there's no subdomain (e.g., "example.com"), returns as-is.
|
||||||
|
fn strip_first_subdomain(host: &str) -> &str {
|
||||||
|
match host.find('.') {
|
||||||
|
Some(idx) => &host[idx + 1..],
|
||||||
|
None => host,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constructs the URL to encode in the QR code.
|
||||||
|
/// Uses X-Forwarded-Proto to detect scheme (defaults to https).
|
||||||
|
fn build_qr_url(headers: &HeaderMap, host: &str, uri: &Uri) -> String {
|
||||||
|
let scheme = headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.split(',').next().unwrap_or("https").trim())
|
||||||
|
.unwrap_or("https");
|
||||||
|
|
||||||
|
let base_domain = strip_first_subdomain(host);
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().map(|q| format!("?{}", q)).unwrap_or_default();
|
||||||
|
|
||||||
|
format!("{}://{}{}{}", scheme, base_domain, path, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for Port 1 (restricted, SVG output).
|
||||||
|
pub async fn handle_svg(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
uri: Uri,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
handle_restricted(state, headers, uri, OutputFormat::Svg).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for Port 2 (restricted, PNG output).
|
||||||
|
pub async fn handle_png(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
uri: Uri,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
handle_restricted(state, headers, uri, OutputFormat::Png).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handler for Port 4 (restricted, AVIF output).
|
||||||
|
pub async fn handle_avif(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
uri: Uri,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
handle_restricted(state, headers, uri, OutputFormat::Avif).await
|
||||||
|
}
|
||||||
|
|
||||||
|
enum OutputFormat {
|
||||||
|
Svg,
|
||||||
|
Png,
|
||||||
|
Avif,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_restricted(
|
||||||
|
state: AppState,
|
||||||
|
headers: HeaderMap,
|
||||||
|
uri: Uri,
|
||||||
|
format: OutputFormat,
|
||||||
|
) -> axum::response::Response {
|
||||||
|
// 1. Extract host
|
||||||
|
let host = match extract_host(&headers) {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Missing Host header",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Determine the base domain (strip first subdomain)
|
||||||
|
let base_domain = strip_first_subdomain(&host).to_string();
|
||||||
|
|
||||||
|
// 3. Check domain against allowlist
|
||||||
|
{
|
||||||
|
let domains = state.domains.read().await;
|
||||||
|
if !domains.contains(&base_domain) && base_domain != state.default_domain {
|
||||||
|
return (
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
format!("Domain '{}' is not in the allowed list", base_domain),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build the URL to encode
|
||||||
|
let qr_url = build_qr_url(&headers, &host, &uri);
|
||||||
|
|
||||||
|
tracing::debug!(host = %host, base_domain = %base_domain, qr_url = %qr_url, "Generating QR code");
|
||||||
|
|
||||||
|
// 5. Generate QR code in the requested format
|
||||||
|
match format {
|
||||||
|
OutputFormat::Svg => match qr::generate_svg(&qr_url, ECL::L, None) {
|
||||||
|
Ok(svg) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("content-type", "image/svg+xml"),
|
||||||
|
("cache-control", "public, max-age=86400"),
|
||||||
|
],
|
||||||
|
svg,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "SVG generation failed");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OutputFormat::Png => match qr::generate_png(&qr_url, ECL::L, None) {
|
||||||
|
Ok(png) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("content-type", "image/png"),
|
||||||
|
("cache-control", "public, max-age=86400"),
|
||||||
|
],
|
||||||
|
png,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "PNG generation failed");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
OutputFormat::Avif => match qr::generate_avif(&qr_url, ECL::L, None) {
|
||||||
|
Ok(avif) => (
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("content-type", "image/avif"),
|
||||||
|
("cache-control", "public, max-age=86400"),
|
||||||
|
],
|
||||||
|
avif,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(error = %e, "AVIF generation failed");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, e).into_response()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_port() {
|
||||||
|
assert_eq!(strip_port("example.com:8080"), "example.com");
|
||||||
|
assert_eq!(strip_port("example.com"), "example.com");
|
||||||
|
assert_eq!(strip_port("[::1]:8080"), "[::1]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_strip_first_subdomain() {
|
||||||
|
assert_eq!(strip_first_subdomain("qr.example.com"), "example.com");
|
||||||
|
assert_eq!(
|
||||||
|
strip_first_subdomain("a.b.example.com"),
|
||||||
|
"b.example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(strip_first_subdomain("example.com"), "com");
|
||||||
|
assert_eq!(strip_first_subdomain("localhost"), "localhost");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_host_x_forwarded() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("x-forwarded-host", "qr.example.com:443".parse().unwrap());
|
||||||
|
headers.insert("host", "internal.server:8080".parse().unwrap());
|
||||||
|
assert_eq!(extract_host(&headers), Some("qr.example.com".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_host_multiple_forwarded() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
"x-forwarded-host",
|
||||||
|
"qr.example.com, proxy.internal".parse().unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(extract_host(&headers), Some("qr.example.com".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_host_fallback() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("host", "qr.example.com".parse().unwrap());
|
||||||
|
assert_eq!(extract_host(&headers), Some("qr.example.com".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/lib.rs
Normal file
4
src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod handlers;
|
||||||
|
pub mod qr;
|
||||||
|
pub mod state;
|
||||||
107
src/main.rs
Normal file
107
src/main.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use axum::{routing::{get, post}, Router};
|
||||||
|
use clap::Parser;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use server18004::config::{self, Cli, ServerConfig};
|
||||||
|
use server18004::handlers;
|
||||||
|
use server18004::state::AppState;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// Initialize tracing
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Parse CLI args
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// Load or generate server config
|
||||||
|
let server_config = match ServerConfig::load(&cli.config_path) {
|
||||||
|
Ok(cfg) => {
|
||||||
|
tracing::info!(path = %cli.config_path, "Loaded server configuration");
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
path = %cli.config_path,
|
||||||
|
"Failed to load config, generating default"
|
||||||
|
);
|
||||||
|
ServerConfig::generate_default(&cli.config_path)?;
|
||||||
|
ServerConfig::load(&cli.config_path)?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load domains
|
||||||
|
let domains = config::load_domains(&cli.domains_path)?;
|
||||||
|
tracing::info!(count = domains.len(), path = %cli.domains_path, "Loaded domain allowlist");
|
||||||
|
|
||||||
|
// Build shared state
|
||||||
|
let app_state = AppState::new(
|
||||||
|
domains,
|
||||||
|
cli.domains_path.clone(),
|
||||||
|
server_config.default_domain.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Port 1: Restricted SVG ──────────────────────────────────────────
|
||||||
|
let svg_state = app_state.clone();
|
||||||
|
let svg_router = Router::new()
|
||||||
|
.fallback(handlers::restricted::handle_svg)
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
|
||||||
|
.with_state(svg_state);
|
||||||
|
|
||||||
|
// ── Port 2: Restricted PNG ──────────────────────────────────────────
|
||||||
|
let png_state = app_state.clone();
|
||||||
|
let png_router = Router::new()
|
||||||
|
.fallback(handlers::restricted::handle_png)
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
|
||||||
|
.with_state(png_state);
|
||||||
|
|
||||||
|
// ── Port 4: Restricted AVIF ─────────────────────────────────────────
|
||||||
|
let avif_state = app_state.clone();
|
||||||
|
let avif_router = Router::new()
|
||||||
|
.fallback(handlers::restricted::handle_avif)
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
|
||||||
|
.with_state(avif_state);
|
||||||
|
|
||||||
|
// ── Port 3: Unrestricted API ────────────────────────────────────────
|
||||||
|
let api_state = app_state.clone();
|
||||||
|
let api_router = Router::new()
|
||||||
|
.route("/generate", post(handlers::api::generate_qr))
|
||||||
|
.route("/domains/add", post(handlers::api::add_domain))
|
||||||
|
.route("/domains/remove", post(handlers::api::remove_domain))
|
||||||
|
.route("/domains", get(handlers::api::list_domains))
|
||||||
|
.route("/health", get(handlers::api::health))
|
||||||
|
.layer(axum::extract::DefaultBodyLimit::max(1024 * 1024)) // 1MB limit
|
||||||
|
.with_state(api_state);
|
||||||
|
|
||||||
|
// ── Bind and serve ──────────────────────────────────────────────────
|
||||||
|
let addr_svg = SocketAddr::from(([0, 0, 0, 0], server_config.port_svg));
|
||||||
|
let addr_png = SocketAddr::from(([0, 0, 0, 0], server_config.port_png));
|
||||||
|
let addr_api = SocketAddr::from(([0, 0, 0, 0], server_config.port_api));
|
||||||
|
let addr_avif = SocketAddr::from(([0, 0, 0, 0], server_config.port_avif));
|
||||||
|
|
||||||
|
let listener_svg = TcpListener::bind(addr_svg).await?;
|
||||||
|
let listener_png = TcpListener::bind(addr_png).await?;
|
||||||
|
let listener_api = TcpListener::bind(addr_api).await?;
|
||||||
|
let listener_avif = TcpListener::bind(addr_avif).await?;
|
||||||
|
|
||||||
|
tracing::info!(addr = %addr_svg, "Starting restricted SVG server");
|
||||||
|
tracing::info!(addr = %addr_png, "Starting restricted PNG server");
|
||||||
|
tracing::info!(addr = %addr_api, "Starting unrestricted API server");
|
||||||
|
tracing::info!(addr = %addr_avif, "Starting restricted AVIF server");
|
||||||
|
|
||||||
|
let svc_svg = axum::serve(listener_svg, svg_router);
|
||||||
|
let svc_png = axum::serve(listener_png, png_router);
|
||||||
|
let svc_api = axum::serve(listener_api, api_router);
|
||||||
|
let svc_avif = axum::serve(listener_avif, avif_router);
|
||||||
|
|
||||||
|
tokio::try_join!(svc_svg, svc_png, svc_api, svc_avif)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
190
src/qr.rs
Normal file
190
src/qr.rs
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
use fast_qr::ECL;
|
||||||
|
use fast_qr::qr::QRBuilder;
|
||||||
|
use png::{ColorType, BitDepth, Compression, FilterType};
|
||||||
|
use ravif::{RGBA8, Img, ColorModel};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
/// Maximum characters in a QR code (theoretical limit for Version 40 is 4,296 alphanumeric).
|
||||||
|
pub const MAX_CONTENT_LEN: usize = 4296;
|
||||||
|
|
||||||
|
pub fn generate_svg(content: &str, ecc: ECL, _module_size: Option<u32>) -> Result<String, String> {
|
||||||
|
if content.len() > MAX_CONTENT_LEN {
|
||||||
|
return Err(format!("Content too long (max {} characters)", MAX_CONTENT_LEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
let qrcode = QRBuilder::new(content)
|
||||||
|
.ecl(ecc)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to generate QR: {:?}", e))?;
|
||||||
|
|
||||||
|
// SVG is naturally scalable, we stick to the default vector output for speed.
|
||||||
|
let svg = fast_qr::convert::svg::SvgBuilder::default()
|
||||||
|
.to_str(&qrcode);
|
||||||
|
|
||||||
|
Ok(svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highly optimized PNG generation:
|
||||||
|
/// - 4x manual bit-multiplication loop
|
||||||
|
/// - 1-bit Grayscale (binary)
|
||||||
|
/// - Fast compression, No filtering
|
||||||
|
pub fn generate_png(content: &str, ecc: ECL, _module_size: Option<u32>) -> Result<Vec<u8>, String> {
|
||||||
|
if content.len() > MAX_CONTENT_LEN {
|
||||||
|
return Err(format!("Content too long (max {} characters)", MAX_CONTENT_LEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
let qrcode = QRBuilder::new(content)
|
||||||
|
.ecl(ecc)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to generate QR: {:?}", e))?;
|
||||||
|
|
||||||
|
let qr_size = qrcode.size;
|
||||||
|
let padding = 4; // Standard QR quiet zone (4 modules)
|
||||||
|
let total_modules = qr_size + (padding * 2);
|
||||||
|
let img_size = total_modules * 4;
|
||||||
|
let row_bytes = (img_size + 7) / 8;
|
||||||
|
|
||||||
|
// Initialize with 0xFF (white)
|
||||||
|
let mut buffer = vec![0xFFu8; row_bytes * img_size];
|
||||||
|
|
||||||
|
// Fast manual 4x scaling loop with padding offset
|
||||||
|
// Since PNG 1-bit Grayscale: 0 = black, 1 = white.
|
||||||
|
for y in 0..qr_size {
|
||||||
|
let row_offset = y * qr_size;
|
||||||
|
// The QR modules start at 'padding' modules from the top/left
|
||||||
|
let py_start = (y + padding) * 4;
|
||||||
|
for x in 0..qr_size {
|
||||||
|
if qrcode.data[row_offset + x].0 & 1 != 0 {
|
||||||
|
let px_start = (x + padding) * 4;
|
||||||
|
// Set 4x4 block to black (0)
|
||||||
|
for dy in 0..4 {
|
||||||
|
let py = py_start + dy;
|
||||||
|
let img_row_start = py * row_bytes;
|
||||||
|
for dx in 0..4 {
|
||||||
|
let px = px_start + dx;
|
||||||
|
let byte_idx = img_row_start + (px / 8);
|
||||||
|
let bit_mask = !(1 << (7 - (px % 8)));
|
||||||
|
buffer[byte_idx] &= bit_mask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
{
|
||||||
|
let mut encoder = png::Encoder::new(Cursor::new(&mut out), img_size as u32, img_size as u32);
|
||||||
|
encoder.set_color(ColorType::Grayscale);
|
||||||
|
encoder.set_depth(BitDepth::One);
|
||||||
|
encoder.set_compression(Compression::Fast);
|
||||||
|
encoder.set_filter(FilterType::NoFilter);
|
||||||
|
|
||||||
|
let mut writer = encoder.write_header()
|
||||||
|
.map_err(|e| format!("Failed to write PNG header: {}", e))?;
|
||||||
|
writer.write_image_data(&buffer)
|
||||||
|
.map_err(|e| format!("Failed to write PNG data: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Optimized AVIF generation:
|
||||||
|
/// - Manual 4x pixel multiplication (RGBA8 for ravif compatibility)
|
||||||
|
/// - Lossless mode (Quality 100)
|
||||||
|
/// - Maximum encoding speed (Speed 10)
|
||||||
|
pub fn generate_avif(content: &str, ecc: ECL, _module_size: Option<u32>) -> Result<Vec<u8>, String> {
|
||||||
|
if content.len() > MAX_CONTENT_LEN {
|
||||||
|
return Err(format!("Content too long (max {} characters)", MAX_CONTENT_LEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
let qrcode = QRBuilder::new(content)
|
||||||
|
.ecl(ecc)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Failed to generate QR: {:?}", e))?;
|
||||||
|
|
||||||
|
let qr_size = qrcode.size;
|
||||||
|
let padding = 4; // Standard quiet zone
|
||||||
|
let total_modules = qr_size + (padding * 2);
|
||||||
|
let img_size = total_modules * 4;
|
||||||
|
|
||||||
|
// ravif takes RGBA8. We fill with white.
|
||||||
|
let mut pixels = vec![RGBA8::new(255, 255, 255, 255); img_size * img_size];
|
||||||
|
|
||||||
|
// Fast manual 4x scaling loop with padding offset
|
||||||
|
for y in 0..qr_size {
|
||||||
|
let qr_row_offset = y * qr_size;
|
||||||
|
let py_start = (y + padding) * 4;
|
||||||
|
for x in 0..qr_size {
|
||||||
|
if qrcode.data[qr_row_offset + x].0 & 1 != 0 {
|
||||||
|
let px_start = (x + padding) * 4;
|
||||||
|
let black = RGBA8::new(0, 0, 0, 255);
|
||||||
|
for dy in 0..4 {
|
||||||
|
let py = py_start + dy;
|
||||||
|
let img_row_offset = py * img_size;
|
||||||
|
for dx in 0..4 {
|
||||||
|
let px = px_start + dx;
|
||||||
|
pixels[img_row_offset + px] = black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let img = Img::new(&pixels[..], img_size, img_size);
|
||||||
|
|
||||||
|
let encoded = ravif::Encoder::new()
|
||||||
|
.with_quality(100.0)
|
||||||
|
.with_speed(10)
|
||||||
|
.with_internal_color_model(ColorModel::RGB)
|
||||||
|
.encode_rgba(img)
|
||||||
|
.map_err(|e| format!("AVIF encoding failed: {}", e))?;
|
||||||
|
|
||||||
|
Ok(encoded.avif_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_ecl(ecl_str: &str) -> ECL {
|
||||||
|
match ecl_str.to_uppercase().as_str() {
|
||||||
|
"L" => ECL::L,
|
||||||
|
"M" => ECL::M,
|
||||||
|
"Q" => ECL::Q,
|
||||||
|
"H" => ECL::H,
|
||||||
|
_ => ECL::L,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_ecl() {
|
||||||
|
assert!(matches!(parse_ecl("L"), ECL::L));
|
||||||
|
assert!(matches!(parse_ecl("m"), ECL::M));
|
||||||
|
assert!(matches!(parse_ecl("q"), ECL::Q));
|
||||||
|
assert!(matches!(parse_ecl("h"), ECL::H));
|
||||||
|
assert!(matches!(parse_ecl("invalid"), ECL::L));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_svg_returns_valid_svg() {
|
||||||
|
let svg = generate_svg("https://example.com", ECL::L, None).unwrap();
|
||||||
|
assert!(svg.contains("<svg"), "Output should contain SVG tag");
|
||||||
|
assert!(svg.contains("</svg>"), "Output should contain closing SVG tag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_png_returns_valid_png() {
|
||||||
|
let png = generate_png("https://example.com", ECL::L, None).unwrap();
|
||||||
|
// PNG files start with the magic bytes: 0x89 P N G
|
||||||
|
assert!(png.len() > 8, "PNG should have content");
|
||||||
|
assert_eq!(&png[0..4], &[0x89, 0x50, 0x4E, 0x47], "Should have PNG magic bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_generate_avif_returns_valid_avif() {
|
||||||
|
let avif = generate_avif("https://example.com", ECL::L, None).unwrap();
|
||||||
|
assert!(avif.len() > 16, "AVIF should have content");
|
||||||
|
// AVIF files usually have "ftypavif"
|
||||||
|
assert!(avif.windows(8).any(|w| w == b"ftypavif"), "Should have AVIF ftyp");
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/state.rs
Normal file
24
src/state.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Shared application state accessible by all server instances.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
/// Thread-safe, read-optimized set of allowed domains.
|
||||||
|
pub domains: Arc<RwLock<HashSet<String>>>,
|
||||||
|
/// Path to the domains config file on disk (for persistence).
|
||||||
|
pub domains_path: String,
|
||||||
|
/// The default domain from the server config.
|
||||||
|
pub default_domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new(domains: HashSet<String>, domains_path: String, default_domain: String) -> Self {
|
||||||
|
Self {
|
||||||
|
domains: Arc::new(RwLock::new(domains)),
|
||||||
|
domains_path,
|
||||||
|
default_domain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
tests/common/mod.rs
Normal file
23
tests/common/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use server18004::state::AppState;
|
||||||
|
|
||||||
|
/// Creates a test AppState with the given allowed domains.
|
||||||
|
/// Uses a temp directory for the domains file so tests don't interfere.
|
||||||
|
pub fn test_state(domains: &[&str]) -> AppState {
|
||||||
|
let domain_set: HashSet<String> = domains.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
// Use a temp file for domain persistence during tests
|
||||||
|
let tmp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||||
|
let domains_path = tmp_dir
|
||||||
|
.path()
|
||||||
|
.join("domains.conf")
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// We need to leak the TempDir so it doesn't get cleaned up during the test
|
||||||
|
// (the test functions are short-lived, this is fine for testing)
|
||||||
|
std::mem::forget(tmp_dir);
|
||||||
|
|
||||||
|
AppState::new(domain_set, domains_path, "test-default.com".to_string())
|
||||||
|
}
|
||||||
3
tests/common/test_health.http
Normal file
3
tests/common/test_health.http
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
GET http://127.0.0.1:4083/domains HTTP/1.1
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
530
tests/integration_test.rs
Normal file
530
tests/integration_test.rs
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use tower::ServiceExt; // for oneshot
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
use common::test_state;
|
||||||
|
|
||||||
|
// ─── Restricted SVG Server Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
fn svg_app(state: server18004::state::AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.fallback(server18004::handlers::restricted::handle_svg)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn png_app(state: server18004::state::AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.fallback(server18004::handlers::restricted::handle_png)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_app(state: server18004::state::AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/generate", post(server18004::handlers::api::generate_qr))
|
||||||
|
.route("/domains/add", post(server18004::handlers::api::add_domain))
|
||||||
|
.route("/domains/remove", post(server18004::handlers::api::remove_domain))
|
||||||
|
.route("/domains", get(server18004::handlers::api::list_domains))
|
||||||
|
.route("/health", get(server18004::handlers::api::health))
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_svg_allowed_domain() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/some/path")
|
||||||
|
.header("host", "qr.example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get("content-type").unwrap(),
|
||||||
|
"image/svg+xml"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let svg = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(svg.contains("<svg"), "Response should contain SVG content");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_svg_forbidden_domain() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/test")
|
||||||
|
.header("host", "qr.forbidden.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_svg_missing_host() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/test")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_svg_x_forwarded_host() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/path")
|
||||||
|
.header("x-forwarded-host", "qr.example.com")
|
||||||
|
.header("host", "internal:8080")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_svg_default_domain() {
|
||||||
|
// The default domain "test-default.com" should always be allowed
|
||||||
|
let state = test_state(&[]); // empty allowlist
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/test")
|
||||||
|
.header("host", "qr.test-default.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Restricted PNG Server Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_png_allowed_domain() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = png_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/test")
|
||||||
|
.header("host", "qr.example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get("content-type").unwrap(),
|
||||||
|
"image/png"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
assert!(body.len() > 8, "PNG should have content");
|
||||||
|
assert_eq!(&body[0..4], &[0x89, 0x50, 0x4E, 0x47], "Should have PNG magic bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_png_forbidden_domain() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = png_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/test")
|
||||||
|
.header("host", "qr.evil.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API Server Tests ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_health_endpoint() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/health")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_svg() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/generate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"text": "https://example.com",
|
||||||
|
"format": "svg"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get("content-type").unwrap(),
|
||||||
|
"image/svg+xml"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_png() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/generate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"text": "https://example.com",
|
||||||
|
"format": "png",
|
||||||
|
"ecl": "H"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get("content-type").unwrap(),
|
||||||
|
"image/png"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_base64() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/generate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"text": "https://example.com",
|
||||||
|
"format": "base64",
|
||||||
|
"base64_source": "png"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["format"], "base64");
|
||||||
|
assert_eq!(json["source"], "png");
|
||||||
|
assert!(!json["data"].as_str().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_base64url_svg_source() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/generate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"text": "hello world",
|
||||||
|
"format": "base64url",
|
||||||
|
"base64_source": "svg"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["format"], "base64url");
|
||||||
|
assert_eq!(json["source"], "svg");
|
||||||
|
// base64url should not contain + or / or =
|
||||||
|
let data = json["data"].as_str().unwrap();
|
||||||
|
assert!(!data.contains('+'), "base64url should not contain +");
|
||||||
|
assert!(!data.contains('/'), "base64url should not contain /");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_generate_invalid_format() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/generate")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"text": "test",
|
||||||
|
"format": "bmp"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Domain Management Tests ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_list_domains_empty() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/domains")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["success"].as_bool().unwrap());
|
||||||
|
assert!(json["domains"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_add_domain() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/domains/add")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"domain": "newdomain.com"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["success"].as_bool().unwrap());
|
||||||
|
assert!(json["domains"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d == "newdomain.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_add_empty_domain() {
|
||||||
|
let state = test_state(&[]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/domains/add")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"domain": " "
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_remove_domain() {
|
||||||
|
let state = test_state(&["example.com", "test.com"]);
|
||||||
|
let app = api_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/domains/remove")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
serde_json::to_string(&json!({
|
||||||
|
"domain": "example.com"
|
||||||
|
}))
|
||||||
|
.unwrap(),
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let json: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["success"].as_bool().unwrap());
|
||||||
|
assert!(!json["domains"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|d| d == "example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cache_control_headers() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/test")
|
||||||
|
.header("host", "qr.example.com")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response.headers().get("cache-control").unwrap(),
|
||||||
|
"public, max-age=86400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_x_forwarded_proto() {
|
||||||
|
let state = test_state(&["example.com"]);
|
||||||
|
let app = svg_app(state);
|
||||||
|
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri("/mypage?q=1")
|
||||||
|
.header("host", "qr.example.com")
|
||||||
|
.header("x-forwarded-proto", "http")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
// The QR code should encode http://example.com/mypage?q=1
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let svg = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(svg.contains("<svg"), "Response should be SVG");
|
||||||
|
}
|
||||||
4
tests/test_health.http
Normal file
4
tests/test_health.http
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
POST http://127.0.0.1:4083/domains/add HTTP/1.1
|
||||||
|
content-type: application/json
|
||||||
|
|
||||||
|
{"domain": "newdomain.com"}
|
||||||
Reference in New Issue
Block a user