Changelog
What changed recently. Full commit-by-commit history lives on GitHub — and the panel itself shows incoming changes under Update before you upgrade.
Latest — The docs site now checks itself against the panel
- Five endpoints were documented nowhere:
/api/ai/test,/api/telegram/test,/api/api-tokens/self-test,/api/blocklist/pornand/api/tunnel-settingsall existed, all had a button in the panel, and none of them appeared in the API reference. The prose described what they do; a bot author had no endpoint to call. - Seventeen of the nineteen server settings were named only by their label: the page said “Obfuscated host”, “REALITY SNI list” and “Block direct IP”, so the name you need for
GET /api/settings, a backup or a hand-edited row —obfuscated_host,reality_sni,block_direct_ip— appeared nowhere. Configuration now carries a table of all nineteen, with defaults and what each one changes. - The admin dashboard was not written down at all: the five cards, what “Active” counts and — the part that surprises everyone — what the small line under it counts instead (expiring within 7 days, not started, out of volume), the CPU/memory/disk card and the five newest accounts now have a section of their own.
- A suite reads the code and the site and compares them: every sidebar section must have a docs destination that really exists, every registered
/apiendpoint must appear in the reference, and every stored setting key must appear by name. Adding an endpoint without writing it down now turns the suite red. It was proved by injection: a fake route and a fake setting key each fail the check, and the restored tree passes again.
v1.15.2 — The installer asked you for a port and a database, then ignored you
- The one-liner never asked anything: every question in the installer — panel port, database (SQLite / MySQL / MariaDB / PostgreSQL, with host, port, name, user and password), admin account, subscription path, Telegram token, reverse proxy, SSL — was already written and already worked. It was gated behind
[[ -t 0 ]], and undercurl … | sudo bashstdin is the pipe carrying install.sh itself, so that test is never true. Every prompt was unreachable: the installer printed step after step, took the default for all of them, and reported success. You got port 8000 and SQLite because it had no way to ask you for anything else. - The terminal you are sitting at is not stdin: it is
/dev/tty, and a pipe does not touch it. So the installer now opens/dev/ttyand reads every prompt from there. This is a correctness requirement, not a convenience: a barereadwith the script arriving on stdin would consume the remaining lines of install.sh and then block forever on the next one. Pasted once, the same one-liner now walks you through the port, the database and the rest; press Enter at any prompt to take the default, so it stays as quick as before if that is what you want. - Unattended installs are still unattended: there is no controlling terminal under systemd, cron,
docker buildor CI, and that case must not block on input that can never arrive — so those installs detect it and take the defaults, saying so on screen. The probe actually opens the device rather than testing-r/-w, because a session can have the node present and still be unopenable.ZEFIRA_NONINTERACTIVE=1forces that path even from a real terminal, for scripts that drive the installer. - The guard that watched this was watching the wrong thing: the test asserted the literal text
[[ -t 0 ]] && INTERACTIVE=1was present — so it stayed green for the entire life of the bug it was supposed to prevent. It is the fifth guard in this project to assert a restated implementation detail instead of a behaviour, after the hash lock's “4 hashes per package on average”, the installer's hardcodedecho 1.14.2, a hand-written list of translated attributes, and the README's per-suite test counts. It is now replaced by a test that runs the gate: it proves a prompt takes its answer from the terminal while stdin is carrying script text, and that a session with no tty falls back to defaults.
v1.15.1 — The hash lock only covered the machine that wrote it, so 11 of 31 packages could not install on Linux
- A fresh install failed on the very first package:
install.shinstalls fromrequirements.lockunder--require-hashes, andcfficame back “these packages do not match the hashes from the requirements file”. The lock was not corrupt.tools_lock.pyhashed whateverpip downloadfound on the machine that ran it and only asked PyPI for the rest when it found nothing — and the machine that generated this lock was Windows on CPython 3.14, socffiwas pinned to the digest of thecp314-win_amd64wheel while the server downloads thecp312-manylinuxone. The pin was right, and about the wrong file. - It was not one package, it was eleven:
cffi,cryptography,greenlet,httptools,markupsafe,psutil,psycopg2-binary,pyyaml,sqlalchemy,watchfilesandwebsocketswere all uninstallable on Linux — which is to say on every platform except the one that wrote the file. The generator now takes the union of what this machine downloaded and every artifact PyPI publishes for the release:cffiwent from 1 hash to 100, the lock from 151 to 916. The asymmetry is the whole point. A missing hash aborts the install; an extra hash is inert, because pip picks the artifact that fits the target and looks it up in the set. So the set has to be a union, never an intersection. - A wrong hash is now found by a tool instead of by a server:
verify_lock_hashes.pychecks every recorded hash against PyPI's own digests and reports any entry whose coverage a Linux install needs is missing. It ships because the failure mode is otherwise invisible until someone installs on a different machine — which is precisely how this shipped.tools_lock.pyprints the command when it finishes. - The guard was written wrong first: the test that watches the lock began as “at least 4 hashes per package on average”, and it missed the exact regression it was written for — cutting
cffifrom 100 hashes to 1 removes 1% of the file and barely moves an average. Tightened to the invariant that is actually visible offline: no entry may have a single hash, because one hash is a single-platform lock. The first tightening then failed onanyioandannotated-types, which are correct at two artifacts (one wheel, one sdist) — so the rule stayed at “more than one, not at least N”. All four regressions are now caught by fault injection. - The one-liner was installing the wrong release: while chasing the hash error above, a second and worse bug turned up in install.sh. The version it clones comes from
cat VERSION || cat $TARGET/VERSION || echo <literal>— and under the documentedcurl … | sudo bashbothcats fail: there is no checkout in the CWD, and/opt/zefiradoes not exist yet on a first install, because the clone that fills it happens some 330 lines further down. The literal is therefore not a fallback, it is the only value that ever survives — and it still said1.14.2. Since thev1.14.2tag exists, the clone succeeded: the one-liner quietly installed v1.14.2 and skipped v1.14.3's security fixes and all of v1.15.0. Nothing errors and nothing warns — you get an old panel and a plausible-looking service. - The test that should have caught it could not: it asserted
|| echo 1.14.2, restating the number it was meant to be checking. It read noVERSIONfile and consulted no tag, so it stayed green through two releases while the value it encoded went stale — a test that cannot fail. It now reads theVERSIONfile and compares, so bumping one without the other is a red build. Verified by putting the stale literal back. - The docs served themselves a stale search index: the asset cache-bust lived in fourteen places — twelve pages carrying
assets/i18n.js?v=34?v=N, plusdocs.jsandsupport-ai.jsfetching the search index and the AI knowledge base. Raising it on the pages alone leaves the two fetchers asking for the old JSON, so a visitor loads new HTML and then gets yesterday's Ctrl+K results and wrong answers from the support bot, with nothing on the page to say so. It happened here while fixing the installer, and the only reason it was caught before release is thatfrontend_bugs_test.pyalready insisted all the versions matched.docs/build_index.pynow calls the existingdocs/bump_assets.pyitself, so the single documented step is actually sufficient — the separate bump step was simply the kind of thing that gets forgotten. A first attempt at that wiring re-implemented the bump and refused to run when the numbers disagreed, which is worse: a mixed set is the recoverable state, and not being able to build is not. - Six translations were empty and five attributes were misspelled: found by a new harness that loads
docs/assets/i18n.js?v=34and inspects the resulting object rather than the source text, becausenode --checkand a regex both pass on a dictionary that is wrong. Two keys —usr.aiAandusr.appA, both the word “The” — were""in Persian, Chinese and Russian, so those pages rendered a missing word. The tell was in the neighbouring value: Persianusr.appCbegins with a space, because the sentence had been written expecting the missing word to arrive ahead of it. Separately, fivedata-i14nattributes in the changelog were a typo fordata-i18n; the keys were translated in all four languages and the markup never asked for them, so those lines had been showing English permanently. Both classes fail now, and the attribute check derives the list of attributesapplyI18nreally reads rather than trusting a hand-written one — which is what made its first version report a false positive ondata-i18n-alt.
v1.15.0 — Developer API: a section in the panel, a base URL you can finally see, and a token test that is allowed to fail
- A section for developers, in the sidebar: the API was documented on the website and its tokens were buried in Settings, so the two things a developer needs were in different places. Quick start shows the base URL of this deployment — taken from your
public_url, because behind a reverse proxy that is the address clients actually reach and the browser's own origin is whatever hostname you happened to be typing — plus the auth header and a copy-pasteablecurl. The token scopes card states plainly whatfullandbotmay reach, and limits & rules covers the things bot authors keep getting wrong: that a Bearer token needs no CSRF header, that a scope mismatch answers 403 rather than 404, and that changing the admin password revokes every token. - A token test that is allowed to fail: a freshly minted token is now proved against the real API before its secret is handed over. The detail that makes it a test rather than a formality: the request carries no session cookie. The panel tries the Bearer header first and falls through to your cookie, so an ordinary same-origin call answers 200 even for a token that does not work — a check that cannot fail proves nothing, and would have reported every broken token as healthy. It is sent exactly the way a bot sends it, cookie-free.
- GET /api/me now says who is asking: it returns
auth: {type, name, scopes}alongside the username — whether the call came from a browser session or from a token, which of your tokens answered, and with what scope. A bot author debugging “my token stopped working” otherwise sees only 200 or 401: not which credential replied, nor that the panel is treating it as abotand refusing the endpoint it called. Nothing new is disclosed; the name and scope of a token are already known to whoever is holding it. - The token card moved, on purpose: API Tokens is no longer in Settings. A second copy would have left the page with two
#apitoken-create-btnelements, and$("…")binds only the first — so one of them would have been a button that looked perfect and did nothing. It also finally shows each token's expiry and marks one that has already expired, since a credential that quietly stops working reads as a broken bot. - Two audits of the new section, six findings: the section could not be moved or hidden. "api" was missing from both canonical menu lists, so Personalize rendered no row for it and the server answered
422to any layout that mentioned it — it was pinned in place from its first release. the token test forged a use. It asked the API over a Bearer header, and the panel stampslast_used_aton every bearer request, so the panel's own check made never used unreachable for every token the UI ever created — the exact field an operator scans for a leaked credential. A dedicatedself-testendpoint does the same hash-and-lookup without the write. plain HTTP got no warning. The quick start hands you a shell line that will carry a full-scope token; overhttp://that crosses the network in cleartext, and the panel supports plaintext installs, so the card now says so. plus: a sparse saved menu order could invert the whole sidebar, the copy handler could read any element on the page, and a crafted layout that mentioned no earlier section put the new entry at the very top — the exact regression its own comment claimed to fix.
v1.14.3 — White-hat security round: a service foothold could reach root, a backup could disarm every rate limiter, and a rebound host could take the AI provider key
- The installer could hand a service foothold root: when install.sh is piped, bash has no script path, so the “anchor to the script’s own directory” rule silently fell back to
$PWD. Runningsudo bash install.shfrom /opt/zefira — a tree the service account owns — made root pip install from a requirements.lock that account could rewrite, and--require-hashestrusts the hashes in that same file. A piped install now never uses the working directory, and /opt/zefira is explicitly refused as a source. - A crafted backup could disarm every rate limiter:
trusted_proxieswas importable, and it is the KEY of every per-IP limiter — login brute force, subscription, restore, QR, probe. One attacker /32 in a “customer list” backup made every later request carry an attacker-chosenX-Forwarded-For(unbounded login guesses) and wrote that value into the audit log, so the operator’s own trail pointed at a third party. Likepublic_urlanddomain, it is set in Settings only. - A leaked API token could replace itself: minting one required no password, so a stolen
fulltoken called the same endpoint and received an equally powerful replacement — and revoking the one token the operator could see did not lock the attacker out. Every durable-credential operation now re-prompts for the admin password (as backup, restore and update already did), a new token expires after 180 days unless you ask for otherwise, and one admin may hold at most 20. - A rebound AI host could receive the provider key: the base URL was validated, then urllib resolved the name again when it opened the socket. A host whose DNS an attacker controls (two answers, low TTL) passed validation, passed the “is it local, should I send the key” check, and then connected to 127.0.0.1 — handing over a live credential. The connect is now pinned to the address that was validated, with the real hostname kept for the Host header and TLS.
- One restore request could hold a core for two hours: rows whose credentials cannot be validated are re-provisioned server-side, and an OpenVPN row mints a 2048-bit RSA key. The row cap is 10,000 and nothing bounded the work, so a 12 kB crafted file kept one core busy for close to two hours — and every mutating
/api/*route answers 409 while a restore holds the lock, so the whole write plane was down for that long. Re-minting is now budgeted, and the overflow is refused and reported instead of silently dropped. Inbounds are capped too, because each one multiplies a subscription rendered on the unauthenticated path. - A crafted backup could repoint the assistant: the AI settings were imported, so a backup could aim the panel at an attacker’s endpoint AND plant text in the system prompt the assistant treats as an ADMIN NOTE — and the next time you asked it anything, the traffic and up to nine tool calls went to the attacker. The whole AI setup is re-entered in Settings now, like the admin credentials.
- Snapshots were readable by every local user, and never pruned:
pg_dump --fileandmysqldump --result-filecreate their output with the process umask, so under the common 0022 the complete database — admin hashes, every customer’s private key, token hashes — was world-readable for the length of the dump. The panel now creates the file itself at 0600, and the dump and pre-update copies are pruned like the SQLite ones. On the docs side: the changelog printed its own emphasis tags as literal text, and every HTML entity in a translation printed as its own characters instead of the character it stands for,frame-ancestorsin a<meta>is ignored by browsers (there is a working frame-buster instead), and a protocol-relative knowledge-base link could leave the site silently.
v1.14.2 — Panel sections, end to end: the update button could die for good, and a REALITY link could be born dead
- The update button could die permanently: the refusal to update through an outdated systemd unit sat before the block that releases the update lock, so one click left the lock held for good — the card then read “updating” forever, hid the button, and every later attempt answered 409 until you restarted the service by hand. The reviewed-commit guard was also unreachable: the browser re-read the branch head at click time, so the SHA you approved was always the SHA that got installed. Both are fixed, and the card now says so when the code landed but the process still needs a restart.
- A REALITY link could be born dead: an empty SNI list was accepted, and the builder then used the connect address as the servername — something no REALITY deployment can verify, so every customer's link was broken while the panel reported a healthy save. An empty list is now refused, a legacy row falls back to the documented defaults, and the Clash config rotates through the list exactly like the share links instead of always pinning the first entry.
- One port, one listener: the conflict check compared protocol and port, so a VLESS inbound and a VMess inbound could both claim 8000 — a configuration no server can start. It also never ran when deleting a server node, which quietly moved its inbounds onto this panel's port and could shadow the main subscription port. Both are checked now, and the delete tells you which inbound blocks it. A node-pinned inbound with no host of its own also links to the node's address now, not to this panel's domain.
- The panel stopped lying to you: a failed update check (rate limit, offline) was shown as “up to date”, because the error branch also required an empty version; a successful update always ended on the red “no systemd?” warning because it compared the upstream head instead of the running commit; “Copy Both Keys” copied an empty private key after every reload (pasting that into Xray kills REALITY for every user); a failed token regen left that row's button dead for the session; the automatic guide download was eaten by the popup blocker with no message; the inbound Enabled column was hard-coded English; and a duplicate tunnel name could answer 500 instead of 409.
- Tests: a new
panel_sections_test.py(176 checks) walks all ten panel sections the way an operator does — create, edit, toggle, check, delete, and assert the state the screen renders, including what a customer actually receives when a node goes offline.frontend_bugs_test.pygrew 10 checks for the UI fixes above and no longer probes a hard-coded port, so its live checks cannot silently test the wrong server. Eight suites, 802 checks, all green.
v1.14.1 — Bug hunt: a customer could vanish on restore, and half the update card was invisible
- Nobody disappears on restore: a backup row with a missing or unreadable expiry (
null, empty, another tool’s export) failed validation and was counted as “skipped” — the customer was silently dropped from the panel. The plan length is now re-derived fromduration_days, a pending row comes back pending, and the backup no longer writes a made-up expiry that made restored customers instantly expired. - The update card talks again: the “commit is NOT signed” and stale-systemd warnings used a
hiddenattribute while the script toggled a class, so they could never appear — and since signed commits are required by default, a refused update failed silently after a cheerful “Update started”. The dashboard and the client apps also agree now on what a plan with no expiry means. - Copy actually copies: the docs copy button appended its own label to the text, so pasting the one-line installer into a shell ran
Copy: command not found. The Ask-AI assistant also stopped answering for good after one failed knowledge-base load (and left a 200 ms retry loop running), and the search index and knowledge base were fetched with a stale?v=that pinned them in the browser cache forever. - Panel buttons and lists recover: a failed delete, token reset or revoke left that row’s button greyed out for good; the inbound list could be re-rendered from a slower earlier response and silently revert a toggle; visiting the Dashboard dropped your search text while the table (and the CSV export) showed every row; out-of-range ports produced a raw validation error instead of a field message; “most used” mis-ranked every plan under 1 GB; the loaded REALITY public key sat in a hidden panel, leaving “Generate” (which rotates the server keypair) as the only way to see it; the CDN preset could display one value while saving another.
- Customer dashboard: switching tabs and coming straight back reloaded the page and threw away your scroll position (it measured the page age, not the time you were away); the language switcher could show a language the server never rendered; and if
i18n.jsever failed to load, every copy button threw and gave no feedback at all. - Docs polish + tooling: the sidebar search only matched the currently displayed language (typing an English term in Persian left the sidebar blank), a Persian “سلام” was answered with the off-topic refusal, a slow search index stayed on “Loading index…” forever, a failed index load looked like an empty one, and
docs/bump_assets.pyno longer resets every asset URL to?v=4when called without a version. - Tests: new
frontend_bugs_test.py(35 checks) pins the front-end fixes the HTTP suites cannot see, and the operator-path suite grew 15 restore checks. Seven suites, 678 checks, all green.
v1.14.0 — Security audit: what an attacker can reach with a backup, a browser or a bad update
- A backup file is no longer a backdoor: admin passwords and API tokens in a backup are never imported (a crafted file could ship a known token hash with full scope, or an admin row whose password the file's author knew), and
public_url/domainstay yours too — they decide where every customer's link and QR point. One malformed admin row no longer aborts the whole file either. - An expired account cannot keep using the service: opening the subscription link in a browser used to bypass the expiry and quota gates and hand back live credentials, QR and configs. The status page is now status-only, and the seller's internal note (payment refs, ticket ids) is no longer shown to the customer at all.
- OpenVPN directive injection closed: a crafted backup could append
up <script>/ a second<ca>/ a rogueremoteafter the certificate block, and those lines were written verbatim into every generated.ovpn— command execution as root on the customer's machine, or a full MITM of the tunnel. PEM blocks must now be exactly one block. - The update path is fail-closed: an incoming commit that adds
instance/secret.key, the database or.envis refused (it could have replaced your master key and forged admin sessions), the install is bound to the exact commit the Update card showed, a root or privileged-ExecStartPresystemd unit now blocks the update instead of only warning, signed commits are required by default, and dependencies install from a hash-locked set instead of a floating resolution. - Nobody can lock you out remotely: the account-wide login budget used to return a hard 429 for everyone (a known admin username + 13 IPs was enough). It is now a progressive backoff that keeps the correct password working, rate-limit counters near saturation are no longer evictable by a key flood, request bodies are bounded before authentication, and the destructive bot-scope routes got their own budget.
- Installer and secrets hardened: no
curl | sudo bashone-liner, the source comes from the installer's own directory (not the working directory a service foothold can edit), a symlinked.envorinstance/is refused instead of followed as root, a truncatedsecret.keyfails closed instead of silently destroying every encrypted setting, the OpenVPN CA key is written 0600 atomically, and the first-run password is never printed to the journal. - Smaller fixes: audit rows name the API token that acted (incident response), the reseller bots in
examples/refuse group chats so a subscription token can never be posted where others can read it, the BackPack guide stops on a failed hash check instead of continuing tosudo bash, and restore settings are capped and validated per row. - Tests + docs: new regression probes for the backup backdoor, the origin repoint, expired-account credentials, OpenVPN injection and the reset budget; the API table now documents the real
botscope and exactly what a restore refuses. All six suites green.
v1.13.9 — Third bug hunt: restore, configs and the panel UI
- Restore could 500: restoring a backup onto an instance that already had one of the plan templates raised
ProgrammingError(the row object was bound as a primary key) and the whole restore failed. A single malformed customer row also aborted the entire file; bad rows are now skipped and the good ones are kept. - Nothing silently lost: the encrypted restore dropped inbounds, server nodes and tunnels entirely (they are restored now); pinned inbounds lost their node (the backup records the node name); OpenVPN credentials signed by another host's CA are re-issued instead of shipped dead; a REALITY public key whose private half cannot be decrypted is no longer paired with the local key; API tokens are replaced by the backup's set instead of resurrecting revoked ones; last-seen history survives a restore.
- Configs that clients can actually load: a subscription that mixes share links with file-based configs is Base64 again (v2rayNG imported zero nodes from the plain-text bundle), the OpenVPN profile no longer demands the CA's name from the server certificate (
VERIFY_X509NAME ERRORon every normal cert), the Hysteria2 URI has the slash the spec requires, and quota rounding no longer tells a customer "out of volume" while the server still serves them. - Settings that were ignored:
ZEFIRA_HY2_PORT,ZEFIRA_WG_PORT,ZEFIRA_DNSandZEFIRA_OVPN_PROTOnever reached the generators;ZEFIRA_DOMAINwas skipped when building subscription links. Empty-label hostnames (a..b), a REALITY SNI likewww..comand apublic_urlon port 0 are now rejected instead of producing dead endpoints. - Panel UI: the API-token list no longer crashes (
tshadowed the translation function), a failed Add-User validation no longer locks the form until reload, one bad expiry can no longer blank the user table, notes can be cleared again, and stale async responses can no longer overwrite newer settings, lists, QR codes or modals. The REALITY public key is filled on load, saves are serialized, and1e3in a number field means 1000 instead of 1. - Concurrency + tests: first-use activation is an atomic claim, server-node probes can no longer stamp a repointed node, first-run credentials from a lost multi-worker race are no longer written, and mutating endpoints answer a retryable 503 (not a 500) on a database lock. New
attack_paths_test.py(64 checks) walks templates, inbounds, CSV, Clash, tunnels and the full backup/restore round-trip; all six suites are green (611 checks).
v1.13.8 — PATCH speaks REST: no more silent no-ops
- No silent failures: a patch made only of unrecognized keys used to answer
200and change nothing. For an integrator that is the worst possible outcome: a bot that sendsused_gbto cap a customer got a success and an uncapped customer. Such a patch is now a422that lists the accepted fields. - Absolute field names accepted:
used_gb,volume_gb,expires_at,note,device_limitanddaysnow set a value (PATCH semantics);add_used_gb/add_volume_gbstay the deltas. Sending both forms of one field, or an empty patch, is a422. - Documented + tested: the API page now lists every PATCH field with copy-paste examples, and the new
attack_quota_test.py(46 checks) walks quota accounting, expiry math, start-on-first-use, device limits and every schema boundary on both create and patch.
v1.13.7 — Deep audit: wrapped-metadata SSRF, restore buffering, QR budget
- Wrapped metadata blocked: IPv4-mapped IPv6 (
::ffff:100.100.100.200), 6to4 and Teredo addresses are unwrapped before the SSRF check. Before this, an admin-configured AIbase_urlpointing at a wrapped metadata address passed the guard and received your stored provider API key in theAuthorizationheader. - Anonymous restore uploads cut off: the restore body is buffered before the route authenticates, so an unauthenticated client could make the server hold ~128 MiB per request. A chunked upload is now refused with 411 before a single body byte is read, plus a per-source budget, one global restore slot and a 60 s upload deadline.
- QR endpoint budgeted: QR generation is CPU-bound and reachable with a bot token, so it is rate-limited per source and per token, memoised (a link's QR never changes), and the fallback
Hostheader is length-checked so a multi-kilobyte hostname can no longer inflate every generated code.
v1.13.6 — White-hat round: login DoS, Telegram injection, malformed input
- Login flood contained: every source IP now has its own budget (100 attempts / 15 min) checked before any password hashing, so rotating usernames can no longer turn the login form into a CPU/memory amplifier. Only four password hashes run at a time; surplus requests get 429 instead of parking a worker, and rate-limit buckets that are currently blocked can no longer be flushed by flooding throwaway keys.
- Telegram injection closed: values shown in notifications (the login username, the IP, a user name) are HTML-escaped before Telegram renders them, so an unauthenticated visitor can no longer plant a clickable phishing link in your security alerts.
- Malformed input never 500s: over-nested JSON, lone-surrogate characters and invalid UTF-8 rows are answered with 400 (and sanitised on read) instead of crashing the request — or, for a hand-edited database row, the entire users list.
- Canonical links: subscription links and QR codes now prefer the configured domain over the spoofable Host header.
- Attack suite: new
attack_test.pyfires 80 live adversarial probes (header spoofing, stored XSS, path traversal, body bombs, unicode, timing oracles, auth matrix) against a running panel. - Installer & updater: no privileged
ExecStartPre=+helpers left (they turned a service compromise into root viaLD_PRELOAD),umask 077so.envis never briefly world-readable, the copy step can no longer carry a plantedsecret.key, the panel binds loopback and the firewall keeps the port private when nginx is enabled,ZEFIRA_TRUSTED_PROXIES=127.0.0.1is set so every visitor gets their own login bucket, nginx no longer records subscription tokens and a broken vhost aborts instead of reporting success, and the updater installs exactly the commit SHA it advertised while showing that commit's signature state.
v1.13.5 — Feature QA: customer dashboard, Telegram hint, CSP
- Customers see why: opening a subscription link after the plan expired or ran out of volume now shows the status page (badge, usage, QR, import buttons, “Out of volume” / “Expired”) instead of a bare 404 — VPN clients still get the 404 as before.
- Telegram state visible: the settings card now actually shows whether a bot token is stored (a hidden JavaScript error left the hint blank forever).
- CSP-clean UI: the stale-systemd-unit warning uses a stylesheet class instead of an inline style that the Content-Security-Policy silently blocked.
- QA suite: new
feature_test.pywalks all 62 endpoints across the 10 panel sections (176 checks) and asserts the feature is usable, not just reachable.
v1.13.4 — Deep audit: concurrency, auth, AI, UI
- Atomic writes: every mutation commits once with its audit row (no more “500 after the user was created”), same-user edits are serialized (two simultaneous top-ups no longer lose an update), SQLite waits instead of failing with “database is locked”, and the rate limiters are thread-safe.
- Auth hardening: changing the password now revokes every API token (bots are told how many), an expired cookie falls through to a valid bearer token, bot tokens may use
reset-token(same effect as the already-allowed/reset), huge IDs return 404 instead of 500, and token/reality/tunnel actions are rate-limited. - AI & clients: Anthropic multi-turn tool use no longer 400s, long replies no longer break the next message, failed AI turns restore your prompt, unknown token scopes no longer restore as
full, MB/months are converted explicitly, and a Test button verifies the provider before first chat. - UI & ops: search races, double-submits, null-row render crashes, the 500-row export cap notice, tunnel regen now copies the new token and re-opens the guide, Telegram renders bold properly and explains 400s, SSL issue says it stores-but-does-not-deploy, the installer backs up
.env/vhost on re-run and validates ports, and the update page warns about stale systemd units.
v1.13.3 — Bug-fix round: validation, links, updater
- Strict numbers: volume/days/ports reject true and numeric strings (plain 30 still works, 0.01 GB floor everywhere); subscriptions emit one link per endpoint instead of 3 duplicates, and misconfigured REALITY is skipped instead of shipping dead links.
- Smarter clients: Clash auto-detect covers Mihomo/Stash, empty proxy lists fall back to DIRECT, and WireGuard addresses never collide past the id cap.
- Safer ops: the updater warns about outdated systemd units and refuses when the panel dir is not writable; restores re-arm stray far-future expiries, stats survive hand-edited rows, CSV export strips bidi spoofing, and downloads re-check login first.
v1.13.2 — Bug-fix round: copy, updater, pending display
- Copy works everywhere: copy buttons fall back when the browser clipboard API is unavailable (plain HTTP), and copy the exact server-built link including custom subscription paths.
- Updater fixed: panel updates no longer fail on read-only system dirs, and the installer deploys TLS plus RHEL nginx layouts correctly.
- Cleaner UI: pending users show no bogus expiry, templates load on open, resets restore every setting, and timezones/expiry editing agree.
v1.13.1 — White-hat rounds 5–7 hardening
- Abuse bounds: user creation is rate-limited (120/hour) and capped at 10,000 users, so a leaked bot token can no longer flood the database or rotate the audit trail away; restores take a safety snapshot first.
- Stricter builders: AI action arguments are strictly typed (no "false"-string flips), volume totals clamp at the documented cap, and link/config builders re-validate hosts, keys and filenames at the last gate. Shadowsocks links are now spec-correct URL-safe base64 — re-copy the link if a strict client complains.
v1.13.0 — BackPack v1.8.2 support
- New UDP transport: plain UDP joins the tunnel transport list (raw, lowest overhead) alongside KCP/QUIC.
- Direct mode covered: setup guide and docs now explain Direct mode (Iran dials out, no inbound port) with its 6 carriers, Link Test recommendations, WSS certificate needs, and IP:port tunnel pinning.
v1.12.0 — Persian, Chinese, Russian
- Full translation: panel, login and subscription pages plus all 12 docs pages in Persian, Simplified Chinese and Russian — RTL layout, language switcher, browser detection.
- Nothing missed: every button, toast, dialog, audit event and docs section covered by a permanent i18n coverage suite.
v1.11.1 — White-hat round 3
- Scope matrix verified: bot tokens probed with traversal, encoded slashes, trailing slashes, query strings and non-numeric IDs — all fail closed (403/404), AI chat stays bot-forbidden.
- Fresh search index (81 sections) + version-pinned index fetch; key-scrub guard hardened for empty keys.
v1.11.0 — Same-bug-class sweep (AI providers)
- Anthropic/Gemini URL tolerance: pasting a full
/v1/messagesor:generateContentendpoint no longer 404s — folded to the API root like Groq (Gemini key-bearing URLs are stripped, never logged). - Reasoning models: o-series / gpt-5 on OpenAI-compatible endpoints get
max_completion_tokensand no custom temperature (their 400s are gone too). - Anti-drift guards: restore validation uses the single
AI_PROVIDERSsource; providers, protocols and ports are cross-checked across backend, schemas, panel form and JS.
v1.10.0 — Groq-first AI (goodbye provider 404)
- Groq is a first-class provider and the default: pick Groq, paste a
gsk_…key, leave base URL empty. The panel normalizes every pasted form (bare host,/v1, full endpoint) tohttps://api.groq.com/openai/v1— the classic misconfigured-URL 404 is gone. - Actionable provider errors: 401/404/429/400 now say what to fix (key, URL/model, quota) instead of a bare unreachable message.
v1.9.0 — Site support AI
- Docs assistant: the ? bubble and support page grew an Ask AI assistant chat that answers from the docs — donating (wallets + copy), changelog, GitHub channels, panel summary, install, subscriptions, clients, troubleshooting — in English and Persian. Strictly site/panel scope with one-line refusals off-topic.
- Offline by design: curated
site-knowledge.json+site-prompt.mdbehavior spec, no backend, no API key, nothing stored; all rendering viatextContent(no HTML injection surface).
v1.8.1 — Encrypted backup in the UI + docs API examples
- Backup button offers encryption: one confirm downloads a scrypt+Fernet blob; the restore flow accepts
.enc.jsonvia/api/restore-encrypted(previously API-only — the UI rejected encrypted files). - Docs: copy-paste bot renewal flow (lookup → combo reset) with scope notes.
v1.8.0 — Developer reset API
- New endpoints for integrations:
GET /api/users/by-username/{username}(bots know usernames, not IDs) andPOST /api/users/{id}/resetcombo (reset_usageand/orreset_tokenin one call — renew/top-up flows, empty flags 400). - Bot scope widened for renewals:
bottokens can now lookup, QR,reset-usageand comboreset; deletes, patches, backup/restore, settings and tokens stay403.
v1.7.0 — AI that acts + complete docs
- Agentic AI assistant: the ? bubble now executes operations — create users, extend days, add volume, reset usage, pause/enable, find users, stats, subscription links. Provider-agnostic
```actionprotocol (same on OpenAI/Anthropic/Gemini), max 3 ops per turn, same validation as the panel buttons, audit-logged (AI_CREATE/AI_PATCH). Destructive jobs (delete, token reset, backup/restore, update, settings, tokens, passwords) stay click-guided, never executed. - Docs cover everything: expanded AI + Telegram bot guides (scopes, 5-minute reseller setup, examples), new Setup guides page (per-protocol client setup incl. L2TP/Cisco/SOCKS5, BackPack tunnel walkthrough, anti-filter cookbook), sidebar links everywhere.
- Smarter knowledge: AI manual grew with client setup, tunnel steps, anti-filter recipes and action rules.
v1.6.1 — White-hat audit round 2
- Restore secret validation: every imported
secret_datamust match exactly what the panel generates (UUID / token / WireGuard-key / OpenVPN-bundle / L2TP-JSON shapes, no control chars). Missing or forged secrets are re-provisioned server-side instead of imported — crafted backups can no longer corrupt subscription output. - Subscription path guard:
SUBSCRIPTION_PATHenv falls back to/subon anything but a plain path prefix (no.., no/-only, 64 chars max). - Installer: sudoers rule resolves the real
systemctlpath instead of assuming/bin. - Site: search-index fetch re-versioned, stale hardcoded section count made dynamic; full panel + docs rescan (subprocess list-form only, no
|safe, no inline handlers, escaped search highlights,rel=noopenereverywhere) — suites still 117/117 + 60/60.
v1.6.0 — L2TP, Cisco AnyConnect, SOCKS5
- 3 new protocols: L2TP/IPsec (per-user password + IPsec PSK, setup guides for Windows/Android/iOS/strongSwan), Cisco AnyConnect / OpenConnect (one-command connect string), SOCKS5 proxy (
socks5://links, Clashsocks5entries, dashboard card). - New server settings: L2TP port (1701), Cisco port (443), SOCKS5 port (1080) — live, backed up, validated like all ports. Inbounds stay relay-protocol-only (VLESS/REALITY/VMess/Trojan/SS/Hysteria2); the new protocols are single-endpoint like WireGuard/OpenVPN.
- Secrets rotate with Reset-token exactly like existing protocols; quota, restore validation, bot scopes and encrypted backups cover the new types automatically.
v1.5.0 — White-hat hardening round
- Non-root systemd: installer creates a
zefirasystem user, owns/opt/zefira, runs withUser=zefira+ProtectSystem=strict,PrivateDevices,UMask=0077. Update restarts via a passwordless sudoers rule scoped tosystemctl restart zefiraonly. Updater repo/branch pinned tomrlurix/zefira-panel@main(custom mirrors needZEFIRA_ALLOW_CUSTOM_REPO=1), service name fixed, kill-switchZEFIRA_ALLOW_UPDATE=0. - Quota enforced: subscriptions with
used_gb >= volume_gbnow 404 like expired/disabled (same for Clash/raw/dashboard).device_limitis officially advisory (shown in panel + Clash comment, never blocks) until reliable device counting exists. - Real body limits: size gate now counts actual streamed bytes (chunked bodies without
Content-Lengthcan no longer bypass the 1 MiB / 64 MiB restore caps). - SSRF blocked: AI
base_urlrejects userinfo, metadata hosts, link-local/multicast targets + DNS-rebinding (any resolved IP blocked = refused) and never follows redirects. Node probes (probe_host+ tunnel check) filter link-local/metadata dials; create/patch reject metadata addresses. - Restore hardened: admin
password_hashmust be scrypt withN>=2^14(power-of-two, sane r/p/salt/dk) or the row is skipped; zero-volume users skipped;trusted_proxiesand AI URL re-validated strictly on import; tokenscopespreserved. - Token scopes:
full(default) vsbot(least-privilege:GET /api/me,GET /api/stats,GET /api/users,POST /api/users,GET /api/templates). Bot tokens can create users includingstart_on_first_use(server-computed expiry, single-commit activation = bot-safe) but can never delete/patch, backup/restore, or manage settings/tokens. Reseller bot examples updated to requestbotscope. - Backups + secrets:
POST /api/backup {"encrypt": true}returns a scrypt+ Fernet blob (needs the admin password to decrypt) viaPOST /api/restore-encrypted.ZEFIRA_ADMIN_PASSWORDis one-time: scrubbed from.envon first boot.trusted_proxiesrefuses0.0.0.0/0, multicast, and overly-broad ranges at input and ignores them at runtime. - Suites still green: 117/117 pentest + 60/60 functional, plus new quota/scope/SSRF/encrypted-backup/chunked checks.
Latest — Server nodes with auto-failover
- Nodes section: register remote VPN servers with automatic health checks every 5 minutes (status, latency, uptime %).
- Pin inbounds to nodes: links from an offline/disabled node are automatically excluded from subscriptions until it recovers.
- Also fixed: the missing tunnel-delete endpoint the UI was already calling.
Latest — AI assistant, 2FA removed
- AI assistant bubble: a panel-only helper (OpenAI-compatible, Anthropic, Gemini) with an encrypted API key, scoped knowledge file and hourly quota.
- Two-factor auth removed entirely — endpoints, login flow, database columns (auto-migrated away), UI and docs. Single strong password is the gate now; password confirmation still guards backup, restore and updates.
- Active Security Layers info card removed from Settings.
2026-09-15 — One-click updates + safer updater
- Update section in the left menu (above Settings): compares your server with GitHub, shows the exact incoming changelog, then pulls + reinstalls + restarts on password confirm.
- Updater pulls from the same source it compared against, refuses on local changes, serializes runs, audits every step.
- Extending an expired account now counts from today instead of leaving it expired.
- Legacy admin usernames are normalized at startup (anti-lockout heal).
2026-09-14 — Full personalization
- Appearance & Branding card: accent/background/card colors via a live
/theme.css, custom brand name everywhere, and a message pinned on all user dashboards. - Appearance is validated, backed up, and covered by pentest checks (public endpoint leaks nothing).
2026-09-13 — User dashboard
- Opening a subscription link in a browser shows a personal dashboard: status, usage ring, days left, last active time + IP, one-tap app imports, per-protocol links, WireGuard/OpenVPN configs, QR and app downloads.
- VPN clients still get raw bytes (User-Agent detection, unknown defaults to raw); expired/disabled users get 404 in both modes.
- Subscription link remarks show the plain username.
2026-09-07 — Locks & gates
- Missing Edit user button wired up (note, volume, expiry, device limit, reset usage).
- 2FA enable/disable and backup download now need your password, not just a session.
- Cross-server restores safely drop undecryptable 2FA (
tfa_dropped) instead of locking you out; junk protocols are sanitized. - Installer enforces the panel password policy (10+ chars, letters + digits).
- Python 3.14 silent-annotation misroute fixed + a guard script that checks all 53 routes.
2026-09-06 — Installer, SSL & docs site
- Branded 7-step installer with a dedicated Nginx + Let's Encrypt step and quoted secrets.
- One-click SSL issue/renew from Settings.
- This documentation site: landing page, Ctrl+K search, changelog, responsive red-black theme on GitHub Pages.
Earlier — Security hardening waves
- Logout kills sessions server-side, proxy-aware Secure cookies + HSTS, 64 MB restore allowance, lockout-DoS resistant limits, IPv6-safe IP blocking, first-run hardening.
- External MySQL/PostgreSQL crash fixed, dialect-agnostic migrations, DB drivers bundled.
- CSP-safe templates (dead buttons and inline styles removed), quoted
.envsecrets, DB input validation. - Pentest suite grown to 91/91 passing checks.
ZEF