mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-02-27 20:53:01 +00:00
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:
parent
37f0880f8f
commit
10eab4cb06
11 changed files with 824 additions and 21 deletions
|
|
@ -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
267
trusttunnel/process.go
Normal 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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"}}
|
||||
|
|
|
|||
100
web/html/form/protocol/trusttunnel.html
Normal file
100
web/html/form/protocol/trusttunnel.html
Normal 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}}
|
||||
|
|
@ -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
180
web/service/trusttunnel.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
26
web/web.go
26
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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue