diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ee8ea65..228c1625 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: cd x-ui/bin # Download dependencies - Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.17/" + Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/" if [ "${{ matrix.platform }}" == "amd64" ]; then wget -q ${Xray_URL}Xray-linux-64.zip unzip Xray-linux-64.zip @@ -244,7 +244,7 @@ jobs: cd x-ui\bin # Download Xray for Windows - $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/" + $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/" Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip" Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath . Remove-Item "Xray-windows-64.zip" diff --git a/DockerInit.sh b/DockerInit.sh index 7c31f06b..97f8c302 100755 --- a/DockerInit.sh +++ b/DockerInit.sh @@ -27,7 +27,7 @@ case $1 in esac mkdir -p build/bin cd build/bin -curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/Xray-linux-${ARCH}.zip" +curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip" unzip "Xray-linux-${ARCH}.zip" rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat mv xray "xray-linux-${FNAME}" diff --git a/config/version b/config/version index 391e9856..eafef0d4 100644 --- a/config/version +++ b/config/version @@ -1 +1 @@ -2.9.2 \ No newline at end of file +2.9.3 \ No newline at end of file diff --git a/go.mod b/go.mod index 331261f8..406a86a8 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( ) require ( - github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect github.com/bytedance/gopkg v0.1.4 // indirect @@ -72,7 +72,7 @@ require ( github.com/quic-go/quic-go v0.59.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect - github.com/sagernet/sing v0.8.8 // indirect + github.com/sagernet/sing v0.8.9 // indirect github.com/sagernet/sing-shadowsocks v0.2.9 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect @@ -95,7 +95,7 @@ require ( golang.org/x/tools v0.44.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/protobuf v1.36.11 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect diff --git a/go.sum b/go.sum index 45931b83..acee05e0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A= -github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= +github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= +github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= @@ -156,8 +156,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/sagernet/sing v0.8.8 h1:1dRlGJ3wm4d2nwjKI1R/dr/7GKDKgUvXyD4OAWlQyt8= -github.com/sagernet/sing v0.8.8/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= +github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU= +github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM= github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= @@ -256,8 +256,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/main.go b/main.go index f8d3357b..20724169 100644 --- a/main.go +++ b/main.go @@ -130,20 +130,22 @@ func runWebServer() { } // resetSetting resets all panel settings to their default values. -func resetSetting() { +func resetSetting() error { err := database.InitDB(config.GetDBPath()) if err != nil { fmt.Println("Failed to initialize database:", err) - return + return err } settingService := service.SettingService{} err = settingService.ResetSettings() if err != nil { fmt.Println("Failed to reset settings:", err) + return err } else { fmt.Println("Settings successfully reset.") } + return nil } // showSetting displays the current panel settings if show is true. @@ -255,11 +257,11 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri } // updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication. -func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) { +func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error { err := database.InitDB(config.GetDBPath()) if err != nil { fmt.Println("Database initialization failed:", err) - return + return err } settingService := service.SettingService{} @@ -311,6 +313,8 @@ func updateSetting(port int, username string, password string, webBasePath strin fmt.Printf("listen %v set successfully", listenIP) } } + + return nil } // updateCert updates the SSL certificate files for the panel. @@ -481,9 +485,13 @@ func main() { return } if reset { - resetSetting() + if err = resetSetting(); err != nil { + return + } } else { - updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) + if err = updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor); err != nil { + return + } } if show { showSetting(show) diff --git a/sub/subService.go b/sub/subService.go index f281cef3..e277c152 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -3,8 +3,10 @@ package sub import ( "encoding/base64" "fmt" + "maps" "net" "net/url" + "slices" "strings" "time" @@ -243,186 +245,54 @@ func (s *SubService) getLink(inbound *model.Inbound, email string) string { return "" } +// Protocol link generators are intentionally ordered as: +// vmess -> vless -> trojan -> shadowsocks -> hysteria. func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.VMESS { return "" } - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } + address := s.resolveInboundAddress(inbound) obj := map[string]any{ "v": "2", "add": address, "port": inbound.Port, "type": "none", } - var stream map[string]any - json.Unmarshal([]byte(inbound.StreamSettings), &stream) + stream := unmarshalStreamSettings(inbound.StreamSettings) network, _ := stream["network"].(string) - obj["net"] = network - switch network { - case "tcp": - tcp, _ := stream["tcpSettings"].(map[string]any) - header, _ := tcp["header"].(map[string]any) - typeStr, _ := header["type"].(string) - obj["type"] = typeStr - if typeStr == "http" { - request := header["request"].(map[string]any) - requestPath, _ := request["path"].([]any) - obj["path"] = requestPath[0].(string) - headers, _ := request["headers"].(map[string]any) - obj["host"] = searchHost(headers) - } - case "kcp": - kcp, _ := stream["kcpSettings"].(map[string]any) - header, _ := kcp["header"].(map[string]any) - obj["type"], _ = header["type"].(string) - obj["path"], _ = kcp["seed"].(string) - case "ws": - ws, _ := stream["wsSettings"].(map[string]any) - obj["path"] = ws["path"].(string) - if host, ok := ws["host"].(string); ok && len(host) > 0 { - obj["host"] = host - } else { - headers, _ := ws["headers"].(map[string]any) - obj["host"] = searchHost(headers) - } - case "grpc": - grpc, _ := stream["grpcSettings"].(map[string]any) - obj["path"] = grpc["serviceName"].(string) - obj["authority"] = grpc["authority"].(string) - if grpc["multiMode"].(bool) { - obj["type"] = "multi" - } - case "httpupgrade": - httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) - obj["path"] = httpupgrade["path"].(string) - if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { - obj["host"] = host - } else { - headers, _ := httpupgrade["headers"].(map[string]any) - obj["host"] = searchHost(headers) - } - case "xhttp": - xhttp, _ := stream["xhttpSettings"].(map[string]any) - obj["path"] = xhttp["path"].(string) - if host, ok := xhttp["host"].(string); ok && len(host) > 0 { - obj["host"] = host - } else { - headers, _ := xhttp["headers"].(map[string]any) - obj["host"] = searchHost(headers) - } - obj["mode"], _ = xhttp["mode"].(string) - // VMess base64 JSON supports arbitrary keys; copy the padding - // settings through so clients can match the server's xhttp - // xPaddingBytes range and, when the admin opted into obfs - // mode, the custom key / header / placement / method. - if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 { - obj["x_padding_bytes"] = xpb - } - if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs { - obj["xPaddingObfsMode"] = true - for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} { - if v, ok := xhttp[field].(string); ok && len(v) > 0 { - obj[field] = v - } - } - } + applyVmessNetworkParams(stream, network, obj) + if finalmask, ok := stream["finalmask"].(map[string]any); ok { + applyFinalMaskObj(finalmask, obj) } security, _ := stream["security"].(string) obj["tls"] = security if security == "tls" { - tlsSetting, _ := stream["tlsSettings"].(map[string]any) - alpns, _ := tlsSetting["alpn"].([]any) - if len(alpns) > 0 { - var alpn []string - for _, a := range alpns { - alpn = append(alpn, a.(string)) - } - obj["alpn"] = strings.Join(alpn, ",") - } - if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - obj["sni"], _ = sniValue.(string) - } - - tlsSettings, _ := searchKey(tlsSetting, "settings") - if tlsSetting != nil { - if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - obj["fp"], _ = fpValue.(string) - } - } + applyVmessTLSParams(stream, obj) } clients, _ := s.inboundService.GetClients(inbound) - clientIndex := -1 - for i, client := range clients { - if client.Email == email { - clientIndex = i - break - } - } + clientIndex := findClientIndex(clients, email) obj["id"] = clients[clientIndex].ID obj["scy"] = clients[clientIndex].Security externalProxies, _ := stream["externalProxy"].([]any) if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { - ep, _ := externalProxy.(map[string]any) - newSecurity, _ := ep["forceTls"].(string) - newObj := map[string]any{} - for key, value := range obj { - if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) { - newObj[key] = value - } - } - newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) - newObj["add"] = ep["dest"].(string) - newObj["port"] = int(ep["port"].(float64)) - - if newSecurity != "same" { - newObj["tls"] = newSecurity - } - if index > 0 { - links += "\n" - } - jsonStr, _ := json.MarshalIndent(newObj, "", " ") - links += "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) - } - return links + return s.buildVmessExternalProxyLinks(externalProxies, obj, inbound, email) } obj["ps"] = s.genRemark(inbound, email, "") - - jsonStr, _ := json.MarshalIndent(obj, "", " ") - return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) + return buildVmessLink(obj) } func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } - if inbound.Protocol != model.VLESS { return "" } - var stream map[string]any - json.Unmarshal([]byte(inbound.StreamSettings), &stream) + address := s.resolveInboundAddress(inbound) + stream := unmarshalStreamSettings(inbound.StreamSettings) clients, _ := s.inboundService.GetClients(inbound) - clientIndex := -1 - for i, client := range clients { - if client.Email == email { - clientIndex = i - break - } - } + clientIndex := findClientIndex(clients, email) uuid := clients[clientIndex].ID port := inbound.Port streamNetwork := stream["network"].(string) @@ -436,481 +306,122 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params["encryption"] = encryption } - switch streamNetwork { - case "tcp": - tcp, _ := stream["tcpSettings"].(map[string]any) - header, _ := tcp["header"].(map[string]any) - typeStr, _ := header["type"].(string) - if typeStr == "http" { - request := header["request"].(map[string]any) - requestPath, _ := request["path"].([]any) - params["path"] = requestPath[0].(string) - headers, _ := request["headers"].(map[string]any) - params["host"] = searchHost(headers) - params["headerType"] = "http" - } - case "kcp": - kcp, _ := stream["kcpSettings"].(map[string]any) - header, _ := kcp["header"].(map[string]any) - params["headerType"] = header["type"].(string) - params["seed"] = kcp["seed"].(string) - case "ws": - ws, _ := stream["wsSettings"].(map[string]any) - params["path"] = ws["path"].(string) - if host, ok := ws["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := ws["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - case "grpc": - grpc, _ := stream["grpcSettings"].(map[string]any) - params["serviceName"] = grpc["serviceName"].(string) - params["authority"], _ = grpc["authority"].(string) - if grpc["multiMode"].(bool) { - params["mode"] = "multi" - } - case "httpupgrade": - httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) - params["path"] = httpupgrade["path"].(string) - if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := httpupgrade["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - case "xhttp": - xhttp, _ := stream["xhttpSettings"].(map[string]any) - params["path"] = xhttp["path"].(string) - if host, ok := xhttp["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := xhttp["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - params["mode"], _ = xhttp["mode"].(string) - applyXhttpPaddingParams(xhttp, params) + applyShareNetworkParams(stream, streamNetwork, params) + if finalmask, ok := stream["finalmask"].(map[string]any); ok { + applyFinalMaskParams(finalmask, params) } security, _ := stream["security"].(string) - if security == "tls" { - params["security"] = "tls" - tlsSetting, _ := stream["tlsSettings"].(map[string]any) - alpns, _ := tlsSetting["alpn"].([]any) - var alpn []string - for _, a := range alpns { - alpn = append(alpn, a.(string)) - } - if len(alpn) > 0 { - params["alpn"] = strings.Join(alpn, ",") - } - if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - params["sni"], _ = sniValue.(string) - } - - tlsSettings, _ := searchKey(tlsSetting, "settings") - if tlsSetting != nil { - if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - params["fp"], _ = fpValue.(string) - } - } - + switch security { + case "tls": + applyShareTLSParams(stream, params) if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 { params["flow"] = clients[clientIndex].Flow } - } - - if security == "reality" { - params["security"] = "reality" - realitySetting, _ := stream["realitySettings"].(map[string]any) - realitySettings, _ := searchKey(realitySetting, "settings") - if realitySetting != nil { - if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { - sNames, _ := sniValue.([]any) - params["sni"] = sNames[random.Num(len(sNames))].(string) - } - if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { - params["pbk"], _ = pbkValue.(string) - } - if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { - shortIds, _ := sidValue.([]any) - params["sid"] = shortIds[random.Num(len(shortIds))].(string) - } - if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { - if fp, ok := fpValue.(string); ok && len(fp) > 0 { - params["fp"] = fp - } - } - if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { - if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { - params["pqv"] = pqv - } - } - params["spx"] = "/" + random.Seq(15) - } - + case "reality": + applyShareRealityParams(stream, params) if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 { params["flow"] = clients[clientIndex].Flow } - } - - if security != "tls" && security != "reality" { + default: params["security"] = "none" } externalProxies, _ := stream["externalProxy"].([]any) if len(externalProxies) > 0 { - links := make([]string, 0, len(externalProxies)) - for _, externalProxy := range externalProxies { - ep, _ := externalProxy.(map[string]any) - newSecurity, _ := ep["forceTls"].(string) - dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) - - if newSecurity != "same" { - params["security"] = newSecurity - } else { - params["security"] = security - } - url, _ := url.Parse(link) - q := url.Query() - - for k, v := range params { - if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) { - q.Add(k, v) - } - } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - - links = append(links, url.String()) - } - return strings.Join(links, "\n") + return s.buildExternalProxyURLLinks( + externalProxies, + params, + security, + func(dest string, port int) string { + return fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) + }, + func(ep map[string]any) string { + return s.genRemark(inbound, email, ep["remark"].(string)) + }, + ) } link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port) - url, _ := url.Parse(link) - q := url.Query() - - for k, v := range params { - q.Add(k, v) - } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } if inbound.Protocol != model.Trojan { return "" } - var stream map[string]any - json.Unmarshal([]byte(inbound.StreamSettings), &stream) + address := s.resolveInboundAddress(inbound) + stream := unmarshalStreamSettings(inbound.StreamSettings) clients, _ := s.inboundService.GetClients(inbound) - clientIndex := -1 - for i, client := range clients { - if client.Email == email { - clientIndex = i - break - } - } + clientIndex := findClientIndex(clients, email) password := clients[clientIndex].Password port := inbound.Port streamNetwork := stream["network"].(string) params := make(map[string]string) params["type"] = streamNetwork - switch streamNetwork { - case "tcp": - tcp, _ := stream["tcpSettings"].(map[string]any) - header, _ := tcp["header"].(map[string]any) - typeStr, _ := header["type"].(string) - if typeStr == "http" { - request := header["request"].(map[string]any) - requestPath, _ := request["path"].([]any) - params["path"] = requestPath[0].(string) - headers, _ := request["headers"].(map[string]any) - params["host"] = searchHost(headers) - params["headerType"] = "http" - } - case "kcp": - kcp, _ := stream["kcpSettings"].(map[string]any) - header, _ := kcp["header"].(map[string]any) - params["headerType"] = header["type"].(string) - params["seed"] = kcp["seed"].(string) - case "ws": - ws, _ := stream["wsSettings"].(map[string]any) - params["path"] = ws["path"].(string) - if host, ok := ws["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := ws["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - case "grpc": - grpc, _ := stream["grpcSettings"].(map[string]any) - params["serviceName"] = grpc["serviceName"].(string) - params["authority"], _ = grpc["authority"].(string) - if grpc["multiMode"].(bool) { - params["mode"] = "multi" - } - case "httpupgrade": - httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) - params["path"] = httpupgrade["path"].(string) - if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := httpupgrade["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - case "xhttp": - xhttp, _ := stream["xhttpSettings"].(map[string]any) - params["path"] = xhttp["path"].(string) - if host, ok := xhttp["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := xhttp["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - params["mode"], _ = xhttp["mode"].(string) - applyXhttpPaddingParams(xhttp, params) + applyShareNetworkParams(stream, streamNetwork, params) + if finalmask, ok := stream["finalmask"].(map[string]any); ok { + applyFinalMaskParams(finalmask, params) } security, _ := stream["security"].(string) - if security == "tls" { - params["security"] = "tls" - tlsSetting, _ := stream["tlsSettings"].(map[string]any) - alpns, _ := tlsSetting["alpn"].([]any) - var alpn []string - for _, a := range alpns { - alpn = append(alpn, a.(string)) - } - if len(alpn) > 0 { - params["alpn"] = strings.Join(alpn, ",") - } - if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - params["sni"], _ = sniValue.(string) - } - - tlsSettings, _ := searchKey(tlsSetting, "settings") - if tlsSetting != nil { - if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - params["fp"], _ = fpValue.(string) - } - } - } - - if security == "reality" { - params["security"] = "reality" - realitySetting, _ := stream["realitySettings"].(map[string]any) - realitySettings, _ := searchKey(realitySetting, "settings") - if realitySetting != nil { - if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { - sNames, _ := sniValue.([]any) - params["sni"] = sNames[random.Num(len(sNames))].(string) - } - if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { - params["pbk"], _ = pbkValue.(string) - } - if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { - shortIds, _ := sidValue.([]any) - params["sid"] = shortIds[random.Num(len(shortIds))].(string) - } - if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { - if fp, ok := fpValue.(string); ok && len(fp) > 0 { - params["fp"] = fp - } - } - if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { - if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { - params["pqv"] = pqv - } - } - params["spx"] = "/" + random.Seq(15) - } - + switch security { + case "tls": + applyShareTLSParams(stream, params) + case "reality": + applyShareRealityParams(stream, params) if streamNetwork == "tcp" && len(clients[clientIndex].Flow) > 0 { params["flow"] = clients[clientIndex].Flow } - } - - if security != "tls" && security != "reality" { + default: params["security"] = "none" } externalProxies, _ := stream["externalProxy"].([]any) if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { - ep, _ := externalProxy.(map[string]any) - newSecurity, _ := ep["forceTls"].(string) - dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) - - if newSecurity != "same" { - params["security"] = newSecurity - } else { - params["security"] = security - } - url, _ := url.Parse(link) - q := url.Query() - - for k, v := range params { - if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) { - q.Add(k, v) - } - } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - - if index > 0 { - links += "\n" - } - links += url.String() - } - return links + return s.buildExternalProxyURLLinks( + externalProxies, + params, + security, + func(dest string, port int) string { + return fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) + }, + func(ep map[string]any) string { + return s.genRemark(inbound, email, ep["remark"].(string)) + }, + ) } link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port) - - url, _ := url.Parse(link) - q := url.Query() - - for k, v := range params { - q.Add(k, v) - } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { - var address string - if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { - address = s.address - } else { - address = inbound.Listen - } if inbound.Protocol != model.Shadowsocks { return "" } - var stream map[string]any - json.Unmarshal([]byte(inbound.StreamSettings), &stream) + address := s.resolveInboundAddress(inbound) + stream := unmarshalStreamSettings(inbound.StreamSettings) clients, _ := s.inboundService.GetClients(inbound) var settings map[string]any json.Unmarshal([]byte(inbound.Settings), &settings) inboundPassword := settings["password"].(string) method := settings["method"].(string) - clientIndex := -1 - for i, client := range clients { - if client.Email == email { - clientIndex = i - break - } - } + clientIndex := findClientIndex(clients, email) streamNetwork := stream["network"].(string) params := make(map[string]string) params["type"] = streamNetwork - switch streamNetwork { - case "tcp": - tcp, _ := stream["tcpSettings"].(map[string]any) - header, _ := tcp["header"].(map[string]any) - typeStr, _ := header["type"].(string) - if typeStr == "http" { - request := header["request"].(map[string]any) - requestPath, _ := request["path"].([]any) - params["path"] = requestPath[0].(string) - headers, _ := request["headers"].(map[string]any) - params["host"] = searchHost(headers) - params["headerType"] = "http" - } - case "kcp": - kcp, _ := stream["kcpSettings"].(map[string]any) - header, _ := kcp["header"].(map[string]any) - params["headerType"] = header["type"].(string) - params["seed"] = kcp["seed"].(string) - case "ws": - ws, _ := stream["wsSettings"].(map[string]any) - params["path"] = ws["path"].(string) - if host, ok := ws["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := ws["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - case "grpc": - grpc, _ := stream["grpcSettings"].(map[string]any) - params["serviceName"] = grpc["serviceName"].(string) - params["authority"], _ = grpc["authority"].(string) - if grpc["multiMode"].(bool) { - params["mode"] = "multi" - } - case "httpupgrade": - httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) - params["path"] = httpupgrade["path"].(string) - if host, ok := httpupgrade["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := httpupgrade["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - case "xhttp": - xhttp, _ := stream["xhttpSettings"].(map[string]any) - params["path"] = xhttp["path"].(string) - if host, ok := xhttp["host"].(string); ok && len(host) > 0 { - params["host"] = host - } else { - headers, _ := xhttp["headers"].(map[string]any) - params["host"] = searchHost(headers) - } - params["mode"], _ = xhttp["mode"].(string) - applyXhttpPaddingParams(xhttp, params) + applyShareNetworkParams(stream, streamNetwork, params) + if finalmask, ok := stream["finalmask"].(map[string]any); ok { + applyFinalMaskParams(finalmask, params) } security, _ := stream["security"].(string) if security == "tls" { - params["security"] = "tls" - tlsSetting, _ := stream["tlsSettings"].(map[string]any) - alpns, _ := tlsSetting["alpn"].([]any) - var alpn []string - for _, a := range alpns { - alpn = append(alpn, a.(string)) - } - if len(alpn) > 0 { - params["alpn"] = strings.Join(alpn, ",") - } - if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { - params["sni"], _ = sniValue.(string) - } - - tlsSettings, _ := searchKey(tlsSetting, "settings") - if tlsSetting != nil { - if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { - params["fp"], _ = fpValue.(string) - } - } + applyShareTLSParams(stream, params) } encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password) @@ -921,61 +432,30 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st externalProxies, _ := stream["externalProxy"].([]any) if len(externalProxies) > 0 { - links := "" - for index, externalProxy := range externalProxies { - ep, _ := externalProxy.(map[string]any) - newSecurity, _ := ep["forceTls"].(string) - dest, _ := ep["dest"].(string) - port := int(ep["port"].(float64)) - link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) - - if newSecurity != "same" { - params["security"] = newSecurity - } else { - params["security"] = security - } - url, _ := url.Parse(link) - q := url.Query() - - for k, v := range params { - if !(newSecurity == "none" && (k == "alpn" || k == "sni" || k == "fp")) { - q.Add(k, v) - } - } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) - - if index > 0 { - links += "\n" - } - links += url.String() - } - return links + proxyParams := cloneStringMap(params) + proxyParams["security"] = security + return s.buildExternalProxyURLLinks( + externalProxies, + proxyParams, + security, + func(dest string, port int) string { + return fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), dest, port) + }, + func(ep map[string]any) string { + return s.genRemark(inbound, email, ep["remark"].(string)) + }, + ) } link := fmt.Sprintf("ss://%s@%s:%d", base64.StdEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) - url, _ := url.Parse(link) - q := url.Query() - - for k, v := range params { - q.Add(k, v) - } - - // Set the new query values on the URL - url.RawQuery = q.Encode() - - url.Fragment = s.genRemark(inbound, email, "") - return url.String() + return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string { if !model.IsHysteria(inbound.Protocol) { return "" } - var stream map[string]interface{} + var stream map[string]any json.Unmarshal([]byte(inbound.StreamSettings), &stream) clients, _ := s.inboundService.GetClients(inbound) clientIndex := -1 @@ -989,8 +469,8 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin params := make(map[string]string) params["security"] = "tls" - tlsSetting, _ := stream["tlsSettings"].(map[string]interface{}) - alpns, _ := tlsSetting["alpn"].([]interface{}) + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) var alpn []string for _, a := range alpns { alpn = append(alpn, a.(string)) @@ -1017,14 +497,15 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin // salamander obfs (Hysteria2). The panel-side link generator already // emits these; keep the subscription output in sync so a client has // the obfs password to match the server. - if finalmask, ok := stream["finalmask"].(map[string]interface{}); ok { - if udpMasks, ok := finalmask["udp"].([]interface{}); ok { + if finalmask, ok := stream["finalmask"].(map[string]any); ok { + applyFinalMaskParams(finalmask, params) + if udpMasks, ok := finalmask["udp"].([]any); ok { for _, m := range udpMasks { - mask, _ := m.(map[string]interface{}) + mask, _ := m.(map[string]any) if mask == nil || mask["type"] != "salamander" { continue } - settings, _ := mask["settings"].(map[string]interface{}) + settings, _ := mask["settings"].(map[string]any) if pw, ok := settings["password"].(string); ok && pw != "" { params["obfs"] = "salamander" params["obfs-password"] = pw @@ -1034,7 +515,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin } } - var settings map[string]interface{} + var settings map[string]any json.Unmarshal([]byte(inbound.Settings), &settings) version, _ := settings["version"].(float64) protocol := "hysteria2" @@ -1047,11 +528,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin // server's own IP/port even when the admin configured an alternate // endpoint (e.g. a CDN hostname + port that forwards to the node). // Matches the behaviour of genVlessLink / genTrojanLink / …. - externalProxies, _ := stream["externalProxy"].([]interface{}) + externalProxies, _ := stream["externalProxy"].([]any) if len(externalProxies) > 0 { links := make([]string, 0, len(externalProxies)) for _, externalProxy := range externalProxies { - ep, ok := externalProxy.(map[string]interface{}) + ep, ok := externalProxy.(map[string]any) if !ok { continue } @@ -1087,6 +568,319 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin return url.String() } +func (s *SubService) resolveInboundAddress(inbound *model.Inbound) string { + if inbound.Listen == "" || inbound.Listen == "0.0.0.0" || inbound.Listen == "::" || inbound.Listen == "::0" { + return s.address + } + return inbound.Listen +} + +func findClientIndex(clients []model.Client, email string) int { + for i, client := range clients { + if client.Email == email { + return i + } + } + return -1 +} + +func unmarshalStreamSettings(streamSettings string) map[string]any { + var stream map[string]any + json.Unmarshal([]byte(streamSettings), &stream) + return stream +} + +func applyPathAndHostParams(settings map[string]any, params map[string]string) { + params["path"] = settings["path"].(string) + if host, ok := settings["host"].(string); ok && len(host) > 0 { + params["host"] = host + } else { + headers, _ := settings["headers"].(map[string]any) + params["host"] = searchHost(headers) + } +} + +func applyPathAndHostObj(settings map[string]any, obj map[string]any) { + obj["path"] = settings["path"].(string) + if host, ok := settings["host"].(string); ok && len(host) > 0 { + obj["host"] = host + } else { + headers, _ := settings["headers"].(map[string]any) + obj["host"] = searchHost(headers) + } +} + +func applyShareNetworkParams(stream map[string]any, streamNetwork string, params map[string]string) { + switch streamNetwork { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + params["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + params["host"] = searchHost(headers) + params["headerType"] = "http" + } + case "kcp": + applyKcpShareParams(stream, params) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + applyPathAndHostParams(ws, params) + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + params["serviceName"] = grpc["serviceName"].(string) + params["authority"], _ = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + params["mode"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + applyPathAndHostParams(httpupgrade, params) + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + applyPathAndHostParams(xhttp, params) + params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) + } +} + +func applyXhttpPaddingObj(xhttp map[string]any, obj map[string]any) { + // VMess base64 JSON supports arbitrary keys; copy the padding + // settings through so clients can match the server's xhttp + // xPaddingBytes range and, when the admin opted into obfs + // mode, the custom key / header / placement / method. + if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 { + obj["x_padding_bytes"] = xpb + } + if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs { + obj["xPaddingObfsMode"] = true + for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} { + if v, ok := xhttp[field].(string); ok && len(v) > 0 { + obj[field] = v + } + } + } +} + +func applyVmessNetworkParams(stream map[string]any, network string, obj map[string]any) { + obj["net"] = network + switch network { + case "tcp": + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + typeStr, _ := header["type"].(string) + obj["type"] = typeStr + if typeStr == "http" { + request := header["request"].(map[string]any) + requestPath, _ := request["path"].([]any) + obj["path"] = requestPath[0].(string) + headers, _ := request["headers"].(map[string]any) + obj["host"] = searchHost(headers) + } + case "kcp": + applyKcpShareObj(stream, obj) + case "ws": + ws, _ := stream["wsSettings"].(map[string]any) + applyPathAndHostObj(ws, obj) + case "grpc": + grpc, _ := stream["grpcSettings"].(map[string]any) + obj["path"] = grpc["serviceName"].(string) + obj["authority"] = grpc["authority"].(string) + if grpc["multiMode"].(bool) { + obj["type"] = "multi" + } + case "httpupgrade": + httpupgrade, _ := stream["httpupgradeSettings"].(map[string]any) + applyPathAndHostObj(httpupgrade, obj) + case "xhttp": + xhttp, _ := stream["xhttpSettings"].(map[string]any) + applyPathAndHostObj(xhttp, obj) + obj["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingObj(xhttp, obj) + } +} + +func applyShareTLSParams(stream map[string]any, params map[string]string) { + params["security"] = "tls" + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + if len(alpn) > 0 { + params["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + params["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + params["fp"], _ = fpValue.(string) + } + } +} + +func applyVmessTLSParams(stream map[string]any, obj map[string]any) { + tlsSetting, _ := stream["tlsSettings"].(map[string]any) + alpns, _ := tlsSetting["alpn"].([]any) + if len(alpns) > 0 { + var alpn []string + for _, a := range alpns { + alpn = append(alpn, a.(string)) + } + obj["alpn"] = strings.Join(alpn, ",") + } + if sniValue, ok := searchKey(tlsSetting, "serverName"); ok { + obj["sni"], _ = sniValue.(string) + } + + tlsSettings, _ := searchKey(tlsSetting, "settings") + if tlsSetting != nil { + if fpValue, ok := searchKey(tlsSettings, "fingerprint"); ok { + obj["fp"], _ = fpValue.(string) + } + } +} + +func applyShareRealityParams(stream map[string]any, params map[string]string) { + params["security"] = "reality" + realitySetting, _ := stream["realitySettings"].(map[string]any) + realitySettings, _ := searchKey(realitySetting, "settings") + if realitySetting != nil { + if sniValue, ok := searchKey(realitySetting, "serverNames"); ok { + sNames, _ := sniValue.([]any) + params["sni"] = sNames[random.Num(len(sNames))].(string) + } + if pbkValue, ok := searchKey(realitySettings, "publicKey"); ok { + params["pbk"], _ = pbkValue.(string) + } + if sidValue, ok := searchKey(realitySetting, "shortIds"); ok { + shortIds, _ := sidValue.([]any) + params["sid"] = shortIds[random.Num(len(shortIds))].(string) + } + if fpValue, ok := searchKey(realitySettings, "fingerprint"); ok { + if fp, ok := fpValue.(string); ok && len(fp) > 0 { + params["fp"] = fp + } + } + if pqvValue, ok := searchKey(realitySettings, "mldsa65Verify"); ok { + if pqv, ok := pqvValue.(string); ok && len(pqv) > 0 { + params["pqv"] = pqv + } + } + params["spx"] = "/" + random.Seq(15) + } +} + +func buildVmessLink(obj map[string]any) string { + jsonStr, _ := json.MarshalIndent(obj, "", " ") + return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) +} + +func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any { + newObj := map[string]any{} + for key, value := range baseObj { + if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) { + newObj[key] = value + } + } + return newObj +} + +func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string { + var links strings.Builder + for index, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + newObj := cloneVmessShareObj(baseObj, newSecurity) + newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) + newObj["add"] = ep["dest"].(string) + newObj["port"] = int(ep["port"].(float64)) + + if newSecurity != "same" { + newObj["tls"] = newSecurity + } + if index > 0 { + links.WriteString("\n") + } + links.WriteString(buildVmessLink(newObj)) + } + return links.String() +} + +func buildLinkWithParams(link string, params map[string]string, fragment string) string { + parsedURL, _ := url.Parse(link) + q := parsedURL.Query() + for k, v := range params { + q.Add(k, v) + } + parsedURL.RawQuery = q.Encode() + parsedURL.Fragment = fragment + return parsedURL.String() +} + +func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string { + parsedURL, _ := url.Parse(link) + q := parsedURL.Query() + for k, v := range params { + if k == "security" { + v = security + } + if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") { + continue + } + q.Add(k, v) + } + parsedURL.RawQuery = q.Encode() + parsedURL.Fragment = fragment + return parsedURL.String() +} + +func (s *SubService) buildExternalProxyURLLinks( + externalProxies []any, + params map[string]string, + baseSecurity string, + makeLink func(dest string, port int) string, + makeRemark func(ep map[string]any) string, +) string { + links := make([]string, 0, len(externalProxies)) + for _, externalProxy := range externalProxies { + ep, _ := externalProxy.(map[string]any) + newSecurity, _ := ep["forceTls"].(string) + dest, _ := ep["dest"].(string) + port := int(ep["port"].(float64)) + + securityToApply := baseSecurity + if newSecurity != "same" { + securityToApply = newSecurity + } + + links = append( + links, + buildLinkWithParamsAndSecurity( + makeLink(dest, port), + params, + makeRemark(ep), + securityToApply, + newSecurity == "none", + ), + ) + } + return strings.Join(links, "\n") +} + +func cloneStringMap(source map[string]string) map[string]string { + cloned := make(map[string]string, len(source)) + maps.Copy(cloned, source) + return cloned +} + func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { separationChar := string(s.remarkModel[0]) orderChars := s.remarkModel[1:] @@ -1246,6 +1040,307 @@ func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) { } } +var kcpMaskToHeaderType = map[string]string{ + "header-dns": "dns", + "header-dtls": "dtls", + "header-srtp": "srtp", + "header-utp": "utp", + "header-wechat": "wechat-video", + "header-wireguard": "wireguard", +} + +var validFinalMaskUDPTypes = map[string]struct{}{ + "salamander": {}, + "mkcp-aes128gcm": {}, + "header-dns": {}, + "header-dtls": {}, + "header-srtp": {}, + "header-utp": {}, + "header-wechat": {}, + "header-wireguard": {}, + "mkcp-original": {}, + "xdns": {}, + "xicmp": {}, + "noise": {}, + "header-custom": {}, +} + +var validFinalMaskTCPTypes = map[string]struct{}{ + "header-custom": {}, + "fragment": {}, + "sudoku": {}, +} + +// applyKcpShareParams reconstructs legacy KCP share-link fields from either +// the historical kcpSettings.header/seed shape or the current finalmask model. +// This keeps subscription output compatible while avoiding panics when older +// keys are absent from modern inbounds. +func applyKcpShareParams(stream map[string]any, params map[string]string) { + extractKcpShareFields(stream).applyToParams(params) +} + +func applyKcpShareObj(stream map[string]any, obj map[string]any) { + extractKcpShareFields(stream).applyToObj(obj) +} + +type kcpShareFields struct { + headerType string + seed string + mtu int + tti int +} + +func (f kcpShareFields) applyToParams(params map[string]string) { + if f.headerType != "" && f.headerType != "none" { + params["headerType"] = f.headerType + } + setStringParam(params, "seed", f.seed) + setIntParam(params, "mtu", f.mtu) + setIntParam(params, "tti", f.tti) +} + +func (f kcpShareFields) applyToObj(obj map[string]any) { + if f.headerType != "" && f.headerType != "none" { + obj["type"] = f.headerType + } + setStringField(obj, "path", f.seed) + setIntField(obj, "mtu", f.mtu) + setIntField(obj, "tti", f.tti) +} + +func extractKcpShareFields(stream map[string]any) kcpShareFields { + fields := kcpShareFields{headerType: "none"} + + if kcp, ok := stream["kcpSettings"].(map[string]any); ok { + if header, ok := kcp["header"].(map[string]any); ok { + if value, ok := header["type"].(string); ok && value != "" { + fields.headerType = value + } + } + if value, ok := kcp["seed"].(string); ok && value != "" { + fields.seed = value + } + if value, ok := readPositiveInt(kcp["mtu"]); ok { + fields.mtu = value + } + if value, ok := readPositiveInt(kcp["tti"]); ok { + fields.tti = value + } + } + + for _, rawMask := range normalizedFinalMaskUDPMasks(stream["finalmask"]) { + mask, _ := rawMask.(map[string]any) + if mask == nil { + continue + } + maskType, _ := mask["type"].(string) + if mapped, ok := kcpMaskToHeaderType[maskType]; ok { + fields.headerType = mapped + continue + } + + switch maskType { + case "mkcp-original": + fields.seed = "" + case "mkcp-aes128gcm": + fields.seed = "" + settings, _ := mask["settings"].(map[string]any) + if value, ok := settings["password"].(string); ok && value != "" { + fields.seed = value + } + } + } + + return fields +} + +func readPositiveInt(value any) (int, bool) { + switch number := value.(type) { + case int: + return number, number > 0 + case int32: + return int(number), number > 0 + case int64: + return int(number), number > 0 + case float32: + parsed := int(number) + return parsed, parsed > 0 + case float64: + parsed := int(number) + return parsed, parsed > 0 + default: + return 0, false + } +} + +func setStringParam(params map[string]string, key, value string) { + if value == "" { + delete(params, key) + return + } + params[key] = value +} + +func setIntParam(params map[string]string, key string, value int) { + if value <= 0 { + delete(params, key) + return + } + params[key] = fmt.Sprintf("%d", value) +} + +func setStringField(obj map[string]any, key, value string) { + if value == "" { + delete(obj, key) + return + } + obj[key] = value +} + +func setIntField(obj map[string]any, key string, value int) { + if value <= 0 { + delete(obj, key) + return + } + obj[key] = value +} + +// applyFinalMaskParams exports the finalmask payload as the compact +// `fm=` share-link field used by v2rayN-compatible clients. +func applyFinalMaskParams(finalmask map[string]any, params map[string]string) { + if fm, ok := marshalFinalMask(finalmask); ok { + params["fm"] = fm + } +} + +func applyFinalMaskObj(finalmask map[string]any, obj map[string]any) { + if fm, ok := marshalFinalMask(finalmask); ok { + obj["fm"] = fm + } +} + +func marshalFinalMask(finalmask map[string]any) (string, bool) { + normalized := normalizeFinalMask(finalmask) + if !hasFinalMaskContent(normalized) { + return "", false + } + b, err := json.Marshal(normalized) + if err != nil || len(b) == 0 || string(b) == "null" { + return "", false + } + return string(b), true +} + +func normalizeFinalMask(finalmask map[string]any) map[string]any { + tcpMasks := normalizedFinalMaskTCPMasks(finalmask) + udpMasks := normalizedFinalMaskUDPMasks(finalmask) + quicParams, hasQuicParams := finalmask["quicParams"].(map[string]any) + + if len(tcpMasks) == 0 && len(udpMasks) == 0 && !hasQuicParams { + return nil + } + + result := map[string]any{} + if len(tcpMasks) > 0 { + result["tcp"] = tcpMasks + } + if len(udpMasks) > 0 { + result["udp"] = udpMasks + } + if hasQuicParams && len(quicParams) > 0 { + result["quicParams"] = quicParams + } + return result +} + +func normalizedFinalMaskTCPMasks(value any) []any { + finalmask, _ := value.(map[string]any) + if finalmask == nil { + return nil + } + rawMasks, _ := finalmask["tcp"].([]any) + if len(rawMasks) == 0 { + return nil + } + + normalized := make([]any, 0, len(rawMasks)) + for _, rawMask := range rawMasks { + mask, _ := rawMask.(map[string]any) + if mask == nil { + continue + } + maskType, _ := mask["type"].(string) + if _, ok := validFinalMaskTCPTypes[maskType]; !ok || maskType == "" { + continue + } + + normalizedMask := map[string]any{"type": maskType} + if settings, ok := mask["settings"].(map[string]any); ok && len(settings) > 0 { + normalizedMask["settings"] = settings + } + normalized = append(normalized, normalizedMask) + } + + if len(normalized) == 0 { + return nil + } + return normalized +} + +func normalizedFinalMaskUDPMasks(value any) []any { + finalmask, _ := value.(map[string]any) + if finalmask == nil { + return nil + } + rawMasks, _ := finalmask["udp"].([]any) + if len(rawMasks) == 0 { + return nil + } + + normalized := make([]any, 0, len(rawMasks)) + for _, rawMask := range rawMasks { + mask, _ := rawMask.(map[string]any) + if mask == nil { + continue + } + maskType, _ := mask["type"].(string) + if _, ok := validFinalMaskUDPTypes[maskType]; !ok || maskType == "" { + continue + } + + normalizedMask := map[string]any{"type": maskType} + if settings, ok := mask["settings"].(map[string]any); ok && len(settings) > 0 { + normalizedMask["settings"] = settings + } + normalized = append(normalized, normalizedMask) + } + + if len(normalized) == 0 { + return nil + } + return normalized +} + +func hasFinalMaskContent(value any) bool { + switch v := value.(type) { + case nil: + return false + case string: + return len(v) > 0 + case map[string]any: + for _, item := range v { + if hasFinalMaskContent(item) { + return true + } + } + return false + case []any: + return slices.ContainsFunc(v, hasFinalMaskContent) + default: + return true + } +} + func searchHost(headers any) string { data, _ := headers.(map[string]any) for k, v := range data { diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index e7880ab9..ed20a7e5 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -3,12 +3,12 @@ const Protocols = { VLESS: 'vless', TROJAN: 'trojan', SHADOWSOCKS: 'shadowsocks', - TUNNEL: 'tunnel', + WIREGUARD: 'wireguard', + HYSTERIA: 'hysteria', MIXED: 'mixed', HTTP: 'http', - WIREGUARD: 'wireguard', + TUNNEL: 'tunnel', TUN: 'tun', - HYSTERIA: 'hysteria', }; const SSMethods = { @@ -323,7 +323,7 @@ class KcpStreamSettings extends XrayCommonClass { uplinkCapacity = 5, downlinkCapacity = 20, cwndMultiplier = 1, - maxSendingWindow = 1350, + maxSendingWindow = 2097152, ) { super(); this.mtu = mtu; @@ -1085,11 +1085,15 @@ class UdpMask extends XrayCommonClass { case 'header-wireguard': return {}; case 'header-custom': - return { client: [], server: [] }; + return { + client: Array.isArray(settings.client) ? settings.client : [], + server: Array.isArray(settings.server) ? settings.server : [], + }; case 'noise': - return { reset: 0, noise: [] }; - case 'sudoku': - return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 }; + return { + reset: settings.reset ?? 0, + noise: Array.isArray(settings.noise) ? settings.noise : [], + }; default: return settings; } @@ -1103,27 +1107,219 @@ class UdpMask extends XrayCommonClass { } toJson() { + const cleanItem = item => { + const out = { ...item }; + if (out.type === 'array') { + delete out.packet; + } else { + delete out.rand; + delete out.randRange; + } + return out; + }; + + let settings = this.settings; + if (this.type === 'noise' && settings && Array.isArray(settings.noise)) { + settings = { ...settings, noise: settings.noise.map(cleanItem) }; + } else if (this.type === 'header-custom' && settings) { + settings = { + ...settings, + client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client, + server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server, + }; + } + return { type: this.type, - settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined + settings: (settings && Object.keys(settings).length > 0) ? settings : undefined }; } } -class FinalMaskStreamSettings extends XrayCommonClass { - constructor(udp = []) { +class TcpMask extends XrayCommonClass { + constructor(type = 'fragment', settings = {}) { super(); - this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)]; + this.type = type; + this.settings = this._getDefaultSettings(type, settings); + } + + _getDefaultSettings(type, settings = {}) { + switch (type) { + case 'fragment': + return { + packets: settings.packets ?? 'tlshello', + length: settings.length ?? '', + delay: settings.delay ?? '', + maxSplit: settings.maxSplit ?? '', + }; + case 'sudoku': + return { + password: settings.password ?? '', + ascii: settings.ascii ?? '', + customTable: settings.customTable ?? '', + customTables: Array.isArray(settings.customTables) ? settings.customTables : [], + paddingMin: settings.paddingMin ?? 0, + paddingMax: settings.paddingMax ?? 0, + }; + case 'header-custom': + return { + clients: Array.isArray(settings.clients) ? settings.clients : [], + servers: Array.isArray(settings.servers) ? settings.servers : [], + }; + default: + return settings; + } } static fromJson(json = {}) { - return new FinalMaskStreamSettings(json.udp || []); + return new TcpMask( + json.type || 'fragment', + json.settings || {} + ); } toJson() { - return { - udp: this.udp.map(udp => udp.toJson()) + const cleanItem = item => { + const out = { ...item }; + if (out.type === 'array') { + delete out.packet; + } else { + delete out.rand; + delete out.randRange; + } + return out; }; + + let settings = this.settings; + if (this.type === 'header-custom' && settings) { + const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group; + settings = { + ...settings, + clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients, + servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers, + }; + } + + return { + type: this.type, + settings: (settings && Object.keys(settings).length > 0) ? settings : undefined + }; + } +} + +class QuicParams extends XrayCommonClass { + constructor( + congestion = 'bbr', + debug = false, + brutalUp = '', + brutalDown = '', + udpHop = undefined, + initStreamReceiveWindow = 8388608, + maxStreamReceiveWindow = 8388608, + initConnectionReceiveWindow = 20971520, + maxConnectionReceiveWindow = 20971520, + maxIdleTimeout = 30, + keepAlivePeriod = 0, + disablePathMTUDiscovery = false, + maxIncomingStreams = 1024, + ) { + super(); + this.congestion = congestion; + this.debug = debug; + this.brutalUp = brutalUp; + this.brutalDown = brutalDown; + this.udpHop = udpHop; + this.initStreamReceiveWindow = initStreamReceiveWindow; + this.maxStreamReceiveWindow = maxStreamReceiveWindow; + this.initConnectionReceiveWindow = initConnectionReceiveWindow; + this.maxConnectionReceiveWindow = maxConnectionReceiveWindow; + this.maxIdleTimeout = maxIdleTimeout; + this.keepAlivePeriod = keepAlivePeriod; + this.disablePathMTUDiscovery = disablePathMTUDiscovery; + this.maxIncomingStreams = maxIncomingStreams; + } + + get hasUdpHop() { + return this.udpHop != null; + } + + set hasUdpHop(value) { + this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined; + } + + static fromJson(json = {}) { + if (!json || Object.keys(json).length === 0) return undefined; + return new QuicParams( + json.congestion, + json.debug, + json.brutalUp, + json.brutalDown, + json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined, + json.initStreamReceiveWindow, + json.maxStreamReceiveWindow, + json.initConnectionReceiveWindow, + json.maxConnectionReceiveWindow, + json.maxIdleTimeout, + json.keepAlivePeriod, + json.disablePathMTUDiscovery, + json.maxIncomingStreams, + ); + } + + toJson() { + const result = { congestion: this.congestion }; + if (this.debug) result.debug = this.debug; + if (this.brutalUp) result.brutalUp = this.brutalUp; + if (this.brutalDown) result.brutalDown = this.brutalDown; + if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval }; + if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow; + if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow; + if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow; + if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow; + if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout; + if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod; + if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery; + if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams; + return result; + } +} + +class FinalMaskStreamSettings extends XrayCommonClass { + constructor(tcp = [], udp = [], quicParams = undefined) { + super(); + this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : []; + this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)]; + this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined); + } + + get enableQuicParams() { + return this.quicParams != null; + } + + set enableQuicParams(value) { + this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined; + } + + static fromJson(json = {}) { + return new FinalMaskStreamSettings( + json.tcp || [], + json.udp || [], + json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined, + ); + } + + toJson() { + const result = {}; + if (this.tcp && this.tcp.length > 0) { + result.tcp = this.tcp.map(t => t.toJson()); + } + if (this.udp && this.udp.length > 0) { + result.udp = this.udp.map(udp => udp.toJson()); + } + if (this.quicParams) { + result.quicParams = this.quicParams.toJson(); + } + return result; } } @@ -1160,6 +1356,16 @@ class StreamSettings extends XrayCommonClass { this.sockopt = sockopt; } + addTcpMask(type = 'fragment') { + this.finalmask.tcp.push(new TcpMask(type)); + } + + delTcpMask(index) { + if (this.finalmask.tcp) { + this.finalmask.tcp.splice(index, 1); + } + } + addUdpMask(type = 'salamander') { this.finalmask.udp.push(new UdpMask(type)); } @@ -1171,7 +1377,10 @@ class StreamSettings extends XrayCommonClass { } get hasFinalMask() { - return this.finalmask.udp && this.finalmask.udp.length > 0; + const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0; + const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0; + const hasQuicParams = this.finalmask.quicParams != null; + return hasTcp || hasUdp || hasQuicParams; } get isTls() { @@ -1370,6 +1579,50 @@ class Inbound extends XrayCommonClass { } } + static hasShareableFinalMaskValue(value) { + if (value == null) { + return false; + } + if (Array.isArray(value)) { + return value.some(item => Inbound.hasShareableFinalMaskValue(item)); + } + if (typeof value === 'object') { + return Object.values(value).some(item => Inbound.hasShareableFinalMaskValue(item)); + } + if (typeof value === 'string') { + return value.length > 0; + } + return true; + } + + static serializeFinalMask(finalmask) { + if (!finalmask) { + return ''; + } + const value = typeof finalmask.toJson === 'function' ? finalmask.toJson() : finalmask; + return Inbound.hasShareableFinalMaskValue(value) ? JSON.stringify(value) : ''; + } + + // Export finalmask with the same compact JSON payload shape that + // v2rayN-compatible share links use: fm=. + static applyFinalMaskToParams(finalmask, params) { + if (!params) return; + const payload = Inbound.serializeFinalMask(finalmask); + if (payload.length > 0) { + params.set("fm", payload); + } + } + + // VMess links are a base64 JSON object, so keep the same fm payload + // under a flat property instead of a URL query string. + static applyFinalMaskToObj(finalmask, obj) { + if (!obj) return; + const payload = Inbound.serializeFinalMask(finalmask); + if (payload.length > 0) { + obj.fm = payload; + } + } + get clients() { switch (this.protocol) { case Protocols.VMESS: return this.settings.vmesses; @@ -1566,6 +1819,8 @@ class Inbound extends XrayCommonClass { } } else if (network === 'kcp') { const kcp = this.stream.kcp; + obj.mtu = kcp.mtu; + obj.tti = kcp.tti; } else if (network === 'ws') { const ws = this.stream.ws; obj.path = ws.path; @@ -1588,6 +1843,8 @@ class Inbound extends XrayCommonClass { Inbound.applyXhttpPaddingToObj(xhttp, obj); } + Inbound.applyFinalMaskToObj(this.stream.finalmask, obj); + if (tls === 'tls') { if (!ObjectUtil.isEmpty(this.stream.tls.sni)) { obj.sni = this.stream.tls.sni; @@ -1626,6 +1883,8 @@ class Inbound extends XrayCommonClass { break; case "kcp": const kcp = this.stream.kcp; + params.set("mtu", kcp.mtu); + params.set("tti", kcp.tti); break; case "ws": const ws = this.stream.ws; @@ -1654,6 +1913,8 @@ class Inbound extends XrayCommonClass { break; } + Inbound.applyFinalMaskToParams(this.stream.finalmask, params); + if (security === 'tls') { params.set("security", "tls"); if (this.stream.isTls) { @@ -1727,6 +1988,8 @@ class Inbound extends XrayCommonClass { break; case "kcp": const kcp = this.stream.kcp; + params.set("mtu", kcp.mtu); + params.set("tti", kcp.tti); break; case "ws": const ws = this.stream.ws; @@ -1755,6 +2018,8 @@ class Inbound extends XrayCommonClass { break; } + Inbound.applyFinalMaskToParams(this.stream.finalmask, params); + if (security === 'tls') { params.set("security", "tls"); if (this.stream.isTls) { @@ -1804,6 +2069,8 @@ class Inbound extends XrayCommonClass { break; case "kcp": const kcp = this.stream.kcp; + params.set("mtu", kcp.mtu); + params.set("tti", kcp.tti); break; case "ws": const ws = this.stream.ws; @@ -1832,6 +2099,8 @@ class Inbound extends XrayCommonClass { break; } + Inbound.applyFinalMaskToParams(this.stream.finalmask, params); + if (security === 'tls') { params.set("security", "tls"); if (this.stream.isTls) { @@ -1899,6 +2168,8 @@ class Inbound extends XrayCommonClass { } } + Inbound.applyFinalMaskToParams(this.stream.finalmask, params); + const url = new URL(link); for (const [key, value] of params) { url.searchParams.set(key, value); @@ -1907,7 +2178,7 @@ class Inbound extends XrayCommonClass { return url.toString(); } - getWireguardLink(address, port, remark, peerId) { + getWireguardTxt(address, port, remark, peerId) { let txt = `[Interface]\n` txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n` txt += `Address = ${this.settings.peers[peerId].allowedIPs[0]}\n` @@ -1929,6 +2200,48 @@ class Inbound extends XrayCommonClass { return txt; } + getWireguardLink(address, port, remark, peerId) { + const peer = this.settings?.peers?.[peerId]; + if (!peer) return ''; + + const link = `wireguard://${address}:${port}`; + const url = new URL(link); + url.username = peer.privateKey || ''; + + if (this.settings?.pubKey) { + url.searchParams.set("publickey", this.settings.pubKey); + } + if (Array.isArray(peer.allowedIPs) && peer.allowedIPs.length > 0 && peer.allowedIPs[0]) { + url.searchParams.set("address", peer.allowedIPs[0]); + } + if (this.settings?.mtu) { + url.searchParams.set("mtu", this.settings.mtu); + } + + url.hash = encodeURIComponent(remark); + return url.toString(); + } + + genWireguardLinks(remark = '', remarkModel = '-ieo') { + const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + const separationChar = remarkModel.charAt(0); + let links = []; + this.settings.peers.forEach((p, index) => { + links.push(this.getWireguardLink(addr, this.port, remark + separationChar + (index + 1), index)); + }); + return links.join('\r\n'); + } + + genWireguardConfigs(remark = '', remarkModel = '-ieo') { + const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname; + const separationChar = remarkModel.charAt(0); + let links = []; + this.settings.peers.forEach((p, index) => { + links.push(this.getWireguardTxt(addr, this.port, remark + separationChar + (index + 1), index)); + }); + return links.join('\r\n'); + } + genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) { switch (this.protocol) { case Protocols.VMESS: @@ -1989,11 +2302,7 @@ class Inbound extends XrayCommonClass { } else { if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark); if (this.protocol == Protocols.WIREGUARD) { - let links = []; - this.settings.peers.forEach((p, index) => { - links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index)); - }); - return links.join('\r\n'); + return this.genWireguardConfigs(remark, remarkModel); } return ''; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index 97602815..6be2ec1b 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -97,6 +97,74 @@ const Address_Port_Strategy = { TxtPortAndAddress: "txtportandaddress" }; +const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack']; + +function normalizeDNSRuleField(value) { + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.map(item => item.toString().trim()).filter(item => item.length > 0).join(','); + } + return value.toString().trim(); +} + +function normalizeDNSRuleAction(action) { + action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim(); + return DNSRuleActions.includes(action) ? action : 'direct'; +} + +function parseLegacyDNSBlockTypes(blockTypes) { + if (blockTypes === null || blockTypes === undefined || blockTypes === '') { + return []; + } + + if (Array.isArray(blockTypes)) { + return blockTypes + .map(item => Number(item)) + .filter(item => Number.isInteger(item) && item >= 0 && item <= 65535); + } + + if (typeof blockTypes === 'number') { + return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : []; + } + + return blockTypes + .toString() + .split(',') + .map(item => item.trim()) + .filter(item => /^\d+$/.test(item)) + .map(item => Number(item)) + .filter(item => item >= 0 && item <= 65535); +} + +function buildLegacyDNSRules(nonIPQuery, blockTypes) { + const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject'; + const rules = []; + const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes); + + if (parsedBlockTypes.length > 0) { + rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(','))); + } + + rules.push(new Outbound.DNSRule('hijack', '1,28')); + rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode)); + + return rules; +} + +function getDNSRulesFromJson(json = {}) { + if (Array.isArray(json.rules) && json.rules.length > 0) { + return json.rules.map(rule => Outbound.DNSRule.fromJson(rule)); + } + + if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) { + return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes); + } + + return []; +} + Object.freeze(Protocols); Object.freeze(SSMethods); Object.freeze(TLS_FLOW_CONTROL); @@ -107,6 +175,7 @@ Object.freeze(WireguardDomainStrategy); Object.freeze(USERS_SECURITY); Object.freeze(MODE_OPTION); Object.freeze(Address_Port_Strategy); +Object.freeze(DNSRuleActions); class CommonClass { @@ -586,11 +655,23 @@ class UdpMask extends CommonClass { case 'header-wireguard': return {}; // No settings needed case 'header-custom': - return { client: [], server: [] }; + return { + client: Array.isArray(settings.client) ? settings.client : [], + server: Array.isArray(settings.server) ? settings.server : [], + }; case 'noise': - return { reset: 0, noise: [] }; + return { + reset: settings.reset ?? 0, + noise: Array.isArray(settings.noise) ? settings.noise : [], + }; case 'sudoku': - return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 }; + return { + ascii: settings.ascii || '', + customTable: settings.customTable || '', + customTables: Array.isArray(settings.customTables) ? settings.customTables : [], + paddingMin: settings.paddingMin ?? 0, + paddingMax: settings.paddingMax ?? 0 + }; default: return settings; } @@ -604,28 +685,187 @@ class UdpMask extends CommonClass { } toJson() { + const cleanItem = item => { + const out = { ...item }; + if (out.type === 'array') { + delete out.packet; + } else { + delete out.rand; + delete out.randRange; + } + return out; + }; + + let settings = this.settings; + if (this.type === 'noise' && settings && Array.isArray(settings.noise)) { + settings = { ...settings, noise: settings.noise.map(cleanItem) }; + } else if (this.type === 'header-custom' && settings) { + settings = { + ...settings, + client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client, + server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server, + }; + } + return { type: this.type, - settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined + settings: (settings && Object.keys(settings).length > 0) ? settings : undefined }; } } -class FinalMaskStreamSettings extends CommonClass { - constructor(udp = []) { +class TcpMask extends CommonClass { + constructor(type = 'fragment', settings = {}) { super(); - this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)]; + this.type = type; + this.settings = this._getDefaultSettings(type, settings); + } + + _getDefaultSettings(type, settings = {}) { + switch (type) { + case 'fragment': + return { + packets: settings.packets ?? 'tlshello', + length: settings.length ?? '', + delay: settings.delay ?? '', + maxSplit: settings.maxSplit ?? '', + }; + case 'sudoku': + return { + password: settings.password ?? '', + ascii: settings.ascii ?? '', + customTable: settings.customTable ?? '', + customTables: Array.isArray(settings.customTables) ? settings.customTables : [], + paddingMin: settings.paddingMin ?? 0, + paddingMax: settings.paddingMax ?? 0, + }; + case 'header-custom': + return { + clients: Array.isArray(settings.clients) ? settings.clients : [], + servers: Array.isArray(settings.servers) ? settings.servers : [], + }; + default: + return settings; + } } static fromJson(json = {}) { - return new FinalMaskStreamSettings(json.udp || []); + return new TcpMask( + json.type || 'fragment', + json.settings || {} + ); } toJson() { - return { - udp: this.udp.map(udp => udp.toJson()) + const cleanItem = item => { + const out = { ...item }; + if (out.type === 'array') { + delete out.packet; + } else { + delete out.rand; + delete out.randRange; + } + return out; }; + let settings = this.settings; + if (this.type === 'header-custom' && settings) { + const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group; + settings = { + ...settings, + clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients, + servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers, + }; + } + + return { + type: this.type, + settings: (settings && Object.keys(settings).length > 0) ? settings : undefined + }; + } +} + +class QuicParams extends CommonClass { + constructor( + congestion = 'bbr', + debug = false, + brutalUp = '', + brutalDown = '', + udpHop = undefined, + ) { + super(); + this.congestion = congestion; + this.debug = debug; + this.brutalUp = brutalUp; + this.brutalDown = brutalDown; + this.udpHop = udpHop; + } + + get hasUdpHop() { + return this.udpHop != null; + } + + set hasUdpHop(value) { + this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined; + } + + static fromJson(json = {}) { + if (!json || Object.keys(json).length === 0) return undefined; + return new QuicParams( + json.congestion, + json.debug, + json.brutalUp, + json.brutalDown, + json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined, + ); + } + + toJson() { + const result = { congestion: this.congestion }; + if (this.debug) result.debug = this.debug; + if (this.brutalUp) result.brutalUp = this.brutalUp; + if (this.brutalDown) result.brutalDown = this.brutalDown; + if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval }; + return result; + } +} + +class FinalMaskStreamSettings extends CommonClass { + constructor(tcp = [], udp = [], quicParams = undefined) { + super(); + this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : []; + this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)]; + this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined); + } + + get enableQuicParams() { + return this.quicParams != null; + } + + set enableQuicParams(value) { + this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined; + } + + static fromJson(json = {}) { + return new FinalMaskStreamSettings( + json.tcp || [], + json.udp || [], + json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined, + ); + } + + toJson() { + const result = {}; + if (this.tcp && this.tcp.length > 0) { + result.tcp = this.tcp.map(t => t.toJson()); + } + if (this.udp && this.udp.length > 0) { + result.udp = this.udp.map(udp => udp.toJson()); + } + if (this.quicParams) { + result.quicParams = this.quicParams.toJson(); + } + return result; } } @@ -661,6 +901,16 @@ class StreamSettings extends CommonClass { this.sockopt = sockopt; } + addTcpMask(type = 'fragment') { + this.finalmask.tcp.push(new TcpMask(type)); + } + + delTcpMask(index) { + if (this.finalmask.tcp) { + this.finalmask.tcp.splice(index, 1); + } + } + addUdpMask(type = 'salamander') { this.finalmask.udp.push(new UdpMask(type)); } @@ -672,7 +922,10 @@ class StreamSettings extends CommonClass { } get hasFinalMask() { - return this.finalmask.udp && this.finalmask.udp.length > 0; + const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0; + const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0; + const hasQuicParams = this.finalmask.quicParams != null; + return hasTcp || hasUdp || hasQuicParams; } get isTls() { @@ -923,6 +1176,10 @@ class Outbound extends CommonClass { stream.kcp = new KcpStreamSettings(); stream.type = json.type; stream.seed = json.path; + const mtu = Number(json.mtu); + if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu; + const tti = Number(json.tti); + if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti; } else if (network === 'ws') { stream.ws = new WsStreamSettings(json.path, json.host); } else if (network === 'grpc') { @@ -960,6 +1217,7 @@ class Outbound extends CommonClass { let headerType = url.searchParams.get('headerType') ?? undefined; let host = url.searchParams.get('host') ?? undefined; let path = url.searchParams.get('path') ?? undefined; + let seed = url.searchParams.get('seed') ?? path ?? undefined; let mode = url.searchParams.get('mode') ?? undefined; if (type === 'tcp' || type === 'none') { @@ -967,7 +1225,11 @@ class Outbound extends CommonClass { } else if (type === 'kcp') { stream.kcp = new KcpStreamSettings(); stream.kcp.type = headerType ?? 'none'; - stream.kcp.seed = path; + stream.kcp.seed = seed; + const mtu = Number(url.searchParams.get('mtu')); + if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu; + const tti = Number(url.searchParams.get('tti')); + if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti; } else if (type === 'ws') { stream.ws = new WsStreamSettings(path, host); } else if (type === 'grpc') { @@ -1277,20 +1539,69 @@ Outbound.BlackholeSettings = class extends CommonClass { }; } }; + +Outbound.DNSRule = class extends CommonClass { + constructor(action = 'direct', qtype = '', domain = '') { + super(); + this.action = action; + this.qtype = qtype; + this.domain = domain; + } + + static fromJson(json = {}) { + return new Outbound.DNSRule( + json.action, + normalizeDNSRuleField(json.qtype), + normalizeDNSRuleField(json.domain), + ); + } + + toJson() { + const rule = { + action: normalizeDNSRuleAction(this.action), + }; + + const qtype = normalizeDNSRuleField(this.qtype); + if (!ObjectUtil.isEmpty(qtype)) { + if (/^\d+$/.test(qtype)) { + rule.qtype = Number(qtype); + } else { + rule.qtype = qtype; + } + } + + const domains = normalizeDNSRuleField(this.domain) + .split(',') + .map(d => d.trim()) + .filter(d => d.length > 0); + if (domains.length > 0) { + rule.domain = domains; + } + + return rule; + } +}; + Outbound.DNSSettings = class extends CommonClass { constructor( network = 'udp', address = '', port = 53, - nonIPQuery = 'reject', - blockTypes = [] + rules = [] ) { super(); this.network = network; this.address = address; this.port = port; - this.nonIPQuery = nonIPQuery; - this.blockTypes = blockTypes; + this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : []; + } + + addRule(action = 'direct') { + this.rules.push(new Outbound.DNSRule(action)); + } + + delRule(index) { + this.rules.splice(index, 1); } static fromJson(json = {}) { @@ -1298,10 +1609,23 @@ Outbound.DNSSettings = class extends CommonClass { json.network, json.address, json.port, - json.nonIPQuery, - json.blockTypes, + getDNSRulesFromJson(json), ); } + + toJson() { + const json = { + network: this.network, + address: this.address, + port: this.port, + }; + + if (this.rules.length > 0) { + json.rules = Outbound.DNSRule.toJsonArray(this.rules); + } + + return json; + } }; Outbound.VmessSettings = class extends CommonClass { constructor(address, port, id, security) { diff --git a/web/assets/js/util/index.js b/web/assets/js/util/index.js index cc7b9287..1f481c85 100644 --- a/web/assets/js/util/index.js +++ b/web/assets/js/util/index.js @@ -152,6 +152,12 @@ class RandomUtil { return Base64.alternativeEncode(String.fromCharCode(...array)); } + static randomBase64(length = 16) { + const array = new Uint8Array(length); + window.crypto.getRandomValues(array); + return Base64.alternativeEncode(String.fromCharCode(...array)); + } + static randomBase32String(length = 16) { const array = new Uint8Array(length); diff --git a/web/controller/server.go b/web/controller/server.go index d32209e1..188e987a 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -22,6 +22,7 @@ type ServerController struct { serverService service.ServerService settingService service.SettingService + panelService service.PanelService lastStatus *service.Status @@ -43,6 +44,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.GET("/status", a.status) g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket) g.GET("/getXrayVersion", a.getXrayVersion) + g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) g.GET("/getNewUUID", a.getNewUUID) @@ -54,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.POST("/stopXrayService", a.stopXrayService) g.POST("/restartXrayService", a.restartXrayService) g.POST("/installXray/:version", a.installXray) + g.POST("/updatePanel", a.updatePanel) g.POST("/updateGeofile", a.updateGeofile) g.POST("/updateGeofile/:fileName", a.updateGeofile) g.POST("/logs/:count", a.getLogs) @@ -131,6 +134,16 @@ func (a *ServerController) getXrayVersion(c *gin.Context) { jsonObj(c, versions, nil) } +// getPanelUpdateInfo retrieves the current and latest panel version. +func (a *ServerController) getPanelUpdateInfo(c *gin.Context) { + info, err := a.panelService.GetUpdateInfo() + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateCheckPopover"), err) + return + } + jsonObj(c, info, nil) +} + // installXray installs or updates Xray to the specified version. func (a *ServerController) installXray(c *gin.Context) { version := c.Param("version") @@ -138,6 +151,12 @@ func (a *ServerController) installXray(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err) } +// updatePanel starts a panel self-update to the latest release. +func (a *ServerController) updatePanel(c *gin.Context) { + err := a.panelService.StartUpdate() + jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err) +} + // updateGeofile updates the specified geo file for Xray. func (a *ServerController) updateGeofile(c *gin.Context) { fileName := c.Param("fileName") diff --git a/web/html/component/aThemeSwitch.html b/web/html/component/aThemeSwitch.html index 431614d6..ca340da3 100644 --- a/web/html/component/aThemeSwitch.html +++ b/web/html/component/aThemeSwitch.html @@ -40,7 +40,8 @@ {{define "component/aThemeSwitch"}}