347 lines
8.2 KiB
Go
347 lines
8.2 KiB
Go
package render
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/peterqiu0516/sub-store/internal/model"
|
|
)
|
|
|
|
// RenderTextProxyList renders proxies into newline-separated text lines using
|
|
// the given producer. If the producer returns "" for a proxy it is skipped.
|
|
// Returns an error when no supported nodes were produced.
|
|
func RenderTextProxyList(proxies []model.ProxyNode, target string, producer func(model.ProxyNode) string) (string, error) {
|
|
var lines []string
|
|
for _, p := range proxies {
|
|
line := producer(p)
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
return "", fmt.Errorf("No supported nodes for %s output", target)
|
|
}
|
|
return strings.Join(lines, "\n"), nil
|
|
}
|
|
|
|
// JoinTextProxy joins a proxy base line with key=value entries.
|
|
// Entries with nil or empty-string values are dropped.
|
|
func JoinTextProxy(base string, entries [][2]any) string {
|
|
var parts []string
|
|
for _, entry := range entries {
|
|
key, _ := entry[0].(string)
|
|
value := entry[1]
|
|
if value == nil {
|
|
continue
|
|
}
|
|
if s, ok := value.(string); ok && s == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, key+"="+FormatTextOptionValue(value))
|
|
}
|
|
if len(parts) == 0 {
|
|
return base
|
|
}
|
|
return base + "," + strings.Join(parts, ",")
|
|
}
|
|
|
|
// FormatTextOptionValue formats a value for a text proxy option line.
|
|
func FormatTextOptionValue(value any) string {
|
|
switch v := value.(type) {
|
|
case bool:
|
|
if v {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
case []any:
|
|
strs := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
strs = append(strs, fmt.Sprint(item))
|
|
}
|
|
return QuoteTextValue(strings.Join(strs, ","))
|
|
case []string:
|
|
return QuoteTextValue(strings.Join(v, ","))
|
|
case nil:
|
|
return ""
|
|
default:
|
|
text := fmt.Sprint(value)
|
|
if strings.ContainsAny(text, ", \t\"") {
|
|
return QuoteTextValue(text)
|
|
}
|
|
return text
|
|
}
|
|
}
|
|
|
|
// QuoteTextValue wraps a string in double quotes, escaping inner quotes.
|
|
func QuoteTextValue(value any) string {
|
|
s := ""
|
|
if value != nil {
|
|
s = fmt.Sprint(value)
|
|
}
|
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
|
return `"` + s + `"`
|
|
}
|
|
|
|
// SanitizeTextProxyName replaces characters that are illegal in text proxy
|
|
// names (=, comma, CR, LF) with spaces. Returns "proxy" when empty.
|
|
func SanitizeTextProxyName(name string) string {
|
|
r := strings.NewReplacer("=", " ", ",", " ", "\r", " ", "\n", " ")
|
|
s := strings.TrimSpace(r.Replace(name))
|
|
if s == "" {
|
|
return "proxy"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// CommonTextOptions returns the shared option entries used by text proxy
|
|
// renderers (surge, loon, qx, etc.).
|
|
func CommonTextOptions(proxy model.ProxyNode) [][2]any {
|
|
return [][2]any{
|
|
{"skip-cert-verify", proxy["skip-cert-verify"]},
|
|
{"udp-relay", proxy["udp"]},
|
|
{"fast-open", fastOpenValue(proxy)},
|
|
{"alpn", FormatAlpn(proxy["alpn"])},
|
|
}
|
|
}
|
|
|
|
func fastOpenValue(proxy model.ProxyNode) any {
|
|
if v, ok := proxy["tfo"]; ok && v != nil {
|
|
return v
|
|
}
|
|
return proxy["fast-open"]
|
|
}
|
|
|
|
// AppendWsOptions appends websocket transport options for surge/loon targets.
|
|
func AppendWsOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any {
|
|
if getString(proxy, "network") != "ws" {
|
|
return entries
|
|
}
|
|
wsOpts, _ := proxy["ws-opts"].(map[string]any)
|
|
path := "/"
|
|
if wsOpts != nil {
|
|
if p, ok := wsOpts["path"].(string); ok && p != "" {
|
|
path = p
|
|
}
|
|
}
|
|
host := WsHeaderHost(wsOpts)
|
|
if target == "surge" {
|
|
entries = append(entries, [2]any{"ws", true}, [2]any{"ws-path", path}, [2]any{"ws-headers", host})
|
|
} else {
|
|
entries = append(entries, [2]any{"path", path}, [2]any{"host", host})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// AppendPluginOptions appends shadowsocks obfs plugin options.
|
|
func AppendPluginOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any {
|
|
if getString(proxy, "plugin") != "obfs" {
|
|
return entries
|
|
}
|
|
pluginOpts, _ := proxy["plugin-opts"].(map[string]any)
|
|
if pluginOpts == nil {
|
|
return entries
|
|
}
|
|
mode := pluginOpts["mode"]
|
|
host := pluginOpts["host"]
|
|
path := pluginOpts["path"]
|
|
if target == "surge" {
|
|
entries = append(entries,
|
|
[2]any{"obfs", mode},
|
|
[2]any{"obfs-host", host},
|
|
[2]any{"obfs-uri", path},
|
|
)
|
|
} else {
|
|
entries = append(entries,
|
|
[2]any{"obfs-name", mode},
|
|
[2]any{"obfs-host", host},
|
|
[2]any{"obfs-uri", path},
|
|
)
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// AppendRealityOptions appends reality options for loon.
|
|
func AppendRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any {
|
|
realityOpts, _ := proxy["reality-opts"].(map[string]any)
|
|
var pubKey, shortID any
|
|
if realityOpts != nil {
|
|
pubKey = realityOpts["public-key"]
|
|
shortID = realityOpts["short-id"]
|
|
}
|
|
return append(entries, [2]any{"public-key", pubKey}, [2]any{"short-id", shortID})
|
|
}
|
|
|
|
// AppendQxRealityOptions appends reality options for Quantumult X.
|
|
func AppendQxRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any {
|
|
realityOpts, _ := proxy["reality-opts"].(map[string]any)
|
|
var pubKey, shortID any
|
|
if realityOpts != nil {
|
|
pubKey = realityOpts["public-key"]
|
|
shortID = realityOpts["short-id"]
|
|
}
|
|
return append(entries, [2]any{"reality-base64-pubkey", pubKey}, [2]any{"reality-hex-shortid", shortID})
|
|
}
|
|
|
|
// AppendQxObfs appends shadowsocks obfs options for Quantumult X.
|
|
func AppendQxObfs(entries [][2]any, proxy model.ProxyNode) [][2]any {
|
|
if getString(proxy, "plugin") != "obfs" {
|
|
return entries
|
|
}
|
|
pluginOpts, _ := proxy["plugin-opts"].(map[string]any)
|
|
if pluginOpts == nil {
|
|
return entries
|
|
}
|
|
return append(entries,
|
|
[2]any{"obfs", pluginOpts["mode"]},
|
|
[2]any{"obfs-host", pluginOpts["host"]},
|
|
[2]any{"obfs-uri", pluginOpts["path"]},
|
|
)
|
|
}
|
|
|
|
// AppendQxTransport appends websocket transport options for Quantumult X.
|
|
func AppendQxTransport(entries [][2]any, proxy model.ProxyNode) [][2]any {
|
|
if getString(proxy, "network") != "ws" {
|
|
return entries
|
|
}
|
|
wsOpts, _ := proxy["ws-opts"].(map[string]any)
|
|
path := "/"
|
|
if wsOpts != nil {
|
|
if p, ok := wsOpts["path"].(string); ok && p != "" {
|
|
path = p
|
|
}
|
|
}
|
|
transport := "ws"
|
|
if getBool(proxy, "tls") {
|
|
transport = "wss"
|
|
}
|
|
return append(entries,
|
|
[2]any{"obfs", transport},
|
|
[2]any{"obfs-uri", path},
|
|
[2]any{"obfs-host", WsHeaderHost(wsOpts)},
|
|
)
|
|
}
|
|
|
|
// WsHeaderHost extracts the Host header from ws-opts headers (case-insensitive).
|
|
func WsHeaderHost(wsOpts map[string]any) string {
|
|
if wsOpts == nil {
|
|
return ""
|
|
}
|
|
headers, ok := wsOpts["headers"].(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if v, ok := headers["Host"]; ok {
|
|
return fmt.Sprint(v)
|
|
}
|
|
if v, ok := headers["host"]; ok {
|
|
return fmt.Sprint(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// FormatAlpn formats an alpn value — joins array with commas or returns the string.
|
|
func FormatAlpn(value any) string {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return ""
|
|
case []any:
|
|
var parts []string
|
|
for _, item := range v {
|
|
s := fmt.Sprint(item)
|
|
if s != "" {
|
|
parts = append(parts, s)
|
|
}
|
|
}
|
|
return strings.Join(parts, ",")
|
|
case []string:
|
|
var parts []string
|
|
for _, item := range v {
|
|
if item != "" {
|
|
parts = append(parts, item)
|
|
}
|
|
}
|
|
return strings.Join(parts, ",")
|
|
case string:
|
|
return v
|
|
default:
|
|
return fmt.Sprint(value)
|
|
}
|
|
}
|
|
|
|
// --- internal helpers shared across renderers ---
|
|
|
|
func getString(proxy model.ProxyNode, key string) string {
|
|
v, _ := proxy[key].(string)
|
|
return v
|
|
}
|
|
|
|
func getBool(proxy model.ProxyNode, key string) bool {
|
|
switch v := proxy[key].(type) {
|
|
case bool:
|
|
return v
|
|
case string:
|
|
return v == "true" || v == "1"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// getInt returns an int value, handling both int and float64 from JSON.
|
|
func getInt(proxy model.ProxyNode, key string) int {
|
|
switch v := proxy[key].(type) {
|
|
case int:
|
|
return v
|
|
case int64:
|
|
return int(v)
|
|
case float64:
|
|
return int(v)
|
|
case string:
|
|
var i int
|
|
fmt.Sscanf(v, "%d", &i)
|
|
return i
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// stringSetting returns the string form of a value, or "" when nil.
|
|
func stringSetting(v any) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprint(v)
|
|
}
|
|
|
|
// numberOrUndefined converts a value to a number, returning 0 when nil/empty.
|
|
func numberOrUndefined(v any) int {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
switch n := v.(type) {
|
|
case int:
|
|
return n
|
|
case int64:
|
|
return int(n)
|
|
case float64:
|
|
return int(n)
|
|
case string:
|
|
if n == "" {
|
|
return 0
|
|
}
|
|
var i int
|
|
fmt.Sscanf(n, "%d", &i)
|
|
return i
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// hasValue returns true when value is non-nil and non-empty-string.
|
|
func hasValue(v any) bool {
|
|
if v == nil {
|
|
return false
|
|
}
|
|
if s, ok := v.(string); ok && s == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|