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 @@
-
+
@@ -23,7 +23,7 @@
{{ i18n "password" }}
-
+
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"}}
+
+
+ {{template "form/trusttunnel"}}
+
+
{{template "form/streamSettings"}}
diff --git a/web/html/form/protocol/trusttunnel.html b/web/html/form/protocol/trusttunnel.html
new file mode 100644
index 00000000..153a6d3d
--- /dev/null
+++ b/web/html/form/protocol/trusttunnel.html
@@ -0,0 +1,100 @@
+{{define "form/trusttunnel"}}
+
+
+
+
+
+
+
+
+
+
+ {{ i18n "pages.inbounds.certificate" }}
+
+ Certificate Path
+
+
+
+
+
+
+
+
+
+
+ {{ i18n "pages.inbounds.key" }}
+
+ Private Key Path
+
+
+
+
+
+
+ Transport Protocols
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Network
+
+
+
+
+
+
+
+
+
+ Allow connections to private network addresses (10.x, 192.168.x, etc.)
+
+ Private Network
+
+
+
+
+
+
+
+ Clients
+
+
+
+
+
+ Client [[ index + 1 ]]
+ inbound.settings.delClient(index)"
+ :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{{end}}
diff --git a/web/service/inbound.go b/web/service/inbound.go
index 101c79d9..af0bab07 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -270,6 +270,10 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
if client.Email == "" {
return inbound, false, common.NewError("empty client ID")
}
+ case "trusttunnel":
+ if client.Password == "" {
+ return inbound, false, common.NewError("empty client password")
+ }
default:
if client.ID == "" {
return inbound, false, common.NewError("empty client ID")
@@ -299,7 +303,14 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
}
needRestart := false
- if inbound.Enable {
+ if inbound.Enable && inbound.Protocol == "trusttunnel" {
+ // TrustTunnel inbounds are managed by TrustTunnelService, not Xray
+ if tt := GetTrustTunnelService(); tt != nil {
+ if err1 := tt.RestartForInbound(inbound); err1 != nil {
+ logger.Warning("Failed to start TrustTunnel:", err1)
+ }
+ }
+ } else if inbound.Enable {
s.xrayApi.Init(p.GetAPIPort())
inboundJson, err1 := json.MarshalIndent(inbound.GenXrayInboundConfig(), "", " ")
if err1 != nil {
@@ -327,19 +338,33 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
var tag string
needRestart := false
- result := db.Model(model.Inbound{}).Select("tag").Where("id = ? and enable = ?", id, true).First(&tag)
- if result.Error == nil {
- s.xrayApi.Init(p.GetAPIPort())
- err1 := s.xrayApi.DelInbound(tag)
- if err1 == nil {
- logger.Debug("Inbound deleted by api:", tag)
- } else {
- logger.Debug("Unable to delete inbound by api:", err1)
- needRestart = true
+
+ // Check if this is a TrustTunnel inbound (skip xray API for those)
+ var protocol string
+ db.Model(model.Inbound{}).Select("protocol").Where("id = ?", id).First(&protocol)
+ isTrustTunnel := protocol == string(model.TrustTunnel)
+
+ if isTrustTunnel {
+ // Stop TrustTunnel process for this inbound
+ db.Model(model.Inbound{}).Select("tag").Where("id = ?", id).First(&tag)
+ if tt := GetTrustTunnelService(); tt != nil && tag != "" {
+ tt.StopForInbound(tag)
}
- s.xrayApi.Close()
} else {
- logger.Debug("No enabled inbound founded to removing by api", tag)
+ result := db.Model(model.Inbound{}).Select("tag").Where("id = ? and enable = ?", id, true).First(&tag)
+ if result.Error == nil {
+ s.xrayApi.Init(p.GetAPIPort())
+ err1 := s.xrayApi.DelInbound(tag)
+ if err1 == nil {
+ logger.Debug("Inbound deleted by api:", tag)
+ } else {
+ logger.Debug("Unable to delete inbound by api:", err1)
+ needRestart = true
+ }
+ s.xrayApi.Close()
+ } else {
+ logger.Debug("No enabled inbound founded to removing by api", tag)
+ }
}
// Delete client traffics of inbounds
@@ -489,6 +514,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
}
needRestart := false
+ if oldInbound.Protocol == "trusttunnel" {
+ // TrustTunnel inbounds are managed by TrustTunnelService
+ err = tx.Save(oldInbound).Error
+ if err != nil {
+ return inbound, false, err
+ }
+ if tt := GetTrustTunnelService(); tt != nil {
+ if err1 := tt.RestartForInbound(oldInbound); err1 != nil {
+ logger.Warning("Failed to restart TrustTunnel:", err1)
+ }
+ }
+ return inbound, false, nil
+ }
+
s.xrayApi.Init(p.GetAPIPort())
if s.xrayApi.DelInbound(tag) == nil {
logger.Debug("Old inbound deleted by api:", tag)
@@ -606,6 +645,10 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
if client.Email == "" {
return false, common.NewError("empty client ID")
}
+ case "trusttunnel":
+ if client.Password == "" {
+ return false, common.NewError("empty client password")
+ }
default:
if client.ID == "" {
return false, common.NewError("empty client ID")
@@ -643,6 +686,25 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
}()
needRestart := false
+ if oldInbound.Protocol == "trusttunnel" {
+ // TrustTunnel: just save client stats and restart the TrustTunnel process
+ for _, client := range clients {
+ if len(client.Email) > 0 {
+ s.AddClientStat(tx, data.Id, &client)
+ }
+ }
+ err = tx.Save(oldInbound).Error
+ if err != nil {
+ return false, err
+ }
+ if tt := GetTrustTunnelService(); tt != nil {
+ if err1 := tt.RestartForInbound(oldInbound); err1 != nil {
+ logger.Warning("Failed to restart TrustTunnel:", err1)
+ }
+ }
+ return false, nil
+ }
+
s.xrayApi.Init(p.GetAPIPort())
for _, client := range clients {
if len(client.Email) > 0 {
@@ -693,7 +755,7 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
if oldInbound.Protocol == "trojan" {
client_key = "password"
}
- if oldInbound.Protocol == "shadowsocks" {
+ if oldInbound.Protocol == "shadowsocks" || oldInbound.Protocol == "trusttunnel" {
client_key = "email"
}
@@ -745,6 +807,19 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
return false, err
}
if needApiDel && notDepleted {
+ if oldInbound.Protocol == "trusttunnel" {
+ // TrustTunnel: save and restart process instead of xray API
+ err = db.Save(oldInbound).Error
+ if err != nil {
+ return false, err
+ }
+ if tt := GetTrustTunnelService(); tt != nil {
+ if err1 := tt.RestartForInbound(oldInbound); err1 != nil {
+ logger.Warning("Failed to restart TrustTunnel:", err1)
+ }
+ }
+ return false, nil
+ }
s.xrayApi.Init(p.GetAPIPort())
err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email)
if err1 == nil {
@@ -798,7 +873,7 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
case "trojan":
oldClientId = oldClient.Password
newClientId = clients[0].Password
- case "shadowsocks":
+ case "shadowsocks", "trusttunnel":
oldClientId = oldClient.Email
newClientId = clients[0].Email
default:
@@ -896,6 +971,20 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
}
}
needRestart := false
+ if oldInbound.Protocol == "trusttunnel" {
+ // TrustTunnel: save and restart process instead of xray API
+ err = tx.Save(oldInbound).Error
+ if err != nil {
+ return false, err
+ }
+ if tt := GetTrustTunnelService(); tt != nil {
+ if err1 := tt.RestartForInbound(oldInbound); err1 != nil {
+ logger.Warning("Failed to restart TrustTunnel:", err1)
+ }
+ }
+ return false, nil
+ }
+
if len(oldEmail) > 0 {
s.xrayApi.Init(p.GetAPIPort())
if oldClients[clientIndex].Enable {
@@ -2490,6 +2579,18 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
}
if needApiDel {
+ if oldInbound.Protocol == "trusttunnel" {
+ err = db.Save(oldInbound).Error
+ if err != nil {
+ return false, err
+ }
+ if tt := GetTrustTunnelService(); tt != nil {
+ if err1 := tt.RestartForInbound(oldInbound); err1 != nil {
+ logger.Warning("Failed to restart TrustTunnel:", err1)
+ }
+ }
+ return false, nil
+ }
s.xrayApi.Init(p.GetAPIPort())
if err1 := s.xrayApi.RemoveUser(oldInbound.Tag, email); err1 == nil {
logger.Debug("Client deleted by api:", email)
diff --git a/web/service/trusttunnel.go b/web/service/trusttunnel.go
new file mode 100644
index 00000000..e0e59401
--- /dev/null
+++ b/web/service/trusttunnel.go
@@ -0,0 +1,180 @@
+package service
+
+import (
+ "sync"
+
+ "github.com/mhsanaei/3x-ui/v2/database"
+ "github.com/mhsanaei/3x-ui/v2/database/model"
+ "github.com/mhsanaei/3x-ui/v2/logger"
+ "github.com/mhsanaei/3x-ui/v2/trusttunnel"
+)
+
+// Package-level TrustTunnel service instance, set during server startup.
+// Used by InboundService to trigger TrustTunnel restarts on inbound changes.
+var ttService *TrustTunnelService
+
+// SetTrustTunnelService sets the package-level TrustTunnel service instance.
+func SetTrustTunnelService(s *TrustTunnelService) {
+ ttService = s
+}
+
+// GetTrustTunnelService returns the package-level TrustTunnel service instance.
+func GetTrustTunnelService() *TrustTunnelService {
+ return ttService
+}
+
+// TrustTunnelService manages TrustTunnel endpoint processes.
+// Each TrustTunnel inbound runs its own process alongside Xray.
+type TrustTunnelService struct {
+ mu sync.Mutex
+ processes map[string]*trusttunnel.Process // keyed by inbound tag
+}
+
+func NewTrustTunnelService() *TrustTunnelService {
+ return &TrustTunnelService{
+ processes: make(map[string]*trusttunnel.Process),
+ }
+}
+
+// StartAll starts TrustTunnel processes for all enabled TrustTunnel inbounds.
+func (s *TrustTunnelService) StartAll() {
+ if !trusttunnel.IsBinaryInstalled() {
+ logger.Debug("TrustTunnel binary not installed, skipping")
+ return
+ }
+
+ inbounds, err := s.getTrustTunnelInbounds()
+ if err != nil {
+ logger.Warning("Failed to get TrustTunnel inbounds:", err)
+ return
+ }
+
+ for _, inbound := range inbounds {
+ if !inbound.Enable {
+ continue
+ }
+ if err := s.startInbound(inbound); err != nil {
+ logger.Warningf("Failed to start TrustTunnel for %s: %v", inbound.Tag, err)
+ }
+ }
+}
+
+// StopAll stops all running TrustTunnel processes.
+func (s *TrustTunnelService) StopAll() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for tag, proc := range s.processes {
+ if proc.IsRunning() {
+ if err := proc.Stop(); err != nil {
+ logger.Warningf("Failed to stop TrustTunnel %s: %v", tag, err)
+ }
+ }
+ }
+ s.processes = make(map[string]*trusttunnel.Process)
+}
+
+// RestartForInbound restarts the TrustTunnel process for a specific inbound.
+func (s *TrustTunnelService) RestartForInbound(inbound *model.Inbound) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ // Stop existing process for this tag
+ if proc, ok := s.processes[inbound.Tag]; ok && proc.IsRunning() {
+ proc.Stop()
+ }
+
+ if !inbound.Enable {
+ delete(s.processes, inbound.Tag)
+ return nil
+ }
+
+ return s.startInboundLocked(inbound)
+}
+
+// StopForInbound stops the TrustTunnel process for a specific inbound tag.
+func (s *TrustTunnelService) StopForInbound(tag string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if proc, ok := s.processes[tag]; ok {
+ if proc.IsRunning() {
+ proc.Stop()
+ }
+ delete(s.processes, tag)
+ }
+}
+
+// IsRunning checks if a TrustTunnel process is running for the given tag.
+func (s *TrustTunnelService) IsRunning(tag string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if proc, ok := s.processes[tag]; ok {
+ return proc.IsRunning()
+ }
+ return false
+}
+
+// CheckAndRestart checks for crashed TrustTunnel processes and restarts them.
+func (s *TrustTunnelService) CheckAndRestart() {
+ if !trusttunnel.IsBinaryInstalled() {
+ return
+ }
+
+ inbounds, err := s.getTrustTunnelInbounds()
+ if err != nil {
+ return
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for _, inbound := range inbounds {
+ if !inbound.Enable {
+ continue
+ }
+ proc, ok := s.processes[inbound.Tag]
+ if !ok || !proc.IsRunning() {
+ logger.Infof("TrustTunnel %s not running, restarting...", inbound.Tag)
+ s.startInboundLocked(inbound)
+ }
+ }
+}
+
+func (s *TrustTunnelService) startInbound(inbound *model.Inbound) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.startInboundLocked(inbound)
+}
+
+func (s *TrustTunnelService) startInboundLocked(inbound *model.Inbound) error {
+ settings, err := trusttunnel.ParseSettings(inbound.Settings)
+ if err != nil {
+ return err
+ }
+
+ proc := trusttunnel.NewProcess(inbound.Tag)
+
+ if err := proc.WriteConfig(inbound.Listen, inbound.Port, settings); err != nil {
+ return err
+ }
+
+ if err := proc.Start(); err != nil {
+ return err
+ }
+
+ s.processes[inbound.Tag] = proc
+ logger.Infof("TrustTunnel started for inbound %s on port %d", inbound.Tag, inbound.Port)
+ return nil
+}
+
+func (s *TrustTunnelService) getTrustTunnelInbounds() ([]*model.Inbound, error) {
+ db := database.GetDB()
+ var inbounds []*model.Inbound
+ err := db.Model(model.Inbound{}).Where("protocol = ?", model.TrustTunnel).Find(&inbounds).Error
+ if err != nil {
+ return nil, err
+ }
+ return inbounds, nil
+}
diff --git a/web/service/xray.go b/web/service/xray.go
index 511ffdda..004de8bb 100644
--- a/web/service/xray.go
+++ b/web/service/xray.go
@@ -113,6 +113,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
if !inbound.Enable {
continue
}
+ // Skip TrustTunnel inbounds — they are managed by TrustTunnelService, not Xray
+ if inbound.Protocol == "trusttunnel" {
+ continue
+ }
// get settings clients
settings := map[string]any{}
json.Unmarshal([]byte(inbound.Settings), &settings)
diff --git a/web/web.go b/web/web.go
index 300572a3..0ecac528 100644
--- a/web/web.go
+++ b/web/web.go
@@ -101,9 +101,10 @@ type Server struct {
api *controller.APIController
ws *controller.WebSocketController
- xrayService service.XrayService
- settingService service.SettingService
- tgbotService service.Tgbot
+ xrayService service.XrayService
+ settingService service.SettingService
+ tgbotService service.Tgbot
+ trustTunnelService *service.TrustTunnelService
wsHub *websocket.Hub
@@ -117,8 +118,9 @@ type Server struct {
func NewServer() *Server {
ctx, cancel := context.WithCancel(context.Background())
return &Server{
- ctx: ctx,
- cancel: cancel,
+ ctx: ctx,
+ cancel: cancel,
+ trustTunnelService: service.NewTrustTunnelService(),
}
}
@@ -299,6 +301,11 @@ func (s *Server) startTask() {
if err != nil {
logger.Warning("start xray failed:", err)
}
+
+ // Start TrustTunnel processes for all enabled TrustTunnel inbounds
+ service.SetTrustTunnelService(s.trustTunnelService)
+ s.trustTunnelService.StartAll()
+
// Check whether xray is running every second
s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob())
@@ -318,6 +325,12 @@ func (s *Server) startTask() {
s.cron.AddJob("@every 10s", job.NewXrayTrafficJob())
}()
+ // Check for crashed TrustTunnel processes every 30 seconds
+ ttService := s.trustTunnelService
+ s.cron.AddFunc("@every 30s", func() {
+ ttService.CheckAndRestart()
+ })
+
// check client ips from log file every 10 sec
s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
@@ -455,6 +468,9 @@ func (s *Server) Start() (err error) {
func (s *Server) Stop() error {
s.cancel()
s.xrayService.StopXray()
+ if s.trustTunnelService != nil {
+ s.trustTunnelService.StopAll()
+ }
if s.cron != nil {
s.cron.Stop()
}