mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 01:32:08 +03:00
main
360 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bd9ccde1f4 |
feat(sub): make external subscription fetch User-Agent configurable (#6613)
* feat(sub): make external subscription fetch User-Agent configurable Some providers reject fetches that do not send a known client User-Agent. Expose externalSubUserAgent as a panel setting (default v2rayNG/1.8.5) and use it when fetching client external subscription URLs. Fixes #6383 * ci: retrigger frontend after npm registry maintenance The frontend job failed solely on `npm audit` while registry.npmjs.org returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite build, and storybook all passed. Local `npm audit --omit=dev --audit-level=high` now reports 0 vulnerabilities. * fix(sub): fall back to the default UA when the DB is not initialised externalSubUserAgent read the setting through SettingService.getSetting, which calls Model() on database.GetDB() and panics on a nil *gorm.DB. The fetch path's other DB read, service.ExternalSubscriptionHwid, already treats a nil DB as unreachable and sends no header; the new UA lookup did not, so any fetch before InitDB panicked instead of sending the historical v2rayNG/1.8.5. Production initialises the DB before the sub server starts, but the internal/sub fetch tests run without one: under make test-go's -shuffle=on, whenever one of them ran before the first InitDB test the panic aborted the whole package. Reproduced deterministically with go test -run '^TestDoFetchSubscriptionLinks_RejectsOversizedBody$'. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
9672249edb |
feat(clients): add calendar weekly renewal and schedule previews (#6524)
* feat(clients): add calendar weekly renewal and schedule previews Expose fixed-day, calendar-weekly, calendar-monthly, and disabled renewal through one shared selector in individual and bulk client forms. Store the weekly weekday separately (Monday 1 through Sunday 7) and use panel-local calendar dates rather than a fixed 168-hour duration. Resolve skipped or repeated midnights to the first valid instant of the selected date, and skip an entirely nonexistent calendar date rather than changing the weekday. Reuse the existing renewal writer and share its boundary alignment and per-period catch-up calculation with an authenticated, read-only preview. Keep monthly precedence for legacy records, fixed-day interval semantics, maximum renewal allowances, first-use durations, and operator-disabled settings unchanged. Selecting a mode does not rewrite an existing cutoff; an unset calendar cutoff requires an explicit action to choose the first. The last-valid-second preview uses the stored exclusive expiry, even when the billing calculation aligns a legacy last-second cutoff up to midnight. Carry weekly schedules through client persistence, paging, enable toggles, inbound settings, and node traffic reconciliation. Migrate missing or nullable weekday columns to disabled by default without altering existing limits, and include the new isolated-schema PostgreSQL regression in the live CI gate. Regenerate API contracts and reference documentation, add lifecycle and form regressions, and document timezone, quota-reset, and upgrade considerations. All participating nodes must be upgraded before weekly mode is enabled; older binaries ignore the new field. Independent periodic traffic resets and the optional month-end subscription-header display are not changed. * fix(clients): validate renewal schedules across inbound write paths Reject conflicting weekly/interval/monthly schedules and out-of-range weekdays on inbound creation and edits, legacy one-client apply paths, record/link synchronization, and traffic metadata writes. Validate imported traffic snapshots as well, before any inbound or client is persisted, so an inbound API cannot create a client that the clients page cannot toggle. Merge a weekly-related schedule as one timestamp-selected tuple rather than filling its zero fields from another renewal mode. Preserve empty migration snapshots and the existing non-weekly monthly/interval merge semantics. Renewal caps, counters, credentials, and deadlines are unchanged. Add regressions for nine write paths, unchanged records and runtime calls after rejection, valid inbound clients remaining editable, and duplicate record merges between individually valid renewal modes. * docs(clients): clarify depleted-client deletion risks on downgrade Explain in English and Chinese that older versions not only stop weekly renewal: their depleted-client cleanup can delete a weekly-only client once its expiry or quota is exhausted. This is conditional on cleanup, not an automatic deletion caused by downgrade itself. Recommend backing up and converting weekly schedules to a mode supported by every participating version before rollback, and avoiding cleanup while mixed versions or unconverted clients remain. Merely disabling weekly renewal does not restore the old binary's missing purge protection. * fix(clients): bound weekly renewal date searches Limit the search for a valid weekly calendar date to eight candidates so an unusual timezone cannot monopolize the single traffic writer. Exhaustion returns the original instant, allowing the existing catch-up forward-progress guard to stop without advancing expiry, consuming an allowance, resetting traffic, or falling back to a fixed-duration schedule that can drift. Reject a non-future calendar suggestion in the read-only preview instead of offering an immediately expired initial cutoff. Also report failed weekly catch-up as a search error when allowances remain, not as cap exhaustion. Existing preview errors use the form's current warning; no API schema or locale changes are needed. Exercise exhaustion with a synthetic valid TZif containing twelve skipped Sundays. This fault-injection case was red without the bound; it is not a claim that a production IANA timezone was observed hanging. Keep the Havana and Apia regressions for real skipped/repeated midnights and absent dates. * fix(tests): isolate weekly renewal preview timezone Stop the weekly search regression from replacing process-global time.Local. CI caught that assignment and its cleanup racing with background timer reads through time.Now, even though the top-level tests do not use t.Parallel. Pass the timezone and current instant into the unchanged preview calculation. The public service still validates the request and resolves the panel timezone; API responses, renewal accounting, and persisted client data are unchanged. Use fixed dates for both suggestion and catch-up exhaustion, removing the test's dependency on today's date and its unnecessary database setup. Keep a bounded-lifetime background clock reader to expose future global-timezone mutations under the existing race gate rather than disabling that check. * ci: retrigger PR checks Create an empty commit to request a fresh pull-request CI run after release dependency downloads failed with network errors. No source, dependency, or workflow changes are included. Retry the existing checks without bypassing them. * ci: retry PR checks and record deferred download hardening Request another CI run after the amd64 release job compiled successfully but failed during dependency fetching with exit code 4 (network failure). Record possible follow-up improvements for the Linux release fetch helper: - Print each download URL and destination, and preserve error details. - Reuse the existing curl configuration with up to five retries; add connection and per-attempt timeouts and a bounded retry window. - Download to a temporary file and promote it to the final filename only after a successful, non-empty transfer. Keep the job failing if downloads ultimately fail. - Validate successful downloads, recovery after a temporary failure, and correct failure after persistent errors before shipping such a change. These improvements are intentionally deferred, not implemented or tested by this commit. This commit is empty: renewal logic, dependencies, workflow configuration, check requirements, and TLS verification remain unchanged. --------- Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com> |
||
|
|
788b76c544 |
fix(amneziawg): sniff the relay with routeOnly; scope the v6 egress to IPv6 (#6654)
* fix(amneziawg): sniff the relay with routeOnly The embedded AmneziaWG relay sniffed without routeOnly, so a sniffed SNI replaced the dial target. Telegram's FakeTLS recovery dials 194.221.250.50:443 with SNI www.google.com; the rewrite sent it to real Google and the client looped on "TLS hash mismatch", stuck on "Connecting". Sniffing here exists only so domain routing rules can match; routeOnly keeps that and dials the IP the peer resolved. Fake-pool targets are still rewritten (the dispatcher ignores routeOnly for fakedns). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(amneziawg): send only IPv6 targets through the peer's v6 egress The per-peer IPv6 egress rule matched every flow of that peer, but its freedom outbound binds a v6 sendThrough and cannot dial an IPv4 target, so IPv4 DNS and any other unsniffed IPv4 traffic of such a peer failed. Sniffed TLS/HTTP only worked because the sniffed domain replaced the IP; with routeOnly on the relay that no longer happens. Limiting the rule to ::/0 keeps the peer's IPv6 identity for IPv6 targets and lets IPv4 targets take the regular outbound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
6ab718f813 |
fix(sub): make Happ require auth on its local SOCKS/HTTP proxy (#6628)
Happ ships its local SOCKS5 (127.0.0.1:10808) and HTTP inbounds with authorization disabled by default. Any app on the same device can then connect to that proxy, bypassing Android's per-app VPN routing, and learn the VPN server address - the leak publicly described in March-April 2026 for Happ, v2rayNG and other VLESS clients. Happ fixed its Xray API exposure, but the unauthenticated local proxy remained. Happ exposes a standard subscription header for this (no Provider ID required): socks-auth-mode / http-auth-mode = auto|manual|from-json| disable. A new subscription setting, subHappLocalProxyAuth (default "auto"), sends both headers to Happ clients. Like every other Happ header it is emitted only when Happ auto-detect is enabled and the User-Agent is Happ, so panels that never opted into the Happ integration see no change. An empty value sends nothing and keeps the client's own setting. Verified on Happ Android 4.4.1 (Xray 26.7.28): a subscription carrying socks-auth-mode manual + a test user/password switched the client's Inbounds screen to Manual with those credentials on "refresh subscription", and "auto" switched it to Auto with generated credentials. Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
8979072bd9 |
fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H The native AmneziaWG validator, both Zod schemas, both forms and the docs now follow the rules amneziawg-go actually enforces. S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes (device/send.go). The peer reads each datagram into a [MaxMessageSize]byte buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go, constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and 1700 on iOS (device/queueconstants_*.go). The limits are therefore S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which iOS peers silently drop, and S3 was capped at 64, a number inherited from the coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these limits. H1-H4. amneziawg-go refuses the whole device when the header ranges overlap ("headers must not overlap", device/uapi.go mergeWithDevice), and so does the kernel module (src/netlink.c). The panel did not check this, so an inbound with overlapping ranges saved and then failed to apply. A blank H is never sent, so the engine keeps its default, WireGuard's own type 1-4; the check treats blank fields that way. The docs said 1-4 "must not be used". They are valid and are the engine default, only unobfuscated without a HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It does not (IpcSet accepts it). The panel keeps that rule as a fingerprint guard, and the docs now say so. Tests: the new params_test cases and the Zod bounds fail on the old code. TestValidatedObfuscationAlwaysApplies runs every accepted set through a real amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the exact S bounds and the full Amnezia Premium set. Before this fix it failed with "headers must not overlap". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(amneziawg): bound only inbound padding by the iOS receive buffer The 1700-byte iOS buffer limits what an inbound's clients can receive, but ValidateObfuscation also runs for outbounds, and the Xray template save re-validates every AmneziaWG outbound. An outbound whose remote server uses S1 above 1552 would have blocked every Xray settings save, though its values come from that server and are received on Linux. ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3; ValidateServerObfuscation adds the receive-buffer bounds and is what inbounds call. The outbound schema and form follow the same split. --------- Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
f3b100282a |
fix: allow IPv4 and IPv6 inbounds to share a port (#6603)
* fix: distinguish IPv4 and IPv6 listen conflicts * fix(ports): let an IPv4 address share a port only with a v6only wildcard xray listens on tcp/udp, and Go opens every wildcard listen, 0.0.0.0 included, as one dual-stack socket unless sockopt.v6only is set. Treating :: and 0.0.0.0 as separate families let the panel save pairs the core then fails to bind, and it broke main's own TestListenOverlaps. listenOverlaps now takes the inbound's sockopt.v6only: a wildcard claims both families, or only IPv6 with v6only, so :: with v6only may share its port with an IPv4 address while a plain :: or 0.0.0.0 still may not. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
bd01f923fb |
fix(tgbot): unstick the add-client wizard's inbound picker (#6621)
* fix(tgbot): unstick the add-client wizard's inbound picker Tapping "➕ Новый клиент" always sent the "choose inbound" message, even when the button list ended up empty after protocol filtering. getInboundsAddClient checked len(inbounds)==0 before filtering but never re-checked after, so an admin whose every inbound was excluded got a message with nothing to tap and no further feedback. WireGuard and AmneziaWG were excluded outright too, a holdover from the wizard's original 2025 implementation, before defaultWireguardClients and defaultAmneziaWGClients existed. Both now auto-generate a keypair + AllowedIPs for a client with none set, and the subscription server already emits wireguard:// and vpn:// share links for them, so both inbound types flow through the same generic Create path as VLESS/Trojan already used by the bot. Mixed/HTTP/Tunnel stay excluded: they have no per-client model in this codebase. - getInboundsAddClient now returns getInboundsFailed when the button list is empty after filtering, instead of sending an unusable keyboard - WireGuard/AmneziaWG removed from the exclusion list in both getInboundsAddClient and getInboundsAttachPicker - the previously duplicated excludedProtocols map is now a single package-level addClientExcludedProtocols shared by both functions * test(tgbot): pin which inbounds the add-client picker offers The picker change had no test. One drives a database holding WireGuard, AmneziaWG, VLESS and Mixed inbounds and wants the first three offered; the other holds only Mixed, HTTP and Tunnel and wants getInboundsFailed instead of an empty keyboard. Both fail on the previous picker. --------- Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
8f1201553e |
fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres (#6612)
* fix(ip-limit): CAS-retry inbound_client_ips merges under Postgres Two writers RMW the same ips JSON blob; on PostgreSQL a lost update drops remote IPs that partitionLiveIps only sees through that blob (#6587). Compare-and-set on the previous blob with re-merge on miss, matching the repo's conditional Where+RowsAffected pattern. Fixes #6587 * ci: retrigger frontend after npm registry maintenance The frontend job failed solely on `npm audit` while registry.npmjs.org returned 503 (Service Under Maintenance). Lint, typecheck, vitest, vite build, and storybook all passed. Local `npm audit --omit=dev --audit-level=high` now reports 0 vulnerabilities. * test(ip-limit): cover the scan's CAS against a mid-scan node sync The job-side compare-and-set had no test of its own. A write injected between the scan's read and its update now has to keep the node's remote IP; main's blind Save drops it. Also keeps the new comments to two lines, as CLAUDE.md requires. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
a2ca023336 |
fix(sub): send panel guid as X-HWID on outbound subscription fetch (#6579)
* fix(panel): accept 2FA codes from adjacent TOTP windows CheckUser compared only gotp.Now(), so a code submitted at the end of its 30s window (or with slight client/server clock drift) failed with 'invalid 2fa code', while the immediate retry in the next window succeeded. Accept current +/-1 window, the standard TOTP skew tolerance. Fixes MHSanaei/3x-ui#6535 * fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode Move the +/-1 window helper to internal/util/totp so both 2FA acceptance points use it: login (CheckUser) and disable/rebind plus username/password changes (VerifyTwoFactorCode). Also shrink comments to the 2-line house rule and anchor the unit test mid-window to avoid a step-boundary flake. Addresses review on #6546 (MEDIUM + 2 LOWs). * fix(sub): send panel guid as X-HWID on outbound subscription fetch Outbound subscriptions hit the same HWID-limited donor 404 as client external links (#6559/#6567). Identify this panel with GetPanelGuid plus X-Device-OS, honoring the externalSubSendHwid opt-out. Fixes MHSanaei/3x-ui#6574 * fix(sub): send the external-subscription X-HWID from outbound fetches too The outbound fetch used panelGuid while client external links send the externalSubHwid id from #6567, so an HWID-limited provider counted one panel as two devices. It also re-added the externalSubSendHwid opt-out that #6567 dropped. Move the id into service.ExternalSubscriptionHwid, keeping the externalSubHwid row so existing installs keep their slot, and send it from both paths. The outbound test now fails on the panelGuid version. --------- Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com> Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com> |
||
|
|
66df77665f |
test(database): give each package its own schema when tests run on PostgreSQL (#6594)
With XUI_DB_TYPE=postgres every test package shared one database and worked in public. Go runs package test binaries concurrently, so migrations raced and rows a previous run left behind leaked into the next. testpg.IsolatePackage creates a schema for the calling package, puts it first on search_path and drops it when the package finishes. It returns at once unless XUI_DB_TYPE is postgres. internal/web/service's TestMain adopts it. |
||
|
|
ee2ff48c81 |
fix(tgbot): scope the add-client wizard to the admin, not the chat (#6604)
Two admins in one group chat shared one draft and one wizard step: clientDrafts and userStateStore were keyed by chat id alone, so the second admin's wizard opened on the first one's email and limits, and whichever of them tapped a control last decided what the other created. Key both stores by (chat, user) instead. A private chat is unaffected: its two ids are equal, so the key matches what the chat alone used to be. A message with no sender (a channel post) keys to user 0, which no admin holds. Fixes #6593. |
||
|
|
3b9ca47a4e |
fix(database): keep the legacy tag cleanup from colliding with an existing tag (#6592)
* fix(database): avoid legacy inbound tag cleanup collisions * test(database): assert the legacy tag cleanup keeps the migration green The collision guard's test asserted only that the colliding tag was left alone, which an unguarded cleanup also produces: the UPDATE fails on the unique index and the row is unchanged either way. The cleanup shares a transaction with every other requirement, so that failure rolls all of them back on every boot and only reaches the log. Assert the call itself succeeds, which is what actually distinguishes the two. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> |
||
|
|
c0c0136037 |
fix(hwid): serialize the device-limit write with its trim (#6591)
setClientLimitHwidByEmail wrote clients.limit_hwid and then trimmed client_hwids as two independent statements. A traffic-cycle Save that read the record before the limit changed could write the stale value back after it, and a failed trim committed the new limit anyway. Both halves now run inside runSerializedTx, the transaction the traffic writer already owns. setClientLimitHwidByEmailTx and clearClientHwidsBySubIDTx refuse a handle that is not that transaction (errClientHwidWriteNotSerialized) instead of falling back to the shared handle. Client delete moves onto the same writer, and BulkCreate withdraws a re-created client's tombstone before applying its optional HWID limit. TestSetClientLimitHwidIsSerializedWithSyncInbound holds a stale traffic-cycle Save open across the limit change and fails without the serialization (limit_hwid = 5, want 1). |
||
|
|
dcaadd4857 |
fix(panel): validate sponsor logo name before any file or network use
The public /sponsors/logo/:name route only accepted names matching an active sponsor's logo, which was already regex-filtered, but that guard was indirect. Checking sponsorLogoRe on the name itself makes the path/URL safety local and clears CodeQL alerts #113 (go/request-forgery) and #114 (go/path-injection). |
||
|
|
fd7b3559bc |
feat(panel): add sponsor slots fed from sponsors.sanaei.dev
Monthly sponsor placements need to change without cutting a panel release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in four slots: an overview banner, a rotating sidebar card (max three), the login page and a new Sponsors page that also lists open placements. An entry shows only while enable is not false and until is in the future; links must be https and logos are png/webp/jpg by name only. The list is cached for an hour and the last good copy survives upstream failures; logos are proxied through /sponsors/logo/:name with failures cached, so CSP stays 'self' and admin browsers never reach a third party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads a sibling ../sponsors/3X checkout so edits can be previewed before push. |
||
|
|
95f19b192f |
fix(nodes): stop a restarting panel from reporting itself as down
Adding a node fails right after that node's panel restarts. nodes/add probes the node's /panel/api/server/status first, and that endpoint returns whatever the @2s ticker last sampled - nil until the first tick lands, so the master reads a healthy panel as unreachable and rejects it with "Add node (remote returned success=false: )", an error whose message is empty because the node answered success with a null obj. The window is far wider than one tick: GetStatus resolved the public IPv4/IPv6 addresses inline and held s.mu across every lookup, so a box with no IPv6 route spent 3s per service - about 15s of nil status after each restart, and the same stall on a fresh panel's first sample. - status now answers from CurrentStatus, which samples on demand when the ticker has not run yet instead of returning a null obj - the public-IP lookups run in the background and outside s.mu, so a status sample never waits on them - probe tells "no status yet" apart from a genuine success=false, so the master's error says something when it meets an older node |
||
|
|
f8db7f6c29 |
fix(nodes): say which half of node mTLS failed, and say it as an error (#6565)
* fix(nodes): say which half of node mTLS failed, and say it as an error A configured client CA bundle that will not parse produced the same warning as a settings read that failed, and both read as though mTLS were merely unavailable. It is not: the node API silently stops accepting client certificates, callers fall back to a bearer token or lose their only credential, and the one line saying so is a warning at boot. Report it at error level, and distinguish the two causes rather than attributing a storage fault to the operator's certificate bundle. NodeMtlsClientCAPool now tags the parse failure with ErrNodeMtlsTrustBundleInvalid; its message text is unchanged, so anything matching on the existing string still matches. Startup is deliberately left alone. Refusing to boot was considered and rejected: the bundle is one of two equal credentials here, a panel that will not start takes the proxies and the subscription server with it, and bundles written before the stricter validation landed in #6188 are already stored, editable only through the panel that would no longer come up. The tests pin the tag on an unusable bundle and its absence on an unset one; without the tag the first goes red. * test(nodes): drop a duplicate node mTLS trust-bundle test TestNodeMtlsClientCAPoolLeavesUnsetBundleUntagged asserted only that an unset nodeMtlsClientCAPem yields (nil, nil). That path returns before the line the sentinel change touched, so the test was green with and without ErrNodeMtlsTrustBundleInvalid, and TestNodeMtlsClientCAPool already pins the same two assertions on the same fixture. A test that passes either way certifies nothing and then gets cited as coverage for the sentinel. TestNodeMtlsClientCAPoolTagsAnInvalidBundle, which does go red without the sentinel, stays as the regression guard. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
536f9a6338 |
fix(tgbot): localize QR caption via I18nBot (#6564)
* fix(panel): accept 2FA codes from adjacent TOTP windows CheckUser compared only gotp.Now(), so a code submitted at the end of its 30s window (or with slight client/server clock drift) failed with 'invalid 2fa code', while the immediate retry in the next window succeeded. Accept current +/-1 window, the standard TOTP skew tolerance. Fixes MHSanaei/3x-ui#6535 * fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode Move the +/-1 window helper to internal/util/totp so both 2FA acceptance points use it: login (CheckUser) and disable/rebind plus username/password changes (VerifyTwoFactorCode). Also shrink comments to the 2-line house rule and anchor the unit test mid-window to avoid a step-boundary flake. Addresses review on #6546 (MEDIUM + 2 LOWs). * fix(tgbot): localize QR caption via I18nBot sendClientQRLinks hardcoded English 'QRCode for client <email>:', bypassing I18nBot, so non-English bot languages (e.g. ru-RU) still got English. Add tgbot.answers.qrCodeForClient key with Email param in all 13 locales and route the caption through I18nBot. Fixes MHSanaei/3x-ui#6562 * fix(tgbot): repair locale JSON syntax, harden QR i18n test - Add missing separators so all 13 locale files parse again. - Rewrite the regression test to read the real shipped files (fails on malformed JSON or missing key). - Add TestTgbotLocalesQrKeyValid covering every locale file. * chore(tgbot): drop QR caption tests that cannot catch the bug TestQRCodeForClientLocalizes never calls sendClientQRLinks: it registers two messages in a synthetic bundle and asserts on I18nBot, a passthrough to go-i18n. With the tgbot_client.go line reverted to the hardcoded English caption, both it and TestTgbotLocalesQrKeyValid still pass, so neither certifies the fix. The malformed-locale class they were added for is already pinned twice: the discord package's TestMain loads every translation file through locale.InitLocalizer and panics on invalid JSON, and frontend/src/test/i18n-dead-keys.test.ts parses all 13 locales and checks each carries the en-US key set. Both go red on the #6564 syntax error this PR first shipped. --------- Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
17e89db979 |
feat(hosts): add a cipher suites override and accept custom suites
The inbound TLS form offered cipherSuites as a closed single-choice list, but xray reads the value as a colon-separated list and accepts any name Go knows, so several suites or one missing from the list could not be set. Both the inbound and the new host field now use a tag picker that keeps the stored value as the colon-joined string xray expects; old single values open unchanged. A host's cipher suites replace the inbound's in the JSON subscription stream, and a blank field inherits them. Share links and Clash carry no cipher suite parameter, so their output is unchanged. |
||
|
|
040d01c5dc |
fix(clients): list HWID devices when the HWID limit is 0
EnforceHwidForSubID returned before recording anything when a sub had no limit, so the panel's HWID Devices list stayed empty for every unlimited client. Devices are now upserted on the (sub_id, hwid_hash) index without enforcement or X-Hwid-* headers; the write is best-effort and only logs on failure, so tracking can never deny a subscription nothing restricts. |
||
|
|
c9e62451e6 |
fix(outbounds): keep subscription tags on their server when reality params rotate
A subscription outbound's tag must stay bound to the upstream server it was assigned to for as long as that server stays in the subscription; balancers and routing rules select by that tag. The identity used to recognise a server across refreshes included every query parameter. A 3x-ui upstream picks a random shortId and SNI of a reality inbound on every request (older releases a random spiderX too), so no reality link was ever recognised, the stable-tag reservation never engaged, and every tag was handed out by list position. Removing or inserting a server then re-pointed existing tags at other servers: sub-germany carried France, sub-sweden Germany, and Sweden became sub-sweden-1. The identity now ignores sid, sni and spx when security=reality, since none of them selects the server. TLS sni still counts: it can pick the backend behind a shared front. Two more paths broke the same rule: - A link repeated in one body (same identity, different remark) shared a single link_identities key, so both tags gained a -N suffix on every refresh. Repeats are now numbered. - Links the core rejects were dropped after tagging, so the stored list that drives positional reuse was shorter than the parsed one and a rotated server behind a dropped link took its neighbour's tag. The filter now runs first; a dropped link's warning names its remark instead of a tag it never used. A mapping an older build already swapped stays swapped: its stored identities no longer match, so positional reuse reproduces it. Deleting and re-adding the subscription reallocates the tags from the remarks. Closes #6556 |
||
|
|
5008906c4c |
feat(clients): filter the client list by clicking a summary stat card
Each card on the Clients page now toggles its status bucket as the sole filter, and the Clients card clears it. The bucket filters used to be wider than the card counts: "active" still included clients near depletion and "deactive" included disabled clients that had run out, so a filtered list could disagree with the number on the card. Both filters now reuse the summary expressions, and a test pins each card's count to the size of its filtered list. |
||
|
|
1d85ef138e |
fix(sub): prevent default profile page URL disclosure (#6538)
* fix(sub): prevent default profile page URL disclosure Add explicit none, builtin, and custom profile page modes. Preserve existing custom URLs and warn before exposing the built-in page. Cover mode selection, legacy settings, and subscription response headers. * fix(subscription): add profile page link options and upgrade notes |
||
|
|
d1c4e0261b |
chore(node): cover the sync tick's online prune from the job package
The traffic sync's call that drops online sets of nodes it no longer fetches had no test: a job-package test cannot install an xray process, so online state was invisible there and removing the call passed. SetXrayProcessForTest installs a test process for tests in other packages, the same kind of seam as Manager.SetRuntimeOverride. The new job test runs a real tick with a disabled node and a deleted one and fails without the call. |
||
|
|
bc49c1a68f |
fix(node): release a deleted node's metric series and HTTP client
Deleting a node must free what the master keeps per node in memory. Delete dropped the node's cpu and mem series but not netUp and netDown, which the heartbeat records too, so each deleted node leaked two tiered histories. It now drops every NodeMetricKeys entry. InvalidateNode, called on node edit, disable and delete, cleared only the cached Remote. The pooled HTTP client and its transport stayed cached until a later call for the same node pruned them, which a deleted node never makes. InvalidateNode now drops those too, outside the manager lock; an edited node pays one fresh handshake on its next call. |
||
|
|
eb11e8c85a |
fix(node): fan out operations that call every node
An operation that calls every node has to finish inside the panel's 30s write timeout. Reset all traffic, UpdatePanels and bulk inbound delete walked the nodes one at a time, up to 10s per hanging node, so 15 hanging nodes out of 150 kept each request running for 2m41s while the browser had already been told it failed. All three now fan out through fanoutInboundResults, bounded by nodeFanoutConcurrency (32, the heartbeat's bound), and UpdatePanels keeps its results in request order. Bulk delete still removes the rows one at a time, since each rewrites shared routing references, and only fans out the node pushes that delInbound now hands back. |
||
|
|
a84bbeab2e |
fix(node): drop online clients and sub-nodes of nodes no longer synced
What the master derives from a node's reports (online clients, active inbounds, learned sub-nodes) must live only while that node is still synced; ClearNodeOnlineClients states it: a downed node must not keep its clients listed as online. Only a failed snapshot fetch cleared the online set, and only a failed probe cleared sub-nodes. A disabled node (both jobs skip it), a node marked offline before the sync tick reached it, a deleted node, and a node whose snapshot fetched but failed to merge all kept their clients online in onlineClients, onlineByGuid and activeInbounds, which the dashboard and a parent master's /clients/onlines read. Disabled and deleted nodes also kept their sub-nodes on the Nodes page until the panel restarted. The traffic sync now keeps online sets only for enabled, online nodes in its list, the heartbeat keeps sub-nodes only for enabled listed nodes, both before the empty-list return, and a failed merge clears like a failed fetch. The sync job's one-line call has no job-level test: that package cannot install the xray process, so RetainSyncedNodeOnlineClients carries the tested rule. |
||
|
|
ea66aa4971 |
fix(traffic): push depletion changes to nodes off the serial writer
Node I/O on the traffic-accounting path must never stall accounting; the
serial writer states it ("Keep network I/O (node pushes) OUT of fn").
AddTraffic still applied the depletion UpdateInbound for every node
inbound inside the writer closure, one at a time with context.Background.
One hanging node held the single writer for each push, freezing traffic
polls, node snapshot merges and every client edit for the whole wave; a
client shared by 150 nodes expiring could hold it for tens of minutes.
The opt-in restart on client disable then ran node by node on the same
traffic job.
Remote plans now leave the writer and go through nodePushPlan and the 4s
nodePushContext, fanned out like client pushes: an offline or slow node
defers to the reconcile its dirty flag already schedules. The node restart
runs in its own goroutine, since nothing replays or waits on it.
TestTrafficDisableImmediatelyUpdatesNodeRuntime called addTrafficLocked
directly, which pinned the push inside the writer; it now calls AddTraffic
and still requires the push to have landed on return.
|
||
|
|
cfa8350d10 |
fix(clients): keep a vless reverse client's handler across a re-add (#6558)
* fix(clients): keep a vless reverse client's handler across a re-add RemoveUser also drops the client's reverse outbound handler, and the account every live remove/re-add path rebuilt carried no reverse at all: buildUserAccount read id/flow/testseed/testpre and nothing else. Editing, bulk re-enabling, quota renewal and adding a client to an existing inbound therefore left a reverse client able to connect but not to open its tunnel until Xray restarted, with nothing logged. A traffic reset is the route operators hit most, since a depleted client is removed and re-added on every renewal. buildUserAccount now carries the tag (it accepts either the settings JSON object or a typed client value), and the five account maps those paths build include the client's reverse. Core chain, read from the pinned xray-core: AddUserOperation -> User.ToMemoryUser -> vless.Account.AsAccount copies Reverse (proxy/vless/account.go:24), and GetReverse rebuilds the handler from the stored account's tag (proxy/vless/inbound/inbound.go:193-205). Each path has a test that fails without its fix; the account-level test fails on both input shapes. * refactor(clients): drop an account map helper nothing calls Local.AddClient and Local.UpdateUser are only reachable through runtime.Runtime, and all four call sites of those two methods sit in a node branch, where the runtime is a *Remote -- Remote.AddUser ignores the map and pushes the inbound snapshot instead. So the extraction and its test covered a path no deployment takes, the reverse key it added could never reach a core, and the previous commit's claim that the node-push paths go through it was wrong. The four account maps that do reach buildUserAccount are untouched. Reported by the PR review. |
||
|
|
af466b6a24 |
fix(node): push a node only the client IPs it hosts
A master's per-node sync must scope what it sends to the clients that node serves, so its cost tracks the node and not the fleet. The global-usage push already did (node_client_traffics by node_id); the 10s client-IP push sent GetAllInboundClientIps, the whole table, to every node. Each node's MergeInboundClientIps then created a row for every foreign email, and its next GET clientIps echoed the whole fleet back. Its IP-limit job only ever reads rows for its own clients, so none of it was used. With 150 nodes x 150 clients, one IP tick pushed 299 MB and pulled 264 MB, every node held 22,500 rows instead of 150, and sync ticks grew 3.8s -> 10.2s even at 1ms latency; the cost grows with the square of the fleet. Both pushes now share nodeHostedEmails. After the change the same fleet moves 2.0 MB / 1.8 MB per tick and ticks stay near 3.2s. Nodes upgraded with foreign rows shed them within 30 minutes via pruneStaleIpRows. |
||
|
|
bc424f0968 |
fix(xray): stop a lone dns qType 0 from matching every query
The core reads a dns rule's qType as a PortList, which drops a bare numeric 0 (infra/conf/common.go: `if number != 0`), and a rule with no qTypes matches every query. A stored `"qType": 0` therefore does not target query type 0: it drops, refuses or hijacks all DNS through that outbound. A qType the panel writes has to be read by the core as exactly the query types it names. Four writers broke that: - DNSOutboundLegacyKeysFix rewrote a lone blockTypes [0] into "qType": 0, so "block type 0" became "block everything" on upgrade. - That seeder shipped in v3.8.0 and is recorded as done, so fixing it does not reach installs that already ran it. DNSOutboundQTypeZeroFix spells any stored numeric qType 0 as "0" once, protocol id matched like the core. - The outbound form adapter turned a typed "0" into the number 0. - The Xray template editor saves raw JSON past that adapter; the save now applies the same rewrite. Each writer is pinned by a test that fails without its part. The rewrite and the repair compare policies as the pinned core builds them, and the repair runs through runSeeders over a database whose legacy-keys seeder already ran, on SQLite and PostgreSQL 16. |
||
|
|
ac3fc12077 |
fix(ports): refuse an inbound on a port an AmneziaWG peer forwards (#6554)
* fix(ports): refuse an inbound on a port an AmneziaWG peer forwards checkForwardedPortsConflict only ever ran from the AmneziaWG save path, and only in one direction: an AmneziaWG client's forwardedPorts were checked against the ports other inbounds already hold, while the reverse -- an ordinary inbound saved onto a port some peer forwards -- had no guard at all. The forward listener binds that port on every interface in both directions (amneziawgnet/portfwd.go's attachTCP/attachUDP), so the two listeners want the same socket: the loser either leaves the peer's forward silently dead or fails the inbound's listen. checkPortConflictTx now resolves that owner the same way the relay-slot checks do -- same host, peers derived from the stored settings with the shared InstanceFromInbound -- and names the peer in the refusal. Sitting inside checkPortConflictTx covers both the save and the enable path added in #6549. TestAddInboundRefusesAPortAnAmneziaWGPeerForwards fails without this -- watched red, the create is allowed -- and its node-row companion pins the scoping that keeps a node row legal on a locally forwarded port. * fix(ports): name only a peer that binds as the owner of a forwarded port The owner lookup read instance.Peers and ForwardedPortsInclude directly, so a peer the forward supervisor skips (no email, or no address the tunnel routes to) was reported as holding a port nothing binds -- refusing a create that is legal with a message naming a row whose own port is its WireGuard one. It also repeated the candidate's listen address as the forward's location, though the forward binds :port on every interface. Share the supervisor's own gate through amneziawgnet.ForwardedPortOwner, report the wildcard bind, and propagate a failed owner query instead of reading it as "no conflict", matching the sibling checks in the same file. * style(ports): keep the forwarded-key doc block within the 2-line cap The reworded desiredPortForwardKeys doc ran to three lines, against the rule this repo sets for committed Go comments. |
||
|
|
d440c2b932 |
fix(panel): accept 2FA codes from adjacent TOTP windows (#6546)
* fix(panel): accept 2FA codes from adjacent TOTP windows CheckUser compared only gotp.Now(), so a code submitted at the end of its 30s window (or with slight client/server clock drift) failed with 'invalid 2fa code', while the immediate retry in the next window succeeded. Accept current +/-1 window, the standard TOTP skew tolerance. Fixes MHSanaei/3x-ui#6535 * fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode Move the +/-1 window helper to internal/util/totp so both 2FA acceptance points use it: login (CheckUser) and disable/rebind plus username/password changes (VerifyTwoFactorCode). Also shrink comments to the 2-line house rule and anchor the unit test mid-window to avoid a step-boundary flake. Addresses review on #6546 (MEDIUM + 2 LOWs). --------- Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com> |
||
|
|
e790f46757 |
fix(xray): restart when a diff strands a client's live session (#6550)
* fix(xray): restart when a diff strands a client's live session Disabling or deleting a client took it out of the generated config and the hot path applied that with AlterInbound/RemoveUser, which only drops the credential (vless, vmess, trojan and shadowsocks all keep the established session running) -- so the panel showed a disabled client whose connection kept passing traffic, and the core offers no API to close one session. A diff that removes a user without re-adding the same email under the same tag is that case: honour the operator's restart-on-client-disable setting and let the caller replace the process, which is already how an auto-disabled client loses its session. An edit re-adds the email and keeps the hot path. * chore(i18n): cover manual disable and delete in the restart-setting description The setting now also decides what happens when a client is disabled or deleted by hand, so the description cannot keep naming only the automatic path. All 13 locales updated in the same commit to keep the wording consistent. * fix(xray): reach the guard from the manual switch and from every protocol Round-1 findings on this PR. The guard sat in tryHotApply, but a manual disable or delete applies through runtime.Runtime and finishes with needRestart false, so none of the three RestartXray schedulers fired and the predicate was never reached: the session in #6533 kept flowing. The apply layer now asks for the restart the setting promises when the client actually leaves the config, on the single-client update and delete paths and on bulk disable, and only for local inbounds so a node row cannot make the master restart its own core. The predicate itself could not fire for shadowsocks or hysteria either, because RemovedUsers is only produced for the protocols diffInboundUsers will diff. The diff now also compares settings.clients of an inbound present in both configs, which is the one shape every account list shares, so those protocols reach the guard through the inbound instead of through nothing. TestManualClientDisableHonoursRestartSetting fails without the apply-layer fix ("needRestart = false, want true" with the setting on) and TestHotDiffDropsUsersOnProtocolsItCannotDiff fails without the diff fix -- both watched red. The two three-line comments this PR added are back inside the cap. * docs(i18n): stop scoping restartXrayOnClientDisable to auto-disable The setting now covers a client disabled or deleted by hand as well, so its title no longer says "Auto" in all 13 locales, and the docs callouts in en, ru, zh and fa describe the same behaviour instead of the auto-only one. |
||
|
|
574caa63e9 |
fix(inbounds): check ports when an inbound is enabled, not only when it is saved (#6549)
* fix(inbounds): check ports when an inbound is enabled, not only when it is saved The save-time guards compare enabled rows, so a row could be created while another disabled row held its port and only collide once the disabled one was switched on. Run the same checks before the flag moves: the refusal names the row that owns the port, the flag is left alone, and tcp/udp coexistence and node rows keep working. * docs(inbounds): state the real reason the enable path needs its own check |
||
|
|
baef3cdd07 |
fix(xray): refuse a config the running core cannot bind (#6547)
* fix(xray): refuse a config the running core cannot bind RestartXray stopped a working core before handing it a config whose listens collide, so the failed bind exited the whole process (main/run.go:94) and the one-second watchdog retried it in a loop: every protocol down, cause only in the logs. The save-time port guards cannot cover this -- SetInboundEnable, the AmneziaWG relay created on the first peer, template and bridge edits all reach a colliding config with no guard on that path. Probe the generated config at the single restart funnel instead. Collisions the running core already serves are excused, so an established setup is never refused by a static read being wrong about it, and the port-bucketed pass costs nothing on a clean config. * fix(xray): surface a refused config and re-key the bind excuse set Round-1 findings on this PR. Refusing the swap left the running core on its previous config with nothing but a log line to show for it, so the status response now carries the reason while the core runs and the overview marks it; the node list picks the same field up through that response. The excuse set is keyed on the two listens, the port and the shared transports instead of the tag pair, so a pair whose listen moves onto the other's address is refused again, while the same two sockets stay excused however the generator orders them. TestBindConflicts/excused_pair_whose_listen_changed_into_a_real_collision fails without the key change -- watched red first. |
||
|
|
43e64993fc |
fix(amneziawg): refuse a row's own relay port and keep a disabled row's slot reserved (#6544)
* fix(amneziawg): refuse a WireGuard port that is the row's own relay port
All three relay checks filter themselves out of the candidates with id !=
ignoreId, so nothing ever compared an AmneziaWG row's own WireGuard listen port
with the relay port its own id derives. Saving a row on that exact port left the
embedded device (UDP on the inbound's listen address, amneziawgnet/device.go:137)
and its injected relay (TCP and UDP on 127.0.0.1, amneziawgnet/relay.go:47-61)
bound to the same UDP port, so whichever loses the race dies -- and when the
relay loses it, Xray refuses the whole config and takes every other protocol on
the host with it. The first AmneziaWG inbound on port 65101 was enough to reach
it: id 1 derives exactly that port.
The row now states the rule its three siblings do: it owns the slot its id
derives. A node-hosted row still keeps its own port, since it binds no relay on
this host.
TestAddInbound_AmneziawgRefusesItsOwnRelayPort and
TestUpdateInbound_AmneziawgRefusesItsOwnRelayPort fail without this -- both were
watched red first -- and pin the two separate call sites, AddInbound's post-Save
block and checkPortConflictTx's ignoreId > 0 block.
* fix(amneziawg): keep a disabled row's relay port reserved for port forwards
loadPortConflictContext filtered its query with enable = true, so a client's
ForwardedPorts spec could claim the relay port a disabled AmneziaWG row's id
derives. That row's relay appears with its first client -- a path that runs no
port check -- and when the relay then loses the loopback bind race to the
forward listener, Xray refuses the whole config instead of losing one forward
(#6542 review, arrived with #6540).
The context now loads every local row and gates only the ordinary-port compare on
enable, which is what a disabled row's own port is worth: free. Its relay slot is
not free, which is the rule #6540 already states for the other two guards.
TestCheckForwardedPortsConflict_DisabledAmneziawgRelayPortIsReserved fails
without this -- watched red first -- and passes with it, while
TestCheckForwardedPortsConflict_IgnoresDisabledInboundPort keeps proving that a
disabled inbound's own port stays available.
* fix(amneziawg): re-run the forward guard once a new row has its own ports
normalizeAmneziaWGSettings validates every client's ForwardedPorts before the row
is saved, and loadPortConflictContext then reads the database -- so the new
AmneziaWG row is never a candidate for itself. A client could forward exactly the
relay port the row's own id derives, or its own WireGuard listen port, and the
create was accepted: at runtime the panel's wildcard forward listener and Xray's
127.0.0.1 relay race for the same port, and a lost relay bind makes Xray refuse
the whole generated config (#6544 review, pre-existing).
The post-Save block is the only place the id is known, so it re-runs the guard
there. Both callers now share amneziaWGForwardedPortsConflict, so the collision
message lives in one place instead of two.
TestAddInbound_AmneziawgRefusesAClientForwardingItsOwnRelayPort fails without
this -- watched red first -- and passes with it.
* fix(amneziawg): stop blocking stored forward specs on a disabled row's slot
Round 2 flagged this PR's widening as the one MEDIUM it introduced, and the code
confirms it: UpdateInboundClient carries a stored ForwardedPorts spec forward for
a partial edit (client_inbound_apply.go:763-765) and re-validates it (:772 and
:909), so after an in-place upgrade an edit that never submitted the field -- a
bot enable/expiry toggle -- is refused over a slot the operator did not touch,
for a relay injectAmneziawgnetSocks does not emit while the row is disabled. The
inbound-save path re-validates every stored spec the same way.
The trade does not pay for itself: the slot this reserves is claimable only by a
spec an operator authors onto 65101-65535, while the cost lands on unrelated
operations. The precise fix -- refuse a newly claimed spec rather than a stored
one, and check the enable transition in SetInboundEnable, where the conflict is
actually created -- is larger than the hole, so the slot goes back to a
documented pre-existing item with its own follow-up.
The create-path re-run added in
|
||
|
|
d52b598abf |
fix(amneziawg): reserve the relay port before an AmneziaWG inbound has a peer (#6542)
* test(amneziawg): pin that a peerless inbound still owns its relay port checkAmneziawgnetSocksConflict skips a candidate whose settings yield no qualifying peer, and normalizeAmneziaWGSettings writes Clients: [] for a fresh AmneziaWG inbound -- so a newly created row reserves nothing, an ordinary inbound can take its derived port, and adding that row's first client then puts two inbounds on 127.0.0.1:65101. The client paths run no port check. Expected red on this head; the fix follows. * fix(amneziawg): reserve the relay port before the first peer is added checkAmneziawgnetSocksConflict skipped a candidate whose settings yield no qualifying peer (amneziawg.InstanceFromInbound), and normalizeAmneziaWGSettings writes Clients: [] for a fresh AmneziaWG inbound. A newly created row therefore reserved nothing, an ordinary inbound could be saved onto the port that row derives, and adding its first client generated the relay next to it: two inbounds on 127.0.0.1:65101, which makes Xray refuse the whole config and take every other protocol on the host down with it. Nothing re-checked it later either -- only AddInbound and UpdateInbound run checkPortConflictTx, and the client paths that create the first peer run no port check at all. Ownership now follows the row, so the check states the same rule as its two siblings, which key on protocol and node_id IS NULL alone. The amneziawg import goes with the guard. TestCheckPortConflict_AmneziawgnetSocksRelayReservedBeforeTheFirstPeer fails without this, on a test-only head whose go-test run failed on exactly that test, and passes with it. * docs(amneziawg): stop the forward check's doc block claiming every row gets a relay Round-1 LOW: the block's justification clause read "every one of them gets a relay inbound", which is false for exactly the rows this change newly reserves for -- injectAmneziawgnetSocks skips a row with no peer email, and that is the row whose port must stay reserved. A reader following the cross-reference landed on the guard this branch removes and read it as the rule. Replaced by the two facts that are true, which also brings the block under CLAUDE.md's two-line cap instead of twelve lines over it. The peerless reason stays where it is load-bearing, in the two-line comment above the candidate loop. |
||
|
|
2d8d304850 |
fix(amneziawg): stop a disabled inbound's relay slot from being taken (#6540)
* test(amneziawg): pin that a disabled row still owns its relay slot
checkAmneziawgnetSocksConflict filters enable = true, so a disabled AmneziaWG
row is not a candidate when an ordinary inbound's configured port is validated.
SetInboundEnable then flips the column with no port check, so enabling that row
later puts a second inbound on 127.0.0.1:65101 and Xray refuses the whole config.
Expected red on this head; the fix follows.
* fix(amneziawg): count a disabled inbound as owning its relay slot
The forward port check filtered its candidates with enable = true, so a disabled
AmneziaWG row was invisible when an ordinary inbound's configured port was
validated. Nothing else covered the gap: the relay is not a database row, and
SetInboundEnable flips the column with no port check, so re-enabling that row put
a second inbound on 127.0.0.1:65101 and made Xray refuse its whole config,
taking every other protocol on the host down with it.
A row owns the slot its id derives for as long as the row exists, which is the
rule the reverse-direction check already follows. TestCheckPortConflict_
DisabledAmneziawgStillOwnsItsRelaySlot fails without this, on a test-only head
whose go-test run failed on exactly that test, and passes with it.
* test(amneziawg): drop the disabled-row case that asserts the reversed rule
TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled stated, in its
name and its doc comment, that a disabled AmneziaWG inbound's port must not
block anything -- the rule the parent commit reverses. It also never reached the
predicate it named: its fixture seeds Settings: {}, which
amneziawg.InstanceFromInbound rejects on parsed.Server == nil one statement
before the enable column is read, so it passed with or without the filter.
Leaving it would document both rules for the same operator state with nothing
failing to flag the contradiction. The rule this PR pins is covered for real by
TestCheckPortConflict_DisabledAmneziawgStillOwnsItsRelaySlot, whose fixture
carries a qualifying server block and an enabled peer.
|
||
|
|
a036ddd66f |
fix(amneziawg): wrap the relay port window instead of refusing ids past it (#6539)
* fix(amneziawg): wrap the relay port window instead of refusing ids past it An AmneziaWG inbound's loopback relay port is SOCKSBasePort + row id, and AddInbound refused any id that pushed it past 65535. The inbounds table is AUTOINCREMENT, so an id is never reused and the counter is only reset when the table empties: the 435-port window was a lifetime budget, and a database that had ever created more inbounds could never create another AmneziaWG one -- the reporter's counter sits at 70350, so the protocol never worked there at all (#6537). Ids now wrap into the same 435 ports, which leaves every id up to 435 with the exact port it had, so no existing row, relay or generated config moves. Wrapping makes the id -> port map non-injective, and nothing compared two derived relay ports before -- two relays on one port would leave Xray with a duplicate listen and refuse to start, taking the whole panel's proxy down. checkAmneziawgnetSocksRelayCollision now refuses a create or an edit whose derived port another local AmneziaWG row already owns, disabled rows included: a row owns its slot for good, and enabling it later re-runs no port check. * test(amneziawg): give each relay-window fixture its own client email Every fixture built the same client email, and an email is unique across the whole panel, so AddInbound refused the second create with "Duplicate email" before either new guard ran -- CI exercised neither the wrap nor the collision refusal. Each fixture now derives its email from its own tag, which is what the tag already exists for. * fix(amneziawg): say relay port in the relay conflict message A refusal that named the port of the automatic loopback relay read as if the named inbound listened on an unrelated port -- its own port is the WireGuard one. portConflictDetail now carries Relay, and both messages that report a derived relay port say "relay port N"; messages that report a configured port render byte-for-byte as before. * test(amneziawg): pin that a node-assigned inbound owns no relay slot A row adopted from a node carries a NodeID and the protocol it arrived with (inbound_node.go:737), yet injectAmneziawgnetSocks skips it, so it binds no loopback relay. The gate this PR added to checkPortConflictTx never looked at NodeID, so editing such a row can be refused for a slot it does not own. Expected red on this head; the fix follows. * fix(amneziawg): skip the relay guards for node-assigned inbounds Round-2 review finding: the gate this PR added to checkPortConflictTx keyed on inbound.Protocol alone, so it also ran for a row adopted from a node. Such a row carries a NodeID and gets no loopback relay -- injectAmneziawgnetSocks skips it and the desired-instance query is node_id IS NULL -- so it owns no slot and can collide with nothing, yet editing it was refused with "relay port N ... already used by inbound '<local>'", naming a port the edited row never binds. Wrapping made this visible: before it, an adopted id above 435 derived a port above 65535 that no row could hold, so the pre-existing reverse check under the same gate could not fire. Both call sites now require NodeID == nil, matching the local-only predicate the forward check already used. TestCheckPortConflict_NodeAssignedAmneziawgOwnsNoRelaySlot fails without this, with the exact false refusal, and passes with it. |
||
|
|
78ab7a9246 |
fix(amneziawg): read the outbound pseudo-protocol id like the core (#6531)
* fix(amneziawg): read the outbound pseudo-protocol id like the core IsAmneziaWGOutbound compared the id exactly while every reader around it does not: the probe lane already reads the same id with strings.EqualFold (outbound/probe_http.go, pinned by TestBuildBatchTestConfigReadsTheProtocolIDLikeTheCore), and the core lowercases a protocol id before it resolves the handler. A template entry spelled "AmneziaWG" therefore stayed unbridged in two paths. transformAmneziaWGOutbounds skipped it and handed the raw pseudo-protocol to the core, which answers "unknown config id: amneziawg" -- Xray then fails to start, since bridging is what makes that entry a socks outbound. The amneziawg job skipped it too, so the reconcile loop never created the instance and the outbound silently carried no tunnel. The exact comparison also made the save path answer two ways for one spelling: CheckXrayConfig routed the exact match to the panel's own validator and the case variant to the core's, so the operator was told the core does not know a protocol the panel implements (probe output, before: `xray core rejects outbound "t1": infra/conf: unknown config id: amneziawg` for "AmneziaWG" and `amneziawg outbound "t1": privateKey is required` for "amneziawg"; after: the panel's own message for both). Reachable only from a template that did not come through the panel's save, which rejects the case variant today -- a restored backup, a direct DB edit, a scripted template, or a legacy DB. That is the same class of data the UppercaseFreedomFinalRulesFix seeder exists to repair, so the panel already treats non-lowercase protocol ids as real operator input. strings.EqualFold is the whole change; the package already imports strings. * style(service): trim the amneziawg outbound test comment to two lines The review flagged the three-line block: CLAUDE.md caps a committed Go comment block at two lines and the test name already carries the what. The remaining two lines keep the why — the core folds the id's case before resolving it, so a mixed-case spelling must bridge here too. |
||
|
|
a810f497e6 |
fix(xray): read the last two inboundTag protocol ids like the core (#6530)
The core lowercases an outbound's protocol id before it resolves the handler, so an outbound spelled "Loopback" still is the loopback outbound. Both readers that keep a loopback outbound's inboundTag in step with the inbound it names compared the id exactly, so such an outbound was skipped: renaming or deleting that inbound left settings.inboundTag pointing at a tag that no longer exists, and traffic returning through the loopback outbound arrives under a tag no routing rule can match (infra/conf/loopback.go:15 carries the tag, proxy/loopback/loopback.go:43 uses it as the inbound identity). The probe lane's "nothing to test here" gate had the same exact comparison, so a "Freedom"/"Blackhole" outbound reported the vaguer "No testable endpoint" where the canonical spelling reports "Outbound has no testable endpoint" — the two spellings took different paths to the same rejection. Both readers now compare case-insensitively; the outbound package reuses its existing equalsAnyFold helper rather than adding a second one. The service reads the config template an operator edits, so a case variant is reachable there; server.go's GetDefaultLogOutboundTags scans the embedded config.json instead, whose protocols are canonical by construction, so it is left as is and no test can tell a case-insensitive read there from an exact one. |
||
|
|
efcf152950 |
fix(outbound): read the probe testability gate's ids like the core (#6527)
A direct, DNS, loopback or blackhole outbound is not a proxy, so the probe must reject it instead of measuring the panel host's own reachability. The gate compared the protocol id exactly while the core lowercases it in LoadWithID before resolving the handler, so "Freedom" and "DNS" were not recognised: the HTTP probe ran through the direct outbound and returned Success=true with a full egress block, and the row's Test button stayed enabled because isUntestable compared exactly as well. The operator reads the panel host's own country and delay as a working tunnel. The batch gate now folds the id once before its switch, and isUntestable goes through the shared isOutboundProtocol helper. |
||
|
|
f69d1e869d |
fix(outbound): read the probe protocol id and transport name like the core (#6526)
* fix(outbound): read the probe protocol id and transport name like the core The probe lane gate and the endpoint extractor behind it compared both strings exactly, so a template the core is running was probed as something else. With mode=tcp an outbound spelled "WireGuard" stayed in the dial-only TCP lane, where extractOutboundEndpoints matched no case and the caller got "No testable endpoint" for an outbound that is passing traffic. The core lowercases a protocol id (infra/conf/loader.go) and a transport name (TransportProtocol.Build) before it resolves either, and resolves both "kcp" and "mkcp" to mKCP, so both readers now normalise the same way. The panel no longer reaches the lane gate itself — the browser now sends http for these outbounds — but the endpoint documents "tcp" for fast dial-only probes with UDP-transport outbounds still probed over HTTP, and that promise has to hold for direct API callers too. * fix(outbound): read the batch probe protocol id like the core Review of #6526 found that folding "WireGuard"/"AmneziaWG" into the UDP lane newly routed those spellings onto two readers in buildBatchTestConfig that still compared the id exactly. A case-variant WireGuard outbound therefore reached the temp probe instance without noKernelTun -- which on Linux creates a kernel TUN device alongside the live panel's own -- and a case-variant AmneziaWG entry was appended raw, rejecting the whole temp config and degrading the batch to serial per-item retries. Both readers now fold the id the way the core does (infra/conf/loader.go lowercases it before the protocol is resolved). |
||
|
|
c90996eda3 |
feat(sub): add opt-in month-end expiry presentation (#6517)
Offer monthly calendar subscriptions an explicit last-valid-second display without moving their real billing boundary or spending renewal allowances. Keep the option off by default and limit conversion to a shared fixed day-1 midnight cutoff at an actual month transition in the panel timezone. Use the authoritative client calendar mode when aggregating node traffic, and share the header formatter across raw, JSON, and Clash exports. Expose the setting in the existing settings API/UI, regenerate its schemas, and document that clients may report expiry one second early or format the date differently in another timezone. Add HTTP, settings, and DST coverage. Stored deadlines, access enforcement, info/remark expiry values, and renewal accounting remain unchanged. Refs: #6516 Co-authored-by: JacktheRanger <219502738+JacktheRanger@users.noreply.github.com> |
||
|
|
826e29e2de |
fix(xray): place the freedom domain strategy where the core reads it (#6515)
* fix(xray): place the freedom domain strategy where the core reads it freedom resolves through the socket layer, so xray-core reads sockopt.domainStrategy and treats both other placements as legacy: it warns on every config load for the outbound-root targetStrategy it migrates itself, and again for the settings-level domainStrategy it deprecates. The panel wrote exactly those two keys from its Freedom Protocol Strategy select, the outbound form card, and the IPv4 routing helper, so any install that had configured a strategy logged a deprecation warning on every start. The strategy now travels in streamSettings.sockopt everywhere the panel emits it: the Basics select, the outbound form (including the JSON tab, which shares the same adapter), the shipped default template, and the IPv4 outbound the routing helper injects. Reading mirrors the loader's own order — root targetStrategy, then the settings keys, then sockopt — so the card keeps showing the value the core would actually run with, and saving drops the legacy keys instead of leaving them behind. A seeder moves the keys for configs already stored in the database, following OutboundRemovedKeysFix. The shared outbound-root Target Strategy field is hidden for freedom, since the core migrates that key into the very sockopt value the card writes and two knobs for one value would race. Tests: placement round-trips and the migration table run through the real vendored core (a captured log handler proves the warning is gone after the rewrite and present before it), and the modal asserts freedom offers a single strategy field. * test(database): seed the template row the seeder test needs A fresh InitDB creates no xrayTemplateConfig row — the panel's setting defaults live in the service layer — so the test has to insert the legacy template itself and then assert the seeder's history gate stops a second pass from rewriting it. * fix(xray): keep one strategy control per outbound, seed the row in tests Review findings: the Transport tab's Sockopts block renders for freedom too, so its Domain Strategy select and the freedom card wrote one sockopt value between them and the card won on save — the field is hidden for freedom now, leaving the card as the single control. The seeder is also pre-marked on a fresh install so it does not run on the second start, and the seeder test seeds the template row itself (a fresh InitDB has none) and asserts the rewrite structurally instead of grepping for a key name that sockopt also uses. |
||
|
|
22346eef78 |
fix(node): import a newly selected node inbound instead of sweeping it
Saving the node form writes the grown selection and marks the node dirty in one transaction. On the next tick ReconcileNode runs before the snapshot merge, and its delete sweep treats a selected tag with no central row as "deleted on the master" — so an inbound the operator just ticked in the picker (or every unselected one, when switching the node to "all") is deleted from the node before the import that would have created its row ever runs. Nothing on disk separates "pending import" from "deleted while the node was unreachable", but the pre-adoption guard already expresses the former: while inbounds_adopted_at is zero the sweep waits for a clean sync to adopt. A save that grows the managed set now zeroes it again, and the same clean sync re-stamps it, so the offline-delete sweep is only deferred by one successful sync, not disabled. The trade: an inbound deleted on the master while the node was unreachable is re-imported instead of swept if the operator grows the node's selection during that same outage. That is visible and recoverable, where the previous behaviour destroyed a live inbound. Closes #6329 |
||
|
|
d600de2c2e |
feat(geodata): add standard source presets (#6504)
* feat(geodata): add standard source presets Expose the existing geofile allowlist to the Geodata editor so administrators can configure the supported scheduled downloads without copying URLs manually Tested with npm run test, npm run lint, npm run typecheck, npm run format:check, and go test ./internal/web/service ./internal/web/controller -run '^(TestStandardGeodataSources|TestGeodata)' -count=1 Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly) * fix(geodata): preserve custom source entries Add missing standard sources instead of replacing existing custom entries. Assisted-by: OpenCode:openai/gpt-5.6-terra (mostly) |
||
|
|
2fcd28c1bc |
refactor(tgbot): make the add-client expiry presets say what they do (#6503)
* refactor(tgbot): make the add-client expiry presets say what they do The wizard's "Add N days" buttons were a copy of the renewal handler, whose accumulate branch they cleared two lines later: the branch tested a value the line above had just set to zero, so it was dead and only the "set N days from first use" path was reachable. That reads as an accident, and the automated review of #6499 flagged it twice. The wizard keeps the term it sets, which is now the code: a create flow has no expiry to add to, the custom keypad lands in this same case, and a corrected number has to replace the one it follows. 0 stays the Unlimited button. The renewal handler (reset_exp_c) genuinely adds to the client's remaining time and is unchanged. * refactor(tgbot): fold in the review of the expiry-preset change The test now starts every row from a term a preset could have left, so each row fails on its own under the accumulate semantics rather than depending on the row before it, and it reuses the package's draft helpers instead of a second copy. The wizard presets drop the "Add" verb they never honoured; the renewal keyboard keeps it, where reset_exp_c really does add to the remaining time. |
||
|
|
1691c9ca2a |
fix(tgbot): keep the add-client draft with the chat that owns it (#6499)
* fix(tgbot): keep the add-client draft with the chat that owns it The wizard held one package-level draft for the whole bot. Its steps run on the ten-goroutine worker pool, so two admins adding a client at the same time wrote into the same form: whichever step ran last decided the email, the limits and the attached inbounds of a client the other chat went on to create, and the attach picker mutated one shared slice from several goroutines at once as well. Each chat now gets its own draft, reached only through the chat that owns it and held for the duration of a step, so a client is created from the values its own chat collected. * fix(tgbot): take the wizard's draft lock only for the wizard A queued report tap held one of the ten worker slots while it waited on the chat's draft, and every chat that reached answerCallback grew the draft map even when the admin gate rejected it. Both follow from acquiring the draft before the gate; the wizard's own steps are the only callers that read it. The draft is now looked up under the same admin-and-wizard check, addClient takes the draft its caller locked instead of looking it up again, a submit drops the entry, and StopBot clears the map with the conversation states. |