feat: migrate to golang
This commit is contained in:
81
internal/subscription/convert.go
Normal file
81
internal/subscription/convert.go
Normal file
@ -0,0 +1,81 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"gitea.epss.net.cn/klesh/ss/internal/yamlutil"
|
||||
)
|
||||
|
||||
// convertContent converts subscription content to Clash YAML format if
|
||||
// needed. Direct port of SubscriptionManager._convert_content.
|
||||
func convertContent(content string) string {
|
||||
// Check if content is already valid YAML with a "proxies" key.
|
||||
var parsed map[string]interface{}
|
||||
if err := yamlutil.Unmarshal([]byte(content), &parsed); err == nil {
|
||||
if _, ok := parsed["proxies"]; ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
var lines []string
|
||||
|
||||
// Try treating as Base64-encoded list first.
|
||||
if !strings.Contains(content, "ss://") &&
|
||||
!strings.Contains(content, "trojan://") &&
|
||||
!strings.Contains(content, "vless://") {
|
||||
if decoded, err := base64StdDecodeWithPadding(content); err == nil {
|
||||
lines = splitLines(decoded)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to reading lines directly.
|
||||
if len(lines) == 0 {
|
||||
lines = splitLines(content)
|
||||
}
|
||||
|
||||
var proxies []map[string]interface{}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var proxy map[string]interface{}
|
||||
switch {
|
||||
case strings.HasPrefix(line, "ss://"):
|
||||
proxy = parseSS(line)
|
||||
case strings.HasPrefix(line, "trojan://"):
|
||||
proxy = parseTrojan(line)
|
||||
case strings.HasPrefix(line, "vless://"):
|
||||
proxy = parseVless(line)
|
||||
}
|
||||
|
||||
if proxy != nil {
|
||||
proxies = append(proxies, proxy)
|
||||
}
|
||||
}
|
||||
|
||||
if len(proxies) > 0 {
|
||||
out, err := yaml.Marshal(map[string]interface{}{"proxies": proxies})
|
||||
if err == nil {
|
||||
return string(out)
|
||||
}
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// splitLines mirrors Python's str.splitlines(): split on \n, \r\n, and \r,
|
||||
// without keeping the line endings, and without producing a trailing empty
|
||||
// element for a final newline.
|
||||
func splitLines(s string) []string {
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
s = strings.TrimSuffix(s, "\n")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, "\n")
|
||||
}
|
||||
18
internal/subscription/ctime_darwin.go
Normal file
18
internal/subscription/ctime_darwin.go
Normal file
@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// creationTime returns the file's birth time on macOS, matching Python's
|
||||
// stat.st_birthtime (subscription_manager.py:273-275).
|
||||
func creationTime(info os.FileInfo) time.Time {
|
||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
return time.Unix(stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec)
|
||||
}
|
||||
return info.ModTime()
|
||||
}
|
||||
16
internal/subscription/ctime_linux.go
Normal file
16
internal/subscription/ctime_linux.go
Normal file
@ -0,0 +1,16 @@
|
||||
//go:build linux
|
||||
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// creationTime returns the file's modification time on Linux, since
|
||||
// birthtime isn't reliably exposed by the kernel/filesystem. This mirrors
|
||||
// the Python version's own fallback (st_ctime) when st_birthtime is
|
||||
// unavailable (subscription_manager.py:273-278).
|
||||
func creationTime(info os.FileInfo) time.Time {
|
||||
return info.ModTime()
|
||||
}
|
||||
19
internal/subscription/ctime_windows.go
Normal file
19
internal/subscription/ctime_windows.go
Normal file
@ -0,0 +1,19 @@
|
||||
//go:build windows
|
||||
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// creationTime returns the file's creation time on Windows, matching
|
||||
// Python's stat.st_ctime on Windows (which is creation time on this
|
||||
// platform, per subscription_manager.py:273-278).
|
||||
func creationTime(info os.FileInfo) time.Time {
|
||||
if stat, ok := info.Sys().(*syscall.Win32FileAttributeData); ok {
|
||||
return time.Unix(0, stat.CreationTime.Nanoseconds())
|
||||
}
|
||||
return info.ModTime()
|
||||
}
|
||||
236
internal/subscription/manager.go
Normal file
236
internal/subscription/manager.go
Normal file
@ -0,0 +1,236 @@
|
||||
// Package subscription manages clash RSS subscriptions with persistent storage.
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.epss.net.cn/klesh/ss/internal/model"
|
||||
"gitea.epss.net.cn/klesh/ss/internal/storage"
|
||||
)
|
||||
|
||||
// Manager manages clash RSS subscriptions with persistent storage.
|
||||
type Manager struct {
|
||||
Storage *storage.Manager
|
||||
Data *model.SubscriptionsData
|
||||
Config map[string]interface{}
|
||||
SubscriptionsDir string
|
||||
}
|
||||
|
||||
// New creates a Manager, loading existing subscriptions/config and ensuring
|
||||
// the subscriptions directory exists.
|
||||
func New(s *storage.Manager) (*Manager, error) {
|
||||
m := &Manager{
|
||||
Storage: s,
|
||||
Data: s.LoadSubscriptions(),
|
||||
Config: s.LoadConfig(),
|
||||
SubscriptionsDir: filepath.Join(s.ConfigDir, "subscriptions"),
|
||||
}
|
||||
if err := os.MkdirAll(m.SubscriptionsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create subscriptions directory: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// AddSubscription adds a new subscription, then refreshes and activates it.
|
||||
func (m *Manager) AddSubscription(name, url string) {
|
||||
sub := m.Data.AddSubscription(name, url)
|
||||
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
m.RefreshSubscription(sub.Name, false)
|
||||
m.ActivateSubscription(sub.Name)
|
||||
fmt.Printf("✅ Added subscription: %s -> %s\n", name, url)
|
||||
} else {
|
||||
fmt.Println("❌ Failed to save subscription")
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshSubscription downloads the subscription content from its URL and
|
||||
// saves it to disk, optionally backing up any existing file first.
|
||||
func (m *Manager) RefreshSubscription(name string, backup bool) {
|
||||
sub, ok := m.Data.Subscriptions[name]
|
||||
if !ok {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("🔄 Refreshing subscription: %s\n", name)
|
||||
|
||||
resp, err := http.Get(sub.URL)
|
||||
if err != nil {
|
||||
m.recordRefreshError(sub, err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
m.recordRefreshError(sub, err)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
err := fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
m.recordRefreshError(sub, err)
|
||||
return
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
filePath := filepath.Join(m.SubscriptionsDir, name+".yml")
|
||||
|
||||
if backup {
|
||||
if info, statErr := os.Stat(filePath); statErr == nil {
|
||||
ctime := creationTime(info)
|
||||
backupName := fmt.Sprintf("%s_%s.yml", name, ctime.Format("20060102_150405"))
|
||||
backupPath := filepath.Join(m.SubscriptionsDir, backupName)
|
||||
if err := os.Rename(filePath, backupPath); err != nil {
|
||||
fmt.Printf("❌ Failed to save file: %v\n", err)
|
||||
m.recordRefreshError(sub, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf(" 🔄 Backed up existing file to: %s\n", backupName)
|
||||
}
|
||||
}
|
||||
|
||||
content := convertContent(body)
|
||||
|
||||
if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
|
||||
fmt.Printf("❌ Failed to save file: %v\n", err)
|
||||
m.recordRefreshError(sub, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := &model.Time{Time: time.Now()}
|
||||
sub.LastRefresh = now
|
||||
fileSize := int64(len(content))
|
||||
sub.FileSize = &fileSize
|
||||
statusCode := resp.StatusCode
|
||||
sub.StatusCode = &statusCode
|
||||
contentHash := int64(stableHash(content))
|
||||
sub.ContentHash = &contentHash
|
||||
sub.LastError = nil
|
||||
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
fmt.Printf("✅ Subscription '%s' refreshed successfully\n", name)
|
||||
fmt.Printf(" 📁 Saved to: %s\n", filePath)
|
||||
fmt.Printf(" 📊 Size: %d bytes\n", len(body))
|
||||
} else {
|
||||
fmt.Println("❌ Failed to save subscription metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) recordRefreshError(sub *model.Subscription, err error) {
|
||||
fmt.Printf("❌ Failed to download subscription: %v\n", err)
|
||||
msg := err.Error()
|
||||
sub.LastError = &msg
|
||||
m.Storage.SaveSubscriptions(m.Data)
|
||||
}
|
||||
|
||||
// stableHash returns a stable 64-bit hash of content. Unlike Python's
|
||||
// built-in hash() (randomized per-process via PYTHONHASHSEED and therefore
|
||||
// not actually stable across runs), this is deterministic.
|
||||
func stableHash(content string) uint64 {
|
||||
h := fnv.New64a()
|
||||
h.Write([]byte(content))
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// DeleteSubscription removes a subscription by name.
|
||||
func (m *Manager) DeleteSubscription(name string) {
|
||||
if m.Data.RemoveSubscription(name) {
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
fmt.Printf("🗑️ Deleted subscription: %s\n", name)
|
||||
} else {
|
||||
fmt.Println("❌ Failed to delete subscription")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
// RenameSubscription renames oldName to newName.
|
||||
func (m *Manager) RenameSubscription(oldName, newName string) {
|
||||
if m.Data.RenameSubscription(oldName, newName) {
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
fmt.Printf("✅ Renamed subscription: %s -> %s\n", oldName, newName)
|
||||
} else {
|
||||
fmt.Println("❌ Failed to rename subscription")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ Failed to rename subscription: '%s' not found or '%s' already exists\n", oldName, newName)
|
||||
}
|
||||
}
|
||||
|
||||
// SetSubscriptionURL updates the URL for a subscription.
|
||||
func (m *Manager) SetSubscriptionURL(name, url string) {
|
||||
if m.Data.SetSubscriptionURL(name, url) {
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
fmt.Printf("✅ Updated URL for subscription '%s': %s\n", name, url)
|
||||
} else {
|
||||
fmt.Println("❌ Failed to save subscription")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateSubscription marks a subscription as active.
|
||||
func (m *Manager) ActivateSubscription(name string) {
|
||||
if m.Data.SetActive(name) {
|
||||
if m.Storage.SaveSubscriptions(m.Data) {
|
||||
fmt.Printf("✅ Activated subscription: %s\n", name)
|
||||
} else {
|
||||
fmt.Println("❌ Failed to activate subscription")
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||
}
|
||||
}
|
||||
|
||||
// ListSubscriptions prints all subscriptions.
|
||||
func (m *Manager) ListSubscriptions() {
|
||||
if len(m.Data.Subscriptions) == 0 {
|
||||
fmt.Println("No subscriptions found")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("📋 Subscriptions:")
|
||||
for name, sub := range m.Data.Subscriptions {
|
||||
activeMarker := "⚪"
|
||||
if sub.Status == model.StatusActive {
|
||||
activeMarker = "✅"
|
||||
}
|
||||
|
||||
lastRefreshStr := ""
|
||||
if sub.LastRefresh != nil {
|
||||
lastRefreshStr = fmt.Sprintf(" (last: %s)", sub.LastRefresh.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s%s\n", activeMarker, name, lastRefreshStr)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSubscriptionURL prints the URL for a subscription.
|
||||
func (m *Manager) GetSubscriptionURL(name string) {
|
||||
sub, ok := m.Data.Subscriptions[name]
|
||||
if !ok {
|
||||
fmt.Printf("❌ Subscription '%s' not found\n", name)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s: %s\n", name, sub.URL)
|
||||
}
|
||||
|
||||
// ShowStorageInfo prints storage location information.
|
||||
func (m *Manager) ShowStorageInfo() {
|
||||
info := m.Storage.GetStorageInfo()
|
||||
fmt.Println("📁 Storage Information:")
|
||||
fmt.Printf(" Platform: %s\n", info.Platform)
|
||||
fmt.Printf(" Config Directory: %s\n", info.ConfigDir)
|
||||
fmt.Printf(" Config File: %s\n", info.ConfigFile)
|
||||
fmt.Printf(" Subscriptions File: %s\n", info.SubscriptionsFile)
|
||||
fmt.Printf(" Directory Exists: %v\n", info.Exists)
|
||||
}
|
||||
275
internal/subscription/uri_parse.go
Normal file
275
internal/subscription/uri_parse.go
Normal file
@ -0,0 +1,275 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseSS parses a ss:// URI into a Clash proxy config map.
|
||||
// Direct port of SubscriptionManager._parse_ss.
|
||||
func parseSS(uri string) map[string]interface{} {
|
||||
if !strings.HasPrefix(uri, "ss://") {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := "ss"
|
||||
if idx := strings.Index(uri, "#"); idx != -1 {
|
||||
fragment := uri[idx+1:]
|
||||
uri = uri[:idx]
|
||||
if unescaped, err := url.QueryUnescape(fragment); err == nil {
|
||||
name = unescaped
|
||||
} else {
|
||||
name = fragment
|
||||
}
|
||||
}
|
||||
|
||||
content := uri[len("ss://"):]
|
||||
|
||||
var server, method, password string
|
||||
var port int
|
||||
|
||||
if idx := strings.Index(content, "@"); idx != -1 {
|
||||
userinfo := content[:idx]
|
||||
hostport := content[idx+1:]
|
||||
|
||||
// Try to decode userinfo first, it might be base64(method:password).
|
||||
decoded, err := base64URLDecodeWithPadding(userinfo)
|
||||
if err == nil {
|
||||
if mIdx := strings.Index(decoded, ":"); mIdx != -1 {
|
||||
method = decoded[:mIdx]
|
||||
password = decoded[mIdx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
if method == "" {
|
||||
if mIdx := strings.Index(userinfo, ":"); mIdx != -1 {
|
||||
method = userinfo[:mIdx]
|
||||
password = userinfo[mIdx+1:]
|
||||
}
|
||||
}
|
||||
|
||||
if method == "" || password == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
hIdx := strings.Index(hostport, ":")
|
||||
if hIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
server = hostport[:hIdx]
|
||||
p, err := strconv.Atoi(hostport[hIdx+1:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
port = p
|
||||
} else {
|
||||
// Entire body is likely base64 encoded.
|
||||
decoded, err := base64URLDecodeWithPadding(content)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.Contains(decoded, "@") {
|
||||
return nil
|
||||
}
|
||||
|
||||
atIdx := strings.Index(decoded, "@")
|
||||
methodPass := decoded[:atIdx]
|
||||
hostPort := decoded[atIdx+1:]
|
||||
|
||||
mIdx := strings.Index(methodPass, ":")
|
||||
if mIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
method = methodPass[:mIdx]
|
||||
password = methodPass[mIdx+1:]
|
||||
|
||||
hIdx := strings.Index(hostPort, ":")
|
||||
if hIdx == -1 {
|
||||
return nil
|
||||
}
|
||||
server = hostPort[:hIdx]
|
||||
p, err := strconv.Atoi(hostPort[hIdx+1:])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
port = p
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"type": "ss",
|
||||
"server": server,
|
||||
"port": port,
|
||||
"cipher": method,
|
||||
"password": password,
|
||||
}
|
||||
}
|
||||
|
||||
// stripBase64Whitespace removes whitespace, matching Python's
|
||||
// base64.b64decode/urlsafe_b64decode default validate=False leniency, which
|
||||
// silently ignores embedded newlines/whitespace (common in HTTP response
|
||||
// bodies) rather than erroring like Go's strict decoder.
|
||||
func stripBase64Whitespace(s string) string {
|
||||
return strings.Join(strings.Fields(s), "")
|
||||
}
|
||||
|
||||
// base64URLDecodeWithPadding decodes base64 (URL-safe alphabet), adding
|
||||
// padding as needed, matching the Python `base64.urlsafe_b64decode` + manual
|
||||
// padding fixup used throughout subscription_manager.py.
|
||||
func base64URLDecodeWithPadding(s string) (string, error) {
|
||||
s = stripBase64Whitespace(s)
|
||||
if pad := len(s) % 4; pad != 0 {
|
||||
s += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
decoded, err := base64.URLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
// base64StdDecodeWithPadding decodes standard-alphabet base64, adding padding
|
||||
// as needed, matching `base64.b64decode` usage in _convert_content.
|
||||
func base64StdDecodeWithPadding(s string) (string, error) {
|
||||
s = stripBase64Whitespace(s)
|
||||
if pad := len(s) % 4; pad != 0 {
|
||||
s += strings.Repeat("=", 4-pad)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decoded), nil
|
||||
}
|
||||
|
||||
// parseTrojan parses a trojan:// URI into a Clash proxy config map.
|
||||
// Direct port of SubscriptionManager._parse_trojan.
|
||||
func parseTrojan(uri string) map[string]interface{} {
|
||||
if !strings.HasPrefix(uri, "trojan://") {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
query := parsed.Query()
|
||||
|
||||
name := "trojan"
|
||||
if parsed.Fragment != "" {
|
||||
if unescaped, err := url.QueryUnescape(parsed.Fragment); err == nil {
|
||||
name = unescaped
|
||||
} else {
|
||||
name = parsed.Fragment
|
||||
}
|
||||
}
|
||||
|
||||
port, _ := strconv.Atoi(parsed.Port())
|
||||
|
||||
config := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": "trojan",
|
||||
"server": parsed.Hostname(),
|
||||
"port": port,
|
||||
"password": passwordFromUserinfo(parsed),
|
||||
"udp": true,
|
||||
}
|
||||
|
||||
if v := query.Get("sni"); v != "" {
|
||||
config["sni"] = v
|
||||
} else if v := query.Get("peer"); v != "" {
|
||||
config["sni"] = v
|
||||
}
|
||||
|
||||
if v := query.Get("allowInsecure"); v != "" {
|
||||
config["skip-cert-verify"] = v == "1"
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// passwordFromUserinfo returns the username component of a parsed URL, which
|
||||
// Python's urllib.parse exposes as `.username` (the trojan/vless password or
|
||||
// UUID is encoded there).
|
||||
func passwordFromUserinfo(u *url.URL) string {
|
||||
if u.User == nil {
|
||||
return ""
|
||||
}
|
||||
return u.User.Username()
|
||||
}
|
||||
|
||||
// parseVless parses a vless:// URI into a Clash proxy config map.
|
||||
// Direct port of SubscriptionManager._parse_vless.
|
||||
func parseVless(uri string) map[string]interface{} {
|
||||
if !strings.HasPrefix(uri, "vless://") {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
query := parsed.Query()
|
||||
|
||||
name := "vless"
|
||||
if parsed.Fragment != "" {
|
||||
if unescaped, err := url.QueryUnescape(parsed.Fragment); err == nil {
|
||||
name = unescaped
|
||||
} else {
|
||||
name = parsed.Fragment
|
||||
}
|
||||
}
|
||||
|
||||
port, _ := strconv.Atoi(parsed.Port())
|
||||
|
||||
config := map[string]interface{}{
|
||||
"name": name,
|
||||
"type": "vless",
|
||||
"server": parsed.Hostname(),
|
||||
"port": port,
|
||||
"uuid": passwordFromUserinfo(parsed),
|
||||
"udp": true,
|
||||
"tls": false,
|
||||
"network": "tcp",
|
||||
}
|
||||
|
||||
if sec := query.Get("security"); sec != "" {
|
||||
if sec == "tls" {
|
||||
config["tls"] = true
|
||||
} else if sec == "reality" {
|
||||
config["tls"] = true
|
||||
realityOpts := map[string]interface{}{}
|
||||
if v := query.Get("pbk"); v != "" {
|
||||
realityOpts["public-key"] = v
|
||||
}
|
||||
if v := query.Get("sid"); v != "" {
|
||||
realityOpts["short-id"] = v
|
||||
}
|
||||
if v := query.Get("sni"); v != "" {
|
||||
config["servername"] = v
|
||||
}
|
||||
config["reality-opts"] = realityOpts
|
||||
}
|
||||
}
|
||||
|
||||
if v := query.Get("flow"); v != "" {
|
||||
config["flow"] = v
|
||||
}
|
||||
|
||||
if v := query.Get("type"); v != "" {
|
||||
config["network"] = v
|
||||
}
|
||||
|
||||
if v := query.Get("sni"); v != "" {
|
||||
config["servername"] = v
|
||||
}
|
||||
|
||||
if v := query.Get("fp"); v != "" {
|
||||
config["client-fingerprint"] = v
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
178
internal/subscription/uri_parse_test.go
Normal file
178
internal/subscription/uri_parse_test.go
Normal file
@ -0,0 +1,178 @@
|
||||
package subscription
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestParseSS_PlainUserinfo(t *testing.T) {
|
||||
uri := "ss://aes-256-gcm:password123@example.com:8388#My%20Node"
|
||||
got := parseSS(uri)
|
||||
want := map[string]interface{}{
|
||||
"name": "My Node",
|
||||
"type": "ss",
|
||||
"server": "example.com",
|
||||
"port": 8388,
|
||||
"cipher": "aes-256-gcm",
|
||||
"password": "password123",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseSS() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSS_Base64Userinfo(t *testing.T) {
|
||||
userinfo := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:password123"))
|
||||
uri := "ss://" + userinfo + "@example.com:8388#node"
|
||||
got := parseSS(uri)
|
||||
want := map[string]interface{}{
|
||||
"name": "node",
|
||||
"type": "ss",
|
||||
"server": "example.com",
|
||||
"port": 8388,
|
||||
"cipher": "aes-256-gcm",
|
||||
"password": "password123",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseSS() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSS_WholeBodyBase64(t *testing.T) {
|
||||
body := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("aes-256-gcm:password123@example.com:8388"))
|
||||
uri := "ss://" + body + "#node"
|
||||
got := parseSS(uri)
|
||||
want := map[string]interface{}{
|
||||
"name": "node",
|
||||
"type": "ss",
|
||||
"server": "example.com",
|
||||
"port": 8388,
|
||||
"cipher": "aes-256-gcm",
|
||||
"password": "password123",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseSS() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSS_NotSS(t *testing.T) {
|
||||
if got := parseSS("trojan://foo@bar:443"); got != nil {
|
||||
t.Fatalf("parseSS() = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrojan_Basic(t *testing.T) {
|
||||
uri := "trojan://mypassword@example.com:443?sni=sni.example.com&allowInsecure=1#My%20Trojan"
|
||||
got := parseTrojan(uri)
|
||||
want := map[string]interface{}{
|
||||
"name": "My Trojan",
|
||||
"type": "trojan",
|
||||
"server": "example.com",
|
||||
"port": 443,
|
||||
"password": "mypassword",
|
||||
"udp": true,
|
||||
"sni": "sni.example.com",
|
||||
"skip-cert-verify": true,
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseTrojan() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrojan_PeerFallback(t *testing.T) {
|
||||
uri := "trojan://mypassword@example.com:443?peer=peer.example.com"
|
||||
got := parseTrojan(uri)
|
||||
if got["sni"] != "peer.example.com" {
|
||||
t.Fatalf("expected sni from peer fallback, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVless_TLS(t *testing.T) {
|
||||
uri := "vless://uuid-1234@example.com:443?security=tls&sni=sni.example.com&fp=chrome&type=ws#node"
|
||||
got := parseVless(uri)
|
||||
want := map[string]interface{}{
|
||||
"name": "node",
|
||||
"type": "vless",
|
||||
"server": "example.com",
|
||||
"port": 443,
|
||||
"uuid": "uuid-1234",
|
||||
"udp": true,
|
||||
"tls": true,
|
||||
"network": "ws",
|
||||
"servername": "sni.example.com",
|
||||
"client-fingerprint": "chrome",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("parseVless() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVless_Reality(t *testing.T) {
|
||||
uri := "vless://uuid-1234@example.com:443?security=reality&pbk=publickey&sid=shortid&sni=sni.example.com"
|
||||
got := parseVless(uri)
|
||||
realityOpts, ok := got["reality-opts"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected reality-opts map, got %#v", got["reality-opts"])
|
||||
}
|
||||
if realityOpts["public-key"] != "publickey" || realityOpts["short-id"] != "shortid" {
|
||||
t.Fatalf("unexpected reality-opts: %#v", realityOpts)
|
||||
}
|
||||
if got["servername"] != "sni.example.com" {
|
||||
t.Fatalf("expected servername set, got %#v", got["servername"])
|
||||
}
|
||||
if got["tls"] != true {
|
||||
t.Fatalf("expected tls=true for reality, got %#v", got["tls"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVless_DefaultsWithoutQuery(t *testing.T) {
|
||||
uri := "vless://uuid-1234@example.com:443"
|
||||
got := parseVless(uri)
|
||||
if got["tls"] != false || got["network"] != "tcp" || got["udp"] != true {
|
||||
t.Fatalf("unexpected defaults: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertContent_PassthroughExistingClashYAML(t *testing.T) {
|
||||
content := "proxies:\n - name: foo\n type: ss\n"
|
||||
if got := convertContent(content); got != content {
|
||||
t.Fatalf("expected passthrough, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertContent_PlainURIList(t *testing.T) {
|
||||
content := "ss://aes-256-gcm:pw@example.com:8388#node1\ntrojan://pw2@example.com:443#node2\n"
|
||||
out := convertContent(content)
|
||||
var parsed map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("output is not valid YAML: %v", err)
|
||||
}
|
||||
proxies, ok := parsed["proxies"].([]interface{})
|
||||
if !ok || len(proxies) != 2 {
|
||||
t.Fatalf("expected 2 proxies, got %#v", parsed["proxies"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertContent_WholeBodyBase64List(t *testing.T) {
|
||||
raw := "ss://aes-256-gcm:pw@example.com:8388#node1\ntrojan://pw2@example.com:443#node2\n"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(raw))
|
||||
out := convertContent(encoded)
|
||||
var parsed map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(out), &parsed); err != nil {
|
||||
t.Fatalf("output is not valid YAML: %v", err)
|
||||
}
|
||||
proxies, ok := parsed["proxies"].([]interface{})
|
||||
if !ok || len(proxies) != 2 {
|
||||
t.Fatalf("expected 2 proxies, got %#v", parsed["proxies"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertContent_UnrecognizedPassthrough(t *testing.T) {
|
||||
content := "not a uri list and not yaml: [unterminated"
|
||||
if got := convertContent(content); got != content {
|
||||
t.Fatalf("expected passthrough of unrecognized content, got %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user