Skip to content

Feature/tuic v5 - #6337

Open
momachka2 wants to merge 19 commits into
MHSanaei:mainfrom
momachka2:feature/tuicV5
Open

Feature/tuic v5#6337
momachka2 wants to merge 19 commits into
MHSanaei:mainfrom
momachka2:feature/tuicV5

Conversation

@momachka2

Copy link
Copy Markdown

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-server as a managed sidecar (following the existing MTProto and AmneziaWG patterns) brings first-class TUIC support directly into 3X-UI.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Documentation
  • Tests only
  • Build / CI / tooling
  • Other

Areas affected

  • Frontend (UI / panel pages)
  • Backend (API endpoints, login, settings)
  • Xray config generation
  • Subscription (share links / Clash / JSON)
  • Statistics / traffic counters
  • Database / migrations
  • Install / upgrade script
  • Docker image
  • Multi-node (sub-nodes)
  • Telegram bot

How was this tested?

  1. Inbound & Client Lifecycle:
    • Created a TUIC inbound on port 8443 with TLS certificates, custom SNI, ALPN (h3, spdy/3.1), and BBR congestion control.
    • Added, edited, and deleted clients; confirmed tuic_<id>.json generation and automatic process reloading.
  2. Client Connectivity & Performance:
    • Connected via Clash Verge / Mihomo using generated subscription links and .yaml config exports.
    • Tested Discord voice streaming and 4K YouTube playback; confirmed high throughput (75+ Mbps) with low latency.
  3. Live Online Status & Speed Meter:
    • Verified real-time client status badge switches to Online on active connections.
    • Verified exact 1:1 traffic accounting from /proc/<pid>/io and live upload/download speed reporting in the panel.
  4. Daemon & Process Isolation:
    • Verified Xray core starts cleanly without protocol conflicts.
    • Verified killStrayTuicProcesses terminates orphan sidecars during service restarts and prevents port locking.
  5. Automated Test Suites:
    • Backend: go test ./... passed with 0 errors across all packages.
    • Frontend: vitest (60 test files, 970 tests) and tsc --noEmit passed with 0 errors.

Screenshots / recordings

image image image image

Breaking changes

None. Existing inbounds, settings, and database schemas remain fully backwards-compatible.

Checklist

  • I tested the change locally and confirmed the described behavior.
  • I added or updated tests for the new behavior (when applicable).
  • go build ./... and the test suite pass locally.
  • For frontend changes: npm run lint, npm run typecheck, and npm run build pass.
  • I updated the Wiki / README / API docs if user-facing behavior changed.
  • My commits follow the project's existing message style.
  • I have no unrelated changes mixed into this PR.

poise52 added 12 commits August 26, 2026 19:15
…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
@github-actions

Copy link
Copy Markdown
Contributor

Code review

13 🔴 / 5 🟡 / 2 🟣

Reviewed head: 7180bafc2e5021ff38a925c86bbff3c8141fa040 (67 files, +2718/−49).

First, context for everything below: no CI has run on this head. All four workflow check-suites for this SHA are action_required (fork PR awaiting maintainer approval), so go-test, race, codegen, frontend, vulncheck and postgres-durable-first have never executed against this code. Findings 1 and 2 are both things those jobs would have caught — worth weighing against the checklist's "go build ./... and the test suite pass locally".


🔴 Important

