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
This commit is contained in:
Claude 2026-02-17 09:45:32 +00:00
parent 37f0880f8f
commit 10eab4cb06
No known key found for this signature in database
11 changed files with 824 additions and 21 deletions

View file

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

267
trusttunnel/process.go Normal file
View file

@ -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
}

View file

@ -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;

View file

@ -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,
};
}
};

View file

@ -15,7 +15,7 @@
</template>
<a-input v-model.trim="client.email"></a-input>
</a-form-item>
<a-form-item v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS">
<a-form-item v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS || inbound.protocol === Protocols.TRUSTTUNNEL">
<template slot="label">
<a-tooltip>
<template slot="title">
@ -23,7 +23,7 @@
</template>
{{ i18n "password" }}
<a-icon v-if="inbound.protocol === Protocols.SHADOWSOCKS" @click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
<a-icon v-if="inbound.protocol === Protocols.TROJAN" @click="client.password = RandomUtil.randomSeq(10)"type="sync"> </a-icon>
<a-icon v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.TRUSTTUNNEL" @click="client.password = RandomUtil.randomSeq(10)"type="sync"> </a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="client.password"></a-input>

View file

@ -152,6 +152,11 @@
{{template "form/tun"}}
</template>
<!-- trusttunnel -->
<template v-if="inbound.protocol === Protocols.TRUSTTUNNEL">
{{template "form/trusttunnel"}}
</template>
<!-- stream settings -->
<template v-if="inbound.canEnableStream()">
{{template "form/streamSettings"}}

View file

@ -0,0 +1,100 @@
{{define "form/trusttunnel"}}
<a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form-item label="Hostname">
<a-input v-model.trim="inbound.settings.hostname"
placeholder="example.com"></a-input>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.certificate" }}</span>
</template>
Certificate Path
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="inbound.settings.certFile"
placeholder="/path/to/fullchain.pem"></a-input>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.key" }}</span>
</template>
Private Key Path
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="inbound.settings.keyFile"
placeholder="/path/to/privkey.pem"></a-input>
</a-form-item>
<a-divider :style="{ margin: '10px 0' }">Transport Protocols</a-divider>
<a-form-item label="HTTP/1.1">
<a-switch v-model="inbound.settings.enableHttp1"></a-switch>
</a-form-item>
<a-form-item label="HTTP/2">
<a-switch v-model="inbound.settings.enableHttp2"></a-switch>
</a-form-item>
<a-form-item label="QUIC (HTTP/3)">
<a-switch v-model="inbound.settings.enableQuic"></a-switch>
</a-form-item>
<a-divider :style="{ margin: '10px 0' }">Network</a-divider>
<a-form-item label="IPv6">
<a-switch v-model="inbound.settings.ipv6Available"></a-switch>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>Allow connections to private network addresses (10.x, 192.168.x, etc.)</span>
</template>
Private Network
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-switch v-model="inbound.settings.allowPrivateNetwork"></a-switch>
</a-form-item>
<a-divider :style="{ margin: '10px 0' }">
Clients
<a-button icon="plus" type="primary" size="small"
@click="inbound.settings.addClient()"></a-button>
</a-divider>
<a-form v-for="(client, index) in inbound.settings.clients" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon v-if="inbound.settings.clients.length > 1" type="delete"
@click="() => inbound.settings.delClient(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }">
</a-icon>
</a-divider>
<a-form-item label="Username">
<a-input v-model.trim="client.email"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "password" }}'>
<a-input v-model.trim="client.password"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "enable" }}'>
<a-switch v-model="client.enable"></a-switch>
</a-form-item>
</a-form>
</a-form>
{{end}}

View file

@ -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)

180
web/service/trusttunnel.go Normal file
View file

@ -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
}

View file

@ -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)

View file

@ -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()
}