mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
Merge branch 'main' into fix/traffic-overage-enforcement
This commit is contained in:
commit
0c037be40a
17 changed files with 873 additions and 114 deletions
43
.github/workflows/codeql.yml
vendored
Normal file
43
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '18 2 * * 2'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
2
go.mod
2
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
|||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/goccy/go-json v0.10.6
|
||||
github.com/goccy/go-yaml v1.19.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
|
@ -48,7 +49,6 @@ require (
|
|||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/btree v1.1.3 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
|
|
|
|||
13
sub/sub.go
13
sub/sub.go
|
|
@ -91,12 +91,21 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Determine if JSON subscription endpoint is enabled
|
||||
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
|
||||
}
|
||||
|
||||
// Set base_path based on LinksPath for template rendering
|
||||
// Ensure LinksPath ends with "/" for proper asset URL generation
|
||||
basePath := LinksPath
|
||||
|
|
@ -255,7 +264,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
g := engine.Group("/")
|
||||
|
||||
s.sub = NewSUBController(
|
||||
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle, SubSupportUrl,
|
||||
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
|
||||
|
||||
|
|
|
|||
385
sub/subClashService.go
Normal file
385
sub/subClashService.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
package sub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
yaml "github.com/goccy/go-yaml"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/xray"
|
||||
)
|
||||
|
||||
type SubClashService struct {
|
||||
inboundService service.InboundService
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
type ClashConfig struct {
|
||||
Proxies []map[string]any `yaml:"proxies"`
|
||||
ProxyGroups []map[string]any `yaml:"proxy-groups"`
|
||||
Rules []string `yaml:"rules"`
|
||||
}
|
||||
|
||||
func NewSubClashService(subService *SubService) *SubClashService {
|
||||
return &SubClashService{SubService: subService}
|
||||
}
|
||||
|
||||
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
|
||||
inbounds, err := s.SubService.getInboundsBySubId(subId)
|
||||
if err != nil || len(inbounds) == 0 {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var traffic xray.ClientTraffic
|
||||
var clientTraffics []xray.ClientTraffic
|
||||
var proxies []map[string]any
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
clients, err := s.inboundService.GetClients(inbound)
|
||||
if err != nil {
|
||||
logger.Error("SubClashService - GetClients: Unable to get clients from inbound")
|
||||
}
|
||||
if clients == nil {
|
||||
continue
|
||||
}
|
||||
if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
|
||||
listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
|
||||
if err == nil {
|
||||
inbound.Listen = listen
|
||||
inbound.Port = port
|
||||
inbound.StreamSettings = streamSettings
|
||||
}
|
||||
}
|
||||
for _, client := range clients {
|
||||
if client.Enable && client.SubID == subId {
|
||||
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
|
||||
proxies = append(proxies, s.getProxies(inbound, client, host)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) == 0 {
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
for index, clientTraffic := range clientTraffics {
|
||||
if index == 0 {
|
||||
traffic.Up = clientTraffic.Up
|
||||
traffic.Down = clientTraffic.Down
|
||||
traffic.Total = clientTraffic.Total
|
||||
if clientTraffic.ExpiryTime > 0 {
|
||||
traffic.ExpiryTime = clientTraffic.ExpiryTime
|
||||
}
|
||||
} else {
|
||||
traffic.Up += clientTraffic.Up
|
||||
traffic.Down += clientTraffic.Down
|
||||
if traffic.Total == 0 || clientTraffic.Total == 0 {
|
||||
traffic.Total = 0
|
||||
} else {
|
||||
traffic.Total += clientTraffic.Total
|
||||
}
|
||||
if clientTraffic.ExpiryTime != traffic.ExpiryTime {
|
||||
traffic.ExpiryTime = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 := ClashConfig{
|
||||
Proxies: proxies,
|
||||
ProxyGroups: []map[string]any{{
|
||||
"name": "PROXY",
|
||||
"type": "select",
|
||||
"proxies": proxyNames,
|
||||
}},
|
||||
Rules: []string{"MATCH,PROXY"},
|
||||
}
|
||||
|
||||
finalYAML, err := yaml.Marshal(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
|
||||
}
|
||||
|
||||
func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client, host string) []map[string]any {
|
||||
stream := s.streamData(inbound.StreamSettings)
|
||||
externalProxies, ok := stream["externalProxy"].([]any)
|
||||
if !ok || len(externalProxies) == 0 {
|
||||
externalProxies = []any{map[string]any{
|
||||
"forceTls": "same",
|
||||
"dest": host,
|
||||
"port": float64(inbound.Port),
|
||||
"remark": "",
|
||||
}}
|
||||
}
|
||||
delete(stream, "externalProxy")
|
||||
|
||||
proxies := make([]map[string]any, 0, len(externalProxies))
|
||||
for _, ep := range externalProxies {
|
||||
extPrxy := ep.(map[string]any)
|
||||
workingInbound := *inbound
|
||||
workingInbound.Listen = extPrxy["dest"].(string)
|
||||
workingInbound.Port = int(extPrxy["port"].(float64))
|
||||
workingStream := cloneMap(stream)
|
||||
|
||||
switch extPrxy["forceTls"].(string) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
proxy := s.buildProxy(&workingInbound, client, workingStream, extPrxy["remark"].(string))
|
||||
if len(proxy) > 0 {
|
||||
proxies = append(proxies, proxy)
|
||||
}
|
||||
}
|
||||
return proxies
|
||||
}
|
||||
|
||||
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
|
||||
proxy := map[string]any{
|
||||
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
|
||||
"server": inbound.Listen,
|
||||
"port": inbound.Port,
|
||||
"udp": true,
|
||||
}
|
||||
|
||||
network, _ := stream["network"].(string)
|
||||
if !s.applyTransport(proxy, network, stream) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch inbound.Protocol {
|
||||
case model.VMESS:
|
||||
proxy["type"] = "vmess"
|
||||
proxy["uuid"] = client.ID
|
||||
proxy["alterId"] = 0
|
||||
cipher := client.Security
|
||||
if cipher == "" {
|
||||
cipher = "auto"
|
||||
}
|
||||
proxy["cipher"] = cipher
|
||||
case model.VLESS:
|
||||
proxy["type"] = "vless"
|
||||
proxy["uuid"] = client.ID
|
||||
if client.Flow != "" && network == "tcp" {
|
||||
proxy["flow"] = client.Flow
|
||||
}
|
||||
var inboundSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
|
||||
proxy["packet-encoding"] = encryption
|
||||
}
|
||||
case model.Trojan:
|
||||
proxy["type"] = "trojan"
|
||||
proxy["password"] = client.Password
|
||||
case model.Shadowsocks:
|
||||
proxy["type"] = "ss"
|
||||
proxy["password"] = client.Password
|
||||
var inboundSettings map[string]any
|
||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
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))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
|
@ -21,12 +21,15 @@ type SUBController struct {
|
|||
subRoutingRules string
|
||||
subPath string
|
||||
subJsonPath string
|
||||
subClashPath string
|
||||
jsonEnabled bool
|
||||
clashEnabled bool
|
||||
subEncrypt bool
|
||||
updateInterval string
|
||||
|
||||
subService *SubService
|
||||
subJsonService *SubJsonService
|
||||
subService *SubService
|
||||
subJsonService *SubJsonService
|
||||
subClashService *SubClashService
|
||||
}
|
||||
|
||||
// NewSUBController creates a new subscription controller with the given configuration.
|
||||
|
|
@ -34,7 +37,9 @@ func NewSUBController(
|
|||
g *gin.RouterGroup,
|
||||
subPath string,
|
||||
jsonPath string,
|
||||
clashPath string,
|
||||
jsonEnabled bool,
|
||||
clashEnabled bool,
|
||||
encrypt bool,
|
||||
showInfo bool,
|
||||
rModel string,
|
||||
|
|
@ -60,12 +65,15 @@ func NewSUBController(
|
|||
subRoutingRules: subRoutingRules,
|
||||
subPath: subPath,
|
||||
subJsonPath: jsonPath,
|
||||
subClashPath: clashPath,
|
||||
jsonEnabled: jsonEnabled,
|
||||
clashEnabled: clashEnabled,
|
||||
subEncrypt: encrypt,
|
||||
updateInterval: update,
|
||||
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subService: sub,
|
||||
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
|
||||
subClashService: NewSubClashService(sub),
|
||||
}
|
||||
a.initRouter(g)
|
||||
return a
|
||||
|
|
@ -80,6 +88,10 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
|||
gJson := g.Group(a.subJsonPath)
|
||||
gJson.GET(":subid", a.subJsons)
|
||||
}
|
||||
if a.clashEnabled {
|
||||
gClash := g.Group(a.subClashPath)
|
||||
gClash.GET(":subid", a.subClashs)
|
||||
}
|
||||
}
|
||||
|
||||
// subs handles HTTP requests for subscription links, returning either HTML page or base64-encoded subscription data.
|
||||
|
|
@ -99,10 +111,13 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
accept := c.GetHeader("Accept")
|
||||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||
// Build page data in service
|
||||
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
||||
subURL, subJsonURL, subClashURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, a.subClashPath, subId)
|
||||
if !a.jsonEnabled {
|
||||
subJsonURL = ""
|
||||
}
|
||||
if !a.clashEnabled {
|
||||
subClashURL = ""
|
||||
}
|
||||
// Get base_path from context (set by middleware)
|
||||
basePath, exists := c.Get("base_path")
|
||||
if !exists {
|
||||
|
|
@ -116,7 +131,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
// Remove trailing slash if exists, add subId, then add trailing slash
|
||||
basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
|
||||
}
|
||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
|
||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, subClashURL, basePathStr)
|
||||
c.HTML(200, "subpage.html", gin.H{
|
||||
"title": "subscription.title",
|
||||
"cur_ver": config.GetVersion(),
|
||||
|
|
@ -136,6 +151,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
"totalByte": page.TotalByte,
|
||||
"subUrl": page.SubUrl,
|
||||
"subJsonUrl": page.SubJsonUrl,
|
||||
"subClashUrl": page.SubClashUrl,
|
||||
"result": page.Result,
|
||||
})
|
||||
return
|
||||
|
|
@ -165,7 +181,6 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
|||
if err != nil || len(jsonSub) == 0 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
// Add headers
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
|
|
@ -176,6 +191,22 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) subClashs(c *gin.Context) {
|
||||
subId := c.Param("subid")
|
||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||
if err != nil || len(clashSub) == 0 {
|
||||
c.String(400, "Error!")
|
||||
} else {
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules)
|
||||
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyCommonHeaders sets common HTTP headers for subscription responses including user info, update interval, and profile title.
|
||||
func (a *SUBController) ApplyCommonHeaders(
|
||||
c *gin.Context,
|
||||
|
|
|
|||
|
|
@ -1031,6 +1031,7 @@ type PageData struct {
|
|||
TotalByte int64
|
||||
SubUrl string
|
||||
SubJsonUrl string
|
||||
SubClashUrl string
|
||||
Result []string
|
||||
}
|
||||
|
||||
|
|
@ -1080,29 +1081,25 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string,
|
|||
|
||||
// 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(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) {
|
||||
// Input validation
|
||||
func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subClashPath, subId string) (subURL, subJsonURL, subClashURL string) {
|
||||
if subId == "" {
|
||||
return "", ""
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// Get configured URIs first (highest priority)
|
||||
configuredSubURI, _ := s.settingService.GetSubURI()
|
||||
configuredSubJsonURI, _ := s.settingService.GetSubJsonURI()
|
||||
configuredSubClashURI, _ := s.settingService.GetSubClashURI()
|
||||
|
||||
// Determine base scheme and host (cached to avoid duplicate calls)
|
||||
var baseScheme, baseHostWithPort string
|
||||
if configuredSubURI == "" || configuredSubJsonURI == "" {
|
||||
if configuredSubURI == "" || configuredSubJsonURI == "" || configuredSubClashURI == "" {
|
||||
baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort)
|
||||
}
|
||||
|
||||
// Build subscription URL
|
||||
subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId)
|
||||
|
||||
// Build JSON subscription URL
|
||||
subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId)
|
||||
subClashURL = s.buildSingleURL(configuredSubClashURI, baseScheme, baseHostWithPort, subClashPath, subId)
|
||||
|
||||
return subURL, subJsonURL
|
||||
return subURL, subJsonURL, subClashURL
|
||||
}
|
||||
|
||||
// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values
|
||||
|
|
@ -1149,7 +1146,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, subURL, subJsonURL string, basePath string) PageData {
|
||||
func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL, subClashURL string, basePath string) PageData {
|
||||
download := common.FormatTraffic(traffic.Down)
|
||||
upload := common.FormatTraffic(traffic.Up)
|
||||
total := "∞"
|
||||
|
|
@ -1183,6 +1180,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
|
|||
TotalByte: traffic.Total,
|
||||
SubUrl: subURL,
|
||||
SubJsonUrl: subJsonURL,
|
||||
SubClashUrl: subClashURL,
|
||||
Result: subs,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ class AllSetting {
|
|||
this.subPort = 2096;
|
||||
this.subPath = "/sub/";
|
||||
this.subJsonPath = "/json/";
|
||||
this.subClashEnable = true;
|
||||
this.subClashPath = "/clash/";
|
||||
this.subDomain = "";
|
||||
this.externalTrafficInformEnable = false;
|
||||
this.externalTrafficInformURI = "";
|
||||
|
|
@ -48,6 +50,7 @@ class AllSetting {
|
|||
this.subShowInfo = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
this.subJsonFragment = "";
|
||||
this.subJsonNoises = "";
|
||||
this.subJsonMux = "";
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
sId: el.getAttribute('data-sid') || '',
|
||||
subUrl: el.getAttribute('data-sub-url') || '',
|
||||
subJsonUrl: el.getAttribute('data-subjson-url') || '',
|
||||
subClashUrl: el.getAttribute('data-subclash-url') || '',
|
||||
download: el.getAttribute('data-download') || '',
|
||||
upload: el.getAttribute('data-upload') || '',
|
||||
used: el.getAttribute('data-used') || '',
|
||||
|
|
@ -98,13 +99,19 @@
|
|||
this.lang = LanguageManager.getLanguage();
|
||||
const tpl = document.getElementById('subscription-data');
|
||||
const sj = tpl ? tpl.getAttribute('data-subjson-url') : '';
|
||||
const sc = tpl ? tpl.getAttribute('data-subclash-url') : '';
|
||||
if (sj) this.app.subJsonUrl = sj;
|
||||
if (sc) this.app.subClashUrl = sc;
|
||||
drawQR(this.app.subUrl);
|
||||
try {
|
||||
const elJson = document.getElementById('qrcode-subjson');
|
||||
if (elJson && this.app.subJsonUrl) {
|
||||
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||
}
|
||||
const elClash = document.getElementById('qrcode-subclash');
|
||||
if (elClash && this.app.subClashUrl) {
|
||||
new QRious({ element: elClash, value: this.app.subClashUrl, size: 220 });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||
window.addEventListener('resize', this._onResize);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ func mapCustomGeoErr(c *gin.Context, err error) error {
|
|||
case errors.Is(err, service.ErrCustomGeoDownload):
|
||||
logger.Warning("custom geo download:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
|
||||
case errors.Is(err, service.ErrCustomGeoSSRFBlocked):
|
||||
logger.Warning("custom geo SSRF blocked:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
|
||||
case errors.Is(err, service.ErrCustomGeoPathTraversal):
|
||||
logger.Warning("custom geo path traversal blocked:", err)
|
||||
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ type AllSetting struct {
|
|||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||
|
|
@ -168,6 +171,13 @@ func (s *AllSetting) CheckValid() error {
|
|||
s.SubJsonPath += "/"
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(s.SubClashPath, "/") {
|
||||
s.SubClashPath = "/" + s.SubClashPath
|
||||
}
|
||||
if !strings.HasSuffix(s.SubClashPath, "/") {
|
||||
s.SubClashPath += "/"
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(s.TimeLocation)
|
||||
if err != nil {
|
||||
return common.NewError("time location not exist:", s.TimeLocation)
|
||||
|
|
|
|||
|
|
@ -79,10 +79,10 @@
|
|||
</template>
|
||||
{{ template "settings/panel/subscription/general" . }}
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
|
||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
|
||||
<template #tab>
|
||||
<a-icon type="code"></a-icon>
|
||||
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
|
||||
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
|
||||
</template>
|
||||
{{ template "settings/panel/subscription/json" . }}
|
||||
</a-tab-pane>
|
||||
|
|
|
|||
|
|
@ -3,43 +3,58 @@
|
|||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subEnable"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subEnableDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subEnableDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>JSON Subscription</template>
|
||||
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subJsonEnable"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>Clash / Mihomo Subscription</template>
|
||||
<template #description>Enable direct Clash and Mihomo YAML
|
||||
subscriptions.</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subClashEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subListen"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subListenDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subListenDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subListen"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subDomain"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subDomainDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subDomainDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subDomain"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subPort"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subPortDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
|
||||
<a-input-number v-model="allSetting.subPort" :min="1"
|
||||
:min="65535"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subPath"
|
||||
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||
|
|
@ -49,9 +64,11 @@
|
|||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subURIDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||
<a-input type="text"
|
||||
placeholder="(http|https)://domain[:port]/path/"
|
||||
v-model="allSetting.subURI"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
|
|
@ -59,14 +76,16 @@
|
|||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subEncrypt"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subEncryptDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subEncryptDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subEncrypt"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subShowInfo"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subShowInfoDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subShowInfoDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subShowInfo"></a-switch>
|
||||
</template>
|
||||
|
|
@ -74,59 +93,72 @@
|
|||
<a-divider>{{ i18n "pages.xray.basicTemplate"}}</a-divider>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subTitleDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subTitle"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subSupportUrl"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subSupportUrlDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subSupportUrlDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
|
||||
<a-input type="text" v-model="allSetting.subSupportUrl"
|
||||
placeholder="https://example.com"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subProfileUrl"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subProfileUrlDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subProfileUrlDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
|
||||
<a-input type="text" v-model="allSetting.subProfileUrl"
|
||||
placeholder="https://example.com"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subAnnounce"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subAnnounceDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subAnnounceDesc"}}</template>
|
||||
<template #control>
|
||||
<a-textarea v-model="allSetting.subAnnounce"></a-textarea>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-divider>{{ i18n "pages.xray.advancedTemplate"}} (Happ)</a-divider>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subEnableRouting"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subEnableRoutingDesc"}}</template>
|
||||
<template #title>{{ i18n
|
||||
"pages.settings.subEnableRouting"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subEnableRoutingDesc"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subEnableRouting"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subRoutingRules"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subRoutingRulesDesc"}}</template>
|
||||
<template #title>{{ i18n
|
||||
"pages.settings.subRoutingRules"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subRoutingRulesDesc"}}</template>
|
||||
<template #control>
|
||||
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
|
||||
<a-textarea v-model="allSetting.subRoutingRules"
|
||||
placeholder="happ://routing/add/..."></a-textarea>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="3" header='{{ i18n "pages.settings.certs" }}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subCertPath"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subCertPathDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subCertPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subCertFile"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subKeyPath"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subKeyPathDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subKeyPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subKeyFile"></a-input>
|
||||
</template>
|
||||
|
|
@ -135,9 +167,11 @@
|
|||
<a-collapse-panel key="4" header='{{ i18n "pages.settings.intervals"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subUpdates"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subUpdatesDesc"}}</template>
|
||||
<template #description>{{ i18n
|
||||
"pages.settings.subUpdatesDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
|
||||
<a-input-number :min="1" v-model="allSetting.subUpdates"
|
||||
:style="{ width: '100%' }"></a-input-number>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{{define "settings/panel/subscription/json"}}
|
||||
<a-collapse default-active-key="1">
|
||||
<a-collapse-panel key="1" header='{{ i18n "pages.xray.generalConfigs"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}}</template>
|
||||
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}} (JSON)</template>
|
||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subJsonPath"
|
||||
|
|
@ -11,14 +11,32 @@
|
|||
placeholder="/json/"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}}</template>
|
||||
<a-setting-list-item paddings="small" v-if="allSetting.subJsonEnable">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}} (JSON)</template>
|
||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||
v-model="allSetting.subJsonURI"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||
<template #title>{{ i18n "pages.settings.subPath"}} (Clash)</template>
|
||||
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" v-model="allSetting.subClashPath"
|
||||
@input="allSetting.subClashPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
|
||||
@blur="allSetting.subClashPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subClashPath)"
|
||||
placeholder="/clash/"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small" v-if="allSetting.subClashEnable">
|
||||
<template #title>{{ i18n "pages.settings.subURI"}} (Clash)</template>
|
||||
<template #description>{{ i18n "pages.settings.subURIDesc"}}</template>
|
||||
<template #control>
|
||||
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
|
||||
v-model="allSetting.subClashURI"></a-input>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
</a-collapse-panel>
|
||||
<a-collapse-panel key="2" header='{{ i18n "pages.settings.fragment"}}'>
|
||||
<a-setting-list-item paddings="small">
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@
|
|||
<a-form-item>
|
||||
<a-space direction="vertical" align="center">
|
||||
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24" style="text-align:center;">
|
||||
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>{{ i18n
|
||||
|
|
@ -112,6 +112,19 @@
|
|||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</a-col>
|
||||
<a-col v-if="app.subClashUrl" :xs="24" :sm="12" style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple" class="qr-tag">
|
||||
<span>Clash / Mihomo</span>
|
||||
</a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||
<canvas id="qrcode-subclash" class="qr-cv" title='{{ i18n "copy" }}'
|
||||
@click="copy(app.subClashUrl)"></canvas>
|
||||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
|
|
@ -242,7 +255,7 @@
|
|||
</a-layout>
|
||||
|
||||
<!-- Bootstrap data for external JS -->
|
||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
|
||||
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
|
||||
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
|
||||
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
|
||||
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
|
|
@ -43,6 +45,8 @@ var (
|
|||
ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
|
||||
ErrCustomGeoNotFound = errors.New("custom_geo_not_found")
|
||||
ErrCustomGeoDownload = errors.New("custom_geo_download")
|
||||
ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked")
|
||||
ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal")
|
||||
)
|
||||
|
||||
type CustomGeoUpdateAllItem struct {
|
||||
|
|
@ -111,25 +115,41 @@ func (s *CustomGeoService) validateAlias(alias string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *CustomGeoService) validateURL(raw string) error {
|
||||
func (s *CustomGeoService) sanitizeURL(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return ErrCustomGeoURLRequired
|
||||
return "", ErrCustomGeoURLRequired
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return ErrCustomGeoInvalidURL
|
||||
return "", ErrCustomGeoInvalidURL
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ErrCustomGeoURLScheme
|
||||
return "", ErrCustomGeoURLScheme
|
||||
}
|
||||
if u.Host == "" {
|
||||
return ErrCustomGeoURLHost
|
||||
return "", ErrCustomGeoURLHost
|
||||
}
|
||||
return nil
|
||||
if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Reconstruct URL from parsed components to break taint propagation.
|
||||
clean := &url.URL{
|
||||
Scheme: u.Scheme,
|
||||
Host: u.Host,
|
||||
Path: u.Path,
|
||||
RawPath: u.RawPath,
|
||||
RawQuery: u.RawQuery,
|
||||
Fragment: u.Fragment,
|
||||
}
|
||||
return clean.String(), nil
|
||||
}
|
||||
|
||||
func localDatFileNeedsRepair(path string) bool {
|
||||
fi, err := os.Stat(path)
|
||||
safePath, err := sanitizeDestPath(path)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
fi, err := os.Stat(safePath)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
|
@ -143,9 +163,56 @@ func CustomGeoLocalFileNeedsRepair(path string) bool {
|
|||
return localDatFileNeedsRepair(path)
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
|
||||
// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
|
||||
func checkSSRFDefault(ctx context.Context, hostname string) error {
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
|
||||
}
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
|
||||
var checkSSRF = checkSSRFDefault
|
||||
|
||||
func ssrfSafeTransport() http.RoundTripper {
|
||||
base, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
base = &http.Transport{}
|
||||
}
|
||||
cloned := base.Clone()
|
||||
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
|
||||
}
|
||||
if err := checkSSRF(ctx, host); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func probeCustomGeoURLWithGET(rawURL string) error {
|
||||
client := &http.Client{Timeout: customGeoProbeTimeout}
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
|
||||
req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -165,8 +232,12 @@ func probeCustomGeoURLWithGET(rawURL string) error {
|
|||
}
|
||||
|
||||
func probeCustomGeoURL(rawURL string) error {
|
||||
client := &http.Client{Timeout: customGeoProbeTimeout}
|
||||
req, err := http.NewRequest(http.MethodHead, rawURL, nil)
|
||||
sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()}
|
||||
req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -199,10 +270,12 @@ func (s *CustomGeoService) EnsureOnStartup() {
|
|||
logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
|
||||
for i := range list {
|
||||
r := &list[i]
|
||||
if err := s.validateURL(r.Url); err != nil {
|
||||
sanitizedURL, err := s.sanitizeURL(r.Url)
|
||||
if err != nil {
|
||||
logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
|
||||
continue
|
||||
}
|
||||
r.Url = sanitizedURL
|
||||
s.syncLocalPath(r)
|
||||
localPath := r.LocalPath
|
||||
if !localDatFileNeedsRepair(localPath) {
|
||||
|
|
@ -218,28 +291,71 @@ func (s *CustomGeoService) EnsureOnStartup() {
|
|||
}
|
||||
|
||||
func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
|
||||
skipped, lm, err := s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, false)
|
||||
safeDestPath, err := sanitizeDestPath(destPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
if skipped {
|
||||
if _, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
|
||||
if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
|
||||
return true, lm, nil
|
||||
}
|
||||
return s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, true)
|
||||
return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
|
||||
}
|
||||
return false, lm, nil
|
||||
}
|
||||
|
||||
// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
|
||||
// It resolves symlinks to prevent symlink-based escapes.
|
||||
// Returns the cleaned absolute path that is safe to use in file operations.
|
||||
func sanitizeDestPath(destPath string) (string, error) {
|
||||
baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
|
||||
}
|
||||
// Resolve symlinks in base directory to get the real path.
|
||||
if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
|
||||
baseDirAbs = resolved
|
||||
}
|
||||
destPathAbs, err := filepath.Abs(destPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
|
||||
}
|
||||
// Resolve symlinks for the parent directory of the destination path.
|
||||
destDir := filepath.Dir(destPathAbs)
|
||||
if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
|
||||
destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
|
||||
}
|
||||
// Verify the resolved path is within the safe base directory using prefix check.
|
||||
safeDirPrefix := baseDirAbs + string(filepath.Separator)
|
||||
if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
|
||||
return "", ErrCustomGeoPathTraversal
|
||||
}
|
||||
return destPathAbs, nil
|
||||
}
|
||||
|
||||
func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
|
||||
safeDestPath, err := sanitizeDestPath(destPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
sanitizedURL, err := s.sanitizeURL(resourceURL)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
req, err = http.NewRequest(http.MethodGet, resourceURL, nil)
|
||||
req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
if !forceFull {
|
||||
if fi, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) {
|
||||
if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
|
||||
if !fi.ModTime().IsZero() {
|
||||
req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
|
||||
} else if lastModifiedHeader != "" {
|
||||
|
|
@ -250,7 +366,8 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last
|
|||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Minute}
|
||||
client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()}
|
||||
// lgtm[go/request-forgery]
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
|
|
@ -267,7 +384,7 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last
|
|||
|
||||
updateModTime := func() {
|
||||
if !serverModTime.IsZero() {
|
||||
_ = os.Chtimes(destPath, serverModTime, serverModTime)
|
||||
_ = os.Chtimes(safeDestPath, serverModTime, serverModTime)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,33 +399,36 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last
|
|||
return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
|
||||
}
|
||||
|
||||
binDir := filepath.Dir(destPath)
|
||||
binDir := filepath.Dir(safeDestPath)
|
||||
if err = os.MkdirAll(binDir, 0o755); err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
tmpPath := destPath + ".tmp"
|
||||
out, err := os.Create(tmpPath)
|
||||
safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
out, err := os.Create(safeTmpPath)
|
||||
if err != nil {
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
n, err := io.Copy(out, resp.Body)
|
||||
closeErr := out.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
_ = os.Remove(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
_ = os.Remove(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
|
||||
}
|
||||
if n < minDatBytes {
|
||||
_ = os.Remove(tmpPath)
|
||||
_ = os.Remove(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
|
||||
}
|
||||
|
||||
if err = os.Rename(tmpPath, destPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
|
||||
_ = os.Remove(safeTmpPath)
|
||||
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
|
||||
}
|
||||
|
||||
|
|
@ -331,6 +451,29 @@ func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
|
|||
r.LocalPath = p
|
||||
}
|
||||
|
||||
func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
|
||||
s.syncLocalPath(r)
|
||||
safePath, err := sanitizeDestPath(r.LocalPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.LocalPath = safePath
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeSafePathIfExists(path string) error {
|
||||
safePath, err := sanitizeDestPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(safePath); err == nil {
|
||||
if err := os.Remove(safePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
|
||||
if err := s.validateType(r.Type); err != nil {
|
||||
return err
|
||||
|
|
@ -338,16 +481,20 @@ func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
|
|||
if err := s.validateAlias(r.Alias); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateURL(r.Url); err != nil {
|
||||
sanitizedURL, err := s.sanitizeURL(r.Url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Url = sanitizedURL
|
||||
var existing int64
|
||||
database.GetDB().Model(&model.CustomGeoResource{}).
|
||||
Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
|
||||
if existing > 0 {
|
||||
return ErrCustomGeoDuplicateAlias
|
||||
}
|
||||
s.syncLocalPath(r)
|
||||
if err := s.syncAndSanitizeLocalPath(r); err != nil {
|
||||
return err
|
||||
}
|
||||
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -356,7 +503,7 @@ func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
|
|||
r.LastUpdatedAt = now
|
||||
r.LastModified = lm
|
||||
if err = database.GetDB().Create(r).Error; err != nil {
|
||||
_ = os.Remove(r.LocalPath)
|
||||
_ = removeSafePathIfExists(r.LocalPath)
|
||||
return err
|
||||
}
|
||||
logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
|
||||
|
|
@ -380,9 +527,11 @@ func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
|
|||
if err := s.validateAlias(r.Alias); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.validateURL(r.Url); err != nil {
|
||||
sanitizedURL, err := s.sanitizeURL(r.Url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.Url = sanitizedURL
|
||||
if cur.Type != r.Type || cur.Alias != r.Alias {
|
||||
var cnt int64
|
||||
database.GetDB().Model(&model.CustomGeoResource{}).
|
||||
|
|
@ -393,12 +542,13 @@ func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
|
|||
}
|
||||
}
|
||||
oldPath := s.resolveDestPath(&cur)
|
||||
s.syncLocalPath(r)
|
||||
r.Id = id
|
||||
r.LocalPath = filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
|
||||
if err := s.syncAndSanitizeLocalPath(r); err != nil {
|
||||
return err
|
||||
}
|
||||
if oldPath != r.LocalPath && oldPath != "" {
|
||||
if _, err := os.Stat(oldPath); err == nil {
|
||||
_ = os.Remove(oldPath)
|
||||
if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
|
||||
logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
|
||||
}
|
||||
}
|
||||
_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
|
||||
|
|
@ -435,14 +585,15 @@ func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
|
|||
}
|
||||
displayName = s.fileNameFor(r.Type, r.Alias)
|
||||
p := s.resolveDestPath(&r)
|
||||
if _, err := sanitizeDestPath(p); err != nil {
|
||||
return displayName, err
|
||||
}
|
||||
if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
|
||||
return displayName, err
|
||||
}
|
||||
if p != "" {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
if rmErr := os.Remove(p); rmErr != nil {
|
||||
logger.Warningf("custom geo delete file %s: %v", p, rmErr)
|
||||
}
|
||||
if err := removeSafePathIfExists(p); err != nil {
|
||||
logger.Warningf("custom geo delete file %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
logger.Infof("custom geo deleted id=%d", id)
|
||||
|
|
@ -467,8 +618,14 @@ func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (disp
|
|||
return "", err
|
||||
}
|
||||
displayName = s.fileNameFor(r.Type, r.Alias)
|
||||
s.syncLocalPath(&r)
|
||||
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
|
||||
if err := s.syncAndSanitizeLocalPath(&r); err != nil {
|
||||
return displayName, err
|
||||
}
|
||||
sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
|
||||
if sanitizeErr != nil {
|
||||
return displayName, sanitizeErr
|
||||
}
|
||||
skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified)
|
||||
if err != nil {
|
||||
if onStartup {
|
||||
logger.Warningf("custom geo startup download id=%d: %v", id, err)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
|
@ -12,6 +13,15 @@ import (
|
|||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
)
|
||||
|
||||
// disableSSRFCheck disables the SSRF guard for the duration of a test,
|
||||
// allowing httptest servers on localhost. It restores the original on cleanup.
|
||||
func disableSSRFCheck(t *testing.T) {
|
||||
t.Helper()
|
||||
orig := checkSSRF
|
||||
checkSSRF = func(_ context.Context, _ string) error { return nil }
|
||||
t.Cleanup(func() { checkSSRF = orig })
|
||||
}
|
||||
|
||||
func TestNormalizeAliasKey(t *testing.T) {
|
||||
if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
|
||||
t.Fatalf("got %q", got)
|
||||
|
|
@ -139,14 +149,16 @@ func TestCustomGeoValidateAlias(t *testing.T) {
|
|||
|
||||
func TestCustomGeoValidateURL(t *testing.T) {
|
||||
s := CustomGeoService{}
|
||||
if err := s.validateURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
|
||||
if _, err := s.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
if err := s.validateURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
|
||||
if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
|
||||
t.Fatal("ftp")
|
||||
}
|
||||
if err := s.validateURL("https://example.com/a.dat"); err != nil {
|
||||
if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if sanitized != "https://example.com/a.dat" {
|
||||
t.Fatalf("unexpected sanitized URL: %s", sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +173,7 @@ func TestCustomGeoValidateType(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCustomGeoDownloadToPath(t *testing.T) {
|
||||
disableSSRFCheck(t)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Test", "1")
|
||||
if r.Header.Get("If-Modified-Since") != "" {
|
||||
|
|
@ -193,6 +206,7 @@ func TestCustomGeoDownloadToPath(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
|
||||
disableSSRFCheck(t)
|
||||
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-Modified-Since") != "" {
|
||||
|
|
@ -221,6 +235,7 @@ func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
|
||||
disableSSRFCheck(t)
|
||||
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("If-Modified-Since") != "" {
|
||||
|
|
@ -264,6 +279,7 @@ func TestCustomGeoFileNameFor(t *testing.T) {
|
|||
|
||||
func TestLocalDatFileNeedsRepair(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", dir)
|
||||
if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
|
||||
t.Fatal("missing")
|
||||
}
|
||||
|
|
@ -297,6 +313,7 @@ func TestLocalDatFileNeedsRepair(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
|
||||
disableSSRFCheck(t)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -311,6 +328,7 @@ func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
|
||||
disableSSRFCheck(t)
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ var defaultValueMap = map[string]string{
|
|||
"subURI": "",
|
||||
"subJsonPath": "/json/",
|
||||
"subJsonURI": "",
|
||||
"subClashEnable": "true",
|
||||
"subClashPath": "/clash/",
|
||||
"subClashURI": "",
|
||||
"subJsonFragment": "",
|
||||
"subJsonNoises": "",
|
||||
"subJsonMux": "",
|
||||
|
|
@ -555,6 +558,18 @@ 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) GetSubJsonFragment() (string, error) {
|
||||
return s.getString("subJsonFragment")
|
||||
}
|
||||
|
|
@ -743,20 +758,22 @@ func extractHostname(host string) string {
|
|||
func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
||||
type settingFunc func() (any, error)
|
||||
settings := map[string]settingFunc{
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
"expireDiff": func() (any, error) { return s.GetExpireDiff() },
|
||||
"trafficDiff": func() (any, error) { return s.GetTrafficDiff() },
|
||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"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() },
|
||||
"remarkModel": func() (any, error) { return s.GetRemarkModel() },
|
||||
"datepicker": func() (any, error) { return s.GetDatepicker() },
|
||||
"ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() },
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
|
|
@ -776,12 +793,19 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
subJsonEnable = b
|
||||
}
|
||||
}
|
||||
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
|
||||
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) == "") {
|
||||
subURI := ""
|
||||
subTitle, _ := s.GetSubTitle()
|
||||
subPort, _ := s.GetSubPort()
|
||||
subPath, _ := s.GetSubPath()
|
||||
subJsonPath, _ := s.GetSubJsonPath()
|
||||
subClashPath, _ := s.GetSubClashPath()
|
||||
subDomain, _ := s.GetSubDomain()
|
||||
subKeyFile, _ := s.GetSubKeyFile()
|
||||
subCertFile, _ := s.GetSubCertFile()
|
||||
|
|
@ -811,6 +835,9 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||
result["subJsonURI"] = subURI + subJsonPath
|
||||
}
|
||||
if subClashEnable && result["subClashURI"].(string) == "" {
|
||||
result["subClashURI"] = subURI + subClashPath
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
|
|
|||
Loading…
Reference in a new issue