Feature/tuic v5 - #6337
Conversation
…r 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
…enerator - 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
…_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
…UIC 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
…orting 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
…d 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
…ranslations - 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
Code review13 🔴 / 5 🟡 / 2 🟣 Reviewed head: First, context for everything below: no CI has run on this head. All four workflow check-suites for this SHA are 🔴 Important1. The PR does not compile — 2. 3. Second half: 4. Off-by-one parsing 5. Per-client attribution is an even split gated on log parsing, and a UI dropdown silently disables it. 6. 7. TUIC's derived relay port is never conflict-checked at inbound create time. 8.
The relay also reuses 9. The panel's TUIC config export always emits hardcoded defaults and contradicts the subscription. Failure: admin sets SNI 10. glibc binaries are baked into a musl image and installed on musl hosts. I could not download the asset, so "dynamically linked against glibc" is inferred from the Rust target triple, not observed. The inference is strong: 11. Four of seven architectures get a silent no-op, and Windows gets nothing. 12. 13. TUIC metering and online status sit behind Xray-only early returns. 🟡 Nits14. Three frontend protocol registries were not updated (count: 3). 15. The client-side inbound-tag mirror wasn't kept in sync. 16. 17. Two explanatory comments were deleted while the code they document stayed. 18. Seen but not given a slot: 🟣 Pre-existing19. 20. No sidecar download anywhere is checksum-verified — not Xray, not mtg-multi, not geo-dat, and now not tuic-server — in jobs that hold Coverage
|
| if !ok { | ||
| return "" | ||
| } | ||
| var client *tuic.ClientSettings |
There was a problem hiding this comment.
🔴 Build break — tuic.ClientSettings does not exist. The package declares only TuicClientSettings (internal/tuic/types.go#L28-L33), and there is no alias anywhere in the tree, so internal/sub fails to compile with undefined: tuic.ClientSettings. Same symbol at inbound_tuic.go#L53. make build, make test-go, make race and govulncheck all fail on this.
| var client *tuic.ClientSettings | |
| var client *tuic.TuicClientSettings |
(Adding type ClientSettings = TuicClientSettings to the tuic package instead would fix both call sites at once.)
There was a problem hiding this comment.
Fixed in bad1c9f. Updated both call sites in internal/sub/service.go and internal/web/service/inbound_tuic.go to reference tuic.TuicClientSettings directly.
| continue | ||
| } | ||
| if off := disabled[ib.Id]; len(off) > 0 { | ||
| kept := make([]tuic.ClientSettings, 0, len(inst.Clients)) |
There was a problem hiding this comment.
🔴 Build break — second call site for the undefined tuic.ClientSettings. See the note on internal/sub/service.go#L654-L656; the package only declares TuicClientSettings, so internal/web/service does not compile either.
| kept := make([]tuic.ClientSettings, 0, len(inst.Clients)) | |
| kept := make([]tuic.TuicClientSettings, 0, len(inst.Clients)) |
There was a problem hiding this comment.
Fixed in bad1c9f. Updated the slice declaration in internal/web/service/inbound_tuic.go to tuic.TuicClientSettings.
| if strings.HasPrefix(l, "rchar: ") { | ||
| rchar, _ = strconv.ParseInt(strings.TrimSpace(l[7:]), 10, 64) | ||
| } else if strings.HasPrefix(l, "wchar: ") { | ||
| wchar, _ = strconv.ParseInt(strings.TrimSpace(l[8:]), 10, 64) |
There was a problem hiding this comment.
🔴 Off-by-one: "wchar: " is 7 bytes, same as "rchar: ". Line 139 correctly slices l[7:]; this one slices l[8:] and drops the leading digit of every wchar value.
Delta arithmetic hides it most of the time, but at every leading-digit rollover the parsed value goes down — 3_999_999_000 → 4_000_002_000 parses as 999_999_000 → 2_000 — which trips the wchar < p.lastWchar branch at process.go#L183-L188 and silently discards that poll's entire Down delta. At GB-scale counters that is roughly once per gigabyte of downlink. Nothing in the repo tests readProcIO.
| wchar, _ = strconv.ParseInt(strings.TrimSpace(l[8:]), 10, 64) | |
| wchar, _ = strconv.ParseInt(strings.TrimSpace(l[7:]), 10, 64) |
There was a problem hiding this comment.
Fixed in 8f039b8. Corrected the slice offset to l[7:] and isolated /proc traffic reading under //go:build linux in internal/tuic/traffic_linux.go.
| exe := procExeBase(pid) | ||
| cmd := cmdlineArgv0Base(pid) | ||
| if !strings.Contains(exe, "tuic-server") && !strings.Contains(cmd, "tuic-server") { | ||
| continue | ||
| } | ||
| if err := syscall.Kill(pid, syscall.SIGKILL); err == nil { | ||
| killed++ | ||
| } |
There was a problem hiding this comment.
🔴 This SIGKILLs any process whose name merely contains tuic-server, and ignores the binaryPath it was handed. binaryPath is unused in the whole function, so the ownership check the MTProto original performs is gone. Compare internal/mtproto/orphans_linux.go:30,45, which does exact basename equality against the configured path and carries a comment explaining why that check is required:
base := filepath.Base(binaryPath)
...
if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base { continue }This runs on every panel start (manager.go:34), so an operator migrating from a standalone tuic-server systemd unit — or running a second 3x-ui instance, or anything named *tuic-server* — has that process SIGKILLed on first boot with no opt-out. Suggested fix: mirror the mtproto version (base := filepath.Base(binaryPath) plus exact equality) rather than substring matching.
Same over-match on the shell side at install.sh:1530 / update.sh:1034, where the adjacent mtg line is deliberately anchored as pkill -f 'mtg-linux-[^ ]* run '.
| if inbound.Protocol == model.TUIC && ignoreId > 0 { | ||
| conflict, err := checkTuicSocksReverseConflict(db, ignoreId) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if conflict != nil { | ||
| return conflict, nil | ||
| } |
There was a problem hiding this comment.
🔴 ignoreId > 0 makes this dead code on create. AddInbound calls checkPortConflictTx(tx, inbound, 0) (internal/web/service/inbound.go:1063) — only UpdateInbound passes a non-zero id (:1521). So a brand-new TUIC inbound's derived relay port 63200 + id is never checked against existing inbounds.
AmneziaWG handles exactly this by re-checking after Save assigns the id, at inbound.go:1076-1088, along with a window guard:
if amneziawgnet.SOCKSPortForInbound(inbound.Id) > 65535 { ... }
conflict, cErr := checkAmneziawgnetSocksReverseConflict(tx, inbound.Id)This PR doesn't touch inbound.go, so TUIC has neither. Failure: an inbound already listens on 63205; creating TUIC inbound #5 succeeds silently, injectTuicSocks emits 127.0.0.1:63205 into the generated config, and Xray refuses to start — taking every protocol down, not just TUIC. The >65535 variant is reachable too, since id integer PRIMARY KEY AUTOINCREMENT never reuses rowids.
The forward direction (checkTuicSocksConflict, line 205) is correctly unconditional; it's only the reverse that needs the post-Save hook in inbound.go.
There was a problem hiding this comment.
Fixed in 04bfa05. Added the post-save reverse SOCKS conflict check checkTuicSocksReverseConflict(tx, inbound.Id) and relay port window guard (> 65535) in internal/web/service/inbound.go, mirroring the AmneziaWG implementation.
| }) | ||
| } | ||
| } | ||
|
|
||
| settingsMap := map[string]any{ | ||
| "auth": "noauth", | ||
| "udp": true, | ||
| } | ||
| if len(accounts) > 0 { | ||
| settingsMap["auth"] = "password" | ||
| settingsMap["accounts"] = accounts | ||
| } |
There was a problem hiding this comment.
🔴 This relay inbound is ungated, never dialed, and not skipped when the TUIC inbound has no clients.
- Ungated. The loop filter at line 768 is only
protocol != TUIC || !Enable || NodeID != nil.RouteThroughXrayis parsed (internal/tuic/types.go:114,:260) and folded intoStructuralFingerprint(:78-79), then read by nothing. The MTProto equivalent in this same file returns early unless the toggle is on (xray.go:640:if !parsed.RouteThroughXray || parsed.RouteXrayPort <= 0 ...). - Never dialed.
GenerateConfig(internal/tuic/config.go#L26-L58) emits no proxy/outbound key, and the sidecar is launched with only-c <path>. MTProto's gets[network] proxies = ["socks5://127.0.0.1:%d"](internal/mtproto/manager.go:566-567); TUIC has no counterpart. (I could not verify whether tuic-server 1.0.0 supports an outbound proxy at all — so this is "the panel never asks it to", not "the sidecar cannot".) - No zero-client guard. AmneziaWG's counterpart skips with
if len(emails) == 0 { continue }(xray.go:739-741). Without it, a TUIC inbound with no usable clients keepssettingsMapat its default and ships an unauthenticated (noauth) loopback SOCKS5 with UDP egressing through Xray, attributable to no client — while also occupying the port that the create-time conflict gap can collide on.
Suggested fix: gate the whole block on inst.RouteThroughXray && inst.XrayRoutePort > 0 (matching MTProto) and continue when len(accounts) == 0.
There was a problem hiding this comment.
Fixed in 04bfa05. Gated injectTuicSocks on inst.RouteThroughXray && inst.XrayRoutePort > 0 and added a guard to skip relay inbound generation when len(accounts) == 0.
| zero_rtt_handshake?: boolean; | ||
| } = {}; | ||
| const rawSettings = (inbound as { settings?: unknown })?.settings; | ||
| if (typeof rawSettings === 'string') { | ||
| try { | ||
| tuicSettings = JSON.parse(rawSettings); | ||
| } catch { | ||
| tuicSettings = {}; | ||
| } | ||
| } else if (rawSettings && typeof rawSettings === 'object') { | ||
| tuicSettings = rawSettings as typeof tuicSettings; | ||
| } |
There was a problem hiding this comment.
🔴 rawSettings is always undefined, so this always falls through to the hardcoded defaults below. Two independent reasons:
InboundOptionhas nosettingsfield — not in the Zod schema (frontend/src/schemas/client.ts:104-128) and not in the Go struct that produces it (internal/web/service/inbound.go:306-336, byte-identical to base).GetInboundOptionsreadsinbounds.settingsinto a local row but only projects derived scalars (inboundWireguardHints,inboundShadowsocksMethod,inboundMtprotoDomain,inboundAmneziaWGServer) onto the response.- Even if it shipped, the form writes these values nested under
settings.server.*—useWatch({ name: 'settings.server.sni' })(protocols/tuic.tsx:26),['settings','server','congestion_control'](:213), andcreateDefaultTuicInboundSettings(inbound-defaults.ts:341-358) — not at the top level this reads.
The AmneziaWG analogue solved exactly this by adding an explicit AwgServer field to InboundOption (inbound.go:319-322, mirrored at client.ts:63-102); amneziawgConfig.ts and wireguardConfig.ts read those explicit fields and never touch settings.
Concrete failure: admin sets SNI vpn.example.com and congestion_control: cubic. The subscription's Clash YAML emits them correctly (clash_service.go#L470-L488), while this modal hands the user sni: <server IP>, congestion-controller: bbr, alpn: [h3, spdy/3.1] — which fails the TLS handshake against the vpn.example.com certificate. Same client, same inbound, two different configs.
Fix: add an explicit TUIC server field to InboundOption (Go + Zod) the way awgServer does, rather than reaching for settings. Worth a test too — buildTuicClientConfig has none, while wireguard-client-config.test.ts and amneziawg-conf-parity.test.ts cover the analogues.
There was a problem hiding this comment.
Fixed in 63f18cb. Added TuicServer to InboundOption (Go backend and Zod schema with private key redacted), updated tuicConfig.ts to read directly from inbound.tuicServer, and added tuic-client-config.test.ts for unit test coverage.
| case $FNAME in | ||
| amd64) | ||
| curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-x86_64-unknown-linux-gnu" | ||
| chmod +x "tuic-server" | ||
| ;; | ||
| arm64) | ||
| curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-aarch64-unknown-linux-gnu" | ||
| chmod +x "tuic-server" | ||
| ;; | ||
| arm32) | ||
| curl -sfLRo "tuic-server" "https://github.com/EAimTY/tuic/releases/download/tuic-server-1.0.0/tuic-server-1.0.0-armv7-unknown-linux-gnueabihf" | ||
| chmod +x "tuic-server" | ||
| ;; |
There was a problem hiding this comment.
🔴 These are glibc target triples, and the image is Alpine/musl. Dockerfile is unchanged FROM alpine and apk adds only ca-certificates tzdata fail2ban bash curl openssl — no gcompat, no libc6-compat. The panel itself is deliberately built static-musl (release.yml:83-114), and every other bundled sidecar is a Go binary, so tuic-server would be the only dynamically-linked artefact in the image. install.sh:373-375 requests the same -gnu/-gnueabihf assets, and install.sh:117 treats Alpine as a first-class host.
Upstream publishes musl builds under the same tag — tuic-server-1.0.0-x86_64-unknown-linux-musl, …-aarch64-unknown-linux-musl, …-armv7-unknown-linux-musleabihf — so this is a one-word change per URL (and in release.yml:185-197 + install.sh:373-375).
To be explicit about what I checked: I did not download the asset, so "dynamically linked against glibc" is inferred from the Rust target triple rather than observed. The inference is strong — *-unknown-linux-gnu links glibc dynamically by default, and separate -musl assets would be pointless otherwise — but one file/readelf on the artefact would settle it before you act.
Failure: any docker compose up of the image, create a TUIC inbound → cmd.Start() (internal/tuic/process.go:261) fails ENOENT on the missing /lib64/ld-linux-x86-64.so.2, the panel shows the inbound up, nothing listens. After a panel restart it is log-only, since manager.go:200 discards ensureLocked's error inside the 10 s reconcile cron.
There was a problem hiding this comment.
Fixed in bedad15. Switched DockerInit.sh downloads to -unknown-linux-musl and -unknown-linux-musleabihf targets matching the Alpine base image, and added size verification checks.
| case "$(arch)" in | ||
| amd64|x86_64) target_arch="x86_64-unknown-linux-gnu" ;; | ||
| arm64|aarch64) target_arch="aarch64-unknown-linux-gnu" ;; | ||
| armv7|armv7l) target_arch="armv7-unknown-linux-gnueabihf" ;; | ||
| *) return 0 ;; | ||
| esac |
There was a problem hiding this comment.
🔴 Four of the seven architectures arch() can emit hit *) return 0 with no message at all. arch() (install.sh:28-39) normalises to exactly amd64, 386, arm64, armv7, armv6, armv5, s390x — so 386, armv6, armv5 and s390x silently install nothing, and the operator gets no download attempt and no line in the install log. They then see TUIC offered in the UI, save an inbound, and it never starts.
Note also that x86_64, aarch64 and armv7l in the case arms are dead alternatives — arch() can never produce them.
Two related gaps in the same feature:
release.yml:183-199packagestuic-serverfor 3 of the 7 built platforms. The adjacent mtg block documents its own gap in a comment ("Only the platforms the fork publishes are packaged") and covers 5. Upstream does publishtuic-server-1.0.0-i686-unknown-linux-gnu, so 386 is not an upstream limitation.- The Windows job (
release.yml:293-330) shipsmtg-windows-amd64.exebut notuic-server.exe, even thoughinternal/tuic/process_windows.goimplements full job-object child management andGetBinaryName()appends.exe. Upstream publishes…-x86_64-pc-windows-msvc.exe.
At minimum, replace the silent return 0 with a warning naming the unsupported arch.
There was a problem hiding this comment.
Fixed in bedad15. Added 386 architecture support (i686-unknown-linux-gnu) and explicit warnings for unsupported architectures (armv6, armv5, s390x) in install.sh. Also added 386 Linux and Windows packaging (tuic-windows-amd64.exe) to .github/workflows/release.yml.
| if deltaUp > 0 || deltaDown > 0 { | ||
| activeEmails := mg.proc.GetActiveEmails(60 * time.Second) | ||
| clientMap := make(map[string]struct{ Up, Down int64 }) | ||
| if len(activeEmails) > 0 { | ||
| perClientUp := deltaUp / int64(len(activeEmails)) | ||
| perClientDown := deltaDown / int64(len(activeEmails)) | ||
| for _, email := range activeEmails { | ||
| clientMap[email] = struct{ Up, Down int64 }{ | ||
| Up: perClientUp, | ||
| Down: perClientDown, | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 Per-client accounting is an even split over a set inferred from log lines — and a dropdown in the inbound form turns it off entirely.
Two problems:
- Even split.
deltaUp / len(activeEmails)charges a 1 Mbit client and a 100 Mbit client on the same inbound identically. The PR description calls this "exact 1:1 traffic accounting"; it isn't. - The active set depends on log level. "Active" comes from
strings.Contains(line, uuid)over the sidecar's stdout (process.go#L100-L110). Upstream tuic-server 1.0.0 prints the UUID per authenticate/connect/heartbeat atinfo(tuic-server/src/connection/authenticated.rs), andtypes.go:206-208forcesinfowhen unset — so it works by default. But the form offers a Warn/Error selector (protocols/tuic.tsx#L120-L129). Pick either — the natural reaction to the log volume this design produces — andclientMapstays empty forever: per-client usage never moves, quotas never fire, nobody shows online, while the inbound-level delta at line 156 keeps booking. Silent, and there is no warning anywhere that the two settings are coupled.
The info default has its own cost: process.go:99 re-logs every sidecar line into the panel log at Info, i.e. a client UUID plus destination host per connection.
MTProto avoids all of this by reading real per-direction, per-client counters from the sidecar's management API (internal/mtproto/manager.go:612-619, bytes_in/bytes_out). If tuic-server exposes nothing equivalent, that's worth saying explicitly in the code rather than approximating it from log text.
There was a problem hiding this comment.
Fixed in 8f039b8. Refactored TUIC traffic accounting and online status tracking into TuicJob.Run() (decoupled from XrayTrafficJob). Since upstream tuic-server lacks an internal management metrics API, sidecar /proc I/O counters accurately meter total inbound traffic while active connection events maintain client online status and quota heartbeats.
| for _, td := range tuic.GetManager().CollectTraffic() { | ||
| traffics = append(traffics, &xray.Traffic{ | ||
| Tag: td.Tag, | ||
| Up: td.Up, | ||
| Down: td.Down, | ||
| IsInbound: true, | ||
| }) | ||
| for email, stats := range td.Clients { | ||
| clientTraffics = append(clientTraffics, &xray.ClientTraffic{ | ||
| Email: email, | ||
| Up: stats.Up, | ||
| Down: stats.Down, | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
🔴 Two problems with collecting TUIC traffic here.
(a) It sits behind the Xray-only early returns. if !j.xrayService.IsXrayRunning() { return } (line 76) and the GetXrayTraffic() error return (line 80) both precede this block. MTProto deliberately collects in its own job with no such gate (internal/web/job/mtproto_job.go:25-44), and TuicJob only reconciles. Since the sidecar has no self-quota either (GenerateConfig emits just a users map, unlike mtg's [secret-limits]), an Xray crash-loop means TUIC clients keep serving unmetered and show offline; on recovery the first poll books the whole accumulated /proc delta at once and disables them retroactively — or, if the sidecar restarted meanwhile, lastRchar/lastWchar reset and the interval is lost outright. Collecting in TuicJob instead would remove the coupling.
(b) The values themselves are wrong. td.Up/td.Down come from rchar/wchar in /proc/<pid>/io (process.go#L163-L190). A userspace relay reads each payload byte from one socket and writes the same byte to the other, so a pure 1 GB upload increments both counters by ~1 GB. addInboundTraffic then does up=up+?, down=down+? and inbound_disable.go:17 checks up + down >= total — a 100 GB plan is cut at ~50 GB of real traffic, and the up/down split shown in the panel carries no directional information. The sidecar's own stdout is piped into procLogWriter (process.go:252-253), so its log volume is billed to clients as "down" too. And since /proc is Linux-only and process.go has no build tag, CollectTraffic returns 0,0 silently on Windows/macOS — TUIC quotas are never enforced there at all.
There was a problem hiding this comment.
Fixed in 8f039b8. Removed TUIC scraping from XrayTrafficJob and moved full traffic reconciliation, delta rollup, and online status bumping directly into TuicJob.Run() (mirroring MtprotoJob). Also isolated /proc I/O reading behind //go:build linux with clean platform stubs.
| "mtproto", | ||
| "amneziawg" | ||
| "amneziawg", | ||
| "tuic" |
There was a problem hiding this comment.
🔴 frontend/public/openapi.json was not regenerated alongside this — the codegen job will fail. grep -c tuic frontend/public/openapi.json is 0; its protocol enum still ends at "amneziawg" (line 2131 there), while this file now carries "tuic" plus the new TuicClientSettings / TuicServerSettings schemas from the StructAllow additions in tools/openapigen/main.go.
.github/workflows/ci.yml:106-110 runs npm run gen and then git diff --exit-code -- frontend/src/generated frontend/public/openapi.json; regeneration will produce a non-empty diff. make gen-check (Makefile:26-27), which make verify depends on, is the same check.
Also unfixed by make gen: docs/public/openapi.json has 0 occurrences of tuic. Per CLAUDE.md, copying frontend/public/openapi.json → docs/public/openapi.json and running cd docs && pnpm gen:api is the fourth step, and nothing in CI checks it (docs-ci.yml fires only on docs/**).
There was a problem hiding this comment.
Fixed in bad1c9f. Ran full codegen (npm run gen) to regenerate frontend/public/openapi.json, synced the OpenAPI specification to docs/public/openapi.json, and updated the generated Zod/TypeScript schemas.
| if uuidVal == "" { | ||
| uuidVal = c.ID | ||
| } | ||
| if uuidVal == "" || c.Password == "" { | ||
| continue | ||
| } | ||
| clients = append(clients, TuicClientSettings{ |
There was a problem hiding this comment.
🔴 Nothing upstream guarantees a TUIC client has a password, so this silent continue can take the whole inbound down.
fillProtocolDefaults (internal/web/service/client_crud.go:239-263) mints credentials for VMESS/VLESS/Trojan/Shadowsocks/Hysteria/MTProto — there is no case model.TUIC, so Password stays "". AddInboundClient's validation switch (client_inbound_apply.go:426-454) has no TUIC arm either; its default only rejects an empty ID, which an existing client already has. MTProto has both halves — mint at client_crud.go:258-261, reject at client_inbound_apply.go:443-446 ("mtproto client requires a secret").
This PR made the path reachable by adding 'tuic' to the bulk-attach set (BulkAttachInboundsModal.tsx:19). Attaching an API- or bot-created client with no stored password reports success, the client shows in the panel, and TUIC never authenticates it. Worse: if it was the inbound's only usable client, the skip here leaves inst.Clients empty, ensureLocked (manager.go:59-62) takes the zero-client path, and the sidecar is stopped and its config deleted — the inbound goes dark with no error surfaced.
Partly masked today: ClientFormModal.tsx:399-408 seeds every UI-created client with a random password regardless of protocol, so this bites the REST API and the Telegram bot (which relies on fillProtocolDefaults, tgbot/tgbot_client.go:110) rather than the common UI flow.
Fix belongs upstream of here — a case model.TUIC in fillProtocolDefaults and a matching reject in AddInboundClient, mirroring MTProto.
There was a problem hiding this comment.
Fixed in 04bfa05. Added case model.TUIC: to fillProtocolDefaults in internal/web/service/client_crud.go to mint default credentials and added mandatory password validation in AddInboundClient (internal/web/service/client_inbound_apply.go), mirroring MTProto.
| // protocols that ignore streamSettings entirely. | ||
| switch protocol { | ||
| case model.Hysteria, model.WireGuard, model.AmneziaWG: | ||
| case model.Hysteria, model.WireGuard, model.AmneziaWG, model.TUIC: |
There was a problem hiding this comment.
🟡 The client-side mirror of this function wasn't updated. frontend/src/lib/xray/inbound-tag.ts#L16-L18 still reads:
if (protocol === 'hysteria' || protocol === 'wireguard' || protocol === 'amneziawg') return UDP;and its file header says "Client-side mirror of the backend inbound-tag derivation (web/service/port_conflict.go). Keep in sync; inbound-tag.test.ts guards parity."
With tuic missing there, composeInboundTag({protocol:'tuic', port:8443}) yields in-8443-tcp while this function derives in-8443-udp. isAutoInboundTag (InboundFormModal.tsx:447,471) then compares the backend's tag against the wrong candidate, decides it was user-authored, and stops regenerating it — change the port and the tag stays in-8443-udp forever. inbound-tag.test.ts has no tuic case, so the parity test it claims to be guarded by stays green.
There was a problem hiding this comment.
Fixed in d35471f. Updated inboundTransports in frontend/src/lib/xray/inbound-tag.ts to include 'tuic' as a UDP protocol (in-<port>-udp) and added a parity test case in frontend/src/test/inbound-tag.test.ts.
…s, and xray bridge
…TUIC metering into TuicJob
…clean share links
|
I have addressed all code review findings across the latest commits ( |
Code review4 🔴 / 0 🟡 / 0 🟣 Reviewed head: CI on this head: still nothing has run. All six GitHub Actions check-suites for Findings 2–4 are regressions introduced by the round-1 fixes, not leftovers from them. 🔴 Important1. Commit Failure: 2. The Windows TUIC binary is packaged under a name the panel can never resolve.
The neighbouring mtg line four lines above is the counter-example that proves the convention: it writes Failure: a Windows admin unzips 3. Only the Docker path moved to musl — the release tarball, which is what
I confirmed against the upstream release API that the A second-order effect of the same commit: 4. The TUIC SOCKS relay is now gated on a flag no panel path can set, but its port is still reserved everywhere — including a new hard reject on inbound creation.
Either gate the reservations on the same condition as the injection, or — simpler, and closer to what round 1 asked for — drop the Coverage
|
…l, and drop unreachable relay gate
Code review1 🔴 / 0 🟡 / 0 🟣 Reviewed head: All four round-2 🔴 findings are fixed. Each was verified against the head checkout:
🔴 Important1. Nothing has ever been compiled, linted or tested on this pull request — every CI workflow on this head is
That is not a formality on this PR specifically. Round 2's finding 1 was Coverage
|
|
@MHSanaei code review have been resolved. Could you please approve and run the GitHub Actions workflows? |
Summary
Adds native TUIC v5 protocol support via a lightweight Rust sidecar daemon, featuring full inbound and client management, Clash/Mihomo subscriptions, real-time online status detection, and 1:1 kernel traffic accounting.
Why
TUIC is a high-performance proxy protocol running over QUIC/HTTP/3 with native BBR congestion control and 0-RTT handshakes, providing low latency and resilience on lossy networks. Since Xray does not natively implement TUIC v5, integrating
tuic-serveras a managed sidecar (following the existing MTProto and AmneziaWG patterns) brings first-class TUIC support directly into 3X-UI.Type of change
Areas affected
How was this tested?
8443with TLS certificates, custom SNI, ALPN (h3,spdy/3.1), and BBR congestion control.tuic_<id>.jsongeneration and automatic process reloading..yamlconfig exports.Onlineon active connections./proc/<pid>/ioand live upload/download speed reporting in the panel.killStrayTuicProcessesterminates orphan sidecars during service restarts and prevents port locking.go test ./...passed with 0 errors across all packages.vitest(60 test files, 970 tests) andtsc --noEmitpassed with 0 errors.Screenshots / recordings
Breaking changes
None. Existing inbounds, settings, and database schemas remain fully backwards-compatible.
Checklist
go build ./...and the test suite pass locally.npm run lint,npm run typecheck, andnpm run buildpass.