Secure Proxy Manager
September 9, 2026 Β· View on GitHub
π Documentation: https://fabriziosalmi.github.io/secure-proxy-manager/
A self-hosted Secure Web Gateway (SWG) β one controllable egress point for a home, lab, or small-office network. It combines a Squid forward proxy, a custom WAF (request/response inspection over ICAP), a DNS sinkhole, and a modern web UI + API into a single Docker Compose stack: the self-hosted counterpart to cloud SWGs like Zscaler / Cloudflare Gateway β with no traffic leaving your network.
Block domains and IPs, enforce a default-deny egress allowlist, inspect requests against WAF rules, sinkhole malware/ad domains at the DNS layer, and see what every client is reaching.
Malicious requests get 403'd at the proxy by the WAF; benign traffic passes. Every category is verified with an adversarial test suite β see Tested like an attacker.

How it compares
Most self-hosted network tools cover one layer. SPM combines forward-proxy egress control, request inspection, and DNS sinkholing in a single stack:
| Capability | Pi-hole / AdGuard | Nginx Proxy Manager | Cloud SWG (Zscalerβ¦) | Secure Proxy Manager |
|---|---|---|---|---|
| DNS sinkhole (block at resolve time) | β | β | β | β |
| Forward proxy (outbound egress control) | β | β | β | β |
| HTTP request/body inspection (WAF) | β | β | β | β |
| Default-deny egress allowlist | β | β | β | β |
| Reverse proxy / ingress | β | β | β | β |
| Self-hosted β traffic stays on your network | β | β | β | β |
| Free / no per-seat cost | β | β | β | β |
Pi-hole/AdGuard block only at the DNS layer; Nginx Proxy Manager is reverse-proxy ingress; cloud SWGs do all of this but as a paid SaaS your traffic flows through. SPM is the self-hosted outbound counterpart β one controllable egress point with WAF inspection and DNS sinkholing combined, on your own metal.
What it is
The stack is five containers (plus an optional sixth):
| Service | Tech | Role |
|---|---|---|
web | React 19 + Vite, served by nginx | The management UI and HTTPS entry point. Terminates TLS, proxies the API and WebSocket. |
backend | Go (chi, modernc/sqlite, JWT) | REST API, log ingestion, settings, auth, background workers. ~20 MB RAM. |
waf | Go ICAP service | Inspects proxied requests/responses Squid hands off over ICAP. |
proxy | Squid (Ubuntu 22.04, squid-openssl) | The actual forward proxy on port 3128, with ICAP wired to the WAF. |
dns | dnsmasq | DNS resolver that sinkholes blacklisted domains at the network layer. |
tailscale | Tailscale (optional) | Sidecar for remote access over a private network. Enabled with a compose profile. |
The backend applies blacklist and config changes by writing to the shared
./config volume: it exports the lists as flat files and touches a trigger
(.reload-squid, .reload-dns, .clear-cache). Each container's own watchdog
notices the change and reloads in place β no Docker socket is mounted into the
backend, and nothing calls the Docker Engine API.
Quick start
Requirements: Docker 20.10+ with Compose v2, ~512 MB RAM, ~2 GB disk. Runs on x86_64 and ARM64.
One command (fresh VPS or server):
curl -fsSL https://raw.githubusercontent.com/fabriziosalmi/secure-proxy-manager/main/deploy/install.sh | sudo bash
The installer checks for Docker, generates random admin credentials, pulls the
pre-built signed images from GHCR, and starts the stack. It prints the
credentials at the end (they are also saved to .env).
Manual:
git clone https://github.com/fabriziosalmi/secure-proxy-manager.git
cd secure-proxy-manager
cp .env.example .env
# Set a strong BASIC_AUTH_PASSWORD in .env (the backend refuses to start with an
# empty, common, or <8-char password). Leave SECRET_KEY empty to auto-generate.
docker compose up -d --build
Then open https://localhost:8443 and accept the self-signed certificate. Log in
with the credentials from your .env (or the ones the installer printed). On
first login a short wizard helps you pick a starting configuration.
To send traffic through the proxy, point a client at http://<host>:3128
(Settings > Client Setup generates per-OS instructions and a PAC file).
Ports
| Port | Service | Notes |
|---|---|---|
| 443, 8443 | Web UI (HTTPS) | Main interface. 8443 is the same UI on an alternate port. |
| 80, 8011 | HTTP | Redirects to HTTPS; also serves ACME challenges for Let's Encrypt. |
| 3128 | Proxy | Point clients here. Only RFC1918/localhost sources are allowed by default. |
| 5001 | Backend API | Bound to 127.0.0.1 on the host; the UI proxies it internally. |
These have to be free on the host before you start. A web server already on port
80 is the usual clash, and it is the one case that fails quietly: the container
cannot bind, and the stack looks like it is merely slow to come up. install.sh
checks every published port first and names the process holding it. Free it
(systemctl stop nginx), or change the host side of the mapping in
docker-compose.yml, or set SKIP_PORT_CHECK=1 if you know the conflict is
harmless.
Configuration
All configuration is environment-driven via .env. .env.example documents the
variables an operator normally sets; the complete set the backend reads is in
backend-go/internal/config/config.go. The essentials:
| Variable | Default | Purpose |
|---|---|---|
BASIC_AUTH_USERNAME / BASIC_AUTH_PASSWORD | required | Admin login. The backend refuses to start if the password is empty, a common default, or shorter than 8 characters. |
SECRET_KEY | auto-generated | JWT signing secret. Leave empty to generate and persist one. If set, it must be unique, random, and 32+ chars; known/example values are rejected at startup. |
LETSENCRYPT_DOMAIN / LETSENCRYPT_EMAIL | empty | Set both to obtain a real certificate via certbot instead of the self-signed one. |
CORS_ALLOWED_ORIGINS | https://localhost:8443 | Allowed UI origin(s). |
DNS_UPSTREAM_1..3 | malware-blocking resolvers | Upstream DNS for dnsmasq. Override to use your own (e.g. Pi-hole). |
PROXY_IP | empty | Your LAN IP, to enable WPAD auto-discovery (wpad.dat) for browsers. |
WAF_BLOCK_THRESHOLD | 10 | Anomaly score at which a request is blocked (lower is stricter). |
WAF_FAIL_OPEN | 0 | If the WAF handler errors, block (0, fail-closed) or allow (1) the request. |
Most runtime behaviour (WAF categories, blocklists, filtering toggles,
notifications) is managed from the UI and stored in the database, not in .env.
Capacity
Single node by design: the login-attempt counters and the JWT revocation list are in-process, the state lives on bind mounts, and each container generates its own configuration β so a second instance would not share what matters. Running more of them is not a supported way to grow. These are the ceilings the design imposes and what happens at each:
| Ceiling | Value | At the limit |
|---|---|---|
| Write throughput | one SQLite writer, pool of 4 | Writes queue (busy_timeout 5s), so it shows as latency, not errors |
| WAF behavioural tracking | 10,000 client IPs | Beyond it an entry is evicted, so beaconing/sharding history resets for some clients |
| Live log stream | 256 queued messages, 64 per viewer | Lines are dropped for a slow viewer rather than stalling the stream |
| WAF security alerts | 64 queued, 8 delivery workers | Alerts are dropped and counted in notifyDropped on /metrics. Blocks are still enforced and logged β only the notification is lost, and the queue fills exactly during an attack |
| API rate limit | 20 req/s per IP, burst 60 | 429 |
| Concurrent DNS scans | 2 | 429 |
| Request body | 55 MB; blacklist import 32 MB | 413 / the import is refused |
| Log ingest | 5,000 lines per 500 ms tick | A backlog catches up over successive ticks |
| Backend resources | 0.25 CPU, 128 MB | Raise deploy.resources.limits in the compose file |
proxy_logs grows with traffic and is pruned only by log_retention_days
(default 30). If that setting is not a positive number the pruning stops and
spm_log_retention_enabled goes to 0.
Features
Filtering
- Domain and IP blacklists and whitelists, managed in the UI or via the API, importable from URLs or pasted content (with CIDR support for IPs).
- DNS sinkholing of blacklisted domains via dnsmasq, so blocked lookups never reach the proxy at all.
- Direct-IP-access blocking and a method whitelist in Squid.
- Optional default-deny egress (off by default): instead of the deny-list blacklists, allow outbound traffic only to an explicit CIDR/domain allowlist and refuse everything else. Toggled in Settings; destinations managed on the Egress Allowlist page.
WAF
- 170 regex rules across 21 toggleable categories (SQLi, XSS, traversal, C2, data-loss, etc.), each enableable/disableable at runtime.
- 7 behavioural heuristics (entropy, beaconing, PII, sharding, morphing, ghosting, sequence) plus DGA detection (bigram + entropy), typosquatting detection (edit distance + homoglyphs), and a safe-URL fast-path cache.
- Anti-evasion input normalization and anomaly scoring; fails closed on internal errors by default.
HTTPS inspection (optional)
- HTTPS is tunnelled via
CONNECTby default - the WAF sees connection metadata (host, port), not request contents. - Enabling SSL-bump in Settings makes Squid intercept TLS with a locally generated CA so the WAF can inspect decrypted requests. This requires installing the generated CA (Settings > download CA) on every client that should trust it. The CA is generated per deployment at first boot.
Operations
- Live dashboard, per-client drill-down, searchable access logs, and an audit log of configuration changes.
- Prometheus metrics on the internal network: the backend at
:5000/metrics(RED metrics per route, DB connection-pool gauges, worker heartbeats, Go runtime) and the WAF at:8080/metrics(counters plus a REQMOD latency histogram). An opt-inobservabilityCompose profile ships Prometheus + Grafana:docker compose --profile observability up -d. - Health probes:
/livez(process up) and/readyz(database reachable β the container healthcheck uses this so a wedged DB surfaces as unhealthy). Structured per-request access logs on the backend. - Notifications to a custom webhook, Gotify, Telegram, or Microsoft Teams, with retry and backoff.
- WebSocket log streaming, JWT auth with a persistent revocation list, per-IP rate limiting, and AES-256-GCM encryption of sensitive settings at rest.
- Optional Let's Encrypt, WPAD/PAC client auto-config, and a Tailscale sidecar.
Using it
Point a client at the proxy. Set the HTTP/HTTPS proxy to http://<host>:3128
on the device, or use the PAC file / WPAD auto-discovery from Settings > Client
Setup. Only RFC1918 and localhost sources are permitted by default.
Block a domain. Blacklists > Domains > add example.com. The change is
exported to Squid and dnsmasq within a couple of seconds. Whitelists take
precedence.
Inspect HTTPS. Settings > enable SSL inspection, download the generated CA, and install it as trusted on your clients. Without this, HTTPS requests are only filtered by host/IP and DNS, not by WAF body rules.
Lock egress to an allowlist. Settings > enable default-deny egress, then add the approved IPs/CIDRs and domains on the Egress Allowlist page. Local clients can then reach only those destinations; everything else is refused.
Import a blocklist. Blacklists > Import supports popular public lists by URL or pasted content. Imports are size-bounded and fetched with an SSRF-safe client that refuses private/internal targets.
Updating
cd secure-proxy-manager
git pull
docker compose up -d --build
Your .env, database, and blacklists live in bind-mounted volumes and are
preserved across updates. The backend checks GitHub for newer releases and shows
a badge in the UI when one is available.
Backup and restore
The database (data/), config (config/), and .env hold all state.
docker compose down
cp data/proxy_manager.db proxy_manager.db.bak
tar czf config.bak.tgz config/ .env
docker compose up -d
The UI also offers a config export/import under Settings, and the database can be exported via the API.
Health and testing
# Service health
curl -skI https://localhost:8443/ # UI
curl -I http://127.0.0.1:5001/health # backend API (localhost only)
# Proxy a request
curl -x http://localhost:3128 -I http://example.com
# End-to-end suite (service health, proxy egress, blocking, log pipeline)
bash tests/ci-e2e.sh
Go unit tests: cd backend-go && go test ./... and cd waf-go && go test ./....
UI tests: cd ui && npm test. A local pre-commit check that runs TypeScript,
ESLint, Go vet/test, and the UI build is at scripts/pre-commit-validate.sh.
Troubleshooting
- Backend container won't start - check
docker compose logs backend. The most common cause is an empty/weakBASIC_AUTH_PASSWORDor a known/shortSECRET_KEY; both are rejected by design. - Can't reach the UI - it is HTTPS on 8443 with a self-signed cert; accept
the certificate, or use
https://localhost:8443(nothttp://...:8011). - Clients aren't being filtered - confirm the device's proxy is set to
:3128and the source IP is RFC1918/localhost (other sources are denied). - HTTPS isn't inspected - that is the default; enable SSL-bump and install the CA to inspect request contents.
API
The backend exposes a REST API (77 routes) used by the UI. A machine-readable
listing is available at GET /api/docs. Authenticate with HTTP Basic auth, or
exchange credentials at POST /api/auth/login for a JWT and send it as a Bearer
token.
Architecture and deployment docs
- DEPLOYMENT.md - step-by-step deployment, reverse-proxy and TLS setup, resource tuning.
- SECURITY.md - security model and how to report issues.
- BENCHMARKS.md - reproducible performance and detection benchmarks.
- CHANGELOG.md - release history.
Tested like an attacker
Most proxies and WAFs ship rules and hope. SPM has an adversarial e2e harness
(make adversarial, gated in CI) that drives real attack traffic through the
running proxy + WAF in an isolated sandbox and fails the build on any
regression. Five planes:
- block-matrix β SQLi / XSS / RCE / SSRF / traversal / secret-exfil / β¦ across the 21 WAF categories: malicious β 403, benign β allowed. Gate: false negatives = 0, false positives = 0.
- API attacker β auth-bypass, forged / tampered JWTs (incl.
alg=none), login SQL-injection, rate-limit probing β no bypass (401/403/429 as expected). - bench / latency β p50/p95 + proxy + ICAP overhead, with a regression gate.
- config-matrix β flips a setting through the real API and proves the data plane changes (disable a rule category β that attack now passes; re-enable β blocked again, selectively).
- resilience β kill the WAF and the proxy fails closed (no fail-open hole); restart it and the stack self-heals.
Run it yourself β it brings up the sandbox, attacks it, prints a block-matrix + FP/FN report, and tears down:
make adversarial
See tests/adversarial/.
Manage it from an AI agent (MCP)
SPM ships an optional Model Context Protocol
server (mcp/) that exposes the management API as tools, so an assistant
or agent (e.g. Claude) can drive your Secure Web Gateway in natural language β
"what are my top egressing domains?", "is anything reaching AI APIs?", "block
evil.example", "disable the SQL_INJECTION category for 5 minutes". Read tools
are safe; mutating tools (block/toggle/reload) are explicit and approved per call.
SPM_URL=http://localhost:5001 SPM_USERNAME=admin SPM_PASSWORD=... uvx spm-mcp
See mcp/README.md for the tool list and Claude Desktop config.
Security notes
- The SSL-bump CA is generated per deployment at first boot and never committed;
treat the private key under
config/as sensitive and never share it. - Set strong admin credentials. The proxy only accepts RFC1918/localhost clients by default - if you expose port 3128 publicly, put it behind authentication or a private network (e.g. the Tailscale sidecar).
- Sensitive settings (webhook URLs, tokens) are encrypted at rest; JWT secrets
and the encryption key are generated and persisted under
data/. - Squid is kept patched against upstream CVEs (e.g. Squidbleed / CVE-2026-47729) via a fail-closed version floor in the proxy image and a disabled FTP gateway β see Security Advisories.
Contributing
See CONTRIBUTING.md. Issues and pull requests are welcome.
Support
SPM is MIT-licensed and free to self-host. If it's useful to you:
- β Star the repo and sponsor to support development.
- π οΈ Want it installed, hardened, or managed for your network or organisation? Email fabrizio.salmi@gmail.com.
License
MIT.