Files
renjue fe8ea784d0
CI / docker (push) Successful in 2m54s
feat: Prism HTTP/SOCKS5 代理网关及管理台
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:58:28 +08:00

465 lines
12 KiB
Go

package subscription
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/prism/proxy/internal/country"
"github.com/prism/proxy/internal/store"
)
const subscriptionUserAgent = "clash.meta/1.19.0"
type Service struct {
store *store.Store
client *http.Client
}
func NewService(s *store.Store) *Service {
return &Service{
store: s,
client: &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
req.Header.Set("User-Agent", subscriptionUserAgent)
return nil
},
},
}
}
type ParsedData struct {
Nodes []store.ProxyNode
Groups []store.ProxyGroup
Rules []store.Rule
}
func (s *Service) FetchAndStore(ctx context.Context, subID int64) error {
sub, err := s.store.GetSubscription(ctx, subID)
if err != nil {
return err
}
body, err := s.fetchURL(sub.URL)
if err != nil {
_ = s.store.UpdateSubscriptionFetch(ctx, subID, 0, err.Error())
return err
}
parsed, err := s.parseSubscription(body, subID, sub.Type)
if err != nil {
_ = s.store.UpdateSubscriptionFetch(ctx, subID, 0, err.Error())
return err
}
if err := s.store.ReplaceSubscriptionData(ctx, subID, parsed.Nodes, parsed.Groups, parsed.Rules); err != nil {
_ = s.store.UpdateSubscriptionFetch(ctx, subID, 0, err.Error())
return err
}
return s.store.UpdateSubscriptionFetch(ctx, subID, len(parsed.Nodes), "")
}
func (s *Service) ParseSubscription(body []byte, subID int64, subType string) (ParsedData, error) {
return s.parseSubscription(body, subID, subType)
}
func (s *Service) parseSubscription(body []byte, subID int64, subType string) (ParsedData, error) {
content := decodeMaybeBase64(body)
text := strings.TrimSpace(string(content))
format := subType
if format == "" || format == "auto" {
format = detectFormat(text)
}
switch format {
case "links":
return s.parseLinks(body, subID)
default:
parsed, err := s.parseClash(body, subID)
if err != nil && looksLikeLinks(text) {
return s.parseLinks(body, subID)
}
return parsed, err
}
}
func detectFormat(text string) string {
if strings.HasPrefix(text, "port:") || strings.HasPrefix(text, "mixed-port:") ||
strings.HasPrefix(text, "proxies:") || strings.Contains(text, "\nproxies:") {
return "clash"
}
if looksLikeLinks(text) {
return "links"
}
return "clash"
}
func looksLikeLinks(text string) bool {
lower := strings.ToLower(text)
for _, prefix := range []string{"ss://", "vmess://", "trojan://", "socks5://", "http://", "https://"} {
if strings.Contains(lower, prefix) {
return true
}
}
return false
}
func (s *Service) fetchURL(rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", subscriptionUserAgent)
req.Header.Set("Accept", "*/*")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("fetch failed: HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func (s *Service) parseClash(body []byte, subID int64) (ParsedData, error) {
content := decodeMaybeBase64(body)
var raw map[string]any
if err := yaml.Unmarshal(content, &raw); err != nil {
return ParsedData{}, fmt.Errorf("invalid clash yaml: %w", err)
}
if _, ok := raw["proxies"]; !ok {
return ParsedData{}, fmt.Errorf("clash config missing proxies")
}
var out ParsedData
if proxies, ok := raw["proxies"].([]any); ok {
for i, p := range proxies {
pm, ok := p.(map[string]any)
if !ok {
continue
}
name, _ := pm["name"].(string)
if name == "" {
name = fmt.Sprintf("node%d", i+1)
}
rawJSON, _ := json.Marshal(pm)
out.Nodes = append(out.Nodes, store.ProxyNode{
Name: name,
RuntimeName: store.RuntimeName("sub", subID, fmt.Sprintf("%d_%s", i, name)),
Type: fmt.Sprint(pm["type"]),
RawConfig: string(rawJSON),
Country: country.Detect(name),
Enabled: true,
})
}
}
if groups, ok := raw["proxy-groups"].([]any); ok {
for i, g := range groups {
gm, ok := g.(map[string]any)
if !ok {
continue
}
name, _ := gm["name"].(string)
if name == "" {
continue
}
gtype, _ := gm["type"].(string)
var members []string
if arr, ok := gm["proxies"].([]any); ok {
for _, m := range arr {
members = append(members, fmt.Sprint(m))
}
}
membersJSON, _ := json.Marshal(members)
out.Groups = append(out.Groups, store.ProxyGroup{
Name: name,
RuntimeName: store.RuntimeName("grp", subID, fmt.Sprintf("%d_%s", i, name)),
Type: gtype,
Proxies: string(membersJSON),
Enabled: true,
})
}
}
if rules, ok := raw["rules"].([]any); ok {
for _, r := range rules {
line := fmt.Sprint(r)
parts := strings.Split(line, ",")
if len(parts) < 2 {
continue
}
rule := store.Rule{
RuleType: parts[0],
Target: parts[len(parts)-1],
Enabled: true,
}
if len(parts) > 2 {
rule.Payload = strings.Join(parts[1:len(parts)-1], ",")
}
out.Rules = append(out.Rules, rule)
}
}
return out, nil
}
func (s *Service) parseLinks(body []byte, subID int64) (ParsedData, error) {
text := string(decodeMaybeBase64(body))
lines := strings.Split(text, "\n")
var out ParsedData
idx := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
node, err := parseLink(line, subID, idx)
if err != nil {
continue
}
out.Nodes = append(out.Nodes, node)
idx++
}
if len(out.Nodes) == 0 {
return ParsedData{}, fmt.Errorf("no valid proxy links found")
}
names := make([]string, 0, len(out.Nodes))
for _, n := range out.Nodes {
names = append(names, n.RuntimeName)
}
namesJSON, _ := json.Marshal(names)
out.Groups = append(out.Groups, store.ProxyGroup{
Name: fmt.Sprintf("sub%d", subID),
RuntimeName: store.RuntimeName("grp", subID, "auto"),
Type: "url-test",
Proxies: string(namesJSON),
Enabled: true,
})
return out, nil
}
func parseLink(link string, subID int64, idx int) (store.ProxyNode, error) {
lower := strings.ToLower(link)
var proxy map[string]any
var name string
var ptype string
switch {
case strings.HasPrefix(lower, "ss://"):
proxy, name, ptype = parseSS(link)
case strings.HasPrefix(lower, "socks5://"):
proxy, name, ptype = parseSocks5(link)
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
proxy, name, ptype = parseHTTP(link)
case strings.HasPrefix(lower, "trojan://"):
proxy, name, ptype = parseTrojan(link)
default:
return store.ProxyNode{}, fmt.Errorf("unsupported link")
}
if proxy == nil {
return store.ProxyNode{}, fmt.Errorf("failed to parse link")
}
if name == "" {
name = fmt.Sprintf("node%d", idx+1)
}
raw, _ := json.Marshal(proxy)
return store.ProxyNode{
Name: name,
RuntimeName: store.RuntimeName("sub", subID, name),
Type: ptype,
RawConfig: string(raw),
Country: country.Detect(name),
Enabled: true,
}, nil
}
func parseSS(link string) (map[string]any, string, string) {
name, payload := splitLinkFragment(link, "ss://")
if payload == "" {
return nil, name, "ss"
}
// SIP002: ss://BASE64(method:password@host:port)#name
if proxy, ok := parseSIP002(payload); ok {
return proxy, name, "ss"
}
// 传统格式: ss://BASE64(method:password)@host:port#name
// 或 ss://BASE64(method:password)@BASE64(host:port)#name(部分机场订阅)
parts := strings.SplitN(payload, "@", 2)
if len(parts) != 2 {
return nil, name, "ss"
}
userinfo := string(decodeBase64Flexible(parts[0]))
if userinfo == "" {
userinfo = parts[0]
}
hostport := string(decodeBase64Flexible(parts[1]))
if hostport == "" {
hostport = parts[1]
}
server, portStr, ok := strings.Cut(hostport, ":")
if !ok {
return nil, name, "ss"
}
var port int
fmt.Sscanf(portStr, "%d", &port)
cipher, password, _ := strings.Cut(userinfo, ":")
if cipher == "" {
cipher = "aes-256-gcm"
}
return map[string]any{
"type": "ss",
"server": server,
"port": port,
"cipher": cipher,
"password": password,
}, name, "ss"
}
func parseSIP002(payload string) (map[string]any, bool) {
decoded := string(decodeBase64Flexible(payload))
at := strings.LastIndex(decoded, "@")
if at <= 0 || !strings.Contains(decoded[:at], ":") {
return nil, false
}
userinfo := decoded[:at]
hostport := decoded[at+1:]
server, portStr, ok := strings.Cut(hostport, ":")
if !ok || server == "" {
return nil, false
}
var port int
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil || port <= 0 {
return nil, false
}
cipher, password, ok := strings.Cut(userinfo, ":")
if !ok || cipher == "" {
return nil, false
}
return map[string]any{
"type": "ss",
"server": server,
"port": port,
"cipher": cipher,
"password": password,
}, true
}
func splitLinkFragment(link, scheme string) (name, payload string) {
rest := strings.TrimPrefix(link, scheme)
if i := strings.Index(rest, "#"); i >= 0 {
name, _ = url.QueryUnescape(rest[i+1:])
rest = rest[:i]
}
return name, rest
}
func decodeBase64Flexible(s string) []byte {
decoders := []func(string) ([]byte, error){
base64.StdEncoding.DecodeString,
base64.RawStdEncoding.DecodeString,
base64.URLEncoding.DecodeString,
base64.RawURLEncoding.DecodeString,
}
for _, dec := range decoders {
if b, err := dec(s); err == nil && len(b) > 0 {
return b
}
}
return nil
}
func parseSocks5(link string) (map[string]any, string, string) {
name, payload := splitLinkFragment(link, "socks5://")
user, pass := "", ""
if at := strings.LastIndex(payload, "@"); at >= 0 {
auth := payload[:at]
payload = payload[at+1:]
if c := strings.Index(auth, ":"); c >= 0 {
user, pass = auth[:c], auth[c+1:]
}
}
server, portStr, _ := strings.Cut(payload, ":")
var port int
fmt.Sscanf(portStr, "%d", &port)
m := map[string]any{"type": "socks5", "server": server, "port": port}
if user != "" {
m["username"] = user
m["password"] = pass
}
return m, name, "socks5"
}
func parseHTTP(link string) (map[string]any, string, string) {
isTLS := strings.HasPrefix(strings.ToLower(link), "https://")
scheme := "http://"
if isTLS {
scheme = "https://"
}
name, payload := splitLinkFragment(link, scheme)
user, pass := "", ""
if at := strings.LastIndex(payload, "@"); at >= 0 {
auth := payload[:at]
payload = payload[at+1:]
if c := strings.Index(auth, ":"); c >= 0 {
user, pass = auth[:c], auth[c+1:]
}
}
server, portStr, _ := strings.Cut(payload, ":")
var port int
fmt.Sscanf(portStr, "%d", &port)
m := map[string]any{"type": "http", "server": server, "port": port, "tls": isTLS}
if user != "" {
m["username"] = user
m["password"] = pass
}
return m, name, "http"
}
func parseTrojan(link string) (map[string]any, string, string) {
name, payload := splitLinkFragment(link, "trojan://")
pass, rest, _ := strings.Cut(payload, "@")
server, portStr, _ := strings.Cut(rest, ":")
var port int
fmt.Sscanf(portStr, "%d", &port)
if q := strings.Index(server, "?"); q >= 0 {
server = server[:q]
}
return map[string]any{
"type": "trojan",
"server": server,
"port": port,
"password": pass,
"sni": server,
}, name, "trojan"
}
func decodeMaybeBase64(body []byte) []byte {
trim := strings.TrimSpace(string(body))
if strings.HasPrefix(trim, "proxies:") || strings.HasPrefix(trim, "{") || strings.HasPrefix(trim, "ss://") {
return body
}
if decoded := decodeBase64Flexible(trim); len(decoded) > 0 {
return decoded
}
return body
}