1. The PR does not compile — tuic.ClientSettings is undefined. The package declares only TuicClientSettings (types.go#L28-L33), but two packages reference a ClientSettings that exists nowhere in the tree: inbound_tuic.go#L52-L54 and sub/service.go#L654-L656. undefined: tuic.ClientSettings in internal/web/service and internal/submake build, make test-go, make race and govulncheck all fail. Add type ClientSettings = TuicClientSettings, or rename the two call sites.

2. frontend/public/openapi.json was never regenerated → the codegen job fails. generated/schemas.ts#L2104-L2108 now ends the protocol enum with "tuic" and gained TuicClientSettings/TuicServerSettings from the new StructAllow entries, but grep -c tuic frontend/public/openapi.json is 0 — its enum still stops at "amneziawg". ci.yml:106-110 runs npm run gen then git diff --exit-code over exactly those two paths (make gen-check, Makefile:26-27, is the same check). docs/public/openapi.json also has 0 occurrences of tuic; per CLAUDE.md that copy plus cd docs && pnpm gen:api is the fourth step nothing in CI checks.

3. /proc/<pid>/io is not a traffic counter — every relayed byte is booked on both directions. process.go#L163-L190 maps rchar→Up and wchar→Down, and those deltas go straight into AddTraffic (xray_traffic_job.go#L83-L97) → up=up+?, down=down+?, checked against up + down >= total. 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 deltaUp and deltaDown by ~1 GB: up+down ≈ 2× real usage, a 100 GB plan is cut at ~50 GB, and the up/down split the panel shows carries no directional information. The sidecar's own stdout is piped through procLogWriter (process.go:252-253), so its log volume is billed to clients as "down" as well. MTProto by contrast gets real per-direction, per-client counters from its sidecar API (internal/mtproto/manager.go:612-619).

Second half: process.go carries no build tag, so on Windows/macOS the read simply fails and CollectTraffic returns 0,0 silently with no log — TUIC quotas are never enforced there at all, even though process_windows.go and the .exe branch of GetBinaryName exist.

4. Off-by-one parsing wchar. process.go#L137-L142: rchar uses l[7:] but wchar uses l[8:], and both prefixes are 7 bytes — the leading digit of every wchar value is dropped. Delta arithmetic absorbs most of it, but every leading-digit rollover (3_999_999_000 → 4_000_002_000 parses as 999_999_000 → 2_000) looks like a decrease and hits the discard branch at :183-188, silently losing that poll's entire Down delta — roughly once per GB. One-character fix; nothing in the repo tests readProcIO.

5. Per-client attribution is an even split gated on log parsing, and a UI dropdown silently disables it. manager.go#L143-L155 divides the process-wide delta evenly across "active" emails — a 1 Mbit and a 100 Mbit client on the same inbound bill identically. "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 at info (tuic-server/src/connection/authenticated.rs) and the panel forces info when unset (types.go:206-208), so it works out of the box — but the inbound form offers a Warn/Error selector (tuic.tsx#L120-L129). Choose either and clientMap is empty forever: per-client usage stays 0, quotas never fire and nobody shows online, while the inbound total keeps climbing. The info default costs the reverse — process.go:99 re-logs every sidecar line into the panel log, i.e. a UUID plus destination host per connection.

6. killStrayTuicProcesses SIGKILLs anything whose name merely contains tuic-server. orphans_linux.go#L25-L33 ignores its own binaryPath argument and substring-matches. The MTProto original it was copied from does exact basename equality against the configured path (internal/mtproto/orphans_linux.go:30,45, with a comment explaining why that ownership check is required). It runs on every panel start (manager.go:34), so an operator migrating from a standalone tuic-server unit, or running a second panel, loses that process on first boot.

7. TUIC's derived relay port is never conflict-checked at inbound create time. port_conflict.go#L225-L232 gates the reverse check on ignoreId > 0, but AddInbound passes 0 (inbound.go:1063), so on create it is dead code. AmneziaWG compensates after Save assigns the id — the reverse re-check and a SOCKSPortForInbound(id) > 65535 window guard, at inbound.go:1076-1088 — and this PR does not touch inbound.go (grep -in tuic there returns nothing). Failure: something already listens on 63205, TUIC inbound #5 is created silently, injectTuicSocks emits 127.0.0.1:63205, and Xray refuses to start — every protocol goes down, not just TUIC. The forward direction is checked; only the reverse, and only at create.

8. injectTuicSocks opens a listener nothing ever dials, ungated and unskipped. xray.go#L789-L797 — three problems compounding:

  • Not gated on RouteThroughXray. The setting is parsed (types.go:114, :260) and folded into StructuralFingerprint (:78-79), then read by nothing. The MTProto equivalent in the same file returns early unless parsed.RouteThroughXray && parsed.RouteXrayPort > 0 (xray.go:640).
  • Nothing dials it: GenerateConfig (config.go:26-58) emits no proxy/outbound key and the binary is launched with only -c <path>, where MTProto's sidecar gets [network] proxies = ["socks5://127.0.0.1:%d"] (internal/mtproto/manager.go:566-567). 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 len(accounts) == 0 → continue guard, unlike AmneziaWG's (xray.go:739-741), so a client-less TUIC inbound ships {"auth":"noauth","udp":true} — an unauthenticated loopback SOCKS5 with UDP egressing through Xray, attributable to no client.

The relay also reuses inbound.Tag, under which the /proc delta is already booked; were the toggle ever wired up, both would land on the same row via addInboundTraffic's WHERE tag = ?.

9. The panel's TUIC config export always emits hardcoded defaults and contradicts the subscription. tuicConfig.ts#L38-L49 reads (inbound as { settings?: unknown })?.settings, but InboundOption has no settings field in either the Zod schema (frontend/src/schemas/client.ts:104-128) or the Go struct that produces it (internal/web/service/inbound.go:306-336, byte-identical to base — GetInboundOptions projects only derived scalars out of the row). So rawSettings is always undefined. Independently, the form writes these values nested under settings.server.* (tuic.tsx:26,213,225,237; inbound-defaults.ts:341-358), so the reads would miss even if settings shipped. AmneziaWG solved exactly this with an explicit AwgServer field on InboundOption (inbound.go:319-322).

Failure: admin sets SNI vpn.example.com and congestion_control: cubic; the subscription's Clash YAML emits both (clash_service.go#L470-L488) while the Client QR modal's TUIC tab hands the user sni: <server IP>, congestion-controller: bbr — which fails the TLS handshake. buildTuicClientConfig has no test; the wireguard and amneziawg analogues both have one.

10. glibc binaries are baked into a musl image and installed on musl hosts. DockerInit.sh#L52-L64 and release.yml:183-199 fetch …-x86_64-unknown-linux-gnu / aarch64-unknown-linux-gnu / armv7-unknown-linux-gnueabihf. The Dockerfile is unchanged FROM alpine with only ca-certificates tzdata fail2ban bash curl openssl — no gcompat/libc6-compat — and install.sh:117 treats Alpine as a first-class host, while the panel itself is deliberately built static-musl (release.yml:83-114) and every other bundled sidecar is a Go binary.

I could not download the asset, so "dynamically linked against glibc" is inferred from the Rust target triple, not observed. The inference is strong: *-unknown-linux-gnu links glibc dynamically by default, and upstream publishes separate -musl / -musleabihf assets under the same tag, which would be pointless otherwise. One file/readelf settles it; the fix is a one-word change per URL. Failure: docker compose up, create a TUIC inbound, cmd.Start() fails ENOENT on the missing loader, the panel shows the inbound up and nothing listens — and after a panel restart it is log-only, since manager.go:200 discards ensureLocked's error inside the 10 s cron.

11. Four of seven architectures get a silent no-op, and Windows gets nothing. install.sh#L371-L378 handles amd64|x86_64, arm64|aarch64, armv7|armv7l, then *) return 0 ;; with no message — but arch() (install.sh:28-39) emits amd64 386 arm64 armv7 armv6 armv5 s390x, so 386, armv6, armv5 and s390x install nothing and say nothing (and x86_64/aarch64/armv7l are dead alternatives arch() can never emit). release.yml:183-199 likewise packages tuic for 3 of the 7 built platforms, where the adjacent mtg block documents its own gap in a comment and covers 5. Upstream publishes i686-unknown-linux-gnu, so 386 is not an upstream limitation. The Windows job (release.yml:293-330) ships mtg-windows-amd64.exe but no tuic-server.exe, despite process_windows.go and GetBinaryName()'s .exe branch; upstream publishes …-x86_64-pc-windows-msvc.exe.

12. fillProtocolDefaults has no TUIC case, and nothing rejects an empty TUIC password. client_crud.go:239-263 (unchanged here) mints credentials for VMESS/VLESS/Trojan/Shadowsocks/Hysteria/MTProto; TUIC falls through with Password == "". AddInboundClient's validation switch (client_inbound_apply.go:426-454) has no TUIC arm either, and its default only rejects an empty ID, which an existing client has. MTProto has both halves (mint at client_crud.go:258-261, reject at client_inbound_apply.go:443-446). This PR made the path reachable by adding 'tuic' to the bulk-attach protocol set (BulkAttachInboundsModal.tsx:19). The client is then dropped by types.go#L225-L231, and if it was the inbound's only usable client ensureLocked takes the len(inst.Clients) == 0 path and stops the sidecar and deletes its config, with no error surfaced. Partly masked: ClientFormModal.tsx:399-408 seeds every UI-created client with a random password regardless of protocol, so this bites clients created via the REST API or the Telegram bot (which relies on fillProtocolDefaults, tgbot/tgbot_client.go:110).

13. TUIC metering and online status sit behind Xray-only early returns. xray_traffic_job.go#L75-L84: if !j.xrayService.IsXrayRunning() { return } and the GetXrayTraffic() error return both precede the new TUIC block. MTProto deliberately collects in its own job with no such gate (internal/web/job/mtproto_job.go:25-44); TuicJob reconciles only. The sidecar has no self-quota either (GenerateConfig emits only a users map, unlike mtg's [secret-limits]). Failure: Xray crash-loops for 20 minutes → an over-quota TUIC client keeps serving unmetered and nobody shows online, then the first successful 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.


🟡 Nits

14. Three frontend protocol registries were not updated (count: 3). isInboundMultiUser (helpers.ts#L83-L93) → the Inbounds-page row menu offers no attach/detach/export for TUIC, even though the PR added 'tuic' to all four Clients-page sets; TRACKED_PROTOCOLS (useInbounds.ts:61-70) → a TUIC row shows 0 clients and no online/expiring rollup; clientSchemaForProtocol (inbound-form-adapter.ts:256-276) → normalizeClients skips the canonical projection, so cross-protocol junk persists into settings.clients.

15. The client-side inbound-tag mirror wasn't kept in sync. port_conflict.go:28 now returns transportUDP for model.TUIC, but inbound-tag.ts#L16-L18 — whose header reads "Keep in sync; inbound-tag.test.ts guards parity" — still lists only hysteria/wireguard/amneziawg. The panel derives in-8443-tcp where the backend derives in-8443-udp, so isAutoInboundTag misclassifies a backend tag as user-authored and stops regenerating it on port change. inbound-tag.test.ts has no tuic case, so CI stays green.

16. disable_sni is keyed off the zero-RTT flag in the raw share link. sub/service.go#L682-L688 emits disable_sni=0 when !inst.ZeroRTTHandshake — an unrelated parameter, and 0 is the default anyway, so the 0-RTT setting reaches no client via this path. The PR's own other two implementations map the same field to reduce-rtt (clash_service.go:480) or omit it (inbound-link.ts:851-897), so panel-copied and subscription-served links for one client differ. I did not verify the tuic:// query-key set against an upstream client; this rests on the internal inconsistency, which is enough on its own.

17. Two explanatory comments were deleted while the code they document stayed. xray_traffic_job.go lost the five-line block ending "See issue #4859" that explains why activeInboundTags is gated on inbounds that moved bytes — the loop survives verbatim at #L164-L172. runtime/local.go lost the AmneziaWG DelInbound comment while keeping its SetNeedRestart(). No behaviour change, but CLAUDE.md spends its two-line comment budget on exactly this — "an invariant, an issue number, a non-obvious constraint".

18. update.sh deletes the sidecar with no fallback, and both scripts pkill too broadly. update.sh#L1042-L1046 removes bin/tuic-server (under an echo that says "Removing old mtg version") and only chmods a replacement if the tarball shipped one — on the four arches from finding 11 it never comes back, silently, on every x-ui update, where install.sh by contrast preserves a hand-placed binary through its backup/restore. And install.sh:1530 / update.sh:1034 use pkill -f 'tuic-server' where the adjacent mtg line is deliberately anchored (pkill -f 'mtg-linux-[^ ]* run ') with a comment saying why — finding 6's over-match, from the shell side.

Seen but not given a slot: install.sh:382-389 has no --remove-on-error and no [[ -s ]] check where five other download sites in the same file do (:1424, :1467, :1497, :1511, :1668), so a mid-body transfer failure leaves a truncated mode-644 file that GetBinaryPath's os.Stat returns ahead of every fallback — but both install and update repair it on the next run.


🟣 Pre-existing

19. DockerInit.sh has no set -e and no per-download guard, so a failed curl is followed by chmod +x on the leftover and the image ships without a working sidecar. This predates the PR — the Xray zip (:33) and the mtg tarball (:47-49) behave identically; the TUIC block just adds an instance. In release.yml the same failure does abort the job, since run: steps use bash -e.

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 contents: write. Pre-existing practice, and the new one is actually stricter than mtg-multi in one respect (pinned to tag tuic-server-1.0.0 rather than resolving releases/latest). Noted only because upstream publishes a .sha256sum beside every tuic asset and the version here is static, so closing it costs one line.


Coverage

  • internal/tuic/ (11 new files, ~1.1k lines) read in full: build tags cover all GOOS, no lock-ordering inversion, no command injection or path traversal, config perms 0600/0755, and the emitted config keys match upstream tuic-server 1.0.0. Defects in 3–6.
  • Panel integration (web/service, web/job, web/runtime, web.go): the dispatch rule holds — interactive edits go through runtime.Runtime via applyLocalTuic, mirroring applyLocalMtproto. Defects in 7, 8, 12, 13.
  • internal/database/model/model.go: no migration needed — adds only the TUIC constant and widens a validate:"oneof=" tag on an existing string column.
  • Routes: none added, so endpoints.ts / TestRouteRegistryContract are correctly untouched. The generated TS artefacts match the Go structs field-for-field; only openapi.json is stale (finding 2).
  • i18n: all 13 new keys present in all 13 locale files and referenced from frontend/src. Clean.
  • Frontend + link output: docs/lib/xray/ implements only vless/trojan/vmess/ss, so TUIC's absence there follows existing precedent rather than being a regression. Defects in 9, 14–16.
  • release.yml: no workflow security finding — only ${{ matrix.platform }} reaches run:, no permissions: or if: change, no step that executes PR code.
  • Not verified: nothing was built, run or downloaded (PR code is read-only here and this job holds a write-scoped token), and no CI has run on this head. tuic-server's linkage (10) is inferred from its target triple; its outbound-proxy support (8) and the tuic:// query-key set (16) are unverified.

Comment thread internal/sub/service.go Outdated
if !ok {
return ""
}
var client *tuic.ClientSettings

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
var client *tuic.ClientSettings
var client *tuic.TuicClientSettings

(Adding type ClientSettings = TuicClientSettings to the tuic package instead would fix both call sites at once.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bad1c9f. Updated both call sites in internal/sub/service.go and internal/web/service/inbound_tuic.go to reference tuic.TuicClientSettings directly.

Comment thread internal/web/service/inbound_tuic.go Outdated
continue
}
if off := disabled[ib.Id]; len(off) > 0 {
kept := make([]tuic.ClientSettings, 0, len(inst.Clients))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
kept := make([]tuic.ClientSettings, 0, len(inst.Clients))
kept := make([]tuic.TuicClientSettings, 0, len(inst.Clients))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in bad1c9f. Updated the slice declaration in internal/web/service/inbound_tuic.go to tuic.TuicClientSettings.

Comment thread internal/tuic/process.go Outdated
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 down3_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.

Suggested change
wchar, _ = strconv.ParseInt(strings.TrimSpace(l[8:]), 10, 64)
wchar, _ = strconv.ParseInt(strings.TrimSpace(l[7:]), 10, 64)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/tuic/orphans_linux.go Outdated
Comment on lines +26 to +33
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++
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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 '.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04bfa05 and bedad15. Replaced substring matching in internal/tuic/orphans_linux.go with exact filepath.Base equality, and anchored the pkill pattern in install.sh and update.sh to tuic-server.*-c .

Comment on lines +226 to +233
if inbound.Protocol == model.TUIC && ignoreId > 0 {
conflict, err := checkTuicSocksReverseConflict(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +786 to +797
})
}
}

settingsMap := map[string]any{
"auth": "noauth",
"udp": true,
}
if len(accounts) > 0 {
settingsMap["auth"] = "password"
settingsMap["accounts"] = accounts
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 This relay inbound is ungated, never dialed, and not skipped when the TUIC inbound has no clients.

  1. Ungated. The loop filter at line 768 is only protocol != TUIC || !Enable || NodeID != nil. RouteThroughXray is parsed (internal/tuic/types.go:114, :260) and folded into StructuralFingerprint (: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 ...).
  2. 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".)
  3. 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 keeps settingsMap at 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04bfa05. Gated injectTuicSocks on inst.RouteThroughXray && inst.XrayRoutePort > 0 and added a guard to skip relay inbound generation when len(accounts) == 0.

Comment on lines +38 to +49
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 rawSettings is always undefined, so this always falls through to the hardcoded defaults below. Two independent reasons:

  1. InboundOption has no settings field — 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). GetInboundOptions reads inbounds.settings into a local row but only projects derived scalars (inboundWireguardHints, inboundShadowsocksMethod, inboundMtprotoDomain, inboundAmneziaWGServer) onto the response.
  2. 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), and createDefaultTuicInboundSettings (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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread DockerInit.sh
Comment on lines +52 to +64
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"
;;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread install.sh
Comment on lines +372 to +377
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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-199 packages tuic-server for 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 publish tuic-server-1.0.0-i686-unknown-linux-gnu, so 386 is not an upstream limitation.
  • The Windows job (release.yml:293-330) ships mtg-windows-amd64.exe but no tuic-server.exe, even though internal/tuic/process_windows.go implements full job-object child management and GetBinaryName() appends .exe. Upstream publishes …-x86_64-pc-windows-msvc.exe.

At minimum, replace the silent return 0 with a warning naming the unsupported arch.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/tuic/manager.go
Comment on lines +143 to +155
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,
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. 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.
  2. 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 at info (tuic-server/src/connection/authenticated.rs), and types.go:206-208 forces info when 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 — and clientMap stays 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/web/job/xray_traffic_job.go Outdated
Comment on lines +83 to +97
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,
})
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.jsondocs/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/**).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread internal/tuic/types.go
Comment on lines +225 to +231
if uuidVal == "" {
uuidVal = c.ID
}
if uuidVal == "" || c.Password == "" {
continue
}
clients = append(clients, TuicClientSettings{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@momachka2

Copy link
Copy Markdown
Author

I have addressed all code review findings across the latest commits (04bfa05, 8f039b8, 63f18cb, d35471f, bad1c9f, bedad15b) and verified all unit/Go tests locally. Could you please re-run the CI workflows for verification? Thank you!

@momachka2
momachka2 marked this pull request as draft August 28, 2026 22:17
@momachka2
momachka2 marked this pull request as ready for review August 28, 2026 22:17
@github-actions

Copy link
Copy Markdown
Contributor

Code review

4 🔴 / 0 🟡 / 0 🟣

Reviewed head: bedad15bc68b828f958f8b47e2ca8f2736a36fd6 — a re-review of the 6 commits since the previously reviewed 7180baf (33 source files, ~+490/−120). Per this repo's review rules a follow-up round reports 🔴 only, so the round-1 nits and pre-existing items are not repeated.

CI on this head: still nothing has run. All six GitHub Actions check-suites for bedad15b are action_required (fork PR awaiting maintainer approval), so go-test, race, codegen, frontend, lint, vulncheck and postgres-durable-first have never executed against this code — same as last round. Finding 1 is what the first of those catches in under a minute, and it sits against the checklist's "go build ./... and the test suite pass locally".

Findings 2–4 are regressions introduced by the round-1 fixes, not leftovers from them.


🔴 Important

1. internal/tuic no longer compiles on Linux — strings is imported and unused.

Commit 04bfa05 replaced the two strings.Contains(...) calls in killStrayTuicProcesses with an exact-basename comparison but left the import behind. orphans_linux.go#L5-L12 — grepping strings\. over the whole 62-line file returns zero matches; the rewritten check at #L29-L32 uses neither.

Failure: go build ./... on any Linux builder dies with internal/tuic/orphans_linux.go:10:2: "strings" imported and not used. internal/tuic and every importer of it — internal/sub, internal/web/service, internal/web/job — fail to build, so make test-go, make lint, make build and make verify are all red and nothing else in the PR can be evaluated. Deleting line 10 is the whole fix.

2. The Windows TUIC binary is packaged under a name the panel can never resolve.

release.yml#L332-L335 saves the sidecar as tuic-windows-amd64.exe, but GetBinaryName() builds tuic-server-<GOOS>-<GOARCH>tuic-server-windows-amd64.exe (process.go#L20-L27), and the only other candidates GetBinaryPath() tries are bin\tuic-server.exe, the two Linux /usr/local/bin and /usr/bin paths, and exec.LookPath("tuic-server") (#L29-L50). None of those matches.

The neighbouring mtg line four lines above is the counter-example that proves the convention: it writes mtg-windows-amd64.exe, which is exactly what mtproto.GetBinaryName() (internal/mtproto/process.go:30-36) asks for. The TUIC line just dropped the -server segment; the string tuic-windows-amd64.exe appears nowhere else in the repo.

Failure: a Windows admin unzips x-ui-windows-amd64.zip and creates a TUIC inbound; GetBinaryPath falls through to the non-existent bin\tuic-server.exe, cmd.Start() fails ENOENT, and the panel shows the inbound up with nothing listening — while a working binary sits unused in bin\. Renaming the output to tuic-server-windows-amd64.exe fixes it.

3. Only the Docker path moved to musl — the release tarball, which is what install.sh actually delivers, still ships glibc binaries to Alpine hosts.

DockerInit.sh correctly switched to x86_64-unknown-linux-musl / aarch64-unknown-linux-musl / armv7-unknown-linux-musleabihf, but the release job that builds the tarball did not: release.yml#L183-L203 still fetches *-unknown-linux-gnu / -gnueabihf for all four arches, and so does install.sh#L370-L384. install.sh treats Alpine as a first-class host (apk update && apk add dcron curl tar …, install.sh:117-118), the panel itself is deliberately built static-musl, and Xray and mtg-multi are static Go — tuic-server would be the only dynamically-linked glibc binary in bin/.

I confirmed against the upstream release API that the x86_64, aarch64 and i686 -unknown-linux-musl assets and -armv7-unknown-linux-musleabihf all exist for tag tuic-server-1.0.0, so this is a four-word change per URL. I did not readelf the assets: "dynamically linked against glibc" is inferred from the *-unknown-linux-gnu triple plus upstream publishing separate -musl assets under the same tag, which would be pointless otherwise.

A second-order effect of the same commit: install_tuic_server() is now unreachable on every architecture. Its only call site is the else of if [[ -f bin/tuic-server ]] (install.sh#L1600-L1605); the tarball now ships the binary for exactly amd64|arm64|armv7|386, and the three arches that can reach the elsearmv6, armv5, s390x — are precisely the ones the new early-return bails out on. So the new [[ -s ]] guard, the rm -f cleanup and the new 386 target added in that same commit never execute. Fixing the triples in release.yml is what actually reaches users.

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.

xray.go#L771-L774 added !inst.RouteThroughXray || inst.XrayRoutePort <= 0 to the skip condition, and #L805-L812 now binds inst.XrayRoutePort instead of tuic.SOCKSPortForInbound(inbound.Id). But RouteThroughXray is read only from a top-level route_through_xray key (internal/tuic/types.go:114), and TuicInboundSettingsSchema has no such field (frontend/src/schemas/protocols/inbound/tuic.ts:45-59) — nor does the TUIC form write one. So for every TUIC inbound the panel can create the relay is no longer injected at all, while the reservation side was left untouched and still assumes 63200+id is always bound:

  • inbound.go#L1103-L1118 — a guard added in this same delta, running after Save assigns the id. On a panel whose inbound auto-increment has passed 2335 (ids are never reused), creating any TUIC inbound now fails outright with tuic: inbound id 2336 exceeds the relay port window, for a port nothing binds. The AmneziaWG guard it copies is legitimate because injectAmneziawgnetSocks binds unconditionally (xray.go:752); this one is not.
  • checkTuicSocksConflict (port_conflict.go:334-359, reached on every create and update via port_conflict.go:197-211) rejects an unrelated inbound that wants 127.0.0.1:63200+id with a spurious conflict naming the TUIC inbound.
  • Converse direction: an API caller who does set route_through_xray: true with routeXrayPort: 443 gets a socks inbound on 127.0.0.1:443 that no conflict check ever validated — checkTuicSocksReverseConflict (port_conflict.go:361-380) still hardcodes SOCKSPortForInbound. If 443 is already taken, Xray refuses to start and every protocol goes down. MTProto avoids exactly this by allocating the port itself via normalizeMtprotoXrayPort (inbound.go:916-949); TUIC has no such normalizer, and nothing bounds routeXrayPort to 65535 either.

Either gate the reservations on the same condition as the injection, or — simpler, and closer to what round 1 asked for — drop the RouteThroughXray gate and keep binding the id-derived port, since the sidecar never dials the relay anyway: internal/tuic/config.go still emits no proxy or outbound field, which is round-1 finding 8 still open.


Coverage

  • Scope: the 6 commits 7180baf..bedad15b only; the 47 files unchanged since round 1 were not re-read.
  • internal/tuic/: import and build-tag audit of every changed file — the readProcIO linux / !linux split is correct and genuinely fixes the old l[8:] wchar truncation. Defect in 1.
  • Panel integration: tuic_job.go now mirrors mtproto_job.go line-for-line, and RefreshLocalOnline (internal/xray/process.go:463) merges with a grace period, so the two jobs do not clobber each other's online sets. Dispatch rule still holds. Defect in 4.
  • Codegen chain: TuicServerSettings is in StructAllow, tuicServer is present in all four frontend/src/generated/ files, and frontend/public/openapi.json is byte-identical to docs/public/openapi.json. Clean — no model or column change, so no migration is owed.
  • Wire format: dropping disable_sni leaves Go genTuicLink and TS inbound-link.ts on the same query-key set, and inbound-tag.ts's UDP forcing now matches port_conflict.go:28. Clean.
  • The 5-line and 3-line comment blocks restored in xray_traffic_job.go and runtime/local.go are verbatim base-branch text (round-1 finding 17), not new 2-line-rule violations; tuic-client-config.test.ts does fail without its fix.
  • Packaging: upstream's tuic-server-1.0.0 asset list fetched via the API — the musl, i686 and windows-msvc assets all exist, so DockerInit's new URLs resolve and its new set -e will not 404 the image build. Defects in 2, 3.
  • Not verified: nothing was built, run or downloaded (PR code is read-only here and this job holds a write-scoped token), and no CI has run on this head. The glibc linkage in 3 is inferred from the target triple, not from readelf; whether tuic-server 1.0.0 honours any outbound proxy at all — which bears on 4 — is still unverified from round 1.

@momachka2
momachka2 marked this pull request as draft August 28, 2026 22:53
@momachka2
momachka2 marked this pull request as ready for review August 28, 2026 23:11
@github-actions

Copy link
Copy Markdown
Contributor

Code review

1 🔴 / 0 🟡 / 0 🟣

Reviewed head: 81141a319fd8027440adb8a2093436b7e194eef0 — a re-review of the single commit since the previously reviewed bedad15b: 81141a31 Fix(review): Remove stale import, correct binary names, switch to musl, and drop unreachable relay gate (4 files, +15/−16). Per this repo's review rules a follow-up round reports 🔴 only, so round-1/round-2 nits are not repeated.

All four round-2 🔴 findings are fixed. Each was verified against the head checkout:

  1. Stale strings import. orphans_linux.go#L4-L12 — the import is gone and a grep for strings. over the whole 61-line file returns nothing. The five remaining imports are each still used (fmt L40/L48, os L19/L40/L48, path/filepath L14/L44/L59, strconv L25, syscall L32). internal/tuic builds on Linux again, so the rest of the PR is evaluable.
  2. Windows binary name. release.yml#L333-L335 now saves tuic-server-windows-amd64.exe, which is exactly what GetBinaryName() produces on windows/amd64 and the first candidate GetBinaryPath() stats (process.go#L20-L33). The download runs with cwd x-ui\bin and Compress-Archive -Path .\x-ui picks it up, so it lands inside the shipped zip.
  3. musl for the release tarball. release.yml#L182-L204 and install.sh#L372-L384 now agree with DockerInit.sh:53-63. I re-fetched the EAimTY/tuic tuic-server-1.0.0 asset list: x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, armv7-unknown-linux-musleabihf and i686-unknown-linux-musl all exist, so every rewritten URL resolves. Each curl -sfLRO basename matches the following mv string exactly on all four arms. A tree-wide grep finds no *-unknown-linux-gnu* TUIC URL and no tuic-windows-amd64.exe left anywhere (the only gnueabihf hits are npm/pnpm lockfile entries).
  4. Relay gate. xray.go#L769-L813 drops the unsatisfiable RouteThroughXray/XrayRoutePort condition and binds tuic.SOCKSPortForInbound(inbound.Id) again — the same derived port the reservation side always used (port_conflict.go:335-361, :363-385) and the one the id-window guard bounds (inbound.go:1107-1119). Injection is now a strict subset of reservation (it additionally needs a non-empty tag and ≥1 client with both email and password), so no unrelated inbound can be saved onto a port the relay will bind. inst is still consumed at xray.go:781, so no unused-variable break; no test asserts the removed behaviour — there is no TUIC test under internal/web/service/ at all.

🔴 Important

1. Nothing has ever been compiled, linted or tested on this pull request — every CI workflow on this head is action_required.

gh api .../actions/runs?head_sha=81141a31 returns CI: completed action_required, and the same for CodeQL Advanced, Release 3X-UI, Deploy Smoke Tests and Docs CI. This is a fork PR awaiting a maintainer's "Approve and run", so go-test, race, codegen, frontend, lint, vulncheck and postgres-durable-first have never executed against any commit of this branch — third round running.

That is not a formality on this PR specifically. Round 2's finding 1 was "strings" imported and not used, a whole-package build break that go build ./... catches in under a minute and that shipped anyway; round 2's findings 2 and 3 were packaging strings that only a real release run exercises. The PR checklist asserts go build ./... and the suite pass locally and that npm run lint/typecheck/build pass, but the round-2 evidence is that the local gate was not actually green. With +3083/−44 across 80 files and a new managed child process, "reviewed by reading" is the only verification this branch has had. Ask a maintainer to approve the workflow runs before merging — I cannot launch them, and this environment cannot build or execute PR code (write-scoped token).


Coverage

  • Scope: the one commit bedad15b..81141a31 (4 files, +15/−16). The 76 files unchanged since round 2 were not re-read; round-1/round-2 open non-🔴 items are unaffected by this commit.
  • Packaging: upstream asset list re-fetched from the GitHub API — all four musl triples and x86_64-pc-windows-msvc.exe exist; release.yml, install.sh and DockerInit.sh now agree; curl/mv names match per arm. Clean.
  • Go: orphans_linux.go imports audited line by line; xray.go injectTuicSocks cross-checked against port_conflict.go, inbound.go and route_egress.go. No compile break, no test regression, no new comment block, no model/column change (so no migration owed), no runtime.Runtime bypass, no new endpoint.
  • Workflow security: the only interpolation added is ${{ matrix.platform }} (trusted); no permissions: change, no new job, no untrusted expression reaching a run: block. Clean.
  • Noted, not counted 🔴: a TUIC inbound saved on its own derived relay port (63200+id) escapes both checks — checkTuicSocksConflict skips ignoreId and checkTuicSocksReverseConflict skips id != id (port_conflict.go:337-341, :366-368) — and would collide with the relay at Xray start. This is the accepted AmneziaWG pattern cloned verbatim (checkAmneziawgnetSocksConflict, port_conflict.go:284-315), where the identical hole is pre-existing on main.
  • Noted, not counted 🔴: DockerInit.sh:52-63 has no i386 or armv6 arm, so the linux/386 and linux/arm/v6 images from docker.yml:60 ship without tuic-server. Pre-dates this commit; the if [ -f "tuic-server" ] guard makes it degrade rather than fail the build.
  • Not verified: nothing was built, run or downloaded — PR code is read-only here and this job holds a write-scoped token. Whether tuic-server 1.0.0 dials any outbound proxy at all (round-1 finding 8, still open: internal/tuic/config.go emits no proxy/outbound field, so the injected relay appears to have no dialer) remains unverified.

@momachka2

Copy link
Copy Markdown
Author

@MHSanaei code review have been resolved. Could you please approve and run the GitHub Actions workflows?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants