Skip to content

v0.6.0

Latest

Choose a tag to compare

@thangphan205 thangphan205 released this 02 Sep 10:01

⚠️ Breaking Changes

  • 💥 One URL now serves the whole application. The UI, the REST API, Swagger and the MCP endpoint all live under a single host. dashboard.${DOMAIN} and api.${DOMAIN} no longer route — Traefik publishes ${DOMAIN} itself, and the frontend's nginx proxies /api, /mcp, /docs and /redoc through to the backend.

    Before After
    Dashboard https://dashboard.example.com https://example.com
    API https://api.example.com/api/v1 https://example.com/api/v1
    Swagger https://api.example.com/docs https://example.com/docs
    MCP https://api.example.com/mcp/ https://example.com/mcp/

    DOMAIN changes meaning with it: it was a base domain that got prefixed, and is now the application's host. Two variables must be set explicitly — the deployment guide never mentioned them before, so a domain deploy silently ran with FRONTEND_HOST=http://localhost:5173, breaking CORS, passkeys, OAuth and email links:

    DOMAIN=tacacs.example.com
    FRONTEND_HOST=https://tacacs.example.com
    VITE_API_URL=                      # must be EMPTY — the bundle now calls its own origin
    TOOLS_DOMAIN=example.com           # new: keeps adminer./traefik. under an existing wildcard

    VITE_API_URL is baked in at build time, so docker compose build frontend is mandatory — a restart alone leaves the old API host compiled into the bundle. See Upgrading to 0.6.0 for the full checklist.

  • 💥 Existing passkeys break if the host changes. WEBAUTHN_RP_ID is derived from FRONTEND_HOST's hostname, and a credential registered under dashboard.example.com cannot be used under example.com — every enrolled passkey stops working and has to be re-registered. To keep them working, set DOMAIN to your existing dashboard host (DOMAIN=dashboard.example.com): the URL is then unchanged, and only the API and MCP move onto it. Password and OAuth logins are unaffected either way.

  • 💥 OAuth redirect URIs move. GOOGLE_REDIRECT_URI and KEYCLOAK_REDIRECT_URI become https://${DOMAIN}/api/v1/oauth/{google,keycloak}/callback, and the same values must be re-registered in the Google Cloud Console and the Keycloak client — a stale redirect URI fails at the provider, not in this application.

  • 💥 MCP clients must be re-pointed from https://api.<domain>/mcp/ to https://<domain>/mcp/. Existing API keys stay valid; only the URL changes. MCP_PATH is now mirrored by hand in frontend/nginx-backend-proxy.conf, so changing it means changing both.

  • 💥 HA peer URLs naming api.<domain> must be updated — either to the peer's single URL, or to http://<ip>:8000, which reaches the backend directly and skips the proxy hop. This applies to PEER_BACKEND_URL/PEER_NODES and to any HaPeerNode rows already in the database.

  • 🔒 uvicorn now runs with --forwarded-allow-ips=*. Without it uvicorn ignores X-Forwarded-Proto from a non-loopback peer, so the backend would treat an https request as http and answer POST /mcp with an absolute http:// redirect that Traefik turns into a 301 — and most HTTP clients drop the request body on a 301. The flag means X-Forwarded-* is trusted from anything that can reach port 8000; the production compose file does not publish that port, but if you publish it yourself, restrict it to the proxy or pin the flag to your compose network's CIDR.

