Pasting a JSON config and clicking OK failed with "Something went wrong"
because validation read the empty form-side tag input instead of the
JSON's tag. Switching from the JSON tab to Basic also discarded any
JSON the user had pasted.
- onOk now validates and submits from the JSON tab using the parsed JSON
- Tab switch JSON→Basic deserializes the JSON back into the structured form
- Invalid JSON keeps the user on the JSON tab with a clear parse error
- Empty form-tag / duplicate-tag errors are now specific, not generic
Replace the single regenerable API token with a named-token list:
- New ApiToken model + service with constant-time auth matching
- Seeder migrates the legacy `apiToken` setting into a "default" row
- Security tab gets create/enable/delete UI; api-docs page links to it
- Dedicated "API Tokens" section in the in-panel docs
URL anchors now reflect the active tab/section on Settings, Xray, and
API Docs pages, so deep links like `/panel/settings#security` work.
Translations for the 8 new SecurityTab strings added across all locales.
- Grip-handle drag-and-drop on the # cell to reorder rules, built on
Pointer Events so the same code works for mouse, touch, and pen
(HTML5 drag doesn't fire from touch on iOS Safari). 5px threshold
keeps quick taps from triggering a reorder; up/down arrow menu
items stay as a keyboard/a11y fallback. Drop indicator is a 2px
blue line on the target edge; dragged row fades to 40%.
- Split the old combined target column into Outbounds and Balancer
columns. Each row now has exactly one populated cell — green
outbound tag or purple balancer tag.
- Mobile drops the a-table (520px+ of column widths overflowed every
phone) for a stacked card layout: # + grip + actions on top, an
"Inbound → Outbound/Balancer" flow row in the middle, and criteria
chips (domain, IP, port, src IP/port, L4, protocol, user, VLESS)
below for whichever fields are actually set. Multi-value chips
collapse to "first +N" with full value on hover.
* style(api-docs): redesign TOC, section icons, endpoint rows, and code blocks with ultra-dark support
* style(api-docs): rename visibleSections to visibleEndpoints, drop dead toc-stuck CSS
- visibleSections counted endpoints, not sections — rename matches
the displayed "X / Y endpoints" label.
- .toc-nav.toc-stuck was never toggled by any code path.
* docs(api): add missing POST /panel/api/inbounds/:id/resetTraffic entry
This route was added in #4334/#4338 but endpoints.js wasn't updated,
breaking TestAPIRoutesDocumented (91 routes in source, 90 documented).
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
When the main panel creates an inbound assigned to a remote node,
the wireInbound helper sends StreamSettings as-is, including
certificateFile/keyFile paths that only exist on the main panel's
filesystem. The remote node's Xray then fails to load them and crashes.
This adds sanitizeStreamSettingsForRemote() which strips file-based
cert paths before forwarding to a remote node. Inline certificate
content (certificate/key) is preserved unchanged.
Closes#4335
Add UserService.BumpLoginEpoch and call it from updateSetting when
TwoFactorEnable flips false → true. Existing cookies (issued under
the looser no-2FA policy) get a 401 on their next request and are
forced through the login flow. Disabling 2FA is a relaxation and
does not bump the epoch — sessions stay valid.
Also fix the dev-mode 401 redirect: targeting `${basePath}login.html`
breaks when basePath isn't "/" (Vite has no file at e.g.
"/test/login.html"; the SPA fallback loops the 401). Navigate to
basePath instead — Vite's bypassMigratedRoute and Go's index
handler both serve login.html for that path.
Strip stale doc-comment from netsafe and IndexController.logout
in line with the project's no-inline-comments convention.
AddInboundClient and UpdateInboundClient previously accepted an
empty Email field for every protocol except shadowsocks (where
email doubles as the client ID). Empty emails break downstream
features that key off email — IP-limit logging, traffic stats,
client-online tracking, subscription remarks.
Reject empty/whitespace-only emails at the service layer so the
API surface (POST /panel/api/inbounds/addClient and
/updateClient/:id) returns a clear error instead of persisting
an unidentifiable client.
Also drop the stale `len(Email) > 0` guard in UpdateInboundClient
that became dead code once empty emails are rejected.
The Node.Probe and Remote.do paths built outbound URLs by string-
formatting admin-controlled fields (Scheme/Address/Port/BasePath)
straight into requests, then dialed the result with the default
transport. CodeQL flagged this as go/request-forgery — an admin
(or anyone who compromises the admin account) could point a node
at internal infrastructure (cloud metadata, RFC1918 ranges, etc.)
and the panel would dutifully fetch it.
Add util/netsafe with a shared TOCTOU-safe DialContext that
resolves the host, rejects private/internal IPs unless the
per-request context whitelists them (per-node AllowPrivateAddress
flag, plumbed through context.Value), and dials the resolved IP
directly so the IP that passed the check is the IP we connect to.
This closes the DNS-rebinding window where a hostname could
resolve to a public IP at check time and a private one at dial.
Also tighten address validation (NormalizeHost rejects anything
that isn't a bare hostname or IP literal — no embedded paths,
userinfo, schemes) and switch URL construction from fmt.Sprintf to
url.URL{} + net.JoinHostPort so admin-supplied values can't smuggle
URL components.
custom_geo.go's isBlockedIP now delegates to netsafe so there's
one source of truth.
- ci.yml: only run on Go/frontend source and lockfiles.
- codeql.yml: scope push/PR triggers to Go and JS/TS sources;
weekly cron still does a full scan.
- release.yml: add matching paths allowlist to pull_request so
doc/workflow-only PRs don't kick off the multi-arch build.
Skips workflow runs on changes to docs, translations, GitHub
configs, and unrelated scripts.
Routine update of Go module dependencies and tidy: bump indirect deps (github.com/quic-go/quic-go v0.59.0→v0.59.1, github.com/sagernet/sing v0.8.9→v0.8.10, github.com/tklauser/go-sysconf v0.3.16→v0.4.0, github.com/tklauser/numcpus v0.11.0→v0.12.0), and update several golang.org/x modules (arch, exp, mod, net, tools) and google.golang.org/genproto. Removed duplicate require entries (x/crypto, x/sys, x/text) and updated go.sum to match the new versions.
* refactor(session): store user ID in session instead of full struct
Replaces storing the full User object in the session cookie with just
the user ID. GetLoginUser now re-fetches the user from the database on
every request so credential/permission changes take effect immediately
without requiring a re-login. Includes a backward-compatible migration
path for existing sessions that still carry the old struct payload.
* feat(auth): block panel with default admin/admin credentials and guide credential change
checkLogin middleware now detects default admin/admin credentials and
redirects every panel route to /panel/settings until they are changed.
The settings page auto-opens the Authentication tab, shows a
non-dismissible error banner, and lists 'Default credentials' first in
the security checklist. Login response includes mustChangeCredentials
so the login page can redirect directly. Logout is now POST-only.
Password must be at least 10 characters and cannot be admin/admin.
* feat(settings): redact secrets in AllSettingView and add TrustedProxyCIDRs
Introduces AllSettingView which strips tgBotToken, twoFactorToken,
ldapPassword, apiToken and warp/nord secrets before sending them to
the browser, replacing them with boolean hasFoo presence flags. A new
/panel/setting/secret endpoint allows updating individual secrets by
key. Secrets that arrive blank on a save are preserved from the DB
rather than overwritten. Adds TrustedProxyCIDRs as a configurable
setting (defaults to localhost CIDRs). URL fields are validated before
save.
* fix(security): SSRF prevention, trusted-proxy header gating, CSP nonce, HTTP timeouts
Adds SanitizeHTTPURL / SanitizePublicHTTPURL to reject private-range
and loopback targets before any outbound HTTP request (node probe,
xray download, outbound test, external traffic inform, tgbot API
server, panel updater). Forwarded headers (X-Real-IP, X-Forwarded-For,
X-Forwarded-Host) are now only trusted when the direct connection
arrives from a CIDR in TrustedProxyCIDRs. CSP policy is tightened with
a per-request nonce. HTTP server gains read/write/idle timeouts. Panel
updater downloads the script to a temp file instead of piping curl into
shell. Xray archive download adds a size cap and response-code check.
backuptotgbot is changed from GET to POST.
* feat(nodes): add allow-private-address toggle per node
Adds AllowPrivateAddress to the Node model (DB default false). When
enabled it bypasses the SSRF private-range check for that node's probe
URL, allowing nodes hosted on RFC-1918 or loopback addresses (e.g.
a private VPN or LAN setup).
* chore: frontend UX improvements, CI pipeline, and dev tooling
- AppSidebar: logout via POST /logout instead of navigating to GET
- InboundList: persist filter state (search, protocol, node) to
localStorage across page reloads; add protocol and node filter dropdowns
- IndexPage: add health status strip (Xray, CPU, Memory, Update) with
quick-action buttons
- dependabot: weekly go mod and npm update schedule
- ci.yml: add GitHub Actions workflow for build and vet
- .nvmrc: pin Node 22 for local development
- frontend: bump package.json and package-lock.json
- SubPage, DnsPresetsModal, api-docs: minor fixes
* fix(ci): stub web/dist before go list to satisfy go:embed at compile time
* chore(ui): remove health-strip bar from dashboard top
* Revert "feat(auth): block panel with default admin/admin credentials and guide credential change"
This reverts commit 56ce6073ce.
* fix(auth): make logout POST+CSRF and propagate session loss to other tabs
- Switch /logout from GET to POST with CSRFMiddleware so it matches the
SPA's existing HttpUtil.post('/logout') call (previously 404'd silently)
and blocks GET-based logout via image tags or link prefetchers. Handler
now returns JSON; the SPA already navigates client-side.
- Return 401 (instead of 404) from /panel/api/* when the caller is a
browser XHR (X-Requested-With: XMLHttpRequest) so the axios interceptor
redirects to the login page on logout-in-another-tab, cookie expiry,
and server restart. Anonymous callers still get 404 to keep endpoints
hidden from casual scanners.
- One-shot the 401 redirect in axios-init.js and hang the rejected
promise so queued polls don't stack reloads or surface error toasts
while the browser is navigating away.
- Add the CSP nonce to the runtime-injected <script> in dist.go so the
panel loads under the existing script-src 'nonce-...' policy.
- Update api-docs endpoints.js: GET /logout doc entry was missing.
* fix(settings): POST /logout after credential change
* fix(auth): invalidate other sessions when credentials change
When the admin changes username/password from one machine, sessions
on every other machine kept working until they manually logged out
because session storage is a signed client-side cookie — there is
no server-side session list to revoke.
Add a per-user LoginEpoch counter stamped into the session at login
and re-verified on every authenticated request. UpdateUser and
UpdateFirstUser bump the epoch (UpdateUser via gorm.Expr so a single
update statement is atomic), so any cookie issued before the change
no longer matches the user's current epoch and GetLoginUser returns
nil — the SPA's 401 interceptor then redirects to the login page.
Backward compatible: the column defaults to 0 and missing cookie
values are treated as 0, so sessions issued before this change
remain valid until the first credential update.
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
- endpoints.js: replace `\"` with `\\"` in xray response example so the
rendered docs actually show escaped JSON-in-JSON (the original
single-quoted `\"` collapsed to a bare `"` and produced malformed output).
- CodeBlock.vue: drop the unnecessary `\[` inside the regex character
class `[{}\[\]]`; `[` does not need escaping inside `[...]`.
* feat(api-docs): enhance API documentation with missing endpoints, search, collapse, and route sync test
- Add 29 undocumented routes across 4 new sections (Settings, Xray Settings,
Subscription Server, WebSocket) plus 4 missing Server API endpoints
- Fix inaccuracies: history metric keys, node metric keys, VLESS enc description
- Add response schemas to 15+ key endpoints
- Add search bar and expand/collapse all controls to the docs page
- Add collapsible endpoint sections with endpoint count
- Add Go test (TestAPIRoutesDocumented) to verify all Go routes are documented
* feat(api-docs): add JSON syntax highlighting and top-right copy button to code blocks
* fix(api-docs): use distinct colors for JSON syntax highlighting (green strings, amber numbers)
* feat(api-docs): add request body examples, error responses, WebSocket message types, and subscription response headers
* fix(api-docs): use ClipboardManager.copyText instead of copy to fix API token copy button
When searching for a user, the projected DBInbound only contains the
matching clients, so isRemovable evaluated to alse (since a single
match made clients.value.length === 1), hiding the Delete button.
Pass the original total client count from the parent's clientCount
prop and use it in the isRemovable check instead of the projected
clients array length.
* fix(hysteria2): restore missing masquerade config in inbound form
Fixes#4303
The Hysteria2 Masquerade option was missing from the Stream settings
tab after the v3.0.0 form rewrite. Added the UI form and ensured the
masquerade block is passed through in subscription JSON generation.
Since v2.9.4, disableInvalidClients sets c['enable']=false in inbound settings JSON when a client hits its limit. autoRenewClients only updated client_traffics.enable - never flipped the JSON field back. The Xray config generator checks both, so client stayed excluded after renewal.
Vue 3's whitespace: condense strips bare whitespace text nodes and
trailing whitespace inside elements, causing the <template> trick
to fail. Use mustache interpolations (which compile to _createTextVNode)
for all spacing between fields so they survive compilation.
The menu item, backend endpoint (POST /panel/api/inbounds/:id/copyClients),
and i18n keys were already in place after the Vue3 migration, but the modal
itself was never ported — clicking the menu just toasted "coming soon".
Adds CopyClientsModal.vue: source inbound dropdown (multi-user inbounds
except the target), per-client checkbox selection via a-table row-selection,
optional Flow override when the target supports TLS flow, and result toasts
for added/skipped/errors.
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management
* Revert "feat(traffic_writer): enhance traffic writer with concurrency safety and state management"
This reverts commit e6760ae396.
* feat(vless): clarify VLESS encryption auth selection and enhance parsing logic
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management
* Revert "feat(traffic_writer): enhance traffic writer with concurrency safety and state management"
This reverts commit e6760ae396.
* feat(traffic_writer): enhance traffic writer with concurrency safety and state management
* feat(web): implement panel-only start/stop methods for in-process restarts
Polls xray's /debug/vars on the 2s status tick, stores memstats and per-outbound observatory delay in the metric history ring buffer, and exposes them through a new XrayMetricsModal opened from the Charts card. Restructures the dashboard to consolidate uptime, usage, version, and Telegram link into stat-style or action-style cards consistent with the existing AntD aesthetic.
After a SIGHUP-driven panel restart (which is exactly what the frontend
triggers after a successful DB import via /panel/setting/restartPanel),
the previous implementation deadlocked:
1. server.Stop() called StopTrafficWriter — cancels the context and waits
for the consumer goroutine to exit. The goroutine dies.
2. server.Start() called StartTrafficWriter, but sync.Once had already
fired, so it was a no-op. twQueue still pointed to the old channel
with no consumer.
3. startTask() → RestartXray(true) → GetXrayConfig() →
InboundService.AddTraffic(nil, nil) → submitTrafficWrite. The send
to twQueue succeeded (buffer space) but <-req.done blocked forever
because no goroutine was draining the channel.
4. RestartXray held the global xray lock for the entire hang, so every
subsequent restart attempt from the panel UI also blocked on
lock.Lock(). User-visible symptom: xray stopped silently after DB
import and no panel action could revive it.
Replace sync.Once with a mutex-guarded Start that spawns a fresh
goroutine on each cycle, and a Stop that resets the package state so
the next Start works. runTrafficWriter now takes its channels as
parameters instead of reading package vars, so the old goroutine can't
interfere with a new one if their lifetimes briefly overlap.
- New GET /panel/api/inbounds/getSubLinks/:subId and /getClientLinks/:id/:email
return the same protocol URLs the panel UI's Copy button emits, honouring
X-Forwarded-Host / X-Forwarded-Proto. Documented in the API docs page.
- Refactor: sub package no longer imports web. The embedded dist FS is
injected via sub.SetDistFS, and the link generator is registered with the
service layer via service.RegisterSubLinkProvider, avoiding the circular
import the new endpoints would otherwise introduce.
- Security: stop emitting window.X_UI_CUR_VER on login.html and drop the
visible version chip from the login page, so the panel version is no
longer pre-auth info disclosure. Authenticated pages still receive it.
- Bump config/version.
New /panel/api-docs route with a one-page reference covering every
/panel/api/* endpoint (Auth, Inbounds, Server, Nodes, Custom Geo,
Backup) plus a Bearer-token primer that reads the current token and
exposes Show/Copy/Regenerate inline. Sidebar gets an API Docs entry
right after Xray; the menu label is shared via menu.apiDocs across all
13 locales.
Different nodes are different machines, so same port + transport across
NodeIDs shouldn't conflict. resolveInboundTag now keeps a caller-supplied
unique tag verbatim so central and node panels stay in agreement instead
of regenerating into a UNIQUE constraint failure on sync.
New installs land on plain dark instead of ultra-dark. The cycle button
icon now has an explicit colour so it stays visible inside the mobile
drawer (the previous color:inherit didn't cascade through the teleported
node), and hover/focus matches the menu's blue across sidebar, login,
and sub pages.
The desktop sider stretched to match the page height, so below lg
(992px) where dashboard cards stack into one column the collapse
trigger plus Logout slid off-screen. Pin the sider with
`position: sticky; height: 100vh; align-self: flex-start` so the chrome
stays viewport-tall. Split the menu into `.sider-nav` (flex: 1,
scrollable) and `.sider-utility` so Logout sits directly above the
48px trigger reserved by padding-bottom.
Replace the `<ThemeSwitch>` a-sub-menu with a single inline icon
button next to the '3X-UI' brand (sun / moon / moon+star SVG). One
click cycles Light -> Dark -> Ultra Dark -> Light. ThemeSwitch.vue
removed since it is now inlined.
Override AD-Vue dark Menu selected + hover/active state on the
sider-nav, sider-utility, and drawer menus to use the same light-blue
tint AD-Vue's light theme uses (rgba(64,150,255,0.2) / #4096ff). The
default dark variant was too subtle against #252526, so the current
page and Logout-on-hover barely distinguished themselves.
DelClient rejects the removal that would leave an inbound with zero
clients (the constraint exists because Xray protocols need at least
one client to keep the inbound functional). The bulk-delete flow
fired one DelClient call per picked client in a loop, so picking
every client meant the final iteration always errored out with
"no client remained in Inbound" and surfaced as a red toast even
though N-1 deletions had already gone through.
Now confirmBulkDelete detects the "all selected" case up front,
drops the last client from the request, and surfaces the partial
operation in the confirm dialog ("N-1 / N — last selected will
remain. Delete the inbound to remove all."). The pre-existing
single-row delete path and partial-selection bulk delete paths are
untouched. If the only client in the inbound is selected, a
Modal.warning explains the constraint instead of asking for confirm.
Frontend (NordModal.vue):
- Server selector gets show-search with the option label set to
`${cityName} ${name} ${hostname}` so admins can find a specific
server inside a 100+ entry country list by typing.
- Each option renders the load as a colored a-tag (green <30%,
orange 30-70%, red >70%) instead of plain text — quicker visual
scan when sorting through servers in the dropdown.
Backend (nord.go):
- GetCountries / GetServers now check resp.StatusCode and return
"NordVPN API error: <status>" on non-200, matching the pattern
GetCredentials already used. Previously a 4xx/5xx body was
returned as a "success" string and the frontend silently failed
to parse it, surfacing only as an empty "No servers found".
- GetCredentials drops its own ad-hoc 10s http.Client and reuses
the shared nordHTTPClient (15s) — one client, one timeout.
- ClientRowTable now applies the General-Settings pageSize to its
expanded client list. The 3.0 rewrite dropped pagination, so users
with thousands of clients per inbound hit a 30-60s browser hang on
expand (#4233).
- ID column was marked responsive: ['xs'] so it was hidden on desktop;
removed the restriction so it shows as the first column everywhere.
- Remark column is now omitted entirely when no inbound has a non-empty
remark, matching the existing Node-column pattern.