Self-hosted service dashboard for local/remote routes with a modular provider system.
CITADEL is built for the real-world homelab/dev workflow:
- You run multiple services/containers, each on a different port.
- You want one clean dashboard to discover and open them.
- You want flexible routing targets (localhost, subnet, tailscale, caddy, cloudflare).
- You want secure remote access via Tailscale out of the box.
CITADEL scans all listening ports on the host, probes them for HTTP services, and automatically maps every discovered service to all enabled providers. With Tailscale enabled, that means every local service is instantly reachable from your entire tailnet — no manual config per service needed.
Example: you start a new service on port 3000. Next scan, it shows up on the dashboard with working links for every provider:
| Provider | Generated URL |
|---|---|
| localhost | https://localhost:3000 |
| subnet | https://192.168.1.50:3000 |
| tailscale | https://citadel-bold-falcon.tailnet.ts.net:3000 |
Start a service, scan, done. Every port is mapped to every provider automatically.
CITADEL ships as a single container based on Fedora, bundling Tailscale, Caddy, PHP-FPM, and a Flask demo service. 📗
- Podman or Docker
- A Tailscale account (auth key or interactive login)
# Optional: set your Tailscale auth key
export TS_AUTHKEY=tskey-auth-...
# Build and start
docker compose up -d
# Check logs for Tailscale auth URL (if no auth key set)
docker compose logs -fpython3 /home/openclaw/safrano9999/CITADEL/build.py
podman run -d --name citadel \
--cap-add NET_ADMIN --cap-add NET_RAW \
--device /dev/net/tun \
-p 8443:443 \
-e TS_AUTHKEY=tskey-auth-... \
localhost/citadel:latestBuild defaults come from build.toml (including base image):
[build]
base_image = "quay.io/fedora/fedora:43"Rawhide example:
python3 /home/openclaw/safrano9999/CITADEL/build.py --base-image quay.io/fedora/fedora:rawhideCopy deploy/citadel.container to ~/.config/containers/systemd/, then:
systemctl --user daemon-reload
systemctl --user start citadel.serviceNamed volumes persist Tailscale state, Caddy config, and application data across restarts.
- Tailscale starts and authenticates (generates a random hostname like
citadel-bold-falcon) - TLS certs are fetched: Tailscale cert for the tailnet domain, self-signed cert for local access
- PHP-FPM starts (serves the dashboard on port 443)
- Flask hello_world starts on port 5000 (demo service for scan detection)
- Caddy starts with two site blocks on :443 (SNI-based cert selection)
- scan.sh runs and discovers all listening services
- Tailscale:
https://citadel-<adj>-<noun>.<tailnet>.ts.net/(valid TLS cert) - Local:
https://localhost:8443/(self-signed cert, port configurable viaCITADEL_PORT)
| Component | Purpose |
|---|---|
| Tailscale | Secure remote access, automatic TLS certs |
| Caddy | Reverse proxy, serves dashboard on :443 |
| PHP-FPM | Runs index.php (dashboard frontend) |
| Flask | Demo service (hello_world/app.py) on port 5000 |
| scan.sh | Port discovery and service probing |
| Variable | Default | Description |
|---|---|---|
TS_HOSTNAME |
random (citadel-<adj>-<noun>) |
Tailscale hostname |
TS_AUTHKEY |
(empty) | Tailscale auth key (if empty, check logs for auth URL) |
CITADEL_PORT |
8443 |
Local port mapping (docker-compose only) |
hello_world/app.py is a minimal Flask app that serves on port 5000 inside the container. It exists as a demo service so that scan.sh has something to discover and display on the dashboard. Replace it with your own services or remove it.
- Port discovery and metadata stay generic.
- Route generation is delegated to providers.
- Provider activation is controlled by folder placement:
extensions/enabled/<provider>/extensions/disabled/<provider>/
extension.json is metadata only (id/label/version), not an activation switch.
Enabled by default:
localhost— routes to127.0.0.1:<port>subnet— routes to<subnet_ip>:<port>(needsconfig.ini)tailscale— routes to<tailnet-domain>:<port>
Disabled by default:
caddy— generates Caddy reverse proxy routes (/p/<port>)cloudflare— placeholder for future integration
Provider scripts live in functions/providers/. dispatch.py runs all enabled providers and aggregates state.
localhostandtailscalework out of the box (no config required).subnetneedsextensions/enabled/subnet/config.iniwithsubnet_ip.caddyandcloudflarecan be configured once enabled.
- Checks runtime via
tailscale status - Default mode: direct-port routing
- Generates URLs like
https://<tailnet-domain>:<port> - Requires root by default (
require_root = true); non-root scan skips with warning.
Generates reverse proxy snippets for path-based routing. Output goes to CADDYFILES/<provider_id>.caddy, imported via wildcard:
import /opt/citadel/CADDYFILES/*.caddyWhen duplicating caddy extensions (caddy, caddy_subnet, etc.), the directory name is used as provider identity to avoid collisions.
- Build
ss.jsonfromss -tlnHp - Apply port policy (
ports.filter.json) - Probe ports for HTTP/HTTPS + HTML detection
- Update per-port cache (
cache/<port>.json) - Build
services.json - Run provider dispatcher
- Write
last_scan.txt
[CITADEL]
ca_cert = /path/to/certs/cert.pem[provider]
subnet_ip = 192.168.x.x{
"default_provider": "tailscale",
"default_refresh_seconds": 0
}{
"whitelist": [],
"blacklist": [4000, "5000-5010"]
}Template: ports.filter.json.example
index.php reads services.json, provider state, and per-provider routes. Features:
- Provider dropdown
- Save default provider (browser storage)
- Fallback route indicator
- Optional auto-refresh
* * * * * /home/user/CITADEL/scan.sh
* * * * * sleep 30 && /home/user/CITADEL/scan.sh