Security Fixes

  • 🔒 Login password hashing migrated from passlib + bcrypt to pwdlib[argon2,bcrypt], matching the upstream full-stack-fastapi-template. New passwords are hashed with argon2id; existing $2b$ bcrypt hashes stay verifiable and are transparently rewritten to argon2 on the owner's next successful login. The rewrite is skipped on NODE_ROLE=standby (read-only replica) and is best-effort — a failed write leaves the old hash in place and never turns a valid login into an error. No database migration is required: user.hashed_password is an unbounded VARCHAR, and argon2 hashes (~97 chars) fit where bcrypt's 60 did.

    The bcrypt==4.3.0 pin is lifted, so the deferred bcrypt 5.0 Dependabot PR can now be closed as already applied. passlib is retained solely for sha512_crypt in crud/tacacs_users.py, whose $6$rounds=...$ output format is dictated by tac_plus-ng; its [bcrypt] extra is dropped, and CryptContext(schemes=["sha512_crypt"]) never imports passlib's bcrypt backend, so bcrypt 5.x is safe alongside it. A regression test asserts that backend stays unloaded.

  • 🔒 Fixed an unauthenticated 500 on /login/access-token. bcrypt 5.0 raises ValueError for secrets longer than 72 bytes instead of truncating them, and the OAuth2 password form field is length-unbounded — so once the pin was lifted, an over-long password submitted against any account still holding a bcrypt hash would have crashed the endpoint. verify_password now treats both that error and an unrecognised hash format as a failed login.

  • 🔒 The application URL is now rate limited at the traefik edge. Nothing in the stack throttled requests: traefik defined only https-redirect and admin-auth, nginx set no limit_req, and the backend had no limiter. Since the single-URL refactor every request — SPA assets, /api/v1, /docs and /mcp — arrives through the one frontend router, so a single middleware there covers the whole surface. Defaults are 100 requests/second per source IP with a burst of 200, tunable via RATE_LIMIT_AVERAGE, RATE_LIMIT_PERIOD and RATE_LIMIT_BURST in .env. sourceCriterion is deliberately left at the request's remote address: traefik terminates the client connection itself, so that already is the real client IP — setting ipstrategy.depth would make it read X-Forwarded-For, which a client can forge to get a fresh bucket per request. Only set depth if a CDN or load balancer is ever put in front of traefik, and then to the exact number of hops.

  • 🔒 Adminer is now behind the traefik dashboard's basic auth. Its router carried no middlewares, so a database login form was reachable from the public internet on adminer.<TOOLS_DOMAIN> — brute-forceable, and exposed to whatever the floating adminer tag happens to ship. The traefik dashboard beside it was already gated; adminer was the asymmetry. It reuses the existing admin-auth middleware rather than declaring a second one, since both stacks share a single docker provider and the name resolves across compose projects.

