Commit Graph

518 Commits

Author SHA1 Message Date
mrchatam
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>
2026-09-13 14:48:28 +02:00
Egor
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>
2026-09-13 14:04:53 +02:00
Pejman Yousefi
cba8f0672f feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement (#6488)
* feat(sub): refine Happ routing presets, serverDescription escaping, and auto-detect placement

* fix(sub): address PR review findings on routing parity, agent regex, and i18n
2026-09-13 12:53:57 +02:00
BlindMaster24
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.
2026-09-13 12:52:55 +02:00
NgaiYeanCoi
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>
2026-09-13 12:44:55 +02:00
Sanaei
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.
2026-09-13 12:01:56 +02:00
Sanaei
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.
2026-09-13 12:01:55 +02:00
DIMFLIX
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.
2026-09-13 11:51:56 +02:00
BlindMaster24
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.
2026-09-13 11:47:22 +02:00
BlindMaster24
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.
2026-09-13 11:46:58 +02:00
BlindMaster24
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.
2026-09-13 11:46:36 +02:00
BlindMaster24
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.
2026-09-13 11:46:14 +02:00
mrchatam
503b5df4b9 fix(link): preserve Shadowsocks TLS query params on import (#6467)
* fix(link): preserve Shadowsocks TLS query params on import

Mirror trojan/vless stream parsing so Xray-native type/security/sni/alpn/fp
query params on ss:// links survive into streamSettings on both Go and TS importers.

Fixes #6094

* fix(link): drop extra blank line so oxfmt passes

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
2026-09-12 11:52:38 +02:00
mrchatam
bdd351bd15 fix(api): return 401 for invalid Bearer token instead of 404 (#6459)
When Authorization: Bearer is present but does not match (or is disabled),
respond with 401 Unauthorized so script authors can distinguish auth failure
from a wrong webBasePath. Requests with no Authorization header still get
404 masking; wrong base paths continue to 404 via NoRoute.

Fixes #6255

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
2026-09-12 11:52:15 +02:00
mrchatam
b332d88438 feat(settings): add Block tab for JSON subscription routing rules (#6466)
* feat(settings): add Block tab for JSON subscription routing rules

Expose the existing blackhole outbound in the subscription formats UI so
operators can add block domain/IP rules without editing subJsonRules by
hand. Scope Direct/Block helpers by outboundTag so the tabs keep separate
rule objects, and keep block rules ahead of direct for Xray match order.

* fix(settings): preserve rule order and clear foreign leftovers

Stop sorting the whole subJsonRules array on every write. Prepend block
defaults only when enabling Block. Clearing the last managed tag also
drops foreign-tag leftovers so the panel can reach an empty setting.
Fixes oxfmt on SubscriptionFormatsTab.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
2026-09-12 11:14:56 +02:00
mrchatam
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>
2026-09-12 11:14:53 +02:00
mrchatam
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>
2026-09-12 11:13:45 +02:00
mrchatam
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>
2026-09-12 11:13:42 +02:00
mrchatam
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>
2026-09-12 11:13:22 +02:00
mrchatam
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>
2026-09-12 11:13:19 +02:00
mrchatam
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>
2026-09-12 11:12:13 +02:00
mrchatam
7a41c59494 fix(amneziawg): honor inbound listen when binding UDP socket (#6461)
* fix(amneziawg): honor inbound listen when binding UDP socket

AmneziaWG inbounds ignored the listen field and always opened
awgconn.NewDefaultBind(), so multi-IP hosts replied from the primary
address and handshakes to a secondary IP never completed (#6367).

Carry Inbound.Listen on amneziawg.Instance, open a Bind pinned to that
address (wildcard when empty/0.0.0.0/::), and include listen in
addressFingerprint so edits rebuild the Device.

Fixes #6367

* fix(amneziawg): fall back to wildcard when listen is unusable

Invalid or non-local listen values no longer hard-fail inbound startup;
treat ::0/[::0] as wildcards and normalize listen in the bind fingerprint.

* fix(amneziawg): use ListenConfig.ListenPacket for noctx

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
2026-09-12 11:11:02 +02:00
mrchatam
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>
2026-09-12 11:06:47 +02:00
mrchatam
f51b0040cf fix(link): map vcn to verifyPeerCertByName in applySecurity (#6479)
Go share-link importer dropped verifyPeerCertByName for VLESS/Trojan/SS
TLS while the TS parser and Hysteria2 path already kept it. Fixes #6477.

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
2026-09-12 11:04:10 +02:00
Sanaei
5815254fc3 fix(tuic): evict the oldest relay flow instead of refusing new clients
udpRelay.flowFor returned "max relay flows reached" once the table held
maxRelayFlows entries, and only the idle sweep (every minute, two-minute
cutoff) freed slots. One host sending a single datagram from each of 4096
source ports therefore locked every new TUIC client out of the inbound for
up to two minutes, repeatably. A full table now evicts the flow last seen
longest ago, which under such a flood is one of the junk flows, and the
newcomer is admitted. TestUDPRelayFullTableAdmitsNewClient fails on the
refusing code with a read timeout for the third client.
2026-09-12 10:40:41 +02:00
amae
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>
2026-09-12 10:15:48 +02:00
Lucas
0a2cd789ba fix(sub): enable ML-KEM for Mihomo REALITY subscriptions (#6451) 2026-09-11 15:34:57 +02:00
Timur Chernykh
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.
2026-09-11 15:32:04 +02:00
Namso9
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>
2026-09-11 11:59:35 +02:00
YoungReckless4
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 112b19a8 predates the break.

ToClient also wrapped a stored 0 in a pointer, so omitempty stopped
omitting: a VLESS client's settings JSON gained "keepAlive": 0 on the
attach and bulk-attach paths, and that JSON reaches xray-core verbatim
through GenXrayInboundConfig. wg_keep_alive cannot tell "off" from "never
set", so a stored 0 now stays nil.

Also copies the regenerated openapi.json over the docs mirror, which
nothing in CI checks, and trims two comment blocks to the two-line cap.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-10 22:32:42 +02:00
duqigit
64b6e43e2b feat(sub): add legacy Clash subscription endpoint (#6338)
* feat(sub): add legacy Clash subscription endpoint

* fix(deps): update js-yaml to patched release

Raise the Swagger UI js-yaml override to 4.3.2 and refresh the lockfile to resolve GHSA-2883-xcg3-v3hh without changing Swagger UI.

* fix(sub): preserve client detection and normalize legacy cipher

Keep the original Clash/Mihomo auto-detection default so existing subscription URLs continue returning YAML. Normalize the panel-supported chacha20-poly1305 alias when generating legacy Clash profiles, and cover both regressions through HTTP endpoint tests.

* refactor(sub): drop an unreachable guard and make the alias test assert

Review of the legacy Clash subscription endpoint left three LOW findings, all
introduced by the change:

- The comment above the routing merge ran to three lines, over CLAUDE.md's
  two-line cap.
- validateClashRouteGraph on the legacy path could never fail: the legacy
  branch skips the routing merge, so it validated the literal config built a
  few lines above against itself. Dead code that reads as a guard.
- TestClashAliasesSkipConfiguredPathConflicts asserted nothing — it could only
  fail on an escaping gin panic, so a regression that registered the alias
  handler on the configured path went unnoticed. It now drives each collision
  through the router and asserts which format answers each path.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-10 21:58:55 +02:00
Sanaei
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.
2026-09-10 21:08:13 +02:00
Sanaei
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.
2026-09-10 21:07:57 +02:00
Sanaei
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.
2026-09-10 21:07:49 +02:00
MRVX
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.
2026-09-10 17:53:49 +02:00
DIMFLIX
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.
2026-09-10 17:05:54 +02:00
Rouzbeh†
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.
2026-09-10 16:24:48 +02:00
Pejman Yousefi
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.
2026-09-10 15:46:12 +02:00
Rouzbeh†
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>
2026-09-10 14:50:48 +02:00
VibeProgramm
876497db6e feat(sub): add AmneziaWG proxy generation for Clash subscriptions (#6326)
* feat(sub): add AmneziaWG proxy generation for Clash subscriptions

Add buildAmneziaWGProxy to generate mihomo-compatible wireguard proxy
entries with amnezia-wg-option sub-block for AmneziaWG inbounds.

Previously, AmneziaWG inbounds were silently skipped in Clash
subscriptions (buildProxy returned nil), making them unusable with
mihomo/Clash clients.

The new function reuses the wireguard proxy base structure and adds:
- v1.0 obfuscation fields (jc/jmin/jmax/s1-s4/h1-h4/i1-i5)
- v1.5 fields (s3/s4/i1-i5)
- v3 fields (header-protection-key, content-padding-addition, timing,
  random-trailers, disable-cookies) with automatic version: 3 tagging

Closes #6310

* fix review nits: doc comment and test call

* fix(sub): use the AmneziaWG inbound's own tunnel address in Clash proxies

buildAmneziaWGProxy took the peer address from model.Client.AllowedIPs, which
matchingClients resolves out of the shared clients.wg_allowed_ips column. That
column holds one address per identity, so a client attached to both a WireGuard
and an AmneziaWG inbound gets the other protocol's address written into its
Clash proxy - an unroutable peer, since the running interface accepts only the
AllowedIPs InstanceFromInbound derives from the inbound's own settings JSON.
Read the address from settings.clients[] and fall back to the shared column.

Also emit remote-dns-resolve alongside dns: mihomo gates its whole `dns` list
on that flag (adapter/outbound/wireguard.go, NewWireGuard), so the panel's
primaryDns/secondaryDns were inert in Clash while the vpn:// .conf turned them
into a real DNS line. Restricted to bare IPs - mihomo aborts the entire config
when dns.ParseNameServer rejects an entry, and nothing validates those fields.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sub): emit the AmneziaWG effective MTU in Clash proxies

main's EffectiveMTU landed while this branch was open, so the Clash builder was
the one AmneziaWG emitter left omitting mtu when the operator set none. The
running interface uses EffectiveMTU (internal/amneziawgnet/device.go), as do the
vpn:// .conf and both TS builders; mihomo instead falls back to its own 1408,
which sits above the tunnel once s4 passes 12 and fragments every packet the
client sends. GenerateObfuscation31 draws s4 from 12..27, so that is the default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(sub): reject a zoned DNS address before opting into remote-dns-resolve

netip.ParseAddr accepts "fe80::1%eth0", but mihomo's parsePureDNSServer
brackets it into "udp://[fe80::1%eth0]" and url.Parse rejects "%et" as a bad
escape, so parseNameServer errors and parseProxies aborts the entire config -
the whole subscription's Clash profile, not just this proxy (#4641 class).
2026-09-10 14:20:18 +02:00
BlindMaster24
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.
2026-09-10 09:41:52 +02:00
Sanaei
6ee74f2032 fix(amneziawgnet): wait for the client netstack goroutines before closing its device
TestPortForwardRoundTripTCPAndUDP flakes in the race job: closing the test's
client WireGuard device races the goroutines still writing into its netstack.

amneziawg-go's device.Close() calls tun.Close() before it stops the routine
draining the tun, and netTun.Close() closes the unbuffered incomingPacket
channel that WriteNotify sends on. A goroutine still inside a netstack write
when the deferred clientDev.Close() runs therefore closes and sends on the same
channel -- reported as a data race, and on a bad interleaving a "send on closed
channel" panic.

The TCP echo listener, its per-connection copies and the UDP echo all write into
clientNet, and teardown only closed the two listeners before the device: nothing
waited for the goroutines themselves. A WaitGroup deferred right after
clientDev.Close() supplies the missing edge, since LIFO then puts the wait
between the listener closes and the device close.

Confirmed by flooding the existing UDP echo goroutine under GOMAXPROCS=1 and 2,
which failed 3/6 and 2/6 runs with the stack CI reported and 0/12 with the fix.
2026-09-09 09:37:12 +02:00
Sanaei
d0edbcec81 feat(xray): update xray-core to v26.9.9 and follow the udpHop move
Bump xtls/xray-core to 52a412d9e2f5 (v26.9.9) and the three binary pins in
DockerInit.sh and release.yml in lockstep.

Upstream moved UDP port hopping out of finalmask.quicParams.udpHop and into a
standalone "udphop" UDP mask with a different shape (mode / interval /
remotePorts / remoteIPs). The old key is gone from QuicParams, and since the
config loader ignores unknown fields it is now silently dropped rather than
rejected — port hopping just stops.

The panel adapts where that key was live:

- Both link importers rebuilt quicParams.udpHop from the standard mport param,
  so an imported hysteria2 link produced an outbound that no longer hops. They
  now emit a udphop mask in intervalremote mode, which is what the old key did.
  The mode is required: UDPHop.Build() rejects an empty or unknown one.
- validFinalMaskUDPTypes and UdpMaskTypeSchema learn "udphop", otherwise the Go
  link generator strips the mask from every link and sub, and Zod strips it on
  the next form round trip.
- mport generation (Go and frontend) reads the mask first and keeps reading the
  legacy key, so inbounds stored before the upgrade still advertise their range.

On an inbound the old key was always inert — only hysteria's dialer consumed
it — so nothing regresses server-side and no migration is needed. udphop stays
out of the mask dropdown on purpose: it is client-only in core, which refuses
to wrap a server socket, and that form is shared with the inbound editor.
2026-09-09 01:53:00 +02:00
Sanaei
cfd596a489 fix(amneziawg): let a cleared header protection key reach a running device
amneziawg-go reads an absent UAPI line as "keep the current value", and
addressFingerprint keys only on the addresses and MTU, so an obfuscation-only
edit reconfigures in place rather than rebuilding. Clearing headerProtectionKey
therefore never took effect: the device kept protecting headers with the old
key. The stale key also keeps the S1-S4 minimum in force, so lowering S3/S4 in
the same edit made every later IpcSet fail with -22 — after replace_peers had
already dropped the peers.

Send the all-zero key when the field is empty, which is how the UAPI expresses
"disabled"; an empty value would be rejected, since it decodes to zero bytes.
2026-09-09 00:57:24 +02:00
Sanaei
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.
2026-09-09 00:51:44 +02:00
Sanaei
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
2026-09-09 00:42:52 +02:00
Sanaei
4e423fa452 fix(sub): drop Reality parameters when a host forces plain TLS
A Host row may set Security to tls on an inbound whose own stream is Reality.
The emitted link then carried security=tls next to pbk, sid, spx and the
Reality dest as sni: the endpoint no longer performs a Reality handshake, so
those describe a server the client will never reach, and clients that honour
them fail to connect. Only the security key was rewritten at emit time, and the
existing strip covered alpn/sni/fp/pcs for forceTls=none alone.

Clear the Reality-only parameters before the endpoint's own TLS overrides are
applied, so a host that supplies its own sni or fingerprint still wins.

Closes #6424
2026-09-09 00:37:31 +02:00
Sanaei
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
2026-09-09 00:30:02 +02:00
Sanaei
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
2026-09-09 00:24:01 +02:00
Sanaei
bc57548a35 fix(clients): withdraw the delete tombstone when the email is re-created
Deleting a client tombstones its email for 90s so a node snapshot captured
before the deletion cannot resurrect it. Nothing withdrew that tombstone when
the operator re-created the same email, so on a master with at least one node
the next merge filtered the live client out of the snapshot and SyncInbound
pruned its inbound link. The client reappeared only once the tombstone expired,
which is the 90-120s detach window reported.

Withdraw it on a successful create, single and bulk, so a tombstone can never
outlive the identity it was meant to bury. A failed create still leaves it
standing, which is what keeps the stale-snapshot guard intact.

Closes #6370
2026-09-09 00:17:54 +02:00