Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
package proxyio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/prism/proxy/internal/country"
|
||||
"github.com/prism/proxy/internal/store"
|
||||
"github.com/prism/proxy/internal/subscription"
|
||||
)
|
||||
|
||||
const ExportVersion = 1
|
||||
|
||||
type ExportDoc struct {
|
||||
Version int `json:"version"`
|
||||
Nodes []ExportNode `json:"nodes"`
|
||||
}
|
||||
|
||||
type ExportNode struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
RawConfig json.RawMessage `json:"raw_config"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
func nodeEnabled(n ExportNode) bool {
|
||||
if n.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *n.Enabled
|
||||
}
|
||||
|
||||
func ToExportNodes(nodes []store.ProxyNode) []ExportNode {
|
||||
out := make([]ExportNode, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
enabled := n.Enabled
|
||||
raw := json.RawMessage(n.RawConfig)
|
||||
if !json.Valid(raw) {
|
||||
raw = json.RawMessage("{}")
|
||||
}
|
||||
out = append(out, ExportNode{
|
||||
Name: n.Name,
|
||||
Type: n.Type,
|
||||
RawConfig: raw,
|
||||
Country: n.Country,
|
||||
Enabled: &enabled,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ExportJSON(nodes []store.ProxyNode) ([]byte, error) {
|
||||
doc := ExportDoc{Version: ExportVersion, Nodes: ToExportNodes(nodes)}
|
||||
return json.MarshalIndent(doc, "", " ")
|
||||
}
|
||||
|
||||
func ExportLinks(nodes []store.ProxyNode) string {
|
||||
var b strings.Builder
|
||||
for _, n := range nodes {
|
||||
if !n.Enabled {
|
||||
continue
|
||||
}
|
||||
link, err := EncodeLink(n)
|
||||
if err != nil {
|
||||
b.WriteString("# ")
|
||||
b.WriteString(n.Name)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(err.Error())
|
||||
b.WriteByte('\n')
|
||||
continue
|
||||
}
|
||||
b.WriteString(link)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func ParseJSON(data []byte) ([]ExportNode, error) {
|
||||
var doc ExportDoc
|
||||
if err := json.Unmarshal(data, &doc); err == nil && (len(doc.Nodes) > 0 || doc.Version > 0) {
|
||||
return doc.Nodes, nil
|
||||
}
|
||||
var nodes []ExportNode
|
||||
if err := json.Unmarshal(data, &nodes); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func ParseText(text string) ([]store.ProxyNode, []string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(text, "{") || strings.HasPrefix(text, "[") {
|
||||
items, err := ParseJSON([]byte(text))
|
||||
if err == nil && len(items) > 0 {
|
||||
nodes, errs := ToStoreNodes(items)
|
||||
return nodes, errs
|
||||
}
|
||||
}
|
||||
return subscription.ParseLinkLines(text)
|
||||
}
|
||||
|
||||
func ToStoreNodes(items []ExportNode) ([]store.ProxyNode, []string) {
|
||||
out := make([]store.ProxyNode, 0, len(items))
|
||||
var errs []string
|
||||
for i, item := range items {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
typ := strings.TrimSpace(strings.ToLower(item.Type))
|
||||
raw := normalizeRawConfig(item.RawConfig)
|
||||
if name == "" {
|
||||
errs = append(errs, fmt.Sprintf("节点 %d: 缺少名称", i+1))
|
||||
continue
|
||||
}
|
||||
if typ == "" {
|
||||
errs = append(errs, fmt.Sprintf("节点 %d: 缺少类型", i+1))
|
||||
continue
|
||||
}
|
||||
if raw == "" || raw == "{}" {
|
||||
errs = append(errs, fmt.Sprintf("节点 %d: 缺少配置", i+1))
|
||||
continue
|
||||
}
|
||||
code := strings.TrimSpace(item.Country)
|
||||
if code == "" {
|
||||
code = country.Detect(name)
|
||||
}
|
||||
out = append(out, store.ProxyNode{
|
||||
Name: name,
|
||||
RuntimeName: store.RuntimeName("manual", 0, name),
|
||||
Type: typ,
|
||||
RawConfig: raw,
|
||||
Source: "manual",
|
||||
Country: code,
|
||||
Enabled: nodeEnabled(item),
|
||||
})
|
||||
}
|
||||
return out, errs
|
||||
}
|
||||
|
||||
func normalizeRawConfig(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
if raw[0] == '{' || raw[0] == '[' {
|
||||
if !json.Valid(raw) {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
func AssignUniqueNames(nodes []store.ProxyNode, reserved map[string]bool) {
|
||||
if reserved == nil {
|
||||
reserved = map[string]bool{}
|
||||
}
|
||||
for i := range nodes {
|
||||
name := strings.TrimSpace(nodes[i].Name)
|
||||
if name == "" {
|
||||
name = "node"
|
||||
}
|
||||
base := name
|
||||
candidate := base
|
||||
for n := 2; reserved[candidate]; n++ {
|
||||
candidate = fmt.Sprintf("%s_%d", base, n)
|
||||
}
|
||||
reserved[candidate] = true
|
||||
nodes[i].Name = candidate
|
||||
nodes[i].RuntimeName = store.RuntimeName("manual", 0, candidate)
|
||||
if nodes[i].Country == "" {
|
||||
nodes[i].Country = country.Detect(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ReservedNames(nodes []store.ProxyNode) map[string]bool {
|
||||
m := make(map[string]bool, len(nodes))
|
||||
for _, n := range nodes {
|
||||
m[n.Name] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func EncodeLink(n store.ProxyNode) (string, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(n.RawConfig), &raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
fragment := url.QueryEscape(n.Name)
|
||||
typ := strings.ToLower(n.Type)
|
||||
switch typ {
|
||||
case "socks5":
|
||||
return encodeSocks5(raw, fragment)
|
||||
case "http", "https":
|
||||
return encodeHTTP(raw, fragment, typ == "https")
|
||||
case "trojan":
|
||||
return encodeTrojan(raw, fragment)
|
||||
default:
|
||||
return "", fmt.Errorf("类型 %s 暂不支持导出为分享链接", n.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeSocks5(raw map[string]any, fragment string) (string, error) {
|
||||
server, _ := raw["server"].(string)
|
||||
port := intFromAny(raw["port"])
|
||||
if server == "" || port <= 0 {
|
||||
return "", fmt.Errorf("socks5 配置不完整")
|
||||
}
|
||||
host := fmt.Sprintf("%s:%d", server, port)
|
||||
user, _ := raw["username"].(string)
|
||||
pass, _ := raw["password"].(string)
|
||||
if user != "" {
|
||||
return fmt.Sprintf("socks5://%s:%s@%s#%s", url.QueryEscape(user), url.QueryEscape(pass), host, fragment), nil
|
||||
}
|
||||
return fmt.Sprintf("socks5://%s#%s", host, fragment), nil
|
||||
}
|
||||
|
||||
func encodeHTTP(raw map[string]any, fragment string, tls bool) (string, error) {
|
||||
server, _ := raw["server"].(string)
|
||||
port := intFromAny(raw["port"])
|
||||
if server == "" || port <= 0 {
|
||||
return "", fmt.Errorf("http 配置不完整")
|
||||
}
|
||||
scheme := "http"
|
||||
if tls {
|
||||
scheme = "https"
|
||||
}
|
||||
host := fmt.Sprintf("%s:%d", server, port)
|
||||
user, _ := raw["username"].(string)
|
||||
pass, _ := raw["password"].(string)
|
||||
if user != "" {
|
||||
return fmt.Sprintf("%s://%s:%s@%s#%s", scheme, url.QueryEscape(user), url.QueryEscape(pass), host, fragment), nil
|
||||
}
|
||||
return fmt.Sprintf("%s://%s#%s", scheme, host, fragment), nil
|
||||
}
|
||||
|
||||
func encodeTrojan(raw map[string]any, fragment string) (string, error) {
|
||||
server, _ := raw["server"].(string)
|
||||
port := intFromAny(raw["port"])
|
||||
pass, _ := raw["password"].(string)
|
||||
if server == "" || port <= 0 || pass == "" {
|
||||
return "", fmt.Errorf("trojan 配置不完整")
|
||||
}
|
||||
return fmt.Sprintf("trojan://%s@%s:%d#%s", url.QueryEscape(pass), server, port, fragment), nil
|
||||
}
|
||||
|
||||
func intFromAny(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return int(i)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package proxyio
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prism/proxy/internal/store"
|
||||
)
|
||||
|
||||
func TestExportLinksSocks5(t *testing.T) {
|
||||
text := ExportLinks([]store.ProxyNode{{
|
||||
Name: "HK-1",
|
||||
Type: "socks5",
|
||||
RawConfig: `{"type":"socks5","server":"1.2.3.4","port":1080,"username":"u","password":"p"}`,
|
||||
Enabled: true,
|
||||
}})
|
||||
if !strings.Contains(text, "socks5://u:p@1.2.3.4:1080#HK-1") {
|
||||
t.Fatalf("got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONDoc(t *testing.T) {
|
||||
nodes, err := ParseJSON([]byte(`{"version":1,"nodes":[{"name":"n1","type":"socks5","raw_config":{"server":"1.1.1.1","port":1080}}]}`))
|
||||
if err != nil || len(nodes) != 1 {
|
||||
t.Fatalf("nodes=%+v err=%v", nodes, err)
|
||||
}
|
||||
stored, errs := ToStoreNodes(nodes)
|
||||
if len(errs) != 0 || len(stored) != 1 || stored[0].Name != "n1" {
|
||||
t.Fatalf("stored=%+v errs=%v", stored, errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignUniqueNames(t *testing.T) {
|
||||
nodes := []store.ProxyNode{{Name: "a"}, {Name: "a"}}
|
||||
reserved := map[string]bool{"a": true}
|
||||
AssignUniqueNames(nodes, reserved)
|
||||
if nodes[0].Name != "a_2" || nodes[1].Name != "a_3" {
|
||||
t.Fatalf("names: %s, %s", nodes[0].Name, nodes[1].Name)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user