Features

  • MCP server — LLM access to TACACS+ configuration. A Model Context Protocol endpoint at /mcp/ lets a client such as Claude Desktop, Claude Code, Google Antigravity, Gemini CLI, Cursor or Windsurf inspect TACACS+ entities, render config previews and diffs, and syntax-check config text with the real tac_plus-ng -P parser. On by default (MCP_ENABLED=true; set to false to disable). Fifteen tools plus a tac_plus-ng syntax reference resource, an entity schema resource, and an authoring prompt. Secrets (Host.secret_key, TacacsUser.password, MAVIS credentials) are masked in every response unless explicitly requested by a superuser key holding mcp:secrets, which is audit-logged. See docs/en/mcp-server.md.

  • MCP clients can edit entities — but never deploy them. A key issued at the Read-write access level can create, update and delete TACACS+ users, groups, hosts, profiles, rulesets, services, MAVIS entries and configuration options, via create_entity, update_entity and delete_entity. Writes additionally require the key to belong to a superuser and are refused on a standby node; each one is recorded in the audit log with user agent mcp/api-key:<key name>, so an MCP-made change is distinguishable from one made in the UI.

    Deployment stays a human action. No tool saves a config file, activates one, or reloads tac_plus-ng, and none will be added: backend/tests/mcp/test_no_config_writes.py walks the AST of every module in the package and fails the build if one gains a reference to the config-writing functions, a write-mode open(), or a subprocess import. An entity edited over MCP sits in the database until someone opens TACACS Configs and presses Generate, then Activate — every write response says exactly that in a next_step field, and the TACACS Configs page now diffs the generated config against the live one, so pending changes surface as a banner instead of going unnoticed. PR #275.

  • API keys — machine credentials for the MCP server, managed under User Settings → API Keys (superuser only). Expiring, revocable, and audit-logged. The plaintext key is shown exactly once at creation; only a 20-character prefix is displayed afterwards. Stored as an HMAC-SHA256 digest keyed on SECRET_KEY — deliberately not a password hash, since a random salt would make indexed lookup impossible and the blocking cost would land on every tool call.

    Each key carries one of two access levels — Read-only (mcp:read) or Read-write (mcp:write, which subsumes read) — plus an independent opt-in for unredacted secret output (mcp:secrets). Anything outside that set is rejected at key creation, so a typo cannot silently mint a key that grants nothing.

  • Multi-client MCP setup guide. An in-app MCP Setup Guide dialog, and a copy-ready connection snippet shown alongside a newly created key, covering Claude Code, Claude Desktop, Google Antigravity, Gemini CLI/Code Assist, Cursor and Windsurf — including the mcp-remote npm bridge for clients without native remote support. Both spell out what a key can and cannot do.

  • API keys can be restricted to an allowed source-IP list. An optional comma-separated allowlist of IPv4/IPv6 addresses or CIDR networks, enforced against the caller's resolved client IP (X-Forwarded-For / X-Real-IP / socket address) on every MCP request — a request from outside the allowlist gets the same generic 401 as an invalid or expired key, so an unauthenticated caller can't learn the key exists. It's the one field editable after creation: unlike scope, name, or expiry, the allowlist can be changed from the same API Keys table row without reissuing the key.

  • setup.sh — a one-command production bootstrap. Deploying by hand meant editing a dozen .env values, generating four secrets, hashing the traefik password with every $ doubled, and remembering -p traefik-public on one compose invocation but not the other — each of which fails silently when missed. setup.sh writes .env from .env.example with generated secrets, checks each hostname against the server's public IP before certificates are attempted, starts traefik under the right project name, builds, migrates, and waits for the backend health check. --config-only, --reconfigure and --yes cover the non-interactive cases; an existing .env is never overwritten without asking.

  • Sign-up is hidden in the UI when open registration is off. The login page offered a Sign up link and /signup rendered its form regardless of USERS_OPEN_REGISTRATION, so a user could fill the whole form and only then be told by a 400 that the server does not accept registrations. The public auth-providers status endpoint — which already tells the pre-auth login page what to show — gained an open_registration key, and /signup redirects to /login when it comes back false. Both sides fail open: the link renders and the form stays if the status call does not answer. This is presentation only; POST /users/signup remains the thing that enforces the setting. The generated client is unchanged, since the response maps to additionalProperties.

Fixes

  • 🐛 A pre-hashed password is no longer hashed a second time. Creating or updating a TACACS+ user with password_type = "crypt" and an already-hashed $6$… value stored sha512_crypt(sha512_crypt(pw)). The account then accepted the digest string as its password and rejected the password the operator had chosen — silently, since nothing errored until someone tried to log in. An already-complete sha512-crypt value is now stored verbatim. This was never MCP-specific: pasting a digest into the UI password field did the same thing.

    The detection is deliberately stricter than passlib's CryptContext.identify(), which accepts a bare $6$notahash — a malformed digest is far likelier to be someone's unusual plaintext than a real hash, so it must still be hashed. It also subsumes the equality check the update path used to carry, which skipped re-hashing only when the new value exactly matched the stored hash, and so missed a freshly salted digest of the same password.

    Surfaced by driving the new MCP write tools from an LLM client: the tacacs://syntax/reference resource shows password login = crypt "$6$rounds=656000$...", and nothing said the password field takes plaintext, so the client pre-hashed with openssl passwd -6 — the reasonable reading of what it had been told. The syntax reference, the entity schema resource, and both write-tool docstrings now state that password is the plaintext password and the server hashes it. PR #277.

  • 🐛 Local users now authenticate over PAP. Generated user { } blocks only ever contained password login = <type> "<value>", so a locally defined user had no PAP credential and tac_plus-ng fell through to pap backend = mavis. Anyone not also present in LDAP failed with pap login failed (backend error) [No answer from LDAP backend.] — which is every local-only account, including on deployments that never intended to use LDAP. The generator now emits password pap = login for every user whose password type is not mavis. Because = login is an alias for "reuse the login password" rather than a type, the one directive covers both clear and crypt users: PAP carries the password in cleartext, so a crypt hash verifies fine, and the secret is not duplicated a second time in the config file. mavis users are deliberately unchanged — they must keep falling through to the backend. Verified against the real tac_plus-ng parser. Reported by @simoneng69 in issue #260; PR #264.

    Related, not fixed in this release. The login backend / user backend / pap backend directives are still hardcoded to mavis, so the matching login_backend, user_backend, and pap_backend fields in TACACS NG Settings accept and store a value that is never emitted. local is not a valid tac_plus-ng backend keyword either — the grammar accepts mavis (optionally prefetch), and "local" means omitting the directive. Separately, the mavis module = external { … } block is emitted even when no MAVIS rows are configured, and first startup always seeds default MAVIS settings, so every deployment ships pointing all authentication at LDAP.

  • 🐛 GET /tacacs_configs/active returned a 500 when nothing had ever been activated. A fresh install has no active config row, and the route fed that None straight into TacacsConfigPublic.model_validate(). It now answers 404, which is what "no configuration is active" should have been all along — and what the new pending-changes banner on the TACACS Configs page relies on to tell a fresh install apart from a drifted one.

  • 🐛 generate_tacacs_ng_config() no longer writes a stray file into the working directory. Every call — including the read-only GET /tacacs_configs/preview — wrote <cwd>/tacacs-ng.conf, so four uvicorn workers raced on the same path, and the OSError fallback created a NamedTemporaryFile(delete=False) that was never cleaned up. The generator is now side-effect free and the stale committed artifact is removed.

  • 🐛 OAuth provider credentials are now read from the database. Admin → Authentication Providers wrote client id, redirect URI and an encrypted secret to authproviderconfig, but the OAuth routes read only settings.GOOGLE_* / settings.KEYCLOAK_*. GET /auth-providers/status did consult the table, so configuring a provider entirely through the UI lit up the login button and then answered 503 Google OAuth is not configured on every click — the one path a UI-only operator can take was the one that could not work. Credentials now resolve per field, table first and environment second, so a half-filled form falls back rather than blanking out a working env config. A row that exists but is switched off refuses instead of quietly reverting to stale environment values, and a stored secret that cannot be decrypted — the result of rotating SECRET_KEY, since Fernet is keyed on it — says so rather than failing later inside the token exchange.

  • 🐛 config_sync_watcher no longer lands in FATAL on primary nodes. The watcher exits 0 within ~150 ms when NODE_ROLE=primary — its designed behaviour, and exitcodes=0 already declared it expected. But supervisord classes an exit as expected only once the process has stayed up for startsecs, which defaults to 1 second, so every primary boot counted four failed starts and ended with gave up: config_sync_watcher entered FATAL state, too many start retries. Nothing was broken by it, but supervisorctl status reported a healthy node as FATAL. startsecs=0 now matches what env_setup, the other short-lived program in that file, already does.

Upgrades

  • ⬆️ bcrypt 4.3.0 → 5.0.0, unblocked by the password-hashing migration above.

Docs

  • docs: restore release notes wiped by the newly-fixed latest-changes bot. PR #263 by @thangphan205.
  • docs: restore release notes wiped by latest-changes, restore its anchor. PR #278 by @thangphan205.
  • 📝 The production deployment guide was rewritten around the single-URL layout. Traefik now reads the project's .env instead of six hand-exported shell variables, so a restart no longer means reconstructing them — and the guide records that Compose interpolates $ inside .env, so an htpasswd hash must have every $ doubled or traefik silently receives a truncated hash that rejects every password without logging a reason. The traefik stack is pinned to -p traefik-public, because run from the repo root it would otherwise inherit the application's project name, at which point --remove-orphans on either stack deletes the other; troubleshooting steps cover re-projecting containers that were already started the wrong way. Five further defects are fixed: the manual pull/exec alembic upgrade sequence (which applies nothing, since it runs inside the old image) is replaced by the build-and-up sequence that prestart already handles, the step 5 verify block no longer curls a port only the dev override publishes or queries a nonexistent alembic_version_view, port 49/tcp is listed as a prerequisite, and password hashing is documented with openssl rather than a Python one-liner.
  • 📝 Open registration is documented rather than prescribed. USERS_OPEN_REGISTRATION=True stays the .env.example default for dev and lab use; a new Disabling Open Registration section describes turning it off, including that up -d backend is required rather than a restart, since the value arrives through the environment: block.
  • 📝 Demo links point at the live host. Folding every URL onto one host retired dashboard.tacacs.9ping.cloud, which both READMEs still used for the demo dashboard, the TACACS+ server IP lookup and the ping command.

