mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
a036ddd66f44f4bfe437fba83bbf0d7b6c3adbc5
321 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
e98be4f72a |
fix(tgbot): render a disabled start-after-first-use client as days (#6500)
A delayed-start expiry is stored as a negative duration, but the card checked the disabled-client branch before the sign of that duration, so it printed the epoch position (-2592000000 ms -> 1969-12-02) and labelled it an expire date. The sign decides first now, which is how BuildClientDraftMessage in this file, subscriptionExpiryFromClient and adjustTraffics already read the same value; the Discord card is the one surface still reading it as unlimited, fixed in #6498. |
||
|
|
d45a09d634 |
fix(discord): page the inbounds reply within Discord's embed caps (#6496)
`!inbounds` built a single embed with one field per inbound and sent it as
it was. Discord rejects the whole message past 25 fields, ten embeds or 6000
counted characters, so an operator holding more than 25 inbounds got no
answer at all, and a remark longer than ~252 runes broke the command on its
own — the `📍 ` prefix spends four units of the same 256-unit field name cap.
The failure left no trace either: the send error was discarded, so the
channel stayed empty and the log stayed quiet.
Fields are now capped by the same helper every other reply in the package
uses for its name and value limits, and packed into messages that fit those
caps, with the header leading only the first embed of each message. The caps
are counted the way Discord counts them, in UTF-16 units, and a page it
answers with a 429 is waited out once rather than dropping the pages behind
it.
|
||
|
|
5c34baa8df |
fix(discord): drop the gateway connection when heartbeats go unanswered (#6497)
The heartbeat goroutine wrote op 1 on its interval and ignored op 11, so a connection that stopped being answered was never noticed. A half-open socket is the case that matters: the kernel accepts the writes and the read loop stays blocked, so the bot serves nothing for as long as the panel runs, and nothing in the log says so. Discord asks clients to close and reconnect when a heartbeat goes unacknowledged, which is what the ticker now does, letting the existing reconnect loop take over. The writeMu regression test's fake gateway answered no heartbeat at all, which the new check reads as a dead socket; it now acknowledges them the way Discord does and paces its op 1 flood, keeping its one-second window of concurrent writes intact. |
||
|
|
cc60cefe02 |
fix(discord): report a start-after-first-use client as days, not unlimited (#6498)
!usage printed Unlimited for any client whose expiry was not a positive timestamp, but the panel stores "Start After First Use" as the duration negated and converts it on the first traffic tick. Such a client does expire, so the operator reading that embed was told the opposite of what the panel and the Telegram bot already say, which both render the same value as days. |
||
|
|
768bbd2a29 |
feat(settings): add setting for Reality scan candidates (#6471)
* feat(settings): allow customizing Reality scan candidate list Persist a realityScanCandidates panel setting (defaulting to the previous hardcoded list), expose it in General Settings with i18n, and have the Find Targets scanner use it when the search box is empty. Fixes #5847 * style(frontend): oxfmt realityScanCandidates in setting.ts * Fix locale JSON syntax This change removes the malformed duplicate key and missing comma in the Android per-app proxy translations across the bundled locale files. The JSON now parses correctly while preserving the translated labels for each language. * docs(i18n): update Happ translations Localize the remaining Happ subscription settings strings across the translation files and refine the English copy. This aligns the labels and descriptions with the current Happ behavior for notifications, TUN options, HWID enforcement, routing presets, and per-app proxy settings. * refactor(reality): drop test-only scaffolding from the candidate setting TestDefaultRealityScanCandidatesCSV compared the CSV against its own initializer and a defaultValueMap lookup, and TestRealityScanCandidateTokensFallsBackWithoutDB drove a no-database state no production caller reaches (the only caller is the scanRealityTargets handler, served after InitDB). The s != nil && GetDB() != nil guard existed only for that second test. None of them could fail except in lockstep with the code they restate. * docs(api): describe the setting-driven scanRealityTargets fallback An empty targets value now probes the realityScanCandidates setting, but the endpoint summary, parameter description and handler comment still promised the built-in seed list, so API consumers were told the wrong target set. Regenerated openapi.json and synced the docs copy, which also lacked the new AllSetting field in the settings reference. --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
bf7ce2daaa |
feat(discord): add Discord notification bot service (#6486)
* feat(discord): add Discord notification bot service, settings UI, and event subscriber - internal/web/service/discord: implement lightweight Discord REST API v10 client and EventBus subscriber - internal/web/service/setting: add discordBotEnable, discordBotToken, discordChannelId, discordEnabledEvents, discordCpu, discordMemory settings and secret protection - internal/web/controller: register POST /panel/api/setting/testDiscord endpoint - frontend: add Discord settings tab, notifications configuration, sidebar navigation, and command palette integration - translation: add localization keys across all 13 locales - tests: add comprehensive unit tests with httptest server and verify route/i18n contracts * fix(discord): address PR review findings on concurrency, linting, i18n, and stories - subscriber: eliminate unbounded goroutines, sending inline per EventBus contract - discord: accept context.Context in SendMessage, SendEmbed, SendTest with http.NewRequestWithContext - format: apply gofumpt to controller and entity struct alignments - i18n: localize testDiscord controller responses across all 13 locales - storybook: add DiscordNotifications.stories.tsx component story * docs: add Discord bot setup and operations guide - add docs/content/docs/en/operations/discord-bot.mdx with setup steps, event indicators, settings, and troubleshooting - add docs/content/docs/ru/operations/discord-bot.mdx with localized instructions - update operations/meta.json across en, ru, zh, fa - link Discord bot from panel configuration overview * feat(discord): add discordLang, discordRunTime, discordBotBackup settings and update settings UI - internal/web/entity: add DiscordRunTime, DiscordBotBackup, DiscordLang fields to AllSetting - internal/web/service/setting: add defaultValueMap entries, getters, and setters - frontend: update AllSetting schema, model defaults, and generate OpenAPI / Zod contracts - frontend: extract shared NotifyTimeField component and update DiscordTab with General and Notifications tabs - translation: add localization keys across all 13 locales * feat(discord): implement scheduled status reports and database backup attachments - internal/web/service/discord: add SendMessageWithFiles supporting multipart uploads - internal/web/service/discord: implement BuildReport and SendReport generating rich status embeds - internal/web/service/discord: attach database backup (and config.json) when discordBotBackup is enabled - internal/web/job: implement DiscordNotifyJob scheduled via robfig/cron - internal/web/locale: add LocalizerFor and I18nForLang helpers - internal/web/controller: trigger reloadDiscordFunc to dynamically reschedule cron upon setting changes - internal/web/web: register and reschedule DiscordNotifyJob - tests: comprehensive unit tests for multipart uploads, status reporting, and job execution * feat(discord): add interactive bot commands via Gateway WebSocket and update documentation - internal/web/service/discord/gateway: connect to Discord Gateway v10 via WebSocket (gorilla/websocket) - internal/web/service/discord/gateway: handle heartbeat loop, reconnection, and command dispatch - commands: implement !status, !report, !backup, !usage <email>, !inbounds, !restart, !help (with ! and / prefixes) - internal/web/web: start/stop Gateway client with server and reload dynamically on setting updates - docs: update operations guide (en, ru) with scheduled reports, backups, commands, and privileged intents - tests: add end-to-end WebSocket Gateway test verifying command handling * style(discord): fix goimports formatting and add 3x-ui to gitignore * fix(discord): stop gateway panics, reconnect storms and proxy bypass The Gateway client wrote to its websocket from both the heartbeat ticker and the read loop answering server-requested op 1 heartbeats. gorilla panics on concurrent writes and neither goroutine recovers, so a colliding heartbeat took the whole panel process down; writes now share writeMu. It also reconnected every 5s forever after close codes Discord marks non-reconnectable (4004 bad token, 4010-4014, including 4014 when Message Content Intent is off), re-identifying and logging a warning each time. The loop now stops on those codes; the docs say to restart the panel. The gateway dialed with websocket.DefaultDialer, bypassing the panel egress proxy the REST client already uses, so where Discord is filtered notifications arrived but commands never connected. * fix(discord): deliver the scheduled report when the backup upload fails SendReport posted the report embed and the x-ui.db/config.json attachments in one multipart request. Once the database outgrows Discord's upload cap (20 MiB by default) the request is rejected and the report embed is lost with it on every run, leaving only a log warning. Send the embed first and the attachments as a second message. * chore(discord): delete tests that pass whether or not the code works TestDiscordNotifyJob_NilServiceNoPanic and TestHandleEvent_NilDiscordService feed a nil DiscordService that web.go never passes, and TestDiscordNotifyJob_DisabledNoPanic passes with or without the enable guard because Xray is not running under test. * fix(discord): require admin user IDs for bot commands and honor discordLang Any member who could post in the configured channel could run !backup (the whole x-ui.db and config.json, even with discordBotBackup off), !restart and !usage. Commands now run only for the Discord user IDs in the new discordAdminIds setting; an empty list turns commands off. discordLang was saved and offered in the UI, but nothing read it, so every embed stayed English. The test message, alerts, the scheduled report and command replies now render through I18nForLang in the chosen language, with a discord section in all 13 locales. InitLocalizer takes an fs.FS so tests load the real translation files. --------- Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
c7518c4038 |
fix(tgbot): send the admin traffic reports as one message (#6490)
Reset all traffics and the sorted usage report replied with one Telegram message per client. A panel with a few hundred clients therefore fired a burst of sendMessage calls that trips Telegram's per-chat rate limit and throttles the bot for every user, not just the admin who tapped. Both reports are now assembled into one string and handed to SendMsgToTgbot, which already pages long messages. Two details the batching would otherwise lose: the reset report still answers (with the reply keyboard removed) when the panel has no clients, and both reports are HTML-escaped as a whole, because a single stray "<" in a remark or an email now costs the ~15-client page it lands on instead of one client's message. |
||
|
|
6a5b4fab6a |
feat(happ): generate Crypt5 subscription links locally (#6494)
* feat(clients): add stateless Happ link generator
Generate Happ provider links from the current effective subscription source without caching results. Reject unsafe provider responses and redact failure diagnostics.
* fix(clients): reject duplicate Happ provider fields
Parse Happ provider objects token by token so duplicate supported keys cannot be silently overwritten by encoding/json.
* feat(clients): expose on-demand Happ link API
Expose a no-store client endpoint backed by the Happ link generator and keep its generated OpenAPI contract synchronized.
* fix(openapi): exclude service interfaces from generated types
Keep dependency-injection interfaces out of the frontend API surface while preserving allowed response schemas.
* feat(clients): add stateless Happ QR presentation
Generate Happ links only for the active modal scope and retire late responses so Standard remains immediately available. Add focused component coverage and localized retry guidance across every locale.
* fix(clients): cover overlapping Happ generations
Prove the cancellation cleanup is required by resolving a retired request while its replacement remains pending. Also wait for Regenerate to leave loading state before exercising the existing action.
* fix(clients): harden Happ link handling
Validate generated responses before rendering and hide actions during unresolved requests. Strengthen route, redirect, timeout, and lint regression coverage with mutation-sensitive tests.
* fix(clients): gate Happ link generation behind operator opt-in
- add a fail-closed happLinkEnable setting
- enforce the gate before and after provider requests
- add locked Happ QR state with privacy disclosure and settings link
- cover backend, frontend, settings, and i18n regressions
* fix(frontend): guard oversized Happ QR codes
Keep valid long crypt5 links copyable while suppressing QR rendering and image actions above the encoder's UTF-8 byte limit. Add localized guidance and boundary coverage.
* fix(clients): log the sanitized transport error for Happ link failures
Every fail() call in HappService.Generate passed a string literal as the
detail, so the sanitizer written for provider errors only ever saw
constants, and an operator following the QR modal's "check Logs" hint
found nothing beyond reason=transport. Transport and body-read errors now
flow through sanitizeHappDetail, which also redacts cookie/session pairs.
Drop TestHappLinkEnableDefaultsOffWithoutPersistingRow: it pinned a getter
and its constant default, which the Generate gate test already drives.
* fix(frontend): size the Happ QR cap to level L and keep the QR modal mounted on close
HAPP_QR_MAX_BYTES was the level-M capacity (2331) while QrPanel encodes at
errorLevel "L", whose version-40 byte-mode capacity is 2953, so valid links
between 2332 and 2953 bytes lost their QR. The cap now matches the encoder
and a test renders the real QrPanel at the boundary.
Keying the modal content on `open` remounted it on every close, which cut
the Modal's exit transition and made the openSubId sync unreachable, so
`loading` never turned on for the subLinks fetch and a client without a
subscription link flashed noLinks on reopen. `open` leaves the key and the
sync block now also resets the Happ state.
* chore(clients): request Happ crypt5 links from api-v3
crypto.happ.su serves api-v2.php and api-v3.php side by side. Probed with
the same payloads, both take {"url"} over a JSON POST, answer
{"encrypted_link":"happ://crypt5/..."} of identical length with the same
crypt5 key marker, and fail the same way: 400 "No url provided.",
500 "Invalid URL format.", 405 on GET. Happ's own generator page is
branded "URL Encryption v3", so the panel follows it. The parser and the
link validator are unchanged.
* feat: add local generation of encrypted Happ links
- Implemented functionality to generate encrypted Happ links locally without network dependency.
- Added validation for URL length and format to ensure compliance with processing limits.
- Introduced new error handling for invalid URLs and control characters.
- Updated translations for various languages to reflect changes in Happ link generation.
- Created unit tests to validate the encryption process and ensure session keys and nonces are unique.
* fix(frontend): match the tuic memo deps to the non-optional subSettings
The Happ branch reads subSettings non-optionally in ClientQrModalContent
(happLinkEnable and the WireGuard/AmneziaWG publicHost memos), so React
Compiler infers subSettings.publicHost. The TUIC memo merged in from main
still listed subSettings?.publicHost, which fails oxlint's
preserve-manual-memoization rule and makes the compiler skip optimizing
the component. make verify stopped at lint-fe on the branch head.
* chore(happ): trim the pinned-key provenance comment to two lines
CLAUDE.md caps a comment block at two lines. The bare URL line repeated
the repository and file the next line already names, so it is folded
into that line (review LOW on happ_crypto.go).
---------
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
|
||
|
|
c3b08b6d9f |
fix(tgbot): guard the mock Telegram server's call counts
staleButtonServer increments its per-method map from the HTTP handler, which httptest runs on one goroutine per connection. #6491's TestAdminListReadersShareTheWriterLock is the first test to reach it from several goroutines at once, so CI's race job flagged the helper's map rather than the code under test. |
||
|
|
b98f947efe |
fix(tgbot): answer only the link callbacks that match nothing
#6493 was written against the if/else chain where every served link action returned early, so its trailing answer ran only for an unrouted payload. #6489 had already turned that chain into a switch that falls through, so after the merge every served link tap also got an error toast, while an unknown payload still returned from the !ok branch unanswered. Move the answer into the !ok branch, the one place nothing matched. This turns TestClientLinkCallbackServesOwnClient and TestUnroutableCallbackIsAnswered green again on main's go-test job. |
||
|
|
2730e4d071 |
feat(sub): let the panel set the JSON subscription DNS servers (#6485)
* feat(sub): let the panel set the JSON subscription DNS servers A baked routing profile (#6402) carries only the DNS its preset defines, so an operator who wants their own resolvers has to override the whole profile or patch the subscription behind a proxy. Add the subJsonDns setting: either a full xray dns block or a bare array of servers. It wins over the profile's DNS while leaving the profile's routing rules intact, and reaches per-inbound, balancer and info-node documents alike. The value is validated with xray's own schema (internal/xray/dnsconf): a block the client could not load is rejected when the settings are saved and ignored with a warning at request time, instead of being baked into every document. Both the sub server and the settings API share that validator, so a stored value can never be silently dropped. xray's Build() is deliberately not used for validation: it resolves geosite tokens from the geodata files and would reject valid configs whenever those are absent from the panel's working directory. * style(dnsconf): drop the ineffectual initial map assignment golangci's ineffassign flagged the zero-value map whose value both paths overwrite: the object branch now assigns the decoded map directly. * docs(sub): scope the DNS setting to the documents it rewrites The Routing header mirrored to Happ/INCY keeps the routing profile's own resolvers, so the setting description and the header-source comment now say so instead of claiming the profile's DNS is replaced everywhere. Also trims two comments in the new dnsconf package to the repo's two-line cap. |
||
|
|
aaa5e61cad |
fix(tgbot): answer the callbacks the bot cannot route (#6493)
Telegram keeps a tapped button in its loading state until the callback is answered, and three paths dropped the tap without answering: a payload that matched no case in the email-scoped switch, one that matched nothing in the switch that follows it, and a button whose hash had aged out of the 20-minute storage, which replied with a chat message only. All three answer now. The email-scoped switch is reached only by an admin's payload carrying arguments, and today an unknown action there falls out of the switch into a return that tells the operator nothing. The expired-hash path keeps the chat message as well, because sendCallbackAnswerTgBot is a single unretried call while SendMsgToTgbot retries connection errors, and after a panel restart that notice is the only explanation the admin gets. |
||
|
|
02c6c3a9c6 |
fix(tgbot): render the add-client draft as HTML and escape its values (#6492)
The draft is sent with ParseMode HTML but was written in Markdown, so every card showed literal asterisks and backticks while the rest of the bot's messages render properly. Its admin-supplied values (email, comment, TG id, inbound remarks) were also interpolated raw, and a single '<' in any of them makes Telegram reject the whole message as unparsable. The wizard's own email and comment prompts echo those same draft values into HTML-parsed messages and were escaped too: the card is deleted before the prompt goes out, so a rejected prompt left the admin with an empty screen. |
||
|
|
615876b2eb |
fix(tgbot): read the admin list and running flag under their mutex (#6491)
Start and Stop replace adminIds under tgBotMutex, but the report cron, the backup job and every incoming callback (checkAdmin) read it unlocked. A concurrent read of a slice header being replaced is not a benign race: it can observe a torn header and iterate past the backing array. The readers now take a snapshot under the same lock, and SendMsgToTgbot uses the existing IsRunning accessor instead of reading the flag directly. |
||
|
|
7ac5277c4f |
fix(tgbot): require client ownership for non-admin link callbacks (#6489)
A non-admin tapping a subscription, individual-links or QR-links button was served whatever email that button carried. Those keyboards outlive the chat they were sent to (group chats, forwarded cards, a client whose tgId was later revoked), so the email in the callback data cannot authorise itself. The lookup the self-service usage command already performs now decides whether the callback is served. The gate also has to read the email at all: encodeQuery replaces any callback payload past 64 chars with a hash, so for a client whose email is long enough the non-admin path saw a bare hash and dropped the tap without a word. The raw data is now decoded before the gate, which is the same decode the admin path already performs, and an email the caller cannot prove is answered with the generic error instead of silence. |
||
|
|
51e0afdd90 |
fix(inbounds): allow negative subSortIndex for subscription order (#6465)
* fix(inbounds): allow negative subSortIndex for subscription order Preserve explicitly set negative indices so primary inbounds can sort ahead of the default without renumbering peers; keep 0/omitted → 1. * fix(inbounds): gofumpt model.go and trim subSortIndex comments --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
b467d4c676 |
feat(reality): warn when target cert chain is too small for ML-DSA-65 (#6470)
* feat(reality): warn when target cert chain is too small for ML-DSA-65 Expose peer cert-chain DER size from the REALITY scanner and surface a UI warning when ML-DSA-65 is enabled but the chain is under xray-core's 3500-byte minimum, so silent fallback failures are easier to catch. Fixes #5973 * fix(reality): gate scanner ML-DSA tag and sync docs OpenAPI Only warn on short cert chains in the target scanner when ML-DSA-65 is enabled. Copy frontend/public/openapi.json to docs/public/openapi.json and fix oxfmt wrapping in the new test. --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
5fbd2b490c |
fix(clients): preserve enable on portable import (#6481)
* fix(clients): preserve enable on portable import Stop BulkCreate and orphan ImportClients from forcing enable=true, and restate Enable=false after GORM Create (clients.enable default:true drops the zero value). Interactive Create still defaults new clients to enabled. Fixes #6478. * fix(clients): respect enable=false on node mirror; omit enable defaults true --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
8082ab4d74 |
feat(clients): show short HWID fingerprint in admin device list (#6464)
* feat(clients): show short HWID fingerprint in admin device list Expose a 12-char prefix of the stored hwid_hash in the admin HWID list API and UI so admins can distinguish devices without querying the database. Full hashes and raw HWIDs remain unexposed. Fixes #6359 * ci: retrigger release matrix after 386 dependency download flake --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
0a838563bb |
fix(clients): use EffectiveFlow in BulkAttach (#6454)
* fix(clients): use EffectiveFlow in BulkAttach Mirror Attach (#4834): seed wire clients from EffectiveFlowsByEmails so a zeroed clients.flow column does not drop Vision on bulk attach (#6432). * test(clients): cover BulkAttach Vision flow when clients.flow is zeroed Regression for #6432 — same scenario as TestAttach_PreservesVisionFlowWhenCanonicalColumnZeroed. * fix(clients): only apply EffectiveFlow when present in BulkAttach Avoid overwriting a non-empty clients.flow when EffectiveFlowsByEmails has no entry for the email. Also drop the extra blank line that broke gofumpt. --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
67addab343 |
fix(inbounds): serve fresh client UUIDs for list and allLinks (#6458)
* fix(inbounds): serve fresh client UUIDs for list and allLinks (#6436) Resolve clients from the clients table in inboundLinks and backfillClientStats so /inbounds/list ClientStats and allLinks match the running Xray identity when embedded settings JSON is stale. * fix(sub): keep WG/AWG settings identity in link exports (#6436) clientsForLinkExport uses the clients table for UUID-bearing protocols and the inbound settings JSON for WireGuard/AmneziaWG so allLinks and per-client QR links stay consistent without collapsing per-inbound tunnel keys. * fix(sub): fall back to settings clients for link export (#6458) Prefer ListClientsForInbound for UUID protocols, but when the clients table is empty or unavailable fall back to GetClients so settings-only inbounds (and share-link unit tests) still produce links. Keep WG/AWG on settings identity. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
5cce2464f1 |
fix(clients): snap EOM 23:59:59 expiry to billing midnight without renew (#6457)
* fix(clients): snap EOM 23:59:59 expiry to billing midnight without renew (#6300) Inclusive end-of-month expiries share the next calendar billing boundary. Normalize onto that midnight before the catch-up loop so the first charged step is a full month and resetMax=1 is not spent on a one-second alignment. * fix(clients): snap only the EOM 23:59:59 instant, not the whole prior day The calendar renew guard was matching [boundary-1d, boundary), so a midday expiry on the day before billing could be snapped past now with renewals=0 and left disabled until midnight. Narrow to [boundary-1s, boundary). Also clear golangci (gofumpt/QF1001), trim comments to 2 lines, and pin that midday expiry still charges for alignment. --------- Co-authored-by: mrchatam <mrchatam@users.noreply.github.com> Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com> |
||
|
|
6d96accd63 |
Feature/tuic v5 (#6337)
* Feat(tuic): Implement native TUIC v5 protocol support via Rust sidecar daemon - Add internal/tuic package for official tuic-server sidecar lifecycle management, configuration generation, and graceful process control - Bridge decrypted TUIC QUIC traffic into loopback Xray SOCKS5 inbounds (63200+id) for traffic accounting, statistics, and routing rules - Implement periodic reconciliation job (cadence @every 10s) and immediate runtime synchronization on inbound/client mutations - Add TUIC inbound & multi-user client settings (UUID + Password authentication) in Web UI with SNI auto-fill and panel certificate loader - Integrate tuic:// subscription links and Clash.Meta (Mihomo) proxy generation for TUIC - Update install.sh to automatically download and install official tuic-server release for x86_64, aarch64, and armv7 - Add full localization for TUIC protocol across all 13 supported languages * Feat(install): Support custom repository and branch in install and update scripts * Ci(release): Enable publish-dev for feature branch and workflow dispatch * Feat(sub): Add TUIC to subscription resolution and client QR config generator - Add 'tuic' to getInboundsBySubId SQL allowlist to resolve TUIC inbounds in subscriptions and sub links - Enhance buildTuicProxy in Clash subscription generator with robust host and credentials resolution - Add tuicConfig.ts to generate standalone Clash/Mihomo YAML configuration - Add dedicated TUIC Config tab in ClientQrModal with QR code and .yaml download button - Add localization keys for TUIC config across all 13 supported languages * Fix(tuic): Exclude TUIC from native Xray inbounds and strip udp_relay_mode from server config - Exclude model.TUIC from native Xray inbounds in GetXrayConfig to prevent Xray startup failure - Remove udp_relay_mode from tuic-server JSON configuration builder - Update install.sh to install tuic-server binary to both xui_folder/bin and /usr/local/bin * Fix(install): Fallback to dev-latest when releases/latest is not present on fork * Feat(tuic): Add real-time online status and LastOnline tracking for TUIC clients - Track client activity by mapping client UUID in tuic-server logs to email - Integrate TUIC active clients into XrayTrafficJob to refresh local online clients - Bump LastOnline timestamp in database and broadcast live online status over WebSocket * Feat(tuic): Implement real-time traffic statistics and live speed reporting for TUIC - Collect precise I/O traffic deltas for tuic-server child processes via /proc/<pid>/io - Aggregate and attribute TUIC traffic deltas per client in tuic Manager - Integrate TUIC traffic deltas into XrayTrafficJob to update database and broadcast live speed * Feat(tuic): Finalize TUIC v5 integration with 1:1 traffic counting and orphan process cleanup - Use exact 1:1 byte delta accounting from /proc/<pid>/io - Add killStrayTuicProcesses to terminate orphan sidecars on panel startup - Fully integrate TUIC with subscriptions, live speed meter, and all 13 locales * Feat(frontend): Polish TUIC UI, support bulk operations, and update translations - Align TUIC inbound certificate form with standard 3X-UI layout (Set Default Cert, Clear) - Remove extra subtitle hint text from TUIC inbound form fields - Support TUIC in client bulk attach/detach and bulk add modals - Add TUIC badge color to client info modal, clients table, and host list - Update password tooltip across all 13 locales to include TUIC - Remove obsolete dead translation keys across all 13 locales * Chore(ci): Finalize TUIC v5 bundling across release workflow, Docker, and scripts * Feat(openapi): Update OpenAPI generator and schemas for TUIC types * Fix(backend): Address core review findings for TUIC types, port checks, and xray bridge * Refactor(traffic): Isolate proc reading with build tags and decouple TUIC metering into TuicJob * Feat(client): Add TuicServer to InboundOption, fix config export and clean share links * Fix(frontend): Register TUIC in multi-user helpers, tracked protocols, and tag derivation * Chore(openapi): Re-generate OpenAPI specification and sync Zod schemas * Chore(scripts): Add Alpine musl binaries, 386 and Windows packaging, and anchor pkill * Fix(review): Remove stale import, correct binary names, switch to musl, and drop unreachable relay gate * Feat(frontend): Show share link in Inbound Info and display UDP tag for TUIC * Docs: Add TUIC v5 configuration guide and link specifications * Docs(tuic): Correct Clash Meta configuration parameter to reduce-rtt * Fix(tuic): Generate client credentials on copy, enforce ID/password validation, and add i386 to DockerInit * Fix(tuic): drop unused relay, fix traffic accounting, and honor host endpoints - Drop unused loopback SOCKS relay and eliminate port collision with AmneziaWG - Correct inbound traffic calculation without double-counting - Drop heuristic client traffic division while retaining online tracking - Support externalProxy host fan-out and conditional parameters in share links - Scope orphan process termination to managed config directory * Fix(tuic): enforce client quotas, decouple Xray restart, and sync openapi schemas - Regenerate OpenAPI, Zod schemas, and TypeScript types without route_through_xray - Populate clientTraffics in TuicJob to enforce client quotas and first-use expiry - Split process I/O delta into up and down in Process.CollectTraffic - Remove SetNeedRestart from updateTuicInbound to prevent Xray session drops - Use InstanceFromInbound for default ALPN and UDP relay mode in tuic:// share links - Support allow_insecure on externalProxy host endpoints without parameter collision * Fix(tuic): attribute client traffic only on single-user inbounds and sync link defaults - Attribute I/O deltas to the client only when the inbound has exactly one configured client, avoiding false billing and disablings on multi-user inbounds - Aggregate client traffic by email in TuicJob so clients on multiple inbounds don't lose deltas - Match frontend genTuicLink defaults for alpn and udp_relay_mode with backend subscription links * Fix(tuic): gate client traffic by total sidecar clients and require client email * Fix(tuic): enforce inbound-only traffic limits and disable client totalGB * fix(tuic): restore delayed start, remove client totalGB rejection, and document linux-only limits * fix(tuic): anchor pkill, fix io baseline/split, escape yaml, and deduplicate start errors * fix(tuic): prevent traffic double-counting, ensure info log level for delayed start, and broaden pkill matching * fix(tuic): address review round 11 findings - internal/sub/json_service: skip tuic protocol in json subscription to prevent direct routing leak - internal/sub/clash_service: honor externalProxy/host row allowInsecure, sni, and alpn in buildTuicProxy - internal/web/runtime: decouple tuic inbound add/delete from xray restart - internal/tuic/config: restore user log-level options (warn, error) without forced info clamp - frontend/src/lib/xray/inbound-link: fix duplicate remark suffix and apply externalProxy TLS overrides - frontend/src/schemas/protocols/stream/external-proxy: propagate allowInsecure through host mapping - tests: add coverage for json sub skip, clash proxy overrides, and link generation * fix(tuic): meter inbound traffic through a UDP relay and bracket IPv6 binds Review repairs on the TUIC v5 sidecar integration: - Inbound traffic was read from the sidecar's /proc/<pid>/io rchar, but the kernel only counts read()/write() there and tuic-server moves its sockets with recvfrom/recvmmsg/sendmmsg/sendto, so an inbound's up/down stayed at 0 forever and inbound total limits never tripped (measured: 12 MiB relayed, rchar delta 0). The panel now owns the inbound's public UDP port with a small relay and runs tuic-server behind it on a loopback port, counting up/down exactly on every OS. tuic-server therefore logs 127.0.0.1 as every client's address; per-client attribution stays unsupported since QUIC is opaque. - Instance.BindTo formatted an IPv6 listen address as ":::8443", which tuic-server rejects with "invalid socket address syntax", so an inbound listening on "::" or any IPv6 literal never started. It now uses net.JoinHostPort; IPv4 output is unchanged. - The log level is passed to the sidecar as chosen. Online status, last-online and delayed start are read from its Info lines, so the Log Level field now says that Warn and Error switch them off for the inbound, and the docs say the same. - Drop two frontend tests that only exercised a getter and a set lookup, and strip the trailing blank line that made gofumpt fail on two of the new Go test files. * fix(tuic): harden tag updates, runtime routing, and relay stability --------- Co-authored-by: poise52 <equipoise52@gmail.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
9f07951ba7 |
feat(outbounds): support custom subscription user agents (#6398)
Some subscription providers require a client-specific User-Agent before returning outbound links. Persist an optional value per subscription and use it for refreshes and previews while preserving the existing default for blank values. |
||
|
|
89ee1242bd |
feat(sub): add read-only HWID device-slot status endpoint (#6380)
* feat(sub): add read-only HWID device-slot status endpoint Closes #6357 A client with an HWID limit had no way to tell a subscriber how many device slots were left: /{subPath}/{subId} only exposes the gate as a boolean through X-Hwid-* headers on a 404, and ?format=info carries no limitHwid or registered count. Every "why can't I connect on my new phone" case therefore had to be answered by the operator by hand. GET /{subPath}/{subId}/hwid-status now returns the aggregate counters: {"active":true,"limit":2,"registered":1,"remaining":1,"full":false} - SELECT-only. It never registers an hwid, never touches last_seen and never calls the enforcement path, so asking about a slot cannot spend one. - Counters only: no hwid value or hash, no email, no device metadata, no IP, no User-Agent, and none of the X-Hwid-* gate headers. - The subscription id is already the bearer secret for /{subPath}/{subId}, so no admin token and no new auth mechanism. - Unknown and disabled subscriptions both answer a bare 404, with identical status, headers and body, so the route cannot be used to probe which subscription ids exist. - No HWID limit configured returns {"active":false,"limit":0,...}. - No schema change and no migration. Scoped to enabled clients exactly like effectiveHwidLimitForSubID, so the reported limit is always the limit the gate enforces on a shared sub_id, and remaining clamps at zero when the effective limit drops below the number of registered devices. A separate route leaves /{subPath}/{subId}, ?format=info and the JSON/Clash routes byte-for-byte unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sub): document hwid-status as the bare object it returns The OpenAPI operation for GET /{subPath}/{subId}/hwid-status inherited the {success,msg,obj} panel envelope from build-openapi.mjs's default 200 response, while the handler writes the HwidSlotStatus struct bare. A client generated from the spec would read `obj` and never find the counters, and the description prose contradicted the schema with a hand-written example. HwidSlotStatus now sits in openapigen's StructAllow with example: tags, the entry references the generated schema through a `responses` block, and build-openapi.mjs attaches the generated example to any `responses` entry that $refs a generated schema, so no example is hand-written. The HEAD variant the controller registers is documented like its siblings, and the summary follows the "path prefix is configured by subPath" wording now that fresh panels randomise the prefix. Regenerated frontend/public/openapi.json, docs/public/openapi.json and the subscription-server MDX. openapi-runtime-contracts.test.ts pins the bare schema, the generated example and the HEAD operation. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
8f162994ef |
feat(clients): let admins set PersistentKeepalive on tunnel clients (#6377)
* feat(clients): let admins set PersistentKeepalive on tunnel clients
model.Client already carries KeepAlive, and every AmneziaWG/WireGuard client
config emitter already writes PersistentKeepalive when it is above zero -- but
nothing in the UI could set it, so it stayed 0 and the line was never emitted.
Without it a peer that goes quiet has nothing to trigger a handshake: WireGuard
only initiates when it has data to send. An idle client stays disconnected
after any interruption -- a NAT mapping timing out, a device sleeping, the
panel restarting -- until the user generates traffic themselves.
New clients default to 25, the conventional value, which also keeps the NAT
mapping open. Existing clients keep whatever they have, and 0 remains valid and
means "do not send keepalives".
* fix(clients): let an explicit 0 actually disable PersistentKeepalive
Addresses review feedback on the previous commit.
UpdateInboundClient carries a stored keepalive forward whenever the incoming
one is zero, so the settings JSON and the running peer survive a metadata-only
edit that omits the field. That was a 0 -> 0 no-op while no UI could set a
nonzero value. Now that the client form can, the carry-forward became reachable
in the other direction: a client created at the form's default of 25 could
never be returned to 0, and the hint text shipped to all 13 locales -- "0
disables it" -- described something the backend silently refused. The save even
reported success, because a settings blob that came back byte-identical skips
the transaction entirely.
The zero value cannot carry that distinction, so model.Client.KeepAlive becomes
*int: nil means the field was never sent, &0 means "send no keepalives". The
pointer survives the internal marshal in ClientService.Update, which is where an
explicit 0 was being erased by omitempty before UpdateInboundClient ever saw it.
ClientRecord.KeepAlive stays a plain int -- it is the stored column, where
"unset" has no meaning -- and the conversions bridge the two.
Two tests, both red before this change in the direction they cover: an explicit
0 must reach wg_keep_alive, and an update that omits the field must still leave
a stored 25 alone.
Also adds the output transform every other numeric field in the client form
already has, so a cleared box sends 0 rather than null.
* fix(clients): repair the keepalive pointer conversion after the main merge
Merging main brought buildAmneziaWGProxy (#6326) in beside the
Client.KeepAlive int -> *int change without reconciling the new call site,
so internal/sub stopped compiling and took every package importing it with
it. The two sides touched different lines, so git merged them without a
conflict -- the green `make verify` on
|
||
|
|
3f1e52f09e |
refactor(panel): drop two duplicated helpers
SettingService.GetDefaultJSONConfig was a byte-identical copy of GetDefaultXrayConfig with no callers anywhere in the tree. amneziawgnet.normalizeDNSServer re-implemented the exported amneziawg.NormalizeDNSServer line for line, in a file that already imports that package for EffectiveMTU three lines above it. Its two callers now use the exported one, so the bare-IP-to-host:port rule has a single definition. |
||
|
|
02f2a63c53 |
refactor(tgbot): extract the shared numeric keypad builder
Six callback flows — limit_traffic, reset_exp, ip_limit and their add_client_* counterparts — each carried the same 28-line inline keyboard: cancel, confirm, the 1-9 grid, clear, 0 and backspace. Only the callback prefix, the threaded email argument, the cancel target and the confirm label key ever differed, and the labels had already drifted apart between otherwise identical flows. numericKeypad now builds that grid from a numericKeypadSpec, cutting 168 lines to 6. The callback data is unchanged: every one of the 84 strings and all 6 label keys were diffed against the pre-refactor router and are byte-identical, which matters because encodeQuery hashes any query over 64 chars and buttons already sitting in a user's chat carry these strings. |
||
|
|
8b9cf260b6 |
perf(clients): batch the client record lookup in bulk operations
BulkResetTraffic resolved every address with its own GetRecordByEmail call, one SELECT per email, purely to find the disabled clients it has to re-enable. Resetting 30 clients issued 30 queries before the batched transaction even started, while BulkAdjust, BulkDelete and BulkSetEnable next to it already loaded their rows with a single chunked IN query. Those three carried a verbatim copy each of both the trim/dedupe loop and the chunked record load, so the reuse is the fix: trimmedUniqueEmails now delegates to the existing uniqueNonEmptyStrings, and clientRecordsByEmail holds the one chunked lookup all four call sites share. A DB failure during the lookup now aborts the reset instead of being swallowed per email; a missing row is still skipped, as before. The new test drives BulkResetTraffic with 3 and with 30 emails and fails unless both issue the same number of SELECTs against clients. |
||
|
|
fc08b53395 |
feat(ui): add global command palette (Ctrl+K) for fast navigation and search (#6352)
* feat(ui): add global command palette (Ctrl+K) for fast navigation and search * fix(ui): address review feedback for shortcut listener, i18n parity, and search deep links * fix(ui): resolve search routing, translation keys, and palette state reset * fix(ui): improve command palette styling and sidebar transitions * fix(ui): address review feedback for typecheck, codegen, debouncing, and state reset * fix(ui): resolve effect state update warning and debounce reset in command palette * fix(ui): address review feedback for stale client search results and theme action * style(ui): apply oxfmt formatting to command palette and tests * fix(deps): update js-yaml override to resolve audit advisory * docs(api): sync the docs OpenAPI copy with the new InboundOption fields Adding Network/Security to InboundOption regenerated frontend/public/openapi.json, but docs/public/openapi.json is a hand-kept copy of that file and nothing checks it: make verify never reaches docs/, and docs-ci.yml fires only on docs/**. The two files were byte-identical on main and had diverged here, so the published API reference described a response shape the panel no longer returns. Regenerating the MDX under docs/content/docs/en/reference/api/ produced no change — the schema is read from the JSON at render time. * fix(ui): unnest the command palette row control and label its shortcut The palette row was a <button> wrapping the copy-subscription <button>. Nested interactive content is invalid HTML and React 19 logs two errors for it on every client result. The row is now a role="button" div using activateOnKey, the pattern the rest of the panel already uses, with line-height pinned so dropping the UA button style does not grow every row. Its keydown handler ignores events bubbling from the nested button: activateOnKey preventDefaults Enter, which would otherwise cancel the browser's Enter-to-click on the copy button and navigate instead. The sidebar chip hardcoded the Mac glyph while the handler accepts Ctrl as well, so Linux and Windows operators were shown a key they do not have; it now picks the modifier from the platform. Also restores the comment on ClientsPage's debouncedSearch that the deep-link change removed — the code it explains is unchanged. |
||
|
|
2dd903ea8e |
feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402)
* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription Accepts the routing-rules format emitted for Happ and INCY (inline JSON, happ:// or incy:// deeplink, or a remote https:// URL resolved through the existing remote routing cache). The JSON subscription will bake these rules into its documents so header-ignoring clients still get routing. * feat(sub): bake Happ/INCY routing profiles into JSON subscription documents When subJsonRoutingRules is set, every emitted document (per-inbound and balancer alike) carries the profile's dns and routing rules baked in, so header-ignoring clients like Happ and INCY still get routing; the legacy simple-rules merge only applies when no profile is set. The balancer document builder keeps rewriting proxy-tag rules to the balancer. * feat(sub): add the subJsonRoutingRules setting Plumbed from the settings store through the subscription server into SubJsonService, so admins can set a routing profile once and every JSON subscription document carries it. * chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules * feat(web): routing profile editor for the JSON subscription A textarea inside the JSON card accepts the routing profile (inline JSON, happ/incy deeplink, or https URL) with a remote-source badge; the badge helper moves to a shared module. Keys added to all 13 locales. * fix(sub): warm and lazily resolve the baked JSON routing source The routing profile was resolved once at service construction: a remote URL that was cold at that moment baked default routing forever, and the cron job never warmed it. The job now warms the subJsonRoutingRules URL, and the profile resolves per request with an in-memory memo (a failed resolve is not cached), so a warmed cache takes effect without a restart. * feat(sub): fall back to the JSON routing profile for the Routing header Happ and INCY download the geo files a routing profile references through the Routing response header. When the Happ header setting was blank the header stayed unset, and clients fetched no geo files even though a JSON routing profile was configured. A blank setting now falls back to the JSON profile: happ/incy deeplinks pass through, inline JSON and remote URLs are normalized to a happ:// deeplink; an unusable or oversized value leaves the header unset. Locale captions mention the fallback. * fix(sub): pass routingRules arg at call sites added by main Main gained four NewSubJsonService call sites after this branch forked; update them to the five-arg signature so internal/sub builds again. * fix(sub): address code review findings on the baked JSON routing The memoised baked template never invalidated, so an edited remote profile kept serving the superseded dns/routing subtrees until a panel restart; bakedTemplate now re-resolves the spec per request and rebuilds only when the payload actually changed (regression-tested). subJsonRoutingRules shared the happ persistence row with subRoutingRules, so only the last-written setting survived a restart; it now resolves under its own jsonhapp kind with the same validation and size caps. The setting also joins validateSettingsURLs, so remote values are canonicalised and bad URLs are rejected on save. Also: drop the unreachable half of the remote-source guard, cut the overlong comment blocks to the two-line convention, and deduplicate remoteSourceBadge in the General tab. Merges upstream/main (call sites for the widened NewSubJsonService signature). * style(sub): gofumpt the json_routing imports * fix(sub): accept happ add/ deeplinks and bound the routing warning The baked-JSON routing parser only recognised happ://routing/onadd/, but normalizeHappRouting treats happ://routing/add/ as an equally valid routing deeplink. An operator pasting the add/ form got the Routing header set, so the panel looked configured, while every JSON subscription document silently carried the default routing instead of their profile. resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it once per emitted document, so a single fetch of an unusable profile wrote one identical warning per document. On the public subscription server that floods the 10240-entry buffer the panel's log view reads, evicting real entries. Log only when the message changes, and reset on a successful resolve so a profile that recovers and fails again is still reported. Also resolve the template once in buildBalancerConfig: two resolves could straddle a profile refresh and pair one revision's dns with the other's routing. |
||
|
|
ed5465d0f2 |
feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust (#6399)
* feat(clients): support setting HWID limit and MTProto ad-tag in bulk adjust Add HWID device limit and Telegram MTProto sponsor channel (ad-tag) support to the bulk client adjustment flow in both the panel API and frontend ClientBulkAdjustModal. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(clients): gate adTag to MTProto inbounds and avoid inbound rewrite for limitHwid Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(clients): stamp updated_at only on the clients a bulk adjust changed The updated_at write was gated on hasInboundChanges, which accumulates over the whole inbound instead of describing the client in hand. Once any client in the settings array changed, every client after it was re-stamped as well, so whether an untouched client kept its own updated_at depended on its position in the array. That field feeds node-snapshot conflict resolution, where a spurious bump lets a stale snapshot value win over the stored record. Track the change per client and fold it into the inbound-level flag where the stamp is written, so the early return still skips a save whose settings JSON would be unchanged. Also condenses the BulkAdjust doc comment back to the two-line maximum. * docs(api): regenerate the bulkAdjust reference for limitHwid and adTag frontend/public/openapi.json was copied to docs/public/, but pnpm gen:api was never re-run, so the API reference page's heading, anchor id and search index still described bulkAdjust without limitHwid or adTag. docs-ci.yml fires only on docs/**, and that path had been touched, so nothing flagged the stale MDX. The externalLinks hunks are the generator rewrapping lines main had left stale, not a content change. * fix(i18n): stop enumerating fields in the bulk-adjust empty-form message bulkAdjustNothing listed the fields the form accepts, so it went stale every time one was added: only en-US ever gained "flow", leaving the other twelve locales describing days and traffic alone, and limitHwid and adTag would have repeated that. Say that one field is required instead of naming which, so the message cannot drift again. |
||
|
|
1456658028 |
feat(sub): add Happ client integration, routing presets, and app management (#6434)
* feat(sub): add Happ client integration, routing presets, and app management Implement comprehensive Happ proxy client integration according to official developer specifications. - Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings. - Add support for 'happ://routing/off' deeplink in routing validation. - Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS. - Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings. - Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings. - Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations. * fix(sub): keep Happ header overrides behind the auto-detect opt-in The Routing-Enable/Hide-Settings off values were emitted on the User-Agent alone, so every panel that upgraded would push "Routing-Enable: 0" — documented by happ.su as disabling routing globally — to every Happ client without the operator enabling anything. They now ride subHappAutoDetect like every other Happ header. Two further mismatches against the vendor spec: - serverDescription was written as a key of the VMess base64 JSON object. happ.su documents it as a "#Title?serverDescription=<base64>" link parameter or a JSON "meta" entry, so the caption never reached Happ while every other VMess consumer received an unknown key. Dropped rather than moved: emitting the documented form is unsafe here because our own parser base64-decodes the whole VMess body (internal/util/link/outbound.go). - The TUN Mode dropdown stored the literal "default", forwarded as "Tun-Mode: default", where happ.su documents system|gvisor only. It now stores the unset value so no header is sent. TUN Type "default" is a documented value and is unchanged. Each fix carries a test that fails without it. |
||
|
|
d5ab84e8d5 |
feat(amneziawg): add AmneziaWG as an outbound protocol (#6320)
* feat(amneziawg): add AmneziaWG as an outbound protocol - AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI - Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test - Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions - Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test - Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go - Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients) - Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress) - Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts) - Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks * fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys - Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns - Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound - Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection - Trim comment blocks exceeding 2 lines across modified files - Fix Storybook test execution on environments with POSIX locale Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com> |
||
|
|
87420e3bb1 |
fix(tgbot): close stale-inbound TOCTOU and contain handler panics (#6442)
Tapping an old get_clients_for_* inline keyboard re-fetched the inbound after the keyboard lookup, discarding the error; if the row vanished between the two reads, the second GetInbound returned nil and inbound.Remark panicked. The callback handler runs on a bare goroutine with no recover(), so that panic killed the whole panel process. Fetch the inbound once in a shared chooseInboundClient helper that answers an error callback on a missing row, and pass the row down to getInboundClientsFor instead of re-reading the DB, removing the between-reads window. Route all three OnReceive handler paths through a recover() barrier so no handler panic can take down the process, and log the GetInbound failure instead of silently swallowing it. |
||
|
|
efc603f59c |
fix(settings): show the SMTP failure reason instead of a raw i18n key
classifySMTPError returned keys already carrying "pages.settings.", while the
four keys TestConnection returns directly do not, and the alert renders every
Message under that one prefix. Any classified failure therefore looked up
pages.settings.pages.settings.smtpErrorAuth, which does not exist, so the panel
printed the key instead of "Authentication failed — check username and
password". The unknown case was worse: it appended the raw error to the key, and
its own text interpolated {{ .Error }}, Go template syntax the frontend's
i18next never fills.
Return the keys unprefixed like the rest, and point the unknown case at the
panel log, which already carries the underlying error.
|
||
|
|
705b291d34 |
fix(amneziawg): stop losing an inbound and its server keys on the API path
Two saves that the panel UI never makes, but the documented REST API does. A client whose allowedIPs normalized to empty passed validation, then InstanceFromInbound skipped the peer and dropped the whole instance when it was the only one. Nothing logged it, so an enabled inbound simply never opened its socket. Refuse an enabled peer with no address, naming the client, the way the injection and collision checks already do. The server keypair was regenerated whenever a payload omitted privateKey, which invalidates every client config already distributed, and a payload carrying only privateKey left publicKey empty so rendered configs got a blank "PublicKey =". An omitted key now means unchanged: the stored pair is carried forward, a half-supplied pair has its public half derived, and generation is reserved for an inbound that has no stored keys at all. UpdateInbound loads the stored row before normalizing so those keys are available. Closes #6407 |
||
|
|
65c5580e7d |
fix(clients): keep per-peer keys when a client spans several tunnel inbounds
A client attached to several WireGuard/AmneziaWG inbounds is that many independent peers, each with its own keypair, preshared key and tunnel address. The client edit form can only represent one peer, so Update's per-inbound loop stamped that single field set onto every attached inbound: every peer ended up with identical keys and one inbound's address, and the tunnels on all the other nodes stopped working with no way to recover the overwritten values from the panel. The only guard covered AllowedIPs, and only for AmneziaWG. When more than one tunnel inbound is in scope and the caller sent no per-inbound override, clear the shared peer fields so UpdateInboundClient's existing carry-forward preserves each inbound's own. A scoped update (?inboundIds=) still narrows to one inbound and edits it normally. Closes #6372 |
||
|
|
2004340d1d |
fix(inbounds): let a node-adopted inbound keep its own protocol on edit
UpdateInbound restores the stored NodeID before the node-eligibility check, so the payload can never introduce an assignment there — the check could only ever fire on a row that already had one. A node's MTProto inbound arrives on the master by adoption, which does not go through that check, so every later edit of it was refused with "mtproto inbounds cannot be assigned to a node". That made the share address of a node-managed MTProto inbound impossible to change from the panel that generates its subscription links. Refuse only a protocol change into an ineligible protocol, which is the one way an update can still strand a row the master's sidecar loops would never reconcile. Closes #6415 |