merge fix/settings-trim: dead settings, upstream remnants, JSON/Clash subscriptions removed

# Conflicts:
#	frontend/src/models/setting.ts
#	frontend/src/pages/settings/GeneralTab.tsx
#	frontend/src/pages/settings/SettingsPage.tsx
#	frontend/src/pages/settings/SubscriptionBalancersTab.tsx
#	frontend/src/pages/xray/outbounds/OutboundsTab.css
#	frontend/src/schemas/setting.ts
This commit is contained in:
Muse
2026-10-23 00:39:44 +00:00
109 changed files with 386 additions and 11297 deletions
+1 -2
View File
@@ -82,7 +82,6 @@ func allModels() []any {
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
&model.SubBalancer{},
&model.AgentNode{},
}
}
@@ -1456,7 +1455,7 @@ func clearLegacyProxySettings() error {
}
func normalizeSettingPaths() error {
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
pathKeys := []string{"webBasePath", "subPath"}
var rows []model.Setting
if err := db.Where("key IN ?", pathKeys).Find(&rows).Error; err != nil {
return err
+2 -6
View File
@@ -163,9 +163,7 @@ func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
t.Cleanup(func() { _ = CloseDB() })
seed := []model.Setting{
{Key: "subJsonPath", Value: "YIrCXJOOOL"},
{Key: "subPath", Value: "/sub"},
{Key: "subClashPath", Value: "clash/"},
{Key: "webBasePath", Value: "/panel/"},
}
for i := range seed {
@@ -179,10 +177,8 @@ func TestNormalizeSettingPaths_RepairsLegacyValues(t *testing.T) {
}
want := map[string]string{
"subJsonPath": "/YIrCXJOOOL/",
"subPath": "/sub/",
"subClashPath": "/clash/",
"webBasePath": "/panel/",
"subPath": "/sub/",
"webBasePath": "/panel/",
}
for key, expected := range want {
var row model.Setting
-1
View File
@@ -57,7 +57,6 @@ func migrationModels() []any {
&model.NodeClientIp{},
&model.ClientGlobalTraffic{},
&model.OutboundSubscription{},
&model.SubBalancer{},
&model.AgentNode{},
}
}
-15
View File
@@ -1274,21 +1274,6 @@ type OutboundSubscription struct {
OutboundCount int `json:"outboundCount" gorm:"-"`
}
// SubBalancer is one extra JSON-subscription config document whose members are
// the selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.
type SubBalancer struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
Remark string `json:"remark" form:"remark" validate:"required,max=256" example:"auto-fastest"`
Strategy string `json:"strategy" form:"strategy" validate:"omitempty,oneof=leastLoad leastPing random roundRobin" example:"random"`
InboundIds []int `json:"inboundIds" form:"inboundIds" gorm:"serializer:json;column:inbound_ids" example:"[1,3]"`
SortOrder int `json:"sortOrder" form:"sortOrder" gorm:"column:sort_order" validate:"omitempty,gte=1" example:"1"`
// No gorm default:true — a bool default makes an explicit false at insert
// collapse back to the column default (zero value is skipped).
Enabled bool `json:"enabled" form:"enabled" example:"true"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1710000000000"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1710000000000"`
}
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
var conflicts []ClientMergeConflict
keep := func(field string, oldV, newV, kept any) {
+10 -45
View File
@@ -28,7 +28,7 @@ func TestBuildURLs_NormalizesListenIP(t *testing.T) {
s := &SubService{}
s.PrepareForRequest("127.0.0.1")
subURL, _, _ := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
subURL := s.BuildURLs("/sub/", "ABC")
if strings.Contains(subURL, "127.0.0.1") {
t.Fatalf("listen IP leaked into Copy URL: %q", subURL)
@@ -48,17 +48,11 @@ func TestBuildURLs_UsesSubscriberDomain(t *testing.T) {
s := &SubService{}
s.PrepareForRequest("sub.example.com")
subURL, jsonURL, clashURL := s.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
subURL := s.BuildURLs("/sub/", "ABC")
if subURL != "http://sub.example.com:2096/sub/ABC" {
t.Fatalf("subURL = %q", subURL)
}
if jsonURL != "http://sub.example.com:2096/json/ABC" {
t.Fatalf("jsonURL = %q", jsonURL)
}
if clashURL != "http://sub.example.com:2096/clash/ABC" {
t.Fatalf("clashURL = %q", clashURL)
}
}
// A local wildcard inbound (no node, no custom share address, blank/0.0.0.0
@@ -89,9 +83,9 @@ func TestBuildURLs_EmptySubId(t *testing.T) {
initSubDB(t)
s := &SubService{}
s.PrepareForRequest("sub.example.com")
a, b, c := s.BuildURLs("/sub/", "/json/", "/clash/", "")
if a != "" || b != "" || c != "" {
t.Fatalf("empty subId must yield empty URLs, got %q %q %q", a, b, c)
a := s.BuildURLs("/sub/", "")
if a != "" {
t.Fatalf("empty subId must yield an empty URL, got %q", a)
}
}
@@ -106,8 +100,8 @@ func TestForRequestDoesNotMutateSharedService(t *testing.T) {
t.Fatalf("ForRequest mutated the shared service: address=%q nodes=%v", base.address, base.nodesByID)
}
firstURL, _, _ := first.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
secondURL, _, _ := second.BuildURLs("/sub/", "/json/", "/clash/", "ABC")
firstURL := first.BuildURLs("/sub/", "ABC")
secondURL := second.BuildURLs("/sub/", "ABC")
if !strings.Contains(firstURL, "first.example.com") {
t.Fatalf("first request URL = %q, want first.example.com", firstURL)
}
@@ -117,9 +111,8 @@ func TestForRequestDoesNotMutateSharedService(t *testing.T) {
}
// A subscriber arriving via a reverse proxy (subURI configured with full
// HTTPS URL) must see the same scheme+host in the JSON and Clash Copy
// URLs as in the main subURL — not the raw sub-server port 2096.
func TestBuildURLs_DerivesJsonFromConfiguredSubURI(t *testing.T) {
// HTTPS URL) gets that exact base in the Copy URL.
func TestBuildURLs_UsesConfiguredSubURI(t *testing.T) {
initSubDB(t)
s := &SubService{}
s.PrepareForRequest("sub.example.com")
@@ -129,37 +122,9 @@ func TestBuildURLs_DerivesJsonFromConfiguredSubURI(t *testing.T) {
"INSERT INTO settings (key, value) VALUES (?, ?)",
"subURI", "https://example.com/sub-xxx/")
subURL, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
subURL := s.BuildURLs("/sub-xxx/", "ABC")
if subURL != "https://example.com/sub-xxx/ABC" {
t.Fatalf("subURL = %q", subURL)
}
if jsonURL != "https://example.com/json/ABC" {
t.Fatalf("jsonURL = %q (should derive scheme+host from subURI), want %q", jsonURL, "https://example.com/json/ABC")
}
if clashURL != "https://example.com/clash/ABC" {
t.Fatalf("clashURL = %q (should derive scheme+host from subURI), want %q", clashURL, "https://example.com/clash/ABC")
}
}
// A malformed subURI (no scheme/host) must not leak a broken base into the
// JSON/Clash URLs; BuildURLs should fall back to the request-derived base.
func TestBuildURLs_MalformedSubURIFallsBackToRequestBase(t *testing.T) {
initSubDB(t)
s := &SubService{}
s.PrepareForRequest("sub.example.com")
// A value with no scheme can't yield a usable scheme+host.
database.GetDB().Exec(
"INSERT INTO settings (key, value) VALUES (?, ?)",
"subURI", "example.com/sub-xxx/")
_, jsonURL, clashURL := s.BuildURLs("/sub-xxx/", "/json/", "/clash/", "ABC")
if jsonURL != "http://sub.example.com:2096/json/ABC" {
t.Fatalf("jsonURL = %q, want fallback to request base %q", jsonURL, "http://sub.example.com:2096/json/ABC")
}
if clashURL != "http://sub.example.com:2096/clash/ABC" {
t.Fatalf("clashURL = %q, want fallback to request base %q", clashURL, "http://sub.example.com:2096/clash/ABC")
}
}
-238
View File
@@ -1,238 +0,0 @@
package sub
import (
"fmt"
"strconv"
"strings"
)
// clashProxyFromExternal parses a pasted share link and converts it into a
// mihomo/Clash proxy entry named `name`. Returns nil for links Clash can't
// represent (the entry is then skipped, mirroring how getProxies drops
// unsupported inbound protocols). vmess/vless/trojan reuse the existing
// applyTransport/applySecurity helpers; ss/hysteria2/wireguard map directly.
func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[string]any {
ob := parseExternalLink(rawLink)
if ob == nil {
return nil
}
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
stream, _ := ob["streamSettings"].(map[string]any)
if stream == nil {
stream = map[string]any{}
}
if settings == nil {
return nil
}
proxy := map[string]any{"name": name, "udp": true}
switch protocol {
case "vmess":
vnext, _ := settings["vnext"].([]any)
if len(vnext) == 0 {
return nil
}
vn, _ := vnext[0].(map[string]any)
users, _ := vn["users"].([]any)
if vn == nil || len(users) == 0 {
return nil
}
user, _ := users[0].(map[string]any)
proxy["type"] = "vmess"
proxy["server"] = fmt.Sprint(vn["address"])
proxy["port"] = clashInt(vn["port"])
proxy["uuid"] = fmt.Sprint(user["id"])
proxy["alterId"] = 0
cipher, _ := user["security"].(string)
if cipher == "" {
cipher = "auto"
}
proxy["cipher"] = cipher
case "vless":
proxy["type"] = "vless"
proxy["server"] = fmt.Sprint(settings["address"])
proxy["port"] = clashInt(settings["port"])
proxy["uuid"] = fmt.Sprint(settings["id"])
if flow, _ := settings["flow"].(string); flow != "" {
proxy["flow"] = flow
}
case "trojan":
server := firstServer(settings)
if server == nil {
return nil
}
proxy["type"] = "trojan"
proxy["server"] = fmt.Sprint(server["address"])
proxy["port"] = clashInt(server["port"])
proxy["password"] = fmt.Sprint(server["password"])
case "shadowsocks":
server := firstServer(settings)
if server == nil {
server = settings
}
method, _ := server["method"].(string)
if method == "" {
return nil
}
proxy["type"] = "ss"
proxy["server"] = fmt.Sprint(server["address"])
proxy["port"] = clashInt(server["port"])
proxy["cipher"] = method
proxy["password"] = fmt.Sprint(server["password"])
return proxy
case "hysteria":
return clashHysteriaFromExternal(settings, stream, name)
case "wireguard":
return clashWireguardFromExternal(settings, name)
default:
return nil
}
network, _ := stream["network"].(string)
if !s.applyTransport(proxy, network, stream) {
return nil
}
security, _ := stream["security"].(string)
if !s.applySecurity(proxy, security, stream) {
return nil
}
return proxy
}
func firstServer(settings map[string]any) map[string]any {
servers, _ := settings["servers"].([]any)
if len(servers) == 0 {
return nil
}
server, _ := servers[0].(map[string]any)
return server
}
func clashHysteriaFromExternal(settings, stream map[string]any, name string) map[string]any {
hy, _ := stream["hysteriaSettings"].(map[string]any)
auth := ""
if hy != nil {
auth, _ = hy["auth"].(string)
}
if auth == "" {
return nil
}
proxy := map[string]any{
"name": name,
"type": "hysteria2",
"server": fmt.Sprint(settings["address"]),
"port": clashInt(settings["port"]),
"password": auth,
"udp": true,
}
if tls, _ := stream["tlsSettings"].(map[string]any); tls != nil {
if sni, _ := tls["serverName"].(string); sni != "" {
proxy["sni"] = sni
}
if alpn := clashStringList(tls["alpn"]); len(alpn) > 0 {
proxy["alpn"] = alpn
}
if fp, _ := tls["fingerprint"].(string); fp != "" {
proxy["client-fingerprint"] = fp
}
}
return proxy
}
func clashWireguardFromExternal(settings map[string]any, name string) map[string]any {
peers, _ := settings["peers"].([]any)
if len(peers) == 0 {
return nil
}
peer, _ := peers[0].(map[string]any)
if peer == nil {
return nil
}
host, port := splitClashHostPort(fmt.Sprint(peer["endpoint"]))
if host == "" || port == 0 {
return nil
}
proxy := map[string]any{
"name": name,
"type": "wireguard",
"server": host,
"port": port,
"udp": true,
}
if sk, _ := settings["secretKey"].(string); sk != "" {
proxy["private-key"] = sk
}
if pk, _ := peer["publicKey"].(string); pk != "" {
proxy["public-key"] = pk
}
if psk, _ := peer["preSharedKey"].(string); psk != "" {
proxy["pre-shared-key"] = psk
}
for _, addr := range clashStringList(settings["address"]) {
ip := stripCIDR(addr)
if strings.Contains(ip, ":") {
proxy["ipv6"] = ip
} else {
proxy["ip"] = ip
}
}
return proxy
}
func clashInt(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
case string:
n, _ := strconv.Atoi(x)
return n
default:
return 0
}
}
func clashStringList(v any) []string {
switch x := v.(type) {
case []any:
out := make([]string, 0, len(x))
for _, item := range x {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
return out
case []string:
return x
case string:
if x == "" {
return nil
}
return strings.Split(x, ",")
default:
return nil
}
}
func stripCIDR(addr string) string {
if before, _, ok := strings.Cut(addr, "/"); ok {
return before
}
return addr
}
func splitClashHostPort(endpoint string) (string, int) {
endpoint = strings.TrimSpace(endpoint)
i := strings.LastIndex(endpoint, ":")
if i < 0 {
return endpoint, 0
}
host := strings.Trim(endpoint[:i], "[]")
port, _ := strconv.Atoi(endpoint[i+1:])
return host, port
}
-774
View File
@@ -1,774 +0,0 @@
package sub
import (
"fmt"
"maps"
"strings"
"github.com/goccy/go-json"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
type SubClashService struct {
SubService *SubService
}
func NewSubClashService(subService *SubService) *SubClashService {
return &SubClashService{SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
subReq := s.SubService.ForRequest(host)
subReq.subscriptionBody = true
inbounds, err := subReq.getInboundsBySubId(subId)
if err != nil {
return "", "", err
}
externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
if err != nil {
return "", "", err
}
if len(inbounds) == 0 && len(externalLinks) == 0 {
return "", "", nil
}
var proxies []map[string]any
seenEmails := make(map[string]struct{})
for _, inbound := range inbounds {
clients := subReq.matchingClients(inbound, subId)
if len(clients) == 0 {
continue
}
subReq.projectThroughFallbackMaster(inbound)
if hostEps := subReq.hostEndpoints(inbound, "clash"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
}
for _, client := range clients {
seenEmails[client.Email] = struct{}{}
proxies = append(proxies, s.getProxies(subReq, inbound, client, host)...)
}
}
for _, ext := range externalLinks {
for _, el := range expandEntry(ext) {
name := el.Name
if name == "" {
name = ext.Email
}
if proxy := s.clashProxyFromExternal(el.Link, name); proxy != nil {
seenEmails[ext.Email] = struct{}{}
proxies = append(proxies, proxy)
}
}
}
if len(proxies) == 0 {
return "", "", nil
}
ensureUniqueProxyNames(proxies)
emails := make([]string, 0, len(seenEmails))
for e := range seenEmails {
emails = append(emails, e)
}
traffic, _ := subReq.AggregateTrafficByEmails(emails)
proxyNames := make([]string, 0, len(proxies)+1)
for _, proxy := range proxies {
if name, ok := proxy["name"].(string); ok && name != "" {
proxyNames = append(proxyNames, name)
}
}
proxyNames = append(proxyNames, "DIRECT")
config := map[string]any{
"proxies": proxies,
"proxy-groups": []map[string]any{{
"name": "PROXY",
"type": "select",
"proxies": proxyNames,
}},
"rules": []string{"MATCH,PROXY"},
}
finalYAML, err := marshalClashYAML(config)
if err != nil {
return "", "", err
}
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
return string(finalYAML), header, nil
}
// ensureUniqueProxyNames keeps every proxy "name" non-empty and unique:
// mihomo rejects the whole config on a duplicate name (the empty string
// genRemark returns for a remark-less inbound counts), vanishing the Clash
// profile on refresh. See issue #4641.
func ensureUniqueProxyNames(proxies []map[string]any) {
seen := make(map[string]struct{}, len(proxies))
for i, proxy := range proxies {
base, _ := proxy["name"].(string)
if base == "" {
base = fallbackProxyName(proxy, i)
}
name := base
for n := 2; ; n++ {
if _, dup := seen[name]; !dup {
break
}
name = fmt.Sprintf("%s-%d", base, n)
}
seen[name] = struct{}{}
proxy["name"] = name
}
}
func fallbackProxyName(proxy map[string]any, idx int) string {
typ, _ := proxy["type"].(string)
server, _ := proxy["server"].(string)
if typ != "" && server != "" {
return fmt.Sprintf("%s-%s-%v", typ, server, proxy["port"])
}
return fmt.Sprintf("proxy-%d", idx+1)
}
func (s *SubClashService) getProxies(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []map[string]any {
stream := s.streamData(inbound.StreamSettings)
// For node-managed inbounds the Clash proxy "server" must be the
// node's address, not the request host. resolveInboundAddress handles
// the node→subscriber-host fallback chain.
defaultDest := subReq.resolveInboundAddress(inbound)
if defaultDest == "" {
defaultDest = host
}
externalProxies, ok := stream["externalProxy"].([]any)
hasExternalProxy := ok && len(externalProxies) > 0
if !hasExternalProxy {
externalProxies = []any{map[string]any{
"forceTls": "same",
"dest": defaultDest,
"port": float64(inbound.Port),
"remark": "",
}}
}
delete(stream, "externalProxy")
network, _ := stream["network"].(string)
proxies := make([]map[string]any, 0, len(externalProxies))
for _, ep := range externalProxies {
extPrxy, ok := ep.(map[string]any)
if !ok {
continue
}
// Expand the host's {{VAR}} remark template for this client (no-op for
// the synthetic/legacy entry) before it becomes the proxy name.
subReq.renderHostRemark(inbound, client, extPrxy, network)
workingInbound := *inbound
workingInbound.Listen, _ = extPrxy["dest"].(string)
if port, ok := extPrxy["port"].(float64); ok {
workingInbound.Port = int(port)
}
workingStream := cloneStreamForExternalProxy(stream)
forceTls, _ := extPrxy["forceTls"].(string)
switch forceTls {
case "tls":
if workingStream["security"] != "tls" {
workingStream["security"] = "tls"
workingStream["tlsSettings"] = map[string]any{}
}
case "none":
if workingStream["security"] != "none" {
workingStream["security"] = "none"
delete(workingStream, "tlsSettings")
delete(workingStream, "realitySettings")
}
}
security, _ := workingStream["security"].(string)
if hasExternalProxy {
applyExternalProxyTLSToStream(extPrxy, workingStream, security)
}
applyHostStreamOverrides(extPrxy, workingStream)
proxy := s.buildProxy(subReq, &workingInbound, client, workingStream, extPrxy)
if len(proxy) > 0 {
// Host-only mihomo knob: ip-version is a top-level proxy field, set
// last so it cannot be clobbered. Absent for legacy externalProxy.
if v, _ := extPrxy["mihomoIpVersion"].(string); v != "" {
proxy["ip-version"] = v
}
proxies = append(proxies, proxy)
}
}
return proxies
}
func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound, client model.Client, stream map[string]any, ep map[string]any) map[string]any {
// Hysteria has its own transport + TLS model, applyTransport /
// applySecurity don't fit.
if inbound.Protocol == model.Hysteria {
return s.buildHysteriaProxy(subReq, inbound, client, ep)
}
if inbound.Protocol == model.WireGuard {
return s.buildWireguardProxy(subReq, inbound, client, ep)
}
network, _ := stream["network"].(string)
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, network),
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
}
if !s.applyTransport(proxy, network, stream) {
return nil
}
switch inbound.Protocol {
case model.VMESS:
proxy["type"] = "vmess"
proxy["uuid"] = client.ID
proxy["alterId"] = 0
proxy["cipher"] = normalizeVmessSecurity(client.Security)
case model.VLESS:
proxy["type"] = "vless"
proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
inboundSettings := subReq.linkSettings(inbound)
streamSecurity, _ := stream["security"].(string)
if client.Flow != "" && !inbound.DisableFlow && vlessFlowAllowed(network, streamSecurity, inboundSettings) {
proxy["flow"] = client.Flow
}
if encryption, ok := inboundSettings["encryption"].(string); ok {
encryption = strings.TrimSpace(encryption)
if encryption != "" && encryption != "none" {
proxy["encryption"] = encryption
}
}
case model.Trojan:
proxy["type"] = "trojan"
proxy["password"] = client.Password
case model.Shadowsocks:
proxy["type"] = "ss"
proxy["password"] = client.Password
inboundSettings := subReq.linkSettings(inbound)
method, _ := inboundSettings["method"].(string)
if method == "" {
return nil
}
proxy["cipher"] = method
if strings.HasPrefix(method, "2022") {
if serverPassword, ok := inboundSettings["password"].(string); ok && serverPassword != "" {
proxy["password"] = fmt.Sprintf("%s:%s", serverPassword, client.Password)
}
}
default:
return nil
}
security, _ := stream["security"].(string)
if !s.applySecurity(proxy, security, stream) {
return nil
}
return proxy
}
// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
// directly instead of going through streamData/tlsData, because those
// helpers prune fields (like `allowInsecure` / the salamander obfs
// block) that the hysteria proxy wants preserved.
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
inboundSettings := subReq.linkSettings(inbound)
proxyType := "hysteria2"
authKey := "password"
if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
proxyType = "hysteria"
authKey = "auth-str"
}
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, "quic"),
"type": proxyType,
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
authKey: client.Auth,
}
var rawStream map[string]any
_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
// TLS details — hysteria always uses TLS.
if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
proxy["sni"] = serverName
}
if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
out := make([]string, 0, len(alpnList))
for _, a := range alpnList {
if s, ok := a.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
proxy["alpn"] = out
}
}
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
proxy["skip-cert-verify"] = true
}
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
proxy["client-fingerprint"] = fp
}
}
}
if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
proxy["skip-cert-verify"] = true
}
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
// block the subscription link generator uses.
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
if udpMasks, ok := finalmask["udp"].([]any); ok {
for _, m := range udpMasks {
mask, _ := m.(map[string]any)
if mask == nil || mask["type"] != "salamander" {
continue
}
settings, _ := mask["settings"].(map[string]any)
if pw, ok := settings["password"].(string); ok && pw != "" {
proxy["obfs"] = "salamander"
proxy["obfs-password"] = pw
break
}
}
}
}
// UDP port hopping. mihomo reads the range from a dedicated `ports`
// field (the base `port` stays as the redirect target).
if hopPorts := hysteriaHopPorts(rawStream); hopPorts != "" {
proxy["ports"] = hopPorts
}
return proxy
}
// buildWireguardProxy produces a mihomo-compatible Clash entry for a native
// WireGuard inbound, mirroring genWireguardLink: the peer public key is derived
// from the inbound secretKey, while the private key, tunnel address, and
// pre-shared key come from the client. Returns nil when the client has no key.
func (s *SubClashService) buildWireguardProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
if client.PrivateKey == "" {
return nil
}
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
secretKey, _ := inboundSettings["secretKey"].(string)
proxy := map[string]any{
"name": subReq.endpointRemark(inbound, client.Email, ep, ""),
"type": "wireguard",
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
"private-key": client.PrivateKey,
}
if secretKey != "" {
if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
proxy["public-key"] = pub
}
}
if client.PreSharedKey != "" {
proxy["pre-shared-key"] = client.PreSharedKey
}
if client.KeepAlive > 0 {
proxy["persistent-keepalive"] = client.KeepAlive
}
for _, addr := range client.AllowedIPs {
ip := stripCIDR(addr)
if ip == "" {
continue
}
if strings.Contains(ip, ":") {
proxy["ipv6"] = ip
} else {
proxy["ip"] = ip
}
}
if mtu, ok := inboundSettings["mtu"].(float64); ok && mtu > 0 {
proxy["mtu"] = int(mtu)
}
if dns, _ := inboundSettings["dns"].(string); dns != "" {
servers := make([]string, 0)
for server := range strings.SplitSeq(dns, ",") {
if server = strings.TrimSpace(server); server != "" {
servers = append(servers, server)
}
}
if len(servers) > 0 {
proxy["dns"] = servers
}
}
return proxy
}
// buildXhttpClashOpts converts xhttpSettings from 3x-ui's camelCase JSON
// storage into the kebab-case map that Mihomo expects under xhttp-opts.
//
// Only client-relevant fields are included (allowlist approach).
// Server-only fields (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
// serverMaxHeaderBytes) are automatically excluded because they are not in
// the mapping. This is intentional — when Mihomo adds new fields, the mapping
// must be updated explicitly rather than leaking unverified fields to clients.
//
// Returns nil if no non-trivial fields are present.
func buildXhttpClashOpts(xhttp map[string]any) map[string]any {
if xhttp == nil {
return nil
}
opts := map[string]any{}
// Direct fields: path, mode
if v, ok := xhttp["path"].(string); ok && v != "" {
opts["path"] = v
}
if v, ok := xhttp["mode"].(string); ok && v != "" {
opts["mode"] = v
}
// Host: explicit host field wins, then fall back to headers.Host
host := ""
if v, ok := xhttp["host"].(string); ok && v != "" {
host = v
} else if headers, ok := xhttp["headers"].(map[string]any); ok {
host = searchHost(headers)
}
if host != "" {
opts["host"] = host
}
type xhttpStringField struct{ src, dst, skipValue string }
stringFields := []xhttpStringField{
{"xPaddingBytes", "x-padding-bytes", ""},
{"uplinkHTTPMethod", "uplink-http-method", ""},
{"sessionIDPlacement", "session-id-placement", ""},
{"sessionIDKey", "session-id-key", ""},
{"sessionIDTable", "session-id-table", ""},
{"sessionIDLength", "session-id-length", ""},
{"seqPlacement", "seq-placement", ""},
{"seqKey", "seq-key", ""},
{"uplinkDataPlacement", "uplink-data-placement", ""},
{"uplinkDataKey", "uplink-data-key", ""},
{"scMaxEachPostBytes", "sc-max-each-post-bytes", "1000000"},
{"scMinPostsIntervalMs", "sc-min-posts-interval-ms", "30"},
}
for _, f := range stringFields {
if v, ok := xhttp[f.src].(string); ok && v != "" && (f.skipValue == "" || v != f.skipValue) {
opts[f.dst] = v
}
}
// Legacy inbounds (pre xray-core #6258) stored sessionPlacement/sessionKey.
// Fall back to them so not-yet-resaved configs still map. Mirrors the
// frontend migration.
for _, f := range []xhttpStringField{
{"sessionPlacement", "session-id-placement", ""},
{"sessionKey", "session-id-key", ""},
} {
if _, exists := opts[f.dst]; exists {
continue
}
if v, ok := xhttp[f.src].(string); ok && v != "" {
opts[f.dst] = v
}
}
// Bool fields (truthy only)
if v, ok := xhttp["noGRPCHeader"].(bool); ok && v {
opts["no-grpc-header"] = true
}
if v, ok := xhttp["xPaddingObfsMode"].(bool); ok && v {
opts["x-padding-obfs-mode"] = true
// Padding obfs gated fields
for _, field := range []struct{ src, dst string }{
{"xPaddingKey", "x-padding-key"},
{"xPaddingHeader", "x-padding-header"},
{"xPaddingPlacement", "x-padding-placement"},
{"xPaddingMethod", "x-padding-method"},
} {
if v, ok := xhttp[field.src].(string); ok && v != "" {
opts[field.dst] = v
}
}
}
// Non-zero value fields
if v, ok := nonZeroShareValue(xhttp["uplinkChunkSize"]); ok {
opts["uplink-chunk-size"] = v
}
// Nested object: xmux → reuse-settings
if xmux, ok := xhttp["xmux"].(map[string]any); ok && len(xmux) > 0 {
reuse := map[string]any{}
for _, f := range []struct{ src, dst string }{
{"maxConcurrency", "max-concurrency"},
{"maxConnections", "max-connections"},
{"cMaxReuseTimes", "c-max-reuse-times"},
{"hMaxRequestTimes", "h-max-request-times"},
{"hMaxReusableSecs", "h-max-reusable-secs"},
} {
if v, ok := xmux[f.src].(string); ok && v != "" {
reuse[f.dst] = v
}
}
if v, ok := nonZeroShareValue(xmux["hKeepAlivePeriod"]); ok {
reuse["h-keep-alive-period"] = v
}
if len(reuse) > 0 {
opts["reuse-settings"] = reuse
}
}
// Headers (drop Host key)
if rawHeaders, ok := xhttp["headers"].(map[string]any); ok && len(rawHeaders) > 0 {
out := map[string]any{}
for k, v := range rawHeaders {
if strings.EqualFold(k, "host") {
continue
}
out[k] = v
}
if len(out) > 0 {
opts["headers"] = out
}
}
if len(opts) == 0 {
return nil
}
return opts
}
func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
switch network {
case "", "tcp":
proxy["network"] = "tcp"
tcp, _ := stream["tcpSettings"].(map[string]any)
if tcp != nil {
header, _ := tcp["header"].(map[string]any)
if header != nil {
typeStr, _ := header["type"].(string)
if typeStr != "" && typeStr != "none" {
return false
}
}
}
return true
case "ws":
proxy["network"] = "ws"
ws, _ := stream["wsSettings"].(map[string]any)
wsOpts := map[string]any{}
if ws != nil {
if path, ok := ws["path"].(string); ok && path != "" {
wsOpts["path"] = path
}
host := ""
if v, ok := ws["host"].(string); ok && v != "" {
host = v
} else if headers, ok := ws["headers"].(map[string]any); ok {
host = searchHost(headers)
}
if host != "" {
wsOpts["headers"] = map[string]any{"Host": host}
}
}
if len(wsOpts) > 0 {
proxy["ws-opts"] = wsOpts
}
return true
case "grpc":
proxy["network"] = "grpc"
grpc, _ := stream["grpcSettings"].(map[string]any)
grpcOpts := map[string]any{}
if grpc != nil {
if serviceName, ok := grpc["serviceName"].(string); ok && serviceName != "" {
grpcOpts["grpc-service-name"] = serviceName
}
}
if len(grpcOpts) > 0 {
proxy["grpc-opts"] = grpcOpts
}
return true
case "httpupgrade":
proxy["network"] = "httpupgrade"
hu, _ := stream["httpupgradeSettings"].(map[string]any)
opts := map[string]any{}
if hu != nil {
if path, ok := hu["path"].(string); ok && path != "" {
opts["path"] = path
}
host := ""
if v, ok := hu["host"].(string); ok && v != "" {
host = v
} else if headers, ok := hu["headers"].(map[string]any); ok {
host = searchHost(headers)
}
if host != "" {
opts["headers"] = map[string]any{"Host": host}
}
}
if len(opts) > 0 {
proxy["http-upgrade-opts"] = opts
}
return true
case "xhttp":
proxy["network"] = "xhttp"
xhttp, _ := stream["xhttpSettings"].(map[string]any)
opts := buildXhttpClashOpts(xhttp)
if opts != nil {
proxy["xhttp-opts"] = opts
}
return true
default:
return false
}
}
func (s *SubClashService) applySecurity(proxy map[string]any, security string, stream map[string]any) bool {
switch security {
case "", "none":
proxy["tls"] = false
return true
case "tls":
proxy["tls"] = true
tlsSettings, _ := stream["tlsSettings"].(map[string]any)
if tlsSettings != nil {
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
proxy["servername"] = serverName
switch proxy["type"] {
case "trojan":
proxy["sni"] = serverName
}
}
if fingerprint, ok := tlsSettings["fingerprint"].(string); ok && fingerprint != "" {
proxy["client-fingerprint"] = fingerprint
}
if alpn, ok := externalProxyALPNList(tlsSettings["alpn"]); ok {
out := make([]string, 0, len(alpn))
for _, item := range alpn {
if s, ok := item.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
proxy["alpn"] = out
}
}
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
proxy["skip-cert-verify"] = true
}
}
if pins, ok := tlsSettings["pin-sha256"].([]any); ok && len(pins) > 0 {
proxy["pin-sha256"] = pins
}
}
return true
case "reality":
proxy["tls"] = true
realitySettings, _ := stream["realitySettings"].(map[string]any)
if realitySettings == nil {
return false
}
if serverName, ok := realitySettings["serverName"].(string); ok && serverName != "" {
proxy["servername"] = serverName
}
realityOpts := map[string]any{}
if publicKey, ok := realitySettings["publicKey"].(string); ok && publicKey != "" {
realityOpts["public-key"] = publicKey
}
if shortID, ok := realitySettings["shortId"].(string); ok && shortID != "" {
realityOpts["short-id"] = shortID
}
if len(realityOpts) > 0 {
proxy["reality-opts"] = realityOpts
}
if fingerprint, ok := realitySettings["fingerprint"].(string); ok && fingerprint != "" {
proxy["client-fingerprint"] = fingerprint
}
return true
default:
return false
}
}
func (s *SubClashService) streamData(stream string) map[string]any {
var streamSettings map[string]any
_ = json.Unmarshal([]byte(stream), &streamSettings)
security, _ := streamSettings["security"].(string)
switch security {
case "tls":
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
}
case "reality":
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
streamSettings["realitySettings"] = s.realityData(realitySettings)
}
}
delete(streamSettings, "sockopt")
return streamSettings
}
func (s *SubClashService) tlsData(tData map[string]any) map[string]any {
tlsData := make(map[string]any, 1)
tlsClientSettings, _ := tData["settings"].(map[string]any)
tlsData["serverName"] = tData["serverName"]
tlsData["alpn"] = tData["alpn"]
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
tlsData["fingerprint"] = fingerprint
}
if pins, ok := tlsClientSettings["pinnedPeerCertSha256"].([]any); ok && len(pins) > 0 {
tlsData["pin-sha256"] = pins
}
return tlsData
}
func (s *SubClashService) realityData(rData map[string]any) map[string]any {
rDataOut := make(map[string]any, 1)
realityClientSettings, _ := rData["settings"].(map[string]any)
if publicKey, ok := realityClientSettings["publicKey"].(string); ok {
rDataOut["publicKey"] = publicKey
}
if fingerprint, ok := realityClientSettings["fingerprint"].(string); ok {
rDataOut["fingerprint"] = fingerprint
}
if serverNames, ok := rData["serverNames"].([]any); ok && len(serverNames) > 0 {
rDataOut["serverName"] = fmt.Sprint(serverNames[0])
}
if shortIDs, ok := rData["shortIds"].([]any); ok && len(shortIDs) > 0 {
rDataOut["shortId"] = fmt.Sprint(shortIDs[0])
}
return rDataOut
}
func cloneMap(src map[string]any) map[string]any {
if src == nil {
return nil
}
dst := make(map[string]any, len(src))
maps.Copy(dst, src)
return dst
}
-885
View File
@@ -1,885 +0,0 @@
package sub
import (
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func TestEnsureUniqueProxyNames(t *testing.T) {
proxies := []map[string]any{
{"name": "", "type": "vless", "server": "a.com", "port": 443},
{"name": "", "type": "vmess", "server": "b.com", "port": 8443},
{"name": "node"},
{"name": "node"},
{"name": ""},
}
ensureUniqueProxyNames(proxies)
seen := map[string]bool{}
for i, p := range proxies {
name, _ := p["name"].(string)
if name == "" {
t.Fatalf("proxy %d still has an empty name (mihomo would reject the config, #4641)", i)
}
if seen[name] {
t.Fatalf("proxy %d has duplicate name %q (mihomo rejects the whole config, #4641)", i, name)
}
seen[name] = true
}
if got := proxies[0]["name"]; got != "vless-a.com-443" {
t.Errorf("empty name fallback = %q, want vless-a.com-443", got)
}
if proxies[2]["name"] == proxies[3]["name"] {
t.Errorf("duplicate %q was not disambiguated", proxies[2]["name"])
}
if got := proxies[4]["name"]; got != "proxy-5" {
t.Errorf("typeless empty name fallback = %q, want proxy-5", got)
}
}
// TestBuildProxy_VLESSRealityFieldsForClash locks the reality field mapping in
// applySecurity (clash_service.go ~488): a regression that drops servername,
// public-key, short-id, or client-fingerprint would hand mihomo a broken reality
// proxy. The existing clash tests don't assert any of these.
func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{Listen: "203.0.113.1", Port: 443, Protocol: model.VLESS, Remark: "r", Settings: `{"encryption":"none"}`}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "reality",
"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}},
"realitySettings": map[string]any{"serverName": "reality.example.com", "publicKey": "PBKvalue", "shortId": "ab12", "fingerprint": "chrome"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid reality stream")
}
if proxy["tls"] != true {
t.Fatalf("tls = %v, want true", proxy["tls"])
}
if proxy["servername"] != "reality.example.com" {
t.Fatalf("servername = %v, want reality.example.com", proxy["servername"])
}
if proxy["client-fingerprint"] != "chrome" {
t.Fatalf("client-fingerprint = %v, want chrome", proxy["client-fingerprint"])
}
opts, _ := proxy["reality-opts"].(map[string]any)
if opts == nil {
t.Fatal("reality-opts missing")
}
if opts["public-key"] != "PBKvalue" {
t.Fatalf("public-key = %v, want PBKvalue", opts["public-key"])
}
if opts["short-id"] != "ab12" {
t.Fatalf("short-id = %v, want ab12", opts["short-id"])
}
}
// TestApplyTransport_TCPHeader pins the tcp-header validation (clash_service.go ~359):
// plain tcp and a "none" header are representable in clash; a non-none obfs header is
// not, so applyTransport must reject it (returning false drops it from the YAML).
func TestApplyTransport_TCPHeader(t *testing.T) {
svc := &SubClashService{}
if !svc.applyTransport(map[string]any{}, "tcp", map[string]any{}) {
t.Fatal("plain tcp must be buildable")
}
noneStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "none"}}}
if !svc.applyTransport(map[string]any{}, "tcp", noneStream) {
t.Fatal("tcp + header type none must be buildable")
}
httpStream := map[string]any{"tcpSettings": map[string]any{"header": map[string]any{"type": "http"}}}
if svc.applyTransport(map[string]any{}, "tcp", httpStream) {
t.Fatal("tcp + non-none (http) header is not representable in clash and must be rejected")
}
}
func TestApplyTransport_XHTTP(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{
"xhttpSettings": map[string]any{
"path": "/xh",
"host": "example.com",
"mode": "auto",
},
}
if !svc.applyTransport(proxy, "xhttp", stream) {
t.Fatalf("applyTransport returned false for xhttp (#4531: would drop the inbound and yield an empty Clash YAML)")
}
if proxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", proxy["network"])
}
opts, ok := proxy["xhttp-opts"].(map[string]any)
if !ok {
t.Fatalf("xhttp-opts missing or wrong type: %#v", proxy["xhttp-opts"])
}
want := map[string]any{"path": "/xh", "host": "example.com", "mode": "auto"}
if !reflect.DeepEqual(opts, want) {
t.Fatalf("xhttp-opts = %#v, want %#v", opts, want)
}
}
func TestApplyTransport_XHTTP_HostFromHeaders(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{
"xhttpSettings": map[string]any{
"path": "/xh",
"headers": map[string]any{"Host": "via-header.example.com"},
},
}
if !svc.applyTransport(proxy, "xhttp", stream) {
t.Fatalf("applyTransport returned false for xhttp")
}
opts, _ := proxy["xhttp-opts"].(map[string]any)
if opts["host"] != "via-header.example.com" {
t.Fatalf("host should fall back to headers.Host, got %v", opts["host"])
}
}
func TestApplyTransport_XHTTP_NoSettings(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{}
if !svc.applyTransport(proxy, "xhttp", stream) {
t.Fatalf("applyTransport returned false for xhttp with no xhttpSettings")
}
if proxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", proxy["network"])
}
if _, exists := proxy["xhttp-opts"]; exists {
t.Fatalf("xhttp-opts should be absent when xhttpSettings is missing, got %#v", proxy["xhttp-opts"])
}
}
func TestApplyTransport_HTTPUpgrade(t *testing.T) {
svc := &SubClashService{}
proxy := map[string]any{}
stream := map[string]any{
"httpupgradeSettings": map[string]any{
"path": "/hu",
"host": "example.com",
},
}
if !svc.applyTransport(proxy, "httpupgrade", stream) {
t.Fatalf("applyTransport returned false for httpupgrade")
}
if proxy["network"] != "httpupgrade" {
t.Fatalf("network = %v, want httpupgrade", proxy["network"])
}
opts, ok := proxy["http-upgrade-opts"].(map[string]any)
if !ok {
t.Fatalf("http-upgrade-opts missing: %#v", proxy["http-upgrade-opts"])
}
if opts["path"] != "/hu" {
t.Fatalf("path = %v, want /hu", opts["path"])
}
headers, _ := opts["headers"].(map[string]any)
if headers["Host"] != "example.com" {
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
}
}
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
encryption := "mlkem768x25519plus.native.0rtt.client"
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "pq",
Settings: `{"encryption":"` + encryption + `"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{
"path": "/",
"mode": "auto",
},
"security": "reality",
"realitySettings": map[string]any{
"publicKey": "pub",
"serverName": "example.com",
"shortId": "abcd",
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if proxy["encryption"] != encryption {
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
}
}
func TestBuildProxy_VLESSFlowXhttpRealityVlessenc(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
encryption := "mlkem768x25519plus.native.0rtt.client"
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "pq-flow",
Settings: `{"encryption":"` + encryption + `"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{
"path": "/",
"mode": "auto",
},
"security": "reality",
"realitySettings": map[string]any{
"publicKey": "pub",
"serverName": "example.com",
"shortId": "abcd",
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if proxy["flow"] != "xtls-rprx-vision" {
t.Fatalf("xhttp+reality+vlessenc Clash proxy must carry the vision flow (#5232): %#v", proxy)
}
}
func TestBuildProxy_VLESSFlowSuppressedByDisableFlow(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "disabled-flow",
Settings: `{"encryption":"` + testMlkemEncryption + `"}`,
DisableFlow: true,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
stream := map[string]any{
"network": "xhttp",
"xhttpSettings": map[string]any{"path": "/", "mode": "auto"},
"security": "reality",
"realitySettings": map[string]any{"publicKey": "pub", "serverName": "example.com", "shortId": "abcd"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if _, ok := proxy["flow"]; ok {
t.Fatalf("DisableFlow inbound must not carry a flow in the Clash proxy: %#v", proxy)
}
}
func TestBuildProxy_VLESSFlowDroppedWithoutVisionSupport(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "plain-flow",
Settings: `{"encryption":"none"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555", Flow: "xtls-rprx-vision"}
stream := map[string]any{
"network": "tcp",
"security": "none",
"tcpSettings": map[string]any{
"header": map[string]any{"type": "none"},
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if _, ok := proxy["flow"]; ok {
t.Fatalf("tcp without tls/reality must not carry a flow: %#v", proxy)
}
}
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.VLESS,
Remark: "plain",
Settings: `{"encryption":"none"}`,
}
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
stream := map[string]any{
"network": "tcp",
"security": "none",
"tcpSettings": map[string]any{
"header": map[string]any{"type": "none"},
},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, stream, nil)
if _, ok := proxy["encryption"]; ok {
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
}
// The rest of the proxy must still be well-formed — otherwise a mutant that
// drops encryption *and* corrupts a core field passes the absence check alone.
if proxy["type"] != "vless" {
t.Fatalf("type = %v, want vless", proxy["type"])
}
if proxy["server"] != "203.0.113.1" {
t.Fatalf("server = %v, want 203.0.113.1", proxy["server"])
}
if proxy["port"] != 443 {
t.Fatalf("port = %v, want 443", proxy["port"])
}
if proxy["uuid"] != client.ID {
t.Fatalf("uuid = %v, want %v", proxy["uuid"], client.ID)
}
}
func TestBuildXhttpClashOpts_FullFieldMapping(t *testing.T) {
xhttp := map[string]any{
"path": "/api/v1",
"mode": "stream-up",
"host": "example.com",
"xPaddingBytes": "100-1000",
"xPaddingObfsMode": true,
"xPaddingKey": "mykey",
"xPaddingHeader": "X-Trace-ID",
"xPaddingPlacement": "queryInHeader",
"xPaddingMethod": "tokenish",
"uplinkHTTPMethod": "POST",
"sessionIDPlacement": "query",
"sessionIDKey": "sess",
"sessionIDTable": "Base62",
"sessionIDLength": "16-32",
"seqPlacement": "header",
"seqKey": "seq",
"uplinkDataPlacement": "body",
"uplinkDataKey": "udata",
"uplinkChunkSize": "64-256",
"noGRPCHeader": true,
"scMaxEachPostBytes": "500000",
"scMinPostsIntervalMs": "50",
"xmux": map[string]any{
"maxConcurrency": "16-32",
"maxConnections": "4",
"cMaxReuseTimes": "8",
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"hKeepAlivePeriod": float64(60),
},
"headers": map[string]any{
"User-Agent": "chrome",
"Host": "should-be-dropped.com",
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts for full field mapping")
}
// Direct fields
if opts["path"] != "/api/v1" {
t.Errorf("path = %v, want /api/v1", opts["path"])
}
if opts["mode"] != "stream-up" {
t.Errorf("mode = %v, want stream-up", opts["mode"])
}
if opts["host"] != "example.com" {
t.Errorf("host = %v, want example.com", opts["host"])
}
// String fields
if opts["x-padding-bytes"] != "100-1000" {
t.Errorf("x-padding-bytes = %v", opts["x-padding-bytes"])
}
if opts["uplink-http-method"] != "POST" {
t.Errorf("uplink-http-method = %v", opts["uplink-http-method"])
}
if opts["session-id-placement"] != "query" {
t.Errorf("session-id-placement = %v", opts["session-id-placement"])
}
if opts["session-id-key"] != "sess" {
t.Errorf("session-id-key = %v", opts["session-id-key"])
}
if opts["session-id-table"] != "Base62" {
t.Errorf("session-id-table = %v", opts["session-id-table"])
}
if opts["session-id-length"] != "16-32" {
t.Errorf("session-id-length = %v", opts["session-id-length"])
}
if opts["seq-placement"] != "header" {
t.Errorf("seq-placement = %v", opts["seq-placement"])
}
if opts["seq-key"] != "seq" {
t.Errorf("seq-key = %v", opts["seq-key"])
}
if opts["uplink-data-placement"] != "body" {
t.Errorf("uplink-data-placement = %v", opts["uplink-data-placement"])
}
if opts["uplink-data-key"] != "udata" {
t.Errorf("uplink-data-key = %v", opts["uplink-data-key"])
}
// DPI-filtered fields (non-default values should pass)
if opts["sc-max-each-post-bytes"] != "500000" {
t.Errorf("sc-max-each-post-bytes = %v", opts["sc-max-each-post-bytes"])
}
if opts["sc-min-posts-interval-ms"] != "50" {
t.Errorf("sc-min-posts-interval-ms = %v", opts["sc-min-posts-interval-ms"])
}
// Bool fields
if opts["no-grpc-header"] != true {
t.Errorf("no-grpc-header = %v, want true", opts["no-grpc-header"])
}
if opts["x-padding-obfs-mode"] != true {
t.Errorf("x-padding-obfs-mode = %v, want true", opts["x-padding-obfs-mode"])
}
// Padding obfs gated fields
if opts["x-padding-key"] != "mykey" {
t.Errorf("x-padding-key = %v", opts["x-padding-key"])
}
if opts["x-padding-header"] != "X-Trace-ID" {
t.Errorf("x-padding-header = %v", opts["x-padding-header"])
}
if opts["x-padding-placement"] != "queryInHeader" {
t.Errorf("x-padding-placement = %v", opts["x-padding-placement"])
}
if opts["x-padding-method"] != "tokenish" {
t.Errorf("x-padding-method = %v", opts["x-padding-method"])
}
// Non-zero value fields
if opts["uplink-chunk-size"] != "64-256" {
t.Errorf("uplink-chunk-size = %v", opts["uplink-chunk-size"])
}
// Reuse-settings (xmux)
reuse, ok := opts["reuse-settings"].(map[string]any)
if !ok {
t.Fatalf("reuse-settings missing or wrong type: %#v", opts["reuse-settings"])
}
if reuse["max-concurrency"] != "16-32" {
t.Errorf("max-concurrency = %v", reuse["max-concurrency"])
}
if reuse["max-connections"] != "4" {
t.Errorf("max-connections = %v", reuse["max-connections"])
}
if reuse["c-max-reuse-times"] != "8" {
t.Errorf("c-max-reuse-times = %v", reuse["c-max-reuse-times"])
}
if reuse["h-max-request-times"] != "600-900" {
t.Errorf("h-max-request-times = %v", reuse["h-max-request-times"])
}
if reuse["h-max-reusable-secs"] != "1800-3000" {
t.Errorf("h-max-reusable-secs = %v", reuse["h-max-reusable-secs"])
}
if reuse["h-keep-alive-period"] != float64(60) {
t.Errorf("h-keep-alive-period = %v, want 60", reuse["h-keep-alive-period"])
}
// Headers (Host should be dropped)
headers, ok := opts["headers"].(map[string]any)
if !ok {
t.Fatalf("headers missing or wrong type: %#v", opts["headers"])
}
if headers["User-Agent"] != "chrome" {
t.Errorf("headers[User-Agent] = %v", headers["User-Agent"])
}
if _, has := headers["Host"]; has {
t.Error("headers should not contain Host key")
}
if _, has := headers["host"]; has {
t.Error("headers should not contain host key (case-insensitive)")
}
}
func TestBuildXhttpClashOpts_DPIDefaultsFiltered(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"mode": "stream-up",
"scMaxEachPostBytes": "1000000",
"scMinPostsIntervalMs": "30",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path and mode should be present)")
}
if _, has := opts["sc-max-each-post-bytes"]; has {
t.Error("sc-max-each-post-bytes should be filtered when value is 1000000")
}
if _, has := opts["sc-min-posts-interval-ms"]; has {
t.Error("sc-min-posts-interval-ms should be filtered when value is 30")
}
}
func TestBuildXhttpClashOpts_PaddingObfsGate(t *testing.T) {
// Sub-test 1: obfs mode false — gated fields should not appear
t.Run("ObfsModeFalse", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xPaddingObfsMode": false,
"xPaddingKey": "should-not-appear",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["x-padding-obfs-mode"]; has {
t.Error("x-padding-obfs-mode should not appear when false")
}
if _, has := opts["x-padding-key"]; has {
t.Error("x-padding-key should not appear when obfs mode is false")
}
})
// Sub-test 2: obfs mode absent — gated fields should not appear
t.Run("ObfsModeAbsent", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xPaddingKey": "should-not-appear",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["x-padding-key"]; has {
t.Error("x-padding-key should not appear when obfs mode is absent")
}
})
// Sub-test 3: obfs mode true with no gated fields — only x-padding-obfs-mode appears
t.Run("ObfsModeTrueNoGatedFields", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xPaddingObfsMode": true,
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if opts["x-padding-obfs-mode"] != true {
t.Errorf("x-padding-obfs-mode = %v, want true", opts["x-padding-obfs-mode"])
}
if _, has := opts["x-padding-key"]; has {
t.Error("x-padding-key should not appear when not set")
}
})
}
func TestBuildXhttpClashOpts_XmuxMapsToReuseSettings(t *testing.T) {
// Sub-test 1: full xmux mapping
t.Run("FullXmux", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{
"maxConcurrency": "16-32",
"maxConnections": "4",
"cMaxReuseTimes": "8",
"hMaxRequestTimes": "600-900",
"hMaxReusableSecs": "1800-3000",
"hKeepAlivePeriod": float64(60),
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
reuse, ok := opts["reuse-settings"].(map[string]any)
if !ok {
t.Fatalf("reuse-settings missing or wrong type: %#v", opts["reuse-settings"])
}
if reuse["max-concurrency"] != "16-32" {
t.Errorf("max-concurrency = %v", reuse["max-concurrency"])
}
if reuse["max-connections"] != "4" {
t.Errorf("max-connections = %v", reuse["max-connections"])
}
if reuse["c-max-reuse-times"] != "8" {
t.Errorf("c-max-reuse-times = %v", reuse["c-max-reuse-times"])
}
if reuse["h-max-request-times"] != "600-900" {
t.Errorf("h-max-request-times = %v", reuse["h-max-request-times"])
}
if reuse["h-max-reusable-secs"] != "1800-3000" {
t.Errorf("h-max-reusable-secs = %v", reuse["h-max-reusable-secs"])
}
if reuse["h-keep-alive-period"] != float64(60) {
t.Errorf("h-keep-alive-period = %v, want 60", reuse["h-keep-alive-period"])
}
})
// Sub-test 2: empty xmux map — no reuse-settings key
t.Run("EmptyXmux", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path is present)")
}
if _, has := opts["reuse-settings"]; has {
t.Error("reuse-settings should not appear for empty xmux")
}
})
// Sub-test 3: hKeepAlivePeriod as int (not float64)
t.Run("IntKeepAlivePeriod", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{
"hKeepAlivePeriod": int(60),
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
reuse, ok := opts["reuse-settings"].(map[string]any)
if !ok {
t.Fatalf("reuse-settings missing: %#v", opts["reuse-settings"])
}
if reuse["h-keep-alive-period"] != int(60) {
t.Errorf("h-keep-alive-period = %v (%T), want 60 (int)", reuse["h-keep-alive-period"], reuse["h-keep-alive-period"])
}
})
// Sub-test 4: hKeepAlivePeriod=0 should be filtered
t.Run("ZeroKeepAlivePeriod", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"xmux": map[string]any{
"hKeepAlivePeriod": float64(0),
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["reuse-settings"]; has {
t.Error("reuse-settings should not appear when only hKeepAlivePeriod=0")
}
})
}
func TestBuildXhttpClashOpts_ServerOnlyFieldsExcluded(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"noSSEHeader": true,
"scMaxBufferedPosts": "100",
"scStreamUpServerSecs": "5",
"serverMaxHeaderBytes": "4096",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path is present)")
}
if _, has := opts["no-sse-header"]; has {
t.Error("noSSEHeader should not appear in Clash output (server-only)")
}
if _, has := opts["sc-max-buffered-posts"]; has {
t.Error("scMaxBufferedPosts should not appear in Clash output (server-only)")
}
if _, has := opts["sc-stream-up-server-secs"]; has {
t.Error("scStreamUpServerSecs should not appear in Clash output (server-only)")
}
if _, has := opts["server-max-header-bytes"]; has {
t.Error("serverMaxHeaderBytes should not appear in Clash output (not in Mihomo)")
}
}
func TestBuildXhttpClashOpts_NilInput(t *testing.T) {
opts := buildXhttpClashOpts(nil)
if opts != nil {
t.Fatalf("expected nil for nil input, got %#v", opts)
}
}
func TestBuildXhttpClashOpts_EmptyInput(t *testing.T) {
opts := buildXhttpClashOpts(map[string]any{})
if opts != nil {
t.Fatalf("expected nil for empty input, got %#v", opts)
}
}
func TestBuildXhttpClashOpts_HostFallbackFromHeaders(t *testing.T) {
// Sub-test 1: host from headers.Host
t.Run("HostFromHeaders", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"headers": map[string]any{"Host": "via-header.example.com"},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if opts["host"] != "via-header.example.com" {
t.Errorf("host = %v, want via-header.example.com", opts["host"])
}
})
// Sub-test 2: headers only contains Host — no headers key in output
t.Run("HeadersOnlyHost", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"headers": map[string]any{"Host": "only-host.example.com"},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["headers"]; has {
t.Error("headers key should not appear when only Host is present (Host is extracted to top-level)")
}
})
// Sub-test 3: case-insensitive Host drop
t.Run("CaseInsensitiveHostDrop", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"host": "explicit.example.com",
"headers": map[string]any{
"host": "lowercase-host.example.com",
"X-Custom": "value",
},
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if opts["host"] != "explicit.example.com" {
t.Errorf("host = %v, want explicit.example.com (explicit host wins)", opts["host"])
}
headers, ok := opts["headers"].(map[string]any)
if !ok {
t.Fatal("headers should be present (X-Custom remains)")
}
if _, has := headers["host"]; has {
t.Error("lowercase 'host' should be dropped from headers")
}
if headers["X-Custom"] != "value" {
t.Errorf("X-Custom = %v, want value", headers["X-Custom"])
}
})
}
func TestBuildXhttpClashOpts_NoGRPCHeaderFalsey(t *testing.T) {
// Sub-test 1: noGRPCHeader: false
t.Run("ExplicitFalse", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
"noGRPCHeader": false,
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts (path is present)")
}
if _, has := opts["no-grpc-header"]; has {
t.Error("no-grpc-header should not appear when noGRPCHeader is false")
}
})
// Sub-test 2: noGRPCHeader absent
t.Run("Absent", func(t *testing.T) {
xhttp := map[string]any{
"path": "/",
}
opts := buildXhttpClashOpts(xhttp)
if opts == nil {
t.Fatal("expected non-nil opts")
}
if _, has := opts["no-grpc-header"]; has {
t.Error("no-grpc-header should not appear when absent")
}
})
}
func TestBuildWireguardProxyForClash(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{
Listen: "203.0.113.9",
Port: 51820,
Protocol: model.WireGuard,
Remark: "wg",
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420,"dns":"1.1.1.1, 8.8.8.8"}`,
}
client := model.Client{
Email: "user",
PrivateKey: clientPriv,
PreSharedKey: "psk-value",
KeepAlive: 25,
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
}
proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil)
if proxy == nil {
t.Fatal("buildProxy returned nil for a valid wireguard client")
}
if proxy["type"] != "wireguard" {
t.Fatalf("type = %v, want wireguard", proxy["type"])
}
if proxy["server"] != "203.0.113.9" {
t.Fatalf("server = %v, want 203.0.113.9", proxy["server"])
}
if proxy["port"] != 51820 {
t.Fatalf("port = %v, want 51820", proxy["port"])
}
if proxy["private-key"] != clientPriv {
t.Fatalf("private-key = %v, want %v", proxy["private-key"], clientPriv)
}
if proxy["public-key"] != serverPub {
t.Fatalf("public-key = %v, want %v (derived from inbound secretKey)", proxy["public-key"], serverPub)
}
if proxy["pre-shared-key"] != "psk-value" {
t.Fatalf("pre-shared-key = %v, want psk-value", proxy["pre-shared-key"])
}
if proxy["persistent-keepalive"] != 25 {
t.Fatalf("persistent-keepalive = %v, want 25", proxy["persistent-keepalive"])
}
if proxy["ip"] != "10.0.0.2" {
t.Fatalf("ip = %v, want 10.0.0.2", proxy["ip"])
}
if proxy["ipv6"] != "fd00::2" {
t.Fatalf("ipv6 = %v, want fd00::2", proxy["ipv6"])
}
if proxy["mtu"] != 1420 {
t.Fatalf("mtu = %v, want 1420", proxy["mtu"])
}
if proxy["udp"] != true {
t.Fatalf("udp = %v, want true", proxy["udp"])
}
if dns, ok := proxy["dns"].([]string); !ok || !reflect.DeepEqual(dns, []string{"1.1.1.1", "8.8.8.8"}) {
t.Fatalf("dns = %v, want [1.1.1.1 8.8.8.8]", proxy["dns"])
}
}
func TestBuildWireguardProxyForClashNoKey(t *testing.T) {
svc := &SubClashService{SubService: &SubService{}}
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"}
if proxy := svc.buildProxy(svc.SubService, inbound, client, nil, nil); proxy != nil {
t.Fatalf("buildProxy = %v, want nil for a keyless wireguard client", proxy)
}
}
-89
View File
@@ -1,89 +0,0 @@
package sub
import (
"reflect"
"regexp"
"strings"
"github.com/goccy/go-yaml"
)
type yamlQuotedString string
func (s yamlQuotedString) MarshalYAML() ([]byte, error) {
return []byte("'" + strings.ReplaceAll(string(s), "'", "''") + "'"), nil
}
var yamlNonStringWords = map[string]bool{
"~": true, "null": true, "Null": true, "NULL": true,
"true": true, "True": true, "TRUE": true,
"false": true, "False": true, "FALSE": true,
"yes": true, "Yes": true, "YES": true,
"no": true, "No": true, "NO": true,
"on": true, "On": true, "ON": true,
"off": true, "Off": true, "OFF": true,
"y": true, "Y": true, "n": true, "N": true,
}
var yamlNonStringNumber = regexp.MustCompile(`^(?:` +
`[-+]?[0-9]+|` +
`0[oO]?[0-7]+|0[xX][0-9a-fA-F]+|` +
`[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+|` +
`[-+]?(?:[0-9]*\.[0-9]+|[0-9]+\.?[0-9]*)(?:[eE][-+]?[0-9]+)?|` +
`[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN)|` +
`[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:[Tt ].*)?` +
`)$`)
func yamlScalarIsAmbiguous(s string) bool {
if s == "" {
return false
}
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s)
}
func quoteAmbiguousYAMLScalars(v any) any {
if v == nil {
return nil
}
if s, ok := v.(string); ok {
if yamlScalarIsAmbiguous(s) {
return yamlQuotedString(s)
}
return s
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Map:
out := make(map[string]any, rv.Len())
iter := rv.MapRange()
for iter.Next() {
key, ok := iter.Key().Interface().(string)
if !ok {
return v
}
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface())
}
return out
case reflect.Slice, reflect.Array:
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
return v
}
out := make([]any, rv.Len())
for i := range rv.Len() {
out[i] = quoteAmbiguousYAMLScalars(rv.Index(i).Interface())
}
return out
case reflect.Pointer, reflect.Interface:
if rv.IsNil() {
return v
}
return quoteAmbiguousYAMLScalars(rv.Elem().Interface())
default:
return v
}
}
func marshalClashYAML(config any) ([]byte, error) {
return yaml.Marshal(quoteAmbiguousYAMLScalars(config))
}
-129
View File
@@ -1,129 +0,0 @@
package sub
import (
"strings"
"testing"
"github.com/goccy/go-yaml"
)
func TestAmbiguousScalarsAreQuoted(t *testing.T) {
tests := []struct {
name string
value string
mustQuote bool
}{
{"reality short-id read as a float", "2351e1", true},
{"short exponent form", "0e1", true},
{"long exponent form", "12e34", true},
{"all digits", "123456", true},
{"leading zeros read as octal", "0177", true},
{"hex form", "0x1f", true},
{"decimal point", "1.5", true},
{"boolean word", "true", true},
{"single letter bool", "y", true},
{"null word", "null", true},
{"tilde", "~", true},
{"date", "2026-07-27", true},
{"sexagesimal", "12:30", true},
{"plain hex with letters", "6ba7b8", false},
{"letters only", "abcdef", false},
{"hostname", "example.com", false},
{"dotted quad", "1.2.3.4", false},
{"uuid", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", false},
{"alpn token", "h2", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"reality-opts": map[string]any{"short-id": tt.value},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
got := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(out)), "reality-opts:"))
quoted := strings.Contains(got, "'"+tt.value+"'") || strings.Contains(got, `"`+tt.value+`"`)
if tt.mustQuote && !quoted {
t.Errorf("%q must be emitted quoted, got %q", tt.value, got)
}
if !tt.mustQuote && quoted {
t.Errorf("%q needs no quoting, got %q", tt.value, got)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
})
}
}
func TestClashShortIDIsQuotedInOutput(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"reality-opts": map[string]any{"short-id": "2351e1"},
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
if !strings.Contains(string(out), `'2351e1'`) {
t.Errorf("short-id must be emitted as a quoted scalar, got:\n%s", out)
}
}
func TestAmbiguousPasswordIsQuoted(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"password": "80e12",
"obfs-password": "12345",
"pre-shared-key": "9e9",
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
for _, want := range []string{`'80e12'`, `'12345'`, `'9e9'`} {
if !strings.Contains(string(out), want) {
t.Errorf("expected %s in output:\n%s", want, out)
}
}
}
func TestUnambiguousScalarsAreUnchanged(t *testing.T) {
config := map[string]any{
"port": 7890,
"mode": "rule",
"proxies": []any{map[string]any{"name": "de-1", "udp": true, "port": 443}},
"rules": []string{"MATCH,PROXY"},
"alpn": []string{"h2", "http/1.1"},
"nonempty": "example.com",
}
quoted, err := marshalClashYAML(config)
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
plain, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("yaml.Marshal: %v", err)
}
if string(quoted) != string(plain) {
t.Errorf("unambiguous document changed:\n--- got ---\n%s\n--- want ---\n%s", quoted, plain)
}
}
func TestQuotedScalarEscapesQuotes(t *testing.T) {
out, err := marshalClashYAML(map[string]any{"password": "1e2'3"})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
if got := decoded["password"]; got != any("1e2'3") {
t.Errorf("password round-tripped to %#v\n%s", got, out)
}
}
+16 -352
View File
@@ -5,16 +5,10 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"unicode"
"github.com/gin-gonic/gin"
@@ -35,15 +29,7 @@ func writeSubError(c *gin.Context, err error) {
c.Status(http.StatusInternalServerError)
}
// cachedSubTemplate holds a parsed custom subscription template together with
// the modification time of the file it was parsed from, so the cache can be
// invalidated when an admin edits the template on disk.
type cachedSubTemplate struct {
tmpl *template.Template
modTime time.Time
}
// SUBController handles HTTP requests for subscription links and JSON configurations.
// SUBController handles HTTP requests for subscription links.
type SUBController struct {
subTitle string
subSupportUrl string
@@ -51,50 +37,22 @@ type SUBController struct {
subAnnounce string
subHideSettings bool
subPath string
subJsonPath string
subClashPath string
subClashAutoDetect bool
clashUserAgent *regexp.Regexp
jsonAutoDetect bool
jsonUserAgent *regexp.Regexp
jsonAlwaysArray bool
jsonEnabled bool
clashEnabled bool
subEncrypt bool
updateInterval string
subPath string
subEncrypt bool
updateInterval string
subService *SubService
subJsonService *SubJsonService
subClashService *SubClashService
clientService service.ClientService
settingService service.SettingService
subTemplateMu sync.RWMutex
subTemplateCache map[string]*cachedSubTemplate
subService *SubService
clientService service.ClientService
settingService service.SettingService
}
type subControllerConfig struct {
subPath string
subJsonPath string
subClashPath string
subClashAutoDetect bool
subClashUserAgentRegex string
subJsonAutoDetect bool
subJsonUserAgentRegex string
subJsonAlwaysArray bool
subJsonEnabled bool
subClashEnabled bool
subPath string
subEncrypt bool
remarkTemplate string
updateInterval string
subJsonMux string
subJsonFinalMask string
subJsonObservatory string
subTitle string
subSupportURL string
subProfileURL string
@@ -108,42 +66,6 @@ func WithSUBPath(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subPath = value }
}
func WithSUBJsonPath(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonPath = value }
}
func WithSUBClashPath(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashPath = value }
}
func WithSUBClashAutoDetect(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashAutoDetect = value }
}
func WithSUBClashUserAgentRegex(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashUserAgentRegex = value }
}
func WithSUBJsonAutoDetect(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonAutoDetect = value }
}
func WithSUBJsonUserAgentRegex(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonUserAgentRegex = value }
}
func WithSUBJsonAlwaysArray(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonAlwaysArray = value }
}
func WithSUBJsonEnabled(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonEnabled = value }
}
func WithSUBClashEnabled(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subClashEnabled = value }
}
func WithSUBEncryption(value bool) SUBControllerOption {
return func(config *subControllerConfig) { config.subEncrypt = value }
}
@@ -156,18 +78,6 @@ func WithSUBUpdateInterval(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.updateInterval = value }
}
func WithSUBJsonMux(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonMux = value }
}
func WithSUBJsonFinalMask(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
}
func WithSUBJsonObservatory(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subJsonObservatory = value }
}
func WithSUBTitle(value string) SUBControllerOption {
return func(config *subControllerConfig) { config.subTitle = value }
}
@@ -191,8 +101,6 @@ func WithSUBHideSettings(value bool) SUBControllerOption {
func defaultSUBControllerConfig() subControllerConfig {
return subControllerConfig{
subPath: "/sub/",
subJsonPath: "/json/",
subClashPath: "/clash/",
subEncrypt: true,
remarkTemplate: service.DefaultRemarkTemplate,
updateInterval: "12",
@@ -207,8 +115,6 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
}
sub := NewSubService(config.remarkTemplate)
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonFinalMask, sub)
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
a := &SUBController{
subTitle: config.subTitle,
subSupportUrl: config.subSupportURL,
@@ -216,45 +122,22 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
subAnnounce: config.subAnnounce,
subHideSettings: config.subHideSettings,
subPath: config.subPath,
subJsonPath: config.subJsonPath,
subClashPath: config.subClashPath,
subClashAutoDetect: config.subClashAutoDetect,
clashUserAgent: compileUserAgentRegex("Clash/Mihomo", config.subClashUserAgentRegex, service.DefaultSubClashUserAgentRegex),
jsonAutoDetect: config.subJsonAutoDetect,
jsonUserAgent: compileUserAgentRegex("Xray JSON", config.subJsonUserAgentRegex, service.DefaultSubJsonUserAgentRegex),
jsonAlwaysArray: config.subJsonAlwaysArray,
jsonEnabled: config.subJsonEnabled,
clashEnabled: config.subClashEnabled,
subEncrypt: config.subEncrypt,
updateInterval: config.updateInterval,
subPath: config.subPath,
subEncrypt: config.subEncrypt,
updateInterval: config.updateInterval,
subService: sub,
subJsonService: subJsonSvc,
subClashService: NewSubClashService(sub),
subTemplateCache: map[string]*cachedSubTemplate{},
subService: sub,
}
a.initRouter(g)
return a
}
// initRouter registers HTTP routes for subscription links and JSON endpoints
// on the provided router group.
// initRouter registers HTTP routes for subscription links on the provided
// router group.
func (a *SUBController) initRouter(g *gin.RouterGroup) {
gLink := g.Group(a.subPath)
gLink.GET(":subid", a.subs)
gLink.HEAD(":subid", a.subs)
if a.jsonEnabled {
gJson := g.Group(a.subJsonPath)
gJson.GET(":subid", a.subJsons)
gJson.HEAD(":subid", a.subJsons)
}
if a.clashEnabled {
gClash := g.Group(a.subClashPath)
gClash.GET(":subid", a.subClashs)
gClash.HEAD(":subid", a.subClashs)
}
}
// maybeServeSubPage renders the HTML info page when the request comes from a
@@ -303,20 +186,14 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
writeSubError(c, err)
return PageData{}, false
}
subURL, subJsonURL, subClashURL := subReq.BuildURLs(a.subPath, a.subJsonPath, a.subClashPath, subId)
if !a.jsonEnabled {
subJsonURL = ""
}
if !a.clashEnabled {
subClashURL = ""
}
subURL := subReq.BuildURLs(a.subPath, subId)
basePath, exists := c.Get("base_path")
if !exists {
basePath = "/"
}
basePathStr := basePath.(string)
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, "")
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, metadata.Title, metadata.SupportURL)
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, basePathStr, metadata.Title, metadata.SupportURL)
page.SubAnnounce = metadata.Announce
return page, true
}
@@ -351,16 +228,6 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.enforceHwid(c) {
return
}
if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
a.recordSubscriptionFetch(c)
logSubscriptionRoute(userAgent, "clash")
return
}
if shouldAutoServeJson(a.jsonAutoDetect, a.jsonEnabled, false, userAgent, a.jsonUserAgent) && a.serveJsonBody(c, true, "application/json; charset=utf-8", false) {
a.recordSubscriptionFetch(c)
logSubscriptionRoute(userAgent, "json")
return
}
logSubscriptionRoute(userAgent, "raw")
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
@@ -400,21 +267,6 @@ func (a *SUBController) recordSubscriptionFetch(c *gin.Context) {
}
}
func shouldAutoServeClash(autoDetect, clashEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
return shouldAutoServeFormat(autoDetect, clashEnabled, wantsHTML, userAgent, userAgentRegex)
}
func shouldAutoServeJson(autoDetect, jsonEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
return shouldAutoServeFormat(autoDetect, jsonEnabled, wantsHTML, userAgent, userAgentRegex)
}
func shouldAutoServeFormat(autoDetect, formatEnabled, wantsHTML bool, userAgent string, userAgentRegex *regexp.Regexp) bool {
if !autoDetect || !formatEnabled || wantsHTML || userAgentRegex == nil {
return false
}
return userAgentRegex.MatchString(userAgent)
}
func logSubscriptionRoute(userAgent, branch string) {
logger.Debugf("Subscription request routed: branch=%s user_agent=%q", branch, sanitizeUserAgentForLog(userAgent))
}
@@ -433,25 +285,6 @@ func sanitizeUserAgentForLog(userAgent string) string {
return clean
}
func compileUserAgentRegex(name, pattern, defaultPattern string) *regexp.Regexp {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
pattern = strings.TrimSpace(defaultPattern)
}
if pattern == "" {
return nil
}
compiled, err := regexp.Compile(pattern)
if err == nil {
return compiled
}
logger.Warningf("Invalid %s User-Agent regex %q; falling back to default %q: %v", name, pattern, defaultPattern, err)
if strings.TrimSpace(defaultPattern) == "" {
return nil
}
return regexp.MustCompile(defaultPattern)
}
// serveSubPage renders internal/web/dist/subpage.html for the current subscription
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount —
// we inject that here, along with window.X_UI_BASE_PATH so the
@@ -481,27 +314,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
subData := a.subPageContext(page)
// When an admin has configured a custom subscription theme, render it
// instead of the default SPA. We render into a buffer first so a template
// that fails mid-execution can't leave a partially-written (corrupt)
// response — on any error we log and fall through to the default page.
if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
logger.Error("sub: custom template parse failed, using default page:", err)
} else if tmpl == nil {
logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
} else {
var buf bytes.Buffer
if execErr := tmpl.Execute(&buf, subData); execErr != nil {
logger.Error("sub: custom template execution failed, using default page:", execErr)
} else {
setNoCacheHeaders(c)
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
return
}
}
}
subDataJSON, err := json.Marshal(subData)
if err != nil {
subDataJSON = []byte("{}")
@@ -554,8 +366,6 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
"uploadByte": page.UploadByte,
"totalByte": page.TotalByte,
"subUrl": page.SubUrl,
"subJsonUrl": page.SubJsonUrl,
"subClashUrl": page.SubClashUrl,
"subTitle": page.SubTitle,
"subSupportUrl": page.SubSupportUrl,
"links": page.Result,
@@ -608,152 +418,6 @@ func setNoCacheHeaders(c *gin.Context) {
c.Header("Expires", "0")
}
// loadSubTemplate returns the parsed custom subscription template located in
// themeDir, preferring sub.html over index.html. Parsed templates are cached and
// only re-parsed when the underlying file's modification time changes, so admin
// edits are picked up without paying a disk read + HTML parse on every request.
//
// It returns (nil, nil) when themeDir is not a usable directory or contains no
// template file — the caller should fall back to the default page. A non-nil
// error means a template file exists but failed to parse.
func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
info, err := os.Stat(themeDir)
if err != nil || !info.IsDir() {
return nil, nil
}
templatePath := filepath.Join(themeDir, "index.html")
if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
templatePath = filepath.Join(themeDir, "sub.html")
}
fi, err := os.Stat(templatePath)
if err != nil {
return nil, nil
}
modTime := fi.ModTime()
a.subTemplateMu.RLock()
cached := a.subTemplateCache[templatePath]
a.subTemplateMu.RUnlock()
if cached != nil && cached.modTime.Equal(modTime) {
return cached.tmpl, nil
}
tmpl, err := template.ParseFiles(templatePath)
if err != nil {
return nil, err
}
a.subTemplateMu.Lock()
a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
a.subTemplateMu.Unlock()
return tmpl, nil
}
// subJsons handles HTTP requests for JSON subscription configurations.
func (a *SUBController) subJsons(c *gin.Context) {
if strings.EqualFold(c.Query("view"), "raw") {
if !a.serveJsonBody(c, a.jsonAlwaysArray, "application/json; charset=utf-8", true) {
writeSubError(c, nil)
}
a.recordSubscriptionFetch(c)
return
}
if a.maybeServeSubPage(c) {
return
}
if !a.enforceHwid(c) {
return
}
a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
}
func (a *SUBController) serveJson(c *gin.Context, alwaysReturnArray bool, contentType string) {
if !a.serveJsonBody(c, alwaysReturnArray, contentType, false) {
writeSubError(c, nil)
}
a.recordSubscriptionFetch(c)
}
func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, contentType string, rawDownload bool) bool {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
jsonSub, header, err := a.subJsonService.GetJson(subId, host, alwaysReturnArray)
if err != nil {
writeSubError(c, err)
return true
}
if len(jsonSub) == 0 {
return false
}
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
var subReq *SubService
metadata := a.metadataForSubRequest(func() *SubService {
if subReq == nil {
subReq = a.subService.ForRequest(host)
}
return subReq
}, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
}
c.Data(200, contentType, []byte(jsonSub))
return true
}
func (a *SUBController) subClashs(c *gin.Context) {
if strings.EqualFold(c.Query("view"), "raw") {
if !a.serveClashBody(c, true) {
writeSubError(c, nil)
}
a.recordSubscriptionFetch(c)
return
}
if a.maybeServeSubPage(c) {
return
}
if !a.enforceHwid(c) {
return
}
if !a.serveClashBody(c, false) {
writeSubError(c, nil)
}
a.recordSubscriptionFetch(c)
}
func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
subId := c.Param("subid")
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
clashSub, header, err := a.subClashService.GetClash(subId, host)
if err != nil {
writeSubError(c, err)
return true
}
if len(clashSub) == 0 {
return false
}
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
var subReq *SubService
metadata := a.metadataForSubRequest(func() *SubService {
if subReq == nil {
subReq = a.subService.ForRequest(host)
}
return subReq
}, subId, profileURL)
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subHideSettings)
if rawDownload {
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
} else if metadata.Title != "" {
// Clash clients commonly use Content-Disposition to choose the imported profile name.
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(metadata.Title)))
}
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
return true
}
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
func (a *SUBController) ApplyCommonHeaders(
c *gin.Context,
+24 -480
View File
@@ -1,17 +1,13 @@
package sub
import (
"bytes"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"time"
"github.com/gin-gonic/gin"
@@ -24,50 +20,17 @@ var testDistFS = fstest.MapFS{
"dist/subpage.html": {Data: []byte(`<!doctype html><html><head></head><body><div id="root"></div></body></html>`)},
}
// newTestSUBController builds a controller with just the bits loadSubTemplate
// needs, so the template tests don't require a database.
func newTestSUBController() *SUBController {
return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
}
type subscriptionTestRouterConfig struct {
clashAutoDetect bool
clashUserAgentRegex string
jsonAutoDetect bool
jsonUserAgentRegex string
jsonAlwaysArray bool
}
func newSubscriptionTestRouter(config subscriptionTestRouterConfig) *gin.Engine {
func newSubscriptionTestRouter() *gin.Engine {
router := gin.New()
options := []SUBControllerOption{
WithSUBJsonEnabled(true),
WithSUBClashEnabled(true),
}
if config.clashAutoDetect {
options = append(options, WithSUBClashAutoDetect(true))
}
if config.clashUserAgentRegex != "" {
options = append(options, WithSUBClashUserAgentRegex(config.clashUserAgentRegex))
}
if config.jsonAutoDetect {
options = append(options, WithSUBJsonAutoDetect(true))
}
if config.jsonUserAgentRegex != "" {
options = append(options, WithSUBJsonUserAgentRegex(config.jsonUserAgentRegex))
}
if config.jsonAlwaysArray {
options = append(options, WithSUBJsonAlwaysArray(true))
}
NewSUBController(router.Group("/"), options...)
NewSUBController(router.Group("/"))
return router
}
func TestNewSUBControllerOptions(t *testing.T) {
gin.SetMode(gin.TestMode)
defaults := NewSUBController(gin.New().Group("/"))
if defaults.subPath != "/sub/" || defaults.subJsonPath != "/json/" || defaults.subClashPath != "/clash/" {
t.Fatalf("default paths = %q, %q, %q", defaults.subPath, defaults.subJsonPath, defaults.subClashPath)
if defaults.subPath != "/sub/" {
t.Fatalf("default path = %q", defaults.subPath)
}
if !defaults.subEncrypt || defaults.updateInterval != "12" {
t.Fatalf("default encryption/update = %v, %q", defaults.subEncrypt, defaults.updateInterval)
@@ -75,111 +38,16 @@ func TestNewSUBControllerOptions(t *testing.T) {
if defaults.subService.remarkTemplate != service.DefaultRemarkTemplate {
t.Fatalf("default remark template = %q", defaults.subService.remarkTemplate)
}
if defaults.jsonEnabled || defaults.clashEnabled {
t.Fatalf("format endpoints enabled by default: json=%v clash=%v", defaults.jsonEnabled, defaults.clashEnabled)
}
configured := NewSUBController(
gin.New().Group("/"),
WithSUBPath("/custom/"),
WithSUBJsonEnabled(true),
WithSUBEncryption(false),
WithSUBUpdateInterval("24"),
)
if configured.subPath != "/custom/" || !configured.jsonEnabled || configured.subEncrypt || configured.updateInterval != "24" {
t.Fatalf("configured values were not applied: path=%q json=%v encrypt=%v update=%q",
configured.subPath, configured.jsonEnabled, configured.subEncrypt, configured.updateInterval)
}
}
func TestShouldAutoServeClash(t *testing.T) {
tests := []struct {
name string
autoDetect bool
clashEnabled bool
wantsHTML bool
userAgent string
pattern string
want bool
}{
{name: "clash verge", autoDetect: true, clashEnabled: true, userAgent: "Clash-Verge/v2.4.2", want: true},
{name: "mihomo", autoDetect: true, clashEnabled: true, userAgent: "mihomo/1.19.12", want: true},
{name: "clash case insensitive", autoDetect: true, clashEnabled: true, userAgent: "CLASH-META/1.0", want: true},
{name: "flclash covered by clash", autoDetect: true, clashEnabled: true, userAgent: "FlClash/0.8.91", want: true},
{name: "generic client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "GenericClient/1.10.0"},
{name: "other client raw fallback", autoDetect: true, clashEnabled: true, userAgent: "OtherClient/2.2"},
{name: "unknown raw fallback", autoDetect: true, clashEnabled: true, userAgent: "CustomClient/1.0"},
{name: "empty raw fallback", autoDetect: true, clashEnabled: true},
{name: "browser HTML wins", autoDetect: true, clashEnabled: true, wantsHTML: true, userAgent: "Clash-Verge/v2.4.2"},
{name: "disabled by default", clashEnabled: true, userAgent: "mihomo/1.19.12"},
{name: "clash endpoint disabled", autoDetect: true, userAgent: "mihomo/1.19.12"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shouldAutoServeClash(tt.autoDetect, tt.clashEnabled, tt.wantsHTML, tt.userAgent, compileUserAgentRegex("Clash/Mihomo", tt.pattern, service.DefaultSubClashUserAgentRegex))
if got != tt.want {
t.Fatalf("shouldAutoServeClash() = %v, want %v", got, tt.want)
}
})
}
}
func TestShouldAutoServeClashUsesConfiguredRegex(t *testing.T) {
configured := compileUserAgentRegex("Clash/Mihomo", `(?i)^custom-client/`, service.DefaultSubClashUserAgentRegex)
if !shouldAutoServeClash(true, true, false, "Custom-Client/1.0", configured) {
t.Fatal("configured User-Agent regex did not match")
}
if shouldAutoServeClash(true, true, false, "Mihomo/1.19", configured) {
t.Fatal("built-in User-Agent matched after a custom regex replaced it")
}
}
func TestShouldAutoServeJson(t *testing.T) {
configured := compileUserAgentRegex("Xray JSON", `(?i)^jsonclient([ /]|$)`, service.DefaultSubJsonUserAgentRegex)
for _, userAgent := range []string{"JsonClient/1.6.32", "jsonclient 1.6.32"} {
if !shouldAutoServeJson(true, true, false, userAgent, configured) {
t.Errorf("configured Xray JSON regex did not match %q", userAgent)
}
}
for _, userAgent := range []string{"GenericClient/1.10.0", "OtherClient/2.2", "ThirdClient/7.0", "CustomClient/1.0"} {
if shouldAutoServeJson(true, true, false, userAgent, configured) {
t.Errorf("configured Xray JSON regex unexpectedly matched %q", userAgent)
}
}
if shouldAutoServeJson(false, true, false, "JsonClient/1.6.32", configured) {
t.Fatal("disabled Xray JSON auto-detection matched")
}
if shouldAutoServeJson(true, false, false, "JsonClient/1.6.32", configured) {
t.Fatal("disabled JSON endpoint matched")
}
if shouldAutoServeJson(true, true, true, "JsonClient/1.6.32", configured) {
t.Fatal("browser HTML request matched Xray JSON")
}
empty := compileUserAgentRegex("Xray JSON", "", service.DefaultSubJsonUserAgentRegex)
if empty != nil {
t.Fatal("empty Xray JSON default should not compile to a matcher")
}
if shouldAutoServeJson(true, true, false, "JsonClient/1.6.32", empty) {
t.Fatal("empty Xray JSON default should not auto-serve")
}
}
func TestShouldAutoServeJsonUsesConfiguredRegex(t *testing.T) {
configured := compileUserAgentRegex("Xray JSON", `(?i)^custom-json/`, service.DefaultSubJsonUserAgentRegex)
if !shouldAutoServeJson(true, true, false, "Custom-JSON/1.0", configured) {
t.Fatal("configured Xray JSON User-Agent regex did not match")
}
if shouldAutoServeJson(true, true, false, "OtherClient/1.10.0", configured) {
t.Fatal("unrelated User-Agent matched after a custom regex was configured")
}
}
func TestCompileUserAgentRegexFallsBackForInvalidPattern(t *testing.T) {
compiled := compileUserAgentRegex("Clash/Mihomo", "[", service.DefaultSubClashUserAgentRegex)
if !compiled.MatchString("Mihomo/1.19") {
t.Fatal("invalid regex did not fall back to the default pattern")
if configured.subPath != "/custom/" || configured.subEncrypt || configured.updateInterval != "24" {
t.Fatalf("configured values were not applied: path=%q encrypt=%v update=%q",
configured.subPath, configured.subEncrypt, configured.updateInterval)
}
}
@@ -215,7 +83,9 @@ func seedSubMtprotoInbound(t *testing.T, subId, tag string, port int) {
}
}
func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
// The link endpoint serves the raw (base64) link list to every client; there
// is no per-client format negotiation left to honour.
func TestSubscriptionServesRawLinksRegardlessOfUserAgent(t *testing.T) {
seedSubDB(t)
seedSubMtprotoInbound(t, "s1", "tg", 4490)
gin.SetMode(gin.TestMode)
@@ -224,365 +94,39 @@ func TestAutoDetectFallsBackToRawWhenFormatHasNoContent(t *testing.T) {
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true, jsonAutoDetect: true}).ServeHTTP(resp, req)
newSubscriptionTestRouter().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("fallback response is not base64: %v", err)
t.Fatalf("response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "tg://proxy") {
t.Fatalf("decoded fallback lacks the Telegram proxy link: %s", decoded)
t.Fatalf("decoded response lacks the Telegram proxy link: %s", decoded)
}
}
func TestStandardSubscriptionAutoDetectsFormats(t *testing.T) {
func TestSubscriptionServesRawBase64Links(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "auto", 4480, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
t.Run("recognized client receives YAML", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "GenericClient/1.10.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{clashAutoDetect: true}).ServeHTTP(resp, req)
newSubscriptionTestRouter().ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, want YAML", got)
}
if body := resp.Body.String(); !strings.Contains(body, "proxies:") || !strings.Contains(body, "type: vless") {
t.Fatalf("auto-detected body is not Clash YAML:\n%s", body)
}
})
t.Run("Clash wins when both format regexes match", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Hybrid/1.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
clashUserAgentRegex: `(?i)^hybrid/`,
jsonAutoDetect: true,
jsonUserAgentRegex: `(?i)^hybrid/`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, want Clash YAML", got)
}
})
t.Run("disabled setting preserves raw base64", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Clash-Verge/v2.4.2")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("raw response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
})
t.Run("configured regex controls detection", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "Mihomo/1.19")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
clashUserAgentRegex: `(?i)^custom-client/`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got == "application/yaml; charset=utf-8" {
t.Fatalf("Content-Type = %q, custom regex should preserve raw response", got)
}
})
t.Run("unrecognized client preserves raw base64", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "GenericClient/1.10.0")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
clashAutoDetect: true,
jsonAutoDetect: true,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("raw response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
})
t.Run("recognized Xray JSON client receives configuration array", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
req.Header.Set("User-Agent", "JsonClient/1.6.32")
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{
jsonAutoDetect: true,
jsonUserAgentRegex: `(?i)^jsonclient([ /]|$)`,
}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("Content-Type = %q, want JSON", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") || !strings.Contains(body, `"outbounds"`) {
t.Fatalf("auto-detected body is not an Xray JSON configuration array:\n%s", body)
}
})
t.Run("explicit JSON endpoint preserves legacy single object by default", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want legacy text/plain", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "{") {
t.Fatalf("legacy explicit JSON body is not an object: %s", body)
}
})
t.Run("explicit JSON endpoint can follow XTLS array standard", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter(subscriptionTestRouterConfig{jsonAlwaysArray: true}).ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type = %q, want legacy text/plain", got)
}
if body := strings.TrimSpace(resp.Body.String()); !strings.HasPrefix(body, "[") {
t.Fatalf("standards-compliant explicit JSON body is not an array: %s", body)
}
})
}
func TestFormatEndpointsRawViewBypassesBrowserPage(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "raw", 4481, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
oldDistFS := distFS
distFS = testDistFS
t.Cleanup(func() { distFS = oldDistFS })
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
tests := []struct {
name string
path string
contentType string
disposition string
bodyContains string
}{
{name: "JSON", path: "/json/s1?view=raw", contentType: "application/json; charset=utf-8", disposition: `attachment; filename="subscription.json"`, bodyContains: "outbounds"},
{name: "Clash", path: "/clash/s1?view=raw", contentType: "application/yaml; charset=utf-8", disposition: `attachment; filename="subscription.yaml"`, bodyContains: "proxies:"},
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+tt.path, nil)
req.Header.Set("Accept", "text/html")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != tt.contentType {
t.Fatalf("Content-Type = %q, want %q", got, tt.contentType)
}
if got := resp.Header().Get("Content-Disposition"); got != tt.disposition {
t.Fatalf("Content-Disposition = %q, want %q", got, tt.disposition)
}
if !strings.Contains(resp.Body.String(), tt.bodyContains) {
t.Fatalf("raw body does not contain %q: %s", tt.bodyContains, resp.Body.String())
}
})
}
for _, path := range []string{"/json/s1", "/clash/s1"} {
t.Run(path+" browser page", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
req.Header.Set("Accept", "text/html")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
if got := resp.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
t.Fatalf("Content-Type = %q, want HTML", got)
}
})
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
t.Helper()
tmpl, err := a.loadSubTemplate(dir)
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("loadSubTemplate: unexpected error: %v", err)
t.Fatalf("raw response is not base64: %v", err)
}
if tmpl == nil {
t.Fatal("loadSubTemplate: expected a template, got nil")
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
t.Fatalf("execute: %v", err)
}
return buf.String()
}
func TestLoadSubTemplate_RendersIndex(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
if want := `<h1>abc-123</h1>`; got != want {
t.Fatalf("rendered = %q, want %q", got, want)
}
}
func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
got := renderTemplate(t, newTestSUBController(), dir, nil)
if got != "from-sub" {
t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
}
}
func TestLoadSubTemplate_FallbackCases(t *testing.T) {
a := newTestSUBController()
t.Run("missing dir", func(t *testing.T) {
tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
t.Run("path is a file not a dir", func(t *testing.T) {
file := filepath.Join(t.TempDir(), "index.html")
writeFile(t, file, `whatever`)
tmpl, err := a.loadSubTemplate(file)
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
t.Run("dir without template file", func(t *testing.T) {
tmpl, err := a.loadSubTemplate(t.TempDir())
if tmpl != nil || err != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
}
})
}
func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
dir := t.TempDir()
// Unterminated action — html/template fails to parse this.
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
tmpl, err := newTestSUBController().loadSubTemplate(dir)
if err == nil {
t.Fatal("expected a parse error for a malformed template, got nil")
}
if tmpl != nil {
t.Fatalf("expected nil template on parse error, got %v", tmpl)
}
}
func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
a := newTestSUBController()
dir := t.TempDir()
path := filepath.Join(dir, "index.html")
// v1 with a fixed mtime.
writeFile(t, path, `v1`)
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
if err := os.Chtimes(path, t1, t1); err != nil {
t.Fatalf("chtimes: %v", err)
}
first, err := a.loadSubTemplate(dir)
if err != nil || first == nil {
t.Fatalf("first load: (%v, %v)", first, err)
}
// Same mtime → cache hit returns the identical parsed template.
second, err := a.loadSubTemplate(dir)
if err != nil {
t.Fatalf("second load: %v", err)
}
if second != first {
t.Fatal("expected cache hit to return the same *template.Template pointer")
}
// New content + newer mtime → cache invalidated, fresh content served.
writeFile(t, path, `v2`)
t2 := t1.Add(time.Hour)
if err := os.Chtimes(path, t2, t2); err != nil {
t.Fatalf("chtimes: %v", err)
}
third, err := a.loadSubTemplate(dir)
if err != nil || third == nil {
t.Fatalf("third load: (%v, %v)", third, err)
}
if third == first {
t.Fatal("expected cache invalidation to re-parse the template after mtime change")
}
var buf bytes.Buffer
if err := third.Execute(&buf, nil); err != nil {
t.Fatalf("execute: %v", err)
}
if buf.String() != "v2" {
t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("decoded raw response lacks VLESS link: %s", decoded)
}
}
-90
View File
@@ -1,90 +0,0 @@
{
"remarks": "",
"dns": {
"tag": "dns_out",
"queryStrategy": "UseIP",
"servers": [
{
"address": "8.8.8.8",
"skipFallback": false
}
]
},
"inbounds": [
{
"port": 10808,
"protocol": "mixed",
"settings": {
"auth": "noauth",
"udp": true,
"userLevel": 8
},
"sniffing": {
"destOverride": [
"http",
"tls",
"quic",
"fakedns"
],
"enabled": true
},
"tag": "mixed"
},
{
"port": 10809,
"protocol": "http",
"settings": {
"userLevel": 8
},
"tag": "http"
}
],
"log": {
"loglevel": "warning"
},
"outbounds": [
{
"tag": "direct",
"protocol": "freedom",
"settings": {
"domainStrategy": "AsIs",
"redirect": "",
"noises": []
}
},
{
"tag": "block",
"protocol": "blackhole",
"settings": {
"response": {
"type": "http"
}
}
}
],
"policy": {
"levels": {
"8": {
"connIdle": 300,
"downlinkOnly": 1,
"handshake": 4,
"uplinkOnly": 1
}
},
"system": {
"statsOutboundUplink": true,
"statsOutboundDownlink": true
}
},
"routing": {
"domainStrategy": "AsIs",
"rules": [
{
"type": "field",
"network": "tcp,udp",
"outboundTag": "proxy"
}
]
},
"stats": {}
}
-24
View File
@@ -152,27 +152,3 @@ func TestLinkDisplayName(t *testing.T) {
}
}
}
func TestClashProxyFromExternalTrojanReality(t *testing.T) {
link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
svc := NewSubClashService(NewSubService(""))
proxy := svc.clashProxyFromExternal(link, "DE-Provider")
if proxy == nil {
t.Fatal("expected a clash proxy, got nil")
}
if proxy["type"] != "trojan" {
t.Fatalf("type = %v, want trojan", proxy["type"])
}
if proxy["server"] != "37.27.201.56" {
t.Fatalf("server = %v", proxy["server"])
}
if proxy["password"] != "provider-pass" {
t.Fatalf("password = %v", proxy["password"])
}
if proxy["name"] != "DE-Provider" {
t.Fatalf("name = %v", proxy["name"])
}
if proxy["tls"] != true {
t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
}
}
+22 -40
View File
@@ -1,19 +1,23 @@
package sub
import (
"encoding/json"
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// A subscription whose only entries are external links — no enabled standard
// inbound — must still render in the JSON and Clash formats, not just the raw
// one. Regression guard for the premature len(inbounds)==0 early return that
// short-circuited GetJson/GetClash before external links were ever fetched.
func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
// inbound — must still render in the link format. Regression guard for the
// premature len(inbounds)==0 early return that short-circuited the builders
// before external links were ever fetched.
func TestLinkFormatServesExternalLinkOnlySub(t *testing.T) {
initSubDB(t)
db := database.GetDB()
@@ -26,44 +30,22 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) {
t.Fatalf("seed external link: %v", err)
}
base := NewSubService("")
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/ext-only", nil)
resp := httptest.NewRecorder()
newSubscriptionTestRouter().ServeHTTP(resp, req)
jsonService := NewSubJsonService("", "", base)
jsonOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", false)
if resp.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("GetJson err = %v", err)
t.Fatalf("response is not base64: %v", err)
}
if jsonOut == "" {
t.Fatal("GetJson returned empty for an external-link-only sub")
if !strings.Contains(string(decoded), "vless://11111111-1111-1111-1111-111111111111@example.com:443") {
t.Fatalf("link output missing the external link: %s", decoded)
}
if !strings.Contains(jsonOut, "DE-Provider") {
t.Fatalf("GetJson missing external remark: %s", jsonOut)
}
var config map[string]any
if err := json.Unmarshal([]byte(jsonOut), &config); err != nil {
t.Fatalf("legacy GetJson must return an object for a single profile: %v; body=%s", err, jsonOut)
}
standardOut, _, err := jsonService.GetJson("ext-only", "sub.example.com", true)
if err != nil {
t.Fatalf("standards-compliant GetJson err = %v", err)
}
var configs []map[string]any
if err := json.Unmarshal([]byte(standardOut), &configs); err != nil {
t.Fatalf("standards-compliant GetJson must return an array for a single profile: %v; body=%s", err, standardOut)
}
if len(configs) != 1 {
t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs))
}
clashOut, _, err := NewSubClashService(base).GetClash("ext-only", "sub.example.com")
if err != nil {
t.Fatalf("GetClash err = %v", err)
}
if clashOut == "" {
t.Fatal("GetClash returned empty for an external-link-only sub")
}
if !strings.Contains(clashOut, "DE-Provider") {
t.Fatalf("GetClash missing external proxy: %s", clashOut)
if !strings.Contains(string(decoded), "DE-Provider") {
t.Fatalf("link output missing external remark: %s", decoded)
}
}
@@ -0,0 +1,44 @@
package sub
import (
"encoding/base64"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// The JSON and Clash subscription formats were removed: their endpoints must
// answer 404, while the link-format endpoint keeps serving the exact raw
// base64 link list.
func TestRemovedFormatEndpointsReturn404(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "gone", 4499, 1, `{"network":"tcp","security":"none"}`)
gin.SetMode(gin.TestMode)
router := newSubscriptionTestRouter()
for _, path := range []string{"/json/s1", "/clash/s1"} {
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com"+path, nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("GET %s status = %d, want 404", path, resp.Code)
}
}
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/s1", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("GET /sub/s1 status = %d, want 200; body=%s", resp.Code, resp.Body.String())
}
decoded, err := base64.StdEncoding.DecodeString(resp.Body.String())
if err != nil {
t.Fatalf("link response is not base64: %v", err)
}
if !strings.Contains(string(decoded), "vless://") {
t.Fatalf("link output changed, decoded body lacks a VLESS link: %s", decoded)
}
}
+2 -89
View File
@@ -261,47 +261,6 @@ func TestSub_HostAllowInsecure(t *testing.T) {
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
}
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(yaml, "skip-cert-verify: true") {
t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
}
}
// A host's Host header and path reach the Clash and JSON renderers even when
// the inbound's own ws settings leave them empty (#5944).
func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "hh", 4457, 1,
`{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni"}}`)
seedHost(t, &model.Host{
InboundId: ib.Id, SortOrder: 0, Remark: "HH", Address: "hh.cdn.com", Port: 8443, Security: "tls",
HostHeader: "cdn.example.com", Path: "/ws-path",
})
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(yaml, "Host: cdn.example.com") {
t.Fatalf("clash ws-opts should carry the host record's Host header:\n%s", yaml)
}
if !strings.Contains(yaml, "path: /ws-path") {
t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
}
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, `"host": "cdn.example.com"`) && !strings.Contains(out, `"host":"cdn.example.com"`) {
t.Fatalf("json wsSettings should carry the host record's Host header:\n%s", out)
}
}
// A host's Final Mask reaches the raw share link as the fm param, merged with
@@ -326,44 +285,6 @@ func TestSub_HostFinalMask_RawLink(t *testing.T) {
}
}
// A host's sockoptParams is injected into the JSON output stream (sockopt is
// stripped from the base stream, re-added per host).
func TestSub_HostSockoptJSON(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "so", 4460, 1,
`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
seedHost(t, &model.Host{
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
SockoptParams: `{"tcpFastOpen":true}`,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
t.Fatalf("json should include the host sockopt:\n%s", out)
}
}
// A host's muxParams override the JSON outbound's mux.
func TestSub_HostMuxJSON(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
seedHost(t, &model.Host{
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
MuxParams: `{"enabled":true,"concurrency":8}`,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "concurrency") {
t.Fatalf("json should include the host mux override:\n%s", out)
}
}
// A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
// inbound (reality keys can't be host-supplied).
func TestSub_HostRealitySniOverride(t *testing.T) {
@@ -393,8 +314,8 @@ func TestSub_HostRealitySniOverride(t *testing.T) {
}
}
// #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
// absent from GetClash but present in the raw GetSubs output.
// #9 — ExcludeFromSubTypes: a host not excluded from the link format stays
// present in the raw GetSubs output.
func TestSub_ExcludeFromSubTypes(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
@@ -409,12 +330,4 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) {
t.Fatalf("host not excluded from raw should appear in GetSubs")
}
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if strings.Contains(yaml, "clashless.cdn.com") {
t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
}
}
+4 -10
View File
@@ -59,12 +59,6 @@ func initHwidSubRouter(t *testing.T, limit int) (*gin.Engine, string) {
NewSUBController(
router.Group("/"),
WithSUBPath("/sub/"),
WithSUBJsonPath("/json/"),
WithSUBClashPath("/clash/"),
WithSUBClashAutoDetect(true),
WithSUBJsonAutoDetect(true),
WithSUBJsonEnabled(true),
WithSUBClashEnabled(true),
)
return router, subID
}
@@ -84,10 +78,10 @@ func requestSub(t *testing.T, router *gin.Engine, method string, path string, hw
return rec
}
func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
func TestSubscriptionHwidGateOnBodyRoute(t *testing.T) {
router, subID := initHwidSubRouter(t, 1)
for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
for _, path := range []string{"/sub/" + subID} {
rec := requestSub(t, router, http.MethodGet, path, "", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("%s missing HWID status = %d, want 404", path, rec.Code)
@@ -102,7 +96,7 @@ func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
t.Fatalf("HEAD missing HWID = %d %#v", rec.Code, rec.Header())
}
for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
for _, path := range []string{"/sub/" + subID} {
rec = requestSub(t, router, http.MethodGet, path, "device-one", "")
if rec.Code != http.StatusOK {
t.Fatalf("%s registered HWID status = %d, body=%q", path, rec.Code, rec.Body.String())
@@ -112,7 +106,7 @@ func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
}
}
rec = requestSub(t, router, http.MethodGet, "/json/"+subID, "device-two", "")
rec = requestSub(t, router, http.MethodGet, "/sub/"+subID, "device-two", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("new HWID after limit status = %d, want 404", rec.Code)
}
-87
View File
@@ -1,87 +0,0 @@
package sub
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedFlowInbound(t *testing.T, subId, tag string, port int, stream string) *model.Inbound {
t.Helper()
db := database.GetDB()
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
email := tag + "@e"
settings := fmt.Sprintf(
`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true,"flow":"xtls-rprx-vision"}],"decryption":"none"}`,
uuid, email, subId)
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
SubSortIndex: 1,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", tag, err)
}
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
link := &model.ClientInbound{ClientId: client.Id, InboundId: ib.Id, FlowOverride: "xtls-rprx-vision"}
if err := db.Create(link).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
return ib
}
// A vision flow left on a client after its inbound moved to a transport Vision
// cannot use is stripped from the raw link and the Clash proxy; the JSON
// subscription must agree instead of emitting an outbound xray rejects.
func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) {
seedSubDB(t)
seedFlowInbound(t, "s1", "wsflow", 4601, wsTLSStream)
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
if joined := strings.Join(links, "\n"); strings.Contains(joined, "flow=") {
t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined)
}
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if strings.Contains(yaml, "flow:") {
t.Fatalf("clash proxy must not carry a flow on ws+tls:\n%s", yaml)
}
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if strings.Contains(out, `"flow"`) {
t.Fatalf("json outbound must not carry a flow on ws+tls:\n%s", out)
}
}
// The gate must not strip a flow the transport does support.
func TestSub_JSONKeepsFlowOnTcpTLS(t *testing.T) {
seedSubDB(t)
seedFlowInbound(t, "s1", "tcpflow", 4602,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, `"flow": "xtls-rprx-vision"`) {
t.Fatalf("json outbound must keep the vision flow on tcp+tls:\n%s", out)
}
}
-933
View File
@@ -1,933 +0,0 @@
package sub
import (
_ "embed"
"encoding/json"
"fmt"
"maps"
"net/url"
"slices"
"sort"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
//go:embed default.json
var defaultJson string
// SubJsonService handles JSON subscription configuration generation and management.
type SubJsonService struct {
configJson map[string]any
defaultOutbounds []json_util.RawMessage
finalMask string
mux string
observatory subBalancerObservatoryConfig
SubService *SubService
}
// NewSubJsonService creates a new JSON subscription service with the given configuration.
func NewSubJsonService(mux string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any
var defaultOutbounds []json_util.RawMessage
_ = json.Unmarshal([]byte(defaultJson), &configJson)
if outboundSlices, ok := configJson["outbounds"].([]any); ok {
for _, defaultOutbound := range outboundSlices {
jsonBytes, _ := json.Marshal(defaultOutbound)
defaultOutbounds = append(defaultOutbounds, jsonBytes)
}
}
return &SubJsonService{
configJson: configJson,
defaultOutbounds: defaultOutbounds,
finalMask: finalMask,
mux: mux,
observatory: defaultSubBalancerObservatoryConfig(),
SubService: subService,
}
}
// GetJson generates a JSON subscription configuration for the given subscription ID and host.
func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bool) (string, string, error) {
subReq := s.SubService.ForRequest(host)
subReq.subscriptionBody = true
inbounds, err := subReq.getInboundsBySubId(subId)
if err != nil {
return "", "", err
}
externalLinks, err := subReq.getClientExternalLinksBySubId(subId)
if err != nil {
return "", "", err
}
if len(inbounds) == 0 && len(externalLinks) == 0 {
return "", "", nil
}
var header string
seenEmails := make(map[string]struct{})
entries := make([]subConfigEntry, 0, len(inbounds))
// Prepare Inbounds
for _, inbound := range inbounds {
clients := subReq.matchingClients(inbound, subId)
if len(clients) == 0 {
continue
}
subReq.projectThroughFallbackMaster(inbound)
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
}
var inboundConfigs []json_util.RawMessage
for _, client := range clients {
seenEmails[client.Email] = struct{}{}
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
}
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
sortIndex: inbound.SubSortIndex,
id: inbound.Id,
configs: inboundConfigs,
})
}
}
entries = s.appendBalancerEntries(entries)
// Inbounds arrive sorted by (sub_sort_index, id); balancers interleave by
// the same key and, on an equal number, follow the inbound group.
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].sortIndex != entries[j].sortIndex {
return entries[i].sortIndex < entries[j].sortIndex
}
if entries[i].kind != entries[j].kind {
return entries[i].kind < entries[j].kind
}
return entries[i].id < entries[j].id
})
var configArray []json_util.RawMessage
for _, entry := range entries {
configArray = append(configArray, entry.configs...)
}
for _, ext := range externalLinks {
for _, el := range expandEntry(ext) {
outbound := parsedExternalOutbound(el.Link)
if outbound == nil {
continue
}
seenEmails[ext.Email] = struct{}{}
remark := el.Name
if remark == "" {
remark = ext.Email
}
newOutbounds := []json_util.RawMessage{outbound}
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = remark
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
configArray = append(configArray, newConfig)
}
}
if len(configArray) == 0 {
return "", "", nil
}
emails := make([]string, 0, len(seenEmails))
for e := range seenEmails {
emails = append(emails, e)
}
traffic, _ := subReq.AggregateTrafficByEmails(emails)
var finalJson []byte
if len(configArray) == 1 && !alwaysReturnArray {
finalJson, _ = json.MarshalIndent(configArray[0], "", " ")
} else {
finalJson, _ = json.MarshalIndent(configArray, "", " ")
}
header = fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
return string(finalJson), header, nil
}
// subConfigEntry is one ordered block of the JSON subscription: an inbound's
// configs (kind 0) or a balancer config (kind 1).
type subConfigEntry struct {
sortIndex int
kind int
id int
configs []json_util.RawMessage
}
const (
subBalancerTag = "balancer"
subBalancerProbeURL = "https://www.google.com/generate_204"
)
// subBalancerObservatoryConfig is the panel-wide burstObservatory ping config
// emitted into every client-side balancer doc (subJsonObservatory setting).
type subBalancerObservatoryConfig struct {
Destination string `json:"destination"`
Connectivity string `json:"connectivity"`
Interval string `json:"interval"`
Sampling int `json:"sampling"`
Timeout string `json:"timeout"`
HTTPMethod string `json:"httpMethod"`
}
func defaultSubBalancerObservatoryConfig() subBalancerObservatoryConfig {
return subBalancerObservatoryConfig{
Destination: subBalancerProbeURL,
Connectivity: "",
Interval: "1m",
Sampling: 2,
Timeout: "5s",
HTTPMethod: "HEAD",
}
}
// SetObservatoryConfig overrides defaults from the panel JSON setting. An empty
// cfg keeps all defaults; invalid values fall back with a warning, never panic.
func (s *SubJsonService) SetObservatoryConfig(cfg string) {
s.observatory = defaultSubBalancerObservatoryConfig()
if cfg == "" {
return
}
var parsed subBalancerObservatoryConfig
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
logger.Warningf("subJsonObservatory: invalid JSON %q, using defaults: %v", cfg, err)
return
}
if parsed.Destination != "" {
if validProbeURL(parsed.Destination) {
s.observatory.Destination = parsed.Destination
} else {
logger.Warningf("subJsonObservatory: invalid destination %q, keeping default %q", parsed.Destination, s.observatory.Destination)
}
}
if parsed.Connectivity != "" {
if validProbeURL(parsed.Connectivity) {
s.observatory.Connectivity = parsed.Connectivity
} else {
logger.Warningf("subJsonObservatory: invalid connectivity %q, keeping default (skip)", parsed.Connectivity)
}
}
if parsed.Interval != "" {
if _, err := time.ParseDuration(parsed.Interval); err == nil {
s.observatory.Interval = parsed.Interval
} else {
logger.Warningf("subJsonObservatory: invalid interval %q, keeping default %q", parsed.Interval, s.observatory.Interval)
}
}
if parsed.Sampling > 0 {
s.observatory.Sampling = parsed.Sampling
}
if parsed.Timeout != "" {
if _, err := time.ParseDuration(parsed.Timeout); err == nil {
s.observatory.Timeout = parsed.Timeout
} else {
logger.Warningf("subJsonObservatory: invalid timeout %q, keeping default %q", parsed.Timeout, s.observatory.Timeout)
}
}
if parsed.HTTPMethod == "HEAD" || parsed.HTTPMethod == "GET" {
s.observatory.HTTPMethod = parsed.HTTPMethod
}
}
// validProbeURL accepts only absolute http(s) URLs so a malformed probe or
// connectivity value can't slip into the emitted burstObservatory.
func validProbeURL(s string) bool {
u, err := url.Parse(s)
if err != nil || u == nil {
return false
}
return u.Scheme == "http" || u.Scheme == "https"
}
func (s *SubJsonService) balancerObservatory(prefix string) map[string]any {
o := s.observatory
return map[string]any{
"subjectSelector": []string{prefix},
"pingConfig": map[string]any{
"destination": o.Destination,
"connectivity": o.Connectivity,
"interval": o.Interval,
"sampling": o.Sampling,
"timeout": o.Timeout,
"httpMethod": o.HTTPMethod,
},
}
}
// appendBalancerEntries appends one entry per enabled balancer that has at
// least one member outbound among the inbound entries.
func (s *SubJsonService) appendBalancerEntries(entries []subConfigEntry) []subConfigEntry {
balancers := getEnabledSubBalancers()
if len(balancers) == 0 {
return entries
}
// Pre-pass: pull each inbound doc's proxy outbound once so every balancer
// reuses it instead of re-unmarshalling the whole document per balancer.
entryProxies := make([][]map[string]any, len(entries))
for i, entry := range entries {
if entry.kind != 0 {
continue
}
for _, config := range entry.configs {
if proxy := extractProxyOutbound(config); proxy != nil {
entryProxies[i] = append(entryProxies[i], proxy)
}
}
}
for i := range balancers {
config := s.buildBalancerConfig(&balancers[i], entries, entryProxies)
if config == nil {
continue
}
entries = append(entries, subConfigEntry{
sortIndex: balancers[i].SortOrder,
kind: 1,
id: balancers[i].Id,
configs: []json_util.RawMessage{config},
})
}
return entries
}
// extractProxyOutbound returns the first outbound of a document when it is the
// proxy (tag == "proxy"), else nil — the only member shape a balancer retags.
func extractProxyOutbound(config json_util.RawMessage) map[string]any {
var doc map[string]any
if json.Unmarshal(config, &doc) != nil {
return nil
}
outbounds, _ := doc["outbounds"].([]any)
if len(outbounds) == 0 {
return nil
}
outbound, _ := outbounds[0].(map[string]any)
if outbound == nil || outbound["tag"] != "proxy" {
return nil
}
return outbound
}
func getEnabledSubBalancers() []model.SubBalancer {
var balancers []model.SubBalancer
if err := database.GetDB().Model(&model.SubBalancer{}).
Where("enabled = ?", true).
Order("sort_order asc, id asc").Find(&balancers).Error; err != nil {
logger.Error("SubJsonService - getEnabledSubBalancers:", err)
return nil
}
return balancers
}
// Suffix by proxy protocol, not transport network — a vmess/tcp member used to
// be mislabelled "vless".
func balancerMemberSuffix(protocol string) string {
if protocol == "" {
return "other"
}
return protocol
}
// buildBalancerConfig assembles the balancer profile: members retagged under a
// per-balancer prefix, a routing.balancers entry, and (for leastPing/leastLoad) an observatory.
func (s *SubJsonService) buildBalancerConfig(balancer *model.SubBalancer, entries []subConfigEntry, entryProxies [][]map[string]any) json_util.RawMessage {
prefix := fmt.Sprintf("bal-%d-", balancer.Id)
usedTags := make(map[string]bool)
var proxies []json_util.RawMessage
var firstTag string
// entryProxies is the pre-extracted proxy outbounds per entry; kind!=0 rows
// have none. Clone before retagging so the cached map stays reusable.
for i, entry := range entries {
if entry.kind != 0 || !slices.Contains(balancer.InboundIds, entry.id) {
continue
}
for _, outbound := range entryProxies[i] {
protocol, _ := outbound["protocol"].(string)
base := prefix + balancerMemberSuffix(protocol)
tag := base
for suffix := 2; usedTags[tag]; suffix++ {
tag = fmt.Sprintf("%s-%d", base, suffix)
}
usedTags[tag] = true
member := maps.Clone(outbound)
member["tag"] = tag
if raw, err := json.MarshalIndent(member, "", " "); err == nil {
if firstTag == "" {
firstTag = tag
}
proxies = append(proxies, raw)
}
}
}
if len(proxies) == 0 {
return nil
}
outbounds := append([]json_util.RawMessage{}, proxies...)
outbounds = append(outbounds, s.defaultOutbounds...)
// The routing subtree in s.configJson is shared by every emitted document;
// clone it (and each rule map) before pointing rules at the balancer.
baseRouting, _ := s.configJson["routing"].(map[string]any)
routing := make(map[string]any, len(baseRouting)+1)
maps.Copy(routing, baseRouting)
baseRules, _ := baseRouting["rules"].([]any)
rules := make([]any, 0, len(baseRules)+1)
for _, rule := range baseRules {
ruleMap, ok := rule.(map[string]any)
if !ok {
rules = append(rules, rule)
continue
}
ruleMap = maps.Clone(ruleMap)
if ruleMap["outboundTag"] == "proxy" {
delete(ruleMap, "outboundTag")
ruleMap["balancerTag"] = subBalancerTag
}
rules = append(rules, ruleMap)
}
routing["rules"] = rules
isObservatory := balancer.Strategy == "leastPing" || balancer.Strategy == "leastLoad"
balancerEntry := map[string]any{
"tag": subBalancerTag,
"selector": []string{prefix},
"strategy": map[string]any{"type": balancer.Strategy},
}
if isObservatory && firstTag != "" {
// With all probes failing, route to the first member instead of
// failing dispatch.
balancerEntry["fallbackTag"] = firstTag
}
routing["balancers"] = []any{balancerEntry}
newConfigJson := make(map[string]any, len(s.configJson)+2)
maps.Copy(newConfigJson, s.configJson)
newConfigJson["outbounds"] = outbounds
newConfigJson["remarks"] = balancer.Remark
newConfigJson["routing"] = routing
// leastPing/leastLoad require a burst observatory (Xray refuses to start
// them without one); fallbackTag above covers the probe-outage case.
if isObservatory {
newConfigJson["burstObservatory"] = s.balancerObservatory(prefix)
}
config, _ := json.MarshalIndent(newConfigJson, "", " ")
return config
}
func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, client model.Client, host string) []json_util.RawMessage {
var newJsonArray []json_util.RawMessage
stream := s.streamData(inbound.StreamSettings, subKey(client))
// When externalProxy is empty the JSON config falls back to a
// synthetic one whose `dest` is the host the client connects to.
// For node-managed inbounds we want the node's address — request
// host won't reach the right xray. resolveInboundAddress already
// implements the node→subscriber-host fallback chain.
defaultDest := subReq.resolveInboundAddress(inbound)
if defaultDest == "" {
defaultDest = host
}
// Per-inbound xmux takes precedence over the global subJsonMux.
// When xmux is present inside xhttpSettings, XHTTP multiplexing
// is handled by xmux — don't also set the legacy outbound.Mux.
mux := s.mux
if xhttp, ok := stream["xhttpSettings"].(map[string]any); ok {
if _, hasXmux := xhttp["xmux"]; hasXmux {
mux = ""
}
}
externalProxies, ok := stream["externalProxy"].([]any)
hasExternalProxy := ok && len(externalProxies) > 0
if !hasExternalProxy {
externalProxies = []any{
map[string]any{
"forceTls": "same",
"dest": defaultDest,
"port": float64(inbound.Port),
"remark": "",
},
}
}
delete(stream, "externalProxy")
network, _ := stream["network"].(string)
for _, ep := range externalProxies {
extPrxy, ok := ep.(map[string]any)
if !ok {
continue
}
// Expand the host's {{VAR}} remark template for this client (no-op for
// the synthetic/legacy entry) before it's used as the config remark.
subReq.renderHostRemark(inbound, client, extPrxy, network)
inbound.Listen, _ = extPrxy["dest"].(string)
if port, ok := extPrxy["port"].(float64); ok {
inbound.Port = int(port)
}
newStream := cloneStreamForExternalProxy(stream)
forceTls, _ := extPrxy["forceTls"].(string)
switch forceTls {
case "tls":
if newStream["security"] != "tls" {
newStream["security"] = "tls"
newStream["tlsSettings"] = map[string]any{}
}
case "none":
if newStream["security"] != "none" {
newStream["security"] = "none"
delete(newStream, "tlsSettings")
}
}
security, _ := newStream["security"].(string)
if hasExternalProxy {
applyExternalProxyTLSToStream(extPrxy, newStream, security)
}
applyHostStreamOverrides(extPrxy, newStream)
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
hostMux := hostMuxOverride(extPrxy)
var newOutbounds []json_util.RawMessage
switch inbound.Protocol {
case "vmess":
newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, jsonMux(mux, hostMux)))
case "vless":
vc := client
vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
// Same gate the raw link and the Clash proxy apply: a flow left
// over from a transport Vision supported produces an outbound
// xray refuses to start.
newNetwork, _ := newStream["network"].(string)
if vc.Flow != "" && !vlessFlowAllowed(newNetwork, security, subReq.linkSettings(inbound)) {
vc.Flow = ""
}
newOutbounds = append(newOutbounds, s.genVless(subReq, inbound, streamSettings, vc, jsonMux(mux, hostMux)))
case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(subReq, inbound, streamSettings, client, jsonMux(mux, hostMux)))
case "hysteria":
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client, jsonMux(mux, hostMux)))
case "wireguard":
wgOutbound := s.genWireguard(inbound, client)
if wgOutbound == nil {
continue
}
newOutbounds = append(newOutbounds, wgOutbound)
}
newOutbounds = append(newOutbounds, s.defaultOutbounds...)
newConfigJson := make(map[string]any)
maps.Copy(newConfigJson, s.configJson)
transport, _ := newStream["network"].(string)
newConfigJson["outbounds"] = newOutbounds
newConfigJson["remarks"] = subReq.endpointRemark(inbound, client.Email, extPrxy, transport)
newConfig, _ := json.MarshalIndent(newConfigJson, "", " ")
newJsonArray = append(newJsonArray, newConfig)
}
return newJsonArray
}
func (s *SubJsonService) streamData(stream string, clientKey string) map[string]any {
var streamSettings map[string]any
if err := json.Unmarshal([]byte(stream), &streamSettings); err != nil || streamSettings == nil {
streamSettings = map[string]any{}
}
security, _ := streamSettings["security"].(string)
switch security {
case "tls":
if tlsSettings, ok := streamSettings["tlsSettings"].(map[string]any); ok {
streamSettings["tlsSettings"] = s.tlsData(tlsSettings)
} else {
delete(streamSettings, "tlsSettings")
}
case "reality":
if realitySettings, ok := streamSettings["realitySettings"].(map[string]any); ok {
streamSettings["realitySettings"] = s.realityData(realitySettings, clientKey)
} else {
delete(streamSettings, "realitySettings")
}
}
delete(streamSettings, "sockopt")
if s.finalMask != "" {
s.applyGlobalFinalMask(streamSettings)
}
// remove proxy protocol
network, _ := streamSettings["network"].(string)
switch network {
case "tcp":
streamSettings["tcpSettings"] = s.removeAcceptProxy(streamSettings["tcpSettings"])
case "ws":
streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
case "httpupgrade":
streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
case "xhttp":
streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
delete(xhttp, "noSSEHeader")
delete(xhttp, "scMaxBufferedPosts")
delete(xhttp, "scStreamUpServerSecs")
delete(xhttp, "serverMaxHeaderBytes")
// Values matching xray-core's own defaults stay off the wire:
// old panels seeded them into every stored config and the
// literal scMinPostsIntervalMs=30 is a DPI fingerprint (#5141).
if v, _ := xhttp["scMaxEachPostBytes"].(string); v == "" || v == "1000000" {
delete(xhttp, "scMaxEachPostBytes")
}
if v, _ := xhttp["scMinPostsIntervalMs"].(string); v == "" || v == "30" {
delete(xhttp, "scMinPostsIntervalMs")
}
}
}
return streamSettings
}
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
var fm map[string]any
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
return
}
merged := mergeFinalMask(streamSettings["finalmask"], fm)
if len(merged) > 0 {
streamSettings["finalmask"] = merged
}
}
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
netSettings, ok := setting.(map[string]any)
if ok {
delete(netSettings, "acceptProxyProtocol")
}
return netSettings
}
func (s *SubJsonService) tlsData(tData map[string]any) map[string]any {
tlsData := make(map[string]any, 1)
tlsClientSettings, _ := tData["settings"].(map[string]any)
tlsData["serverName"] = tData["serverName"]
tlsData["alpn"] = tData["alpn"]
if fingerprint, ok := tlsClientSettings["fingerprint"].(string); ok {
tlsData["fingerprint"] = fingerprint
}
if cs, ok := tData["cipherSuites"].(string); ok && cs != "" {
tlsData["cipherSuites"] = cs
}
if ech, ok := tlsClientSettings["echConfigList"].(string); ok && ech != "" {
tlsData["echConfigList"] = ech
}
if vcn, ok := verifyPeerCertByNameValue(tlsClientSettings); ok {
tlsData["verifyPeerCertByName"] = vcn
}
// xray-core now parses pinnedPeerCertSha256 as a comma-separated string, not
// an array; emit the joined form so v2ray clients can import the config (#5401).
if pins, ok := pinnedSha256List(tlsClientSettings); ok {
tlsData["pinnedPeerCertSha256"] = strings.Join(pins, ",")
}
return tlsData
}
func (s *SubJsonService) realityData(rData map[string]any, clientKey string) map[string]any {
rltyData := make(map[string]any, 1)
rltyClientSettings, _ := rData["settings"].(map[string]any)
rltyData["show"] = false
rltyData["publicKey"] = rltyClientSettings["publicKey"]
rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
seed, _ := rltyClientSettings["spiderX"].(string)
rltyData["spiderX"] = deriveSpiderX(seed, clientKey)
shortIds, ok := rData["shortIds"].([]any)
if ok && len(shortIds) > 0 {
rltyData["shortId"], _ = shortIds[random.Num(len(shortIds))].(string)
} else {
rltyData["shortId"] = ""
}
serverNames, ok := rData["serverNames"].([]any)
if ok && len(serverNames) > 0 {
rltyData["serverName"], _ = serverNames[random.Num(len(serverNames))].(string)
} else {
rltyData["serverName"] = ""
}
return rltyData
}
// jsonMux picks the per-host mux override when present, else the global mux.
func jsonMux(global, override string) string {
if override != "" {
return override
}
return global
}
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
Tag: "proxy",
}
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
}
outbound.StreamSettings = streamSettings
security := normalizeVmessSecurity(client.Security)
outbound.Settings = map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"security": security,
"level": 8,
}
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
func (s *SubJsonService) genVless(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
Tag: "proxy",
}
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
}
outbound.StreamSettings = streamSettings
// Add encryption for VLESS outbound from inbound settings
inboundSettings := subReq.linkSettings(inbound)
encryption, _ := inboundSettings["encryption"].(string)
settings := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"encryption": encryption,
"level": 8,
}
if client.Flow != "" && !inbound.DisableFlow {
settings["flow"] = client.Flow
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
func (s *SubJsonService) genServer(subReq *SubService, inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{}
serverData := make([]ServerSetting, 1)
serverData[0] = ServerSetting{
Address: inbound.Listen,
Port: inbound.Port,
Level: 8,
Password: client.Password,
}
if inbound.Protocol == model.Shadowsocks {
inboundSettings := subReq.linkSettings(inbound)
method, _ := inboundSettings["method"].(string)
serverData[0].Method = method
// server password in multi-user 2022 protocols
if strings.HasPrefix(method, "2022") {
if serverPassword, ok := inboundSettings["password"].(string); ok {
serverData[0].Password = fmt.Sprintf("%s:%s", serverPassword, client.Password)
}
}
}
outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy"
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
}
outbound.StreamSettings = streamSettings
// Wrap the endpoint in a "servers" array (the standard Xray schema for
// Shadowsocks/Trojan outbounds). The flat top-level form only parses on very
// recent xray-core; older bundled cores (e.g. in v2rayN) reject it, so SS
// links fail to connect. See genVnext/genVless for the VMess/VLESS shape.
server := map[string]any{
"address": serverData[0].Address,
"port": serverData[0].Port,
"password": serverData[0].Password,
"level": 8,
}
if inbound.Protocol == model.Shadowsocks {
server["method"] = serverData[0].Method
}
outbound.Settings = map[string]any{
"servers": []any{server},
}
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any, client model.Client, mux string) json_util.RawMessage {
outbound := Outbound{
Protocol: string(inbound.Protocol),
Tag: "proxy",
}
if mux != "" {
outbound.Mux = json_util.RawMessage(mux)
}
var settings, stream map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &settings)
version, _ := settings["version"].(float64)
outbound.Settings = map[string]any{
"version": int(version),
"address": inbound.Listen,
"port": inbound.Port,
}
_ = json.Unmarshal([]byte(inbound.StreamSettings), &stream)
hyStream, _ := stream["hysteriaSettings"].(map[string]any)
outHyStream := map[string]any{
"version": int(version),
"auth": client.Auth,
}
if udpIdleTimeout, ok := hyStream["udpIdleTimeout"].(float64); ok {
outHyStream["udpIdleTimeout"] = int(udpIdleTimeout)
}
if masquerade, ok := hyStream["masquerade"].(map[string]any); ok {
outHyStream["masquerade"] = masquerade
}
newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
}
newStream["network"] = "hysteria"
newStream["security"] = "tls"
outbound.StreamSettings, _ = json.MarshalIndent(newStream, "", " ")
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
// genWireguard builds an Xray wireguard outbound for a native WireGuard inbound,
// mirroring genWireguardLink: the peer public key is derived from the inbound
// secretKey, the client owns the private key / tunnel address / pre-shared key,
// and the peer routes the full tunnel. Returns nil when the client has no key.
func (s *SubJsonService) genWireguard(inbound *model.Inbound, client model.Client) json_util.RawMessage {
if client.PrivateKey == "" {
return nil
}
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
secretKey, _ := inboundSettings["secretKey"].(string)
peer := map[string]any{
"endpoint": joinHostPort(inbound.Listen, inbound.Port),
"allowedIPs": []string{"0.0.0.0/0", "::/0"},
}
if secretKey != "" {
if pub, err := wgutil.PublicKeyFromPrivate(secretKey); err == nil {
peer["publicKey"] = pub
}
}
if client.PreSharedKey != "" {
peer["preSharedKey"] = client.PreSharedKey
}
if client.KeepAlive > 0 {
peer["keepAlive"] = client.KeepAlive
}
settings := map[string]any{
"secretKey": client.PrivateKey,
"peers": []any{peer},
}
if len(client.AllowedIPs) > 0 {
settings["address"] = client.AllowedIPs
}
if mtu, ok := inboundSettings["mtu"].(float64); ok && mtu > 0 {
settings["mtu"] = int(mtu)
}
outbound := map[string]any{
"protocol": string(inbound.Protocol),
"tag": "proxy",
"settings": settings,
}
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
func mergeFinalMask(base any, extra map[string]any) map[string]any {
merged := map[string]any{}
if baseMap, ok := base.(map[string]any); ok {
for key, value := range baseMap {
switch key {
case "tcp", "udp":
if masks, ok := value.([]any); ok {
merged[key] = append([]any(nil), masks...)
}
default:
merged[key] = value
}
}
}
for key, value := range extra {
switch key {
case "tcp", "udp":
baseMasks, _ := merged[key].([]any)
extraMasks, _ := value.([]any)
if len(extraMasks) > 0 {
merged[key] = append(baseMasks, extraMasks...)
}
case "quicParams":
if _, exists := merged[key]; !exists {
merged[key] = value
}
default:
merged[key] = value
}
}
return merged
}
type Outbound struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
StreamSettings json_util.RawMessage `json:"streamSettings"`
Mux json_util.RawMessage `json:"mux,omitempty"`
Settings map[string]any `json:"settings,omitempty"`
}
type ServerSetting struct {
Password string `json:"password"`
Level int `json:"level"`
Address string `json:"address"`
Port int `json:"port"`
Flow string `json:"flow,omitempty"`
Method string `json:"method,omitempty"`
}
-434
View File
@@ -1,434 +0,0 @@
package sub
import (
"encoding/json"
"reflect"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
func hasDirectOutOutbound(svc *SubJsonService) bool {
for _, raw := range svc.defaultOutbounds {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
continue
}
if outbound["tag"] == "direct_out" {
return true
}
}
return false
}
func outboundSettings(t *testing.T, raw []byte) map[string]any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal outbound: %v", err)
}
settings, _ := parsed["settings"].(map[string]any)
if settings == nil {
t.Fatal("outbound has no settings")
}
return settings
}
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", finalMask, nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
}
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
}
finalmask, _ := stream["finalmask"].(map[string]any)
if finalmask == nil {
t.Fatal("streamSettings is missing finalmask")
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
}
udp, _ := finalmask["udp"].([]any)
if len(udp) != 1 {
t.Fatalf("udp masks len = %d, want 1", len(udp))
}
quic, _ := finalmask["quicParams"].(map[string]any)
if quic == nil || quic["congestion"] != "bbr" {
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
}
}
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", finalMask, nil)
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]}
}`, "")
finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 2 {
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
}
a, _ := tcp[0].(map[string]any)
b, _ := tcp[1].(map[string]any)
if a["type"] != "sudoku" || b["type"] != "fragment" {
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
}
}
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
}
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out sockopt must never be set")
}
}
// xray-core parses tlsSettings.pinnedPeerCertSha256 as a comma-separated string;
// the JSON subscription must emit that form, not an array, or v2ray clients fail
// to import the config (#5401).
func TestSubJsonServicePinnedCertJoinedToString(t *testing.T) {
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","settings":{"pinnedPeerCertSha256":["aa11","bb22"]}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any)
if tls == nil {
t.Fatalf("tlsSettings missing: %#v", stream)
}
if got := tls["pinnedPeerCertSha256"]; got != "aa11,bb22" {
t.Fatalf("pinnedPeerCertSha256 = %#v, want comma-separated string \"aa11,bb22\"", got)
}
}
func TestSubJsonServiceTLSCipherSuitesForwarded(t *testing.T) {
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"TLS_AES_256_GCM_SHA384","settings":{}}}`, "")
tls, _ := stream["tlsSettings"].(map[string]any)
if got := tls["cipherSuites"]; got != "TLS_AES_256_GCM_SHA384" {
t.Fatalf("cipherSuites = %#v, want %q", got, "TLS_AES_256_GCM_SHA384")
}
stream = svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"a.example.com","cipherSuites":"","settings":{}}}`, "")
tls, _ = stream["tlsSettings"].(map[string]any)
if _, present := tls["cipherSuites"]; present {
t.Fatalf("empty cipherSuites must be omitted, got %#v", tls["cipherSuites"])
}
}
func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext")
}
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
t.Fatalf("flat vless settings wrong: %#v", settings)
}
}
func TestSubJsonServiceVlessFlowSuppressedByDisableFlow(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`, DisableFlow: true}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVless(&SubService{}, inbound, nil, client, ""))
if _, ok := settings["flow"]; ok {
t.Fatalf("DisableFlow inbound must not carry a flow in the JSON outbound: %#v", settings)
}
}
func TestSubJsonServiceVmessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
client := model.Client{ID: "uuid-2"}
settings := outboundSettings(t, NewSubJsonService("", "", nil).genVnext(inbound, nil, client, ""))
if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext")
}
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
t.Fatalf("flat vmess settings wrong: %#v", settings)
}
}
// Shadowsocks/Trojan outbounds must use the standard "servers" array so older
// bundled xray-cores (e.g. v2rayN) parse them; the flat top-level form only
// works on very recent xray-core.
func TestSubJsonServiceServerUsesServersArray(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, trojan, nil, client, ""))
server := firstServer(settings)
if server == nil {
t.Fatalf("trojan outbound must use a servers array, got: %#v", settings)
}
if server["password"] != "p4ss" || server["address"] != "1.2.3.4" {
t.Fatalf("trojan server entry wrong: %#v", server)
}
if _, ok := server["method"]; ok {
t.Fatalf("trojan must not carry method: %#v", server)
}
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", nil).genServer(&SubService{}, ss, nil, client, ""))
ssServer := firstServer(ssSettings)
if ssServer == nil {
t.Fatalf("shadowsocks outbound must use a servers array, got: %#v", ssSettings)
}
if ssServer["method"] != "aes-256-gcm" {
t.Fatalf("shadowsocks server entry must carry method: %#v", ssServer)
}
}
func TestSubJsonServiceXmuxSuppressesGlobalMux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", nil)
// When xmux is present in xhttpSettings, the per-inbound xmux handles
// multiplexing and the legacy outbound.Mux must NOT be set.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up","xmux":{"maxConcurrency":"16-32"}}}`
parsed := svc.streamData(stream, "")
mux := globalMux
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
if _, hasXmux := xhttp["xmux"]; hasXmux {
mux = ""
}
}
streamSettings, _ := json.Marshal(parsed)
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1"}
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
var ob map[string]any
if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
if _, has := ob["mux"]; has {
t.Fatal("outbound.Mux must NOT be set when per-inbound xmux is present")
}
// Verify xmux is still inside xhttpSettings in streamSettings.
ss, _ := ob["streamSettings"].(map[string]any)
if ss == nil {
t.Fatal("streamSettings missing from outbound")
}
xhttp, _ := ss["xhttpSettings"].(map[string]any)
if xhttp == nil {
t.Fatal("xhttpSettings missing from streamSettings")
}
xmux, _ := xhttp["xmux"].(map[string]any)
if xmux == nil {
t.Fatal("xmux missing from xhttpSettings — per-inbound xmux must survive streamData()")
}
if xmux["maxConcurrency"] != "16-32" {
t.Fatalf("xmux.maxConcurrency = %v, want 16-32", xmux["maxConcurrency"])
}
}
func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
globalMux := `{"enabled":true,"concurrency":8}`
svc := NewSubJsonService(globalMux, "", nil)
// When no xmux is present, the global subJsonMux should be used.
stream := `{"network":"xhttp","security":"tls","tlsSettings":{"serverName":"example.com"},"xhttpSettings":{"path":"/api","mode":"packet-up"}}`
parsed := svc.streamData(stream, "")
mux := globalMux
if xhttp, ok := parsed["xhttpSettings"].(map[string]any); ok {
if _, hasXmux := xhttp["xmux"]; hasXmux {
mux = ""
}
}
streamSettings, _ := json.Marshal(parsed)
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1"}
raw := svc.genVless(&SubService{}, inbound, streamSettings, client, mux)
var ob map[string]any
if err := json.Unmarshal(raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
m, has := ob["mux"]
if !has {
t.Fatal("outbound.Mux must be set when global subJsonMux is configured and no per-inbound xmux")
}
mm, _ := m.(map[string]any)
if mm["enabled"] != true || mm["concurrency"] != float64(8) {
t.Fatalf("mux payload wrong: %#v", m)
}
}
func realitySpiderXFromStream(t *testing.T, svc *SubJsonService, clientKey string) string {
t.Helper()
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/seed"}
}
}`, clientKey)
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX = %q, want a 16-char /-prefixed value", spx)
}
return spx
}
func TestSubJsonServiceRealityDataDerivesPerClientSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", nil)
alice := realitySpiderXFromStream(t, svc, "subAlice")
if again := realitySpiderXFromStream(t, svc, "subAlice"); again != alice {
t.Fatalf("spiderX not stable for the same client: %q vs %q", alice, again)
}
if bob := realitySpiderXFromStream(t, svc, "subBob"); bob == alice {
t.Fatalf("spiderX identical across clients (fingerprintable): %q", alice)
}
}
// streamData must tolerate malformed stored inbounds: unparseable stream JSON
// (with a finalMask configured, which writes into the map) and tls/reality
// security whose settings key is missing or null previously panicked the
// subscription request.
func TestSubJsonServiceStreamDataMalformedInputs(t *testing.T) {
withMask := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
stream := withMask.streamData("not-json", "clientKey")
if _, ok := stream["finalmask"]; !ok {
t.Fatal("finalMask must still apply when stream settings fail to parse")
}
svc := NewSubJsonService("", "", nil)
noReality := svc.streamData(`{"network":"tcp","security":"reality"}`, "clientKey")
if v, ok := noReality["realitySettings"]; ok {
t.Fatalf("missing realitySettings must stay absent, got %v", v)
}
nullTls := svc.streamData(`{"network":"tcp","security":"tls","tlsSettings":null}`, "")
if v, ok := nullTls["tlsSettings"]; ok {
t.Fatalf("null tlsSettings must be dropped, got %v", v)
}
}
func TestSubJsonServiceRealityDataSpiderXFallsBackWhenNoClientKey(t *testing.T) {
svc := NewSubJsonService("", "", nil)
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`, "")
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
}
}
func TestSubJsonServiceWireguard(t *testing.T) {
serverPriv, serverPub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("server keypair: %v", err)
}
clientPriv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatalf("client keypair: %v", err)
}
inbound := &model.Inbound{
Listen: "203.0.113.9",
Port: 51820,
Protocol: model.WireGuard,
Settings: `{"secretKey":"` + serverPriv + `","mtu":1420}`,
}
client := model.Client{
Email: "user",
PrivateKey: clientPriv,
PreSharedKey: "psk-value",
KeepAlive: 25,
AllowedIPs: []string{"10.0.0.2/32", "fd00::2/128"},
}
raw := NewSubJsonService("", "", nil).genWireguard(inbound, client)
if raw == nil {
t.Fatal("genWireguard returned nil for a valid wireguard client")
}
settings := outboundSettings(t, raw)
if settings["secretKey"] != clientPriv {
t.Fatalf("secretKey = %v, want client private key", settings["secretKey"])
}
address, _ := settings["address"].([]any)
if len(address) != 2 || address[0] != "10.0.0.2/32" || address[1] != "fd00::2/128" {
t.Fatalf("address = %v, want client tunnel addresses", settings["address"])
}
if settings["mtu"] != float64(1420) {
t.Fatalf("mtu = %v, want 1420", settings["mtu"])
}
peers, _ := settings["peers"].([]any)
if len(peers) != 1 {
t.Fatalf("peers len = %d, want 1", len(peers))
}
peer, _ := peers[0].(map[string]any)
if peer["publicKey"] != serverPub {
t.Fatalf("peer publicKey = %v, want %v (derived from inbound secretKey)", peer["publicKey"], serverPub)
}
if peer["endpoint"] != "203.0.113.9:51820" {
t.Fatalf("peer endpoint = %v, want 203.0.113.9:51820", peer["endpoint"])
}
if peer["preSharedKey"] != "psk-value" {
t.Fatalf("peer preSharedKey = %v, want psk-value", peer["preSharedKey"])
}
if peer["keepAlive"] != float64(25) {
t.Fatalf("peer keepAlive = %v, want 25", peer["keepAlive"])
}
allowed, _ := peer["allowedIPs"].([]any)
if !reflect.DeepEqual(allowed, []any{"0.0.0.0/0", "::/0"}) {
t.Fatalf("peer allowedIPs = %v, want full tunnel", peer["allowedIPs"])
}
}
func TestSubJsonServiceWireguardNoKey(t *testing.T) {
inbound := &model.Inbound{Listen: "203.0.113.9", Port: 51820, Protocol: model.WireGuard, Settings: `{}`}
client := model.Client{Email: "user"}
if raw := NewSubJsonService("", "", nil).genWireguard(inbound, client); raw != nil {
t.Fatalf("genWireguard = %s, want nil for a keyless wireguard client", raw)
}
}
-118
View File
@@ -25,79 +25,11 @@ func initMutDB(t *testing.T) {
t.Cleanup(func() { _ = database.CloseDB() })
}
func TestSubJsonService_EmptyRulesLeavesDefault(t *testing.T) {
svc := NewSubJsonService("", "", nil)
routing, _ := svc.configJson["routing"].(map[string]any)
got, _ := routing["rules"].([]any)
if len(got) != 1 {
t.Fatalf("rules len = %d, want 1 (no custom rules → default untouched)", len(got))
}
}
// --- json_service.go:331,356,408 — mux is attached only when configured ---
func TestSubJsonService_MuxAttachedWhenConfigured(t *testing.T) {
const mux = `{"enabled":true,"concurrency":8}`
client := model.Client{ID: "uuid-1", Password: "p4ss"}
cases := []struct {
name string
raw []byte
wantMux bool
protocol model.Protocol
}{
{"vmess mux", NewSubJsonService(mux, "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, mux), true, model.VMESS},
{"vless mux", NewSubJsonService(mux, "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, mux), true, model.VLESS},
{"server mux", NewSubJsonService(mux, "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, mux), true, model.Trojan},
{"vmess no mux", NewSubJsonService("", "", nil).genVnext(&model.Inbound{Protocol: model.VMESS, Settings: `{}`}, nil, client, ""), false, model.VMESS},
{"vless no mux", NewSubJsonService("", "", nil).genVless(&SubService{}, &model.Inbound{Protocol: model.VLESS, Settings: `{}`}, nil, client, ""), false, model.VLESS},
{"server no mux", NewSubJsonService("", "", nil).genServer(&SubService{}, &model.Inbound{Protocol: model.Trojan, Settings: `{}`}, nil, client, ""), false, model.Trojan},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var ob map[string]any
if err := json.Unmarshal(tc.raw, &ob); err != nil {
t.Fatalf("unmarshal outbound: %v", err)
}
m, has := ob["mux"]
if tc.wantMux {
if !has {
t.Fatalf("mux must be set when configured, outbound = %#v", ob)
}
mm, _ := m.(map[string]any)
if mm["enabled"] != true || mm["concurrency"] != float64(8) {
t.Fatalf("mux payload wrong: %#v", m)
}
} else if has {
t.Fatalf("mux must be omitted when empty, outbound = %#v", ob)
}
})
}
}
// --- applyGlobalFinalMask — a non-empty finalMask that merges to nothing must
// not add the finalmask key (the `len(merged) > 0` guard). ---
func TestSubJsonService_FinalMaskMergingToEmptyNotAdded(t *testing.T) {
// finalMask is non-empty (passes the len(fm)==0 early return) but its only
// key is an empty tcp slice, which mergeFinalMask drops → merged is empty,
// so applyGlobalFinalMask must NOT set finalmask.
svc := NewSubJsonService("", `{"tcp":[]}`, nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream["finalmask"]; ok {
t.Fatalf("finalMask merging to empty must not add a finalmask key: %#v", stream["finalmask"])
}
// Sanity: a finalMask that DOES merge to something still gets set, so the
// guard is the only distinguishing factor.
svc2 := NewSubJsonService("", `{"tcp":[{"type":"fragment"}]}`, nil)
stream2 := svc2.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`, "")
if _, ok := stream2["finalmask"]; !ok {
t.Fatal("non-empty finalMask must be set")
}
}
// --- mergeFinalMask — an empty extra tcp slice must not clobber the base ---
func TestMergeFinalMask_EmptyExtraTcpKeepsBase(t *testing.T) {
@@ -419,53 +351,3 @@ func TestDecodeSubscriptionBody_StripsWhitespaceInBase64(t *testing.T) {
t.Fatalf("whitespace-laden base64 body decoded wrong: %#v", links)
}
}
// --- clash_service.go:123 — duplicate proxy names disambiguate as base-N ---
func TestEnsureUniqueProxyNames_SuffixSequence(t *testing.T) {
proxies := []map[string]any{
{"name": "node"},
{"name": "node"},
{"name": "node"},
}
ensureUniqueProxyNames(proxies)
if proxies[0]["name"] != "node" {
t.Fatalf("first occurrence must keep base name, got %v", proxies[0]["name"])
}
if proxies[1]["name"] != "node-2" {
t.Fatalf("second duplicate = %v, want node-2", proxies[1]["name"])
}
if proxies[2]["name"] != "node-3" {
t.Fatalf("third duplicate = %v, want node-3", proxies[2]["name"])
}
}
// --- clash_service.go:422,447 — empty transport opts must NOT add the *-opts key ---
func TestApplyTransport_EmptyOptsOmitted(t *testing.T) {
svc := &SubClashService{}
// httpupgrade with no path/host → opts empty → no http-upgrade-opts key (clash:422).
huProxy := map[string]any{}
if !svc.applyTransport(huProxy, "httpupgrade", map[string]any{"httpupgradeSettings": map[string]any{}}) {
t.Fatal("httpupgrade must still be buildable")
}
if huProxy["network"] != "httpupgrade" {
t.Fatalf("network = %v, want httpupgrade", huProxy["network"])
}
if _, ok := huProxy["http-upgrade-opts"]; ok {
t.Fatalf("empty opts must not set http-upgrade-opts: %#v", huProxy["http-upgrade-opts"])
}
// xhttp with no path/host/mode → opts empty → no xhttp-opts key (clash:447).
xhProxy := map[string]any{}
if !svc.applyTransport(xhProxy, "xhttp", map[string]any{"xhttpSettings": map[string]any{}}) {
t.Fatal("xhttp must still be buildable")
}
if xhProxy["network"] != "xhttp" {
t.Fatalf("network = %v, want xhttp", xhProxy["network"])
}
if _, ok := xhProxy["xhttp-opts"]; ok {
t.Fatalf("empty opts must not set xhttp-opts: %#v", xhProxy["xhttp-opts"])
}
}
+2 -2
View File
@@ -20,7 +20,7 @@ func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) {
}
emails := []string{"john@x", "alice@x"}
page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "", "", "/", "", "")
page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "/", "", "")
if len(page.Result) != 4 {
t.Fatalf("Result len = %d, want 4 (3 host links + 1 single link)", len(page.Result))
@@ -61,7 +61,7 @@ func TestSubIsOnline(t *testing.T) {
func TestBuildPageData_IsOnlineFalseWithoutLiveConnections(t *testing.T) {
s := &SubService{}
page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, []string{"vless://a@h1:443?type=tcp#DE-john@x"}, []string{"john@x"}, "", "", "", "/", "", "")
page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, []string{"vless://a@h1:443?type=tcp#DE-john@x"}, []string{"john@x"}, "", "/", "", "")
if page.IsOnline {
t.Fatal("IsOnline must be false when the subscription's client has no live connection")
+54 -50
View File
@@ -200,9 +200,7 @@ func (s *SubService) linkSettings(inbound *model.Inbound) map[string]any {
}
// loadRemarkSettings populates the per-request remark formatting state so
// every subscription format — raw, JSON, Clash — renders remarks the same way
// (the date formatter reads datepicker). Loading it only in getSubs left
// JSON/Clash with the zero value.
// link remarks render consistently (the date formatter reads datepicker).
func (s *SubService) loadRemarkSettings() {
var err error
s.datepicker, err = s.settingService.GetDatepicker()
@@ -401,9 +399,8 @@ func (s *SubService) inboundLinks(inbound *model.Inbound) []string {
// query and folds the rows into a single ClientTraffic + lastOnline.
// xray.ClientTraffic.Email is globally unique, so a multi-inbound
// client's single row is attached to exactly one inbound — iterating
// per-inbound ClientStats would miss it on the others. Used by GetSubs,
// SubClashService.GetClash, and SubJsonService.GetJson to keep the
// sub-info header consistent across all three formats.
// per-inbound ClientStats would miss it on the others. Used by GetSubs
// to keep the sub-info header consistent.
func (s *SubService) AggregateTrafficByEmails(emails []string) (xray.ClientTraffic, int64) {
var agg xray.ClientTraffic
var lastOnline int64
@@ -1980,9 +1977,8 @@ func externalProxyALPNList(value any) ([]any, bool) {
}
// externalProxyPins extracts an external-proxy entry's pinnedPeerCertSha256
// as a []any of non-empty strings. The []any element type matches what the
// JSON/Clash sub builders expect when reading the value back off the cloned
// stream's tlsSettings.settings.
// as a []any of non-empty strings, matching the shape link building expects
// when reading the value back off the cloned stream's tlsSettings.settings.
func externalProxyPins(value any) ([]any, bool) {
switch v := value.(type) {
case []string:
@@ -2719,8 +2715,6 @@ type PageData struct {
UploadByte int64
TotalByte int64
SubUrl string
SubJsonUrl string
SubClashUrl string
SubTitle string
SubSupportUrl string
SubAnnounce string
@@ -2783,51 +2777,21 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
return
}
// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID.
// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components.
func (s *SubService) BuildURLs(subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
// BuildURLs constructs the absolute subscription URL for a given subscription ID.
// It prioritizes a configured URI, then individual settings, and finally falls
// back to request-derived components.
func (s *SubService) BuildURLs(subPath, subId string) (subURL string) {
if subId == "" {
return "", "", ""
return ""
}
configuredSubURI, _ := s.settingService.GetSubURI()
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
// Same base as the panel's Client Information page; s.address is the
// subscriber's host already normalized away from any loopback/bind IP.
base := s.settingService.BuildSubURIBase(s.address)
subURL = s.buildSingleURL(configuredSubURI, base, subPath, subId)
// When subURI is explicitly configured (reverse-proxy setup), use its
// scheme+host as the base for JSON and Clash URLs so they match the
// reverse-proxy endpoint instead of the raw sub-server port. Fall back
// to the request-derived base if subURI is empty or can't be parsed
// into a scheme+host (e.g. a malformed value with no scheme).
jsonClashBase := base
if configuredSubURI != "" {
if derived := s.extractBaseFromURI(configuredSubURI); derived != "" {
jsonClashBase = derived
}
}
subJsonURL = s.buildSingleURL(configuredSubJsonURI, jsonClashBase, subJsonPath, subId)
subClashURL = s.buildSingleURL(configuredSubClashURI, jsonClashBase, subClashPath, subId)
return subURL, subJsonURL, subClashURL
}
// extractBaseFromURI extracts scheme://host from a configured URI.
// e.g., "https://example.com/sub-xxx/" → "https://example.com".
// Returns "" when the URI is empty or lacks a scheme/host, so callers can
// fall back to the request-derived base instead of emitting a broken value.
func (s *SubService) extractBaseFromURI(uri string) string {
u, err := url.Parse(uri)
if err != nil || u.Scheme == "" || u.Host == "" {
return ""
}
return fmt.Sprintf("%s://%s", u.Scheme, u.Host)
return s.buildSingleURL(configuredSubURI, base, subPath, subId)
}
// buildSingleURL constructs a single URL using configured URI or base components
@@ -2848,7 +2812,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page.
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, emails []string, subURL, subJsonURL, subClashURL string, basePath string, subTitle string, subSupportUrl string) PageData {
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, emails []string, subURL string, basePath string, subTitle string, subSupportUrl string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
@@ -2896,8 +2860,6 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
UploadByte: traffic.Up,
TotalByte: traffic.Total,
SubUrl: subURL,
SubJsonUrl: subJsonURL,
SubClashUrl: subClashURL,
SubTitle: subTitle,
SubSupportUrl: subSupportUrl,
Result: pageLinks,
@@ -2944,3 +2906,45 @@ func extraSalamanderKeys(settings map[string]any) []string {
sort.Strings(extra)
return extra
}
func mergeFinalMask(base any, extra map[string]any) map[string]any {
merged := map[string]any{}
if baseMap, ok := base.(map[string]any); ok {
for key, value := range baseMap {
switch key {
case "tcp", "udp":
if masks, ok := value.([]any); ok {
merged[key] = append([]any(nil), masks...)
}
default:
merged[key] = value
}
}
}
for key, value := range extra {
switch key {
case "tcp", "udp":
baseMasks, _ := merged[key].([]any)
extraMasks, _ := value.([]any)
if len(extraMasks) > 0 {
merged[key] = append(baseMasks, extraMasks...)
}
case "quicParams":
if _, exists := merged[key]; !exists {
merged[key] = value
}
default:
merged[key] = value
}
}
return merged
}
func cloneMap(src map[string]any) map[string]any {
if src == nil {
return nil
}
dst := make(map[string]any, len(src))
maps.Copy(dst, src)
return dst
}
-72
View File
@@ -69,51 +69,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
return nil, err
}
JsonPath, err := s.settingService.GetSubJsonPath()
if err != nil {
return nil, err
}
ClashPath, err := s.settingService.GetSubClashPath()
if err != nil {
return nil, err
}
subJsonEnable, err := s.settingService.GetSubJsonEnable()
if err != nil {
return nil, err
}
subClashEnable, err := s.settingService.GetSubClashEnable()
if err != nil {
return nil, err
}
subClashAutoDetect, err := s.settingService.GetSubClashAutoDetect()
if err != nil {
subClashAutoDetect = false
}
subJsonAutoDetect, err := s.settingService.GetSubJsonAutoDetect()
if err != nil {
subJsonAutoDetect = false
}
subJsonAlwaysArray, err := s.settingService.GetSubJsonAlwaysArray()
if err != nil {
subJsonAlwaysArray = false
}
subJsonUserAgentRegex, err := s.settingService.GetSubJsonUserAgentRegex()
if err != nil {
subJsonUserAgentRegex = service.DefaultSubJsonUserAgentRegex
}
subClashUserAgentRegex, err := s.settingService.GetSubClashUserAgentRegex()
if err != nil {
subClashUserAgentRegex = service.DefaultSubClashUserAgentRegex
}
// Set base_path based on LinksPath for template rendering
// Ensure LinksPath ends with "/" for proper asset URL generation
basePath := LinksPath
@@ -140,21 +95,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubUpdates = "10"
}
SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil {
SubJsonMux = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
}
SubJsonObservatory, err := s.settingService.GetSubJsonObservatory()
if err != nil {
SubJsonObservatory = ""
}
SubTitle, err := s.settingService.GetSubTitle()
if err != nil {
SubTitle = ""
@@ -236,21 +176,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(g,
WithSUBPath(LinksPath),
WithSUBJsonPath(JsonPath),
WithSUBClashPath(ClashPath),
WithSUBClashAutoDetect(subClashAutoDetect),
WithSUBClashUserAgentRegex(subClashUserAgentRegex),
WithSUBJsonAutoDetect(subJsonAutoDetect),
WithSUBJsonUserAgentRegex(subJsonUserAgentRegex),
WithSUBJsonAlwaysArray(subJsonAlwaysArray),
WithSUBJsonEnabled(subJsonEnable),
WithSUBClashEnabled(subClashEnable),
WithSUBEncryption(Encrypt),
WithSUBRemarkTemplate(RemarkTemplate),
WithSUBUpdateInterval(SubUpdates),
WithSUBJsonMux(SubJsonMux),
WithSUBJsonFinalMask(SubJsonFinalMask),
WithSUBJsonObservatory(SubJsonObservatory),
WithSUBTitle(SubTitle),
WithSUBSupportURL(SubSupportUrl),
WithSUBProfileURL(SubProfileUrl),
@@ -1,60 +0,0 @@
package sub
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// seedSubProtocolInbound seeds an inbound of the given protocol with one client
// wired into the clients/client_inbounds tables so getInboundsBySubId resolves it.
func seedSubProtocolInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string, protocol model.Protocol) *model.Inbound {
t.Helper()
db := database.GetDB()
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
email := tag + "@e"
settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}]}`, uuid, email, subId)
ib := &model.Inbound{
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
Protocol: protocol, Remark: tag, Settings: settings, StreamSettings: stream,
SubSortIndex: subSortIndex,
}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("seed inbound %s: %v", tag, err)
}
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
if err := db.Create(client).Error; err != nil {
t.Fatalf("seed client %s: %v", email, err)
}
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
t.Fatalf("seed client_inbound %s: %v", email, err)
}
return ib
}
// The member tag suffix is the inbound's real protocol, not its transport
// network: a vmess/tcp member is tagged bal-N-vmess, not the old bal-N-vless.
func TestSubJson_BalancerMemberTagUsesProtocol(t *testing.T) {
seedSubDB(t)
vm := seedSubProtocolInbound(t, "s1", "vm", 4901, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`, model.VMESS)
seedSubBalancer(t, &model.SubBalancer{
Remark: "proto", Strategy: "random", InboundIds: []int{vm.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "proto")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
tags := docOutboundTags(balancerDoc)
if !strings.Contains(strings.Join(tags, ","), "bal-1-vmess") {
t.Fatalf("vmess member tag = %v, want a bal-1-vmess suffix", tags)
}
}
-421
View File
@@ -1,421 +0,0 @@
package sub
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
t.Helper()
if err := database.GetDB().Create(b).Error; err != nil {
t.Fatalf("seed balancer: %v", err)
}
return b
}
func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
t.Helper()
var docs []map[string]any
if err := json.Unmarshal([]byte(out), &docs); err != nil {
t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
}
return docs
}
func docOutboundTags(doc map[string]any) []string {
outbounds, _ := doc["outbounds"].([]any)
tags := make([]string, 0, len(outbounds))
for _, ob := range outbounds {
if m, ok := ob.(map[string]any); ok {
tags = append(tags, m["tag"].(string))
}
}
return tags
}
func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
for _, doc := range docs {
if doc["remarks"] == remarks {
return doc
}
}
return nil
}
// The balancer document retags members under a per-balancer prefix, points
// proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
func TestSubJson_BalancerDocument(t *testing.T) {
seedSubDB(t)
tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 3 {
t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
}
balancerDoc := findDocByRemarks(docs, "auto")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
t.Fatalf("balancer outbound tags = %v", tags)
}
routing, _ := balancerDoc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
if len(balancers) != 1 {
t.Fatalf("balancers = %d, want 1", len(balancers))
}
balancer, _ := balancers[0].(map[string]any)
if balancer["tag"] != "balancer" {
t.Fatalf("balancer tag = %v", balancer["tag"])
}
if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
t.Fatalf("selector = %v", selector)
}
strategy, _ := balancer["strategy"].(map[string]any)
if strategy["type"] != "leastLoad" {
t.Fatalf("strategy = %v", strategy)
}
if balancer["fallbackTag"] != "bal-1-vless" {
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
}
ruleJSON, _ := json.Marshal(routing["rules"])
if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
}
if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
}
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
if proxyRules != 1 { // default catch-all only (custom sub rules were removed)
t.Fatalf("balancerTag rules = %d, want 1: %s", proxyRules, ruleJSON)
}
observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
t.Fatalf("subjectSelector = %v", selector)
}
ping, _ := observatory["pingConfig"].(map[string]any)
if ping["destination"] != subBalancerProbeURL {
t.Fatalf("pingConfig destination = %v", ping["destination"])
}
// The routing rewrite must not leak into the manual documents: s.configJson
// is shared, so a missing clone would corrupt every other doc.
for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
manual := findDocByRemarks(docs, remarks)
if manual == nil {
t.Fatalf("manual doc %q missing:\n%s", remarks, out)
}
if tags := docOutboundTags(manual); tags[0] != "proxy" {
t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
}
manualRouting, _ := manual["routing"].(map[string]any)
manualRules, _ := json.Marshal(manualRouting["rules"])
if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
}
if _, has := manualRouting["balancers"]; has {
t.Fatalf("manual doc %q must not carry balancers", remarks)
}
}
}
func stringify(values []any) []string {
out := make([]string, 0, len(values))
for _, v := range values {
out = append(out, v.(string))
}
return out
}
// The balancer interleaves with inbounds by the same 1-based number and, on a
// tie, follows the inbound group with that number.
func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
seedSubDB(t)
later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
var remarks []string
for _, doc := range docs {
remarks = append(remarks, doc["remarks"].(string))
}
if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
t.Fatalf("doc order = %v, want [first bal later]", remarks)
}
balancerDoc := findDocByRemarks(docs, "bal")
routing, _ := balancerDoc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
if strategy["type"] != "roundRobin" {
t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
}
}
// A disabled balancer is not emitted; an enabled one whose selected inbounds
// have no configs for this subscriber is skipped rather than emitted empty.
func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
seedSubDB(t)
inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
})
seedSubBalancer(t, &model.SubBalancer{
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 1 {
t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
}
if docs[0]["remarks"] != "only-only@e" {
t.Fatalf("remaining doc = %v", docs[0]["remarks"])
}
}
// Two members sharing a transport get deduplicated tags (…-2 suffix), matching
// the reference makeTag convention.
func TestSubJson_BalancerTagDedup(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
balancerDoc := findDocByRemarks(docs, "dedup")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
t.Fatalf("balancer outbound tags = %v", tags)
}
}
// random/roundRobin have no fallback so they emit no observatory; leastPing
// carries one, with the panel-wide ping config overriding the defaults.
func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
seedSubDB(t)
rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
})
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
rnd := findDocByRemarks(docs, "rnd")
if _, has := rnd["burstObservatory"]; has {
t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
}
pinger := findDocByRemarks(docs, "pinger")
obs, _ := pinger["burstObservatory"].(map[string]any)
if obs == nil {
t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
}
ping, _ := obs["pingConfig"].(map[string]any)
if ping["destination"] != "https://probe.example/204" {
t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
}
if ping["httpMethod"] != "GET" {
t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
}
if ping["sampling"] != float64(5) {
t.Fatalf("sampling = %v, want 5", ping["sampling"])
}
if ping["interval"] != "1m" {
t.Fatalf("interval = %v, want default 1m", ping["interval"])
}
}
// A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
// filters enable=true, so B never reaches entries. Guards the access scoping.
func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable inbound B: %v", err)
}
seedSubBalancer(t, &model.SubBalancer{
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
balancerDoc := findDocByRemarks(docs, "bal")
if balancerDoc == nil {
t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
}
tags := docOutboundTags(balancerDoc)
joined := strings.Join(tags, ",")
if !strings.Contains(joined, "bal-1-vless") {
t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
}
// B's address must not surface anywhere in the balancer doc — not as an
// outbound tag, not as a connection target a client could dial.
balJSON, _ := json.Marshal(balancerDoc)
if strings.Contains(string(balJSON), "203.0.113.5:4752") {
t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
}
}
// A balancer whose only selected inbound is disabled for this subscriber is
// skipped entirely — never emitted as an empty balancer with zero members.
func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
seedSubDB(t)
only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable only inbound: %v", err)
}
seedSubBalancer(t, &model.SubBalancer{
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if strings.TrimSpace(out) == "" {
return
}
docs := parseSubJsonDocs(t, out)
if findDocByRemarks(docs, "empty") != nil {
t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
}
}
// Connectivity defaults to empty (skip the direct pre-check); an explicit empty
// value stays empty instead of restoring the old generate_204 default.
func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
seedSubDB(t)
inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "" {
t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
}
js.SetObservatoryConfig(`{"connectivity":""}`)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "" {
t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
}
js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "http://probe.example/204" {
t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
}
}
// leastPing/leastLoad always emit a burst observatory (Xray won't start them
// without one); a stored {"enabled":false} is ignored as it is mandatory.
func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(`{"enabled":false}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
if pinger == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if _, has := pinger["burstObservatory"]; !has {
t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
}
routing, _ := pinger["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
balancer, _ := balancers[0].(map[string]any)
if balancer["fallbackTag"] != "bal-1-vless" {
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
}
}
func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string) map[string]any {
t.Helper()
doc := findDocByRemarks(docs, remarks)
if doc == nil {
t.Fatalf("balancer doc %q missing", remarks)
}
obs, _ := doc["burstObservatory"].(map[string]any)
if obs == nil {
t.Fatalf("balancer %q has no burstObservatory", remarks)
}
ping, _ := obs["pingConfig"].(map[string]any)
return ping
}
-53
View File
@@ -1,53 +0,0 @@
package sub
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Bad observatory settings (malformed JSON, non-URL destination, bad duration)
// must not leak into the emitted burstObservatory — each falls back to the
// built-in defaults instead of poisoning the client config.
func TestSubJson_ObservatoryConfigInvalidValuesFallBack(t *testing.T) {
seedSubDB(t)
inb := seedSubInbound(t, "s1", "lp", 4821, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
})
def := defaultSubBalancerObservatoryConfig()
cases := []struct {
name string
cfg string
}{
{"bad json", `{not-json`},
{"bad destination", `{"destination":"not-a-url"}`},
{"bad interval", `{"interval":"xyz"}`},
{"bad timeout", `{"timeout":"5x"}`},
{"bad connectivity", `{"connectivity":"ftp://bad"}`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
js := NewSubJsonService("", "", NewSubService(""))
js.SetObservatoryConfig(tc.cfg)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["destination"] != def.Destination {
t.Fatalf("destination = %v, want default %q (cfg=%s)", ping["destination"], def.Destination, tc.cfg)
}
if ping["interval"] != def.Interval {
t.Fatalf("interval = %v, want default %q (cfg=%s)", ping["interval"], def.Interval, tc.cfg)
}
if ping["timeout"] != def.Timeout {
t.Fatalf("timeout = %v, want default %q (cfg=%s)", ping["timeout"], def.Timeout, tc.cfg)
}
if ping["connectivity"] != def.Connectivity {
t.Fatalf("connectivity = %v, want default %q (cfg=%s)", ping["connectivity"], def.Connectivity, tc.cfg)
}
})
}
}
+15 -42
View File
@@ -2,7 +2,6 @@ package sub
import (
"fmt"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -44,7 +43,7 @@ func TestGetSubsToleratesUnusualStreamSettings(t *testing.T) {
}
}
func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
func TestGetSubsToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
seedSubDB(t)
db := database.GetDB()
@@ -68,65 +67,39 @@ func TestGetJsonToleratesHysteriaWithoutHysteriaSettings(t *testing.T) {
t.Fatalf("seed client_inbound: %v", err)
}
jsonService := NewSubJsonService("", "", NewSubService(""))
out, _, err := jsonService.GetJson(subId, "sub.example.com", true)
links, _, _, _, err := NewSubService("").GetSubs(subId, "sub.example.com")
if err != nil {
t.Fatalf("GetJson: %v", err)
t.Fatalf("GetSubs: %v", err)
}
if out == "" {
t.Fatal("GetJson returned empty for a hysteria inbound without hysteriaSettings")
if len(links) != 1 {
t.Fatalf("expected 1 share link for a hysteria inbound without hysteriaSettings, got %d", len(links))
}
}
func TestGetJsonToleratesNonStringRealityShortId(t *testing.T) {
func TestGetSubsToleratesNonStringRealityShortId(t *testing.T) {
seedSubDB(t)
stream := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["sni.example.com"],"shortIds":[42],"settings":{"publicKey":"pk"}}}`
seedSubInbound(t, "rlty1", "rlty", 46400, 1, stream)
jsonService := NewSubJsonService("", "", NewSubService(""))
out, _, err := jsonService.GetJson("rlty1", "sub.example.com", true)
links, _, _, _, err := NewSubService("").GetSubs("rlty1", "sub.example.com")
if err != nil {
t.Fatalf("GetJson: %v", err)
t.Fatalf("GetSubs: %v", err)
}
if out == "" {
t.Fatal("GetJson returned empty for a reality inbound with a non-string shortId element")
if len(links) != 1 {
t.Fatalf("expected 1 share link for a reality inbound with a non-string shortId element, got %d", len(links))
}
}
func TestGetClashEmitsPinnedCertSha256(t *testing.T) {
seedSubDB(t)
const pin = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}`
seedSubInbound(t, "pin1", "pin", 46300, 1, stream)
out, _, err := NewSubClashService(NewSubService("")).GetClash("pin1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(out, "pin-sha256") {
t.Fatalf("Clash proxy dropped the pinned cert sha256:\n%s", out)
}
}
func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) {
func TestGetSubsToleratesExternalProxyMissingPort(t *testing.T) {
seedSubDB(t)
stream := `{"network":"tcp","security":"none","externalProxy":[{"forceTls":"same","dest":"cdn.example.com"}]}`
seedSubInbound(t, "extp1", "extp", 46500, 1, stream)
jsonService := NewSubJsonService("", "", NewSubService(""))
jsonOut, _, err := jsonService.GetJson("extp1", "sub.example.com", true)
links, _, _, _, err := NewSubService("").GetSubs("extp1", "sub.example.com")
if err != nil {
t.Fatalf("GetJson: %v", err)
t.Fatalf("GetSubs: %v", err)
}
if jsonOut == "" {
t.Fatal("GetJson returned empty for an externalProxy entry missing port")
}
clashOut, _, err := NewSubClashService(NewSubService("")).GetClash("extp1", "sub.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if clashOut == "" {
t.Fatal("GetClash returned empty for an externalProxy entry missing port")
if len(links) == 0 {
t.Fatal("GetSubs returned no links for an externalProxy entry missing port")
}
}
+2 -15
View File
@@ -209,19 +209,6 @@ func TestGetSubsScale(t *testing.T) {
t.Fatalf("GetSubs links = %d, want 3", len(links))
}
jsonSvc := NewSubJsonService("", "", &SubService{})
start = time.Now()
for range reps {
body, _, err := jsonSvc.GetJson(scaleTargetSubId, "sub.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if body == "" {
t.Fatal("GetJson returned empty body")
}
}
jsonDur := time.Since(start) / reps
start = time.Now()
for range reps {
missLinks, _, _, _, err := svc.GetSubs("no-such-sub", "sub.example.com")
@@ -234,8 +221,8 @@ func TestGetSubsScale(t *testing.T) {
}
missDur := time.Since(start) / reps
t.Logf("N=%-7d raw=%-10v json=%-10v miss=%v",
n, rawDur.Round(time.Millisecond), jsonDur.Round(time.Millisecond), missDur.Round(time.Millisecond))
t.Logf("N=%-7d raw=%-10v miss=%v",
n, rawDur.Round(time.Millisecond), missDur.Round(time.Millisecond))
})
}
}
-36
View File
@@ -45,39 +45,3 @@ func TestSub_HostVlessRoute_RawMultiHost(t *testing.T) {
t.Fatalf("host C (no route) must keep the original 3rd group: %s", parts[2])
}
}
func TestSub_HostVlessRoute_JSON(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
js := NewSubJsonService("", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", false)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if !strings.Contains(out, "11111111-2222-01bb-8444-") {
t.Fatalf("json outbound id should encode route 443 (01bb):\n%s", out)
}
if strings.Contains(out, "11111111-2222-4333-8444-") {
t.Fatalf("original id 3rd group must be replaced in json:\n%s", out)
}
}
func TestSub_HostVlessRoute_Clash(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
clash := NewSubClashService(NewSubService(""))
yaml, _, err := clash.GetClash("s1", "req.example.com")
if err != nil {
t.Fatalf("GetClash: %v", err)
}
if !strings.Contains(yaml, "11111111-2222-01bb-8444-") {
t.Fatalf("clash proxy uuid should encode route 443 (01bb):\n%s", yaml)
}
if strings.Contains(yaml, "11111111-2222-4333-8444-") {
t.Fatalf("original uuid 3rd group must be replaced in clash:\n%s", yaml)
}
}
-3
View File
@@ -218,9 +218,6 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
// config surface retired with the xray settings page, ITEM6 B4.)
a.settingController = NewSettingController(api)
// Subscription balancers — client-side balancers for the JSON sub output
NewSubBalancerController(api)
// Extra routes
api.POST("/backuptotgbot", a.BackuptoTgbot)
}
-126
View File
@@ -1,126 +0,0 @@
package controller
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// SubBalancerController manages client-side JSON-subscription balancers.
type SubBalancerController struct {
SubBalancerService service.SubBalancerService
}
func NewSubBalancerController(g *gin.RouterGroup) *SubBalancerController {
a := &SubBalancerController{}
g = g.Group("/sub-balancers")
g.GET("", a.list)
g.POST("", a.create)
g.POST("/:id", a.update)
g.DELETE("/:id", a.del)
g.POST("/:id/del", a.del)
return a
}
// parseSubBalancerForm reads the urlencoded form (HttpUtil default): scalars
// via ShouldBind, inboundIds as repeated keys. enabled is returned as *bool so
// Update can keep the stored value when the key is absent; a bad value is a 400.
func parseSubBalancerForm(c *gin.Context) (*model.SubBalancer, *bool, error) {
form := struct {
Remark string `form:"remark"`
Strategy string `form:"strategy"`
SortOrder int `form:"sortOrder"`
}{}
if err := c.ShouldBind(&form); err != nil {
return nil, nil, err
}
var enabled *bool
if raw, ok := c.GetPostForm("enabled"); ok {
v, err := strconv.ParseBool(raw)
if err != nil {
return nil, nil, fmt.Errorf("invalid enabled %q: %w", raw, err)
}
enabled = &v
}
balancer := &model.SubBalancer{
Remark: form.Remark,
Strategy: form.Strategy,
SortOrder: form.SortOrder,
}
for _, raw := range c.PostFormArray("inboundIds") {
id, err := strconv.Atoi(raw)
if err != nil {
return nil, nil, fmt.Errorf("invalid inbound id %q: %w", raw, err)
}
balancer.InboundIds = append(balancer.InboundIds, id)
}
return balancer, enabled, nil
}
func (a *SubBalancerController) parseID(c *gin.Context) (int, error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id < 1 {
return 0, fmt.Errorf("invalid id %q", c.Param("id"))
}
return id, nil
}
func (a *SubBalancerController) list(c *gin.Context) {
balancers, err := a.SubBalancerService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.list"), err)
return
}
jsonObj(c, balancers, nil)
}
func (a *SubBalancerController) create(c *gin.Context) {
balancer, enabled, err := parseSubBalancerForm(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
return
}
balancer.Enabled = enabled == nil || *enabled
created, err := a.SubBalancerService.Create(balancer)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.create"), err)
return
}
jsonObj(c, created, nil)
}
func (a *SubBalancerController) update(c *gin.Context) {
id, err := a.parseID(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
return
}
balancer, enabled, err := parseSubBalancerForm(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
return
}
updated, err := a.SubBalancerService.Update(id, balancer, enabled)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.update"), err)
return
}
jsonObj(c, updated, nil)
}
func (a *SubBalancerController) del(c *gin.Context) {
id, err := a.parseID(c)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.invalidId"), err)
return
}
if err := a.SubBalancerService.Delete(id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.subBalancers.toasts.delete"), err)
return
}
jsonObj(c, "", nil)
}
@@ -1,105 +0,0 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
)
func setupSubBalancerRouter(t *testing.T) *gin.Engine {
t.Helper()
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
gin.SetMode(gin.TestMode)
router := gin.New()
NewSubBalancerController(router.Group("/panel/api"))
return router
}
func subBalancerPost(t *testing.T, router *gin.Engine, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
return resp
}
func responseObj(t *testing.T, body string) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("unmarshal response %q: %v", body, err)
}
return m
}
// enabled absent on create defaults to true; "false" disables; a non-boolean
// value is rejected so a malformed toggle can't silently flip the row.
func TestSubBalancerController_EnabledParsing(t *testing.T) {
router := setupSubBalancerRouter(t)
base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base)
if !strings.Contains(resp.Body.String(), `"success":true`) {
t.Fatalf("create no enabled: %s", resp.Body.String())
}
bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != true {
t.Fatalf("absent enabled = %v, want true", bal["enabled"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != false {
t.Fatalf("enabled=false -> %v, want false", bal["enabled"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=bogus")
if !strings.Contains(resp.Body.String(), `"success":false`) {
t.Fatalf("enabled=bogus should be rejected: %s", resp.Body.String())
}
}
// An update omitting enabled preserves the stored value instead of resetting it
// to the create default — a partial PATCH must not clobber the toggle.
func TestSubBalancerController_UpdatePreservesEnabledWhenAbsent(t *testing.T) {
router := setupSubBalancerRouter(t)
base := "remark=auto&strategy=random&sortOrder=1&inboundIds=1"
resp := subBalancerPost(t, router, "/panel/api/sub-balancers", base+"&enabled=false")
bal := responseObj(t, resp.Body.String())["obj"].(map[string]any)
id := strconv.Itoa(int(bal["id"].(float64)))
if bal["enabled"] != false {
t.Fatalf("setup: enabled = %v, want false", bal["enabled"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1")
if !strings.Contains(resp.Body.String(), `"success":true`) {
t.Fatalf("update: %s", resp.Body.String())
}
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != false {
t.Fatalf("update without enabled = %v, want preserved false", bal["enabled"])
}
if bal["remark"] != "renamed" {
t.Fatalf("remark = %v, want renamed", bal["remark"])
}
resp = subBalancerPost(t, router, "/panel/api/sub-balancers/"+id, "remark=renamed&strategy=random&sortOrder=1&inboundIds=1&enabled=true")
bal = responseObj(t, resp.Body.String())["obj"].(map[string]any)
if bal["enabled"] != true {
t.Fatalf("enabled=true -> %v, want true", bal["enabled"])
}
}
+8 -8
View File
@@ -18,8 +18,8 @@ func TestCheckValidWildcardListenPortConflict(t *testing.T) {
}
}
// The allowlist and the trusted-proxy list share one validator, so this also
// pins that each list still reports its own message (#5378).
// The trusted-proxy list validator must report its own message naming the
// bad entry (#5378).
func TestCheckValidIPOrCIDRLists(t *testing.T) {
base := func() *AllSetting {
return &AllSetting{WebPort: 2053, SubPort: 2096}
@@ -27,22 +27,22 @@ func TestCheckValidIPOrCIDRLists(t *testing.T) {
for _, v := range []string{"", "203.0.113.10", "198.51.100.0/24", " 203.0.113.10 , 2001:db8::/32 ", "203.0.113.10,,"} {
s := base()
s.IpLimitAllowlist = v
s.TrustedProxyCIDRs = v
if err := s.CheckValid(); err != nil {
t.Errorf("ipLimitAllowlist=%q: unexpected error %v", v, err)
t.Errorf("trustedProxyCIDRs=%q: unexpected error %v", v, err)
}
}
for _, v := range []string{"nonsense", "203.0.113.10/33", "203.0.113.10, oops"} {
s := base()
s.IpLimitAllowlist = v
s.TrustedProxyCIDRs = v
err := s.CheckValid()
if err == nil {
t.Errorf("ipLimitAllowlist=%q: want error, got nil", v)
t.Errorf("trustedProxyCIDRs=%q: want error, got nil", v)
continue
}
if !strings.Contains(err.Error(), "IP limit allowlist entry is not valid:") {
t.Errorf("ipLimitAllowlist=%q: error %q does not name the setting", v, err)
if !strings.Contains(err.Error(), "trusted proxy CIDR is not valid:") {
t.Errorf("trustedProxyCIDRs=%q: error %q does not name the setting", v, err)
}
}
+6 -66
View File
@@ -4,7 +4,6 @@ import (
"crypto/tls"
"math"
"net"
"net/netip"
"strings"
"time"
@@ -26,8 +25,6 @@ type AllSetting struct {
WebBasePath string `json:"webBasePath" form:"webBasePath"`
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
IpLimitAllowlist string `json:"ipLimitAllowlist" form:"ipLimitAllowlist"`
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
@@ -36,8 +33,6 @@ type AllSetting struct {
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
Datepicker string `json:"datepicker" form:"datepicker"`
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
@@ -52,18 +47,12 @@ type AllSetting struct {
TimeLocation string `json:"timeLocation" form:"timeLocation"`
SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubListen string `json:"subListen" form:"subListen"`
SubEnable bool `json:"subEnable" form:"subEnable"`
SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubListen string `json:"subListen" form:"subListen"`
// SubPort 0 means "no explicit port": subscription links are generated
// without a port (standard scheme port behind a reverse proxy) while the
// sub server still binds the default port. See SettingService.BuildSubURIBase
@@ -83,15 +72,6 @@ type AllSetting struct {
TrafficStatsSwitches string `json:"trafficStatsSwitches" form:"trafficStatsSwitches"`
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
SubURI string `json:"subURI" form:"subURI"`
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
SubClashPath string `json:"subClashPath" form:"subClashPath"`
SubClashURI string `json:"subClashURI" form:"subClashURI"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
@@ -115,24 +95,6 @@ func pathHasForbiddenChar(s string) bool {
return false
}
// CheckNetipAddrOrPrefixList mirrors parseIpLimitAllowlist exactly: net and netip
// disagree (net accepts "/024", netip does not), so save and scan must share rules.
func CheckNetipAddrOrPrefixList(list, message string) error {
for entry := range strings.SplitSeq(list, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if _, err := netip.ParseAddr(entry); err == nil {
continue
}
if _, err := netip.ParsePrefix(entry); err != nil {
return common.NewError(message, entry)
}
}
return nil
}
// checkIPOrCIDRList rejects the first comma-separated entry that is neither a
// bare address nor a CIDR, naming it with the caller's message.
func checkIPOrCIDRList(list, message string) error {
@@ -198,8 +160,6 @@ func (s *AllSetting) CheckValid() error {
}{
{"web base path", s.WebBasePath},
{"subscription path", s.SubPath},
{"subscription JSON path", s.SubJsonPath},
{"subscription Clash path", s.SubClashPath},
} {
if pathHasForbiddenChar(p.value) {
return common.NewError("URI path contains an invalid character:", p.name)
@@ -219,30 +179,10 @@ func (s *AllSetting) CheckValid() error {
s.SubPath += "/"
}
if !strings.HasPrefix(s.SubJsonPath, "/") {
s.SubJsonPath = "/" + s.SubJsonPath
}
if !strings.HasSuffix(s.SubJsonPath, "/") {
s.SubJsonPath += "/"
}
if !strings.HasPrefix(s.SubClashPath, "/") {
s.SubClashPath = "/" + s.SubClashPath
}
if !strings.HasSuffix(s.SubClashPath, "/") {
s.SubClashPath += "/"
}
if err := checkIPOrCIDRList(s.TrustedProxyCIDRs, "trusted proxy CIDR is not valid:"); err != nil {
return err
}
// Rejected here rather than skipped at scan time: a typo in an allowlist
// entry silently leaves the address unprotected until a trusted network gets banned.
if err := CheckNetipAddrOrPrefixList(s.IpLimitAllowlist, "IP limit allowlist entry is not valid:"); err != nil {
return err
}
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)
-82
View File
@@ -1,82 +0,0 @@
package job
import (
"net/netip"
"slices"
"strings"
)
// An address that matches is neither counted towards a client's IP limit nor
// banned: counting it would still cut the shared network it protects (#5378).
type ipLimitAllowlist struct {
prefixes []netip.Prefix
addrs []netip.Addr
}
// Comma-separated, each entry a CIDR or a bare address. Unparseable entries are
// skipped: the validator uses these same rules, so only a hand-edited DB differs.
func parseIpLimitAllowlist(raw string) ipLimitAllowlist {
var list ipLimitAllowlist
for field := range strings.SplitSeq(raw, ",") {
field = strings.TrimSpace(field)
if field == "" {
continue
}
if prefix, err := netip.ParsePrefix(field); err == nil {
// Unmapped: contains() unmaps the queried address, and Prefix.Contains
// is false whenever the bit lengths disagree.
if addr := prefix.Addr(); addr.Is4In6() {
if p4, perr := addr.Unmap().Prefix(prefix.Bits() - 96); perr == nil {
prefix = p4
}
}
list.prefixes = append(list.prefixes, prefix.Masked())
continue
}
if addr, err := netip.ParseAddr(field); err == nil {
list.addrs = append(list.addrs, addr.Unmap())
}
}
return list
}
func (l ipLimitAllowlist) empty() bool {
return len(l.prefixes) == 0 && len(l.addrs) == 0
}
func (l ipLimitAllowlist) contains(ip string) bool {
if l.empty() {
return false
}
addr, err := netip.ParseAddr(strings.TrimSpace(ip))
if err != nil {
return false
}
addr = addr.Unmap()
if slices.Contains(l.addrs, addr) {
return true
}
for _, prefix := range l.prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
// split separates the entries an allowlist protects from the ones the limit
// still applies to, preserving the caller's ordering in both.
func (l ipLimitAllowlist) split(entries []IPWithTimestamp) (limited, allowed []IPWithTimestamp) {
if l.empty() {
return entries, nil
}
limited = make([]IPWithTimestamp, 0, len(entries))
for _, entry := range entries {
if l.contains(entry.IP) {
allowed = append(allowed, entry)
continue
}
limited = append(limited, entry)
}
return limited, allowed
}
@@ -1,36 +0,0 @@
package job
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
)
// Save-time validation and scan-time parsing must accept exactly the same set:
// anything the validator lets through and the parser drops is silently unprotected.
func TestAllowlistValidatorAndParserAgree(t *testing.T) {
for _, entry := range []string{
"198.51.100.7",
"198.51.100.0/24",
"2001:db8::1",
"2001:db8::/32",
"198.51.100.0/024",
"not-an-address",
"198.51.100.0/33",
} {
accepted := entity.CheckNetipAddrOrPrefixList(entry, "invalid:") == nil
parsed := len(parseIpLimitAllowlist(entry).prefixes)+len(parseIpLimitAllowlist(entry).addrs) > 0
if accepted != parsed {
t.Errorf("%q: validator=%v parser=%v — a disagreement leaves the entry silently unprotected", entry, accepted, parsed)
}
}
}
// An IPv4-mapped prefix used to parse but never match, because contains() unmaps
// the queried address and Prefix.Contains is false across bit lengths.
func TestAllowlistMatchesIPv4MappedPrefix(t *testing.T) {
list := parseIpLimitAllowlist("::ffff:198.51.100.0/120")
if !list.contains("198.51.100.5") {
t.Fatal("an IPv4-mapped entry matched nothing: it protects no one")
}
}
+8 -9
View File
@@ -23,18 +23,17 @@ and an entry for a removed route documents an endpoint that 404s. This test
constructs the real router and diffs it against the registry both ways.
Scope: everything under /panel/api/ plus the session-auth surface the
registry also documents (/login, /logout, /csrf-token, /getTwoFactorEnable,
/ws). SPA page routes are UI, not API, and stay out; registry paths that
start with "/{" describe the standalone subscription server, which this
engine does not serve.
registry also documents (/login, /logout, /csrf-token, /ws). SPA page
routes are UI, not API, and stay out; registry paths that start with "/{"
describe the standalone subscription server, which this engine does not
serve.
*/
var contractExtraRoutes = map[string]bool{
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
"POST /getTwoFactorEnable": true,
"GET /ws": true,
"POST /login": true,
"POST /logout": true,
"GET /csrf-token": true,
"GET /ws": true,
}
func inContractScope(method, path string) bool {
-17
View File
@@ -9,7 +9,6 @@ import (
"fmt"
"net"
"regexp"
"slices"
"sort"
"strings"
"time"
@@ -1371,22 +1370,6 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
if err := tx.Where("inbound_id = ?", id).Delete(&model.Host{}).Error; err != nil {
return err
}
// Drop the deleted inbound from any sub-balancer that selects it; a
// dangling id would emit a member no subscriber can resolve (#5648).
var balancers []model.SubBalancer
if err := tx.Find(&balancers).Error; err != nil {
return err
}
for i := range balancers {
before := balancers[i].InboundIds
balancers[i].InboundIds = slices.DeleteFunc(before, func(b int) bool { return b == id })
if len(balancers[i].InboundIds) == len(before) {
continue
}
if err := tx.Save(&balancers[i]).Error; err != nil {
return err
}
}
if loadErr == nil && ib.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *ib.NodeID)
}
+5 -179
View File
@@ -40,11 +40,9 @@ func init() {
}
const (
DefaultSubClashUserAgentRegex = `(?i)(clash|mihomo)`
DefaultSubJsonUserAgentRegex = ``
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
maxRegexLength = 2048
DefaultRemarkTemplate = "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D"
DefaultTrustedProxyCIDRs = "127.0.0.1/32,::1/128"
maxRegexLength = 2048
)
var defaultValueMap = map[string]string{
@@ -75,7 +73,6 @@ var defaultValueMap = map[string]string{
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
"sessionMaxAge": "360",
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
"ipLimitAllowlist": "",
"pageSize": "25",
"expireDiff": "0",
"trafficDiff": "0",
@@ -83,12 +80,6 @@ var defaultValueMap = map[string]string{
"subShowIdentityOnAllLinks": "false",
"timeLocation": "Local",
"subEnable": "true",
"subJsonEnable": "false",
"subJsonAutoDetect": "false",
"subJsonAlwaysArray": "false",
"subJsonUserAgentRegex": "",
"subClashAutoDetect": "false",
"subClashUserAgentRegex": "",
"subTitle": "",
"subSupportUrl": "",
"subProfileUrl": "",
@@ -103,27 +94,14 @@ var defaultValueMap = map[string]string{
"subUpdates": "12",
"subEncrypt": "true",
"subURI": "",
"subJsonPath": "/json/",
"subJsonURI": "",
"subClashEnable": "false",
"subClashPath": "/clash/",
"subClashURI": "",
"subJsonMux": "",
"subJsonFinalMask": "",
"subJsonObservatory": "",
"subThemeDir": "",
"datepicker": "gregorian",
"warp": "",
"warpUpdateInterval": "0",
"nord": "",
"pia": "",
"restartXrayOnClientDisable": "true",
"panelOutbound": "",
"devChannelEnable": "false",
// Event bus — per-subscriber event filtering (empty = all disabled)
// Consecutive failed observatory probes before an outbound.down event fires
"tgBotEnable": "false",
"tgBotToken": "",
"tgBotProxy": "",
@@ -136,8 +114,6 @@ var defaultValueMap = map[string]string{
"tgLang": "en-US",
// Event bus — per-subscriber event filtering (empty = all disabled)
"tgEnabledEvents": "login.attempt,cpu.high",
"outboundDownThreshold": "3",
}
// SettingService provides business logic for application settings management.
@@ -397,16 +373,6 @@ func (s *SettingService) GetWebDomain() (string, error) {
return s.getString("webDomain")
}
// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
// requests (version checks, Telegram, subscription fetches) are routed through.
func (s *SettingService) GetPanelOutbound() (string, error) {
return s.getString("panelOutbound")
}
func (s *SettingService) SetPanelOutbound(tag string) error {
return s.setString("panelOutbound", tag)
}
// PanelEgressProxyURL resolves the loopback SOCKS bridge that the generated
// config exposes when a panel outbound is configured (see injectPanelEgress).
// It returns "" — meaning a direct connection — when the feature is off or
@@ -473,12 +439,6 @@ func (s *SettingService) GetSessionMaxAge() (int, error) {
return s.getInt("sessionMaxAge")
}
// GetIpLimitAllowlist returns the operator's trusted addresses and networks,
// which the IP limit neither counts nor bans.
func (s *SettingService) GetIpLimitAllowlist() (string, error) {
return s.getString("ipLimitAllowlist")
}
func (s *SettingService) GetTrustedProxyCIDRs() (string, error) {
return s.getString("trustedProxyCIDRs")
}
@@ -564,30 +524,6 @@ func (s *SettingService) GetSubEnable() (bool, error) {
return s.getBool("subEnable")
}
func (s *SettingService) GetSubJsonEnable() (bool, error) {
return s.getBool("subJsonEnable")
}
func (s *SettingService) GetSubJsonAutoDetect() (bool, error) {
return s.getBool("subJsonAutoDetect")
}
func (s *SettingService) GetSubJsonAlwaysArray() (bool, error) {
return s.getBool("subJsonAlwaysArray")
}
func (s *SettingService) GetSubJsonUserAgentRegex() (string, error) {
return s.getString("subJsonUserAgentRegex")
}
func (s *SettingService) GetSubClashAutoDetect() (bool, error) {
return s.getBool("subClashAutoDetect")
}
func (s *SettingService) GetSubClashUserAgentRegex() (string, error) {
return s.getString("subClashUserAgentRegex")
}
func (s *SettingService) GetSubTitle() (string, error) {
return s.getString("subTitle")
}
@@ -622,10 +558,6 @@ func (s *SettingService) GetSubPath() (string, error) {
return s.getString("subPath")
}
func (s *SettingService) GetSubJsonPath() (string, error) {
return s.getString("subJsonPath")
}
func (s *SettingService) GetSubDomain() (string, error) {
return s.getString("subDomain")
}
@@ -662,38 +594,6 @@ func (s *SettingService) GetSubURI() (string, error) {
return s.getString("subURI")
}
func (s *SettingService) GetSubJsonURI() (string, error) {
return s.getString("subJsonURI")
}
func (s *SettingService) GetSubClashEnable() (bool, error) {
return s.getBool("subClashEnable")
}
func (s *SettingService) GetSubClashPath() (string, error) {
return s.getString("subClashPath")
}
func (s *SettingService) GetSubClashURI() (string, error) {
return s.getString("subClashURI")
}
func (s *SettingService) GetSubJsonMux() (string, error) {
return s.getString("subJsonMux")
}
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask")
}
func (s *SettingService) GetSubJsonObservatory() (string, error) {
return s.getString("subJsonObservatory")
}
func (s *SettingService) GetSubThemeDir() (string, error) {
return s.getString("subThemeDir")
}
func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker")
}
@@ -761,10 +661,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) {
// Event bus — per-subscriber event filtering
// GetOutboundDownThreshold returns how many consecutive failed observatory
// probes an outbound must accumulate before an outbound.down notification is
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
func (s *SettingService) GetTgBotToken() (string, error) {
return s.getString("tgBotToken")
}
@@ -841,14 +737,6 @@ func (s *SettingService) SetTgEnabledEvents(events string) error {
return s.setString("tgEnabledEvents", events)
}
func (s *SettingService) GetOutboundDownThreshold() (int, error) {
return s.getInt("outboundDownThreshold")
}
func (s *SettingService) SetOutboundDownThreshold(value int) error {
return s.setInt("outboundDownThreshold", value)
}
// SecretClears marks redacted secrets the user explicitly emptied. Without a
// flag, a blank submitted secret means "unchanged" (the field is always served
// blank to the browser) and the stored value is preserved.
@@ -863,9 +751,6 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
if err := validateSettingsURLs(allSetting); err != nil {
return err
}
if err := validateSubUserAgentRegexes(allSetting); err != nil {
return err
}
if err := allSetting.CheckValid(); err != nil {
return err
}
@@ -906,38 +791,6 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
})
}
func validateSubUserAgentRegexes(allSetting *entity.AllSetting) error {
jsonPattern, err := validateSubUserAgentRegex("Xray JSON", allSetting.SubJsonUserAgentRegex, DefaultSubJsonUserAgentRegex)
if err != nil {
return err
}
clashPattern, err := validateSubUserAgentRegex("Clash/Mihomo", allSetting.SubClashUserAgentRegex, DefaultSubClashUserAgentRegex)
if err != nil {
return err
}
allSetting.SubJsonUserAgentRegex = jsonPattern
allSetting.SubClashUserAgentRegex = clashPattern
return nil
}
func validateSubUserAgentRegex(name, pattern, defaultPattern string) (string, error) {
pattern = strings.TrimSpace(pattern)
effectivePattern := pattern
if effectivePattern == "" {
effectivePattern = defaultPattern
}
if len(effectivePattern) > maxRegexLength {
return "", common.NewErrorf("%s User-Agent regex must not exceed %d characters", name, maxRegexLength)
}
if _, err := regexp.Compile(effectivePattern); err != nil {
return "", common.NewErrorf("%s User-Agent regex is invalid: %v", name, err)
}
// Return the original pattern (empty string if cleared) so the caller
// can distinguish "user explicitly set empty" from "user set a value".
// The empty value is stored in the DB and inherited as runtime default.
return pattern, nil
}
func ValidateRegex(pattern string) error {
if len(pattern) > maxRegexLength {
return common.NewErrorf("Regular expression must not exceed %d characters", maxRegexLength)
@@ -1031,14 +884,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
"pageSize": func() (any, error) { return s.GetPageSize() },
"defaultCert": func() (any, error) { return s.GetCertFile() },
"defaultKey": func() (any, error) { return s.GetKeyFile() },
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
"subEnable": func() (any, error) { return s.GetSubEnable() },
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
"subTitle": func() (any, error) { return s.GetSubTitle() },
"subURI": func() (any, error) { return s.GetSubURI() },
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
"subClashURI": func() (any, error) { return s.GetSubClashURI() },
"datepicker": func() (any, error) { return s.GetDatepicker() },
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
"accessLogEnable": func() (any, error) { return s.GetAccessLogEnable() },
@@ -1059,36 +907,14 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
}
subEnable := result["subEnable"].(bool)
subJsonEnable := false
if v, ok := result["subJsonEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subJsonEnable = b
}
}
subClashEnable := false
if v, ok := result["subClashEnable"]; ok {
if b, ok2 := v.(bool); ok2 {
subClashEnable = b
}
}
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") || (subClashEnable && result["subClashURI"].(string) == "") {
if subEnable && result["subURI"].(string) == "" {
subURI := s.BuildSubURIBase(host)
subTitle, _ := s.GetSubTitle()
subPath, _ := s.GetSubPath()
subJsonPath, _ := s.GetSubJsonPath()
subClashPath, _ := s.GetSubClashPath()
if subEnable && result["subURI"].(string) == "" {
result["subURI"] = subURI + subPath
}
result["subURI"] = subURI + subPath
if result["subTitle"].(string) == "" {
result["subTitle"] = subTitle
}
if subJsonEnable && result["subJsonURI"].(string) == "" {
result["subJsonURI"] = subURI + subJsonPath
}
if subClashEnable && result["subClashURI"].(string) == "" {
result["subClashURI"] = subURI + subClashPath
}
}
return result, nil
-199
View File
@@ -1,199 +0,0 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestValidateRegex(t *testing.T) {
for _, pattern := range []string{"", `(?i)^jsonclient([ /]|$)`, `(?m)^general-purpose$`} {
if err := ValidateRegex(pattern); err != nil {
t.Errorf("ValidateRegex(%q) returned %v", pattern, err)
}
}
for _, pattern := range []string{"[", strings.Repeat("a", 2049)} {
if err := ValidateRegex(pattern); err == nil {
t.Errorf("ValidateRegex(%q) accepted an invalid pattern", pattern)
}
}
}
func TestSubscriptionAutoDetectDefaultsWithoutStoredRows(t *testing.T) {
setupSettingTestDB(t)
keys := []string{"subClashAutoDetect", "subClashUserAgentRegex", "subJsonAutoDetect", "subJsonAlwaysArray", "subJsonUserAgentRegex"}
if err := database.GetDB().Where("key IN ?", keys).Delete(&model.Setting{}).Error; err != nil {
t.Fatal(err)
}
s := &SettingService{}
clashEnabled, err := s.GetSubClashAutoDetect()
if err != nil {
t.Fatal(err)
}
jsonEnabled, err := s.GetSubJsonAutoDetect()
if err != nil {
t.Fatal(err)
}
jsonAlwaysArray, err := s.GetSubJsonAlwaysArray()
if err != nil {
t.Fatal(err)
}
clashRegex, err := s.GetSubClashUserAgentRegex()
if err != nil {
t.Fatal(err)
}
jsonRegex, err := s.GetSubJsonUserAgentRegex()
if err != nil {
t.Fatal(err)
}
if clashEnabled || jsonEnabled || jsonAlwaysArray {
t.Fatalf("missing subscription flags must default off: clashAuto=%v jsonAuto=%v jsonAlwaysArray=%v", clashEnabled, jsonEnabled, jsonAlwaysArray)
}
if clashRegex != "" {
t.Fatalf("missing Clash regex = %q, want empty inherited value", clashRegex)
}
if jsonRegex != "" {
t.Fatalf("missing JSON regex = %q, want empty inherited value", jsonRegex)
}
var count int64
if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("default lookup unexpectedly persisted %d setting rows", count)
}
}
func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
settings.SubJsonUserAgentRegex = " "
settings.SubClashUserAgentRegex = ""
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
for _, key := range []string{"subJsonUserAgentRegex", "subClashUserAgentRegex"} {
var stored model.Setting
if err := database.GetDB().Where("key = ?", key).First(&stored).Error; err != nil {
t.Fatal(err)
}
if stored.Value != "" {
t.Fatalf("%s stored value = %q, want empty inherited value", key, stored.Value)
}
}
}
func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
if settings.SubClashAutoDetect {
t.Fatal("subClashAutoDetect default = true, want false")
}
if settings.SubJsonAutoDetect {
t.Fatal("subJsonAutoDetect default = true, want false")
}
if settings.SubJsonAlwaysArray {
t.Fatal("subJsonAlwaysArray default = true, want false")
}
if settings.SubJsonUserAgentRegex != "" {
t.Fatalf("subJsonUserAgentRegex = %q, want empty inherited value", settings.SubJsonUserAgentRegex)
}
if settings.SubClashUserAgentRegex != "" {
t.Fatalf("subClashUserAgentRegex = %q, want empty inherited value", settings.SubClashUserAgentRegex)
}
settings.SubClashAutoDetect = true
settings.SubClashUserAgentRegex = `(?i)^custom-clash/`
settings.SubJsonAutoDetect = true
settings.SubJsonAlwaysArray = true
settings.SubJsonUserAgentRegex = `(?i)^custom-json/`
settings.SubJsonEnable = true
settings.SubJsonPath = "/json-custom/"
settings.SubJsonURI = "https://subscriptions.example.com/json-custom/"
settings.SubClashEnable = true
settings.SubClashPath = "/clash-custom/"
settings.SubClashURI = "https://subscriptions.example.com/clash-custom/"
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
got, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
if !got.SubClashEnable {
t.Fatal("subClashEnable = false, want true")
}
if !got.SubClashAutoDetect {
t.Fatal("subClashAutoDetect = false, want true")
}
if !got.SubJsonAutoDetect {
t.Fatal("subJsonAutoDetect = false, want true")
}
if !got.SubJsonAlwaysArray {
t.Fatal("subJsonAlwaysArray = false, want true")
}
if !got.SubJsonEnable {
t.Fatal("subJsonEnable = false, want true")
}
if got.SubJsonPath != "/json-custom/" {
t.Fatalf("subJsonPath = %q, want %q", got.SubJsonPath, "/json-custom/")
}
if got.SubJsonURI != "https://subscriptions.example.com/json-custom/" {
t.Fatalf("subJsonURI = %q, want %q", got.SubJsonURI, "https://subscriptions.example.com/json-custom/")
}
if got.SubJsonUserAgentRegex != `(?i)^custom-json/` {
t.Fatalf("subJsonUserAgentRegex = %q, want %q", got.SubJsonUserAgentRegex, `(?i)^custom-json/`)
}
if got.SubClashUserAgentRegex != `(?i)^custom-clash/` {
t.Fatalf("subClashUserAgentRegex = %q, want %q", got.SubClashUserAgentRegex, `(?i)^custom-clash/`)
}
if got.SubClashPath != "/clash-custom/" {
t.Fatalf("subClashPath = %q, want %q", got.SubClashPath, "/clash-custom/")
}
if got.SubClashURI != "https://subscriptions.example.com/clash-custom/" {
t.Fatalf("subClashURI = %q, want %q", got.SubClashURI, "https://subscriptions.example.com/clash-custom/")
}
}
func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
settings.SubClashUserAgentRegex = "["
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex")
}
}
func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
settings.SubJsonUserAgentRegex = "["
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex")
}
}
@@ -0,0 +1,19 @@
package service
import (
"strings"
"testing"
)
func TestValidateRegex(t *testing.T) {
for _, pattern := range []string{"", `(?i)^jsonclient([ /]|$)`, `(?m)^general-purpose$`} {
if err := ValidateRegex(pattern); err != nil {
t.Errorf("ValidateRegex(%q) returned %v", pattern, err)
}
}
for _, pattern := range []string{"[", strings.Repeat("a", 2049)} {
if err := ValidateRegex(pattern); err == nil {
t.Errorf("ValidateRegex(%q) accepted an invalid pattern", pattern)
}
}
}
-101
View File
@@ -1,101 +0,0 @@
package service
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
var subBalancerStrategies = map[string]struct{}{
"leastLoad": {},
"leastPing": {},
"random": {},
"roundRobin": {},
}
// SubBalancerService manages client-side JSON-subscription balancers; rows
// are read per request by internal/sub, so mutations need no xray restart.
type SubBalancerService struct{}
func (s *SubBalancerService) validate(b *model.SubBalancer) error {
b.Remark = strings.TrimSpace(b.Remark)
if b.Remark == "" {
return common.NewError("balancer remark is required")
}
if len(b.Remark) > 256 {
return common.NewError("balancer remark too long (max 256)")
}
if b.Strategy == "" {
b.Strategy = "random"
}
if _, ok := subBalancerStrategies[b.Strategy]; !ok {
return common.NewError("invalid balancer strategy:", b.Strategy)
}
if len(b.InboundIds) == 0 {
return common.NewError("balancer must select at least one inbound")
}
if b.SortOrder < 1 {
b.SortOrder = 1
}
return nil
}
// List returns all balancers in subscription order.
func (s *SubBalancerService) List() ([]*model.SubBalancer, error) {
var balancers []*model.SubBalancer
err := database.GetDB().Model(&model.SubBalancer{}).
Order("sort_order asc, id asc").Find(&balancers).Error
return balancers, err
}
func (s *SubBalancerService) Get(id int) (*model.SubBalancer, error) {
var balancer model.SubBalancer
if err := database.GetDB().First(&balancer, id).Error; err != nil {
return nil, err
}
return &balancer, nil
}
func (s *SubBalancerService) Create(balancer *model.SubBalancer) (*model.SubBalancer, error) {
if err := s.validate(balancer); err != nil {
return nil, err
}
if err := database.GetDB().Create(balancer).Error; err != nil {
return nil, err
}
return balancer, nil
}
func (s *SubBalancerService) Update(id int, balancer *model.SubBalancer, enabled *bool) (*model.SubBalancer, error) {
if err := s.validate(balancer); err != nil {
return nil, err
}
current, err := s.Get(id)
if err != nil {
return nil, err
}
current.Remark = balancer.Remark
current.Strategy = balancer.Strategy
current.InboundIds = balancer.InboundIds
current.SortOrder = balancer.SortOrder
if enabled != nil {
current.Enabled = *enabled
}
if err := database.GetDB().Save(current).Error; err != nil {
return nil, err
}
return current, nil
}
func (s *SubBalancerService) Delete(id int) error {
res := database.GetDB().Delete(&model.SubBalancer{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return common.NewError("sub balancer not found")
}
return nil
}
@@ -1,36 +0,0 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Deleting an inbound that a sub-balancer selects must strip its id from
// InboundIds, leaving no dangling member reference (#5648 mirrors the hosts
// cascade). With the only member gone the balancer stops emitting a doc.
func TestDelInboundClearsSubBalancerInboundIds(t *testing.T) {
setupSubBalancerDB(t)
ib := &model.Inbound{UserId: 1, Tag: "cleanup", Enable: false, Listen: "203.0.113.7", Port: 5001, Protocol: model.VLESS, Remark: "cleanup", Settings: `{}`, StreamSettings: `{}`}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
balSvc := &SubBalancerService{}
bal, err := balSvc.Create(&model.SubBalancer{Remark: "bal", Strategy: "random", InboundIds: []int{ib.Id}, SortOrder: 1, Enabled: true})
if err != nil {
t.Fatalf("create balancer: %v", err)
}
if _, err := (&InboundService{}).DelInbound(ib.Id); err != nil {
t.Fatalf("DelInbound: %v", err)
}
stored, err := balSvc.Get(bal.Id)
if err != nil {
t.Fatalf("get balancer: %v", err)
}
if len(stored.InboundIds) != 0 {
t.Fatalf("InboundIds = %v, want empty (no dangling id)", stored.InboundIds)
}
}
-161
View File
@@ -1,161 +0,0 @@
package service
import (
"path/filepath"
"strings"
"sync"
"testing"
"github.com/op/go-logging"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
)
var subBalancerLoggerOnce sync.Once
func setupSubBalancerDB(t *testing.T) {
t.Helper()
subBalancerLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() {
if err := database.CloseDB(); err != nil {
t.Logf("CloseDB warning: %v", err)
}
})
}
func TestSubBalancerServiceCRUD(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
created, err := svc.Create(&model.SubBalancer{
Remark: "auto", Strategy: "", InboundIds: []int{1, 2}, SortOrder: 0, Enabled: false,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if created.Strategy != "random" {
t.Fatalf("strategy = %q, want normalized random", created.Strategy)
}
if created.SortOrder != 1 {
t.Fatalf("sortOrder = %d, want normalized 1", created.SortOrder)
}
stored, err := svc.Get(created.Id)
if err != nil {
t.Fatalf("get: %v", err)
}
if stored.Enabled {
t.Fatal("explicit disabled balancer must be stored disabled")
}
second, err := svc.Create(&model.SubBalancer{
Remark: "second", Strategy: "leastPing", InboundIds: []int{1}, SortOrder: 3, Enabled: true,
})
if err != nil {
t.Fatalf("create second: %v", err)
}
list, err := svc.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 2 || list[0].Id != created.Id || list[1].Id != second.Id {
t.Fatalf("list order = [%d %d], want [%d %d]", list[0].Id, list[1].Id, created.Id, second.Id)
}
enabledFalse := false
updated, err := svc.Update(second.Id, &model.SubBalancer{
Remark: "renamed", Strategy: "leastLoad", InboundIds: []int{2}, SortOrder: 2,
}, &enabledFalse)
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.Remark != "renamed" || updated.Strategy != "leastLoad" || updated.SortOrder != 2 || updated.Enabled {
t.Fatalf("update stored wrong row: %+v", updated)
}
after, err := svc.Get(second.Id)
if err != nil {
t.Fatalf("get after update: %v", err)
}
if after.Enabled || after.Strategy != "leastLoad" || len(after.InboundIds) != 1 || after.InboundIds[0] != 2 {
t.Fatalf("update did not persist: %+v", after)
}
if err := svc.Delete(created.Id); err != nil {
t.Fatalf("delete: %v", err)
}
list, err = svc.List()
if err != nil {
t.Fatalf("list after delete: %v", err)
}
if len(list) != 1 || list[0].Id != second.Id {
t.Fatalf("list after delete = %v", list)
}
}
// roundRobin is a valid xray routing strategy (selects outbounds in order) and
// must pass the same validation as the other three.
func TestSubBalancerServiceRoundRobin(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
created, err := svc.Create(&model.SubBalancer{
Remark: "rr", Strategy: "roundRobin", InboundIds: []int{1, 2}, SortOrder: 1, Enabled: true,
})
if err != nil {
t.Fatalf("create roundRobin: %v", err)
}
if created.Strategy != "roundRobin" {
t.Fatalf("strategy = %q, want roundRobin", created.Strategy)
}
stored, err := svc.Get(created.Id)
if err != nil {
t.Fatalf("get: %v", err)
}
if stored.Strategy != "roundRobin" {
t.Fatalf("stored strategy = %q, want roundRobin", stored.Strategy)
}
}
// Deleting a missing balancer reports not-found instead of success:true, so
// a stale UI row can't claim a delete that touched nothing.
func TestSubBalancerServiceDeleteNotFound(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
if err := svc.Delete(999); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("Delete(999) = %v, want a not-found error", err)
}
}
func TestSubBalancerServiceValidation(t *testing.T) {
setupSubBalancerDB(t)
svc := &SubBalancerService{}
cases := []struct {
name string
row model.SubBalancer
want string
}{
{"empty remark", model.SubBalancer{Strategy: "random", InboundIds: []int{1}}, "remark is required"},
{"bad strategy", model.SubBalancer{Remark: "x", Strategy: "fastest", InboundIds: []int{1}}, "invalid balancer strategy"},
{"no inbounds", model.SubBalancer{Remark: "x", Strategy: "random"}, "at least one inbound"},
{"long remark", model.SubBalancer{Remark: strings.Repeat("x", 257), Strategy: "random", InboundIds: []int{1}}, "max 256"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := svc.Create(&tc.row)
if err == nil {
t.Fatal("create must fail")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error = %q, want substring %q", err.Error(), tc.want)
}
})
}
}
+9 -47
View File
@@ -137,23 +137,20 @@ func (t *Tgbot) SubmitAddClient() (bool, error) {
})
}
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
// buildSubscriptionURLs builds the subscription page URL for a client email
func (t *Tgbot) buildSubscriptionURLs(email string) (string, error) {
// Resolve subId from client email
traffic, client, err := t.inboundService.GetClientByEmail(email)
_ = traffic
if err != nil || client == nil {
return "", "", errors.New("client not found")
return "", errors.New("client not found")
}
// Gather settings to construct absolute URLs
subURI, _ := t.settingService.GetSubURI()
subJsonURI, _ := t.settingService.GetSubJsonURI()
subDomain, _ := t.settingService.GetSubDomain()
subPort, _ := t.settingService.GetSubPort()
subPath, _ := t.settingService.GetSubPath()
subJsonPath, _ := t.settingService.GetSubJsonPath()
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
subKeyFile, _ := t.settingService.GetSubKeyFile()
subCertFile, _ := t.settingService.GetSubCertFile()
@@ -189,17 +186,10 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
if !strings.HasSuffix(subPath, "/") {
subPath = subPath + "/"
}
if !strings.HasPrefix(subJsonPath, "/") {
subJsonPath = "/" + subJsonPath
}
if !strings.HasSuffix(subJsonPath, "/") {
subJsonPath = subJsonPath + "/"
}
var subURL string
var subJsonURL string
// If pre-configured URIs are available, use them directly
// If a pre-configured URI is available, use it directly
if subURI != "" {
if !strings.HasSuffix(subURI, "/") {
subURI = subURI + "/"
@@ -209,32 +199,17 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
}
if subJsonURI != "" {
if !strings.HasSuffix(subJsonURI, "/") {
subJsonURI = subJsonURI + "/"
}
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
} else {
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
}
if !subJsonEnable {
subJsonURL = ""
}
return subURL, subJsonURL, nil
return subURL, nil
}
// sendClientSubLinks sends the subscription links for the client to the chat.
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
subURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
}
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
if subJsonURL != "" {
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
}
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
@@ -249,7 +224,7 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
subURL, _, err := t.buildSubscriptionURLs(email)
subURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
@@ -329,9 +304,9 @@ func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
}
}
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
// sendClientQRLinks generates QR images for the subscription URL and a few individual links, then sends them
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
subURL, err := t.buildSubscriptionURLs(email)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return
@@ -359,19 +334,6 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
}
// Send JSON URL QR (filename: subjson.png) when available
if subJsonURL != "" {
if png, err := createQR(subJsonURL, 320); err == nil {
document := tu.Document(
tu.ID(chatId),
tu.FileFromBytes(png, "subjson.png"),
)
_, _ = bot.SendDocument(context.Background(), document)
} else {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
}
}
// Also generate a few individual links' QRs (first up to 5)
subPageURL := subURL
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil)
-35
View File
@@ -62,41 +62,6 @@ func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
header := fmt.Sprintf("<b>📡 %s</b>\n", host)
switch e.Type {
case eventbus.EventOutboundDown:
msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
"Tag=="+e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
if data.Error != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
"Error=="+data.Error)
}
if data.Delay > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
"Delay=="+fmt.Sprintf("%d", data.Delay))
}
}
return msg
case eventbus.EventOutboundUp:
msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
"Tag=="+e.Source)
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
"Delay=="+fmt.Sprintf("%d", data.Delay))
}
return msg
case eventbus.EventXrayCrash:
errStr := ""
if e.Data != nil {
errStr = fmt.Sprint(e.Data)
}
msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
if errStr != "" {
msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
}
return msg
case eventbus.EventNodeDown:
msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "تثبيت",
"clients": "عملاء",
"usage": "استخدام",
"twoFactorCode": "الكود",
"remained": "المتبقي",
"security": "أمان",
"somethingWentWrong": "حدث خطأ ما",
@@ -103,8 +102,7 @@
"docs": "التوثيق",
"openMenu": "فتح القائمة",
"pinSidebar": "تثبيت الشريط الجانبي",
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي",
"subFormats": "Sub Formats"
"unpinSidebar": "إلغاء تثبيت الشريط الجانبي"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "فُصل {detached}، تخطي {skipped}، أخطاء {errors}.",
"subLinksTitle": "روابط الاشتراك ({count})",
"subLinkColumn": "رابط الاشتراك",
"subJsonLinkColumn": "رابط JSON للاشتراك",
"subLinksCopyAll": "نسخ الكل",
"subLinksCopiedAll": "تم نسخ {count} رابط",
"subLinksEmpty": "لا يحتوي أي من العملاء المحددين على معرف اشتراك.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "المنفذ الافتراضي 2053 معروف — غيّره إلى منفذ عشوائي.",
"warnDefaultBasePath": "المسار الأساسي الافتراضي \"/\" معروف — غيّره إلى مسار عشوائي.",
"warnDefaultSubPath": "مسار الاشتراك الافتراضي \"/sub/\" معروف — قم بتغييره.",
"warnDefaultJsonPath": "مسار اشتراك JSON الافتراضي \"/json/\" معروف — قم بتغييره.",
"panelListeningIP": "IP الاستماع",
"panelListeningIPDesc": "عنوان IP للبانل. (سيبه فاضي عشان يستمع على كل الـ IPs)",
"panelListeningDomain": "دومين الاستماع",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "مسار URI للبانل. (يبدأ بـ '/' وبينتهي بـ '/')",
"pageSize": "حجم الصفحة",
"pageSizeDesc": "حدد حجم الصفحة لجدول الإدخالات. (0 = تعطيل)",
"panelOutbound": "صادر ترافيك اللوحة",
"panelOutboundDesc": "بيوجه طلبات اللوحة نفسها — فحص إصدارات وتنزيلات اللوحة/Xray، تيليجرام، وتحديث ملفات geo العادي — عبر صادر Xray ده لتجاوز فلترة GitHub/تيليجرام على الخادم. وارد جسر محلي بيتضاف تلقائياً للإعداد الشغال وبيتطبق مباشرة. تحديث Geodata التلقائي الأصلي في Xray مش متأثر؛ ليه صادر تنزيل خاص بيه. اتركه فارغاً للاتصال المباشر.",
"panelOutboundPh": "اتصال مباشر",
"datepicker": "نوع التقويم",
"datepickerPlaceholder": "اختار التاريخ",
"datepickerDescription": "المهام المجدولة هتشتغل بناءً على التقويم ده.",
"oldUsername": "اسم المستخدم الحالي",
"currentPassword": "الباسورد الحالي",
"newUsername": "اسم المستخدم الجديد",
@@ -1104,22 +1095,6 @@
"subSettings": "الاشتراك",
"subEnable": "تفعيل خدمة الاشتراك",
"subEnableDesc": "يفعل خدمة الاشتراك.",
"subJsonEnable": "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل.",
"subJsonEnableTitle": "اشتراك JSON",
"subClashEnableTitle": "اشتراك Clash / Mihomo",
"subFormatsTipTitle": "إعدادات الاشتراك الخاصة بالتنسيق",
"subFormatsTipDesc": "اضبط مسارات URL وعناوين URL العكسية والاكتشاف التلقائي للعملاء لكل من JSON وClash / Mihomo بشكل منفصل.",
"subFormatsTipAction": "فتح تنسيقات الاشتراك",
"subJsonAutoDetect": "اكتشاف عملاء Xray JSON تلقائيًا",
"subJsonAutoDetectDesc": "عند التفعيل، يتلقى العملاء المتوافقون المعروفون الذين يطلبون رابط الاشتراك القياسي مصفوفة إعدادات Xray JSON تلقائيًا. يحصل العملاء الآخرون على الاستجابة الخام/Base64. يتطلب تفعيل اشتراك JSON وإعادة تشغيل اللوحة.",
"subJsonAlwaysArray": "إرجاع مصفوفة JSON دائمًا",
"subJsonAlwaysArrayDesc": "يعيد مسار اشتراك JSON الصريح كمصفوفة حتى عند وجود ملف واحد، وفقًا لمعيار XTLS. تستخدم استجابات JSON المكتشفة تلقائيًا المصفوفات دائمًا. عطّله للاحتفاظ باستجابة الكائن الواحد القديمة.",
"subJsonUserAgentRegex": "تعبير User-Agent لعملاء Xray JSON",
"subJsonUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) لاختيار صيغة Xray JSON تلقائيًا على رابط الاشتراك القياسي. فارغ افتراضيًا، لذا يبقى الاكتشاف التلقائي معطلاً حتى تحدد نمطًا للعملاء الذين تريد خدمتهم. يحصل العملاء الآخرون على الاستجابة الخام/Base64. أعد تشغيل اللوحة بعد التغيير.",
"subClashAutoDetect": "اكتشاف عملاء Clash/Mihomo تلقائيًا",
"subClashAutoDetectDesc": "عند التفعيل، يتلقى عملاء Clash/Mihomo المعروفون الذين يطلبون رابط الاشتراك القياسي إعداد Clash بصيغة YAML تلقائيًا. تستمر المتصفحات في عرض صفحة الاشتراك، ويحصل العملاء الآخرون على الاستجابة الخام/Base64، وتبقى روابط JSON وClash الصريحة متاحة. يتطلب تفعيل اشتراك Clash/Mihomo وإعادة تشغيل اللوحة لتطبيق التغيير.",
"subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",
"subClashUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) للتعرف على عملاء Clash/Mihomo في رابط الاشتراك القياسي. اتركه فارغًا لاستخدام النمط الافتراضي. أعد تشغيل اللوحة بعد التغيير.",
"subTitle": "عنوان الاشتراك",
"subTitleDesc": "العنوان اللي هيظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "رابط الدعم",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "إعلان",
"subAnnounceDesc": "نص الإعلان المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "مجلد قالب الاشتراك",
"subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
"subThemeDirDocs": "دليل القالب ↗",
"subEnableRouting": "تفعيل التوجيه",
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
"subRoutingRules": "قواعد التوجيه",
"subRoutingRulesDesc": "ألصق رابط happ:// جاهزًا أو عنوان HTTPS دائمًا. تحدّث اللوحة القواعد البعيدة في الخلفية وتحتفظ بآخر قيمة صالحة، لذلك لا تنتظر طلبات الاشتراك المصدر. (فقط لـ Happ)",
"subHideSettings": "إخفاء إعدادات الخادم",
"subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
"subIncyEnableRouting": "تفعيل التوجيه",
"subIncyEnableRoutingDesc": "حقن ملف تعريف التوجيه في محتوى الاشتراك لعميل Incy. (فقط لـ Incy)",
"subIncyRoutingRules": "قواعد التوجيه",
"subIncyRoutingRulesDesc": "ألصق رابط incy:// جاهزًا أو عنوان HTTPS دائمًا لملف JSON. ينشئ Incy ملف autorouting ويحدّثه تلقائيًا. (فقط لـ Incy)",
"subClashEnableRouting": "تفعيل التوجيه",
"subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
"subClashRoutingRules": "قواعد التوجيه العامة",
"subClashRoutingRulesDesc": "ألصق قواعد/YAML أو عنوان HTTPS دائمًا. تحدّثه اللوحة في الخلفية، وتستورد المجموعات وموفري القواعد والقواعد فقط، وتحافظ على عقد VPN المُنشأة وآخر قيمة صالحة.",
"subListen": "IP الاستماع",
"subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)",
"subPort": "بورت الاستماع",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "CIDR وكلاء موثوقين",
"trustedProxyCidrsDesc": "IPs/CIDRs مفصولة بفواصل يُسمح لها بتعيين ترويسات host، proto و client IP المعاد توجيهها.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "يضيف أقنعة TCP/UDP الخاصة بـ Xray finalmask ومعلمات QUIC إلى كل ملف Xray JSON يتم إنشاؤه. يتطلب تطبيقًا يدعم اشتراكات Xray JSON وإصدارًا حديثًا من نواة Xray.",
"packets": "الحزم",
"length": "الطول",
"interval": "الفاصل",
@@ -1185,16 +1143,12 @@
"packet": "حزمة",
"delayMs": "التأخير (ms)",
"applyTo": "تطبيق على",
"addNoise": "+ ضوضاء",
"concurrency": "التزامن",
"xudpConcurrency": "تزامن xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "ينقل أكثر من تيار بيانات مستقل خلال تيار بيانات واحد قائم.",
"muxSett": "إعدادات MUX",
"direct": "اتصال مباشر",
"directDesc": "ينشئ اتصال مباشر مع الدومينات أو نطاقات IP لدولة معينة.",
"certs": "الشهادات",
"dateAndTime": "التاريخ والوقت",
"proxyAndServer": "البروكسي والسيرفر",
@@ -1204,7 +1158,6 @@
"language": "اللغة",
"security": {
"admin": "بيانات الأدمن",
"twoFactorModalError": "رمز خاطئ",
"show": "إظهار",
"hide": "إخفاء",
"apiTokenNew": "رمز جديد",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "خطأ في الحصول على حركات المرور الصادرة",
"resetOutboundTrafficError": "خطأ في إعادة تعيين حركات المرور الصادرة"
},
"eventOutboundDown": "غير متصل",
"eventOutboundUp": "متصل",
"eventXrayCrash": "تعطّل",
"eventNodeDown": "غير متصلة",
"eventNodeUp": "متصلة",
"eventCPUHigh": "ارتفاع استخدام المعالج (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "مسح",
"secretClearUndo": "تراجع عن المسح",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "قائمة سماح حد IP",
"ipLimitAllowlistDesc": "عناوين وشبكات لا يحسبها حد IP ولا يحظرها، حتى لا يستهلك عنوان مكتب أو حرم جامعي مشترك حد العميل. IPs/CIDRs مفصولة بفواصل.",
"subBalancers": {
"menu": "موزّعات الاشتراك",
"title": "موزّع الاشتراك",
"add": "إضافة موزّع",
"desc": "كل موزّع مُفعّل يُضاف إلى اشتراك JSON كملف تعريف إضافي يختار تلقائيًا أفضل نقطة نهاية من الإينبوندات المحددة.",
"remark": "ملاحظة",
"remarkPlaceholder": "تلقائي · الأسرع",
"strategy": "الاستراتيجية",
"strategyLeastLoad": "أقل حمل",
"strategyLeastPing": "أقل ping",
"strategyRandom": "عشوائي",
"strategyRoundRobin": "دوران",
"sortOrder": "الترتيب",
"sortOrderHelp": "الموضع في قائمة الاشتراك، متداخل مع ترتيب الإينبوندات؛ عند تساوي الرقم يأتي الموزّع بعد الإينباند.",
"inbounds": "الإينبوندات",
"inboundsCount": "{count} الإينبوندات",
"enabled": "مُفعّل",
"empty": "لا يوجد موزّعات بعد",
"deleteConfirm": "حذف هذا الموزّع؟",
"errRemarkRequired": "الملاحظة مطلوبة",
"errInboundsRequired": "اختر إينبوندًا واحدًا على الأقل",
"errSortOrder": "الترتيب يجب أن يكون عددًا صحيحًا ≥ 1",
"toasts": {
"list": "تعذّر عرض موزّعات الاشتراك",
"create": "تعذّر إنشاء موزّع اشتراك",
"update": "تعذّر تحديث موزّع اشتراك",
"delete": "تعذّر حذف موزّع اشتراك",
"invalidId": "معرّف غير صالح"
},
"tabBalancers": "موازنات التحميل",
"tabObservatory": "المرصد",
"observatory": {
"title": "مرصد الموزّع",
"desc": "معاملات probe لـ burstObservatory المُضمَّن في كل ملف leastPing/leastLoad. random/roundRobin بلا مرصد. يُحفظ كإعداد شامل لاشتراك JSON.",
"destination": "عنوان probe",
"destinationDesc": "العنوان الذي يقيس العميل به كل صادر عضو.",
"connectivity": "عنوان الاتصالية",
"connectivityDesc": "عنوان اختياري للتحقق مرة واحدة من وصول العضو للهدف. اتركه فارغًا للتخطي.",
"interval": "فترة probe",
"intervalDesc": "الزمن بين جولات probe، مثال 1m.",
"timeout": "مهلة probe",
"timeoutDesc": "مهلة probe واحدة، مثال 5s.",
"sampling": "أخذ العينات",
"samplingDesc": "عدد probe المتتالية لقياس الاستقرار.",
"httpMethod": "أسلوب HTTP",
"httpMethodDesc": "الأسلوب المستخدم في طلبات probe.",
"note": "تحمل موزّعات leastPing/leastLoad دائمًا burstObservatory. يخصّص هذا المفتاح معاملات probe — أوقفه لاستخدام الإعدادات الافتراضية المدمجة. تُطبَّق التغييرات بعد إعادة تشغيل اللوحة."
}
},
"TGBotSettings": "بوت تيليجرام",
"notifications": "الإشعارات",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح",
"FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء.",
"eventOutboundDown": "الصادر {{ .Tag }} غير متصل",
"eventOutboundUp": "الصادر {{ .Tag }} متصل",
"eventErrorDetail": "الخطأ: {{ .Error }}",
"eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية",
"eventXrayCrash": "تعطّل Xray",
"eventXrayCrashError": "الخطأ: {{ .Error }}",
"eventNodeDown": "العقدة {{ .Name }} غير متصلة",
"eventNodeUp": "العقدة {{ .Name }} متصلة",
"eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Install",
"clients": "Clients",
"usage": "Usage",
"twoFactorCode": "Code",
"remained": "Remaining",
"security": "Security",
"somethingWentWrong": "Something went wrong",
@@ -103,8 +102,7 @@
"docs": "Documentation",
"openMenu": "Open menu",
"pinSidebar": "Pin sidebar",
"unpinSidebar": "Unpin sidebar",
"subFormats": "Sub Formats"
"unpinSidebar": "Unpin sidebar"
},
"pages": {
"login": {
@@ -738,7 +736,6 @@
"detachFromInboundsResultMixed": "Detached {detached}, skipped {skipped}, errors {errors}.",
"subLinksTitle": "Sub links ({count})",
"subLinkColumn": "Subscription URL",
"subJsonLinkColumn": "Subscription JSON URL",
"subLinksCopyAll": "Copy all",
"subLinksCopiedAll": "Copied {count} link(s)",
"subLinksEmpty": "None of the selected clients have a subscription ID.",
@@ -1208,7 +1205,6 @@
"warnDefaultPort": "Default port 2053 is well-known — change it to a random port.",
"warnDefaultBasePath": "Default base path \"/\" is well-known — change it to a random path.",
"warnDefaultSubPath": "Default subscription path \"/sub/\" is well-known — change it.",
"warnDefaultJsonPath": "Default JSON subscription path \"/json/\" is well-known — change it.",
"panelListeningIP": "Listen IP",
"panelListeningIPDesc": "The IP address for the web panel. (leave blank to listen on all IPs)",
"panelListeningDomain": "Listen Domain",
@@ -1223,16 +1219,11 @@
"panelUrlPathDesc": "The URI path for the web panel. (begins with ‘/‘ and concludes with ‘/‘)",
"pageSize": "Pagination Size",
"pageSizeDesc": "Define page size for inbounds table. (0 = disable)",
"panelOutbound": "Panel Traffic Outbound",
"panelOutboundDesc": "Routes the panel's own requests — panel/Xray version checks and downloads, Telegram, and the normal geo-file update — through this Xray outbound to bypass server-side filtering of GitHub/Telegram. A loopback bridge inbound is added to the running config automatically and applied live. The Xray-native Geodata Auto-Update is not affected; it has its own download outbound. Leave empty for a direct connection.",
"panelOutboundPh": "Direct connection",
"remarkTemplate": "Remark Template",
"remarkTemplateDesc": "When set, this replaces the remark model for every subscription link — write your own format with the variable tokens (use the button to insert them). Leave empty to use the model above.",
"subShowIdentityOnAllLinks": "Show identity on every link",
"subShowIdentityOnAllLinksDesc": "When enabled, {{EMAIL}} and {{USERNAME}} stay on every subscription-body remark. Usage tokens still appear on the first link only.",
"datepicker": "Calendar Type",
"datepickerPlaceholder": "Select date",
"datepickerDescription": "Scheduled tasks will run based on this calendar.",
"oldUsername": "Current Username",
"currentPassword": "Current Password",
"newUsername": "New Username",
@@ -1248,22 +1239,6 @@
"subSettings": "Subscription",
"subEnable": "Subscription Service",
"subEnableDesc": "Enable/Disable the subscription service.",
"subJsonEnable": "Enable/Disable the JSON subscription endpoint independently.",
"subJsonEnableTitle": "JSON subscription",
"subClashEnableTitle": "Clash / Mihomo subscription",
"subFormatsTipTitle": "Format-specific subscription settings",
"subFormatsTipDesc": "Configure JSON and Clash / Mihomo URL paths, reverse URLs, and client auto-detection separately.",
"subFormatsTipAction": "Open Sub Formats",
"subJsonAutoDetect": "Auto-detect Xray JSON clients",
"subJsonAutoDetectDesc": "When enabled, recognized compatible clients requesting the standard subscription URL receive an Xray JSON configuration array automatically. Other clients keep the raw/base64 response. Requires JSON subscription to be enabled and a panel restart to apply.",
"subJsonAlwaysArray": "Always return a JSON array",
"subJsonAlwaysArrayDesc": "Return the explicit JSON subscription endpoint as an array even when it contains one profile, as required by the XTLS subscription standard. Auto-detected JSON responses always use arrays. Leave disabled to preserve the legacy single-object response.",
"subJsonUserAgentRegex": "Xray JSON User-Agent regex",
"subJsonUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to auto-select the Xray JSON format on the standard subscription URL. Empty by default, so auto-detection stays off until you set a pattern for the clients you want to serve. Other clients keep the raw/base64 response. Restart the panel after changes.",
"subClashAutoDetect": "Auto-detect Clash/Mihomo clients",
"subClashAutoDetectDesc": "When enabled, recognized Clash/Mihomo clients requesting the standard subscription URL receive Clash YAML automatically. Browsers still show the subscription page, other clients keep the raw/base64 response, and the explicit JSON and Clash URLs remain available. Requires Clash/Mihomo subscription to be enabled and a panel restart to apply.",
"subClashUserAgentRegex": "Clash/Mihomo User-Agent regex",
"subClashUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to recognize Clash/Mihomo clients on the standard subscription URL. Leave empty to use the default pattern. Restart the panel after changes.",
"subTitle": "Subscription Title",
"subTitleDesc": "Title shown in VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "Support URL",
@@ -1272,23 +1247,8 @@
"subProfileUrlDesc": "A link to your website displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Announce",
"subAnnounceDesc": "The announcement text displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Sub Theme Directory",
"subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
"subThemeDirDocs": "Template guide ↗",
"subEnableRouting": "Enable routing",
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules",
"subRoutingRulesDesc": "Paste a ready happ:// deeplink or one permanent HTTPS URL returning a deeplink or JSON. The panel refreshes remote rules in the background and keeps the last valid value, so subscription requests never wait for the source. (Happ only)",
"subHideSettings": "Hide server settings",
"subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
"subIncyEnableRouting": "Enable routing",
"subIncyEnableRoutingDesc": "Inject a routing profile into the subscription body for the Incy client. (Only for Incy)",
"subIncyRoutingRules": "Routing rules",
"subIncyRoutingRulesDesc": "Paste a ready incy:// deeplink or one permanent HTTPS URL returning JSON. An HTTPS URL becomes an autorouting profile refreshed by Incy itself. (Incy only)",
"subClashEnableRouting": "Enable routing",
"subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
"subClashRoutingRules": "Global routing rules",
"subClashRoutingRulesDesc": "Paste inline rules/YAML or one permanent HTTPS URL. The panel refreshes it in the background, imports only groups/providers/rules, preserves generated VPN nodes and keeps the last valid value.",
"subListen": "Listen IP",
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
"subPort": "Listen Port",
@@ -1317,8 +1277,6 @@
"trustedProxyCidrs": "Trusted proxy CIDRs",
"trustedProxyCidrsDesc": "Comma-separated IPs/CIDRs allowed to set forwarded host, proto, and client IP headers.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Inject Xray finalmask TCP/UDP masks and QUIC parameters into every generated Xray JSON profile. Requires a client app that supports Xray JSON subscriptions and a recent Xray core.",
"packets": "Packets",
"length": "Length",
"interval": "Interval",
@@ -1329,16 +1287,12 @@
"packet": "Packet",
"delayMs": "Delay (ms)",
"applyTo": "Apply to",
"addNoise": "+ Noise",
"concurrency": "Concurrency",
"xudpConcurrency": "xudp concurrency",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Transmit multiple independent data streams within an established data stream.",
"muxSett": "Mux Settings",
"direct": "Direct Connection",
"directDesc": "Directly establishes connections with domains or IP ranges of a specific country.",
"certs": "Certificates",
"dateAndTime": "Date and Time",
"proxyAndServer": "Proxy and Server",
@@ -1348,7 +1302,6 @@
"language": "Language",
"security": {
"admin": "Admin credentials",
"twoFactorModalError": "Wrong code",
"show": "Show",
"hide": "Hide",
"apiTokenNew": "New token",
@@ -1370,9 +1323,6 @@
"getOutboundTrafficError": "Error getting traffic",
"resetOutboundTrafficError": "Error resetting outbound traffic"
},
"eventOutboundDown": "Down",
"eventOutboundUp": "Up",
"eventXrayCrash": "Crash",
"eventNodeDown": "Down",
"eventNodeUp": "Up",
"eventCPUHigh": "CPU high (%)",
@@ -1384,59 +1334,6 @@
},
"secretClear": "Clear",
"secretClearUndo": "Undo clear",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP limit allowlist",
"ipLimitAllowlistDesc": "Addresses and networks that the IP limit never counts and never bans, so a shared office or campus address cannot use up a client's limit. Comma-separated, IP or CIDR.",
"subBalancers": {
"menu": "Sub Balancers",
"title": "Subscription balancer",
"add": "Add balancer",
"desc": "Each enabled balancer is added to the JSON subscription as one extra profile that automatically picks the best of the selected inbounds' endpoints (routing.balancers + burstObservatory in the client config).",
"remark": "Remark",
"remarkPlaceholder": "Auto · fastest",
"strategy": "Strategy",
"strategyLeastLoad": "Least load",
"strategyLeastPing": "Least ping",
"strategyRandom": "Random",
"strategyRoundRobin": "Round robin",
"sortOrder": "Order",
"sortOrderHelp": "Position in the subscription list, interleaved with the inbounds' own order; on equal numbers the balancer comes after the inbound.",
"inbounds": "Inbounds",
"inboundsCount": "{count} Inbounds",
"enabled": "Enabled",
"empty": "No balancers yet",
"deleteConfirm": "Delete this balancer?",
"errRemarkRequired": "Remark is required",
"errInboundsRequired": "Select at least one inbound",
"errSortOrder": "Order must be a whole number ≥ 1",
"toasts": {
"list": "Failed to list subscription balancers",
"create": "Failed to create subscription balancer",
"update": "Failed to update subscription balancer",
"delete": "Failed to delete subscription balancer",
"invalidId": "Invalid id"
},
"tabBalancers": "Balancers",
"tabObservatory": "Observatory",
"observatory": {
"title": "Balancer observatory",
"desc": "Probe parameters for the burst observatory emitted into each leastPing/leastLoad balancer profile. random/roundRobin balancers get no observatory. Stored as a panel-wide JSON-sub setting.",
"destination": "Probe URL",
"destinationDesc": "URL the client pings to measure each member outbound.",
"connectivity": "Connectivity URL",
"connectivityDesc": "Optional URL checked once to confirm the member can reach the probe destination. Leave empty to skip.",
"interval": "Probe interval",
"intervalDesc": "Time between probe rounds, e.g. 1m.",
"timeout": "Probe timeout",
"timeoutDesc": "Per-probe timeout, e.g. 5s.",
"sampling": "Sampling",
"samplingDesc": "Number of consecutive probes averaged for stability.",
"httpMethod": "HTTP method",
"httpMethodDesc": "Method used for probe requests.",
"note": "leastPing/leastLoad balancers always carry a burst observatory. This switch customises its probe parameters — turn it off to use the built-in defaults. Changes apply after a panel restart."
}
},
"TGBotSettings": "Telegram Bot",
"notifications": "Notifications",
"notifyTime": {
@@ -1662,12 +1559,8 @@
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ✅ Success",
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Result: ❌ Failed \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Traffic reset process finished for all clients.",
"eventOutboundDown": "Outbound {{ .Tag }} is DOWN",
"eventOutboundUp": "Outbound {{ .Tag }} is UP",
"eventErrorDetail": "Error: {{ .Error }}",
"eventDelayDetail": "Delay: {{ .Delay }}ms",
"eventXrayCrash": "Xray CRASHED",
"eventXrayCrashError": "Error: {{ .Error }}",
"eventNodeDown": "Node {{ .Name }} is DOWN",
"eventNodeUp": "Node {{ .Name }} is UP",
"eventLoginFallback": "Login failed from {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Instalar",
"clients": "Clientes",
"usage": "Uso",
"twoFactorCode": "Código",
"remained": "Restante",
"security": "Seguridad",
"somethingWentWrong": "Algo salió mal",
@@ -103,8 +102,7 @@
"docs": "Documentación",
"openMenu": "Abrir menú",
"pinSidebar": "Fijar barra lateral",
"unpinSidebar": "Desfijar barra lateral",
"subFormats": "Sub Formats"
"unpinSidebar": "Desfijar barra lateral"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Desasociados {detached}, omitidos {skipped}, errores {errors}.",
"subLinksTitle": "Enlaces sub ({count})",
"subLinkColumn": "URL de suscripción",
"subJsonLinkColumn": "URL JSON de suscripción",
"subLinksCopyAll": "Copiar todo",
"subLinksCopiedAll": "Copiados {count} enlace(s)",
"subLinksEmpty": "Ninguno de los clientes seleccionados tiene ID de suscripción.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "El puerto por defecto 2053 es conocido — cámbielo a uno aleatorio.",
"warnDefaultBasePath": "La ruta base por defecto \"/\" es conocida — cámbiela a una ruta aleatoria.",
"warnDefaultSubPath": "La ruta de suscripción por defecto \"/sub/\" es conocida — cámbiela.",
"warnDefaultJsonPath": "La ruta de suscripción JSON por defecto \"/json/\" es conocida — cámbiela.",
"panelListeningIP": "IP de Escucha del Panel",
"panelListeningIPDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
"panelListeningDomain": "Dominio de Escucha del Panel",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "Debe empezar con '/' y terminar con.",
"pageSize": "Tamaño de paginación",
"pageSizeDesc": "Defina el tamaño de página para la tabla de entradas. Establezca 0 para desactivar",
"panelOutbound": "Salida del tráfico del panel",
"panelOutboundDesc": "Enruta las peticiones del propio panel — comprobaciones de versión y descargas de panel/Xray, Telegram y la actualización normal de archivos geo — a través de esta salida de Xray para sortear el filtrado de GitHub/Telegram en el servidor. Una entrada puente local se añade automáticamente a la configuración en ejecución y se aplica en vivo. La Autoactualización de Geodata nativa de Xray no se ve afectada; tiene su propia salida de descarga. Deja vacío para conexión directa.",
"panelOutboundPh": "Conexión directa",
"datepicker": "selector de fechas",
"datepickerPlaceholder": "Seleccionar fecha",
"datepickerDescription": "El tipo de calendario selector especifica la fecha de vencimiento",
"oldUsername": "Nombre de Usuario Actual",
"currentPassword": "Contraseña Actual",
"newUsername": "Nuevo Nombre de Usuario",
@@ -1104,22 +1095,6 @@
"subSettings": "Suscripción",
"subEnable": "Habilitar Servicio",
"subEnableDesc": "Función de suscripción con configuración separada.",
"subJsonEnable": "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente.",
"subJsonEnableTitle": "Suscripción JSON",
"subClashEnableTitle": "Suscripción Clash / Mihomo",
"subFormatsTipTitle": "Configuración de suscripción específica por formato",
"subFormatsTipDesc": "Configura por separado las rutas URL, las URL inversas y la detección automática de clientes para JSON y Clash / Mihomo.",
"subFormatsTipAction": "Abrir formatos de suscripción",
"subJsonAutoDetect": "Detectar automáticamente clientes Xray JSON",
"subJsonAutoDetectDesc": "Al activarlo, los clientes compatibles reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente una matriz de configuraciones Xray JSON. Los demás clientes conservarán la respuesta sin procesar/Base64. Requiere activar la suscripción JSON y reiniciar el panel.",
"subJsonAlwaysArray": "Devolver siempre una matriz JSON",
"subJsonAlwaysArrayDesc": "Devuelve el endpoint JSON explícito como una matriz incluso con un solo perfil, según el estándar XTLS. Las respuestas JSON detectadas automáticamente siempre usan matrices. Desactívalo para conservar la respuesta heredada de un solo objeto.",
"subJsonUserAgentRegex": "Expresión User-Agent de Xray JSON",
"subJsonUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para seleccionar automáticamente el formato Xray JSON en la URL de suscripción estándar. Vacía de forma predeterminada, por lo que la detección automática permanece desactivada hasta que definas un patrón para los clientes que quieras atender. Los demás clientes conservan la respuesta sin procesar/Base64. Reinicia el panel después de cambiarla.",
"subClashAutoDetect": "Detectar automáticamente clientes Clash/Mihomo",
"subClashAutoDetectDesc": "Al activarlo, los clientes Clash/Mihomo reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente YAML de Clash. Los navegadores seguirán mostrando la página de suscripción, los demás clientes conservarán la respuesta sin procesar/Base64 y las URL explícitas de JSON y Clash seguirán disponibles. Requiere activar la suscripción Clash/Mihomo y reiniciar el panel para aplicar el cambio.",
"subClashUserAgentRegex": "Expresión User-Agent de Clash/Mihomo",
"subClashUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para reconocer clientes Clash/Mihomo en la URL de suscripción estándar. Déjala vacía para usar el patrón predeterminado. Reinicia el panel después de cambiarla.",
"subTitle": "Título de la Suscripción",
"subTitleDesc": "Título mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "URL de soporte",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Anuncio",
"subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Directorio del tema de suscripción",
"subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
"subThemeDirDocs": "Guía de plantillas ↗",
"subEnableRouting": "Habilitar enrutamiento",
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
"subRoutingRules": "Reglas de enrutamiento",
"subRoutingRulesDesc": "Pegue un enlace happ:// listo o una URL HTTPS permanente. El panel actualiza las reglas remotas en segundo plano y conserva el último valor válido, sin retrasar las solicitudes de suscripción. (Solo para Happ)",
"subHideSettings": "Ocultar configuración del servidor",
"subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
"subIncyEnableRouting": "Habilitar enrutamiento",
"subIncyEnableRoutingDesc": "Inyectar un perfil de enrutamiento en el cuerpo de la suscripción para el cliente Incy. (Solo para Incy)",
"subIncyRoutingRules": "Reglas de enrutamiento",
"subIncyRoutingRulesDesc": "Pegue un enlace incy:// listo o una URL HTTPS permanente a JSON. Incy crea un perfil de autorouting y lo actualiza automáticamente. (Solo para Incy)",
"subClashEnableRouting": "Habilitar enrutamiento",
"subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
"subClashRoutingRules": "Reglas globales de enrutamiento",
"subClashRoutingRulesDesc": "Pegue reglas/YAML o una URL HTTPS permanente. El panel la actualiza en segundo plano, importa solo grupos, proveedores de reglas y reglas, y conserva los nodos VPN generados y el último valor válido.",
"subListen": "Listening IP",
"subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.",
"subPort": "Puerto de Suscripción",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "CIDR de proxy de confianza",
"trustedProxyCidrsDesc": "IP/CIDR separados por coma que pueden establecer las cabeceras de host, proto e IP del cliente reenviadas.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Inyecta máscaras TCP/UDP de finalmask de Xray y parámetros QUIC en cada perfil Xray JSON generado. Requiere una aplicación compatible con suscripciones Xray JSON y un núcleo Xray reciente.",
"packets": "Paquetes",
"length": "Longitud",
"interval": "Intervalo",
@@ -1185,16 +1143,12 @@
"packet": "Paquete",
"delayMs": "Retraso (ms)",
"applyTo": "Aplicar a",
"addNoise": "+ Ruido",
"concurrency": "Concurrencia",
"xudpConcurrency": "Concurrencia xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Transmite múltiples flujos de datos independientes dentro de un flujo de datos establecido.",
"muxSett": "Configuración Mux",
"direct": "Conexión Directa",
"directDesc": "Establece conexiones directas con dominios o rangos de IP de un país específico.",
"certs": "Certificados",
"dateAndTime": "Fecha y Hora",
"proxyAndServer": "Proxy y Servidor",
@@ -1204,7 +1158,6 @@
"language": "Idioma",
"security": {
"admin": "Credenciales de administrador",
"twoFactorModalError": "Código incorrecto",
"show": "Mostrar",
"hide": "Ocultar",
"apiTokenNew": "Nuevo token",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Error al obtener el tráfico saliente",
"resetOutboundTrafficError": "Error al reiniciar el tráfico saliente"
},
"eventOutboundDown": "Caído",
"eventOutboundUp": "Activo",
"eventXrayCrash": "Caída",
"eventNodeDown": "Caído",
"eventNodeUp": "Activo",
"eventCPUHigh": "CPU alta (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Borrar",
"secretClearUndo": "Deshacer borrado",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Lista de permitidos del límite de IP",
"ipLimitAllowlistDesc": "Direcciones y redes que el límite de IP nunca cuenta ni banea, para que una dirección compartida de oficina o campus no agote el límite de un cliente. IP/CIDR separados por coma.",
"subBalancers": {
"menu": "Balanceadores de suscripción",
"title": "Balanceador de suscripción",
"add": "Añadir balanceador",
"desc": "Cada balanceador activo se añade a la suscripción JSON como un perfil adicional que elige automáticamente el mejor de los endpoints de los inbounds seleccionados.",
"remark": "Comentario",
"remarkPlaceholder": "Auto · el más rápido",
"strategy": "Estrategia",
"strategyLeastLoad": "Menor carga",
"strategyLeastPing": "Menor ping",
"strategyRandom": "Aleatorio",
"strategyRoundRobin": "Round robin",
"sortOrder": "Orden",
"sortOrderHelp": "Posición en la lista de la suscripción, intercalada con el orden de los inbounds; con el mismo número, el balanceador va después del inbound.",
"inbounds": "Inbounds",
"inboundsCount": "{count} Inbounds",
"enabled": "Activado",
"empty": "Aún no hay balanceadores",
"deleteConfirm": "¿Eliminar este balanceador?",
"errRemarkRequired": "El comentario es obligatorio",
"errInboundsRequired": "Selecciona al menos un inbound",
"errSortOrder": "El orden debe ser un número entero ≥ 1",
"toasts": {
"list": "No se pudieron listar los balanceadores de suscripción",
"create": "No se pudo crear el balanceador de suscripción",
"update": "No se pudo actualizar el balanceador de suscripción",
"delete": "No se pudo eliminar el balanceador de suscripción",
"invalidId": "Id no válido"
},
"tabBalancers": "Equilibradores",
"tabObservatory": "Observatorio",
"observatory": {
"title": "Observatorio del balanceador",
"desc": "Parámetros de probe para el burstObservatory incluido en cada perfil leastPing/leastLoad. random/roundRobin no generan observatorio. Se guarda como ajuste global de la suscripción JSON.",
"destination": "URL de probe",
"destinationDesc": "Dirección que el cliente sondea para medir cada salida miembro.",
"connectivity": "URL de conectividad",
"connectivityDesc": "Dirección opcional para verificar una vez que el miembro llega al destino. Vacío para omitir.",
"interval": "Intervalo de probe",
"intervalDesc": "Tiempo entre rondas de probe, p. ej. 1m.",
"timeout": "Tiempo de espera de probe",
"timeoutDesc": "Tiempo de espera de cada probe, p. ej. 5s.",
"sampling": "Muestreo",
"samplingDesc": "Número de probes consecutivos para promediar estabilidad.",
"httpMethod": "Método HTTP",
"httpMethodDesc": "Método usado para las solicitudes de probe.",
"note": "Los balanceadores leastPing/leastLoad siempre llevan un burstObservatory. Este interruptor personaliza sus parámetros de probe — apágalo para usar los valores predeterminados integrados. Los cambios se aplican tras reiniciar el panel."
}
},
"TGBotSettings": "Bot de Telegram",
"notifications": "Notificaciones",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito",
"FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.",
"eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
"eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
"eventErrorDetail": "Error: {{ .Error }}",
"eventDelayDetail": "Retardo: {{ .Delay }} ms",
"eventXrayCrash": "Xray se ha BLOQUEADO",
"eventXrayCrashError": "Error: {{ .Error }}",
"eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
"eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
"eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "نصب",
"clients": "کاربران",
"usage": "استفاده",
"twoFactorCode": "کد",
"remained": "باقی‌مانده",
"security": "امنیت",
"somethingWentWrong": "مشکلی پیش آمد",
@@ -103,8 +102,7 @@
"docs": "مستندات",
"openMenu": "باز کردن منو",
"pinSidebar": "ثابت کردن نوار کناری",
"unpinSidebar": "برداشتن تثبیت نوار کناری",
"subFormats": "Sub Formats"
"unpinSidebar": "برداشتن تثبیت نوار کناری"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "جدا شد {detached}، نادیده {skipped}، خطا {errors}.",
"subLinksTitle": "لینک‌های اشتراک ({count})",
"subLinkColumn": "آدرس اشتراک",
"subJsonLinkColumn": "آدرس JSON اشتراک",
"subLinksCopyAll": "کپی همه",
"subLinksCopiedAll": "{count} لینک کپی شد",
"subLinksEmpty": "هیچ‌کدام از کاربران انتخابی شناسه اشتراک ندارند.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "پورت پیش‌فرض 2053 شناخته‌شده است — آن را به یک پورت تصادفی تغییر دهید.",
"warnDefaultBasePath": "مسیر پایه پیش‌فرض «/» شناخته‌شده است — آن را به یک مسیر تصادفی تغییر دهید.",
"warnDefaultSubPath": "مسیر ساب پیش‌فرض «/sub/» شناخته‌شده است — تغییرش دهید.",
"warnDefaultJsonPath": "مسیر JSON ساب پیش‌فرض «/json/» شناخته‌شده است — تغییرش دهید.",
"panelListeningIP": "آدرس آی‌پی",
"panelListeningIPDesc": "آدرس آی‌پی برای وب پنل. برای گوش‌دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
"panelListeningDomain": "نام دامنه",
@@ -1083,16 +1079,11 @@
"panelUrlPathDesc": "برای وب پنل. با '/' شروع‌ و با '/' خاتمه‌ می‌یابد URI مسیر",
"pageSize": "اندازه صفحه بندی جدول",
"pageSizeDesc": "(اندازه صفحه برای جدول ورودی‌ها.(0 = غیرفعال",
"panelOutbound": "اوتباند ترافیک پنل",
"panelOutboundDesc": "درخواست‌های خود پنل — بررسی نسخه و دانلود پنل/Xray، تلگرام، و به‌روزرسانی معمولی فایل‌های geo — را از این اوتباند Xray عبور می‌دهد تا فیلترینگ GitHub/تلگرام در سمت سرور دور زده شود. یک ورودی پل لوکال به‌صورت خودکار به کانفیگ در حال اجرا اضافه و زنده اعمال می‌شود. روی Geodata Auto-Update نِیتیو Xray اثری ندارد؛ آن اوتباند دانلود مخصوص خودش را دارد. برای اتصال مستقیم خالی بگذارید.",
"panelOutboundPh": "اتصال مستقیم",
"remarkTemplate": "قالب ریمارک",
"remarkTemplateDesc": "اگر پر شود، جای مدلِ ریمارک را برای همه‌ی لینک‌های اشتراک می‌گیرد — فرمت دلخواهت را با توکن‌های متغیر بنویس (از دکمه برای درج استفاده کن). خالی = استفاده از مدلِ بالا.",
"subShowIdentityOnAllLinks": "نمایش هویت در همه لینک‌ها",
"subShowIdentityOnAllLinksDesc": "در صورت فعال بودن، {{EMAIL}} و {{USERNAME}} در یادداشت هر لینک بدنه اشتراک باقی می‌مانند. توکن‌های مصرف همچنان فقط در لینک اول نمایش داده می‌شوند.",
"datepicker": "نوع تقویم",
"datepickerPlaceholder": "انتخاب تاریخ",
"datepickerDescription": "وظایف برنامه ریزی شده بر اساس این تقویم اجرا می‌شود",
"oldUsername": "نام‌کاربری فعلی",
"currentPassword": "رمز‌عبور فعلی",
"newUsername": "نام‌کاربری جدید",
@@ -1108,22 +1099,6 @@
"subSettings": "سابسکریپشن",
"subEnable": "فعال‌سازی سرویس سابسکریپشن",
"subEnableDesc": "سرویس سابسکریپشن‌ را فعال‌می‌کند",
"subJsonEnable": "فعال/غیرفعال‌سازی مستقل نقطه دسترسی سابسکریپشن JSON.",
"subJsonEnableTitle": "اشتراک JSON",
"subClashEnableTitle": "اشتراک Clash / Mihomo",
"subFormatsTipTitle": "تنظیمات اشتراک ویژه هر قالب",
"subFormatsTipDesc": "مسیرهای URL، آدرس‌های معکوس و تشخیص خودکار کلاینت را برای JSON و Clash / Mihomo جداگانه پیکربندی کنید.",
"subFormatsTipAction": "باز کردن قالب‌های اشتراک",
"subJsonAutoDetect": "تشخیص خودکار کلاینت‌های Xray JSON",
"subJsonAutoDetectDesc": "با فعال‌سازی، کلاینت‌های سازگار و شناخته‌شده که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار آرایه تنظیمات Xray JSON دریافت می‌کنند. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. برای اعمال تغییر، اشتراک JSON باید فعال باشد و پنل راه‌اندازی مجدد شود.",
"subJsonAlwaysArray": "همیشه آرایه JSON برگردان",
"subJsonAlwaysArrayDesc": "مسیر صریح اشتراک JSON را حتی برای یک پروفایل، مطابق استاندارد XTLS به‌صورت آرایه برمی‌گرداند. پاسخ‌های JSON تشخیص‌داده‌شده همیشه آرایه هستند. برای حفظ پاسخ قدیمی تک‌شیء غیرفعال بگذارید.",
"subJsonUserAgentRegex": "عبارت User-Agent برای Xray JSON",
"subJsonUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا صیغه Xray JSON روی آدرس استاندارد اشتراک به‌طور خودکار انتخاب شود. به‌طور پیش‌فرض خالی است، بنابراین تشخیص خودکار غیرفعال می‌ماند تا زمانی که الگویی برای کلاینت‌های موردنظر خود تعیین کنید. سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
"subClashAutoDetect": "تشخیص خودکار کلاینت‌های Clash/Mihomo",
"subClashAutoDetectDesc": "با فعال‌سازی، کلاینت‌های شناخته‌شده Clash/Mihomo که آدرس استاندارد اشتراک را درخواست می‌کنند، به‌طور خودکار پیکربندی Clash با فرمت YAML دریافت می‌کنند. مرورگرها همچنان صفحه اشتراک را نمایش می‌دهند، سایر کلاینت‌ها پاسخ خام/Base64 را دریافت می‌کنند و آدرس‌های صریح JSON و Clash در دسترس می‌مانند. برای اعمال تغییر، اشتراک Clash/Mihomo باید فعال باشد و پنل راه‌اندازی مجدد شود.",
"subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",
"subClashUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده می‌شود تا کلاینت‌های Clash/Mihomo در آدرس استاندارد اشتراک شناسایی شوند. برای استفاده از الگوی پیش‌فرض خالی بگذارید. پس از تغییر، پنل را راه‌اندازی مجدد کنید.",
"subTitle": "عنوان اشتراک",
"subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "آدرس پشتیبانی",
@@ -1132,23 +1107,8 @@
"subProfileUrlDesc": "لینک وب‌سایت شما که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "اعلان",
"subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده می‌شود. از توکن‌های هویت کلاینت پشتیبانی می‌کند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "پوشه قالب صفحه اشتراک",
"subThemeDirDesc": "مسیر مطلق پوشه‌ای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیش‌فرض خالی بگذارید.",
"subThemeDirDocs": "راهنمای قالب ↗",
"subEnableRouting": "فعال‌سازی مسیریابی",
"subEnableRoutingDesc": "تنظیمات سراسری برای فعال‌سازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
"subRoutingRules": "قوانین مسیریابی",
"subRoutingRulesDesc": "یک پیوند آماده happ:// یا یک نشانی دائمی HTTPS وارد کنید. پنل قوانین راه‌دور را در پس‌زمینه به‌روزرسانی و آخرین مقدار معتبر را نگه می‌دارد، بنابراین درخواست اشتراک منتظر منبع نمی‌ماند. (فقط برای Happ)",
"subHideSettings": "پنهان کردن تنظیمات سرور",
"subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
"subIncyEnableRouting": "فعال‌سازی مسیریابی",
"subIncyEnableRoutingDesc": "تزریق پروفایل مسیریابی به بدنه اشتراک برای کلاینت Incy. (فقط برای Incy)",
"subIncyRoutingRules": "قوانین مسیریابی",
"subIncyRoutingRulesDesc": "یک پیوند آماده incy:// یا یک نشانی دائمی HTTPS برای JSON وارد کنید. Incy یک نمایه autorouting می‌سازد و آن را خودکار به‌روزرسانی می‌کند. (فقط برای Incy)",
"subClashEnableRouting": "فعال‌سازی مسیریابی",
"subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
"subClashRoutingRules": "قوانین مسیریابی سراسری",
"subClashRoutingRulesDesc": "قوانین/YAML یا یک نشانی دائمی HTTPS وارد کنید. پنل آن را در پس‌زمینه به‌روزرسانی می‌کند، فقط گروه‌ها، ارائه‌دهندگان قانون و قوانین را وارد می‌کند و گره‌های VPN ساخته‌شده و آخرین مقدار معتبر را حفظ می‌کند.",
"subListen": "آدرس آی‌پی",
"subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید",
"subPort": "پورت",
@@ -1177,8 +1137,6 @@
"trustedProxyCidrs": "CIDRهای پراکسی مورد اعتماد",
"trustedProxyCidrsDesc": "IPها/CIDRها (با کاما) که مجازند هدرهای host، proto و client IP فوروارد را تنظیم کنند.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "ماسک‌های TCP/UDP مربوط به Xray finalmask و پارامترهای QUIC را به هر پروفایل Xray JSON تولیدشده اضافه می‌کند. به برنامه‌ای با پشتیبانی از اشتراک Xray JSON و هسته جدید Xray نیاز دارد.",
"packets": "بسته‌ها",
"length": "طول",
"interval": "بازه",
@@ -1189,16 +1147,12 @@
"packet": "بسته",
"delayMs": "تأخیر (ms)",
"applyTo": "اعمال بر",
"addNoise": "+ نویز",
"concurrency": "هم‌زمانی",
"xudpConcurrency": "هم‌زمانی xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "چندین جریان داده مستقل را در یک جریان داده ثابت منتقل می کند",
"muxSett": "تنظیمات ماکس",
"direct": "اتصال مستقیم",
"directDesc": "به طور مستقیم با دامنه ها یا محدوده آی‌پی یک کشور خاص ارتباط برقرار می کند",
"certs": "گواهی‌ها",
"dateAndTime": "تاریخ و زمان",
"proxyAndServer": "پراکسی و سرور",
@@ -1208,7 +1162,6 @@
"language": "زبان",
"security": {
"admin": "اعتبارنامه‌های ادمین",
"twoFactorModalError": "کد نادرست",
"show": "نمایش",
"hide": "پنهان",
"apiTokenNew": "توکن جدید",
@@ -1230,9 +1183,6 @@
"getOutboundTrafficError": "خطا در دریافت ترافیک خروجی",
"resetOutboundTrafficError": "خطا در بازنشانی ترافیک خروجی"
},
"eventOutboundDown": "قطع",
"eventOutboundUp": "وصل",
"eventXrayCrash": "کرش",
"eventNodeDown": "قطع",
"eventNodeUp": "وصل",
"eventCPUHigh": "بالا بودن CPU (٪)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "پاک کردن",
"secretClearUndo": "لغو پاک کردن",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "فهرست مجاز محدودیت IP",
"ipLimitAllowlistDesc": "نشانی‌ها و شبکه‌هایی که محدودیت IP هرگز آن‌ها را نمی‌شمارد و مسدود نمی‌کند، تا نشانی مشترک یک اداره یا دانشگاه محدودیت کاربر را مصرف نکند. IPها/CIDRها (با کاما).",
"subBalancers": {
"menu": "موزان‌کننده‌های اشتراک",
"title": "موزان‌کننده اشتراک",
"add": "افزودن موزان‌کننده",
"desc": "هر موزان‌کنندهٔ فعال به‌عنوان یک پروفایل اضافه به اشتراک JSON اضافه می‌شود و به‌طور خودکار بهترین نقطهٔ پایانیِ اینباند‌های انتخاب‌شده را برمی‌گزیند.",
"remark": "توضیح",
"remarkPlaceholder": "خودکار · سریع‌ترین",
"strategy": "استراتژی",
"strategyLeastLoad": "کم‌ترین بار",
"strategyLeastPing": "کم‌ترین پینگ",
"strategyRandom": "تصادفی",
"strategyRoundRobin": "گردشی",
"sortOrder": "ترتیب",
"sortOrderHelp": "جایگاه در فهرست اشتراک، درهم‌تنیده با ترتیب اینباند‌ها؛ با شمارهٔ برابر، موزان‌کننده بعد از اینباند می‌آید.",
"inbounds": "اینباند‌ها",
"inboundsCount": "{count} اینباند‌ها",
"enabled": "فعال",
"empty": "هنوز موزان‌کننده‌ای وجود ندارد",
"deleteConfirm": "این موزان‌کننده حذف شود؟",
"errRemarkRequired": "توضیح الزامی است",
"errInboundsRequired": "حداقل یک اینباند انتخاب کنید",
"errSortOrder": "ترتیب باید عدد صحیح ≥ ۱ باشد",
"toasts": {
"list": "فهرست‌سازی موزان‌کننده‌های اشتراک ناموفق بود",
"create": "ایجاد موزان‌کننده اشتراک ناموفق بود",
"update": "به‌روزرسانی موزان‌کننده اشتراک ناموفق بود",
"delete": "حذف موزان‌کننده اشتراک ناموفق بود",
"invalidId": "شناسه نامعتبر"
},
"tabBalancers": "بالانسرها",
"tabObservatory": "رصدخانه",
"observatory": {
"title": "رصدگر موزان‌کننده",
"desc": "پارامترهای probe برای burstObservatory که در هر پروفایل leastPing/leastLoad نوشته می‌شود. random/roundRobin رصدگر ندارند. به‌صورت تنظیم سراسری اشتراک JSON ذخیره می‌شود.",
"destination": "آدرس probe",
"destinationDesc": "آدرسی که کلاینت برای سنجش هر خروجی عضو آن را probe می‌کند.",
"connectivity": "آدرس اتصال",
"connectivityDesc": "آدرس اختیاری برای بررسی یک‌بارهٔ دسترسی به هدف. خالی بگذارید تا رد شود.",
"interval": "بازه probe",
"intervalDesc": "زمان بین دورهای probe، مثلاً 1m.",
"timeout": "مهلت probe",
"timeoutDesc": "مهلت هر probe، مثلاً 5s.",
"sampling": "نمونه‌برداری",
"samplingDesc": "تعداد probe متوالی برای میانگین پایداری.",
"httpMethod": "متد HTTP",
"httpMethodDesc": "متد استفاده‌شده برای درخواست‌های probe.",
"note": "موزان‌کننده‌های leastPing/leastLoad همیشه burstObservatory دارند. این کلید پارامترهای probe آن را سفارشی می‌کند — آن را خاموش کنید تا از پیش‌فرض‌های داخلی استفاده شود. تغییرات پس از راه‌اندازی مجدد پنل اعمال می‌شوند."
}
},
"TGBotSettings": "ربات تلگرام",
"notifications": "اعلان‌ها",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیت‌آمیز",
"FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید.",
"eventOutboundDown": "خروجی {{ .Tag }} قطع است",
"eventOutboundUp": "خروجی {{ .Tag }} وصل است",
"eventErrorDetail": "خطا: {{ .Error }}",
"eventDelayDetail": "تأخیر: {{ .Delay }} میلی‌ثانیه",
"eventXrayCrash": "Xray کرش کرد",
"eventXrayCrashError": "خطا: {{ .Error }}",
"eventNodeDown": "نود {{ .Name }} قطع است",
"eventNodeUp": "نود {{ .Name }} وصل است",
"eventLoginFallback": "ورود ناموفق از {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Instal",
"clients": "Klien",
"usage": "Penggunaan",
"twoFactorCode": "Kode",
"remained": "Tersisa",
"security": "Keamanan",
"somethingWentWrong": "Terjadi kesalahan",
@@ -103,8 +102,7 @@
"docs": "Dokumentasi",
"openMenu": "Buka menu",
"pinSidebar": "Sematkan bilah sisi",
"unpinSidebar": "Lepas sematan bilah sisi",
"subFormats": "Sub Formats"
"unpinSidebar": "Lepas sematan bilah sisi"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Dilepas {detached}, dilewati {skipped}, error {errors}.",
"subLinksTitle": "Tautan sub ({count})",
"subLinkColumn": "URL Langganan",
"subJsonLinkColumn": "URL JSON Langganan",
"subLinksCopyAll": "Salin semua",
"subLinksCopiedAll": "{count} tautan disalin",
"subLinksEmpty": "Tidak ada klien terpilih yang memiliki ID langganan.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "Port default 2053 sudah umum diketahui — ubah ke port acak.",
"warnDefaultBasePath": "Base path default \"/\" sudah umum diketahui — ubah ke path acak.",
"warnDefaultSubPath": "Path langganan default \"/sub/\" sudah umum diketahui — ubahlah.",
"warnDefaultJsonPath": "Path langganan JSON default \"/json/\" sudah umum diketahui — ubahlah.",
"panelListeningIP": "IP Pendengar",
"panelListeningIPDesc": "Alamat IP untuk panel web. (biarkan kosong untuk mendengarkan semua IP)",
"panelListeningDomain": "Domain Pendengar",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "URI path untuk panel web. (dimulai dengan ‘/‘ dan diakhiri dengan ‘/‘)",
"pageSize": "Ukuran Halaman",
"pageSizeDesc": "Tentukan ukuran halaman untuk tabel masuk. (0 = nonaktif)",
"panelOutbound": "Outbound lalu lintas panel",
"panelOutboundDesc": "Mengarahkan permintaan panel sendiri — pemeriksaan versi dan unduhan panel/Xray, Telegram, dan pembaruan file geo biasa — melalui outbound Xray ini untuk melewati pemfilteran GitHub/Telegram di sisi server. Inbound jembatan lokal ditambahkan secara otomatis ke konfigurasi yang berjalan dan diterapkan langsung. Pembaruan Otomatis Geodata bawaan Xray tidak terpengaruh; ia memiliki outbound unduhan sendiri. Kosongkan untuk koneksi langsung.",
"panelOutboundPh": "Koneksi langsung",
"datepicker": "Jenis Kalender",
"datepickerPlaceholder": "Pilih tanggal",
"datepickerDescription": "Tugas terjadwal akan berjalan berdasarkan kalender ini.",
"oldUsername": "Username Saat Ini",
"currentPassword": "Kata Sandi Saat Ini",
"newUsername": "Username Baru",
@@ -1104,22 +1095,6 @@
"subSettings": "Langganan",
"subEnable": "Aktifkan Layanan Langganan",
"subEnableDesc": "Mengaktifkan layanan langganan.",
"subJsonEnable": "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri.",
"subJsonEnableTitle": "Langganan JSON",
"subClashEnableTitle": "Langganan Clash / Mihomo",
"subFormatsTipTitle": "Pengaturan langganan khusus format",
"subFormatsTipDesc": "Konfigurasikan jalur URL, URL proksi balik, dan deteksi otomatis klien untuk JSON serta Clash / Mihomo secara terpisah.",
"subFormatsTipAction": "Buka Format Langganan",
"subJsonAutoDetect": "Deteksi otomatis klien Xray JSON",
"subJsonAutoDetectDesc": "Jika diaktifkan, klien kompatibel yang dikenali dan meminta URL langganan standar akan otomatis menerima larik konfigurasi Xray JSON. Klien lain tetap menerima respons mentah/Base64. Langganan JSON harus diaktifkan dan panel harus dimulai ulang.",
"subJsonAlwaysArray": "Selalu kembalikan larik JSON",
"subJsonAlwaysArrayDesc": "Mengembalikan endpoint langganan JSON eksplisit sebagai larik meski hanya berisi satu profil, sesuai standar XTLS. Respons JSON yang terdeteksi otomatis selalu menggunakan larik. Nonaktifkan untuk mempertahankan respons objek tunggal lama.",
"subJsonUserAgentRegex": "Regex User-Agent Xray JSON",
"subJsonUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk memilih format Xray JSON secara otomatis pada URL langganan standar. Kosong secara bawaan, sehingga deteksi otomatis tetap nonaktif hingga Anda menetapkan pola untuk klien yang ingin dilayani. Klien lain tetap menerima respons mentah/Base64. Mulai ulang panel setelah mengubahnya.",
"subClashAutoDetect": "Deteksi otomatis klien Clash/Mihomo",
"subClashAutoDetectDesc": "Jika diaktifkan, klien Clash/Mihomo yang dikenali dan meminta URL langganan standar akan otomatis menerima YAML Clash. Browser tetap menampilkan halaman langganan, klien lain tetap menerima respons mentah/Base64, dan URL JSON serta Clash eksplisit tetap tersedia. Langganan Clash/Mihomo harus diaktifkan dan panel harus dimulai ulang agar perubahan diterapkan.",
"subClashUserAgentRegex": "Regex User-Agent Clash/Mihomo",
"subClashUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk mengenali klien Clash/Mihomo pada URL langganan standar. Kosongkan untuk memakai pola bawaan. Mulai ulang panel setelah mengubahnya.",
"subTitle": "Judul Langganan",
"subTitleDesc": "Judul yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "URL Dukungan",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Pengumuman",
"subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Direktori Tema Langganan",
"subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
"subThemeDirDocs": "Panduan templat ↗",
"subEnableRouting": "Aktifkan perutean",
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
"subRoutingRules": "Aturan routing",
"subRoutingRulesDesc": "Tempel deeplink happ:// siap pakai atau satu URL HTTPS permanen. Panel memperbarui aturan jarak jauh di latar belakang dan menyimpan nilai valid terakhir, sehingga permintaan langganan tidak menunggu sumber. (Hanya untuk Happ)",
"subHideSettings": "Sembunyikan pengaturan server",
"subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
"subIncyEnableRouting": "Aktifkan perutean",
"subIncyEnableRoutingDesc": "Menyuntikkan profil perutean ke dalam body langganan untuk klien Incy. (Hanya untuk Incy)",
"subIncyRoutingRules": "Aturan routing",
"subIncyRoutingRulesDesc": "Tempel deeplink incy:// siap pakai atau URL HTTPS permanen ke JSON. Incy membuat profil autorouting dan memperbaruinya secara otomatis. (Hanya untuk Incy)",
"subClashEnableRouting": "Aktifkan routing",
"subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
"subClashRoutingRules": "Aturan routing global",
"subClashRoutingRulesDesc": "Tempel aturan/YAML atau satu URL HTTPS permanen. Panel memperbaruinya di latar belakang, hanya mengimpor grup, penyedia aturan, dan aturan, serta mempertahankan node VPN buatan panel dan nilai valid terakhir.",
"subListen": "IP Pendengar",
"subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)",
"subPort": "Port Pendengar",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "CIDR proxy tepercaya",
"trustedProxyCidrsDesc": "IP/CIDR (dipisahkan koma) yang diizinkan mengatur header forwarded host, proto, dan client IP.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Menyisipkan mask TCP/UDP Xray finalmask dan parameter QUIC ke setiap profil Xray JSON yang dibuat. Membutuhkan aplikasi klien yang mendukung langganan Xray JSON dan inti Xray terbaru.",
"packets": "Paket",
"length": "Panjang",
"interval": "Interval",
@@ -1185,16 +1143,12 @@
"packet": "Paket",
"delayMs": "Penundaan (ms)",
"applyTo": "Terapkan ke",
"addNoise": "+ Noise",
"concurrency": "Konkurensi",
"xudpConcurrency": "Konkurensi xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Mengirimkan beberapa aliran data independen dalam aliran data yang sudah ada.",
"muxSett": "Pengaturan Mux",
"direct": "Koneksi langsung",
"directDesc": "Secara langsung membuat koneksi dengan domain atau rentang IP negara tertentu.",
"certs": "Sertifikat",
"dateAndTime": "Tanggal dan Waktu",
"proxyAndServer": "Proxy dan Server",
@@ -1204,7 +1158,6 @@
"language": "Bahasa",
"security": {
"admin": "Kredensial admin",
"twoFactorModalError": "Kode salah",
"show": "Tampilkan",
"hide": "Sembunyikan",
"apiTokenNew": "Token baru",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Gagal mendapatkan lalu lintas keluar",
"resetOutboundTrafficError": "Gagal mereset lalu lintas keluar"
},
"eventOutboundDown": "Mati",
"eventOutboundUp": "Aktif",
"eventXrayCrash": "Crash",
"eventNodeDown": "Mati",
"eventNodeUp": "Aktif",
"eventCPUHigh": "CPU tinggi (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Hapus",
"secretClearUndo": "Batalkan hapus",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Daftar izin batas IP",
"ipLimitAllowlistDesc": "Alamat dan jaringan yang tidak pernah dihitung maupun diblokir oleh batas IP, sehingga alamat kantor atau kampus bersama tidak menghabiskan batas klien. IP/CIDR (dipisahkan koma).",
"subBalancers": {
"menu": "Penyeimbang langganan",
"title": "Penyeimbang langganan",
"add": "Tambah penyeimbang",
"desc": "Setiap penyeimbang yang aktif ditambahkan ke langganan JSON sebagai profil tambahan yang otomatis memilih titik akhir terbaik dari inbound terpilih.",
"remark": "Keterangan",
"remarkPlaceholder": "Otomatis · tercepat",
"strategy": "Strategi",
"strategyLeastLoad": "Beban terendah",
"strategyLeastPing": "Ping terendah",
"strategyRandom": "Acak",
"strategyRoundRobin": "Round robin",
"sortOrder": "Urutan",
"sortOrderHelp": "Posisi dalam daftar langganan, berselang-seling dengan urutan inbound; jika sama, penyeimbang berada setelah inbound.",
"inbounds": "Inbound",
"inboundsCount": "{count} Inbound",
"enabled": "Aktif",
"empty": "Belum ada penyeimbang",
"deleteConfirm": "Hapus penyeimbang ini?",
"errRemarkRequired": "Keterangan wajib diisi",
"errInboundsRequired": "Pilih minimal satu inbound",
"errSortOrder": "Urutan harus bilangan bulat ≥ 1",
"toasts": {
"list": "Gagal menampilkan daftar penyeimbang langganan",
"create": "Gagal membuat penyeimbang langganan",
"update": "Gagal memperbarui penyeimbang langganan",
"delete": "Gagal menghapus penyeimbang langganan",
"invalidId": "Id tidak valid"
},
"tabBalancers": "Penyeimbang",
"tabObservatory": "Observatory",
"observatory": {
"title": "Observatorium penyeimbang",
"desc": "Parameter probe untuk burstObservatory yang disisipkan ke setiap profil leastPing/leastLoad. random/roundRobin tanpa observatorium. Disimpan sebagai pengaturan langganan JSON tingkat panel.",
"destination": "URL probe",
"destinationDesc": "Alamat yang di-probe klien untuk mengukur setiap outbound anggota.",
"connectivity": "URL konektivitas",
"connectivityDesc": "Alamat opsional untuk memeriksa sekali bahwa anggota menjangkau tujuan. Kosongkan untuk melewati.",
"interval": "Interval probe",
"intervalDesc": "Waktu antar ronde probe, mis. 1m.",
"timeout": "Waktu habis probe",
"timeoutDesc": "Waktu habis per probe, mis. 5s.",
"sampling": "Pengambilan sampel",
"samplingDesc": "Jumlah probe beruntun untuk merata-ratakan stabilitas.",
"httpMethod": "Metode HTTP",
"httpMethodDesc": "Metode yang dipakai untuk permintaan probe.",
"note": "Penyeimbang leastPing/leastLoad selalu membawa burstObservatory. Sakelar ini menyesuaikan parameter probe-nya — matikan untuk memakai bawaan default. Perubahan berlaku setelah panel dimulai ulang."
}
},
"TGBotSettings": "Bot Telegram",
"notifications": "Notifikasi",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil",
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien.",
"eventOutboundDown": "Outbound {{ .Tag }} MATI",
"eventOutboundUp": "Outbound {{ .Tag }} AKTIF",
"eventErrorDetail": "Kesalahan: {{ .Error }}",
"eventDelayDetail": "Penundaan: {{ .Delay }}ms",
"eventXrayCrash": "Xray CRASH",
"eventXrayCrashError": "Kesalahan: {{ .Error }}",
"eventNodeDown": "Node {{ .Name }} MATI",
"eventNodeUp": "Node {{ .Name }} AKTIF",
"eventLoginFallback": "Gagal masuk dari {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "インストール",
"clients": "クライアント",
"usage": "利用状況",
"twoFactorCode": "コード",
"remained": "残り",
"security": "セキュリティ",
"somethingWentWrong": "エラーが発生しました",
@@ -103,8 +102,7 @@
"docs": "ドキュメント",
"openMenu": "メニューを開く",
"pinSidebar": "サイドバーを固定",
"unpinSidebar": "サイドバーの固定を解除",
"subFormats": "Sub Formats"
"unpinSidebar": "サイドバーの固定を解除"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "デタッチ {detached}、スキップ {skipped}、エラー {errors}。",
"subLinksTitle": "サブリンク ({count})",
"subLinkColumn": "サブスクリプション URL",
"subJsonLinkColumn": "サブスクリプション JSON URL",
"subLinksCopyAll": "すべてコピー",
"subLinksCopiedAll": "{count} リンクをコピーしました",
"subLinksEmpty": "選択したクライアントにはサブスクリプション ID がありません。",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "デフォルトポート 2053 はよく知られています — ランダムなポートに変更してください。",
"warnDefaultBasePath": "デフォルトのベースパス \"/\" はよく知られています — ランダムなパスに変更してください。",
"warnDefaultSubPath": "デフォルトのサブスクリプションパス \"/sub/\" はよく知られています — 変更してください。",
"warnDefaultJsonPath": "デフォルトの JSON サブスクリプションパス \"/json/\" はよく知られています — 変更してください。",
"panelListeningIP": "パネル監視IP",
"panelListeningIPDesc": "デフォルトではすべてのIPを監視する",
"panelListeningDomain": "パネル監視ドメイン",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "'/'で始まり、'/'で終わる必要があります",
"pageSize": "ページサイズ",
"pageSizeDesc": "インバウンドテーブルのページサイズを定義します。0を設定すると無効化されます",
"panelOutbound": "パネルトラフィックのアウトバウンド",
"panelOutboundDesc": "パネル自体のリクエスト (パネル/Xray のバージョンチェックとダウンロード、Telegram、通常の geo ファイル更新) をこの Xray アウトバウンド経由でルーティングし、サーバー側の GitHub/Telegram フィルタリングを回避します。ローカルのブリッジインバウンドが実行中の設定に自動的に追加され、ライブで適用されます。Xray ネイティブの Geodata 自動更新は影響を受けません。専用のダウンロードアウトバウンドを持ちます。直接接続するには空のままにします。",
"panelOutboundPh": "直接接続",
"datepicker": "日付ピッカー",
"datepickerPlaceholder": "日付を選択",
"datepickerDescription": "日付選択カレンダーで有効期限を指定する",
"oldUsername": "旧ユーザー名",
"currentPassword": "旧パスワード",
"newUsername": "新しいユーザー名",
@@ -1104,22 +1095,6 @@
"subSettings": "サブスクリプション設定",
"subEnable": "サブスクリプションサービスを有効にする",
"subEnableDesc": "サブスクリプションサービス機能を有効にする",
"subJsonEnable": "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。",
"subJsonEnableTitle": "JSON サブスクリプション",
"subClashEnableTitle": "Clash / Mihomo サブスクリプション",
"subFormatsTipTitle": "形式別サブスクリプション設定",
"subFormatsTipDesc": "JSON と Clash / Mihomo の URL パス、リバース URL、クライアント自動検出を個別に設定します。",
"subFormatsTipAction": "サブスクリプション形式を開く",
"subJsonAutoDetect": "Xray JSON クライアントを自動検出",
"subJsonAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の互換クライアントへ Xray JSON 設定配列を自動的に返します。その他のクライアントには従来の raw/Base64 応答を返します。JSON サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
"subJsonAlwaysArray": "常に JSON 配列を返す",
"subJsonAlwaysArrayDesc": "プロファイルが 1 件でも、明示的な JSON サブスクリプションを XTLS 標準に従う配列として返します。自動検出された JSON 応答は常に配列です。無効時は従来の単一オブジェクト応答を維持します。",
"subJsonUserAgentRegex": "Xray JSON User-Agent 正規表現",
"subJsonUserAgentRegexDesc": "標準サブスクリプション URL で Xray JSON 形式を自動選択するため、クライアントの User-Agent と照合する Go RE2 正規表現です。既定では空欄のため、対象とするクライアント向けのパターンを設定するまで自動判別は無効のままです。その他のクライアントには従来の raw/Base64 応答を返します。変更後にパネルを再起動してください。",
"subClashAutoDetect": "Clash/Mihomo クライアントを自動検出",
"subClashAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の Clash/Mihomo クライアントへ Clash YAML を自動的に返します。ブラウザには引き続きサブスクリプションページを表示し、その他のクライアントには従来の raw/Base64 応答を返します。明示的な JSON および Clash URL も引き続き利用できます。Clash/Mihomo サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",
"subClashUserAgentRegexDesc": "標準サブスクリプション URL で Clash/Mihomo クライアントを識別するため、クライアントの User-Agent と照合する Go RE2 正規表現です。空欄の場合は既定のパターンを使用します。変更後にパネルを再起動してください。",
"subTitle": "サブスクリプションタイトル",
"subTitleDesc": "VPNクライアントに表示されるタイトル。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
"subSupportUrl": "サポートURL",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
"subAnnounce": "お知らせ",
"subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
"subThemeDir": "サブスクリプションテーマディレクトリ",
"subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
"subThemeDirDocs": "テンプレートガイド ↗",
"subEnableRouting": "ルーティングを有効化",
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
"subRoutingRules": "ルーティングルール",
"subRoutingRulesDesc": "完成した happ:// ディープリンク、または永続的な HTTPS URL を入力します。パネルはリモートルールをバックグラウンドで更新し、最後の有効値を保持するため、サブスクリプション要求は取得を待ちません。(Happのみ)",
"subHideSettings": "サーバー設定を非表示",
"subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
"subIncyEnableRouting": "ルーティングを有効化",
"subIncyEnableRoutingDesc": "Incyクライアント用に、サブスクリプション本文へルーティングプロファイルを挿入します。(Incyのみ)",
"subIncyRoutingRules": "ルーティングルール",
"subIncyRoutingRulesDesc": "完成した incy:// ディープリンク、または JSON への永続的な HTTPS URL を入力します。Incy は autorouting プロファイルを作成し、自動更新します。(Incyのみ)",
"subClashEnableRouting": "ルーティングを有効化",
"subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
"subClashRoutingRules": "グローバルルーティングルール",
"subClashRoutingRulesDesc": "ルール/YAML、または永続的な HTTPS URL を入力します。パネルはバックグラウンドで更新し、グループ・ルールプロバイダー・ルールのみを取り込み、生成済み VPN ノードと最後の有効値を保持します。",
"subListen": "監視IP",
"subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)",
"subPort": "監視ポート",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "信頼できるプロキシ CIDR",
"trustedProxyCidrsDesc": "転送される host、proto、クライアント IP ヘッダーを設定可能な IP/CIDR (カンマ区切り)。",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "生成される各 Xray JSON プロファイルへ Xray finalmask の TCP/UDP マスクと QUIC パラメーターを追加します。Xray JSON サブスクリプション対応アプリと新しい Xray コアが必要です。",
"packets": "パケット",
"length": "長さ",
"interval": "間隔",
@@ -1185,16 +1143,12 @@
"packet": "パケット",
"delayMs": "遅延 (ms)",
"applyTo": "適用先",
"addNoise": "+ ノイズ",
"concurrency": "並行数",
"xudpConcurrency": "xudp 並行数",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "確立されたストリーム内で複数の独立したストリームを伝送する",
"muxSett": "マルチプレクサ設定",
"direct": "直接接続",
"directDesc": "特定の国のドメインまたはIP範囲に直接接続する",
"certs": "証明書",
"dateAndTime": "日付と時刻",
"proxyAndServer": "プロキシとサーバー",
@@ -1204,7 +1158,6 @@
"language": "言語",
"security": {
"admin": "管理者の資格情報",
"twoFactorModalError": "コードが間違っています",
"show": "表示",
"hide": "非表示",
"apiTokenNew": "新規トークン",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "送信トラフィックの取得エラー",
"resetOutboundTrafficError": "送信トラフィックのリセットエラー"
},
"eventOutboundDown": "ダウン",
"eventOutboundUp": "アップ",
"eventXrayCrash": "クラッシュ",
"eventNodeDown": "ダウン",
"eventNodeUp": "アップ",
"eventCPUHigh": "CPU高負荷(%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "クリア",
"secretClearUndo": "クリアを取り消す",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP 制限の許可リスト",
"ipLimitAllowlistDesc": "IP 制限がカウントもブロックもしないアドレスとネットワーク。オフィスや学内の共有アドレスがクライアントの上限を使い切らないようにします。IP/CIDR (カンマ区切り)。",
"subBalancers": {
"menu": "サブスクリプションバランサー",
"title": "サブスクリプションバランサー",
"add": "バランサーを追加",
"desc": "有効なバランサーは JSON サブスクリプションに追加プロファイルとして加わり、選択したインバウンドのエンドポイントから最適なものを自動選択します。",
"remark": "備考",
"remarkPlaceholder": "自動 · 最速",
"strategy": "方式",
"strategyLeastLoad": "最小負荷",
"strategyLeastPing": "最小 ping",
"strategyRandom": "ランダム",
"strategyRoundRobin": "ラウンドロビン",
"sortOrder": "順序",
"sortOrderHelp": "サブスクリプション一覧内の位置。インバウンドの順序と交互に並び、同番号の場合はインバウンドの後ろになります。",
"inbounds": "インバウンド",
"inboundsCount": "{count} インバウンド",
"enabled": "有効",
"empty": "バランサーはまだありません",
"deleteConfirm": "このバランサーを削除しますか?",
"errRemarkRequired": "備考を入力してください",
"errInboundsRequired": "インバウンドを1つ以上選択してください",
"errSortOrder": "順序は1以上の整数にしてください",
"toasts": {
"list": "サブスクリプションバランサーの一覧取得に失敗しました",
"create": "サブスクリプションバランサーの作成に失敗しました",
"update": "サブスクリプションバランサーの更新に失敗しました",
"delete": "サブスクリプションバランサーの削除に失敗しました",
"invalidId": "無効な id です"
},
"tabBalancers": "負荷分散",
"tabObservatory": "オブザーバトリ",
"observatory": {
"title": "バランサー観測",
"desc": "各 leastPing/leastLoad バランサープロファイルに埋め込む burstObservatory のプローブ設定。random/roundRobin には観測を入れません。パネル全体の JSON サブ設定として保存されます。",
"destination": "プローブ URL",
"destinationDesc": "クライアントが各メンバーアウトバウンドを計測するためのアドレス。",
"connectivity": "接続確認 URL",
"connectivityDesc": "メンバーがプローブ先へ到達できるか一度確認する任意のアドレス。空ならスキップ。",
"interval": "プローブ間隔",
"intervalDesc": "プローブ周期の間隔(例: 1m)。",
"timeout": "プローブタイムアウト",
"timeoutDesc": "1回のプローブのタイムアウト(例: 5s)。",
"sampling": "サンプリング",
"samplingDesc": "安定度を平均するための連続プローブ回数。",
"httpMethod": "HTTP メソッド",
"httpMethodDesc": "プローブ要求に使う HTTP メソッド。",
"note": "leastPing/leastLoad バランサーは常に burstObservatory を持ちます。このスイッチはプローブパラメータをカスタマイズします — オフにすると組み込みのデフォルトを使います。変更はパネルの再起動後に反映されます。"
}
},
"TGBotSettings": "Telegram Bot",
"notifications": "通知",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
"FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。",
"eventOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
"eventOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
"eventErrorDetail": "エラー: {{ .Error }}",
"eventDelayDetail": "遅延: {{ .Delay }}ms",
"eventXrayCrash": "Xrayがクラッシュしました",
"eventXrayCrashError": "エラー: {{ .Error }}",
"eventNodeDown": "ノード {{ .Name }} がダウンしています",
"eventNodeUp": "ノード {{ .Name }} が復旧しました",
"eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Instalar",
"clients": "Clientes",
"usage": "Uso",
"twoFactorCode": "Código",
"remained": "Restante",
"security": "Segurança",
"somethingWentWrong": "Algo deu errado",
@@ -103,8 +102,7 @@
"docs": "Documentação",
"openMenu": "Abrir menu",
"pinSidebar": "Fixar barra lateral",
"unpinSidebar": "Desafixar barra lateral",
"subFormats": "Sub Formats"
"unpinSidebar": "Desafixar barra lateral"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Desassociados {detached}, ignorados {skipped}, erros {errors}.",
"subLinksTitle": "Links sub ({count})",
"subLinkColumn": "URL da assinatura",
"subJsonLinkColumn": "URL JSON da assinatura",
"subLinksCopyAll": "Copiar tudo",
"subLinksCopiedAll": "Copiados {count} link(s)",
"subLinksEmpty": "Nenhum dos clientes selecionados tem ID de assinatura.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "A porta padrão 2053 é bem conhecida — altere para uma porta aleatória.",
"warnDefaultBasePath": "O caminho base padrão \"/\" é bem conhecido — altere para um caminho aleatório.",
"warnDefaultSubPath": "O caminho de assinatura padrão \"/sub/\" é bem conhecido — altere-o.",
"warnDefaultJsonPath": "O caminho de assinatura JSON padrão \"/json/\" é bem conhecido — altere-o.",
"panelListeningIP": "IP de Escuta",
"panelListeningIPDesc": "O endereço IP para o painel web. (deixe em branco para escutar em todos os IPs)",
"panelListeningDomain": "Domínio de Escuta",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "O caminho URI para o painel web. (começa com ‘/‘ e termina com ‘/‘)",
"pageSize": "Tamanho da Paginação",
"pageSizeDesc": "Definir o tamanho da página para a tabela de entradas. (0 = desativado)",
"panelOutbound": "Saída do tráfego do painel",
"panelOutboundDesc": "Encaminha as requisições do próprio painel — verificações de versão e downloads do painel/Xray, Telegram e a atualização normal de arquivos geo — por esta saída do Xray para contornar a filtragem de GitHub/Telegram no servidor. Uma entrada ponte local é adicionada automaticamente à configuração em execução e aplicada ao vivo. A Atualização Automática de Geodata nativa do Xray não é afetada; ela tem sua própria saída de download. Deixe vazio para conexão direta.",
"panelOutboundPh": "Conexão direta",
"datepicker": "Tipo de Calendário",
"datepickerPlaceholder": "Selecionar data",
"datepickerDescription": "Tarefas agendadas serão executadas com base neste calendário.",
"oldUsername": "Nome de Usuário Atual",
"currentPassword": "Senha Atual",
"newUsername": "Novo Nome de Usuário",
@@ -1104,22 +1095,6 @@
"subSettings": "Assinatura",
"subEnable": "Ativar Serviço de Assinatura",
"subEnableDesc": "Ativa o serviço de assinatura.",
"subJsonEnable": "Ativar/Desativar o endpoint de assinatura JSON de forma independente.",
"subJsonEnableTitle": "Assinatura JSON",
"subClashEnableTitle": "Assinatura Clash / Mihomo",
"subFormatsTipTitle": "Configurações de assinatura específicas por formato",
"subFormatsTipDesc": "Configure separadamente os caminhos de URL, URLs reversas e a detecção automática de clientes para JSON e Clash / Mihomo.",
"subFormatsTipAction": "Abrir formatos de assinatura",
"subJsonAutoDetect": "Detectar clientes Xray JSON automaticamente",
"subJsonAutoDetectDesc": "Quando ativado, clientes compatíveis reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente uma matriz de configurações Xray JSON. Os outros clientes manterão a resposta bruta/Base64. Requer a assinatura JSON ativada e a reinicialização do painel.",
"subJsonAlwaysArray": "Sempre retornar uma matriz JSON",
"subJsonAlwaysArrayDesc": "Retorna o endpoint JSON explícito como matriz mesmo com apenas um perfil, conforme o padrão XTLS. Respostas JSON detectadas automaticamente sempre usam matrizes. Desative para preservar a resposta legada de objeto único.",
"subJsonUserAgentRegex": "Expressão User-Agent do Xray JSON",
"subJsonUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para selecionar automaticamente o formato Xray JSON na URL de assinatura padrão. Vazia por padrão, então a detecção automática permanece desativada até você definir um padrão para os clientes que deseja atender. Os outros clientes mantêm a resposta bruta/Base64. Reinicie o painel após alterá-la.",
"subClashAutoDetect": "Detectar clientes Clash/Mihomo automaticamente",
"subClashAutoDetectDesc": "Quando ativado, clientes Clash/Mihomo reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente YAML do Clash. Os navegadores continuarão exibindo a página de assinatura, os outros clientes manterão a resposta bruta/Base64 e as URLs explícitas de JSON e Clash continuarão disponíveis. Requer a assinatura Clash/Mihomo ativada e a reinicialização do painel para aplicar a alteração.",
"subClashUserAgentRegex": "Expressão User-Agent do Clash/Mihomo",
"subClashUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para reconhecer clientes Clash/Mihomo na URL de assinatura padrão. Deixe em branco para usar o padrão predefinido. Reinicie o painel após alterá-la.",
"subTitle": "Título da Assinatura",
"subTitleDesc": "Título exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "URL de Suporte",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Anúncio",
"subAnnounceDesc": "O texto do anúncio exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Diretório do tema de assinatura",
"subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
"subThemeDirDocs": "Guia de modelos ↗",
"subEnableRouting": "Ativar roteamento",
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
"subRoutingRules": "Regras de roteamento",
"subRoutingRulesDesc": "Cole um deeplink happ:// pronto ou uma URL HTTPS permanente. O painel atualiza as regras remotas em segundo plano e mantém o último valor válido, sem atrasar as solicitações de assinatura. (Apenas para Happ)",
"subHideSettings": "Ocultar configurações do servidor",
"subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
"subIncyEnableRouting": "Ativar roteamento",
"subIncyEnableRoutingDesc": "Injetar um perfil de roteamento no corpo da assinatura para o cliente Incy. (Apenas para Incy)",
"subIncyRoutingRules": "Regras de roteamento",
"subIncyRoutingRulesDesc": "Cole um deeplink incy:// pronto ou uma URL HTTPS permanente para JSON. O Incy cria um perfil de autorouting e o atualiza automaticamente. (Apenas para Incy)",
"subClashEnableRouting": "Ativar roteamento",
"subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
"subClashRoutingRules": "Regras globais de roteamento",
"subClashRoutingRulesDesc": "Cole regras/YAML ou uma URL HTTPS permanente. O painel a atualiza em segundo plano, importa apenas grupos, provedores de regras e regras, e preserva os nós VPN gerados e o último valor válido.",
"subListen": "IP de Escuta",
"subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)",
"subPort": "Porta de Escuta",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "CIDRs de proxy confiável",
"trustedProxyCidrsDesc": "IPs/CIDRs separados por vírgula que podem definir os cabeçalhos host, proto e IP do cliente encaminhados.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Injeta máscaras TCP/UDP do finalmask do Xray e parâmetros QUIC em cada perfil Xray JSON gerado. Requer um aplicativo compatível com assinaturas Xray JSON e um núcleo Xray recente.",
"packets": "Pacotes",
"length": "Comprimento",
"interval": "Intervalo",
@@ -1185,16 +1143,12 @@
"packet": "Pacote",
"delayMs": "Atraso (ms)",
"applyTo": "Aplicar a",
"addNoise": "+ Ruído",
"concurrency": "Concorrência",
"xudpConcurrency": "Concorrência xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Transmitir múltiplos fluxos de dados independentes dentro de um fluxo de dados estabelecido.",
"muxSett": "Configurações de Mux",
"direct": "Conexão Direta",
"directDesc": "Estabelece conexões diretamente com domínios ou intervalos de IP de um país específico.",
"certs": "Certificados",
"dateAndTime": "Data e Hora",
"proxyAndServer": "Proxy e Servidor",
@@ -1204,7 +1158,6 @@
"language": "Idioma",
"security": {
"admin": "Credenciais de administrador",
"twoFactorModalError": "Código incorreto",
"show": "Mostrar",
"hide": "Ocultar",
"apiTokenNew": "Novo token",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Erro ao obter tráfego de saída",
"resetOutboundTrafficError": "Erro ao redefinir tráfego de saída"
},
"eventOutboundDown": "Inativo",
"eventOutboundUp": "Ativo",
"eventXrayCrash": "Falha",
"eventNodeDown": "Inativo",
"eventNodeUp": "Ativo",
"eventCPUHigh": "CPU alta (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Limpar",
"secretClearUndo": "Desfazer limpeza",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Lista de permissões do limite de IP",
"ipLimitAllowlistDesc": "Endereços e redes que o limite de IP nunca conta nem bane, para que um endereço compartilhado de escritório ou campus não esgote o limite de um cliente. IPs/CIDRs separados por vírgula.",
"subBalancers": {
"menu": "Balanceadores de assinatura",
"title": "Balanceador de assinatura",
"add": "Adicionar balanceador",
"desc": "Cada balanceador ativo é adicionado à assinatura JSON como um perfil extra que escolhe automaticamente o melhor endpoint entre os inbounds selecionados.",
"remark": "Descrição",
"remarkPlaceholder": "Auto · mais rápido",
"strategy": "Estratégia",
"strategyLeastLoad": "Menor carga",
"strategyLeastPing": "Menor ping",
"strategyRandom": "Aleatório",
"strategyRoundRobin": "Round robin",
"sortOrder": "Ordem",
"sortOrderHelp": "Posição na lista da assinatura, intercalada com a ordem dos inbounds; em caso de empate, o balanceador vem depois do inbound.",
"inbounds": "Inbounds",
"inboundsCount": "{count} Inbounds",
"enabled": "Ativado",
"empty": "Ainda não há balanceadores",
"deleteConfirm": "Excluir este balanceador?",
"errRemarkRequired": "A descrição é obrigatória",
"errInboundsRequired": "Selecione ao menos um inbound",
"errSortOrder": "A ordem deve ser um inteiro ≥ 1",
"toasts": {
"list": "Falha ao listar os balanceadores de assinatura",
"create": "Falha ao criar o balanceador de assinatura",
"update": "Falha ao atualizar o balanceador de assinatura",
"delete": "Falha ao excluir o balanceador de assinatura",
"invalidId": "Id inválido"
},
"tabBalancers": "Balanceadores",
"tabObservatory": "Observatório",
"observatory": {
"title": "Observatório do balanceador",
"desc": "Parâmetros de probe para o burstObservatory embutido em cada perfil leastPing/leastLoad. random/roundRobin não geram observatório. Salvo como ajuste global da assinatura JSON.",
"destination": "URL de probe",
"destinationDesc": "Endereço que o cliente sonda para medir cada saída membro.",
"connectivity": "URL de conectividade",
"connectivityDesc": "Endereço opcional para verificar uma vez que o membro alcança o destino. Vazio para pular.",
"interval": "Intervalo de probe",
"intervalDesc": "Tempo entre rodadas de probe, p. ex. 1m.",
"timeout": "Tempo limite de probe",
"timeoutDesc": "Tempo limite de cada probe, p. ex. 5s.",
"sampling": "Amostragem",
"samplingDesc": "Número de probes consecutivos para média de estabilidade.",
"httpMethod": "Método HTTP",
"httpMethodDesc": "Método usado nas requisições de probe.",
"note": "Balanceadores leastPing/leastLoad sempre carregam um burstObservatory. Esta opção personaliza seus parâmetros de probe — desligue-a para usar os padrões integrados. As alterações se aplicam após reiniciar o painel."
}
},
"TGBotSettings": "Bot do Telegram",
"notifications": "Notificações",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso",
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes.",
"eventOutboundDown": "O outbound {{ .Tag }} está INATIVO",
"eventOutboundUp": "O outbound {{ .Tag }} está ATIVO",
"eventErrorDetail": "Erro: {{ .Error }}",
"eventDelayDetail": "Latência: {{ .Delay }}ms",
"eventXrayCrash": "O Xray FALHOU",
"eventXrayCrashError": "Erro: {{ .Error }}",
"eventNodeDown": "O nó {{ .Name }} está INATIVO",
"eventNodeUp": "O nó {{ .Name }} está ATIVO",
"eventLoginFallback": "Falha de login a partir de {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Установка",
"clients": "Клиенты",
"usage": "Использование",
"twoFactorCode": "Код 2FA",
"remained": "Остаток",
"security": "Безопасность",
"somethingWentWrong": "Что-то пошло не так",
@@ -103,8 +102,7 @@
"docs": "Документация",
"openMenu": "Открыть меню",
"pinSidebar": "Закрепить боковую панель",
"unpinSidebar": "Открепить боковую панель",
"subFormats": "Форматы подписки"
"unpinSidebar": "Открепить боковую панель"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Отвязано {detached}, пропущено {skipped}, ошибок {errors}.",
"subLinksTitle": "Sub-ссылки ({count})",
"subLinkColumn": "URL подписки",
"subJsonLinkColumn": "URL JSON-подписки",
"subLinksCopyAll": "Копировать все",
"subLinksCopiedAll": "Скопировано {count} ссылок",
"subLinksEmpty": "Ни у одного из выбранных клиентов нет ID подписки.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "Стандартный порт 2053 широко известен — измените его на случайный.",
"warnDefaultBasePath": "Базовый путь по умолчанию \"/\" широко известен — измените его на случайный.",
"warnDefaultSubPath": "Путь подписки по умолчанию \"/sub/\" широко известен — измените его.",
"warnDefaultJsonPath": "JSON-путь подписки по умолчанию \"/json/\" широко известен — измените его.",
"panelListeningIP": "IP-адрес для управления панелью",
"panelListeningIPDesc": "Оставьте пустым для подключения с любого IP",
"panelListeningDomain": "Домен панели",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "Должен начинаться с '/' и заканчиваться '/'",
"pageSize": "Размер нумерации страниц",
"pageSizeDesc": "Определить размер страницы для таблицы подключений. Установите 0, чтобы отключить",
"panelOutbound": "Исходящий для трафика панели",
"panelOutboundDesc": "Маршрутизирует собственные запросы панели — проверки версий и загрузки панели/Xray, Telegram и обычное обновление geo-файлов — через этот исходящий Xray для обхода серверной фильтрации GitHub/Telegram. Локальный мост-входящий добавляется в работающую конфигурацию автоматически и применяется на лету. Встроенное в Xray автообновление Geodata не затрагивается; у него свой исходящий для загрузки. Оставьте пустым для прямого подключения.",
"panelOutboundPh": "Прямое подключение",
"datepicker": "Тип календаря",
"datepickerPlaceholder": "Выберите дату",
"datepickerDescription": "Запланированные задачи будут выполняться в соответствии с этим календарем.",
"oldUsername": "Текущий логин",
"currentPassword": "Текущий пароль",
"newUsername": "Новый логин",
@@ -1104,22 +1095,6 @@
"subSettings": "Подписка",
"subEnable": "Включить подписку",
"subEnableDesc": "Функция подписки с отдельной конфигурацией",
"subJsonEnable": "Включить/отключить JSON-эндпоинт подписки независимо.",
"subJsonEnableTitle": "JSON-подписка",
"subClashEnableTitle": "Подписка Clash / Mihomo",
"subFormatsTipTitle": "Настройки подписки для отдельных форматов",
"subFormatsTipDesc": "Настройте отдельно URL-пути, обратные URL и автоматическое определение клиентов для JSON и Clash / Mihomo.",
"subFormatsTipAction": "Открыть форматы подписки",
"subJsonAutoDetect": "Автоматически определять клиентов Xray JSON",
"subJsonAutoDetectDesc": "Если параметр включён, распознанные совместимые клиенты при запросе стандартного URL подписки автоматически получают массив конфигураций Xray JSON. Остальные клиенты получают прежний ответ raw/Base64. Для применения требуется включить JSON-подписку и перезапустить панель.",
"subJsonAlwaysArray": "Всегда возвращать массив JSON",
"subJsonAlwaysArrayDesc": "Возвращает явную JSON-подписку как массив даже при одном профиле согласно стандарту XTLS. Автоматически определённые JSON-ответы всегда используют массив. Отключите, чтобы сохранить прежний ответ в виде одного объекта.",
"subJsonUserAgentRegex": "Регулярное выражение User-Agent Xray JSON",
"subJsonUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для автоматического выбора формата Xray JSON на стандартном URL подписки. По умолчанию пустое, поэтому автоопределение отключено, пока вы не зададите шаблон для нужных клиентов. Остальные клиенты получают ответ raw/Base64. После изменения перезапустите панель.",
"subClashAutoDetect": "Автоматически определять клиентов Clash/Mihomo",
"subClashAutoDetectDesc": "Если параметр включён, распознанные клиенты Clash/Mihomo при запросе стандартного URL подписки автоматически получают Clash YAML. Браузеры по-прежнему показывают страницу подписки, остальные клиенты получают прежний ответ raw/Base64, а явные URL JSON и Clash остаются доступными. Для применения требуется включить подписку Clash/Mihomo и перезапустить панель.",
"subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",
"subClashUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для распознавания клиентов Clash/Mihomo на стандартном URL подписки. Оставьте поле пустым, чтобы использовать шаблон по умолчанию. После изменения перезапустите панель.",
"subTitle": "Заголовок подписки",
"subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "URL поддержки",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Объявление",
"subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Каталог темы подписки",
"subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
"subThemeDirDocs": "Руководство по шаблонам ↗",
"subEnableRouting": "Включить маршрутизацию",
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
"subRoutingRules": "Правила маршрутизации",
"subRoutingRulesDesc": "Вставьте готовый happ:// deeplink либо одну постоянную HTTPS-ссылку на deeplink или JSON. Панель обновляет удалённые правила в фоне и хранит последнее рабочее значение, поэтому запрос подписки не ждёт источник. (Только для Happ)",
"subHideSettings": "Скрыть настройки сервера",
"subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
"subIncyEnableRouting": "Включить маршрутизацию",
"subIncyEnableRoutingDesc": "Внедрять профиль маршрутизации в тело подписки для клиента Incy. (Только для Incy)",
"subIncyRoutingRules": "Правила маршрутизации",
"subIncyRoutingRulesDesc": "Вставьте готовый incy:// deeplink либо одну постоянную HTTPS-ссылку на JSON. Для HTTPS-ссылки создаётся autorouting-профиль, который Incy обновляет самостоятельно. (Только для Incy)",
"subClashEnableRouting": "Включить маршрутизацию",
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации",
"subClashRoutingRulesDesc": "Вставьте правила/YAML либо одну постоянную HTTPS-ссылку. Панель обновляет её в фоне, импортирует только группы, провайдеры правил и правила, сохраняет созданные VPN-узлы и последнее рабочее значение.",
"subListen": "Прослушивание IP",
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
"subPort": "Порт подписки",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "Доверенные CIDR прокси",
"trustedProxyCidrsDesc": "IP/CIDR через запятую, которым разрешено устанавливать заголовки forwarded host, proto и client IP.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Добавляет TCP/UDP-маски finalmask Xray и параметры QUIC в каждый созданный профиль Xray JSON. Требуется приложение с поддержкой подписок Xray JSON и свежая версия ядра Xray.",
"packets": "Пакеты",
"length": "Длина",
"interval": "Интервал",
@@ -1185,16 +1143,12 @@
"packet": "Пакет",
"delayMs": "Задержка (мс)",
"applyTo": "Применить к",
"addNoise": "+ Шум",
"concurrency": "Параллелизм",
"xudpConcurrency": "Параллелизм xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Передача нескольких независимых потоков данных в одном соединении.",
"muxSett": "Настройки Mux",
"direct": "Прямое подключение",
"directDesc": "Устанавливает прямые соединения с доменами или IP-адресами определённой страны.",
"certs": "Сертификаты",
"dateAndTime": "Дата и время",
"proxyAndServer": "Прокси и сервер",
@@ -1204,7 +1158,6 @@
"language": "Язык интерфейса",
"security": {
"admin": "Учетные данные администратора",
"twoFactorModalError": "Неверный код",
"show": "Показать",
"hide": "Скрыть",
"apiTokenNew": "Новый токен",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Ошибка получения трафика исходящего подключения",
"resetOutboundTrafficError": "Ошибка сброса трафика исходящего подключения"
},
"eventOutboundDown": "Недоступен",
"eventOutboundUp": "Работает",
"eventXrayCrash": "Сбой",
"eventNodeDown": "Недоступен",
"eventNodeUp": "В сети",
"eventCPUHigh": "Превышение порога CPU (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Очистить",
"secretClearUndo": "Отменить очистку",
"calendarGregorian": "Григорианский (обычный)",
"calendarJalalian": "Джалали (شمسی)",
"ipLimitAllowlist": "Доверенные адреса для лимита",
"ipLimitAllowlistDesc": "Адреса и подсети, которые лимит не считает и не банит: общий офисный или студенческий адрес не израсходует лимит клиента. Через запятую, адрес или подсеть.",
"subBalancers": {
"menu": "Балансировщики подписки",
"title": "Балансировщик подписки",
"add": "Добавить балансировщик",
"desc": "Каждый включённый балансировщик добавляется в JSON-подписку как отдельный профиль, автоматически выбирающий лучший из эндпоинтов выбранных инбаундов (routing.balancers + burstObservatory в клиентском конфиге).",
"remark": "Примечание",
"remarkPlaceholder": "Авто · самый быстрый",
"strategy": "Стратегия",
"strategyLeastLoad": "Минимальная нагрузка",
"strategyLeastPing": "Минимальный пинг",
"strategyRandom": "Случайный",
"strategyRoundRobin": "По очереди",
"sortOrder": "Порядок",
"sortOrderHelp": "Позиция в списке подписки, чередуется с порядком инбаундов; при равных номерах балансировщик идёт после инбаунда.",
"inbounds": "Инбаунды",
"inboundsCount": "{count} Инбаунды",
"enabled": "Включён",
"empty": "Балансировщиков пока нет",
"deleteConfirm": "Удалить этот балансировщик?",
"errRemarkRequired": "Укажите примечание",
"errInboundsRequired": "Выберите хотя бы один инбаунд",
"errSortOrder": "Порядок — целое число ≥ 1",
"toasts": {
"list": "Не удалось получить список балансировщиков подписки",
"create": "Не удалось создать балансировщик подписки",
"update": "Не удалось обновить балансировщик подписки",
"delete": "Не удалось удалить балансировщик подписки",
"invalidId": "Некорректный id"
},
"tabBalancers": "Балансировщик",
"tabObservatory": "Обсерватория",
"observatory": {
"title": "Обсерватория балансировщика",
"desc": "Параметры probe-запросов для burstObservatory, добавляемого в профили leastPing/leastLoad. random/roundRobin обходятся без обсерватории. Хранится как общая настройка JSON-подписки.",
"destination": "URL проверки",
"destinationDesc": "Адрес, по которому клиент проверяет доступность каждого участника.",
"connectivity": "URL связности",
"connectivityDesc": "Необязательный адрес для однократной проверки доступности цели. Оставьте пустым, чтобы пропустить.",
"interval": "Интервал проверок",
"intervalDesc": "Время между раундами проверок, например 1m.",
"timeout": "Тайм-аут проверки",
"timeoutDesc": "Тайм-аут одной проверки, например 5s.",
"sampling": "Выборка",
"samplingDesc": "Число подряд проверок для усреднения стабильности.",
"httpMethod": "HTTP-метод",
"httpMethodDesc": "Метод запросов при проверках.",
"note": "Балансировщики leastPing/leastLoad всегда содержат burst-обсерваторию. Этот переключатель настраивает её параметры проб — выключите, чтобы использовать встроенные значения по умолчанию. Изменения применяются после перезапуска панели."
}
},
"TGBotSettings": "Telegram-бот",
"notifications": "Уведомления",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно",
"FailedResetTraffic": "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Сброс трафика завершён для всех клиентов.",
"eventOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
"eventOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
"eventErrorDetail": "Ошибка: {{ .Error }}",
"eventDelayDetail": "Задержка: {{ .Delay }} мс",
"eventXrayCrash": "Сбой Xray",
"eventXrayCrashError": "Ошибка: {{ .Error }}",
"eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
"eventNodeUp": "Узел {{ .Name }} В СЕТИ",
"eventLoginFallback": "Неудачный вход с {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Yükle",
"clients": "Kullanıcılar",
"usage": "Kullanım",
"twoFactorCode": "Kod",
"remained": "Kalan",
"security": "Güvenlik",
"somethingWentWrong": "Bir hata oluştu",
@@ -103,8 +102,7 @@
"docs": "Belgeler",
"openMenu": "Menüyü aç",
"pinSidebar": "Kenar çubuğunu sabitle",
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır",
"subFormats": "Abonelik Formatları"
"unpinSidebar": "Kenar çubuğu sabitlemesini kaldır"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Ayrıldı {detached}, atlandı {skipped}, hatalar {errors}.",
"subLinksTitle": "Abonelik Bağlantıları ({count})",
"subLinkColumn": "Abonelik URL",
"subJsonLinkColumn": "Abonelik JSON URL",
"subLinksCopyAll": "Tümünü Kopyala",
"subLinksCopiedAll": "{count} bağlantı kopyalandı",
"subLinksEmpty": "Seçilen kullanıcıların hiçbirinin abonelik ID'si yok.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "Varsayılan 2053 portu yaygın olarak bilinmektedir — farklı bir portla değiştirin.",
"warnDefaultBasePath": "Varsayılan temel yol \"/\" yaygın olarak bilinmektedir — rastgele bir yol ile değiştirin.",
"warnDefaultSubPath": "Varsayılan abonelik yolu \"/sub/\" yaygın olarak bilinmektedir — değiştirin.",
"warnDefaultJsonPath": "Varsayılan JSON abonelik yolu \"/json/\" yaygın olarak bilinmektedir — değiştirin.",
"panelListeningIP": "Dinleme IP",
"panelListeningIPDesc": "Web paneli için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
"panelListeningDomain": "Dinleme Alan Adı",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "Web paneli için URI yolu. ('/' ile başlar ve '/' ile biter)",
"pageSize": "Sayfa Boyutu",
"pageSizeDesc": "Gelen Bağlantılar tablosu için sayfa boyutunu belirler. (0 = devre dışı)",
"panelOutbound": "Panel Trafiği Gideni",
"panelOutboundDesc": "Panelin kendi isteklerini — panel/Xray sürüm kontrolleri ve indirmeleri, Telegram ve normal geo dosyası güncellemesi — bu Xray gideni üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini aşmak için. Yerel bir köprü gelen bağlantısı çalışan yapılandırmaya otomatik eklenir ve canlı uygulanır. Xray'in yerel Geodata Otomatik Güncellemesi etkilenmez; kendi indirme gidenine sahiptir. Doğrudan bağlantı için boş bırakın.",
"panelOutboundPh": "Doğrudan bağlantı",
"datepicker": "Takvim Türü",
"datepickerPlaceholder": "Tarih Seçin",
"datepickerDescription": "Planlanmış görevler bu takvime göre çalışacaktır.",
"oldUsername": "Mevcut Kullanıcı Adı",
"currentPassword": "Mevcut Şifre",
"newUsername": "Yeni Kullanıcı Adı",
@@ -1104,22 +1095,6 @@
"subSettings": "Abonelik",
"subEnable": "Abonelik Hizmeti",
"subEnableDesc": "Abonelik hizmetini etkinleştirir/devre dışı bırakır.",
"subJsonEnable": "JSON abonelik uç noktasını bağımsız olarak etkinleştirir/devre dışı bırakır.",
"subJsonEnableTitle": "JSON Aboneliği",
"subClashEnableTitle": "Clash / Mihomo Aboneliği",
"subFormatsTipTitle": "Biçime özel abonelik ayarları",
"subFormatsTipDesc": "JSON ve Clash / Mihomo için URL yollarını, ters URL'leri ve otomatik istemci algılamayı ayrı ayrı yapılandırın.",
"subFormatsTipAction": "Abonelik Biçimlerini Aç",
"subJsonAutoDetect": "Xray JSON istemcilerini otomatik algıla",
"subJsonAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış uyumlu istemciler otomatik olarak bir Xray JSON yapılandırma dizisi alır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. JSON aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
"subJsonAlwaysArray": "Her zaman JSON dizisi döndür",
"subJsonAlwaysArrayDesc": "Açık JSON abonelik uç noktasını tek profil olsa bile XTLS standardına uygun bir dizi olarak döndürür. Otomatik algılanan JSON yanıtları her zaman dizi kullanır. Eski tek nesne yanıtını korumak için devre dışı bırakın.",
"subJsonUserAgentRegex": "Xray JSON User-Agent düzenli ifadesi",
"subJsonUserAgentRegexDesc": "Standart abonelik URL'sinde Xray JSON biçimini otomatik seçmek için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan olarak boştur; bu nedenle hizmet vermek istediğiniz istemciler için bir desen belirleyene kadar otomatik algılama kapalı kalır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. Değişiklikten sonra paneli yeniden başlatın.",
"subClashAutoDetect": "Clash/Mihomo istemcilerini otomatik algıla",
"subClashAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış Clash/Mihomo istemcileri otomatik olarak Clash YAML alır. Tarayıcılar abonelik sayfasını göstermeye devam eder, diğer istemciler ham/Base64 yanıtını almaya devam eder ve açık JSON ile Clash URL'leri kullanılabilir kalır. Uygulanması için Clash/Mihomo aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
"subClashUserAgentRegex": "Clash/Mihomo User-Agent düzenli ifadesi",
"subClashUserAgentRegexDesc": "Standart abonelik URL'sinde Clash/Mihomo istemcilerini tanımak için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan deseni kullanmak için boş bırakın. Değişiklikten sonra paneli yeniden başlatın.",
"subTitle": "Abonelik Başlığı",
"subTitleDesc": "VPN istemcisinde gösterilen başlık. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "Destek URL'si",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "VPN istemcisinde görüntülenen web sitenize giden bağlantı. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Duyuru",
"subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Abonelik Tema Dizini",
"subThemeDirDesc": "Abonelik sayfası için özel bir şablon (index.html/sub.html) içeren klasörün mutlak yolu (örn. /etc/3x-ui/sub_templates/my-theme/). Varsayılan sayfayı kullanmak için boş bırakın.",
"subThemeDirDocs": "Şablon kılavuzu ↗",
"subEnableRouting": "Yönlendirmeyi etkinleştir",
"subEnableRoutingDesc": "VPN istemcisinde yönlendirmeyi etkinleştirmek için genel ayar. (Yalnızca Happ için)",
"subRoutingRules": "Yönlendirme kuralları",
"subRoutingRulesDesc": "Hazır bir happ:// derin bağlantısı veya kalıcı bir HTTPS URL'si yapıştırın. Panel uzak kuralları arka planda yeniler ve son geçerli değeri saklar; abonelik istekleri kaynağı beklemez. (Yalnızca Happ için)",
"subHideSettings": "Sunucu ayarlarını gizle",
"subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
"subIncyEnableRouting": "Yönlendirmeyi etkinleştir",
"subIncyEnableRoutingDesc": "Incy istemcisi için abonelik gövdesine bir yönlendirme profili ekleyin. (Yalnızca Incy için)",
"subIncyRoutingRules": "Yönlendirme kuralları",
"subIncyRoutingRulesDesc": "Hazır bir incy:// derin bağlantısı veya JSON için kalıcı bir HTTPS URL'si yapıştırın. Incy bir autorouting profili oluşturur ve otomatik olarak günceller. (Yalnızca Incy için)",
"subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
"subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
"subClashRoutingRules": "Genel Yönlendirme Kuralları",
"subClashRoutingRulesDesc": "Kurallar/YAML veya kalıcı bir HTTPS URL'si yapıştırın. Panel bunu arka planda yeniler, yalnızca grupları, kural sağlayıcılarını ve kuralları içe aktarır; oluşturulan VPN düğümlerini ve son geçerli değeri korur.",
"subListen": "Dinleme IP",
"subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)",
"subPort": "Dinleme Portu",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "Güvenilir Proxy CIDR'leri",
"trustedProxyCidrsDesc": "İletilen host, proto ve istemci IP başlıklarını ayarlamasına izin verilen IP'ler/CIDR'ler (virgülle ayrılmış).",
"subFormats": {
"finalMask": "Son Maske",
"finalMaskDesc": "Xray finalmask TCP/UDP maskelerini ve QUIC parametrelerini oluşturulan her Xray JSON profiline ekler. Xray JSON aboneliklerini destekleyen bir istemci uygulaması ve güncel bir Xray çekirdeği gerektirir.",
"packets": "Paketler",
"length": "Uzunluk",
"interval": "Aralık",
@@ -1185,16 +1143,12 @@
"packet": "Paket",
"delayMs": "Gecikme (ms)",
"applyTo": "Şuna Uygula",
"addNoise": "+ Gürültü",
"concurrency": "Eşzamanlılık",
"xudpConcurrency": "xudp eşzamanlılık",
"xudpUdp443": "UDP 443"
},
"mux": "Ağız",
"muxDesc": "Mevcut bir veri akışı üzerinden birden çok bağımsız veri akışını iletir.",
"muxSett": "Mux Ayarları",
"direct": "Doğrudan Bağlantı",
"directDesc": "Belirli bir ülkenin alan adları veya IP aralıkları ile doğrudan bağlantı kurar.",
"certs": "Sertifikalar",
"dateAndTime": "Tarih ve Saat",
"proxyAndServer": "Proxy ve Sunucu",
@@ -1204,7 +1158,6 @@
"language": "Dil",
"security": {
"admin": "Yönetici Kimlik Bilgileri",
"twoFactorModalError": "Yanlış kod",
"show": "Göster",
"hide": "Gizle",
"apiTokenNew": "Yeni Token",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Giden trafik alınırken hata oluştu.",
"resetOutboundTrafficError": "Giden trafik sıfırlanırken hata oluştu."
},
"eventOutboundDown": "Çevrimdışı",
"eventOutboundUp": "Çevrimiçi",
"eventXrayCrash": "Çökme",
"eventNodeDown": "Çevrimdışı",
"eventNodeUp": "Çevrimiçi",
"eventCPUHigh": "Yüksek CPU (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Temizle",
"secretClearUndo": "Temizlemeyi geri al",
"calendarGregorian": "Gregoryen (Standart)",
"calendarJalalian": "Jalalian (Güneş)",
"ipLimitAllowlist": "IP limiti izin listesi",
"ipLimitAllowlistDesc": "IP limitinin asla saymadığı ve engellemediği adresler ve ağlar; böylece ortak bir ofis veya kampüs adresi kullanıcının limitini tüketmez. IP'ler/CIDR'ler (virgülle ayrılmış).",
"subBalancers": {
"menu": "Abonelik dengeleyicileri",
"title": "Abonelik dengeleyici",
"add": "Dengeleyici ekle",
"desc": "Etkin her dengeleyici, seçilen inbound'ların uç noktalarından en iyisini otomatik seçen ek bir profil olarak JSON aboneliğine eklenir.",
"remark": "Açıklama",
"remarkPlaceholder": "Otomatik · en hızlı",
"strategy": "Strateji",
"strategyLeastLoad": "En düşük yük",
"strategyLeastPing": "En düşük ping",
"strategyRandom": "Rastgele",
"strategyRoundRobin": "Sıralı",
"sortOrder": "Sıra",
"sortOrderHelp": "Abonelik listesindeki konumu, inbound sırası ile iç içe yerleşir; eşit numarada dengeleyici inbound'dan sonra gelir.",
"inbounds": "Inbound'lar",
"inboundsCount": "{count} Inbound'lar",
"enabled": "Etkin",
"empty": "Henüz dengeleyici yok",
"deleteConfirm": "Bu dengeleyici silinsin mi?",
"errRemarkRequired": "Açıklama zorunludur",
"errInboundsRequired": "En az bir inbound seçin",
"errSortOrder": "Sıra 1 veya daha büyük bir tam sayı olmalı",
"toasts": {
"list": "Abonelik dengeleyicileri listelenemedi",
"create": "Abonelik dengeleyicisi oluşturulamadı",
"update": "Abonelik dengeleyicisi güncellenemedi",
"delete": "Abonelik dengeleyicisi silinemedi",
"invalidId": "Geçersiz id"
},
"tabBalancers": "Dengeleyiciler",
"tabObservatory": "Gözlemci",
"observatory": {
"title": "Dengeleyici gözlemi",
"desc": "Her leastPing/leastLoad dengeleyici profiline gömülen burstObservatory probe parametreleri. random/roundRobin için gözlem eklenmez. Paneller arası JSON abonelik ayarı olarak saklanır.",
"destination": "Probe URL'si",
"destinationDesc": "İstemcinin her üye çıkışı ölçmek için denediği adres.",
"connectivity": "Bağlantı URL'si",
"connectivityDesc": "Üyenin hedefe ulaşabildiğini tek kez doğrulamak için isteğe bağlı adres. Atlamak için boş bırakın.",
"interval": "Probe aralığı",
"intervalDesc": "Probe turları arasındaki süre, örn. 1m.",
"timeout": "Probe zaman aşımı",
"timeoutDesc": "Tek bir probe için zaman aşımı, örn. 5s.",
"sampling": "Örnekleme",
"samplingDesc": "Kararlılık ortalaması için ardışık probe sayısı.",
"httpMethod": "HTTP yöntemi",
"httpMethodDesc": "Probe isteklerinde kullanılan HTTP yöntemi.",
"note": "leastPing/leastLoad dengeleyicileri her zaman bir burstObservatory taşır. Bu anahtar probe parametrelerini özelleştirir — yerleşik varsayılanları kullanmak için kapatın. Değişiklikler panel yeniden başlatıldıktan sonra uygulanır."
}
},
"TGBotSettings": "Telegram Botu",
"notifications": "Bildirimler",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı",
"FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı.",
"eventOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
"eventOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
"eventErrorDetail": "Hata: {{ .Error }}",
"eventDelayDetail": "Gecikme: {{ .Delay }}ms",
"eventXrayCrash": "Xray ÇÖKTÜ",
"eventXrayCrashError": "Hata: {{ .Error }}",
"eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI",
"eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
"eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Встановити",
"clients": "Клієнти",
"usage": "Використання",
"twoFactorCode": "Код",
"remained": "Залишилося",
"security": "Беспека",
"somethingWentWrong": "Щось пішло не так",
@@ -103,8 +102,7 @@
"docs": "Документація",
"openMenu": "Відкрити меню",
"pinSidebar": "Закріпити бічну панель",
"unpinSidebar": "Відкріпити бічну панель",
"subFormats": "Формати підписки"
"unpinSidebar": "Відкріпити бічну панель"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Від'єднано {detached}, пропущено {skipped}, помилок {errors}.",
"subLinksTitle": "Sub-посилання ({count})",
"subLinkColumn": "URL підписки",
"subJsonLinkColumn": "URL JSON-підписки",
"subLinksCopyAll": "Копіювати все",
"subLinksCopiedAll": "Скопійовано {count} посилань",
"subLinksEmpty": "Жоден з обраних клієнтів не має ID підписки.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "Стандартний порт 2053 широко відомий — змініть його на випадковий.",
"warnDefaultBasePath": "Базовий шлях за замовчуванням \"/\" широко відомий — змініть його на випадковий.",
"warnDefaultSubPath": "Шлях підписки за замовчуванням \"/sub/\" широко відомий — змініть його.",
"warnDefaultJsonPath": "JSON-шлях підписки за замовчуванням \"/json/\" широко відомий — змініть його.",
"panelListeningIP": "Слухати IP",
"panelListeningIPDesc": "IP-адреса для веб-панелі. (залиште порожнім, щоб слухати всі IP-адреси)",
"panelListeningDomain": "Домен прослуховування",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "Шлях URL для веб-панелі. (починається з ‘/‘ і закінчується ‘/‘)",
"pageSize": "Розмір сторінки",
"pageSizeDesc": "Визначити розмір сторінки для вхідної таблиці. (0 = вимкнено)",
"panelOutbound": "Вихідний для трафіку панелі",
"panelOutboundDesc": "Маршрутизує власні запити панелі — перевірки версій і завантаження панелі/Xray, Telegram та звичайне оновлення geo-файлів — через цей вихідний Xray для обходу фільтрації GitHub/Telegram на стороні сервера. Локальний міст-вхідний додається до робочої конфігурації автоматично і застосовується наживо. Вбудоване в Xray автооновлення Geodata не зачіпається; воно має власний вихідний для завантаження. Залиште порожнім для прямого підключення.",
"panelOutboundPh": "Пряме підключення",
"datepicker": "Тип календаря",
"datepickerPlaceholder": "Виберіть дату",
"datepickerDescription": "Заплановані завдання виконуватимуться на основі цього календаря.",
"oldUsername": "Поточне ім'я користувача",
"currentPassword": "Поточний пароль",
"newUsername": "Нове ім'я користувача",
@@ -1104,22 +1095,6 @@
"subSettings": "Підписка",
"subEnable": "Увімкнути службу підписки",
"subEnableDesc": "Вмикає службу підписки.",
"subJsonEnable": "Увімкнути/вимкнути JSON-кінець підписки незалежно.",
"subJsonEnableTitle": "JSON-підписка",
"subClashEnableTitle": "Підписка Clash / Mihomo",
"subFormatsTipTitle": "Налаштування підписки для окремих форматів",
"subFormatsTipDesc": "Окремо налаштуйте URL-шляхи, зворотні URL-адреси й автоматичне визначення клієнтів для JSON та Clash / Mihomo.",
"subFormatsTipAction": "Відкрити формати підписки",
"subJsonAutoDetect": "Автоматично визначати клієнтів Xray JSON",
"subJsonAutoDetectDesc": "Якщо параметр увімкнено, розпізнані сумісні клієнти під час запиту стандартної URL-адреси підписки автоматично отримують масив конфігурацій Xray JSON. Інші клієнти отримують звичайну відповідь raw/Base64. Для застосування потрібно ввімкнути JSON-підписку та перезапустити панель.",
"subJsonAlwaysArray": "Завжди повертати масив JSON",
"subJsonAlwaysArrayDesc": "Повертає явну JSON-підписку як масив навіть для одного профілю відповідно до стандарту XTLS. Автоматично визначені JSON-відповіді завжди використовують масив. Вимкніть, щоб зберегти попередню відповідь одним об’єктом.",
"subJsonUserAgentRegex": "Регулярний вираз User-Agent Xray JSON",
"subJsonUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для автоматичного вибору формату Xray JSON на стандартній URL-адресі підписки. Типово порожнє, тому автоматичне визначення вимкнено, доки ви не задасте шаблон для потрібних клієнтів. Інші клієнти отримують відповідь raw/Base64. Після зміни перезапустіть панель.",
"subClashAutoDetect": "Автоматично визначати клієнтів Clash/Mihomo",
"subClashAutoDetectDesc": "Якщо параметр увімкнено, розпізнані клієнти Clash/Mihomo під час запиту стандартної URL-адреси підписки автоматично отримують Clash YAML. Браузери й надалі показують сторінку підписки, інші клієнти отримують звичайну відповідь raw/Base64, а явні URL-адреси JSON і Clash залишаються доступними. Для застосування потрібно ввімкнути підписку Clash/Mihomo та перезапустити панель.",
"subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",
"subClashUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для розпізнавання клієнтів Clash/Mihomo на стандартній URL-адресі підписки. Залиште поле порожнім для стандартного шаблону. Після зміни перезапустіть панель.",
"subTitle": "Назва Підписки",
"subTitleDesc": "Назва, яка відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "URL підтримки",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Оголошення",
"subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Каталог теми підписки",
"subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
"subThemeDirDocs": "Посібник із шаблонів ↗",
"subEnableRouting": "Увімкнути маршрутизацію",
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
"subRoutingRules": "Правила маршрутизації",
"subRoutingRulesDesc": "Вставте готове посилання happ:// або одну постійну HTTPS-адресу. Панель оновлює віддалені правила у фоні та зберігає останнє коректне значення, тому запит підписки не чекає на джерело. (Тільки для Happ)",
"subHideSettings": "Приховати налаштування сервера",
"subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
"subIncyEnableRouting": "Увімкнути маршрутизацію",
"subIncyEnableRoutingDesc": "Вставляти профіль маршрутизації в тіло підписки для клієнта Incy. (Тільки для Incy)",
"subIncyRoutingRules": "Правила маршрутизації",
"subIncyRoutingRulesDesc": "Вставте готове посилання incy:// або постійну HTTPS-адресу JSON. Incy створює профіль autorouting і автоматично його оновлює. (Тільки для Incy)",
"subClashEnableRouting": "Увімкнути маршрутизацію",
"subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
"subClashRoutingRules": "Глобальні правила маршрутизації",
"subClashRoutingRulesDesc": "Вставте правила/YAML або одну постійну HTTPS-адресу. Панель оновлює її у фоні, імпортує лише групи, постачальників правил і правила, зберігаючи створені VPN-вузли та останнє коректне значення.",
"subListen": "Слухати IP",
"subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)",
"subPort": "Слухати порт",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "Довірені CIDR проксі",
"trustedProxyCidrsDesc": "IP/CIDR через кому, яким дозволено встановлювати заголовки forwarded host, proto та client IP.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Додає TCP/UDP-маски finalmask Xray і параметри QUIC до кожного створеного профілю Xray JSON. Потрібен застосунок із підтримкою підписок Xray JSON і нова версія ядра Xray.",
"packets": "Пакети",
"length": "Довжина",
"interval": "Інтервал",
@@ -1185,16 +1143,12 @@
"packet": "Пакет",
"delayMs": "Затримка (мс)",
"applyTo": "Застосувати до",
"addNoise": "+ Шум",
"concurrency": "Паралельність",
"xudpConcurrency": "Паралельність xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Передавати кілька незалежних потоків даних у межах встановленого потоку даних.",
"muxSett": "Налаштування Mux",
"direct": "Пряме підключення",
"directDesc": "Безпосередньо встановлює з’єднання з доменами або діапазонами IP певної країни.",
"certs": "Сертифікати",
"dateAndTime": "Дата та час",
"proxyAndServer": "Проксі та сервер",
@@ -1204,7 +1158,6 @@
"language": "Мова",
"security": {
"admin": "Облікові дані адміністратора",
"twoFactorModalError": "Невірний код",
"show": "Показати",
"hide": "Сховати",
"apiTokenNew": "Новий токен",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Помилка отримання вихідного трафіку",
"resetOutboundTrafficError": "Помилка скидання вихідного трафіку"
},
"eventOutboundDown": "Недоступне",
"eventOutboundUp": "Доступне",
"eventXrayCrash": "Збій",
"eventNodeDown": "Недоступний",
"eventNodeUp": "Доступний",
"eventCPUHigh": "Високе навантаження на CPU (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Очистити",
"secretClearUndo": "Скасувати очищення",
"calendarGregorian": "Григоріанський (звичайний)",
"calendarJalalian": "Джалалі (شمسی)",
"ipLimitAllowlist": "Довірені адреси для ліміту",
"ipLimitAllowlistDesc": "Адреси та підмережі, які ліміт не рахує і не банить: спільна офісна чи студентська адреса не витратить ліміт клієнта. Через кому, адреса або підмережа.",
"subBalancers": {
"menu": "Балансувальники підписки",
"title": "Балансувальник підписки",
"add": "Додати балансувальник",
"desc": "Кожний увімкнений балансувальник додається до JSON-підписки як окремий профіль, що автоматично обирає найкращу з кінцевих точок вибраних інбаундів.",
"remark": "Примітка",
"remarkPlaceholder": "Авто · найшвидший",
"strategy": "Стратегія",
"strategyLeastLoad": "Найменше навантаження",
"strategyLeastPing": "Найменший ping",
"strategyRandom": "Випадково",
"strategyRoundRobin": "По черзі",
"sortOrder": "Порядок",
"sortOrderHelp": "Позиція у списку підписки, чергується з порядком інбаундів; за однакового номера йде після інбаунда.",
"inbounds": "Інбаунди",
"inboundsCount": "{count} Інбаунди",
"enabled": "Увімкнено",
"empty": "Балансувальників ще немає",
"deleteConfirm": "Видалити цей балансувальник?",
"errRemarkRequired": "Вкажіть примітку",
"errInboundsRequired": "Виберіть хоча б один інбаунд",
"errSortOrder": "Порядок — ціле число ≥ 1",
"toasts": {
"list": "Не вдалося отримати список балансувальників підписки",
"create": "Не вдалося створити балансувальник підписки",
"update": "Не вдалося оновити балансувальник підписки",
"delete": "Не вдалося видалити балансувальник підписки",
"invalidId": "Некоректний id"
},
"tabBalancers": "Балансери",
"tabObservatory": "Обсерваторія",
"observatory": {
"title": "Обсерваторія балансувальника",
"desc": "Параметри probe-запитів для burstObservatory, що додається у профілі leastPing/leastLoad. random/roundRobin обходяться без обсерваторії. Зберігається як загальна налаштування JSON-підписки.",
"destination": "URL перевірки",
"destinationDesc": "Адреса, за якою клієнт перевіряє доступність кожного учасника.",
"connectivity": "URL зв’язності",
"connectivityDesc": "Необов’язкова адреса для одноразової перевірки доступності цілі. Залиште порожнім, щоб пропустити.",
"interval": "Інтервал перевірок",
"intervalDesc": "Час між раундами перевірок, наприклад 1m.",
"timeout": "Тайм-аут перевірки",
"timeoutDesc": "Тайм-аут однієї перевірки, наприклад 5s.",
"sampling": "Вибірка",
"samplingDesc": "Кількість підряд перевірок для усереднення стабільності.",
"httpMethod": "HTTP-метод",
"httpMethodDesc": "Метод запитів під час перевірок.",
"note": "Балансувальники leastPing/leastLoad завжди мають burstObservatory. Цей перемикач налаштовує її параметри probe — вимкніть, щоб використовувати вбудовані значення за замовчуванням. Зміни застосовуються після перезапуску панелі."
}
},
"TGBotSettings": "Telegram-бот",
"notifications": "Сповіщення",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно",
"FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів.",
"eventOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
"eventOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
"eventErrorDetail": "Помилка: {{ .Error }}",
"eventDelayDetail": "Затримка: {{ .Delay }} мс",
"eventXrayCrash": "Стався збій Xray",
"eventXrayCrashError": "Помилка: {{ .Error }}",
"eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ",
"eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ",
"eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "Cài đặt",
"clients": "Các khách hàng",
"usage": "Sử dụng",
"twoFactorCode": "Mã",
"remained": "Còn lại",
"security": "Bảo vệ",
"somethingWentWrong": "Đã xảy ra lỗi",
@@ -103,8 +102,7 @@
"docs": "Tài liệu",
"openMenu": "Mở menu",
"pinSidebar": "Ghim thanh bên",
"unpinSidebar": "Bỏ ghim thanh bên",
"subFormats": "Sub Formats"
"unpinSidebar": "Bỏ ghim thanh bên"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "Đã tách {detached}, bỏ qua {skipped}, lỗi {errors}.",
"subLinksTitle": "Liên kết sub ({count})",
"subLinkColumn": "URL đăng ký",
"subJsonLinkColumn": "URL JSON đăng ký",
"subLinksCopyAll": "Sao chép tất cả",
"subLinksCopiedAll": "Đã sao chép {count} liên kết",
"subLinksEmpty": "Không client nào trong các client đã chọn có ID đăng ký.",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "Cổng mặc định 2053 đã quá phổ biến — đổi sang cổng ngẫu nhiên.",
"warnDefaultBasePath": "Đường dẫn cơ sở mặc định \"/\" đã quá phổ biến — đổi sang đường dẫn ngẫu nhiên.",
"warnDefaultSubPath": "Đường dẫn đăng ký mặc định \"/sub/\" đã quá phổ biến — đổi nó.",
"warnDefaultJsonPath": "Đường dẫn đăng ký JSON mặc định \"/json/\" đã quá phổ biến — đổi nó.",
"panelListeningIP": "IP Nghe của bảng điều khiển",
"panelListeningIPDesc": "Mặc định để trống để nghe tất cả các IP.",
"panelListeningDomain": "Tên miền của nghe bảng điều khiển",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "Phải bắt đầu và kết thúc bằng '/'",
"pageSize": "Kích thước phân trang",
"pageSizeDesc": "Xác định kích thước trang cho bảng gửi đến. Đặt 0 để tắt",
"panelOutbound": "Outbound cho lưu lượng panel",
"panelOutboundDesc": "Định tuyến các yêu cầu của chính bảng điều khiển — kiểm tra phiên bản và tải xuống panel/Xray, Telegram, và cập nhật tệp geo thông thường — qua outbound Xray này để vượt qua lọc GitHub/Telegram phía máy chủ. Một inbound cầu nối cục bộ được tự động thêm vào cấu hình đang chạy và áp dụng trực tiếp. Tính năng Tự động cập nhật Geodata gốc của Xray không bị ảnh hưởng; nó có outbound tải xuống riêng. Để trống để kết nối trực tiếp.",
"panelOutboundPh": "Kết nối trực tiếp",
"datepicker": "Kiểu lịch",
"datepickerPlaceholder": "Chọn ngày",
"datepickerDescription": "Tác vụ chạy theo lịch trình sẽ chạy theo kiểu lịch này.",
"oldUsername": "Tên người dùng hiện tại",
"currentPassword": "Mật khẩu hiện tại",
"newUsername": "Tên người dùng mới",
@@ -1104,22 +1095,6 @@
"subSettings": "Gói đăng ký",
"subEnable": "Bật dịch vụ",
"subEnableDesc": "Tính năng gói đăng ký với cấu hình riêng",
"subJsonEnable": "Bật/Tắt điểm cuối đăng ký JSON độc lập.",
"subJsonEnableTitle": "Đăng ký JSON",
"subClashEnableTitle": "Đăng ký Clash / Mihomo",
"subFormatsTipTitle": "Cài đặt đăng ký theo từng định dạng",
"subFormatsTipDesc": "Cấu hình riêng đường dẫn URL, URL proxy ngược và tính năng tự động nhận diện ứng dụng cho JSON và Clash / Mihomo.",
"subFormatsTipAction": "Mở định dạng đăng ký",
"subJsonAutoDetect": "Tự động nhận diện ứng dụng Xray JSON",
"subJsonAutoDetectDesc": "Khi bật, các ứng dụng tương thích được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận một mảng cấu hình Xray JSON. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Cần bật đăng ký JSON và khởi động lại bảng điều khiển.",
"subJsonAlwaysArray": "Luôn trả về mảng JSON",
"subJsonAlwaysArrayDesc": "Trả endpoint đăng ký JSON tường minh dưới dạng mảng ngay cả khi chỉ có một hồ sơ, theo tiêu chuẩn XTLS. Phản hồi JSON được tự động nhận diện luôn dùng mảng. Tắt để giữ phản hồi một đối tượng kiểu cũ.",
"subJsonUserAgentRegex": "Biểu thức User-Agent Xray JSON",
"subJsonUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để tự động chọn định dạng Xray JSON trên URL đăng ký tiêu chuẩn. Mặc định để trống, nên tính năng tự động nhận diện vẫn tắt cho đến khi bạn đặt mẫu cho các ứng dụng muốn phục vụ. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Khởi động lại bảng điều khiển sau khi thay đổi.",
"subClashAutoDetect": "Tự động nhận diện ứng dụng Clash/Mihomo",
"subClashAutoDetectDesc": "Khi bật, các ứng dụng Clash/Mihomo được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận cấu hình Clash YAML. Trình duyệt vẫn hiển thị trang đăng ký, các ứng dụng khác tiếp tục nhận phản hồi thô/Base64, còn các URL JSON và Clash riêng vẫn khả dụng. Cần bật đăng ký Clash/Mihomo và khởi động lại bảng điều khiển để áp dụng thay đổi.",
"subClashUserAgentRegex": "Biểu thức User-Agent Clash/Mihomo",
"subClashUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để nhận diện ứng dụng Clash/Mihomo trên URL đăng ký tiêu chuẩn. Để trống để dùng mẫu mặc định. Khởi động lại bảng điều khiển sau khi thay đổi.",
"subTitle": "Tiêu đề Đăng ký",
"subTitleDesc": "Tiêu đề hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subSupportUrl": "URL Hỗ trợ",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subAnnounce": "Thông báo",
"subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
"subThemeDir": "Thư mục giao diện Đăng ký",
"subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
"subThemeDirDocs": "Hướng dẫn mẫu ↗",
"subEnableRouting": "Bật định tuyến",
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subRoutingRules": "Quy tắc định tuyến",
"subRoutingRulesDesc": "Dán deeplink happ:// có sẵn hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật quy tắc từ xa trong nền và giữ giá trị hợp lệ gần nhất, nên yêu cầu đăng ký không phải chờ nguồn. (Chỉ dành cho Happ)",
"subHideSettings": "Ẩn cài đặt máy chủ",
"subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
"subIncyEnableRouting": "Bật định tuyến",
"subIncyEnableRoutingDesc": "Chèn hồ sơ định tuyến vào nội dung đăng ký cho ứng dụng Incy. (Chỉ dành cho Incy)",
"subIncyRoutingRules": "Quy tắc định tuyến",
"subIncyRoutingRulesDesc": "Dán deeplink incy:// có sẵn hoặc URL HTTPS cố định tới JSON. Incy tạo hồ sơ autorouting và tự động cập nhật. (Chỉ dành cho Incy)",
"subClashEnableRouting": "Bật định tuyến",
"subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
"subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
"subClashRoutingRulesDesc": "Dán quy tắc/YAML hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật trong nền, chỉ nhập nhóm, nhà cung cấp quy tắc và quy tắc, đồng thời giữ các nút VPN đã tạo và giá trị hợp lệ gần nhất.",
"subListen": "Listening IP",
"subListenDesc": "Mặc định để trống để nghe tất cả các IP",
"subPort": "Cổng gói đăng ký",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "CIDR proxy tin cậy",
"trustedProxyCidrsDesc": "IPs/CIDRs cách nhau bằng dấu phẩy được phép đặt header host, proto và IP client chuyển tiếp.",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "Thêm mask TCP/UDP của Xray finalmask và tham số QUIC vào mỗi hồ sơ Xray JSON được tạo. Yêu cầu ứng dụng hỗ trợ đăng ký Xray JSON và lõi Xray mới.",
"packets": "Gói",
"length": "Độ dài",
"interval": "Khoảng",
@@ -1185,16 +1143,12 @@
"packet": "Gói",
"delayMs": "Trễ (ms)",
"applyTo": "Áp dụng cho",
"addNoise": "+ Nhiễu",
"concurrency": "Đồng thời",
"xudpConcurrency": "Đồng thời xudp",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "Truyền nhiều luồng dữ liệu độc lập trong luồng dữ liệu đã thiết lập.",
"muxSett": "Mux Cài đặt",
"direct": "Kết nối trực tiếp",
"directDesc": "Trực tiếp thiết lập kết nối với tên miền hoặc dải IP của một quốc gia cụ thể.",
"certs": "Chứng chỉ",
"dateAndTime": "Ngày và giờ",
"proxyAndServer": "Proxy và máy chủ",
@@ -1204,7 +1158,6 @@
"language": "Ngôn ngữ",
"security": {
"admin": "Thông tin đăng nhập quản trị viên",
"twoFactorModalError": "Mã sai",
"show": "Hiển thị",
"hide": "Ẩn",
"apiTokenNew": "Token mới",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "Lỗi khi lấy lưu lượng truy cập đi",
"resetOutboundTrafficError": "Lỗi khi đặt lại lưu lượng truy cập đi"
},
"eventOutboundDown": "Ngừng hoạt động",
"eventOutboundUp": "Hoạt động",
"eventXrayCrash": "Sự cố",
"eventNodeDown": "Ngừng hoạt động",
"eventNodeUp": "Hoạt động",
"eventCPUHigh": "CPU cao (%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "Xóa",
"secretClearUndo": "Hoàn tác xóa",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "Danh sách cho phép của giới hạn IP",
"ipLimitAllowlistDesc": "Các địa chỉ và mạng mà giới hạn IP không bao giờ tính và không bao giờ chặn, để một địa chỉ dùng chung của văn phòng hoặc trường học không dùng hết giới hạn của người dùng. IPs/CIDRs cách nhau bằng dấu phẩy.",
"subBalancers": {
"menu": "Bộ cân bằng đăng ký",
"title": "Bộ cân bằng đăng ký",
"add": "Thêm bộ cân bằng",
"desc": "Mỗi bộ cân bằng đang bật được thêm vào đăng ký JSON như một hồ sơ riêng, tự động chọn điểm cuối tốt nhất trong các inbound đã chọn.",
"remark": "Ghi chú",
"remarkPlaceholder": "Tự động · nhanh nhất",
"strategy": "Chiến lược",
"strategyLeastLoad": "Tải thấp nhất",
"strategyLeastPing": "Ping thấp nhất",
"strategyRandom": "Ngẫu nhiên",
"strategyRoundRobin": "Luân phiên",
"sortOrder": "Thứ tự",
"sortOrderHelp": "Vị trí trong danh sách đăng ký, xen kẽ với thứ tự inbound; khi cùng số, bộ cân bằng đứng sau inbound.",
"inbounds": "Inbound",
"inboundsCount": "{count} Inbound",
"enabled": "Đã bật",
"empty": "Chưa có bộ cân bằng nào",
"deleteConfirm": "Xóa bộ cân bằng này?",
"errRemarkRequired": "Cần nhập ghi chú",
"errInboundsRequired": "Chọn ít nhất một inbound",
"errSortOrder": "Thứ tự phải là số nguyên ≥ 1",
"toasts": {
"list": "Không thể liệt kê các bộ cân bằng đăng ký",
"create": "Không thể tạo bộ cân bằng đăng ký",
"update": "Không thể cập nhật bộ cân bằng đăng ký",
"delete": "Không thể xóa bộ cân bằng đăng ký",
"invalidId": "Id không hợp lệ"
},
"tabBalancers": "Cân bằng",
"tabObservatory": "Observatory",
"observatory": {
"title": "Đài quan sát bộ cân bằng",
"desc": "Tham số probe cho burstObservatory nhúng vào mỗi hồ sơ leastPing/leastLoad. random/roundRobin không có đài quan sát. Lưu thành cài đặt chung của đăng ký JSON.",
"destination": "URL probe",
"destinationDesc": "Địa chỉ client thăm dò để đo mỗi outbound thành viên.",
"connectivity": "URL kết nối",
"connectivityDesc": "Địa chỉ tuỳ chọn để kiểm tra một lần thành viên có tới đích được không. Để trống để bỏ qua.",
"interval": "Khoảng probe",
"intervalDesc": "Thời gian giữa các vòng probe, vd. 1m.",
"timeout": "Hết giờ probe",
"timeoutDesc": "Hết giờ cho mỗi probe, vd. 5s.",
"sampling": "Lấy mẫu",
"samplingDesc": "Số lần probe liên tiếp để trung bình độ ổn định.",
"httpMethod": "Phương thức HTTP",
"httpMethodDesc": "Phương thức dùng cho yêu cầu probe.",
"note": "Các bộ cân bằng leastPing/leastLoad luôn mang một burstObservatory. Công tắc này tùy chỉnh các tham số probe — tắt nó để dùng mặc định tích hợp. Các thay đổi áp dụng sau khi khởi động lại bảng điều khiển."
}
},
"TGBotSettings": "Bot Telegram",
"notifications": "Thông báo",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công",
"FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng.",
"eventOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
"eventOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
"eventErrorDetail": "Lỗi: {{ .Error }}",
"eventDelayDetail": "Độ trễ: {{ .Delay }}ms",
"eventXrayCrash": "Xray GẶP SỰ CỐ",
"eventXrayCrashError": "Lỗi: {{ .Error }}",
"eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG",
"eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
"eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "安装",
"clients": "客户端",
"usage": "使用情况",
"twoFactorCode": "代码",
"remained": "剩余",
"security": "安全",
"somethingWentWrong": "出了点问题",
@@ -103,8 +102,7 @@
"docs": "文档",
"openMenu": "打开菜单",
"pinSidebar": "固定侧边栏",
"unpinSidebar": "取消固定侧边栏",
"subFormats": "Sub Formats"
"unpinSidebar": "取消固定侧边栏"
},
"pages": {
"login": {
@@ -738,7 +736,6 @@
"detachFromInboundsResultMixed": "已分离 {detached},已跳过 {skipped},错误 {errors}。",
"subLinksTitle": "订阅链接 ({count})",
"subLinkColumn": "订阅 URL",
"subJsonLinkColumn": "订阅 JSON URL",
"subLinksCopyAll": "全部复制",
"subLinksCopiedAll": "已复制 {count} 条链接",
"subLinksEmpty": "选中的客户端均无订阅 ID。",
@@ -1090,7 +1087,6 @@
"warnDefaultPort": "默认端口 2053 众所周知 — 请更改为随机端口。",
"warnDefaultBasePath": "默认根路径 \"/\" 众所周知 — 请更改为随机路径。",
"warnDefaultSubPath": "默认订阅路径 \"/sub/\" 众所周知 — 请更改。",
"warnDefaultJsonPath": "默认 JSON 订阅路径 \"/json/\" 众所周知 — 请更改。",
"panelListeningIP": "面板监听 IP",
"panelListeningIPDesc": "默认留空监听所有 IP",
"panelListeningDomain": "面板监听域名",
@@ -1105,12 +1101,7 @@
"panelUrlPathDesc": "必须以 '/' 开头,以 '/' 结尾",
"pageSize": "分页大小",
"pageSizeDesc": "定义入站表的页面大小。设置 0 表示禁用",
"panelOutbound": "面板流量出站",
"panelOutboundDesc": "通过此 Xray 出站路由面板自身的请求(面板/Xray 版本检查与下载、Telegram、普通 geo 文件更新),以绕过服务端对 GitHub/Telegram 的过滤。本地桥接入站会自动添加到运行中的配置并实时生效。Xray 原生的 Geodata 自动更新不受影响,它有自己的下载出站。留空表示直连。",
"panelOutboundPh": "直连",
"datepicker": "日期选择器",
"datepickerPlaceholder": "选择日期",
"datepickerDescription": "选择器日历类型指定到期日期",
"oldUsername": "原用户名",
"currentPassword": "原密码",
"newUsername": "新用户名",
@@ -1126,22 +1117,6 @@
"subSettings": "订阅设置",
"subEnable": "启用订阅服务",
"subEnableDesc": "启用订阅服务功能",
"subJsonEnable": "单独启用/禁用 JSON 订阅端点。",
"subJsonEnableTitle": "JSON 订阅",
"subClashEnableTitle": "Clash / Mihomo 订阅",
"subFormatsTipTitle": "特定格式的订阅设置",
"subFormatsTipDesc": "分别配置 JSON 和 Clash / Mihomo 的 URL 路径、反向代理 URL 和客户端自动识别。",
"subFormatsTipAction": "打开订阅格式",
"subJsonAutoDetect": "自动识别 Xray JSON 客户端",
"subJsonAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别兼容客户端将自动获得 Xray JSON 配置数组。其他客户端继续获得原始/Base64 响应。需要启用 JSON 订阅并重启面板才能生效。",
"subJsonAlwaysArray": "始终返回 JSON 数组",
"subJsonAlwaysArrayDesc": "即使只有一个配置,也将显式 JSON 订阅端点返回为数组,以符合 XTLS 订阅标准。自动识别的 JSON 响应始终使用数组。禁用时保留旧版单对象响应。",
"subJsonUserAgentRegex": "Xray JSON User-Agent 正则表达式",
"subJsonUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上自动选择 Xray JSON 格式的 Go RE2 正则表达式。默认留空,因此在为需要服务的客户端设置规则之前,自动识别保持关闭。其他客户端继续获得原始/Base64 响应。更改后请重启面板。",
"subClashAutoDetect": "自动识别 Clash/Mihomo 客户端",
"subClashAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别 Clash/Mihomo 客户端将自动获得 Clash YAML。浏览器仍显示订阅页面,其他客户端继续获得原始/Base64 响应,独立的 JSON 和 Clash URL 仍然可用。需要启用 Clash/Mihomo 订阅并重启面板才能生效。",
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
"subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
"subTitle": "订阅标题",
"subTitleDesc": "在 VPN 客户端中显示的标题。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
"subSupportUrl": "支持链接",
@@ -1150,23 +1125,8 @@
"subProfileUrlDesc": "VPN 客户端中显示的网站链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
"subAnnounce": "公告",
"subAnnounceDesc": "VPN 客户端中显示的公告文本。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
"subThemeDir": "订阅主题目录",
"subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
"subThemeDirDocs": "模板指南 ↗",
"subEnableRouting": "启用路由",
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(仅限 Happ)",
"subRoutingRules": "路由规则",
"subRoutingRulesDesc": "粘贴现成的 happ:// 深层链接或一个固定 HTTPS URL。面板会在后台更新远程规则并保留最后一个有效值,因此订阅请求无需等待远程源。(仅限 Happ)",
"subHideSettings": "隐藏服务器设置",
"subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
"subIncyEnableRouting": "启用路由",
"subIncyEnableRoutingDesc": "为 Incy 客户端将路由配置注入订阅内容中。(仅限 Incy)",
"subIncyRoutingRules": "路由规则",
"subIncyRoutingRulesDesc": "粘贴现成的 incy:// 深层链接或指向 JSON 的固定 HTTPS URL。Incy 会创建 autorouting 配置并自动更新。(仅限 Incy)",
"subClashEnableRouting": "启用路由",
"subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
"subClashRoutingRules": "全局路由规则",
"subClashRoutingRulesDesc": "粘贴规则/YAML 或一个固定 HTTPS URL。面板会在后台更新,仅导入代理组、规则提供者和规则,并保留面板生成的 VPN 节点及最后一个有效值。",
"subListen": "监听 IP",
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
"subPort": "监听端口",
@@ -1195,8 +1155,6 @@
"trustedProxyCidrs": "可信代理 CIDR",
"trustedProxyCidrsDesc": "允许设置转发 host、proto 和客户端 IP 标头的 IP/CIDR(逗号分隔)。",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "将 Xray finalmask TCP/UDP 掩码和 QUIC 参数注入每个生成的 Xray JSON 配置。需要支持 Xray JSON 订阅的客户端应用和较新的 Xray 核心。",
"packets": "数据包",
"length": "长度",
"interval": "间隔",
@@ -1207,16 +1165,12 @@
"packet": "数据包",
"delayMs": "延迟 (ms)",
"applyTo": "应用于",
"addNoise": "+ 噪声",
"concurrency": "并发",
"xudpConcurrency": "xudp 并发",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "在已建立的数据流内传输多个独立的数据流",
"muxSett": "复用器设置",
"direct": "直接连接",
"directDesc": "直接与特定国家的域或 IP 范围建立连接",
"certs": "证书",
"dateAndTime": "日期和时间",
"proxyAndServer": "代理和服务器",
@@ -1226,7 +1180,6 @@
"language": "语言",
"security": {
"admin": "管理员凭据",
"twoFactorModalError": "验证码错误",
"show": "显示",
"hide": "隐藏",
"apiTokenNew": "新建令牌",
@@ -1248,9 +1201,6 @@
"getOutboundTrafficError": "获取出站流量错误",
"resetOutboundTrafficError": "重置出站流量错误"
},
"eventOutboundDown": "断开",
"eventOutboundUp": "恢复",
"eventXrayCrash": "崩溃",
"eventNodeDown": "离线",
"eventNodeUp": "上线",
"eventCPUHigh": "CPU 占用过高(%)",
@@ -1266,59 +1216,6 @@
},
"secretClear": "清除",
"secretClearUndo": "撤销清除",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP 限制白名单",
"ipLimitAllowlistDesc": "IP 限制永远不会计入也不会封禁的地址和网段,避免办公室或校园的共享地址耗尽客户端的限额。IP/CIDR(逗号分隔)。",
"subBalancers": {
"menu": "订阅均衡器",
"title": "订阅均衡器",
"add": "添加均衡器",
"desc": "每个启用的均衡器会作为额外配置加入 JSON 订阅,自动在所选入站的端点中选择最优节点(客户端配置中的 routing.balancers + burstObservatory)。",
"remark": "备注",
"remarkPlaceholder": "自动 · 最快",
"strategy": "策略",
"strategyLeastLoad": "最小负载",
"strategyLeastPing": "最低延迟",
"strategyRandom": "随机",
"strategyRoundRobin": "轮询",
"sortOrder": "顺序",
"sortOrderHelp": "在订阅列表中的位置,与入站顺序交错排列;序号相同时排在入站之后。",
"inbounds": "入站",
"inboundsCount": "{count} 入站",
"enabled": "启用",
"empty": "暂无均衡器",
"deleteConfirm": "确定删除此均衡器?",
"errRemarkRequired": "请填写备注",
"errInboundsRequired": "请至少选择一个入站",
"errSortOrder": "顺序必须为不小于 1 的整数",
"toasts": {
"list": "列出订阅均衡器失败",
"create": "创建订阅均衡器失败",
"update": "更新订阅均衡器失败",
"delete": "删除订阅均衡器失败",
"invalidId": "无效的 id"
},
"tabBalancers": "负载均衡",
"tabObservatory": "观测器",
"observatory": {
"title": "均衡器探活",
"desc": "写入每个 leastPing/leastLoad 均衡器配置的 burstObservatory 探活参数。random/roundRobin 不生成探活。作为面板级 JSON 订阅设置保存。",
"destination": "探活 URL",
"destinationDesc": "客户端探测每个成员出站的地址。",
"connectivity": "连通性 URL",
"connectivityDesc": "可选地址,检查成员能否到达探活目标。留空则跳过。",
"interval": "探活间隔",
"intervalDesc": "探活轮次之间的时间,例如 1m。",
"timeout": "探活超时",
"timeoutDesc": "单次探活超时,例如 5s。",
"sampling": "采样",
"samplingDesc": "用于稳定度平均的连续探活次数。",
"httpMethod": "HTTP 方法",
"httpMethodDesc": "探活请求使用的 HTTP 方法。",
"note": "leastPing/leastLoad 均衡器始终带有 burstObservatory。此开关自定义其探活参数 — 关闭以使用内置默认值。更改在面板重启后生效。"
}
},
"TGBotSettings": "Telegram 机器人",
"notifications": "通知",
"notifyTime": {
@@ -1662,12 +1559,8 @@
"SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功",
"FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 所有客户的流量重置已完成。",
"eventOutboundDown": "出站 {{ .Tag }} 已断开",
"eventOutboundUp": "出站 {{ .Tag }} 已恢复",
"eventErrorDetail": "错误:{{ .Error }}",
"eventDelayDetail": "延迟:{{ .Delay }} 毫秒",
"eventXrayCrash": "Xray 已崩溃",
"eventXrayCrashError": "错误:{{ .Error }}",
"eventNodeDown": "节点 {{ .Name }} 已离线",
"eventNodeUp": "节点 {{ .Name }} 已上线",
"eventLoginFallback": "来自 {{ .Source }} 的登录失败",
+1 -108
View File
@@ -70,7 +70,6 @@
"install": "安裝",
"clients": "客戶端",
"usage": "使用情況",
"twoFactorCode": "代碼",
"remained": "剩餘",
"security": "安全",
"somethingWentWrong": "發生錯誤",
@@ -103,8 +102,7 @@
"docs": "文件",
"openMenu": "開啟選單",
"pinSidebar": "固定側邊欄",
"unpinSidebar": "取消固定側邊欄",
"subFormats": "Sub Formats"
"unpinSidebar": "取消固定側邊欄"
},
"pages": {
"login": {
@@ -716,7 +714,6 @@
"detachFromInboundsResultMixed": "已分離 {detached},已略過 {skipped},錯誤 {errors}。",
"subLinksTitle": "訂閱連結 ({count})",
"subLinkColumn": "訂閱 URL",
"subJsonLinkColumn": "訂閱 JSON URL",
"subLinksCopyAll": "全部複製",
"subLinksCopiedAll": "已複製 {count} 條連結",
"subLinksEmpty": "選取的客戶端皆無訂閱 ID。",
@@ -1068,7 +1065,6 @@
"warnDefaultPort": "預設連接埠 2053 廣為人知 — 請更改為隨機連接埠。",
"warnDefaultBasePath": "預設根路徑 \"/\" 廣為人知 — 請更改為隨機路徑。",
"warnDefaultSubPath": "預設訂閱路徑 \"/sub/\" 廣為人知 — 請更改。",
"warnDefaultJsonPath": "預設 JSON 訂閱路徑 \"/json/\" 廣為人知 — 請更改。",
"panelListeningIP": "面板監聽 IP",
"panelListeningIPDesc": "預設留空監聽所有 IP",
"panelListeningDomain": "面板監聽域名",
@@ -1083,12 +1079,7 @@
"panelUrlPathDesc": "必須以 '/' 開頭,以 '/' 結尾",
"pageSize": "分頁大小",
"pageSizeDesc": "定義入站表的頁面大小。設定 0 表示禁用",
"panelOutbound": "面板流量出站",
"panelOutboundDesc": "透過此 Xray 出站路由面板自身的請求(面板/Xray 版本檢查與下載、Telegram、一般 geo 檔案更新),以繞過伺服器端對 GitHub/Telegram 的過濾。本地橋接入站會自動加入執行中的設定並即時生效。Xray 原生的 Geodata 自動更新不受影響,它有自己的下載出站。留空表示直連。",
"panelOutboundPh": "直連",
"datepicker": "日期選擇器",
"datepickerPlaceholder": "選擇日期",
"datepickerDescription": "選擇器日曆類型指定到期日期",
"oldUsername": "原使用者名稱",
"currentPassword": "原密碼",
"newUsername": "新使用者名稱",
@@ -1104,22 +1095,6 @@
"subSettings": "訂閱設定",
"subEnable": "啟用訂閱服務",
"subEnableDesc": "啟用訂閱服務功能",
"subJsonEnable": "獨立啟用/停用 JSON 訂閱端點。",
"subJsonEnableTitle": "JSON 訂閱",
"subClashEnableTitle": "Clash / Mihomo 訂閱",
"subFormatsTipTitle": "特定格式的訂閱設定",
"subFormatsTipDesc": "分別設定 JSON 和 Clash / Mihomo 的 URL 路徑、反向代理 URL 與用戶端自動識別。",
"subFormatsTipAction": "開啟訂閱格式",
"subJsonAutoDetect": "自動識別 Xray JSON 用戶端",
"subJsonAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別相容用戶端將自動取得 Xray JSON 設定陣列。其他用戶端繼續取得原始/Base64 回應。需要啟用 JSON 訂閱並重新啟動面板才能生效。",
"subJsonAlwaysArray": "始終回傳 JSON 陣列",
"subJsonAlwaysArrayDesc": "即使只有一個設定,也將明確 JSON 訂閱端點回傳為陣列,以符合 XTLS 訂閱標準。自動識別的 JSON 回應始終使用陣列。停用時保留舊版單一物件回應。",
"subJsonUserAgentRegex": "Xray JSON User-Agent 正規表示式",
"subJsonUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上自動選擇 Xray JSON 格式的 Go RE2 正規表示式。預設留空,因此在為要服務的用戶端設定規則之前,自動辨識會保持關閉。其他用戶端繼續取得原始/Base64 回應。變更後請重新啟動面板。",
"subClashAutoDetect": "自動識別 Clash/Mihomo 用戶端",
"subClashAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別 Clash/Mihomo 用戶端將自動取得 Clash YAML。瀏覽器仍會顯示訂閱頁面,其他用戶端繼續取得原始/Base64 回應,獨立的 JSON 和 Clash URL 仍可使用。需要啟用 Clash/Mihomo 訂閱並重新啟動面板才能生效。",
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
"subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
"subTitle": "訂閱標題",
"subTitleDesc": "在 VPN 客戶端中顯示的標題。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
"subSupportUrl": "支援連結",
@@ -1128,23 +1103,8 @@
"subProfileUrlDesc": "VPN 用戶端中顯示的網站連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
"subAnnounce": "公告",
"subAnnounceDesc": "VPN 用戶端中顯示的公告文字。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
"subThemeDir": "訂閱主題目錄",
"subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
"subThemeDirDocs": "範本指南 ↗",
"subEnableRouting": "啟用路由",
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
"subRoutingRules": "路由規則",
"subRoutingRulesDesc": "貼上現成的 happ:// 深層連結或一個固定 HTTPS URL。面板會在背景更新遠端規則並保留最後一個有效值,因此訂閱請求不需等待遠端來源。(僅限 Happ)",
"subHideSettings": "隱藏伺服器設定",
"subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
"subIncyEnableRouting": "啟用路由",
"subIncyEnableRoutingDesc": "為 Incy 用戶端將路由設定檔注入訂閱內容中。(僅限 Incy)",
"subIncyRoutingRules": "路由規則",
"subIncyRoutingRulesDesc": "貼上現成的 incy:// 深層連結或指向 JSON 的固定 HTTPS URL。Incy 會建立 autorouting 設定並自動更新。(僅限 Incy)",
"subClashEnableRouting": "啟用路由",
"subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
"subClashRoutingRules": "全域路由規則",
"subClashRoutingRulesDesc": "貼上規則/YAML 或一個固定 HTTPS URL。面板會在背景更新,只匯入代理群組、規則提供者與規則,並保留面板產生的 VPN 節點及最後一個有效值。",
"subListen": "監聽 IP",
"subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)",
"subPort": "監聽埠",
@@ -1173,8 +1133,6 @@
"trustedProxyCidrs": "信任代理 CIDR",
"trustedProxyCidrsDesc": "允許設定轉發 host、proto 與客戶端 IP 標頭的 IP/CIDR(逗號分隔)。",
"subFormats": {
"finalMask": "Final Mask",
"finalMaskDesc": "將 Xray finalmask TCP/UDP 遮罩和 QUIC 參數注入每個產生的 Xray JSON 設定。需要支援 Xray JSON 訂閱的用戶端應用程式和較新的 Xray 核心。",
"packets": "封包",
"length": "長度",
"interval": "間隔",
@@ -1185,16 +1143,12 @@
"packet": "封包",
"delayMs": "延遲 (ms)",
"applyTo": "套用至",
"addNoise": "+ 雜訊",
"concurrency": "並發",
"xudpConcurrency": "xudp 並發",
"xudpUdp443": "xudp UDP 443"
},
"mux": "Mux",
"muxDesc": "在已建立的資料流內傳輸多個獨立的資料流",
"muxSett": "複用器設定",
"direct": "直接連線",
"directDesc": "直接與特定國家的域或 IP 範圍建立連線",
"certs": "證書",
"dateAndTime": "日期和時間",
"proxyAndServer": "代理和伺服器",
@@ -1204,7 +1158,6 @@
"language": "語言",
"security": {
"admin": "管理員憑證",
"twoFactorModalError": "驗證碼錯誤",
"show": "顯示",
"hide": "隱藏",
"apiTokenNew": "新增令牌",
@@ -1226,9 +1179,6 @@
"getOutboundTrafficError": "取得出站流量錯誤",
"resetOutboundTrafficError": "重設出站流量錯誤"
},
"eventOutboundDown": "中斷",
"eventOutboundUp": "恢復",
"eventXrayCrash": "當機",
"eventNodeDown": "離線",
"eventNodeUp": "上線",
"eventCPUHigh": "CPU 偏高(%)",
@@ -1244,59 +1194,6 @@
},
"secretClear": "清除",
"secretClearUndo": "復原清除",
"calendarGregorian": "Gregorian (Standard)",
"calendarJalalian": "Jalalian (شمسی)",
"ipLimitAllowlist": "IP 限制白名單",
"ipLimitAllowlistDesc": "IP 限制永遠不會計入也不會封鎖的位址與網段,避免辦公室或校園的共用位址耗盡客戶端的額度。IP/CIDR(逗號分隔)。",
"subBalancers": {
"menu": "訂閱平衡器",
"title": "訂閱平衡器",
"add": "新增平衡器",
"desc": "每個啟用的平衡器會作為額外設定加入 JSON 訂閱,自動從所選入站的端點中挑選最佳節點(用戶端設定中的 routing.balancers + burstObservatory)。",
"remark": "備註",
"remarkPlaceholder": "自動 · 最快",
"strategy": "策略",
"strategyLeastLoad": "最小負載",
"strategyLeastPing": "最低延遲",
"strategyRandom": "隨機",
"strategyRoundRobin": "輪詢",
"sortOrder": "順序",
"sortOrderHelp": "在訂閱列表中的位置,與入站順序交錯排列;序號相同時排在入站之後。",
"inbounds": "入站",
"inboundsCount": "{count} 入站",
"enabled": "啟用",
"empty": "尚無平衡器",
"deleteConfirm": "確定刪除此平衡器?",
"errRemarkRequired": "請填寫備註",
"errInboundsRequired": "請至少選擇一個入站",
"errSortOrder": "順序必須為不小於 1 的整數",
"toasts": {
"list": "列出訂閱平衡器失敗",
"create": "建立訂閱平衡器失敗",
"update": "更新訂閱平衡器失敗",
"delete": "刪除訂閱平衡器失敗",
"invalidId": "無效的 id"
},
"tabBalancers": "負載均衡",
"tabObservatory": "觀測器",
"observatory": {
"title": "平衡器探活",
"desc": "寫入每個 leastPing/leastLoad 平衡器設定檔的 burstObservatory 探活參數。random/roundRobin 不產生探活。以面板級 JSON 訂閱設定儲存。",
"destination": "探活 URL",
"destinationDesc": "用戶端探測每個成員出站的位址。",
"connectivity": "連通性 URL",
"connectivityDesc": "選用位址,檢查成員能否到達探活目標。留空則跳過。",
"interval": "探活間隔",
"intervalDesc": "探活輪次之間的時間,例如 1m。",
"timeout": "探活逾時",
"timeoutDesc": "單次探活逾時,例如 5s。",
"sampling": "取樣",
"samplingDesc": "用於穩定度平均的連續探活次數。",
"httpMethod": "HTTP 方法",
"httpMethodDesc": "探活請求使用的 HTTP 方法。",
"note": "leastPing/leastLoad 平衡器始終帶有 burstObservatory。此開關自訂其探活參數 — 關閉以使用內建預設值。變更在面板重啟後生效。"
}
},
"TGBotSettings": "Telegram 機器人",
"notifications": "通知",
"notifyTime": {
@@ -1640,12 +1537,8 @@
"SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
"FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 所有客戶的流量重置已完成。",
"eventOutboundDown": "出站 {{ .Tag }} 已中斷",
"eventOutboundUp": "出站 {{ .Tag }} 已恢復",
"eventErrorDetail": "錯誤:{{ .Error }}",
"eventDelayDetail": "延遲:{{ .Delay }} 毫秒",
"eventXrayCrash": "Xray 已當機",
"eventXrayCrashError": "錯誤:{{ .Error }}",
"eventNodeDown": "節點 {{ .Name }} 已離線",
"eventNodeUp": "節點 {{ .Name }} 已上線",
"eventLoginFallback": "來自 {{ .Source }} 的登入失敗",