Internal

  • 🧪 A guard test now enforces that MCP can never deploy a config. tests/mcp/test_no_config_writes.py parses every module under app/mcp_server/ and asserts none references create_tacacs_config / update_tacacs_config / delete_tacacs_config, opens a file for writing, or imports a way to shell out. It walks the AST rather than grepping, because those modules name the forbidden functions in their own docstrings precisely to say they are off limits.

  • 🔇 The AttributeError: module 'bcrypt' has no attribute '__about__' warning that appeared in every test run is gone — passlib no longer touches bcrypt at all.

  • fix: latest-changes workflow fails on every run — missing token secret. PR #262 by @thangphan205.

  • ⬆ Migrate MCP server to mcp 2.0. PR #290 by @thangphan205.

  • ⬆ bump shiki from 4.3.1 to 4.4.3 in /frontend. PR #287 by @dependabot[bot].

  • ⬆ update fastapi[standard] requirement from <1.0.0,>=0.114.2 to >=0.141.1,<1.0.0 in /backend. PR #286 by @dependabot[bot].

  • ⬆ bump react-dom and @types/react-dom in /frontend. PR #288 by @dependabot[bot].

  • ⬆ bump react-error-boundary from 6.1.2 to 6.1.3 in /frontend. PR #285 by @dependabot[bot].

  • ⬆ bump @tanstack/router-devtools from 1.167.0 to 1.167.1 in /frontend. PR #280 by @dependabot[bot].

  • ⬆ bump pygments from 2.18.0 to 2.20.0 in /backend. PR #282 by @dependabot[bot].

  • ⬆ bump coverage from 7.14.3 to 7.15.4 in /backend. PR #281 by @dependabot[bot].

  • ⬆ bump emails from 1.1.1 to 1.1.2 in /backend. PR #279 by @dependabot[bot].

  • ⬆ bump webauthn from 2.5.1 to 3.0.0 in /backend. PR #268 by @dependabot[bot].

  • ⬆ update psycopg[binary] requirement from <4.0.0,>=3.1.13 to >=3.3.4,<4.0.0 in /backend. PR #267 by @dependabot[bot].

  • ⬆ bump @playwright/test from 1.61.1 to 1.62.1 in /frontend. PR #250 by @dependabot[bot].

  • ⬆ bump vite from 8.1.0 to 8.2.2 in /frontend. PR #266 by @dependabot[bot].

  • ⬆ bump @tanstack/router-plugin from 1.139.1 to 1.168.34 in /frontend. PR #269 by @dependabot[bot].

  • ⬆ bump recharts from 3.9.2 to 3.10.1 in /frontend. PR #270 by @dependabot[bot].

  • ⬆ update sentry-sdk[fastapi] requirement from <3.0.0,>=1.40.6 to >=2.68.0,<3.0.0 in /backend. PR #271 by @dependabot[bot].

  • ⬆ bump mypy from 1.11.2 to 2.3.1 in /backend. PR #273 by @dependabot[bot].

  • ⬆ bump axios from 1.18.0 to 1.19.0 in /frontend. PR #272 by @dependabot[bot].

  • ⬆ update pwdlib[argon2,bcrypt] requirement from <0.4.0,>=0.3.0 to >=0.3.1,<0.4.0 in /backend. PR #274 by @dependabot[bot].