⚠️ 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}andapi.${DOMAIN}no longer route — Traefik publishes${DOMAIN}itself, and the frontend's nginx proxies/api,/mcp,/docsand/redocthrough to the backend.Before After Dashboard https://dashboard.example.comhttps://example.comAPI https://api.example.com/api/v1https://example.com/api/v1Swagger https://api.example.com/docshttps://example.com/docsMCP https://api.example.com/mcp/https://example.com/mcp/DOMAINchanges 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 withFRONTEND_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_URLis baked in at build time, sodocker compose build frontendis 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_IDis derived fromFRONTEND_HOST's hostname, and a credential registered underdashboard.example.comcannot be used underexample.com— every enrolled passkey stops working and has to be re-registered. To keep them working, setDOMAINto 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_URIandKEYCLOAK_REDIRECT_URIbecomehttps://${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/tohttps://<domain>/mcp/. Existing API keys stay valid; only the URL changes.MCP_PATHis now mirrored by hand infrontend/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 tohttp://<ip>:8000, which reaches the backend directly and skips the proxy hop. This applies toPEER_BACKEND_URL/PEER_NODESand to anyHaPeerNoderows already in the database. -
🔒 uvicorn now runs with
--forwarded-allow-ips=*. Without it uvicorn ignoresX-Forwarded-Protofrom a non-loopback peer, so the backend would treat an https request as http and answerPOST /mcpwith an absolutehttp://redirect that Traefik turns into a 301 — and most HTTP clients drop the request body on a 301. The flag meansX-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 upstreamfull-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 onNODE_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_passwordis an unboundedVARCHAR, and argon2 hashes (~97 chars) fit where bcrypt's 60 did.The
bcrypt==4.3.0pin is lifted, so the deferred bcrypt 5.0 Dependabot PR can now be closed as already applied. passlib is retained solely forsha512_cryptincrud/tacacs_users.py, whose$6$rounds=...$output format is dictated by tac_plus-ng; its[bcrypt]extra is dropped, andCryptContext(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 raisesValueErrorfor 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_passwordnow 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-redirectandadmin-auth, nginx set nolimit_req, and the backend had no limiter. Since the single-URL refactor every request — SPA assets,/api/v1,/docsand/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 viaRATE_LIMIT_AVERAGE,RATE_LIMIT_PERIODandRATE_LIMIT_BURSTin.env.sourceCriterionis deliberately left at the request's remote address: traefik terminates the client connection itself, so that already is the real client IP — settingipstrategy.depthwould make it readX-Forwarded-For, which a client can forge to get a fresh bucket per request. Only setdepthif 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 floatingadminertag happens to ship. The traefik dashboard beside it was already gated; adminer was the asymmetry. It reuses the existingadmin-authmiddleware 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 realtac_plus-ng -Pparser. On by default (MCP_ENABLED=true; set tofalseto 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 holdingmcp: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_entityanddelete_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 agentmcp/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.pywalks the AST of every module in the package and fails the build if one gains a reference to the config-writing functions, a write-modeopen(), 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 anext_stepfield, 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-remotenpm 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.envvalues, generating four secrets, hashing the traefik password with every$doubled, and remembering-p traefik-publicon one compose invocation but not the other — each of which fails silently when missed.setup.shwrites.envfrom.env.examplewith 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,--reconfigureand--yescover the non-interactive cases; an existing.envis 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
/signuprendered its form regardless ofUSERS_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 anopen_registrationkey, and/signupredirects to/loginwhen 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/signupremains the thing that enforces the setting. The generated client is unchanged, since the response maps toadditionalProperties.
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 storedsha512_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/referenceresource showspassword login = crypt "$6$rounds=656000$...", and nothing said thepasswordfield takes plaintext, so the client pre-hashed withopenssl 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 thatpasswordis the plaintext password and the server hashes it. PR #277. -
🐛 Local users now authenticate over PAP. Generated
user { }blocks only ever containedpassword login = <type> "<value>", so a locally defined user had no PAP credential and tac_plus-ng fell through topap backend = mavis. Anyone not also present in LDAP failed withpap 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 emitspassword pap = loginfor every user whose password type is notmavis. Because= loginis an alias for "reuse the login password" rather than a type, the one directive covers bothclearandcryptusers: 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.mavisusers are deliberately unchanged — they must keep falling through to the backend. Verified against the realtac_plus-ngparser. Reported by @simoneng69 in issue #260; PR #264.Related, not fixed in this release. The
login backend/user backend/pap backenddirectives are still hardcoded tomavis, so the matchinglogin_backend,user_backend, andpap_backendfields in TACACS NG Settings accept and store a value that is never emitted.localis not a valid tac_plus-ng backend keyword either — the grammar acceptsmavis(optionallyprefetch), and "local" means omitting the directive. Separately, themavis 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/activereturned a 500 when nothing had ever been activated. A fresh install has no active config row, and the route fed thatNonestraight intoTacacsConfigPublic.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-onlyGET /tacacs_configs/preview— wrote<cwd>/tacacs-ng.conf, so four uvicorn workers raced on the same path, and theOSErrorfallback created aNamedTemporaryFile(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 onlysettings.GOOGLE_*/settings.KEYCLOAK_*.GET /auth-providers/statusdid consult the table, so configuring a provider entirely through the UI lit up the login button and then answered503 Google OAuth is not configuredon 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 rotatingSECRET_KEY, since Fernet is keyed on it — says so rather than failing later inside the token exchange. -
🐛
config_sync_watcherno longer lands in FATAL on primary nodes. The watcher exits 0 within ~150 ms whenNODE_ROLE=primary— its designed behaviour, andexitcodes=0already declared it expected. But supervisord classes an exit as expected only once the process has stayed up forstartsecs, which defaults to 1 second, so every primary boot counted four failed starts and ended withgave up: config_sync_watcher entered FATAL state, too many start retries. Nothing was broken by it, butsupervisorctl statusreported a healthy node as FATAL.startsecs=0now matches whatenv_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
.envinstead of six hand-exported shell variables, so a restart no longer means reconstructing them — and the guide records that Compose interpolates$inside.env, so anhtpasswdhash 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-orphanson either stack deletes the other; troubleshooting steps cover re-projecting containers that were already started the wrong way. Five further defects are fixed: the manualpull/exec alembicupgrade 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 nonexistentalembic_version_view, port49/tcpis listed as a prerequisite, and password hashing is documented withopensslrather than a Python one-liner. - 📝 Open registration is documented rather than prescribed.
USERS_OPEN_REGISTRATION=Truestays the.env.exampledefault for dev and lab use; a new Disabling Open Registration section describes turning it off, including thatup -d backendis required rather than a restart, since the value arrives through theenvironment: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.pyparses every module underapp/mcp_server/and asserts none referencescreate_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].