From 10eab4cb06b3b2df9de577ebb2e54bb174b3f638 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 09:45:32 +0000 Subject: [PATCH] Add TrustTunnel protocol support as a separate managed process TrustTunnel (by AdGuard) is an independent VPN protocol binary that runs alongside Xray. This integrates it into the 3x-ui panel so users can create TrustTunnel inbounds through the same UI. Architecture: - TrustTunnel runs as a separate process (not an xray inbound) - Each TrustTunnel inbound gets its own TOML config and process - TrustTunnel inbounds are skipped during xray config generation - Periodic health checks restart crashed TrustTunnel processes New files (isolated, minimal merge conflict risk): - trusttunnel/process.go: process lifecycle and TOML config generation - web/service/trusttunnel.go: service layer with start/stop/restart - web/html/form/protocol/trusttunnel.html: UI form template Modified files (minimal, targeted changes): - database/model/model.go: add TrustTunnel protocol constant - web/service/xray.go: skip trusttunnel inbounds in xray config - web/service/inbound.go: validation + TrustTunnel process triggers - web/web.go: startup/shutdown integration - web/assets/js/model/inbound.js: protocol enum + settings class - web/assets/js/model/dbinbound.js: isTrustTunnel helper - web/html/form/inbound.html: form conditional - web/html/form/client.html: password field for TrustTunnel clients https://claude.ai/code/session_01RQBndg4ZPmYAToK4KKcBzp --- database/model/model.go | 1 + trusttunnel/process.go | 267 ++++++++++++++++++++++++ web/assets/js/model/dbinbound.js | 5 + web/assets/js/model/inbound.js | 124 +++++++++++ web/html/form/client.html | 4 +- web/html/form/inbound.html | 5 + web/html/form/protocol/trusttunnel.html | 100 +++++++++ web/service/inbound.go | 129 ++++++++++-- web/service/trusttunnel.go | 180 ++++++++++++++++ web/service/xray.go | 4 + web/web.go | 26 ++- 11 files changed, 824 insertions(+), 21 deletions(-) create mode 100644 trusttunnel/process.go create mode 100644 web/html/form/protocol/trusttunnel.html create mode 100644 web/service/trusttunnel.go diff --git a/database/model/model.go b/database/model/model.go index 6225df52..e487c8e6 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -21,6 +21,7 @@ const ( Shadowsocks Protocol = "shadowsocks" Mixed Protocol = "mixed" WireGuard Protocol = "wireguard" + TrustTunnel Protocol = "trusttunnel" ) // User represents a user account in the 3x-ui panel. diff --git a/trusttunnel/process.go b/trusttunnel/process.go new file mode 100644 index 00000000..e57542a6 --- /dev/null +++ b/trusttunnel/process.go @@ -0,0 +1,267 @@ +// Package trusttunnel manages TrustTunnel endpoint processes alongside Xray. +// TrustTunnel is a separate VPN protocol binary that runs independently of xray-core. +package trusttunnel + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "syscall" + "time" + + "github.com/mhsanaei/3x-ui/v2/config" + "github.com/mhsanaei/3x-ui/v2/logger" +) + +const ( + DefaultInstallPath = "/opt/trusttunnel" + BinaryName = "trusttunnel_endpoint" +) + +// Settings holds the TrustTunnel configuration parsed from the inbound's Settings JSON. +type Settings struct { + Hostname string `json:"hostname"` + CertFile string `json:"certFile"` + KeyFile string `json:"keyFile"` + EnableHTTP1 bool `json:"enableHttp1"` + EnableHTTP2 bool `json:"enableHttp2"` + EnableQUIC bool `json:"enableQuic"` + IPv6Available bool `json:"ipv6Available"` + AllowPrivateNetwork bool `json:"allowPrivateNetwork"` + Clients []Client `json:"clients"` +} + +// Client represents a TrustTunnel user credential. +type Client struct { + Email string `json:"email"` + Password string `json:"password"` + Enable bool `json:"enable"` + LimitIP int `json:"limitIp"` + TotalGB int64 `json:"totalGB"` + ExpiryTime int64 `json:"expiryTime"` + TgID int64 `json:"tgId"` + SubID string `json:"subId"` + Comment string `json:"comment"` + Reset int `json:"reset"` + CreatedAt int64 `json:"created_at,omitempty"` + UpdatedAt int64 `json:"updated_at,omitempty"` +} + +// GetInstallPath returns the TrustTunnel binary installation directory. +func GetInstallPath() string { + p := os.Getenv("TRUSTTUNNEL_PATH") + if p != "" { + return p + } + return DefaultInstallPath +} + +// GetBinaryPath returns the full path to the TrustTunnel endpoint binary. +func GetBinaryPath() string { + return filepath.Join(GetInstallPath(), BinaryName) +} + +// GetConfigDir returns the directory for TrustTunnel config files for a given inbound tag. +func GetConfigDir(tag string) string { + return filepath.Join(config.GetBinFolderPath(), "trusttunnel", tag) +} + +// IsBinaryInstalled checks if the TrustTunnel binary exists. +func IsBinaryInstalled() bool { + _, err := os.Stat(GetBinaryPath()) + return err == nil +} + +// GetVersion returns the TrustTunnel binary version string. +func GetVersion() string { + cmd := exec.Command(GetBinaryPath(), "--version") + data, err := cmd.Output() + if err != nil { + return "Unknown" + } + return strings.TrimSpace(string(data)) +} + +// Process manages a single TrustTunnel endpoint process. +type Process struct { + cmd *exec.Cmd + tag string + configDir string + logWriter *logWriter + exitErr error + startTime time.Time +} + +type logWriter struct { + buf bytes.Buffer + lastLine string +} + +func (w *logWriter) Write(p []byte) (n int, err error) { + n, err = w.buf.Write(p) + lines := strings.Split(strings.TrimSpace(w.buf.String()), "\n") + if len(lines) > 0 { + w.lastLine = lines[len(lines)-1] + } + return +} + +// NewProcess creates a new TrustTunnel process for the given inbound tag. +func NewProcess(tag string) *Process { + return &Process{ + tag: tag, + configDir: GetConfigDir(tag), + logWriter: &logWriter{}, + startTime: time.Now(), + } +} + +func (p *Process) IsRunning() bool { + if p.cmd == nil || p.cmd.Process == nil { + return false + } + return p.cmd.ProcessState == nil +} + +func (p *Process) GetErr() error { + return p.exitErr +} + +func (p *Process) GetResult() string { + if p.logWriter.lastLine == "" && p.exitErr != nil { + return p.exitErr.Error() + } + return p.logWriter.lastLine +} + +func (p *Process) GetUptime() uint64 { + return uint64(time.Since(p.startTime).Seconds()) +} + +// WriteConfig generates TOML configuration files from an inbound's settings. +func (p *Process) WriteConfig(listen string, port int, settings Settings) error { + if err := os.MkdirAll(p.configDir, 0o750); err != nil { + return fmt.Errorf("failed to create config dir: %w", err) + } + + listenAddr := listen + if listenAddr == "" { + listenAddr = "0.0.0.0" + } + listenAddr = fmt.Sprintf("%s:%d", listenAddr, port) + + // vpn.toml + vpnToml := fmt.Sprintf(`listen_address = "%s" +ipv6_available = %t +allow_private_network_connections = %t +credentials_file = "credentials.toml" +`, listenAddr, settings.IPv6Available, settings.AllowPrivateNetwork) + + // Add protocol sections based on user selection + if settings.EnableHTTP1 { + vpnToml += "\n[listen_protocols.http1]\n" + } + if settings.EnableHTTP2 { + vpnToml += "\n[listen_protocols.http2]\n" + } + if settings.EnableQUIC { + vpnToml += "\n[listen_protocols.quic]\n" + } + + if err := os.WriteFile(filepath.Join(p.configDir, "vpn.toml"), []byte(vpnToml), 0o640); err != nil { + return fmt.Errorf("failed to write vpn.toml: %w", err) + } + + // hosts.toml + if settings.Hostname != "" && settings.CertFile != "" && settings.KeyFile != "" { + hostsToml := fmt.Sprintf(`[[main_hosts]] +hostname = "%s" +cert_chain_path = "%s" +private_key_path = "%s" +`, settings.Hostname, settings.CertFile, settings.KeyFile) + + if err := os.WriteFile(filepath.Join(p.configDir, "hosts.toml"), []byte(hostsToml), 0o640); err != nil { + return fmt.Errorf("failed to write hosts.toml: %w", err) + } + } + + // credentials.toml + var credBuf strings.Builder + for _, client := range settings.Clients { + if !client.Enable { + continue + } + credBuf.WriteString(fmt.Sprintf("[[client]]\nusername = \"%s\"\npassword = \"%s\"\n\n", + escapeToml(client.Email), escapeToml(client.Password))) + } + if err := os.WriteFile(filepath.Join(p.configDir, "credentials.toml"), []byte(credBuf.String()), 0o640); err != nil { + return fmt.Errorf("failed to write credentials.toml: %w", err) + } + + return nil +} + +// Start launches the TrustTunnel endpoint process. +// The binary expects positional args: trusttunnel_endpoint vpn.toml hosts.toml +func (p *Process) Start() error { + if p.IsRunning() { + return fmt.Errorf("trusttunnel %s is already running", p.tag) + } + + if !IsBinaryInstalled() { + return fmt.Errorf("trusttunnel binary not found at %s", GetBinaryPath()) + } + + cmd := exec.Command(GetBinaryPath(), "vpn.toml", "hosts.toml") + cmd.Dir = p.configDir + cmd.Stdout = p.logWriter + cmd.Stderr = p.logWriter + p.cmd = cmd + p.startTime = time.Now() + p.exitErr = nil + + go func() { + err := cmd.Run() + if err != nil { + logger.Errorf("TrustTunnel process %s exited: %v", p.tag, err) + p.exitErr = err + } + }() + + return nil +} + +// Stop terminates the TrustTunnel endpoint process. +func (p *Process) Stop() error { + if !p.IsRunning() { + return nil + } + if runtime.GOOS == "windows" { + return p.cmd.Process.Kill() + } + return p.cmd.Process.Signal(syscall.SIGTERM) +} + +// ParseSettings parses TrustTunnel settings from the inbound's Settings JSON string. +func ParseSettings(settingsJSON string) (Settings, error) { + var s Settings + s.EnableHTTP1 = true + s.EnableHTTP2 = true + s.EnableQUIC = true + s.IPv6Available = true + if err := json.Unmarshal([]byte(settingsJSON), &s); err != nil { + return s, fmt.Errorf("failed to parse trusttunnel settings: %w", err) + } + return s, nil +} + +func escapeToml(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return s +} diff --git a/web/assets/js/model/dbinbound.js b/web/assets/js/model/dbinbound.js index befc618e..1e6f7eb5 100644 --- a/web/assets/js/model/dbinbound.js +++ b/web/assets/js/model/dbinbound.js @@ -63,6 +63,10 @@ class DBInbound { return this.protocol === Protocols.WIREGUARD; } + get isTrustTunnel() { + return this.protocol === Protocols.TRUSTTUNNEL; + } + get address() { let address = location.hostname; if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { @@ -124,6 +128,7 @@ class DBInbound { case Protocols.VMESS: case Protocols.VLESS: case Protocols.TROJAN: + case Protocols.TRUSTTUNNEL: return true; case Protocols.SHADOWSOCKS: return this.toInbound().isSSMultiUser; diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index b6059cf7..41add9e9 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -8,6 +8,7 @@ const Protocols = { HTTP: 'http', WIREGUARD: 'wireguard', TUN: 'tun', + TRUSTTUNNEL: 'trusttunnel', }; const SSMethods = { @@ -1201,6 +1202,7 @@ class Inbound extends XrayCommonClass { case Protocols.VLESS: return this.settings.vlesses; case Protocols.TROJAN: return this.settings.trojans; case Protocols.SHADOWSOCKS: return this.isSSMultiUser ? this.settings.shadowsockses : null; + case Protocols.TRUSTTUNNEL: return this.settings.clients; default: return null; } } @@ -1827,6 +1829,7 @@ Inbound.Settings = class extends XrayCommonClass { case Protocols.HTTP: return new Inbound.HttpSettings(protocol); case Protocols.WIREGUARD: return new Inbound.WireguardSettings(protocol); case Protocols.TUN: return new Inbound.TunSettings(protocol); + case Protocols.TRUSTTUNNEL: return new Inbound.TrustTunnelSettings(protocol); default: return null; } } @@ -1842,6 +1845,7 @@ Inbound.Settings = class extends XrayCommonClass { case Protocols.HTTP: return Inbound.HttpSettings.fromJson(json); case Protocols.WIREGUARD: return Inbound.WireguardSettings.fromJson(json); case Protocols.TUN: return Inbound.TunSettings.fromJson(json); + case Protocols.TRUSTTUNNEL: return Inbound.TrustTunnelSettings.fromJson(json); default: return null; } } @@ -2708,3 +2712,123 @@ Inbound.TunSettings = class extends Inbound.Settings { }; } }; + +Inbound.TrustTunnelSettings = class extends Inbound.Settings { + constructor( + protocol, + hostname = '', + certFile = '', + keyFile = '', + enableHttp1 = true, + enableHttp2 = true, + enableQuic = true, + ipv6Available = true, + allowPrivateNetwork = false, + clients = [new Inbound.TrustTunnelSettings.Client()], + ) { + super(protocol); + this.hostname = hostname; + this.certFile = certFile; + this.keyFile = keyFile; + this.enableHttp1 = enableHttp1; + this.enableHttp2 = enableHttp2; + this.enableQuic = enableQuic; + this.ipv6Available = ipv6Available; + this.allowPrivateNetwork = allowPrivateNetwork; + this.clients = clients; + } + + addClient() { + this.clients.push(new Inbound.TrustTunnelSettings.Client()); + } + + delClient(index) { + this.clients.splice(index, 1); + } + + static fromJson(json = {}) { + return new Inbound.TrustTunnelSettings( + Protocols.TRUSTTUNNEL, + json.hostname, + json.certFile, + json.keyFile, + json.enableHttp1 ?? true, + json.enableHttp2 ?? true, + json.enableQuic ?? true, + json.ipv6Available ?? true, + json.allowPrivateNetwork ?? false, + (json.clients || []).map(c => Inbound.TrustTunnelSettings.Client.fromJson(c)), + ); + } + + toJson() { + return { + hostname: this.hostname, + certFile: this.certFile, + keyFile: this.keyFile, + enableHttp1: this.enableHttp1, + enableHttp2: this.enableHttp2, + enableQuic: this.enableQuic, + ipv6Available: this.ipv6Available, + allowPrivateNetwork: this.allowPrivateNetwork, + clients: this.clients.map(c => c.toJson()), + }; + } +}; + +Inbound.TrustTunnelSettings.Client = class extends XrayCommonClass { + constructor( + email = RandomUtil.randomText(8) + '@trusttunnel', + password = RandomUtil.randomSeq(16), + enable = true, + limitIp = 0, + totalGB = 0, + expiryTime = 0, + tgId = '', + subId = RandomUtil.randomText(16), + comment = '', + reset = 0, + ) { + super(); + this.email = email; + this.password = password; + this.enable = enable; + this.limitIp = limitIp; + this.totalGB = totalGB; + this.expiryTime = expiryTime; + this.tgId = tgId; + this.subId = subId; + this.comment = comment; + this.reset = reset; + } + + static fromJson(json = {}) { + return new Inbound.TrustTunnelSettings.Client( + json.email, + json.password, + json.enable ?? true, + json.limitIp, + json.totalGB, + json.expiryTime, + json.tgId, + json.subId, + json.comment, + json.reset, + ); + } + + toJson() { + return { + email: this.email, + password: this.password, + enable: this.enable, + limitIp: this.limitIp, + totalGB: this.totalGB, + expiryTime: this.expiryTime, + tgId: this.tgId, + subId: this.subId, + comment: this.comment, + reset: this.reset, + }; + } +}; diff --git a/web/html/form/client.html b/web/html/form/client.html index 908f28d2..2cd7161a 100644 --- a/web/html/form/client.html +++ b/web/html/form/client.html @@ -15,7 +15,7 @@ - + diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html index 8b59dc28..24778c11 100644 --- a/web/html/form/inbound.html +++ b/web/html/form/inbound.html @@ -152,6 +152,11 @@ {{template "form/tun"}} + + +