mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-25 04:17:15 +00:00
da01b7637dc317098e84ef54008fafdb722aaf35
407 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
da01b7637d |
feat(sub): client-side balancers for the JSON subscription (#6243)
* feat(sub): add SubBalancer model and migration Client-side JSON-subscription balancer row: remark, strategy, member inbound ids, sort order, enabled. Registered in allModels and migrationModels so AutoMigrate and SQLite->Postgres copy pick it up. * feat(sub): add SubBalancer service List/Get/Create/Update/Delete over the sub_balancers table with remark trim, strategy allowlist (leastLoad/leastPing/random) and sort-order floor. Rows are read per request by the subscription builder, so mutations need no xray restart. * feat(sub): add SubBalancer API controller and routes GET/POST /panel/api/sub-balancers, POST /:id (update), DELETE /:id and POST /:id/del alias. inboundIds bind from repeated form keys. Mounted under the /panel/api group so the existing API token + CSRF middleware cover it. * feat(sub): emit client-side balancers in JSON subscription For each enabled balancer, append one config document whose outbounds are the selected inbounds' proxy outbounds retagged under a per-balancer prefix, with routing.balancers + burstObservatory selecting it. Balancer entries interleave with inbound entries by sort order; on equal numbers the balancer follows the inbound. Skipped when disabled or no member outbound is present. * test(sub): cover SubBalancer service and JSON output Service: validation gates (remark/strategy/inbound ids/sort order) and CRUD round-trip. JSON: balancer document shape, sort interleaving with inbounds, disabled/empty skip, and member tag dedup. * feat(sub): add sub-balancers i18n keys pages.settings.subBalancers.* block (menu, title, add, desc, field labels, strategy names, sort-order help, validation messages) added to all 13 locales. * feat(sub): add SubBalancer schema and API queries Zod schema (entity + form, strategy enum, validation messages wired to i18n keys), react-query hooks for list/create/update/delete, and the sub-balancers query key. * feat(sub): add subscription balancers settings tab SubscriptionBalancersTab lists balancers (sort order, remark, strategy, inbound count, enabled toggle, edit/delete) with a form modal (remark, strategy, sort order, multi-select inbounds filtered to multi-client protocols, enabled). Wired into SettingsPage under #subscription-balancers, and the sidebar shows the entry only when JSON subscription is enabled. * test(sub): add SubBalancer form modal test Covers add-mode (no validation errors, confirm with parsed values) and edit-mode (seeds from the balancer, preserves strategy/sort order/enabled). * feat(sub): register sub-balancers in API docs and OpenAPI Adds the sub-balancers endpoint group to endpoints.ts (list/create/update/delete + POST del alias) and regenerates frontend/public/openapi.json from it. * docs: sync openapi.json with frontend docs/public/openapi.json had fallen behind frontend/public/openapi.json (fewer paths/schemas). Copy the current frontend spec so the docs site renders the full API. * docs: add subscription balancers API reference Registers the sub-balancers page (generated MDX) and adds the sub-balancers paths to docs/public/openapi.json so the page renders the list/create/update/delete operations. * feat(sub): accept roundRobin balancer strategy Add roundRobin to the model oneof tag and the service strategy allowlist, alongside leastLoad/leastPing/random. Covered by a service-level create test that fails on the old allowlist. * feat(sub): add roundRobin strategy label pages.settings.subBalancers.strategyRoundRobin added to all 13 locales. * feat(sub): expose roundRobin in balancer form Zod strategy enum, form modal label key, and table strategy colour for roundRobin. * docs(sub): list roundRobin in strategy description The create/update strategy param description now mentions roundRobin alongside the other three. * feat(sub): add subJsonObservatory setting Panel-wide JSON string carrying the burstObservatory ping config (destination, connectivity, interval, sampling, timeout, httpMethod) emitted into client-side balancer docs. Stored like subJsonMux/Rules/FinalMask. * feat(sub): wire observatory config through sub controller WithSUBJsonObservatory option; the controller calls SubJsonService.SetObservatoryConfig after construction. * feat(sub): emit observatory conditionally with configurable probes burstObservatory is emitted only for leastPing/leastLoad; random/roundRobin get none (no fallback, so an observatory would only probe for nothing). Probe params come from the subJsonObservatory setting, falling back to the built-in defaults when empty or partial. Test covers the conditional emit and the override. * feat(sub): add subJsonObservatory to AllSetting model Frontend AllSetting model and Zod schema carry the new panel-wide observatory config string. * feat(sub): add balancer observatory config card New Sub Formats tab editing destination/connectivity/interval/sampling/timeout/httpMethod, stored as JSON in subJsonObservatory. Toggle off clears the setting; the backend then falls back to defaults. * fix(sub): hide save/restart header on sub-balancers tab Sub-balancer mutations are incremental (own CRUD API, no Save, no restart), so the page-wide 'every change needs to be saved / restart the panel' banner is misleading there. The in-tab alert already explains it correctly. * feat(sub): add observatory config i18n keys pages.settings.subBalancers.observatory.* (title, desc, probe field labels and help texts) added to all 13 locales. * feat(sub): regenerate openapi for subJsonObservatory openapigen picks up the new AllSetting field; openapi.json synced into docs. * feat(sub): add observatory tab to sub-balancers Mirrors the Xray Balancers page: two tabs (Balancers + Observatory). Wires allSetting/updateSetting into the tab and adds tabBalancers / tabObservatory labels to all locales. The page Save header is shown again on this tab so the observatory config can be saved. * refactor(sub): drop observatory tab from sub-formats Now that the observatory config lives under sub-balancers, remove the duplicate tab plus its state and defaults from sub-formats. * fix(sub): add missing inboundsCount i18n key The sub-balancers table rendered the raw key path in the Inbounds column because pages.settings.subBalancers.inboundsCount was not defined. Added it to all 13 locales. * test(sub): pin disabled-inbound exclusion from balancer The balancer builds its members from the subscriber's already-filtered entry set, so an inbound disabled for that user can never surface as a member. Adds tests for both shapes (one of several disabled, and the only selected one disabled). * fix(sub): make observatory toggle honest, default connectivity off, add balancer fallback Three coupled defects on the balancer observatory surface, flagged in PR review: - The Observatory Switch wrote '' which the Go side treats as "use built-in defaults", so leastPing/leastLoad still shipped a burstObservatory the admin could no longer see or edit. The observatory is mandatory for these strategies (Xray refuses to start leastPing/leastLoad without one — verified against Xray 26.7), so the switch is relabelled to "customise probe parameters vs built-in defaults" rather than on/off: '' keeps the defaults, a stored JSON overrides them. An info Alert explains this. - Connectivity defaulted to http://www.google.com/generate_204 and an explicit {"connectivity":""} restored it, so the UI's "Leave empty to skip" was unreachable and the direct pre-check was dead on arrival on censored client networks. Default to "" and honour an explicit empty value. - routing.balancers had no fallbackTag, so a leastPing/leastLoad balancer whose probes all fail selects nothing and dispatch fails. Emit fallbackTag pointing at the first member so a probe outage degrades instead of breaking. Also skip balancer entries (kind!=0) in the member scan so a balancer can never match another balancer's row id. Tests cover each fix and fail without it. * fix(sub-balancer): localize controller toasts and reject malformed ids Route the new controller's user-facing messages through I18nWeb so non-English admins get localized toasts like every other controller, and switch parseID to strconv.Atoi rejecting ids < 1 so "12abc" and negative ids no longer coerce to a silent no-op delete that reports success. * fix(sub-balancer): enforce remark length cap server-side The model's validate:"max=256" tag was never enforced (parseSubBalancerForm binds an ad-hoc struct without validate.Struct), so a scripted API client could store an unbounded remark that is emitted verbatim as the remarks field of every affected subscriber's config. Reject len > 256 in validate() to match the frontend Zod cap. * fix(sub-balancer): exclude mtproto from balancer member picker SubJsonService.getConfig has no mtproto case, so an mtproto inbound's first outbound is "direct" and the buildBalancerConfig "tag != proxy" guard drops it — an admin could select it, save without error, and get a balancer that silently omits it (or no document at all). Drop it from the picker and fix the comment. * docs(sub-balancers): add nav entry, fix tab pointer, note mirror scope - Add "subscription-balancers" to the en reference/api meta.json pages array so the new MDX page is reachable from the sidebar (fa/ru/zh have no MDX — gen-openapi.ts emits into en only). - Fix the endpoints.ts section description from "Settings -> Subscription" to "Settings -> Sub Balancers" (the feature's own tab) and regenerate the OpenAPI spec + MDX. - Note in docs/lib/xray/subscription.ts that balancer documents are intentionally out of scope for that mirror. * style(model): trim SubBalancer comment to 2-line cap CLAUDE.md caps committed Go comment blocks at 2 lines; this one was 3. * fix(sub-balancer): parse enabled explicitly and preserve it on partial update parseSubBalancerForm treated any non-"false" value as true (so "bogus" silently enabled) and always overwrote Enabled on update, so a PATCH that omitted the toggle reset a disabled balancer back to enabled. Parse the field with strconv.ParseBool and return *bool: absent means "no change" on update and "true" on create; a malformed value is rejected as 400. Update keeps the stored Enabled when the pointer is nil. * fix(sub-balancer): clear deleted inbound from sub_balancers.InboundIds DelInbound cascaded hosts but left the deleted inbound id in every sub_balancers.InboundIds, so the balancer kept emitting a member no subscriber could resolve — a dangling outbound tag with no proxy behind it. Strip the id inside the existing delete transaction (same shape as the hosts cascade, #5648); with the last member gone the balancer stops emitting. * fix(sub-balancer): return not-found when deleting a missing balancer Delete returned the gorm result error only, which is nil when no row matched, so the controller reported success:true for an id that never existed — a stale UI row looked like a clean delete. Check RowsAffected and return a not-found error on 0 so the toast reflects reality. * style(sub): shorten leastPing/leastLoad observatory comments The observatory-emission guard comment and its test comment ran a few lines long; trim them to a couple of lines each without dropping the invariant that leastPing/leastLoad require a burst observatory. * fix(sub): validate observatory setting instead of silently dropping it SetObservatoryConfig applied whatever survived json.Unmarshal with no checks, so a bad probe URL ("not-a-url"), non-duration interval/timeout, or even unparseable JSON was either silently applied or silently ignored. Validate each field: parse durations with time.ParseDuration, require http(s) URLs for destination/connectivity, and log a warning naming the field and the bad value on every fallback — including the unmarshal error, which was a quiet return. Bad values now keep the built-in defaults instead of leaking into the emitted burstObservatory. * fix(sub): deduplicate burst-observatory defaults across Go and frontend The burst-observatory ping defaults lived in three places that had drifted: Go defaultSubBalancerObservatoryConfig (http probe, sampling 3), the Zod PingConfigSchema, and DEFAULT_BURST_OBSERVATORY (both with a connectivity pre-check URL). Align them to one set: https probe destination, sampling 2, and empty connectivity (skip the direct pre-check). The settings tab now parses the stored JSON through PingConfigSchema and seeds its default from DEFAULT_BURST_OBSERVATORY instead of carrying its own literal. * refactor(sub): extract proxy outbounds once before the balancer loop buildBalancerConfig unmarshalled every inbound document and re-extracted its first outbound on each balancer, so with B balancers and N inbound docs the same document was parsed B*N times. Pull each doc's proxy outbound in a single pre-pass over the entries and cache it per entry; buildBalancerConfig now clones the cached map before retagging, so one parse serves every balancer. Output is byte-for-byte unchanged. * fix(sub): form balancer member tags from the inbound protocol, not tcp→vless balancerTransport derived the bal-N tag suffix from the outbound's transport network and hard-coded tcp→vless, so a vmess/tcp or trojan/tcp member was mislabelled "vless" in every client config — the tag lied about the proxy type. Use the outbound's real protocol as the suffix (bal-1-vmess, bal-1-vless, bal-1-trojan, …) so the tag names the actual proxy; the selector prefix and dedup suffix are unchanged. Update the existing tag assertions and add a vmess case that fails under the old mapping. * fix(sub-balancer): default strategy to random in the create form The create-balancer form seeded strategy to 'leastLoad', but the service validate() defaults an empty strategy to 'random' and the API docs say the default is 'random' — so a freshly opened form showed leastLoad while saving without touching the field silently stored random. Align the form default to 'random' so what the admin sees is what gets persisted. * feat(api-docs): document the SubBalancer response schema The five sub-balancer endpoints carried no responseSchema, so the API docs page rendered them without a typed example. Add example: tags to every SubBalancer field, allow the struct through openapigen, and point the list (responseSchemaArray) and single-row endpoints at 'SubBalancer'. Regenerate the Zod/JSON schemas and OpenAPI doc and mirror openapi.json into docs/. * style(sub-balancer): drop whitespace-only separator lines, add final newline subBalancer.ts and SubBalancerFormModal.tsx used single-space blank lines as separators between statements and had no trailing newline. Replace them with clean empty blank lines and end each file with a newline. * fix(i18n): translate sub-balancer toasts and observatory note The sub-balancer toast messages (list/create/update/delete/invalidId) and the observatory note were left in English across 11 non-English locales (ar, es, fa, id, ja, pt-BR, tr, uk, vi, zh-CN, zh-TW) while every other key in the subBalancers block was already translated. Translate them to match the meaning and terminology of the surrounding keys in each file; the JSON structure and keys are unchanged. * fix(sub-balancer): hide disabled inbounds from the member picker The picker offered every protocol-eligible inbound regardless of its enable flag, but getInboundsBySubId filters `AND inbounds.enable = true`. A disabled member is therefore dropped from every subscriber's entries, and when it was the balancer's only member the balancer document silently stops being emitted — with nothing in the UI explaining why. TestSubJson_BalancerSkippedWhenAll MembersDisabled already documents that backend behavior. Filter the way the sibling client picker has since #5645: hide disabled inbounds, but keep one that is already selected so editing an existing balancer cannot silently drop a member. Drop the `?? []` on the useWatch result so the new useMemo dependency stays referentially stable. * style(sub): trim the balancerMemberSuffix comment to the 2-line cap Comment blocks in committed Go are capped at 2 lines; the name already carries what the function picks, so keep only the why. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
81fcacab11 |
chore(build): bump Go toolchain to 1.27.0
Go 1.27.0 shipped on 2026-08-19. Raise the go directive and the builder image so Docker and release builds pick it up; every CI job already reads the version from go.mod, and golangci-lint v2.13.1 release binaries are themselves built with go1.27.0, so the lint job needs no pin change. |
||
|
|
02002dc1c3 |
feat(routing): add client picker to user rules (#6271)
* feat(routing): add client picker to user rules Replace the free-text user criterion with a searchable multi-select backed by existing panel clients. Preserve saved values that no longer exist so editing legacy rules remains lossless. * feat(routing): polish user picker states Align the routing user selector with the inbound-tag multi-select, including search, clear, loading, empty, and error states. Localize the new copy across every supported locale and cover legacy saved users with a regression test. * fix(routing): keep custom user identifiers Use tags mode with comma tokenization so the user picker suggests panel clients without rejecting HTTP, Mixed, or raw-template identifiers. Restore the comma hint and cover custom entries with a regression test. |
||
|
|
326009e9d3 |
fix(traffic): clear cross-panel rows only for clients actually renewed (#6263)
autoRenewClients collects every expired client that carries a reset interval, but three of them never reach a new window: one may be missing from its inbound's settings, one may resolve to no whole interval, and one may still land in the past once the reset cap truncates the catch-up. All three keep their counters and their expiry on purpose. clearGlobalTraffic was still called with the full candidate list, so those three lost their cross-panel rows while their local counters stayed. The next push recreates the rows, and the expiry branch of the depletion check cuts these clients regardless, so nothing is served past its limit — but between the delete and the next push the cross-panel view under-reports them, and since the expiry never advances that repeats on every poll. Pass only the clients whose counters this pass reset. clearGlobalTraffic already early-returns on an empty list, so a poll that renews nobody stays a no-op rather than deleting every row. The renewed count returned to the caller now counts the same set, instead of reporting candidates as renewals. Tests cover both directions: a capped catch-up keeps its rows, and an actually renewed client still loses them, since stale pushed totals would otherwise re-deplete the fresh window at once. |
||
|
|
585f4ecdc0 |
fix: reject Hysteria inbound updates with empty client auth (#6268)
Fixes #6232 Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> |
||
|
|
bd6a6aba43 |
feat(pia): add PIA login-and-add WireGuard outbounds (#6272)
* feat(pia): add login-and-add WireGuard outbounds (#2) * fix(pia): keep PIA outbounds identifiable after the editor strips hostname The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch. |
||
|
|
a255ab7c65 |
fix(node): don't stamp InboundsAdoptedAt when the sync adopted nothing (#6284)
* fix(node): don't stamp InboundsAdoptedAt when the sync adopted nothing
Onboarding a node in selected mode with an empty tag list empties the
traffic snapshot via FilterNodeSnapshot before the merge sees it, so
the first clean sync adopts nothing — yet syncOne stamped
InboundsAdoptedAt regardless. The flag is documented as the first
clean sync that imported the node's pre-existing inbounds; stamping it
in this state arms the reconcile sweep (gated on the flag since
|
||
|
|
1250fbb734 |
feat(clients): allow removing a single HWID device (#6265)
* feat(clients): allow removing a single HWID device Only "list" and "clear all" existed for registered HWID devices, so freeing one slot under a client's HWID limit meant clearing every device and waiting for the ones you kept to re-register. Adds a per-device delete: DELETE /panel/api/clients/hwids/:email/:id, scoped to the client's own sub_id (device ids are a global auto-increment, not per-subID, so this also prevents deleting another client's device), plus a delete button next to each device in the existing HWID modal. Addresses MHSanaei/3x-ui#6245. * feat(clients): surface HWID limit + device log in the client info card Mirrors the existing IP-limit row/eye-icon-modal pattern that's already in this card. The HWID devices modal reuses the same list/clear-all/per-device-delete UI already shipped for the edit form's own HWID modal, so a device can be removed without opening the edit form at all. * i18n: add HWID single-delete strings to all 13 locales deleteHwid/deleteHwidConfirm/hwidDeleted were only added to en-US and ru-RU in the previous commit; backfilling the other 11 locales the project's own translation set covers. * fix(clients): address automated review of HWID single-delete PR - ClientInfoModal: use the existing dateLabel() helper (Jalali-aware) for HWID first/last-seen instead of a raw dayjs format, matching every other timestamp in the same modal. - Add okText/cancelText to the delete-device Popconfirm in both ClientInfoModal and ClientFormModal so all 13 locales get a translated confirm dialog instead of Antd's English default. - deleteHwid controller: stop reusing the success toast key on both error paths, which rendered a red "Update successful" toast on a real (not just theoretical) failure such as a stale HWID modal. - Trim DeleteClientHwid's doc comment to the repo's 2-line cap and correct it: deletion is scoped by sub_id, which can span more than one ClientRecord, not strictly "this client only". - Add TestDeleteClientHwid covering cross-sub_id id rejection, unknown id rejection, and a real successful delete. * chore: retrigger CI (previous run stuck installing Playwright Chromium) * fix(clients): address the arbiter review on the HWID single-delete PR - Extract the HWID device list into a shared frontend/src/lib/clients/ hwid-log.ts type/normalizer, a shared useClientHwids hook, and a shared ClientHwidListModal component, mirroring the existing IP-log pattern. ClientInfoModal and ClientFormModal both render the same component now, so the two copies can no longer drift the way they already had (different date formatting, different tag styles). - Add a Popconfirm to the HWID "Clear all" button (previously unconfirmed, unlike the per-device delete right next to it) — closes the confirm/no-confirm asymmetry the review flagged as the main risk. - Sync docs/public/openapi.json with the two hwids paths and regenerate clients.mdx. Scoped to just those two paths rather than a full copy from frontend/public/openapi.json: the docs copy is far enough behind on unrelated paths (a host-group API rename) that a full sync breaks the Next.js build on locale pages referencing the old shape — out of scope for this PR. * fix(clients): trim HWID list comment blocks to 2 lines Repo convention caps comment blocks at 2 lines; both were 1 line over. * chore: retrigger CI build (arm64) and build (armv6) failed on a transient Go module proxy network error (INTERNAL_ERROR stream reset), unrelated to this PR's changes. |
||
|
|
e4798a027c |
chore(lint): adapt to staticcheck v0.8.0 under golangci-lint v2.13.1
golangci-lint v2.13.0 pinned honnef.co/go/tools v0.8.0-rc.1, whose staticcheck never terminates on internal/web/service/tgbot: the run pins ~520% CPU with RSS climbing past 700MB rather than deadlocking, so it reads as a hang. controller/, job/ and service/... only appeared stuck because they pull tgbot into the analysis graph. v2.13.1 ships the final v0.8.0 and clears it — that package goes from unbounded to 0s, and a cold full run to 22s. CI needs no pin; it already tracks latest. The same bump reworded SA1019 from parser.ParseDir to go/parser.ParseDir, which silently voided the openapigen exclusion, so the pattern now matches either spelling. fasthttp Client.RetryIf is deprecated in favour of RetryIfErr. The old path left resetTimeout at its zero value, so returning false preserves the existing retry timing exactly. The rest are gofumpt redundant-paren removals from the stricter formatter — semantic no-ops. |
||
|
|
f7db247b07 |
perf(clients): write client_inbounds deltas and check identity from the clients table
Client CRUD latency scaled with the number of client-inbound edges rather than with the size of the change. On a 5k-client / 8-inbound / ~56k-edge PostgreSQL panel, creating one client took 60-120s (#6252). Two independent causes, both confirmed by the reporter's pg_stat_statements and reproduced locally at their topology. SyncInbound deleted every client_inbounds row for an inbound and re-inserted the whole set, so a one-client edit rewrote thousands of unrelated rows. The dominant caller was not user CRUD: the node traffic poll re-syncs every node inbound from its snapshot every 5s, so the panel churned the entire membership table continuously in the background. SyncInbound now reads the current links and writes only the difference - insert missing, update a changed flow_override, delete departed. Callers are unchanged, so every reconciliation path benefits, and the four hot client CRUD paths additionally pass only the clients they touched via ApplyInboundClientDelta. The insert needs clause.OnConflict: the unconditional delete it replaces also serialized concurrent syncs of one inbound, and the node poll commits in its own transaction outside the serialized writer, where a duplicate key would abort the whole poll on PostgreSQL. Identity and membership questions expanded every inbound's settings.clients JSON - 5.75s per call under the reporter's load. They now read the indexed clients and client_inbounds tables, which every read path already trusts, over just the emails being checked. A LOWER(email) expression index keeps the case-insensitive matching indexed; a struct tag cannot declare one. Measured on PostgreSQL 17 at 8 inbounds x 6000 clients, rows written to client_inbounds per operation, before -> after: create across 8 inbounds 48008 ins / 48000 del -> 8 ins / 0 del update the client 48008 ins / 48008 del -> 0 ins / 0 del detach from 4 inbounds 24000 ins / 24004 del -> 0 ins / 4 del delete the client 24000 ins / 24004 del -> 0 ins / 4 del Two behavior changes worth naming. An email seen with two different subIds across two inbounds' JSON used to be locked so that no add could claim it, including the one with the correct subId; the clients row now adjudicates. And on an install whose settings JSON holds an email with no matching link, "is this email on another inbound" now answers no, so deleting it elsewhere purges its traffic rows; compactOrphans and the startup heal already converge such drift. Every added test was verified against a hand-written mutation of this change, so none of them pass regardless of the fix. One mutation survives on purpose: swapping OnConflict DoUpdates for DoNothing is only observable when two transactions race the same row, and a timing-dependent test would be flaky. Per-node batching of remote pushes and the metadata-only inbounds list from the same report are deliberately not in this change. Closes #6252 |
||
|
|
c8a3a2d723 |
fix(security): require a 2FA code to replace the stored TOTP secret
The confirmation gate in updateSetting only covered the true -> false transition, so a settings save that kept twoFactorEnable=true while carrying a non-blank twoFactorToken silently rebound the authenticator. preserveRedactedSecrets restores the stored secret only when the submitted one is blank, so a non-blank value went straight through without any branch asking for a code. Not reachable pre-auth or cross-site (CSRFMiddleware rejects unsafe methods without the session token), but it matters after a session hijack or with an admin API token, which sets api_authed and short-circuits the CSRF check: the attacker gains persistence and locks the legitimate operator out of their own authenticator. Now a code is required whenever 2FA is currently on and the submitted secret differs from the stored one. Enabling from off is untouched, as no code exists yet to verify, and a blank secret still means "unchanged", so the panel's normal save path is unaffected. Reported by @n0ctal (GHSA-xqqw-jqqv-99h6). |
||
|
|
b51f09768b |
fix(netsafe): classify IPv6 transition and CGNAT ranges as internal
IsBlockedIP leaned entirely on Go's net.IP predicates, which judge an address by its own range only. 6to4 (2002::/16), NAT64 (64:ff9b::/96 and 64:ff9b:1::/48) and Teredo (2001::/32) each tunnel an arbitrary IPv4 destination inside an IPv6 address, so all five predicates returned false for e.g. 64:ff9b::7f00:1 and the SSRF guard waved it through. CGNAT (100.64.0.0/10) and the deprecated site-local block were unclassified for the same reason. Reported as GHSA-cfpf-wmjp-gh6c. Reaching the embedded IPv4 needs a 6to4 tunnel, NAT64 gateway or Teredo client on the host, none of which exist by default, so this is hardening rather than a live path off a stock install. The guard backs outbound subscription fetches, node sync, reality scan, the tgbot API URL and the xray setting test URL, which is reason enough to close the gap. The deprecated and local-use prefixes are blocked outright since nothing public routes through them. The NAT64 well-known prefix is judged by the IPv4 it embeds instead: on a DNS64 network every public IPv4 host resolves into it, so blocking it wholesale would break legitimate fetches. |
||
|
|
380aff4d82 |
Add remote routing URL support (#6168)
* Add remote routing URL support * Harden remote routing refresh * fix(sub): harden remote routing fetch and accept Mihomo src rule flag Remote routing bytes reach the YAML/JSON parsers from goroutines that run outside Gin's recovery, so a parser panic on crafted input would take down the whole panel. Contain it in fetch() (a panic now degrades to a failed refresh that keeps the last-good value and releases the in-flight slot) and start the refresh, cache-load and startup-warm goroutines through common.GoRecover like the other background workers. The route-graph validator only skipped a trailing no-resolve flag, so a valid Mihomo rule like IP-CIDR,x,DIRECT,no-resolve,src was rejected as an unknown target; skip both option flags. Also deduplicate the HTTPS-source classification into common.ParseRemoteRoutingURL so the save-time validator and the resolver can never drift (internal/sub imports internal/web/service, so the copy existed only to avoid the import cycle), move the test-only mergeRemoteClashRulesYAML helper into the test file, and trim oversized comment blocks. --------- Co-authored-by: Duxxie <yelloduxx@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
3a2f9b48da |
feat(web): add network-only PWA installability (#6190)
* feat(web): add network-only PWA installability Serve the manifest, registration script, network-only service worker, and icons under the runtime web base path so panels remain installable at arbitrary configured URLs. This does not add offline caching or change panel, API, database, or Xray behavior. * chore(docs): remove development planning notes Keep the pull request focused on the PWA implementation, tests, and user-facing verification documentation. * feat(web): adopt the 3X logo PWA icon set from #1865 Replace the two placeholder SVG icons with the six-size PNG set (16/24/32/64/192/512) contributed by @Incognito-Coder in PR #1865. The PNGs have transparent rounded corners, so the manifest entries drop the maskable purpose claim and rely on the default any. --------- Co-authored-by: korsun009 <277924786+korsun009@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
3f1dd4bf5a |
fix: follow-ups from the post-merge reviews of #6221, #6227, #6230 and #6239 (#6250)
* fix: follow-ups from the post-merge reviews of #6221, #6227, #6230 and #6239 Six defects the automated reviews found after those PRs merged. Each is verified rather than taken on trust — two by experiment, the rest by reading the merged code. **Import restore never wrote an empty local value** (#6227). GORM builds the assignment map from the struct passed to Assign and drops zero-valued fields, so `Assign(model.Setting{Value: ""})` produced an empty Updates and the imported row survived. Empty is the normal state: UpdateAllSetting writes a row for every AllSetting field including the blank ones. That is exactly the case the PR existed for — a destination with no certificate inheriting the source machine's path. Confirmed with a throwaway test before changing anything: the value stayed "IMPORTED". Now uses saveSetting, which is not zero-filtered. **Import destroyed node mTLS material** (#6227). The "no local row means the default applied, so drop the import" branch fires for the five nodeMtls* keys, which are minted on demand and deliberately absent from AllSetting, so a fresh install has no row for them. Reinstall-then-restore therefore deleted the CA certificate and its private key — and the backup was the only copy, since neither is surfaced in the UI or the export. Those keys are now kept. **The clients-list enable toggle wiped renewal state** (#6239, #6238). setEnable hand-builds the update payload and carried reset but not resetDay or resetMax, so one click on the switch turned calendar mode off and lifted the renewal cap permanently. The form-modal tests could not catch it because that path does send both fields. **"Delete depleted clients" deleted calendar clients** (#6239). The predicate read `reset = 0` as "does not auto-renew", which is exactly the calendar shape, in two places. Both now share one constant that also requires `reset_day = 0`. **Allowlist validation and parsing disagreed** (#6230). Save used net, scan used netip, and they differ: `198.51.100.0/024` saves without complaint and is silently dropped at scan — the failure the PR set out to remove. Verified by running both parsers. An IPv4-mapped prefix parsed but could never match, because contains() unmaps the query while the prefix stayed 128-bit; it is unmapped at parse now. A test asserts the two acceptance sets agree. **A comment stated the opposite of the truth** (#6221). GetInbounds has no enable filter, so a node reports a disabled inbound normally; the row in that bug report was missing only because it was never delivered. Reworded to the real invariant. Also trims two comment blocks in ip_limit_allowlist.go to the repo's two-line maximum. Not included: the reviewer's suggestion to lift the node hand-off out of `if inbound.Enable` in AddInbound. It is the right root-cause fix, but it changes delivery behaviour on multi-node deployments and belongs in its own change with its own testing, not in a cleanup batch. One reported finding is not real: BulkCreate does call validateClientResetDay, validateClientResetMax and validateClientTrafficReset — verified in the merged tree. * fix(netsafe): wrap both errors so errorlint passes Unrelated to this PR's subject and in a file it does not otherwise touch. It is here only because CI lints the merge result, and `main` has been red since #6242 landed: `fmt.Errorf("%w; %v", ...)` wraps the first error and formats the second, which errorlint rejects. Go 1.20 allows more than one %w, so both are wrapped now and `errors.Is` works against either. |
||
|
|
abd320994a |
Add per-client external link controls (#5650)
* Add enable toggle for external client links * Document external link enable API fields * Extend external client link metadata * Fix external subscription cache status updates * fix(sub): address the review on per-client external link controls Blocking: the expiry filter dropped legacy rows. expiry_time was added without a default, so AutoMigrate makes it nullable and backfills NULL, and `expiry_time = 0 OR expiry_time > ?` is false for NULL under three-valued logic — every external link written before the upgrade vanished from all subscriptions. Add `default:0` on expiry_time and last_fetch_at, make the predicate NULL-tolerant, and backfill the NULLs a pre-fix build could already have written. Rework fetch-status recording. It ran inside the singleflight in-flight window, so every goroutine parked on the shared fetch waited for a DB write to commit on the public, unauthenticated subscription path — and because it was keyed on the row id, waiters and cache hits recorded nothing, leaving rows that lost the race stuck on "Not fetched yet" forever. fetchSubscriptionLinks now reports whether it did the network fetch and expandEntry records afterwards, off the serving path, keyed on kind+value so every row sharing the URL is stamped by the one fetch. Keying on value also closes the recycled-rowid hazard: saves delete and re-insert rows, and SQLite reuses rowids, so an in-flight write could land on an unrelated client's row. The write no longer discards its error either. Drop the inert id round-trip. The panel never sent it, and the byId branch was guarded by the exact kind+value equality that byKindValue already keys on, so it could not change an outcome. Matching on kind+value alone is what actually preserves fetch status across saves. Reject a negative expiryTime instead of storing a row that is silently invisible in every subscription — elsewhere a negative expiryTime means "a duration from first use", so an API caller reusing that convention got no error and no links. Drop the ~50 lines of .client-form-* / .client-inbounds-field CSS that no component renders; it is leftover from the WireGuard PR this one was split from. i18n: reuse the already-translated pages.inbounds.leaveBlankToNeverExpire instead of shipping an English duplicate under pages.clients, and translate namePrefix, lastFetchAt, lastFetchError and neverFetched into all 12 non-English locales. Cover the persistence path that had no test: the fetch-status writer over a real DB against a failing then a succeeding server, a cache hit writing nothing, and the negative-expiry rejection. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
708a69acde |
fix(reality): make the REALITY target check usable on a private network (#6242)
* fix(reality): make the REALITY target check usable on a private network The probe dials through netsafe.SSRFGuardedDialContext, so a fronting service reachable only inside the deployment (a Docker service name, a LAN address) always failed with "blocked private/internal address": the inbound itself works, because the guard sits in the probe path only, so the panel reported a red verdict on a healthy configuration. Instead of a panel-wide setting that lifts the guard for good, the guard is now lifted per probe and only after the operator confirms the local-network warning in a modal; the verdict keeps privateTarget set, so a passing local check stays a warning rather than a green success. The probe also sent the target host as SNI. Clients dial the target but send a name from serverNames, so a fronting proxy answered with its default certificate — a Traefik front reached as "traefik" reported "certificate is valid for <hash>.traefik.default, not traefik" on a deployment whose clients get a valid chain. The panel now sends the first configured serverName as SNI and the certificate is verified against it; empty serverNames keeps the old fallback. The reported target stays the dialled address, so a passing check no longer rewrites the target field with the SNI host. The result panel reports what was actually seen: the SNI used, the certificate subject/issuer and its expiry stay visible when the chain is untrusted (with "Not trusted" appended) instead of being replaced by that verdict alone. Certificate names are copied into the SNI field only when the chain verified — the names on a proxy's default certificate would otherwise become the SNI of the next check. The bulk/CIDR scanner keeps the guard unconditionally: honouring the opt-in there would turn it into an internal network scanner. * fix(reality): recover from a stale SNI and report a refused address reliably Review follow-up on the REALITY target check. The probe sends the stored serverNames as SNI, and the panel only wrote names back when the whole chain verified, so switching Target while the SNI field still held the previous target's names failed every rescan: the new target's real names came back from the probe but were discarded with the verdict. The certificate is now checked in two steps — chain first, then the name — and a trusted chain presented for other names is enough for the panel to offer those names, so the next scan passes. Picking a row in the bulk scanner replaces the names outright, since keeping the previous target's SNI leaves a REALITY config that cannot work. SSRFGuardedDialContext kept the refusal only in lastErr, so on a dual-stack name a refused private address followed by a failing public one lost the sentinel and the panel silently skipped the confirmation. The refusal is now tracked separately and reported alongside the last dial error. Honouring the opt-in is logged with the target and the resolved address, since it bypasses the SSRF guard on an authenticated endpoint. The read-only SNI row in the result is labelled "SNI used" so it no longer collides with the SNI field below it, and the comment blocks are back within the 2-line limit. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
5c7ca5b579 |
feat(clients): give each client its own traffic reset cycle (#6240)
* feat(clients): give each client its own traffic reset cycle Traffic reset is configured on the inbound, so every client sharing an inbound resets together. An operator running a monthly 1000GB plan and a weekly 200GB plan side by side has to press Reset Traffic by hand. Clients now carry the same trafficReset / trafficResetDay pair the inbound already has, with the same vocabulary and the same monthly due-day rule, and PeriodicTrafficResetJob makes a second pass over the clients whose own cycle matches the period it is running for. A client that leaves the field at never behaves exactly as before: only its inbound's schedule can reset it. The fields live on ClientRecord as well as in the inbound settings JSON, so an ordinary edit does not write the cycle back as empty, and an unknown period is rejected rather than coerced, since a coerced value would read as configured while no job would ever select the client. Cron expressions and a custom post-reset quota from the issue are left out: both are separate decisions, and neither has an inbound-level counterpart to stay consistent with. * fix(clients): make the per-client reset cycle editable and safe to run Review found three things wrong with the first cut, one of them mine and worse than the bug it replaced. The cycle could only be set at creation. ClientService.Update writes the columns directly only for a client with no inbounds; the normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so an edit updated the settings JSON while the clients column kept the old value and the job kept applying the old cycle. The earlier test passed because it asserted the value survived an unrelated edit, which it did precisely because nothing ever wrote it. Replaced with a test that changes the cycle and switches it off again. Avoiding the re-enable that ResetTrafficByEmail performs was wrong. Depletion disables clients.enable and the settings JSON as well as client_traffics.enable, so lifting only the quota gate left a depleted client out of the generated config with zeroed counters, which no longer match the depleted predicate: locked out permanently. The rule is now about cause, not state — a client the quota switched off is restored, one disabled below its quota was switched off by hand and is skipped. The bulk path also bypassed node propagation and the MTProto sidecar quota that ResetTrafficByEmail handles, so it silently did nothing on node-backed inbounds. Dropped in favour of the integrated path, whose needRestart is now collected and turned into a single SetToNeedRestart. Also adds the AutoMigrate NULL backfill, guards the merge so a stale node snapshot cannot erase a configured cycle, validates the bulk-create and import paths, normalizes the day the way the inbound path does, marks the fields omitempty so existing clients match the published contract, and shares one TRAFFIC_RESETS tuple between the three forms. * fix(clients): validate renew fields on the bulk and import paths too BulkCreate and ImportClients insert client records without going through Create, so the resetDay/resetMax checks added with the calendar renewal (#6239) and the renew cap (#6238) never ran there. An API caller could store resetDay 45 or a negative resetMax, values the renewal query then mishandles silently. Mirror Create's validation on both batch paths, next to the trafficReset check they already carry. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
b8903fadf4 |
feat(clients): renew on a calendar day instead of a rolling interval (#6239)
* feat(clients): renew on a calendar day instead of a rolling interval Auto-renew advances the expiry by a fixed number of milliseconds, so a client set to 30 days drifts against the calendar: renewing on 31 January lands on 2 March, and by the end of the year the billing day has wandered a fortnight from where the operator's own plan resets. Add a per-client renewal day. When set, the expiry steps whole calendar months at midnight in the panel's time zone. A month too short for the chosen day renews on its last day and the following month returns to the chosen one, so the 31st does not decay into the 28th permanently. Zero keeps the interval mode, so existing clients are untouched. The interval branch now also refuses a zero step. It is unreachable while the selection filter holds, but that loop runs on the single traffic writer, and a zero interval there would hang every panel mutation behind it. * fix(clients): persist the calendar renewal day on the client record resetDay lived only in the inbound settings JSON and client_traffics, so every path that rebuilds a client from the clients table wrote it back as zero: an ordinary edit, an attach to a second inbound, a traffic reset on a disabled client. Calendar mode turned itself off during normal use and the operator only found out a month later. Adds reset_day to ClientRecord and threads it through ToRecord, ToClient, applyClientRecordMerge and the record update map, so the value survives the round trip. The clients page filter and ClientSlim now recognise the mode, nodeClientRenewed classifies a calendar renewal as a renewal, the node snapshot merge carries reset_day, and the service layer rejects a day outside 0-31 rather than clamping it silently. Also renames the label keys to renewOnDay to keep them apart from the existing renewDays, translates them and the new RESET_DAY subscription placeholder in all 13 locales, adds the field to the bulk-add modal, and drops the stray internal/web/dist/.gitkeep build stub. * fix(clients): let the billing day be changed after creation ClientService.Update writes the record columns directly only for a client with no inbounds. The normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so moving a client from the 20th to the 5th updated the inbound settings JSON while clients.reset_day kept the old value and the renewal kept using it. The existing test did not catch it: it asserted the day survived an unrelated edit, and it survived precisely because nothing on that path ever wrote it. TestClientEditChangesTheBillingDay moves the day and then switches calendar mode off again; removing the record write turns it red. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
6638ac4a1e |
i18n: translate importKeepHostSettings keys
Translate the `importKeepHostSettings` and `importKeepHostSettingsDesc` keys from English placeholders into 10 locale files (ar-EG, es-ES, fa-IR, id-ID, ja-JP, pt-BR, tr-TR, vi-VN, zh-CN, zh-TW). Also removes `main_test.go` which contained a test for a `commandHelp` function. |
||
|
|
d6472740dc |
feat(limitip): let operators exempt trusted addresses from the IP limit (#6230)
* feat(limitip): let operators exempt trusted addresses from the IP limit Behind a shared address — an office gateway, a campus NAT, a residential carrier — every user looks like the same client. One of them trips the IP limit and the address is disconnected and handed to fail2ban, taking the others with it. Today the only way out is editing jail.d by hand, which an update overwrites. Add an allowlist setting of addresses and networks. A matching address is neither banned nor counted towards the limit: counting it would still cut the shared network the entry exists to protect. Entries are validated on save rather than skipped at scan time — a typo would otherwise leave the address unprotected until someone noticed the bans. * fix(limitip): keep each doc comment on its function and one grammar for the list Three review follow-ups. loadAllowlist landed between hasLimitIp's doc comment and hasLimitIp itself, so godoc showed one function's rationale above another's body; it now sits after that function with its own comment. The parser advertised semicolons and whitespace as separators while the settings validator accepts commas only, making those forms unreachable through the panel and the API — a promise the software never keeps. Both sides now read the same comma-separated grammar. The dist stub was a build artifact and does not belong in the tree. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. * chore(i18n): translate the IP limit allowlist strings into the remaining locales Ten locales carried the English source text verbatim; only ru-RU and uk-UA were translated. The i18n dead-key test only checks that a key exists in every file, so an untranslated value passes it silently. Wording follows each locale's existing terms: the ipLimit noun already in the file, and the comma-separated IP/CIDR phrasing from trustedProxyCidrsDesc. * refactor(limitip): share one IP/CIDR list validator and read the allowlist only when enforcing The allowlist check in CheckValid was a line-for-line copy of the trusted-proxy loop directly above it. Both now call one helper, each passing its own message, so the two lists cannot drift apart. Run() read the allowlist on every 10s scan, including the majority of panels where no client carries an IP limit and the value is discarded. It is now read only once enforcement is known to apply. CheckValid had no test for either list. The new one pins that a malformed entry is rejected and that each list still names itself in the error, which is what the shared helper could otherwise break. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
6e80a468e3 |
feat(server): keep this machine's own settings when importing a database (#6227)
* feat(server): keep this machine's own settings when importing a database Import replaces the database wholesale, so the uploaded file's listen addresses, ports, base path, certificate paths and node identity land on the destination. Moving a configuration to a new host therefore leaves the panel answering on an address it does not own, presenting certificates it does not have, and claiming the source machine's identity towards its nodes. Capture the host-bound settings before the swap and write them back once the imported database opens. Everything else — inbounds, clients, templates, the rest of the settings — still comes from the file. A checkbox controls it, defaulting to keeping this machine's values; clearing it restores the old behaviour for anyone deliberately cloning a host. * fix(server): drop imported host settings this machine never had, and cover Postgres Two gaps in the previous commit. The snapshot only recorded rows that existed, so a key with no row here — the default for every certificate path, both listen addresses and all the node mTLS material — kept the imported value: exactly the case the change is meant to fix. The snapshot now records which keys were absent and deletes the imported row for them, letting the default apply again. The PostgreSQL path took the flag and ignored it, so a dump restore still adopted the source machine's settings. It now captures and restores the same way the SQLite path does. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
e940f30bb8 |
feat(clients): cap how many times a client may auto-renew (#6238)
* feat(clients): cap how many times a client may auto-renew Auto-renew today runs forever: a prepaid or fixed-term client keeps being handed new periods until an operator remembers to switch it off. There is no way to say "renew this three times, then let it lapse". Add a per-client maximum. Zero keeps today's behaviour, so nothing changes for anyone who does not set one. When the count is reached the client is simply left to expire, like any client without auto-renew. Catching up several missed periods spends one allowance per period. A client that was away for three cycles must not receive three of them free of the cap, and the catch-up stops at the last period the cap paid for rather than jumping to the present. * fix(clients): persist the auto-renew cap and stop the capped churn resetMax lived only in the inbound settings JSON and client_traffics, so every path that rebuilds a client from the clients table wrote it back as zero. The edit dialog showed 0 for a capped client, and saving an unrelated comment change lifted the cap; an attach or a traffic reset did the same with no operator action at all. Adds reset_max to ClientRecord and threads it through ToRecord, ToClient, applyClientRecordMerge, the record update map and ClientSlim, so the cap survives the round trip. When the cap truncates a catch-up the client is still expired, but the renewal side effects fired anyway: counters were zeroed for periods it can never use, and it was enabled and pushed to xray only for disableInvalidClients to undo both in the same transaction. Those are now skipped when the new expiry has not reached the present. Also makes any non-positive resetMax mean unlimited instead of silently meaning "never renew again", rejects a negative one at the service layer, surfaces renewals used against allowed in the client info modal so the operator can see what to raise, adds the field to the bulk-add modal, translates the labels in all 13 locales, and drops the stray internal/web/dist/.gitkeep build stub. * fix(clients): let the renewal cap be changed after creation ClientService.Update writes the record columns directly only for a client with no inbounds. The normal path goes through SyncInbound and applyClientRecordMerge, which this change had not extended, so raising a cap from 3 to 6 — the natural action when a customer buys another block of periods — updated the inbound settings JSON while clients.reset_max kept the old value and the renewal query kept enforcing it. The existing test did not catch it: it asserted the cap survived an unrelated edit, and it survived precisely because nothing on that path ever wrote it. TestClientEditChangesTheRenewalCap raises the cap and then lifts it entirely; removing the record write turns it red. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
6a674c7f0c |
fix(node): keep disabled inbounds the node snapshot cannot report (#6221)
* fix(node): keep disabled inbounds the node snapshot cannot report A node builds its traffic snapshot from the inbounds Xray is actually running, so an inbound with enable=false is never in it. The central sweep reads that absence as "the node no longer has this inbound" and deletes the row, its clients' traffic history and its port reservation — on a perfectly healthy node, with no way to tell it apart from a real deletion. Disabling an inbound in the panel and waiting one sync interval is enough to lose it. Skip disabled inbounds in the sweep: their absence carries no information, and an explicit delete still removes them. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. |
||
|
|
81cfd8570e |
fix(inbounds): close the port check-and-claim race on the serial writer (#6225)
* fix(inbounds): close the port check-and-claim race on the serial writer AddInbound reads the port conflict outside its transaction and then commits in a bare db.Transaction, so two overlapping creates both pass the read and both insert. UpdateInbound already runs on the single traffic writer, and so does the node snapshot path; AddInbound is the one inbound writer left out. Move it onto runSerializedTx and evaluate the conflict inside the transaction, in both AddInbound and UpdateInbound. The check and the claim then commit together on one goroutine, which closes the window on SQLite (immediate write lock) and PostgreSQL alike without new schema, locks or configuration. The wildcard/specific pair is the case worth naming: those are two distinct rows, so no unique index can reject them — only the semantic check can, and only if nothing can interleave between it and the insert. * fix(inbounds): restore the port check UpdateInbound lost The previous commit deleted UpdateInbound's pre-flight conflict check and never added the in-transaction one, so editing an inbound onto an occupied port was accepted outright. No test covered that path, so CI stayed green. Evaluate the conflict inside the transaction, as AddInbound already does, and add the regression test that fails without it. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
5c9268c431 |
feat(i18n): translate the log levels, access events and calendar labels (#6226)
* feat(i18n): translate the log levels, access events and calendar labels The log-level selector, the access-log event tags, the Sub Formats sidebar entry and the calendar choices were hardcoded English, so a fully translated locale still showed them in English on core screens. Add eleven keys across the 13 locales and reference them. Russian and Ukrainian are translated; the remaining locales carry the English string, the same convention the existing files already use for untranslated entries. Two module-level constants had to move: the calendar list and the access-event map were built outside the component, where t is not in scope. The event map now stores keys and resolves them at render. * fix(i18n): keep the log export language-independent and fit the translations Three follow-ups from review. The downloaded x-ui.log had started carrying the translated event text, so its contents depended on the panel language and the Russian value for PROXY contains a space in a field format whose other values are single tokens. The export keeps DIRECT/BLOCKED/PROXY; only the on-screen tag is translated. The log-level select had a fixed 95px width sized for "Warning", which clips "Предупреждение"; it now grows with its content. The three access filters stayed English while the tags they filter became translated, so they use the same keys. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
2b1fe1fd02 |
ci: actually run the PostgreSQL schema and migration tests (#6224)
* ci: actually run the PostgreSQL schema and migration tests TestHostAutoMigrateCreatesColumns_Postgres and TestMigrate_Postgres skip unless XUI_DB_TYPE and XUI_DB_DSN are set. CI sets them only for the durable-first step, so both tests have never run: a green pipeline says nothing about the PostgreSQL schema or the migration path. The job already has a PostgreSQL service. Point those two tests at it and fail if either skips, the same guard the durable-first step uses. * ci: make the PostgreSQL guard fail on a renamed test, and self-test the workflow The guard asserted the absence of `--- SKIP`, which only catches a test that ran and skipped. A renamed or deleted test makes `-run` match nothing, so `go test` prints "no tests to run" and exits 0 — the step stays green while testing nothing, which is the exact failure this PR set out to close. Both steps now count `--- PASS` lines and require the expected number: at least one for durable-first, exactly two for the schema tests. Also adds `.github/workflows/ci.yml` to both `paths` filters so a change to the workflow runs the workflow — without it this PR's own CI never fired and the new step would first execute on main after merge — and hoists the duplicated DSN to job-level `env`. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
4b0e9f9b60 |
fix(nodes): log the inbound the node snapshot removes centrally (#6219)
The orphan sweep deletes a central inbound and the traffic history of every client on it, but wrote nothing. An inbound that vanishes minutes after being created is then indistinguishable from one that never arrived, and the only way to tell them apart is reading the source. Name the node, tag, id and port so the removal is visible in the panel log. Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
5d6d98d1f9 |
fix(warp): preserve WARP Plus license key when changing IP (#6218)
ChangeWarpIP rotated the WireGuard keypair by registering a brand-new Cloudflare device via RegWarp, which overwrites the stored warp data with the fresh registration's empty license_key. The old key was then re-applied only best-effort: any SetWarpLicense failure was swallowed with a warning log, permanently deleting the saved WARP Plus key, and even on success the response returned to the UI carried the pre-reapply snapshot (empty key). Fix: write the old license key back into the stored warp data immediately after RegWarp (before the remote upgrade attempt), so storage never loses it; keep the remote re-apply as best-effort but surface its failure as a warning field in the response; and return the final stored data so the modal shows the preserved key. The auto-update IP job shares this path and is fixed too. warpAPIBase is now a var so integration tests can point at a mock Cloudflare API. Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com> |
||
|
|
930a0ed59d |
feat(inbound): DisableFlow — opt an inbound out of auto XTLS Vision (#5689) (#5698)
* feat(inbound): add DisableFlow to opt an inbound out of auto XTLS Vision Adds an inbound-level DisableFlow flag so operators can suppress automatic xtls-rprx-vision injection on a specific inbound even when its transport is flow-capable — e.g. a tunneled/CDN-fronted XHTTP+vlessenc inbound where Vision is not wanted, while keeping it on the same client's Reality inbounds. When set, the inbound reports tlsFlowCapable=false, the write path clamps each attached client's flow to empty (so flow_override stores ""), and share links/subscriptions never carry the flow for it. The flag is panel-only metadata and is never sent to xray. Closes part of #5689. * feat(inbound): DisableFlow toggle in the inbound form (frontend) Wire the DisableFlow field through the form schema + adapters and add a VLESS-gated switch in the inbound form, plus en-US strings. tsc --noEmit and eslint pass. * fix(inbound): honor DisableFlow in all emitters + on toggle; regen OpenAPI Addresses review on #5690: - Clash (clash_service.go) and JSON (json_service.go) subscription emitters now also skip the flow for a DisableFlow inbound — previously only the raw share-link path was gated, so those two still advertised it (blocking 1). - UpdateInbound now strips any flow already stored on a DisableFlow inbound's clients (settings.clients[].flow + client_inbounds.flow_override) so xray and the subscription agree; otherwise toggling DisableFlow on an existing Vision client left xray expecting a flow the client no longer sends. - Regenerated the OpenAPI + zod/types/examples artifacts for the new field and added an example tag (blocking 2; make gen-check is clean). - Added Clash + JSON DisableFlow suppression tests alongside the raw-link one. * fix(inbound): make DisableFlow durable, clamp on create, guard live config Addresses the review + completeness audit on #5690: - UpdateInbound now persists inbound.DisableFlow onto the saved row. It was only read to branch strip-vs-restore, so toggling the flag on an existing inbound never stuck and MigrationRestoreVisionFlow re-injected the flow — the exact #5689 path (editing a multi-inbound client's inbound) self-reverted. - DBInbound (frontend) declares + initializes disableFlow so ObjectUtil .cloneProps carries the API value through; the edit Switch previously always read false and re-saving silently reverted the opt-out. - AddInbound strips client flow (settings + parsed clients) when DisableFlow is set, so a created-disabled inbound never persists a flow xray would expect. - GetXrayConfig forces flow="" for DisableFlow inbounds (VLESS + Trojan) as defense-in-depth, keeping the live config and the subscription in agreement. - genTrojanLink share link honors DisableFlow too. - Drop the dead explicit flow_override clear in UpdateInbound (SyncInbound rebuilds it from the stripped settings). - Clear disableFlow in the inbound form when switching to a non-VLESS protocol. - Add disableFlow/disableFlowHelp to the remaining 12 locales. Tests: stripClientFlows unit cases; DB-backed AddInbound clamp; UpdateInbound persist+strip+resist-restore regression (fails without the persist fix); frontend DBInbound + adapter round-trip (fails without the model field). * style(inbound): drop // line comments per repo CLAUDE.md The DisableFlow work followed the surrounding code's commenting style; the repo CLAUDE.md forbids // line comments in committed Go/TS. Remove the comments I added (Go + frontend + tests) and regenerate OpenAPI/schemas, which drops the generated field descriptions sourced from the Go doc comments. No behavior change; full go test (service+sub, CGO) + frontend typecheck/vitest green; golangci-lint clean on the changed files. * fix(runtime): propagate disableFlow to nodes Preserve the inbound DisableFlow flag when syncing inbounds across nodes and when recreating central records from remote traffic snapshots. This keeps multi-node deployments from reintroducing VLESS Vision flow in node configs and share links, and updates the related tests to cover the wired field and VLESS JSON generation. |
||
|
|
f22df49a71 |
fix(sub): restore the subscription info page for browser visits
Revert |
||
|
|
b4e4478699 |
feat(inbounds): add a narrow endpoint for subscription sort order (#6179)
* feat(inbounds): add a narrow endpoint for subscription sort order Changing an inbound's position in subscription output currently goes through /update/:id, which takes a whole inbound: the caller has to send settings and the entire client list back, and whatever it read before the edit is what gets written. Two people reordering and editing clients in the same inbound race on one blob, and the reorder wins by overwriting. Mirror the existing /setEnable/:id shape. The handler takes only the index and the service reads the stored inbound, so nothing in the request can reach the settings JSON. Node-owned inbounds are marked dirty in the same transaction and pushed through the existing runtime update. * fix(nodes): scope sub sort index updates --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
bab39393f1 |
fix(nodes): validate every certificate in the node mTLS trust bundle (#6188)
* fix(nodes): validate every certificate in the node mTLS trust bundle AppendCertsFromPEM reports success once a single certificate parses, so a trust bundle whose later entries are damaged or truncated was accepted with those entries silently absent from the pool. Parse and validate every PEM block instead, and reject the bundle if any of them is malformed. * fix(mtls): reject malformed certificate bundle layout --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
338822ab07 |
fix(sub): keep copy page within mobile viewport
Constrain the copy-only subscription page to the dynamic viewport, wrap long localized text, and infer text direction so mobile browsers cannot render a horizontally shifted desktop-width page. Add regression coverage for the responsive layout contract. |
||
|
|
43bc915397 |
fix(sub): serve a copy-only page when a subscription URL is opened in a browser (#6183)
* fix(sub): show copy-only page for browser subscription visits Browser navigation to /sub previously rendered the normal subscription page, which exposed subscription material in page data or raw base64 depending on request headers. Keep VPN clients on the raw subscription body, but classify browser document requests and return a neutral static copy-only HTML page with no embedded share links or page data. This preserves the C1 LimitIP parser fix in the same master candidate while avoiding a DE rollback of the browser subscription UX. * fix(sub): keep the themed page for an explicit html request Only implicit browser navigation is downgraded to the copy-only page. An operator who appends html=1 or view=html already holds the URL, so the themed subscription page keeps rendering for them and serveSubPage stays in use. * fix(sub): keep browser pages copy-only |
||
|
|
dafd3c0e64 |
feat(sub): warn when salamander settings cannot reach the client (#6177)
* feat(sub): warn when salamander settings cannot reach the client A hysteria2 share link carries obfuscation as obfs=salamander plus obfs-password, and nothing else. Xray's finalmask accepts more than that — packetSize among them — and those extra settings change what the server expects on the wire. The emitted URI then looks complete but describes a server the client cannot reach: every standard client applies plain salamander, the server drops the packets, and the failure is silent on both ends. Log the unexpressible keys when building such a link, naming the inbound, so the cause is visible instead of appearing as a client-side problem. * fix(sub): deduplicate salamander warnings |
||
|
|
be70535b94 | feat(inbounds): improve multi-node online attribution (#6164) | ||
|
|
2d669fa4b7 | feat(sub): add template variables to subscription metadata (#6163) | ||
|
|
8c8556ab32 |
feat(frontend): multi-node cloning initial implementation (#6216)
* feat(frontend): multinode cloning initial implementation * fix(frontend): harden live node detection in multinode cloning Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(frontend): add aria label to clone inbound modal * fix(frontend): shallow copy inbound settings during cloning * fix(frontend): avoid potential port conflict during testing * fix(frontend): selection buttons and websocket selection reset fix --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> |
||
|
|
d7698ec7aa |
feat(xray): browse geosite/geoip categories from routing rules (#6165)
* feat(xray): browse geosite/geoip categories from routing rules Routing rules made you type category names from memory: nothing showed which categories a database actually contains, what is inside one, or whether a name resolves at all — a typo only surfaced when Xray refused the config. The panel now reads Xray's .dat databases itself and exposes them over four endpoints: databases in the asset folder, a database's categories, one page of a category's rules, and validation of the tokens already in a rule. The reader walks the protobuf wire format directly rather than decoding into Go structs, because a 10 MB geosite.dat holds well over a million domains and materialising them costs ~284 MB where streaming costs ~19 MB. Only the category index is cached, entry pages are scanned on demand, and scans are serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB. A database's type is decided by its contents, not its file name, since custom .dat files are named freely. In the rule form, the source-IP, IP and domain fields gain a database button opening the browser: search over categories, a preview of what a category holds, and a multi-select that merges into the field. Plain domains, CIDRs and categories the panel does not know are left untouched; categories already present come back ticked, and unticking one removes it from the rule. * fix(xray): read geo databases through os.Root and match codes verbatim CodeQL flagged the database read as a path built from a user-supplied value, and it was right about the shape of it. The file name arrives in a request; resolve() rejects traversal and stats the file through an os.Root, but the read itself went through a joined path with os.ReadFile. That left the symlink defence incomplete: the stat could pass while the read followed a link planted — or swapped in — afterwards. Reads now go through the same root, so a request-supplied name never becomes a path this code resolves on its own, and the size limit is applied to the opened file rather than to a separate stat of it. Lookup no longer trims the category code either. It backs the routing-token validator, and the core matches codes verbatim: "geosite: cn" will not start Xray, so repairing that space here hid exactly the typo the validator exists to report. * fix(xray): address review findings on the geo category browser Asset folder. The browser read config.GetBinFolderPath() unconditionally, but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the bin folder (ensureXrayAssetLocation). On an install pointing at a shared asset directory the panel listed an empty folder and reported perfectly valid geosite:/geoip: tokens as missing — the validator warning about a correct config. The directory is now resolved with the core's precedence. Paging. Serving one page read and rescanned the whole database, so walking category-ads-all re-read it per page. The index now records each category's byte range and a page reads only that record through the os.Root handle, with the current category's records held for the duration of a paging session. Profiling that also showed the real cost was not the read but the slice of payload pointers built per call — a category holds a hundred thousand of them — so records are now walked with a callback instead. Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB. Cached failures. Any error from reading a file was latched under the file's size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as damaged until it changed on disk. Only deterministic failures are cached. Wrong kind. A geoip: token typed into a domain field parsed as a plain domain and was waved through, though the core cannot resolve it as one. It is now reported, with its own reason and wording. Frontend. The category filter fed the query key on every keystroke, so each character triggered a request that re-scanned the database; it is debounced now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus these three fields on a validation error again. A failed validation shows that it failed instead of rendering the same empty state as "no issues". Also drops an unreachable branch in the token-count guard and corrects the categories endpoint docs, where limit is unbounded by default. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com> |
||
|
|
694ad6deae |
feat(sub): add per-client subscription HWID limits (#5802)
* feat(sub): add per-client subscription HWID limits * fix(sub): address HWID review on shared subId and bulk create * fix(sub): store HWID devices by sub_id and drop anchor client workaround * fix(sub): restore UA auto-detect and HTML page routing in subs() The cherry-pick of the HWID gate onto main's refactored SUBController had dropped main's UA-based format auto-detection and sub-page handling from subs(). Restore those branches, slotting enforceHwid after the HTML page and before format detection so the gate only applies to machine-readable subscription bodies. Also adapt tests to main's options-struct constructor and to the ClientService.Update signature extended with limitHwid. * fix(frontend): drop axios from HttpUtil.delete The bulk-delete rework's committed version still referenced axios, which this file no longer imports, breaking typecheck in CI. Use the httpRequest wrapper like the other verbs. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
1793a9b8b4 |
feat(nodes): opt-in encryption at rest for the outbound node API token (#6186)
* node: encrypt outbound bearer token at rest * fix(nodes): keep bearer tokens encrypted throughout --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
0f14ce7551 |
fix(web): fallback to default secret when database setting is empty (#6189)
* fix(web): fallback to default secret when database setting is empty * style(web): format setting_security_test.go with gofumpt |
||
|
|
7ecd88b9e3 |
fix(nodes): apply a rotated master mTLS certificate without restarting the panel (#6194)
* fix(mtls): invalidate pooled clients after credential rotation * fix(mtls): make connection reload read-only --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
1230559e69 |
feat(api): scoped, optionally expiring API tokens (#6201)
* security(api): add scoped expiring API tokens * security(api): make scoped token lifecycle enforceable --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
aecbad3ab1 |
test(tgbot): detect open-coded keypad transitions (#6214)
Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
2217213e9f |
feat(sub): expose last subscription fetch time (#6217)
* feat(sub): expose last subscription fetch time Record successful GET subscription fetches per client and surface the timestamp in the client API and UI. * fix(frontend): include last subscription fetch in client traffic * Update sub_fetch_test.go --------- Co-authored-by: Hermes Agent <hermes-agent@localhost> |
||
|
|
ad32144c42 |
fix(sub): use a fullwidth percent in USAGE_PERCENTAGE (#6174)
* fix(sub): use a fullwidth percent in USAGE_PERCENTAGE A remark is placed in the share link fragment, so an ASCII percent is percent-encoded to %25. Happ treats such a fragment as malformed, discards the whole remark and falls back to showing the server hostname, which defeats the point of a remark template and leaks the host into the client's server list. Emit U+FF05 FULLWIDTH PERCENT SIGN instead. It renders the same to a reader, never produces %25, and round-trips through url.Parse unchanged. * test(sub): exercise production fragment encoding --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
34c248bb79 |
fix(cli): stop -getApiToken accumulating admin tokens (#6175)
* fix(cli): stop -getApiToken accumulating admin tokens `x-ui setting -getApiToken` reads like a getter, but when tokens already exist it minted a brand-new one named `cli-fallback-<unix>` on every invocation. The plaintext is printed once and the row stays enabled forever, so an operator who runs the command a few times while debugging silently leaves several admin-equivalent credentials behind that nobody can tell apart or revoke knowingly. Keep the convenience the fallback was added for, but rotate a single `cli-fallback` token instead: RecreateByName drops any existing row with that name before issuing a new one, so at most one CLI-issued token exists at a time and the previous plaintext stops working. * fix(api-token): preserve token on failed replacement --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |
||
|
|
17fea2f656 |
fix(database): keep IP limits when the fail2ban probe is inconclusive (#6176)
* fix(database): keep IP limits when the fail2ban probe is inconclusive ResetIpLimitNoFail2ban clears limitIp on every client — inbound settings JSON and the clients table — whenever fail2banCanEnforce() returns false, then records itself in the seeder history so it never re-evaluates. The probe was a single `fail2ban-client -h` run, so it answered false both when fail2ban is genuinely absent and when the command merely failed that once: a panel that starts before fail2ban is up, or in a container where it is installed a moment later, permanently loses every configured limit with no log line and no way back. Separate the two. A missing binary still means "absent" and the cleanup runs as before; a binary that exists but will not run is reported as unknown, leaves the configured values untouched, logs why, and does not record the seeder, so the next start decides again. * test(database): cover fail2ban reset safeguards --------- Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com> |