NACo is a modern, container-native open-source Network Access Control & AAA server. It replaces commercial NACs (Cisco ISE, Aruba ClearPass, FortiNAC) with a focused, auditable stack that runs anywhere Docker runs.
Heads-up — NACo is a clean v2.0.0 fork of the now-archived RaspISE project. There is no upgrade path from RaspISE. The schema, configuration layout, secrets, and container surface have all changed. See
CHANGELOG.mdfor the full rationale.
- Features
- Architecture
- Quick start
- Configuration
- Network access — NAS / switch setup
- EAP via FreeRADIUS sidecar
- Observability stack
- Day-two operations
- Backup & restore
- Security model
- Contributing
- License
| Area | Capability |
|---|---|
| RADIUS | PAP, CHAP, MAB (RFC 3580) + RFC 3579 Message-Authenticator (BlastRADIUS) |
| RADIUS accounting | Start / Stop / Interim-Update with active-session tracking |
| Change of Authorization | RFC 5176 Disconnect & CoA with response-authenticator verification |
| EAP | Delegated to FreeRADIUS sidecar via REST hooks (/api/v1/eap/*) |
| TACACS+ | RFC 8907 authentication, authorization & accounting |
| Policy engine | Default-deny attribute rules: user, group, MAC OUI, NAS, time-of-day, VLAN |
| Vendor interop | Dynamic VLAN via RFC 3580 + per-policy VSAs (Cisco, Aruba, Juniper, Fortinet, MikroTik, …) — see docs/VENDORS.md |
| Identity sources | Local DB (bcrypt), LDAP / Active Directory with auto-provisioning |
| Captive guest portal | Cookie-CSRF, MAC-bound timed sessions, Wi-Fi QR code provisioning |
| Device profiling | Passive DHCP / mDNS fingerprinting with OUI-based classification |
| Admin UI | FastAPI + Jinja2, TOTP-protected, full audit log |
| REST API | OpenAPI 3 (Swagger at /api/v1/docs), JWT-bearer auth |
| Observability | Prometheus metrics, structured JSON logs, Loki/Grafana profile |
| Deployment | Docker Compose stack — Postgres, Redis, Caddy (auto-TLS), optional sidecars |
┌─────────────┐ RADIUS / TACACS+ ┌───────────────────────┐
│ switch/AP │ ────────────────────────► │ NACo │ ──► PostgreSQL
└─────────────┘ │ (single FastAPI app) │
▲ │ │ ──► Redis
│ EAP │ / Admin UI │
│ │ /api/v1 REST API │
▼ │ /portal Captive │
┌─────────────┐ REST /api/v1/eap/* │ /metrics Prom OpenMetrics
│ FreeRADIUS │ ─────────────────────────► │ RADIUS 1812 / 1813 │
│ sidecar │ │ TACACS+ 49 │
└─────────────┘ │ CoA 3799 │
└──────────┬────────────┘
│
Caddy reverse proxy (auto-TLS)
│
▼
operators
A single Uvicorn process hosts the admin UI, the REST API, the captive portal, and the OpenMetrics endpoint. RADIUS, TACACS+, and the device profiler run as supervised asyncio tasks inside the same app. FreeRADIUS is opt-in and used only for EAP outer-method termination (EAP-TLS / PEAP / EAP-TTLS) — the policy decision still happens in NACo.
- Docker 24+
- Docker Compose v2 (
docker compose, not the legacydocker-compose) - One free public DNS name if you want Caddy to provision Let's Encrypt certificates automatically (otherwise the stack falls back to a self-signed certificate).
curl -fsSL https://raw.githubusercontent.com/ssnrshnn/NACo/main/install.sh | bashThat's it. The installer clones the repo and runs quickstart.sh, which
checks prerequisites, generates .env with strong secrets plus the EAP
certificates, pulls the prebuilt image from GHCR (building locally only as
a fallback), starts the stack, and prints the generated admin password.
Everything lives in one docker-compose.yml at the repository root.
Prefer explicit steps?
git clone https://github.com/ssnrshnn/NACo.git
cd NACo
./quickstart.sh # or continue fully by hand:cp .env.example .env # fill in the REQUIRED values
docker compose up -d
docker compose logs -f nacoThen open:
- Admin UI:
https://<your-host>/(default loginadmin/${NACO_ADMIN_PASSWORD}— change at first login) - REST API:
https://<your-host>/api/v1/docs - Portal:
http://<your-host>/portal(captive portals must be HTTP) - Metrics:
https://<your-host>/api/v1/metrics
# FreeRADIUS 802.1X (EAP-TLS / EAP-TTLS / PEAP) is ON by default — skip it with:
./quickstart.sh --no-eap
# Add Prometheus + Grafana + Loki + Promtail
./quickstart.sh --obs
# Profiles are pinned in .env: COMPOSE_PROFILES=eap,obsNACo reads YAML from the path in $NACO_CONFIG (default
/etc/naco/config.yaml). Every section is validated by Pydantic — startup
fails loudly on unknown keys or wrong types.
The deployment config lives at config/config.yaml
in the repository root — Docker Compose mounts that directory to
/etc/naco, so edits take effect on the next docker compose restart naco.
The full schema is defined by the Pydantic models in
naco/config/__init__.py — that file is the
source of truth.
| Key | Purpose | Default |
|---|---|---|
server.session_secret |
Signs admin session cookies | unset, must rotate |
server.api_secret |
Signs API JWTs | unset, must rotate |
server.csrf_secret |
HMAC for admin-UI CSRF tokens | unset, must rotate |
server.trusted_proxies |
CIDRs allowed to set X-Forwarded-For |
127.0.0.1/32, ::1/128, 172.16.0.0/12 |
database.url |
SQLAlchemy URL (PostgreSQL or SQLite) | postgresql+asyncpg://naco:naco@postgres/naco |
cache.url |
Redis URL for rate limiting & sessions | redis://redis:6379/0 |
radius.require_message_authenticator |
Drop Access-Requests without a valid MA | true (BlastRADIUS mitigation) |
radius.coa_on_policy_change |
Disconnect affected sessions when a policy changes | true |
eap.bearer_token |
Auth token for FreeRADIUS rlm_rest callbacks |
unset until you enable EAP |
Every secret can also be passed via ${NACO_*} environment variables (see
.env.example).
Per-vendor configuration examples (Cisco, Aruba, Juniper, Fortinet,
MikroTik, UniFi, Extreme, Ruckus, HPE, Palo Alto) live in
docs/VENDORS.md. The generic recipe:
| Field | Value |
|---|---|
| Auth Server | <naco-host>:1812 (PAP/CHAP/MAB) · :2812 (802.1X/EAP) |
| Acct Server | <naco-host>:1813 · :2813 (802.1X/EAP) |
| Shared Secret | the value from radius.clients[].secret |
| CoA Server | <naco-host>:3799 |
| Message-Authenticator | enabled (mandatory by default) |
Then add the NAS in NACo so its IP and secret are known:
# Either via UI: Settings → NAS Clients → Add
# Or via API:
curl -X POST https://<naco>/api/v1/nas \
-H "Authorization: Bearer $TOKEN" \
-d '{"name":"core-sw01","ip_address":"10.0.0.1","secret":"…"}'TACACS+ uses the same tacacs.clients block:
tacacs:
enabled: true
key: "tacacs_default"
clients:
- address: "10.0.0.1"
key: "tacacs_per_device_secret"NACo's built-in RADIUS server intentionally does not implement EAP — that family is large and security-sensitive enough to deserve a dedicated project. Instead the stack ships a FreeRADIUS sidecar, enabled by default, that terminates the EAP tunnel on ports 2812/2813 and calls NACo back over HTTP for every identity and policy decision:
NAS ──RADIUS+EAP :2812──► FreeRADIUS ──REST──► NACo policy engine
◄──RADIUS Accept/Reject──◄────VLAN/policy────
./quickstart.sh wires everything: bearer token, NAS shared secret, and a
self-signed CA + server certificate (replace with your PKI for
production). Supported methods: EAP-TTLS + PAP, PEAP + GTC, and
EAP-TLS. PEAP-MSCHAPv2 is deliberately unsupported — it requires
reversible NT password hashes, and NACo stores bcrypt only.
Port split: point 802.1X switches/SSIDs at 2812, MAB/PAP-only gear at
1812 (NACo built-in, Message-Authenticator enforced). Opt out with
./quickstart.sh --no-eap or by removing eap from COMPOSE_PROFILES
in .env. Details in deploy/freeradius/.
The obs compose profile spins up Prometheus, Grafana, Loki, and Promtail
pre-wired to NACo:
- Prometheus scrapes
https://<naco>/api/v1/metricsevery 15 s. - Loki ingests container logs via Promtail (
naco,freeradius,caddy, …). - Grafana is provisioned with a default NACo dashboard: authentication rate, RADIUS / TACACS+ latency, active sessions, policy rejections.
docker compose --profile obs up -d
open http://localhost:3000 # admin / ${GRAFANA_ADMIN_PASSWORD}Health probes are split so orchestrators restart NACo only when NACo itself is broken:
| Endpoint | Purpose |
|---|---|
/api/v1/health/live |
Liveness — 200 whenever the process serves HTTP; touches no dependency. Point restart probes here. |
/api/v1/health/ready |
Readiness — 200 only when the database answers; 503 otherwise. Redis state is reported but non-gating. Gate traffic here. |
/api/v1/health |
Back-compat alias of ready. |
Policy changes propagate immediately. When a policy is created,
edited, or deleted, NACo finds the active sessions the rule applies to and
sends their NASes RFC 5176 Disconnect-Requests, so clients re-authenticate
under the new rules instead of keeping stale access until the next
re-auth. On by default; turn off with radius.coa_on_policy_change: false.
Bulk disconnect any slice of the session table:
curl -X POST https://<naco>/api/v1/sessions/disconnect \
-H "Authorization: Bearer $TOKEN" \
-d '{"username": "alice"}' # or mac_address / nas_ip / "all": true
# → Disconnect sent to 2 session(s): 2 acked, 0 failed, 0 skippedCSV import/export for users, devices, and NAS clients:
# Inventory snapshots (exports never contain password hashes or secrets)
curl -H "Authorization: Bearer $TOKEN" https://<naco>/api/v1/users/export.csv
curl -H "Authorization: Bearer $TOKEN" https://<naco>/api/v1/devices/export.csv
curl -H "Authorization: Bearer $TOKEN" https://<naco>/api/v1/nas/export.csv
# Bulk onboarding — create-only: existing rows are skipped, never overwritten
curl -X POST -H "Authorization: Bearer $TOKEN" \
-F "file=@users.csv" https://<naco>/api/v1/users/import
# → {"created": 40, "skipped": 2, "errors": ["line 7: …"]}Import columns: users username,password,email,full_name,group,enabled
(group is a group name that must exist) · devices
mac_address,hostname,device_type,notes,authorized · NAS
name,ip_address,secret,description,enabled (secret required, ≥ 16
chars). Every row is validated by the same Pydantic models as the JSON
API.
API tokens give automation (CI, monitoring, scripts) long-lived
credentials without sharing an admin login. Each token carries a role
ceiling (VIEWER / OPERATOR / SUPERUSER) enforced by the same RBAC
as admin accounts; only a SHA-256 digest is stored, and the raw value is
shown once, at creation. Tokens cannot mint or revoke other tokens.
curl -X POST https://<naco>/api/v1/tokens \
-H "Authorization: Bearer $ADMIN_JWT" \
-d '{"name": "ci-deploy", "role": "OPERATOR", "expires_days": 90}'
# → {"token": "naco_…", …} ← store it; it is never shown again
curl https://<naco>/api/v1/users -H "Authorization: Bearer naco_…"Synthetic AAA probes exercise the full protocol path (socket →
parsing → policy engine → reply) — what a NAS actually experiences,
beyond what /health/* can see:
docker compose exec naco nacoctl test-radius # PAP Access-Request
docker compose exec naco nacoctl test-tacacs # TACACS+ PAP login
# RADIUS probe: Access-Reject in 4.2 msWith the default --expect any, a Reject is healthy — it proves the
server parsed the request and evaluated policy. Use
--expect accept --username u --password p to also validate a real
credential. Exit codes are monitoring-friendly: 0 expectation met,
1 server answered with the other outcome, 2 no response. The probe's
source IP must be a registered NAS (the in-container 127.0.0.1 works
out of the box once a localhost NAS exists).
Use the bundled nacoctl CLI from inside the container:
# Backup — works for both Postgres and SQLite
docker compose exec naco nacoctl backup --output /backups/naco-$(date +%F).sql.gz
# Encrypted backup (age is bundled in the image; keep key.txt OFF the server)
age-keygen -o key.txt # once — prints the age1… public key
docker compose exec naco nacoctl backup \
--output /backups/naco-$(date +%F).sql.gz.age \
--age-recipient age1your...publickey
# Restore (DESTRUCTIVE)
docker compose exec naco nacoctl restore --input /backups/naco-2026-05-12.sql.gz
docker compose exec naco nacoctl restore \
--input /backups/naco-2026-05-12.sql.gz.age --age-identity /run/secrets/key.txtFor Postgres deployments, the same tool wraps pg_dump / psql; for
SQLite it copies the file. Either way the backup is portable across NACo
v2.x. Plain dumps contain live credentials — prefer --age-recipient
for anything leaving the host.
NACo is default-deny:
- A new install ships with no permit policies. Every authentication is rejected until you create policies — there is no implicit "Default Permit-All" entry.
- RADIUS Access-Requests without a valid Message-Authenticator are dropped (CVE-2024-3596 / BlastRADIUS mitigation).
- MAB requires
User-Password == User-Name == MAC(RFC 3580). A compromised NAS cannot bypass MAB by guessing passwords. - Captive-portal guests are the one deliberate exception to
default-deny: a MAC with a live, unexpired guest registration is
accepted via MAB onto the guest VLAN (decision label
GUEST_SESSION). An explicit DENY policy still takes precedence, and the grant disappears when the session expires. - The admin UI, API, and portal use three independent secrets
(
session_secret,api_secret,csrf_secret). A leak of one does not compromise the others. - All admin actions are persisted in
admin_audit_logsfor 365 days by default. - Secrets are encrypted at rest: NAS shared secrets, TACACS+ keys,
and admin TOTP seeds are stored AES-256-GCM–encrypted under
NACO_MASTER_KEY(generated byquickstart.sh— back it up together with your.env). Encrypt pre-existing rows withnacoctl encrypt-secrets; rotate withnacoctl rotate-master-key.
See SECURITY.md for the full threat model, reporting
procedure, and CVE coordination policy.
We welcome issues and pull requests. Start with
CONTRIBUTING.md for the test matrix, lint rules, and
release cadence.
NACo is released under the MIT License.