feat: migrate to golang

This commit is contained in:
2026-07-19 20:01:38 +08:00
parent 302d4e6bb5
commit a2630df9e0
69 changed files with 4750 additions and 3369 deletions

View 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")
}

View 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()
}

View 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()
}

View 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()
}

View 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)
}

View 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
}

View 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)
}
}