init xboard node
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// LoadCustomConfig reads a custom config file (JSON or YAML) and returns it
|
||||
// as a generic map. Returns nil if path is empty or file does not exist.
|
||||
func LoadCustomConfig(path string) (map[string]any, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Warn("custom config file not found, skipping", "path", path)
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read custom config %s: %w", path, err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as JSON: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := yaml.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("parse custom config as YAML: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("loaded custom config", "path", path, "keys", mapKeys(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// MergeAppendList appends items from src list into dst, returning the merged list.
|
||||
// Each item is converted to map[string]any for JSON-marshal compatibility.
|
||||
func MergeAppendList(dst []map[string]any, srcRaw any) []map[string]any {
|
||||
srcList, ok := toSliceOfMaps(srcRaw)
|
||||
if !ok {
|
||||
return dst
|
||||
}
|
||||
return append(dst, srcList...)
|
||||
}
|
||||
|
||||
// MergePrependList prepends items from src list before dst items.
|
||||
func MergePrependList(dst []map[string]any, srcRaw any) []map[string]any {
|
||||
srcList, ok := toSliceOfMaps(srcRaw)
|
||||
if !ok {
|
||||
return dst
|
||||
}
|
||||
return append(srcList, dst...)
|
||||
}
|
||||
|
||||
func toSliceOfMaps(v any) ([]map[string]any, bool) {
|
||||
if v == nil {
|
||||
return nil, false
|
||||
}
|
||||
switch items := v.(type) {
|
||||
case []any:
|
||||
result := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if m, ok := toMap(item); ok {
|
||||
result = append(result, m)
|
||||
}
|
||||
}
|
||||
return result, len(result) > 0
|
||||
case []map[string]any:
|
||||
return items, len(items) > 0
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func toMap(v any) (map[string]any, bool) {
|
||||
switch m := v.(type) {
|
||||
case map[string]any:
|
||||
return m, true
|
||||
case map[any]any:
|
||||
result := make(map[string]any, len(m))
|
||||
for k, val := range m {
|
||||
result[fmt.Sprint(k)] = val
|
||||
}
|
||||
return result, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(m map[string]any) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// NeedsGeoIP returns true when any panel route rule contains a "geoip:" match entry.
|
||||
func NeedsGeoIP(routes []panel.RouteRule) bool {
|
||||
for _, r := range routes {
|
||||
for _, m := range r.Match {
|
||||
if strings.HasPrefix(m, "geoip:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NeedsGeoSite returns true when any panel route rule contains a "geosite:" match entry.
|
||||
func NeedsGeoSite(routes []panel.RouteRule) bool {
|
||||
for _, r := range routes {
|
||||
for _, m := range r.Match {
|
||||
if strings.HasPrefix(m, "geosite:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package geodata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
singboxGeoIPURL = "https://github.com/SagerNet/sing-geoip/releases/latest/download/geoip.db"
|
||||
singboxGeoSiteURL = "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db"
|
||||
xrayGeoIPURL = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat"
|
||||
xrayGeoSiteURL = "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat"
|
||||
)
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Minute}
|
||||
|
||||
func Ensure(dir string, needGeoIP, needGeoSite bool, kernelType string) error {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("create geo_data_dir %q: %w", dir, err)
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
name string
|
||||
url string
|
||||
}
|
||||
|
||||
var files []entry
|
||||
if kernelType == "singbox" {
|
||||
if needGeoIP {
|
||||
files = append(files, entry{"geoip.db", singboxGeoIPURL})
|
||||
}
|
||||
if needGeoSite {
|
||||
files = append(files, entry{"geosite.db", singboxGeoSiteURL})
|
||||
}
|
||||
} else {
|
||||
if needGeoIP {
|
||||
files = append(files, entry{"geoip.dat", xrayGeoIPURL})
|
||||
}
|
||||
if needGeoSite {
|
||||
files = append(files, entry{"geosite.dat", xrayGeoSiteURL})
|
||||
}
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, f := range files {
|
||||
if err := ensureFile(dir, f.name, f.url); err != nil {
|
||||
slog.Warn("geo database auto-download failed", "file", f.name, "error", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func ensureFile(dir, name, url string) error {
|
||||
dst := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return nil
|
||||
}
|
||||
slog.Info("geo database missing, downloading automatically", "file", name, "url", url)
|
||||
if err := atomicDownload(dst, url); err != nil {
|
||||
return err
|
||||
}
|
||||
if fi, err := os.Stat(dst); err == nil {
|
||||
slog.Info("geo database ready", "file", name, "size_kb", fi.Size()/1024)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func atomicDownload(dst, url string) error {
|
||||
resp, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("http get %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected HTTP status from %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
tmp := dst + ".tmp"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
defer os.Remove(tmp)
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
return fmt.Errorf("write geo database: %w", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
return fmt.Errorf("rename to destination: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package kernel
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Kernel is the interface for proxy kernel backends (sing-box, xray, etc.)
|
||||
type Kernel interface {
|
||||
// Name returns the kernel identifier (e.g. "sing-box")
|
||||
Name() string
|
||||
// ApplyConfig generates kernel config from panel data and (re)starts the process
|
||||
ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error
|
||||
// Stop gracefully stops the kernel process
|
||||
Stop()
|
||||
// IsRunning returns whether the kernel is currently active
|
||||
IsRunning() bool
|
||||
// GetConnections returns all active proxy connections
|
||||
GetConnections(ctx context.Context) ([]Connection, error)
|
||||
// CloseConnection terminates a specific connection by ID
|
||||
CloseConnection(ctx context.Context, connID string) error
|
||||
// SetSpeedLimitFunc configures optional per-user bandwidth throttling.
|
||||
// The function resolves a user UUID to a *rate.Limiter (nil = unlimited).
|
||||
// For sing-box, the limiter is embedded in the connection tracker wrapper
|
||||
// so both byte counting and rate limiting happen in a single CountFunc
|
||||
// callback, allowing splice/zero-copy paths.
|
||||
SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter)
|
||||
// Reload hot-swaps the inbound users and routing rules without full kernel restart.
|
||||
// Existing connections may be briefly interrupted, but routes/outbounds stay alive.
|
||||
Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error
|
||||
}
|
||||
|
||||
// Connection represents an active proxy connection reported by the kernel
|
||||
type Connection struct {
|
||||
ID string
|
||||
UserID int // extracted user ID (0 if unknown)
|
||||
Upload int64 // cumulative upload bytes
|
||||
Download int64 // cumulative download bytes
|
||||
SourceIP string // client source IP (cleaned, no port/brackets)
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// M is a shorthand for building JSON-like maps
|
||||
type M = map[string]interface{}
|
||||
|
||||
func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
outbounds := []M{
|
||||
{"type": "direct", "tag": "direct"},
|
||||
{"type": "block", "tag": "block"},
|
||||
}
|
||||
|
||||
// Panel-defined custom outbounds (structured, converted to sing-box native)
|
||||
for _, co := range nc.CustomOutbounds {
|
||||
outbounds = append(outbounds, outboundConfigToSingbox(co))
|
||||
}
|
||||
// Static outbounds from local config file (already kernel-native format)
|
||||
for _, co := range kcfg.CustomOutbound {
|
||||
outbounds = append(outbounds, M(co))
|
||||
}
|
||||
|
||||
cfg := M{
|
||||
"log": M{
|
||||
"level": kcfg.LogLevel,
|
||||
"timestamp": true,
|
||||
},
|
||||
"outbounds": outbounds,
|
||||
}
|
||||
|
||||
inbound := buildInbound(nc, users, certFile, keyFile)
|
||||
if inbound != nil {
|
||||
cfg["inbounds"] = []M{inbound}
|
||||
}
|
||||
|
||||
// Merge panel routes and static config routes
|
||||
cfg["route"] = buildRoutes(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
|
||||
// Automatically enable rule_set caching (cache_file) when panel routes
|
||||
// reference geoip:/geosite: entries so that the downloaded .srs rule_set
|
||||
// files survive across process restarts.
|
||||
if kernel.NeedsGeoIP(nc.Routes) || kernel.NeedsGeoSite(nc.Routes) {
|
||||
cfg["experimental"] = M{
|
||||
"cache_file": M{
|
||||
"enabled": true,
|
||||
"path": filepath.Join(kcfg.ConfigDir, "cache.db"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
mergeCustomSingbox(cfg, kcfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// outboundConfigToSingbox converts a structured OutboundConfig (from the panel)
|
||||
// into a sing-box outbound object. sing-box uses a flat layout where all
|
||||
// protocol-specific fields sit at the top level alongside "type" and "tag".
|
||||
func outboundConfigToSingbox(oc panel.OutboundConfig) M {
|
||||
m := M{
|
||||
"type": oc.Protocol,
|
||||
"tag": oc.Tag,
|
||||
}
|
||||
|
||||
// Transform common protocol keys to sing-box native format
|
||||
// WireGuard: secret_key -> private_key; peers: endpoint -> address+port
|
||||
if oc.Protocol == "wireguard" {
|
||||
if sk, ok := oc.Settings["secret_key"]; ok {
|
||||
m["private_key"] = sk
|
||||
}
|
||||
if peers, ok := oc.Settings["peers"].([]any); ok {
|
||||
var wgPeers []M
|
||||
for _, p := range peers {
|
||||
if peerMap, ok := p.(map[string]any); ok {
|
||||
newPeer := M{}
|
||||
for k, v := range peerMap {
|
||||
if k == "endpoint" {
|
||||
if ep, ok := v.(string); ok {
|
||||
host, portStr, err := net.SplitHostPort(ep)
|
||||
if err == nil {
|
||||
newPeer["address"] = host
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
newPeer["port"] = port
|
||||
} else {
|
||||
newPeer["address"] = ep
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newPeer[k] = v
|
||||
}
|
||||
}
|
||||
wgPeers = append(wgPeers, newPeer)
|
||||
}
|
||||
}
|
||||
m["peers"] = wgPeers
|
||||
}
|
||||
|
||||
// Copy any other top-level settings not handled above
|
||||
for k, v := range oc.Settings {
|
||||
if k != "secret_key" && k != "peers" {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for k, v := range oc.Settings {
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if oc.ProxyTag != "" {
|
||||
m["proxy_tag"] = oc.ProxyTag
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func mergeRouteList(a, b []map[string]any) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(a)+len(b))
|
||||
res = append(res, a...)
|
||||
res = append(res, b...)
|
||||
return res
|
||||
}
|
||||
|
||||
func buildRoutes(panelRoutes []panel.RouteRule, custom []map[string]any) M {
|
||||
var rules []M
|
||||
|
||||
// Custom Routes (Panel-pushed or Local) go FIRST to have highest priority
|
||||
for _, cr := range custom {
|
||||
rules = append(rules, M(cr))
|
||||
}
|
||||
|
||||
// Standard blocks for private/loopback
|
||||
rules = append(rules, M{
|
||||
"outbound": "block",
|
||||
"ip_cidr": []string{
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.0.0.0/24",
|
||||
"192.168.0.0/16",
|
||||
"198.18.0.0/15",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
"::1/128",
|
||||
},
|
||||
}, M{
|
||||
"outbound": "block",
|
||||
"domain": []string{
|
||||
"geoip:private",
|
||||
},
|
||||
})
|
||||
|
||||
// Panel-defined routes (usually specific blocks/proxies)
|
||||
for _, pr := range panelRoutes {
|
||||
if len(pr.Match) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var domains, cidrs []string
|
||||
for _, m := range pr.Match {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
// Strip leading wildcard for domain matching
|
||||
if strings.HasPrefix(m, "*.") {
|
||||
m = strings.TrimPrefix(m, "*.")
|
||||
}
|
||||
// Check if it's a CIDR block (contains /)
|
||||
if strings.Contains(m, "/") {
|
||||
cidrs = append(cidrs, m)
|
||||
} else {
|
||||
// Otherwise treat as domain
|
||||
domains = append(domains, m)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine outbound tag based on action
|
||||
outbound := "block"
|
||||
if pr.Action == "direct" {
|
||||
outbound = "direct"
|
||||
} else if pr.Action == "dns" {
|
||||
// Special case for sing-box DNS routing:
|
||||
// If action is "dns" and action_value is provided, we use it as the server name.
|
||||
// Otherwise it defaults to "dns-out".
|
||||
server := "dns-out"
|
||||
if pr.ActionValue != "" {
|
||||
server = pr.ActionValue
|
||||
}
|
||||
outbound = server
|
||||
} else if pr.Action == "proxy" {
|
||||
// If panel provides a specific outbound tag in action_value, use it.
|
||||
// This allows routing to WARP_JP etc. via normal routes.
|
||||
if pr.ActionValue != "" {
|
||||
outbound = pr.ActionValue
|
||||
}
|
||||
}
|
||||
|
||||
// Create separate rule for domains
|
||||
if len(domains) > 0 {
|
||||
rule := M{
|
||||
"domain_suffix": domains,
|
||||
"outbound": outbound,
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
|
||||
// Create separate rule for CIDRs
|
||||
if len(cidrs) > 0 {
|
||||
rule := M{
|
||||
"ip_cidr": cidrs,
|
||||
"outbound": outbound,
|
||||
}
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
}
|
||||
|
||||
return M{
|
||||
"final": "direct",
|
||||
"rules": rules,
|
||||
}
|
||||
}
|
||||
|
||||
func mergeCustomSingbox(cfg M, kcfg config.KernelConfig) {
|
||||
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
||||
if err != nil {
|
||||
slog.Error("failed to load custom sing-box config", "error", err)
|
||||
return
|
||||
}
|
||||
if custom == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// dns — custom replaces
|
||||
if v, ok := custom["dns"]; ok {
|
||||
cfg["dns"] = v
|
||||
}
|
||||
|
||||
// experimental — custom replaces
|
||||
if v, ok := custom["experimental"]; ok {
|
||||
cfg["experimental"] = v
|
||||
}
|
||||
|
||||
// outbounds — append custom entries
|
||||
if v, ok := custom["outbounds"]; ok {
|
||||
if existing, ok := cfg["outbounds"].([]M); ok {
|
||||
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
|
||||
}
|
||||
}
|
||||
|
||||
// endpoints (sing-box 1.11+ wireguard etc.) — append
|
||||
if v, ok := custom["endpoints"]; ok {
|
||||
if existing, ok := cfg["endpoints"].([]M); ok {
|
||||
cfg["endpoints"] = kernel.MergeAppendList(existing, v)
|
||||
} else {
|
||||
if items := kernel.MergeAppendList(nil, v); len(items) > 0 {
|
||||
cfg["endpoints"] = items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// route — merge sub-fields
|
||||
if customRoute, ok := custom["route"]; ok {
|
||||
if customRouteMap, ok := customRoute.(map[string]any); ok {
|
||||
mergeCustomSingboxRoute(cfg, customRouteMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeCustomSingboxRoute(cfg M, customRoute map[string]any) {
|
||||
route, ok := cfg["route"].(M)
|
||||
if !ok {
|
||||
route = M{}
|
||||
cfg["route"] = route
|
||||
}
|
||||
|
||||
// rules — custom rules prepended (so they match before panel rules)
|
||||
if v, ok := customRoute["rules"]; ok {
|
||||
if existing, ok := route["rules"].([]M); ok {
|
||||
route["rules"] = kernel.MergePrependList(existing, v)
|
||||
}
|
||||
}
|
||||
|
||||
// rule_set — custom rule_sets appended
|
||||
if v, ok := customRoute["rule_set"]; ok {
|
||||
if existing, ok := route["rule_set"].([]M); ok {
|
||||
route["rule_set"] = kernel.MergeAppendList(existing, v)
|
||||
} else {
|
||||
route["rule_set"] = kernel.MergeAppendList(nil, v)
|
||||
}
|
||||
}
|
||||
|
||||
// final, auto_detect_interface, default_interface, etc. — custom overrides
|
||||
for k, v := range customRoute {
|
||||
if k == "rules" || k == "rule_set" {
|
||||
continue
|
||||
}
|
||||
route[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base := M{
|
||||
"tag": nc.Protocol + "-in",
|
||||
"listen": "::",
|
||||
"listen_port": nc.ServerPort,
|
||||
}
|
||||
|
||||
switch nc.Protocol {
|
||||
case "shadowsocks":
|
||||
return buildShadowsocks(base, nc, users)
|
||||
case "vmess":
|
||||
return buildVMess(base, nc, users, certFile, keyFile)
|
||||
case "vless":
|
||||
return buildVLESS(base, nc, users, certFile, keyFile)
|
||||
case "trojan":
|
||||
return buildTrojan(base, nc, users, certFile, keyFile)
|
||||
case "hysteria":
|
||||
return buildHysteria(base, nc, users, certFile, keyFile)
|
||||
case "tuic":
|
||||
return buildTUIC(base, nc, users, certFile, keyFile)
|
||||
case "anytls":
|
||||
return buildAnyTLS(base, nc, users, certFile, keyFile)
|
||||
case "naive":
|
||||
return buildNaive(base, nc, users, certFile, keyFile)
|
||||
case "socks":
|
||||
return buildSocks(base, users)
|
||||
case "http":
|
||||
return buildHTTP(base, nc, users, certFile, keyFile)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
||||
base["type"] = "shadowsocks"
|
||||
base["method"] = nc.Cipher
|
||||
|
||||
if strings.HasPrefix(nc.Cipher, "2022-blake3-") {
|
||||
base["password"] = nc.ServerKey
|
||||
}
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
if nc.Plugin != "" {
|
||||
base["plugin"] = nc.Plugin
|
||||
base["plugin_opts"] = nc.PluginOpt
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "vmess"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"uuid": u.UUID,
|
||||
"alterId": 0,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
applyTransport(base, nc)
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "vless"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
user := M{
|
||||
"name": u.UUID,
|
||||
"uuid": u.UUID,
|
||||
}
|
||||
if nc.Flow != "" {
|
||||
user["flow"] = nc.Flow
|
||||
}
|
||||
userList = append(userList, user)
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
applyTransport(base, nc)
|
||||
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
} else if nc.TLS == 2 {
|
||||
base["tls"] = buildRealityConfig(nc)
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "trojan"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
applyTransport(base, nc)
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
} else if nc.TLS == 2 {
|
||||
base["tls"] = buildRealityConfig(nc)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHysteria(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
if nc.Version == 2 {
|
||||
base["type"] = "hysteria2"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
if nc.Obfs != "" {
|
||||
base["obfs"] = M{
|
||||
"type": nc.Obfs,
|
||||
"password": nc.ObfsPassword,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
base["type"] = "hysteria"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"auth_str": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
base["up_mbps"] = nc.UpMbps
|
||||
base["down_mbps"] = nc.DownMbps
|
||||
|
||||
if nc.Obfs != "" {
|
||||
base["obfs"] = nc.Obfs
|
||||
}
|
||||
}
|
||||
|
||||
tls := buildTLSConfig(nc, certFile, keyFile)
|
||||
// Hysteria/Hysteria2 uses QUIC and requires ALPN; default to h3 if not set.
|
||||
if _, ok := tls["alpn"]; !ok {
|
||||
tls["alpn"] = []string{"h3"}
|
||||
}
|
||||
base["tls"] = tls
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTUIC(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "tuic"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"uuid": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
if nc.CongestionControl != "" {
|
||||
base["congestion_control"] = nc.CongestionControl
|
||||
}
|
||||
|
||||
tls := buildTLSConfig(nc, certFile, keyFile)
|
||||
// TUIC requires ALPN for QUIC negotiation; default to h3 if not set by panel.
|
||||
if _, ok := tls["alpn"]; !ok {
|
||||
tls["alpn"] = []string{"h3"}
|
||||
}
|
||||
base["tls"] = tls
|
||||
return base
|
||||
}
|
||||
|
||||
func buildAnyTLS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "anytls"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"name": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
if nc.PaddingScheme != "" {
|
||||
base["padding_scheme"] = string(nc.PaddingScheme)
|
||||
}
|
||||
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
return base
|
||||
}
|
||||
|
||||
func buildNaive(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "naive"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"username": strconv.Itoa(u.ID),
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func buildSocks(base M, users []panel.User) M {
|
||||
base["type"] = "socks"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"username": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["type"] = "http"
|
||||
|
||||
userList := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
userList = append(userList, M{
|
||||
"username": u.UUID,
|
||||
"password": u.UUID,
|
||||
})
|
||||
}
|
||||
base["users"] = userList
|
||||
|
||||
if nc.TLS == 1 {
|
||||
base["tls"] = buildTLSConfig(nc, certFile, keyFile)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func applyTransport(base M, nc *panel.NodeConfig) {
|
||||
if nc.Network == "" || nc.Network == "tcp" {
|
||||
return
|
||||
}
|
||||
|
||||
transport := M{"type": nc.Network}
|
||||
|
||||
if nc.NetworkSettings != nil {
|
||||
switch nc.Network {
|
||||
case "ws":
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
transport["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["headers"]; ok {
|
||||
transport["headers"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
if transport["headers"] == nil {
|
||||
transport["headers"] = M{"Host": v}
|
||||
}
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["max_early_data"]; ok {
|
||||
transport["max_early_data"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["early_data_header_name"]; ok {
|
||||
transport["early_data_header_name"] = v
|
||||
}
|
||||
case "grpc":
|
||||
if v, ok := nc.NetworkSettings["service_name"]; ok {
|
||||
transport["service_name"] = v
|
||||
}
|
||||
case "httpupgrade":
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
transport["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
transport["host"] = v
|
||||
}
|
||||
case "h2", "http":
|
||||
transport["type"] = "http"
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
transport["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
transport["host"] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base["transport"] = transport
|
||||
}
|
||||
|
||||
func buildTLSConfig(nc *panel.NodeConfig, certFile, keyFile string) M {
|
||||
tls := M{"enabled": true}
|
||||
|
||||
serverName := nc.ServerName
|
||||
if serverName == "" && nc.Host != "" {
|
||||
serverName = nc.Host
|
||||
}
|
||||
if serverName != "" {
|
||||
tls["server_name"] = serverName
|
||||
}
|
||||
|
||||
if nc.TLSSettings != nil {
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
||||
tls["server_name"] = sn
|
||||
}
|
||||
if alpn, ok := nc.TLSSettings["alpn"]; ok {
|
||||
tls["alpn"] = alpn
|
||||
}
|
||||
}
|
||||
|
||||
if certFile != "" && keyFile != "" {
|
||||
tls["certificate_path"] = certFile
|
||||
tls["key_path"] = keyFile
|
||||
}
|
||||
|
||||
return tls
|
||||
}
|
||||
|
||||
func buildRealityConfig(nc *panel.NodeConfig) M {
|
||||
tls := M{"enabled": true}
|
||||
|
||||
if nc.TLSSettings == nil {
|
||||
return tls
|
||||
}
|
||||
|
||||
reality := M{"enabled": true}
|
||||
|
||||
if pk, ok := nc.TLSSettings["private_key"]; ok {
|
||||
reality["private_key"] = pk
|
||||
}
|
||||
if sid, ok := nc.TLSSettings["short_id"]; ok {
|
||||
switch v := sid.(type) {
|
||||
case string:
|
||||
reality["short_id"] = []string{v}
|
||||
case []interface{}:
|
||||
reality["short_id"] = v
|
||||
}
|
||||
}
|
||||
|
||||
handshake := M{}
|
||||
if dest, ok := nc.TLSSettings["dest"]; ok {
|
||||
destStr := fmt.Sprintf("%v", dest)
|
||||
parts := strings.SplitN(destStr, ":", 2)
|
||||
handshake["server"] = parts[0]
|
||||
if len(parts) == 2 {
|
||||
if port, err := strconv.Atoi(parts[1]); err == nil {
|
||||
handshake["server_port"] = port
|
||||
}
|
||||
} else {
|
||||
handshake["server_port"] = 443
|
||||
}
|
||||
}
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok {
|
||||
handshake["server"] = sn
|
||||
if _, exists := handshake["server_port"]; !exists {
|
||||
handshake["server_port"] = 443
|
||||
}
|
||||
}
|
||||
|
||||
if len(handshake) > 0 {
|
||||
reality["handshake"] = handshake
|
||||
}
|
||||
|
||||
tls["reality"] = reality
|
||||
return tls
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
var testUsers = []panel.User{
|
||||
{ID: 1, UUID: "aaaaaaaa-1111-2222-3333-444444444444", SpeedLimit: 0, DeviceLimit: 0},
|
||||
{ID: 2, UUID: "bbbbbbbb-5555-6666-7777-888888888888", SpeedLimit: 3, DeviceLimit: 2},
|
||||
}
|
||||
|
||||
// --- Shadowsocks ---
|
||||
|
||||
func TestBuildInbound_Shadowsocks(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "shadowsocks")
|
||||
assertMapValue(t, inbound, "method", "aes-128-gcm")
|
||||
assertMapValue(t, inbound, "listen_port", 111)
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("users: got %d, want 2", len(users))
|
||||
}
|
||||
assertMapValue(t, users[0], "name", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_Shadowsocks2022(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 222,
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "base64serverkey==",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "method", "2022-blake3-aes-128-gcm")
|
||||
assertMapValue(t, inbound, "password", "base64serverkey==")
|
||||
}
|
||||
|
||||
func TestBuildInbound_ShadowsocksWithPlugin(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 333,
|
||||
Cipher: "aes-128-gcm",
|
||||
Plugin: "obfs-local",
|
||||
PluginOpt: "obfs=http;obfs-host=example.com",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "plugin", "obfs-local")
|
||||
assertMapValue(t, inbound, "plugin_opts", "obfs=http;obfs-host=example.com")
|
||||
}
|
||||
|
||||
// --- VMess ---
|
||||
|
||||
func TestBuildInbound_VMess(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "vmess")
|
||||
assertMapValue(t, inbound, "tag", "vmess-in")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "alterId", 0)
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/path/cert.pem", "/path/key.pem")
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
assertMapValue(t, tls, "server_name", "example.com")
|
||||
assertMapValue(t, tls, "certificate_path", "/path/cert.pem")
|
||||
assertMapValue(t, tls, "key_path", "/path/key.pem")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_NoTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 80,
|
||||
TLS: 0,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Error("should not have TLS when tls=0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithWebSocket(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 80,
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/ws",
|
||||
"headers": map[string]interface{}{
|
||||
"Host": "example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "ws")
|
||||
assertMapValue(t, transport, "path", "/ws")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithGRPC(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
Network: "grpc",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"service_name": "mygrpc",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "grpc")
|
||||
assertMapValue(t, transport, "service_name", "mygrpc")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithH2(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
Network: "h2",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/h2path",
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "http")
|
||||
assertMapValue(t, transport, "path", "/h2path")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VMess_WithHTTPUpgrade(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 443,
|
||||
Network: "httpupgrade",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/upgrade",
|
||||
"host": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
transport := inbound["transport"].(M)
|
||||
assertMapValue(t, transport, "type", "httpupgrade")
|
||||
assertMapValue(t, transport, "path", "/upgrade")
|
||||
assertMapValue(t, transport, "host", "example.com")
|
||||
}
|
||||
|
||||
// --- VLESS ---
|
||||
|
||||
func TestBuildInbound_VLESS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "vless")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VLESS_WithFlow(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
Flow: "xtls-rprx-vision",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "flow", "xtls-rprx-vision")
|
||||
}
|
||||
|
||||
func TestBuildInbound_VLESS_Reality(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "test-private-key",
|
||||
"short_id": "abc123",
|
||||
"dest": "www.example.com:443",
|
||||
"server_name": "www.example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
|
||||
reality := tls["reality"].(M)
|
||||
assertMapValue(t, reality, "enabled", true)
|
||||
assertMapValue(t, reality, "private_key", "test-private-key")
|
||||
|
||||
handshake := reality["handshake"].(M)
|
||||
assertMapValue(t, handshake, "server", "www.example.com")
|
||||
assertMapValue(t, handshake, "server_port", 443)
|
||||
}
|
||||
|
||||
func TestBuildInbound_VLESS_Reality_ShortIDArray(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "pk",
|
||||
"short_id": []interface{}{"id1", "id2"},
|
||||
"dest": "example.com",
|
||||
},
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
tls := inbound["tls"].(M)
|
||||
reality := tls["reality"].(M)
|
||||
ids := reality["short_id"].([]interface{})
|
||||
if len(ids) != 2 {
|
||||
t.Errorf("short_id: got %d entries, want 2", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trojan ---
|
||||
|
||||
func TestBuildInbound_Trojan(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "trojan")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_Trojan_NoTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 80,
|
||||
TLS: 0,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Error("trojan with tls=0 should not have TLS config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInbound_Trojan_WithTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
// --- Hysteria ---
|
||||
|
||||
func TestBuildInbound_Hysteria2(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "hysteria",
|
||||
ServerPort: 444,
|
||||
Version: 2,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
assertMapValue(t, inbound, "type", "hysteria2")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
func TestBuildInbound_Hysteria2_WithObfs(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "hysteria",
|
||||
ServerPort: 444,
|
||||
Version: 2,
|
||||
Obfs: "salamander",
|
||||
ObfsPassword: "secret",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
obfs := inbound["obfs"].(M)
|
||||
assertMapValue(t, obfs, "type", "salamander")
|
||||
assertMapValue(t, obfs, "password", "secret")
|
||||
}
|
||||
|
||||
func TestBuildInbound_Hysteria1(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "hysteria",
|
||||
ServerPort: 444,
|
||||
Version: 0,
|
||||
UpMbps: 100,
|
||||
DownMbps: 200,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
assertMapValue(t, inbound, "type", "hysteria")
|
||||
assertMapValue(t, inbound, "up_mbps", 100)
|
||||
assertMapValue(t, inbound, "down_mbps", 200)
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "auth_str", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
// --- TUIC ---
|
||||
|
||||
func TestBuildInbound_TUIC(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "tuic",
|
||||
ServerPort: 555,
|
||||
CongestionControl: "bbr",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
assertMapValue(t, inbound, "type", "tuic")
|
||||
assertMapValue(t, inbound, "congestion_control", "bbr")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "uuid", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
// --- AnyTLS ---
|
||||
|
||||
func TestBuildInbound_AnyTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "anytls",
|
||||
ServerPort: 443,
|
||||
PaddingScheme: "stop=8\n0=30-30",
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
assertMapValue(t, inbound, "type", "anytls")
|
||||
assertMapValue(t, inbound, "padding_scheme", "stop=8\n0=30-30")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_AnyTLS_NoPaddingScheme(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "anytls",
|
||||
ServerPort: 443,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
if _, exists := inbound["padding_scheme"]; exists {
|
||||
t.Error("should not have padding_scheme when empty")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Naive ---
|
||||
|
||||
func TestBuildInbound_Naive(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "naive",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
assertMapValue(t, inbound, "type", "naive")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "username", "1")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
// --- Socks ---
|
||||
|
||||
func TestBuildInbound_Socks(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "socks")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "username", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
assertMapValue(t, users[0], "password", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
|
||||
if _, exists := inbound["tls"]; exists {
|
||||
t.Error("socks should not have TLS")
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP ---
|
||||
|
||||
func TestBuildInbound_HTTP(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "http",
|
||||
ServerPort: 8080,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
assertMapValue(t, inbound, "type", "http")
|
||||
|
||||
users := inbound["users"].([]M)
|
||||
assertMapValue(t, users[0], "username", "aaaaaaaa-1111-2222-3333-444444444444")
|
||||
}
|
||||
|
||||
func TestBuildInbound_HTTP_WithTLS(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "http",
|
||||
ServerPort: 443,
|
||||
TLS: 1,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "/c.pem", "/k.pem")
|
||||
tls := inbound["tls"].(M)
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
}
|
||||
|
||||
// --- Unknown protocol ---
|
||||
|
||||
func TestBuildInbound_Unknown(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "unknown-proto",
|
||||
ServerPort: 999,
|
||||
}
|
||||
inbound := buildInbound(nc, testUsers, "", "")
|
||||
if inbound != nil {
|
||||
t.Errorf("unknown protocol should return nil, got %v", inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildConfig ---
|
||||
|
||||
func TestBuildConfig(t *testing.T) {
|
||||
kcfg := config.KernelConfig{LogLevel: "info"}
|
||||
nc := &panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 111,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(kcfg, nc, testUsers, "", "")
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Error("empty JSON output")
|
||||
}
|
||||
|
||||
logCfg := cfg["log"].(M)
|
||||
assertMapValue(t, logCfg, "level", "info")
|
||||
assertMapValue(t, logCfg, "timestamp", true)
|
||||
|
||||
outbounds := cfg["outbounds"].([]M)
|
||||
if len(outbounds) != 2 {
|
||||
t.Errorf("outbounds: got %d, want 2", len(outbounds))
|
||||
}
|
||||
assertMapValue(t, outbounds[0], "type", "direct")
|
||||
assertMapValue(t, outbounds[1], "type", "block")
|
||||
|
||||
inbounds := cfg["inbounds"].([]M)
|
||||
if len(inbounds) != 1 {
|
||||
t.Fatalf("inbounds: got %d, want 1", len(inbounds))
|
||||
}
|
||||
assertMapValue(t, inbounds[0], "type", "shadowsocks")
|
||||
}
|
||||
|
||||
func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
protocols := []struct {
|
||||
name string
|
||||
nc *panel.NodeConfig
|
||||
}{
|
||||
{"shadowsocks", &panel.NodeConfig{Protocol: "shadowsocks", ServerPort: 111, Cipher: "aes-128-gcm"}},
|
||||
{"vmess", &panel.NodeConfig{Protocol: "vmess", ServerPort: 443}},
|
||||
{"vless", &panel.NodeConfig{Protocol: "vless", ServerPort: 443}},
|
||||
{"trojan", &panel.NodeConfig{Protocol: "trojan", ServerPort: 443}},
|
||||
{"hysteria2", &panel.NodeConfig{Protocol: "hysteria", ServerPort: 444, Version: 2}},
|
||||
{"hysteria1", &panel.NodeConfig{Protocol: "hysteria", ServerPort: 444, Version: 1}},
|
||||
{"tuic", &panel.NodeConfig{Protocol: "tuic", ServerPort: 555}},
|
||||
{"anytls", &panel.NodeConfig{Protocol: "anytls", ServerPort: 443}},
|
||||
{"naive", &panel.NodeConfig{Protocol: "naive", ServerPort: 443}},
|
||||
{"socks", &panel.NodeConfig{Protocol: "socks", ServerPort: 1080}},
|
||||
{"http", &panel.NodeConfig{Protocol: "http", ServerPort: 8080}},
|
||||
}
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(config.KernelConfig{LogLevel: "warn"}, tc.nc, testUsers, "/c.pem", "/k.pem")
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", tc.name, err)
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("round-trip %s: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
func TestBuildRoutes_Default(t *testing.T) {
|
||||
route := buildRoutes(nil, nil)
|
||||
assertMapValue(t, route, "final", "direct")
|
||||
|
||||
rules := route["rules"].([]M)
|
||||
if len(rules) < 2 {
|
||||
t.Fatalf("expected at least 2 default rules, got %d", len(rules))
|
||||
}
|
||||
assertMapValue(t, rules[0], "outbound", "block")
|
||||
assertMapValue(t, rules[1], "outbound", "block")
|
||||
}
|
||||
|
||||
func TestBuildRoutes_WithCustomRules(t *testing.T) {
|
||||
rules := []panel.RouteRule{
|
||||
{ID: 1, Match: []string{"blocked.com"}, Action: "block"},
|
||||
{ID: 2, Match: []string{"10.0.0.0/8"}, Action: "block"},
|
||||
{ID: 3, Match: []string{"allowed.com"}, Action: "direct"},
|
||||
}
|
||||
route := buildRoutes(rules, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
|
||||
if len(allRules) != 5 {
|
||||
t.Fatalf("rules count: got %d, want 5", len(allRules))
|
||||
}
|
||||
|
||||
assertMapValue(t, allRules[2], "outbound", "block")
|
||||
if _, ok := allRules[2]["domain_suffix"]; !ok {
|
||||
t.Error("domain rule should use domain_suffix")
|
||||
}
|
||||
|
||||
assertMapValue(t, allRules[3], "outbound", "block")
|
||||
if _, ok := allRules[3]["ip_cidr"]; !ok {
|
||||
t.Error("IP rule should use ip_cidr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRoutes_MultiMatch(t *testing.T) {
|
||||
// A single route with mixed domain + CIDR matches should produce separate rules
|
||||
// Wildcard *.evil.com should become evil.com for domain_suffix
|
||||
rules := []panel.RouteRule{
|
||||
{ID: 1, Match: []string{"*.evil.com", "bad.org", "192.168.1.0/24"}, Action: "block"},
|
||||
{ID: 2, Match: []string{"*.bypass.com"}, Action: "direct"},
|
||||
}
|
||||
route := buildRoutes(rules, nil)
|
||||
allRules := route["rules"].([]M)
|
||||
|
||||
// 2 default private-IP rules + 1 domain rule + 1 CIDR rule + 1 domain rule = 5
|
||||
if len(allRules) != 5 {
|
||||
t.Fatalf("rules count: got %d, want 5", len(allRules))
|
||||
}
|
||||
|
||||
// Rule #2 (index 2): domains from first route (wildcards stripped)
|
||||
domains := allRules[2]["domain_suffix"].([]string)
|
||||
if len(domains) != 2 || domains[0] != "evil.com" || domains[1] != "bad.org" {
|
||||
t.Errorf("domain_suffix: got %v, want [evil.com bad.org]", domains)
|
||||
}
|
||||
assertMapValue(t, allRules[2], "outbound", "block")
|
||||
|
||||
// Rule #3 (index 3): CIDRs from first route
|
||||
cidrs := allRules[3]["ip_cidr"].([]string)
|
||||
if len(cidrs) != 1 || cidrs[0] != "192.168.1.0/24" {
|
||||
t.Errorf("ip_cidr: got %v, want [192.168.1.0/24]", cidrs)
|
||||
}
|
||||
assertMapValue(t, allRules[3], "outbound", "block")
|
||||
|
||||
// Rule #4 (index 4): direct rule (wildcard stripped)
|
||||
directDomains := allRules[4]["domain_suffix"].([]string)
|
||||
if len(directDomains) != 1 || directDomains[0] != "bypass.com" {
|
||||
t.Errorf("direct domain_suffix: got %v, want [bypass.com]", directDomains)
|
||||
}
|
||||
assertMapValue(t, allRules[4], "outbound", "direct")
|
||||
}
|
||||
|
||||
// --- TLS Config ---
|
||||
|
||||
func TestBuildTLSConfig_WithCert(t *testing.T) {
|
||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
||||
tls := buildTLSConfig(nc, "/cert.pem", "/key.pem")
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
assertMapValue(t, tls, "server_name", "example.com")
|
||||
assertMapValue(t, tls, "certificate_path", "/cert.pem")
|
||||
assertMapValue(t, tls, "key_path", "/key.pem")
|
||||
}
|
||||
|
||||
func TestBuildTLSConfig_NoCert(t *testing.T) {
|
||||
nc := &panel.NodeConfig{ServerName: "example.com"}
|
||||
tls := buildTLSConfig(nc, "", "")
|
||||
assertMapValue(t, tls, "enabled", true)
|
||||
if _, exists := tls["certificate_path"]; exists {
|
||||
t.Error("should not have certificate_path when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTLSConfig_FallbackToHost(t *testing.T) {
|
||||
nc := &panel.NodeConfig{Host: "fallback.com"}
|
||||
tls := buildTLSConfig(nc, "", "")
|
||||
assertMapValue(t, tls, "server_name", "fallback.com")
|
||||
}
|
||||
|
||||
func TestBuildTLSConfig_TLSSettingsOverride(t *testing.T) {
|
||||
nc := &panel.NodeConfig{
|
||||
ServerName: "original.com",
|
||||
TLSSettings: map[string]interface{}{
|
||||
"server_name": "override.com",
|
||||
},
|
||||
}
|
||||
tls := buildTLSConfig(nc, "", "")
|
||||
assertMapValue(t, tls, "server_name", "override.com")
|
||||
}
|
||||
|
||||
// --- Transport ---
|
||||
|
||||
func TestApplyTransport_TCP(t *testing.T) {
|
||||
base := M{}
|
||||
nc := &panel.NodeConfig{Network: "tcp"}
|
||||
applyTransport(base, nc)
|
||||
if _, exists := base["transport"]; exists {
|
||||
t.Error("tcp should not add transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_Empty(t *testing.T) {
|
||||
base := M{}
|
||||
nc := &panel.NodeConfig{Network: ""}
|
||||
applyTransport(base, nc)
|
||||
if _, exists := base["transport"]; exists {
|
||||
t.Error("empty network should not add transport")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransport_WS_MaxEarlyData(t *testing.T) {
|
||||
base := M{}
|
||||
nc := &panel.NodeConfig{
|
||||
Network: "ws",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/ws",
|
||||
"max_early_data": 2048,
|
||||
"early_data_header_name": "Sec-WebSocket-Protocol",
|
||||
},
|
||||
}
|
||||
applyTransport(base, nc)
|
||||
transport := base["transport"].(M)
|
||||
assertMapValue(t, transport, "max_early_data", 2048)
|
||||
assertMapValue(t, transport, "early_data_header_name", "Sec-WebSocket-Protocol")
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func assertMapValue(t *testing.T, m M, key string, expected interface{}) {
|
||||
t.Helper()
|
||||
val, ok := m[key]
|
||||
if !ok {
|
||||
t.Errorf("key %q not found in map", key)
|
||||
return
|
||||
}
|
||||
switch e := expected.(type) {
|
||||
case int:
|
||||
switch v := val.(type) {
|
||||
case int:
|
||||
if v != e {
|
||||
t.Errorf("%s: got %d, want %d", key, v, e)
|
||||
}
|
||||
case float64:
|
||||
if int(v) != e {
|
||||
t.Errorf("%s: got %v, want %d", key, v, e)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s: got %v (type %T), want %d", key, val, val, e)
|
||||
}
|
||||
default:
|
||||
if val != expected {
|
||||
t.Errorf("%s: got %v, want %v", key, val, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing/common/buf"
|
||||
singM "github.com/sagernet/sing/common/metadata"
|
||||
N "github.com/sagernet/sing/common/network"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
)
|
||||
|
||||
// connRecord holds the minimal per-connection state we need.
|
||||
// Each field is accessed by multiple goroutines; upload/download use atomics.
|
||||
type connRecord struct {
|
||||
id string
|
||||
userID int
|
||||
sourceIP string
|
||||
upload atomic.Int64
|
||||
download atomic.Int64
|
||||
conn net.Conn // kept for force-close via CloseByID
|
||||
}
|
||||
|
||||
func (r *connRecord) toConnection() kernel.Connection {
|
||||
return kernel.Connection{
|
||||
ID: r.id,
|
||||
UserID: r.userID,
|
||||
SourceIP: r.sourceIP,
|
||||
Upload: r.upload.Load(),
|
||||
Download: r.download.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// ConnTracker is a lightweight in-process connection tracker that replaces
|
||||
// the Clash API TrafficManager. It handles:
|
||||
// - per-connection byte counting (upload / download)
|
||||
// - optional per-user speed limiting (when SetSpeedLimitFunc is configured)
|
||||
// - source IP tracking for device-limit enforcement
|
||||
// - force-close a connection by ID
|
||||
//
|
||||
// Both byte counting and speed limiting are implemented via sing's CountFunc
|
||||
// callback mechanism (ReadCounter / WriteCounter interfaces). This allows
|
||||
// sing's copy pipeline to use splice / ReadWaiter zero-copy paths while
|
||||
// still counting bytes and enforcing bandwidth limits through post-transfer
|
||||
// callbacks.
|
||||
//
|
||||
// Memory model:
|
||||
// - active: live connections, looked up by ID for snapshots and CloseByID
|
||||
// - pending: closed connections accumulated since the last Snapshot call,
|
||||
// drained atomically so the Tracker sees every connection's final bytes
|
||||
//
|
||||
// Thread safety: active is guarded by mu; pending by pendingMu.
|
||||
type ConnTracker struct {
|
||||
mu sync.RWMutex
|
||||
active map[string]*connRecord
|
||||
|
||||
// idCounter generates unique connection IDs without crypto/rand.
|
||||
idCounter atomic.Int64
|
||||
|
||||
pendingMu sync.Mutex
|
||||
pending []*connRecord
|
||||
maxPending int
|
||||
|
||||
userMapMu sync.RWMutex
|
||||
userMap map[string]int // UUID → userID
|
||||
|
||||
// speedLimitFunc resolves a user UUID to a *rate.Limiter. When set,
|
||||
// new connections for users with a speed limit are automatically
|
||||
// rate-limited without needing a second ConnectionTracker wrapper.
|
||||
// The function must be safe for concurrent calls.
|
||||
speedLimitFunc func(uuid string) *rate.Limiter
|
||||
}
|
||||
|
||||
// NewConnTracker creates a tracker.
|
||||
func NewConnTracker(maxPending int) *ConnTracker {
|
||||
if maxPending <= 0 {
|
||||
maxPending = 50000
|
||||
}
|
||||
return &ConnTracker{
|
||||
active: make(map[string]*connRecord),
|
||||
maxPending: maxPending,
|
||||
userMap: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
// SetSpeedLimitFunc configures the per-user speed limit lookup.
|
||||
// fn is called for every new connection; it must return nil for unlimited users.
|
||||
// Thread-safe: fn itself must be safe for concurrent calls.
|
||||
func (t *ConnTracker) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
|
||||
t.speedLimitFunc = fn
|
||||
}
|
||||
|
||||
// SetUserMap replaces the UUID→userID mapping. Must be called whenever the
|
||||
// user list changes so that new connections are attributed to the correct user.
|
||||
func (t *ConnTracker) SetUserMap(m map[string]int) {
|
||||
t.userMapMu.Lock()
|
||||
t.userMap = m
|
||||
t.userMapMu.Unlock()
|
||||
}
|
||||
|
||||
// ─── adapter.ConnectionTracker ───────────────────────────────────────────────
|
||||
|
||||
// RoutedConnection wraps a TCP conn to count bytes, track lifecycle,
|
||||
// and optionally rate-limit per-user bandwidth.
|
||||
func (t *ConnTracker) RoutedConnection(
|
||||
ctx context.Context, conn net.Conn,
|
||||
metadata adapter.InboundContext,
|
||||
_ adapter.Rule, _ adapter.Outbound,
|
||||
) net.Conn {
|
||||
rec := t.allocRecord(metadata, conn)
|
||||
var lim *rate.Limiter
|
||||
if t.speedLimitFunc != nil {
|
||||
lim = t.speedLimitFunc(metadata.User)
|
||||
}
|
||||
return &trackedConn{Conn: conn, rec: rec, tracker: t, limiter: lim, ctx: ctx}
|
||||
}
|
||||
|
||||
// RoutedPacketConnection wraps a UDP PacketConn to count bytes, track lifecycle,
|
||||
// and optionally rate-limit per-user bandwidth.
|
||||
func (t *ConnTracker) RoutedPacketConnection(
|
||||
ctx context.Context, conn N.PacketConn,
|
||||
metadata adapter.InboundContext,
|
||||
_ adapter.Rule, _ adapter.Outbound,
|
||||
) N.PacketConn {
|
||||
rec := t.allocRecord(metadata, nil)
|
||||
var lim *rate.Limiter
|
||||
if t.speedLimitFunc != nil {
|
||||
lim = t.speedLimitFunc(metadata.User)
|
||||
}
|
||||
return &trackedPacketConn{PacketConn: conn, rec: rec, tracker: t, limiter: lim, ctx: ctx}
|
||||
}
|
||||
|
||||
// ─── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
func (t *ConnTracker) allocRecord(metadata adapter.InboundContext, conn net.Conn) *connRecord {
|
||||
// Use a fast atomic counter instead of crypto/rand UUID generation.
|
||||
// Under connection floods (thousands of concurrent handshakes), uuid.NewV4()
|
||||
// becomes a syscall bottleneck; a counter is allocation-free and lock-free.
|
||||
id := strconv.FormatInt(t.idCounter.Add(1), 36)
|
||||
t.userMapMu.RLock()
|
||||
uid := t.userMap[metadata.User]
|
||||
t.userMapMu.RUnlock()
|
||||
rec := &connRecord{
|
||||
id: id,
|
||||
userID: uid,
|
||||
sourceIP: metadata.Source.Addr.String(),
|
||||
conn: conn,
|
||||
}
|
||||
t.mu.Lock()
|
||||
t.active[rec.id] = rec
|
||||
t.mu.Unlock()
|
||||
return rec
|
||||
}
|
||||
|
||||
// closeRecord is idempotent: if the record has already been moved to pending,
|
||||
// the second call is a no-op.
|
||||
func (t *ConnTracker) closeRecord(rec *connRecord) {
|
||||
t.mu.Lock()
|
||||
_, present := t.active[rec.id]
|
||||
if present {
|
||||
delete(t.active, rec.id)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
if !present {
|
||||
return
|
||||
}
|
||||
|
||||
t.pendingMu.Lock()
|
||||
if len(t.pending) < t.maxPending {
|
||||
t.pending = append(t.pending, rec)
|
||||
} else {
|
||||
slog.Warn("conntracker: pending buffer full, dropping closed connection",
|
||||
"max_pending", t.maxPending, "user_id", rec.userID)
|
||||
}
|
||||
t.pendingMu.Unlock()
|
||||
}
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Snapshot returns a point-in-time view of all active connections plus all
|
||||
// connections that closed since the previous Snapshot call. The pending
|
||||
// (closed) list is drained atomically so callers never miss a connection.
|
||||
func (t *ConnTracker) Snapshot() []kernel.Connection {
|
||||
// Drain pending under its own lock (hot path for closed connections).
|
||||
t.pendingMu.Lock()
|
||||
pending := t.pending
|
||||
t.pending = nil
|
||||
t.pendingMu.Unlock()
|
||||
|
||||
// Two-phase read to minimise RLock hold time at high connection counts:
|
||||
// Phase 1 (under RLock): collect record *pointers* — a pointer copy is
|
||||
// ~8 bytes vs ~80 bytes for a full Connection struct, and no atomic loads.
|
||||
// Phase 2 (lock-free): call toConnection() which atomically reads the
|
||||
// upload/download counters. Atomics are safe without any mutex.
|
||||
// This shrinks the RLock window proportionally as connection count grows,
|
||||
// reducing serialisation against concurrent allocRecord/closeRecord calls.
|
||||
t.mu.RLock()
|
||||
ptrs := make([]*connRecord, 0, len(t.active))
|
||||
for _, rec := range t.active {
|
||||
ptrs = append(ptrs, rec)
|
||||
}
|
||||
t.mu.RUnlock()
|
||||
|
||||
result := make([]kernel.Connection, 0, len(ptrs)+len(pending))
|
||||
for _, rec := range ptrs {
|
||||
result = append(result, rec.toConnection())
|
||||
}
|
||||
for _, rec := range pending {
|
||||
result = append(result, rec.toConnection())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CloseByID force-closes a live connection by its ID.
|
||||
// Returns true if the connection was found and closed.
|
||||
func (t *ConnTracker) CloseByID(id string) bool {
|
||||
t.mu.RLock()
|
||||
rec, ok := t.active[id]
|
||||
t.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if rec.conn != nil {
|
||||
rec.conn.Close()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ActiveCount returns the number of currently tracked live connections.
|
||||
func (t *ConnTracker) ActiveCount() int {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return len(t.active)
|
||||
}
|
||||
|
||||
// ─── trackedConn (TCP) ───────────────────────────────────────────────────────
|
||||
|
||||
type trackedConn struct {
|
||||
net.Conn
|
||||
rec *connRecord
|
||||
tracker *ConnTracker
|
||||
limiter *rate.Limiter // nil = unlimited
|
||||
ctx context.Context // for rate limiter WaitN cancellation
|
||||
}
|
||||
|
||||
// Read is the fallback path used when the copy pipeline cannot unwrap this
|
||||
// wrapper (should not happen with current code, but kept for robustness).
|
||||
func (c *trackedConn) Read(b []byte) (int, error) {
|
||||
if c.limiter != nil {
|
||||
if burst := c.limiter.Burst(); len(b) > burst {
|
||||
b = b[:burst]
|
||||
}
|
||||
}
|
||||
n, err := c.Conn.Read(b)
|
||||
if n > 0 {
|
||||
c.rec.download.Add(int64(n))
|
||||
if c.limiter != nil {
|
||||
waitN := min(n, c.limiter.Burst())
|
||||
_ = c.limiter.WaitN(c.ctx, waitN)
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Write is the fallback path.
|
||||
func (c *trackedConn) Write(b []byte) (int, error) {
|
||||
if c.limiter != nil {
|
||||
burst := c.limiter.Burst()
|
||||
var n int
|
||||
for n < len(b) {
|
||||
chunk := min(len(b)-n, burst)
|
||||
_ = c.limiter.WaitN(c.ctx, chunk)
|
||||
written, writeErr := c.Conn.Write(b[n : n+chunk])
|
||||
n += written
|
||||
c.rec.upload.Add(int64(written))
|
||||
if writeErr != nil {
|
||||
return n, writeErr
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
n, err := c.Conn.Write(b)
|
||||
if n > 0 {
|
||||
c.rec.upload.Add(int64(n))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *trackedConn) Close() error {
|
||||
c.tracker.closeRecord(c.rec)
|
||||
return c.Conn.Close()
|
||||
}
|
||||
|
||||
// makeCountFunc builds a CountFunc that does byte accounting and optional
|
||||
// rate limiting in a single callback. The direction is selected by the
|
||||
// counter pointer (download or upload).
|
||||
func (c *trackedConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
||||
if c.limiter == nil {
|
||||
// Fast path: no rate limiting, just atomic add.
|
||||
return func(n int64) { counter.Add(n) }
|
||||
}
|
||||
return func(n int64) {
|
||||
counter.Add(n)
|
||||
waitN := int(n)
|
||||
if burst := c.limiter.Burst(); waitN > burst {
|
||||
waitN = burst
|
||||
}
|
||||
_ = c.limiter.WaitN(c.ctx, waitN)
|
||||
}
|
||||
}
|
||||
|
||||
// UnwrapReader implements N.ReadCounter — lets the copy pipeline strip this
|
||||
// wrapper, collect a counter callback, and reach the inner conn for
|
||||
// splice / ReadWaiter zero-copy transfer.
|
||||
func (c *trackedConn) UnwrapReader() (io.Reader, []N.CountFunc) {
|
||||
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.rec.download)}
|
||||
}
|
||||
|
||||
// UnwrapWriter implements N.WriteCounter.
|
||||
func (c *trackedConn) UnwrapWriter() (io.Writer, []N.CountFunc) {
|
||||
return c.Conn, []N.CountFunc{c.makeCountFunc(&c.rec.upload)}
|
||||
}
|
||||
|
||||
func (c *trackedConn) Upstream() any { return c.Conn }
|
||||
func (c *trackedConn) ReaderReplaceable() bool { return true }
|
||||
func (c *trackedConn) WriterReplaceable() bool { return true }
|
||||
|
||||
// ─── trackedPacketConn (UDP / QUIC) ──────────────────────────────────────────
|
||||
|
||||
type trackedPacketConn struct {
|
||||
N.PacketConn
|
||||
rec *connRecord
|
||||
tracker *ConnTracker
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) ReadPacket(buffer *buf.Buffer) (singM.Socksaddr, error) {
|
||||
dest, err := c.PacketConn.ReadPacket(buffer)
|
||||
if err == nil {
|
||||
n := int64(buffer.Len())
|
||||
c.rec.download.Add(n)
|
||||
if c.limiter != nil {
|
||||
waitN := int(min(n, int64(c.limiter.Burst())))
|
||||
_ = c.limiter.WaitN(c.ctx, waitN)
|
||||
}
|
||||
}
|
||||
return dest, err
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) WritePacket(buffer *buf.Buffer, dest singM.Socksaddr) error {
|
||||
n := int64(buffer.Len())
|
||||
err := c.PacketConn.WritePacket(buffer, dest)
|
||||
if err == nil {
|
||||
c.rec.upload.Add(n)
|
||||
if c.limiter != nil {
|
||||
waitN := int(min(n, int64(c.limiter.Burst())))
|
||||
_ = c.limiter.WaitN(c.ctx, waitN)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) Close() error {
|
||||
c.tracker.closeRecord(c.rec)
|
||||
return c.PacketConn.Close()
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) makeCountFunc(counter *atomic.Int64) N.CountFunc {
|
||||
if c.limiter == nil {
|
||||
return func(n int64) { counter.Add(n) }
|
||||
}
|
||||
return func(n int64) {
|
||||
counter.Add(n)
|
||||
waitN := int(n)
|
||||
if burst := c.limiter.Burst(); waitN > burst {
|
||||
waitN = burst
|
||||
}
|
||||
_ = c.limiter.WaitN(c.ctx, waitN)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) UnwrapPacketReader() (N.PacketReader, []N.CountFunc) {
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.rec.download)}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) UnwrapPacketWriter() (N.PacketWriter, []N.CountFunc) {
|
||||
return c.PacketConn, []N.CountFunc{c.makeCountFunc(&c.rec.upload)}
|
||||
}
|
||||
|
||||
func (c *trackedPacketConn) Upstream() any { return c.PacketConn }
|
||||
func (c *trackedPacketConn) ReaderReplaceable() bool { return true }
|
||||
func (c *trackedPacketConn) WriterReplaceable() bool { return true }
|
||||
@@ -0,0 +1,348 @@
|
||||
package singbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
box "github.com/sagernet/sing-box"
|
||||
"github.com/sagernet/sing-box/adapter"
|
||||
"github.com/sagernet/sing-box/include"
|
||||
singLog "github.com/sagernet/sing-box/log"
|
||||
"github.com/sagernet/sing-box/option"
|
||||
"github.com/sagernet/sing/common/auth"
|
||||
singJSON "github.com/sagernet/sing/common/json"
|
||||
"github.com/sagernet/sing/service"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// drainTimeout is how long stop() waits for in-flight connections to finish
|
||||
// naturally after closing all listen sockets, before hard-killing them.
|
||||
const drainTimeout = 5 * time.Second
|
||||
|
||||
// SingBox implements kernel.Kernel by embedding sing-box as a Go library.
|
||||
//
|
||||
// Connection tracking is done entirely in-process via ConnTracker, which is
|
||||
// registered as an adapter.ConnectionTracker with sing-box's router. This
|
||||
// avoids the HTTP/JSON overhead of the Clash API (saves 5-15 MB at 500+
|
||||
// concurrent connections).
|
||||
type SingBox struct {
|
||||
cfg config.KernelConfig
|
||||
|
||||
mu sync.Mutex
|
||||
box *box.Box
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// connTracker is our lightweight in-process byte/IP tracker.
|
||||
// Created fresh on every ApplyConfig (full restart).
|
||||
// Survives ReloadUsers (hot-swap) since live connections persist.
|
||||
connTracker *ConnTracker
|
||||
|
||||
// speedLimitFunc resolves a user UUID to a *rate.Limiter.
|
||||
// Set once by SetSpeedLimitFunc and forwarded to every new ConnTracker.
|
||||
speedLimitFunc func(string) *rate.Limiter
|
||||
|
||||
// trackerRegistered prevents duplicate AppendTracker calls on the same
|
||||
// Router instance during ReloadUsers. Reset to false on full restart.
|
||||
trackerRegistered bool
|
||||
}
|
||||
|
||||
func New(cfg config.KernelConfig) *SingBox {
|
||||
return &SingBox{cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *SingBox) Name() string { return "sing-box" }
|
||||
|
||||
func (s *SingBox) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("sing-box config generated", "len", len(data))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ctx = include.Context(ctx)
|
||||
|
||||
opts, err := singJSON.UnmarshalExtendedContext[option.Options](ctx, data)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("parse sing-box options: %w", err)
|
||||
}
|
||||
|
||||
s.stop()
|
||||
|
||||
instance, err := box.New(box.Options{
|
||||
Context: ctx,
|
||||
Options: opts,
|
||||
})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return fmt.Errorf("create sing-box instance: %w", err)
|
||||
}
|
||||
|
||||
if err := instance.Start(); err != nil {
|
||||
instance.Close()
|
||||
cancel()
|
||||
return fmt.Errorf("start sing-box: %w", err)
|
||||
}
|
||||
|
||||
s.box = instance
|
||||
s.ctx = ctx
|
||||
s.cancel = cancel
|
||||
|
||||
// Fresh tracker on full restart (all connections were terminated by s.stop).
|
||||
s.connTracker = NewConnTracker(0)
|
||||
s.connTracker.SetUserMap(buildUserMap(users))
|
||||
if s.speedLimitFunc != nil {
|
||||
s.connTracker.SetSpeedLimitFunc(s.speedLimitFunc)
|
||||
}
|
||||
|
||||
s.trackerRegistered = false
|
||||
s.registerTracker(ctx)
|
||||
|
||||
slog.Info("sing-box started (in-process)", "users", len(users))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload hot-swaps the inbound users and routing rules without restarting the box.
|
||||
// Routes, outbounds, and the connTracker stay alive so in-flight connections
|
||||
// continue to be tracked correctly.
|
||||
func (s *SingBox) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.box == nil {
|
||||
return fmt.Errorf("not running")
|
||||
}
|
||||
|
||||
cfgMap := buildConfig(s.cfg, nodeConfig, users, certFile, keyFile)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
opts, err := singJSON.UnmarshalExtendedContext[option.Options](s.ctx, data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse options: %w", err)
|
||||
}
|
||||
|
||||
im := service.FromContext[adapter.InboundManager](s.ctx)
|
||||
if im == nil {
|
||||
return fmt.Errorf("inbound manager not available")
|
||||
}
|
||||
|
||||
router := service.FromContext[adapter.Router](s.ctx)
|
||||
if router == nil {
|
||||
return fmt.Errorf("router not available")
|
||||
}
|
||||
|
||||
// Update routing rules
|
||||
if err := router.UpdateRules(opts.Route.Rules, opts.Route.RuleSet); err != nil {
|
||||
slog.Warn("routing reload failed", "error", err)
|
||||
} else {
|
||||
slog.Info("sing-box routing reloaded")
|
||||
}
|
||||
|
||||
nopFactory := singLog.NewNOPFactory()
|
||||
|
||||
for _, inb := range opts.Inbounds {
|
||||
tag := inb.Tag
|
||||
if existing, ok := im.Get(tag); ok && existing.Type() == inb.Type {
|
||||
var err error
|
||||
switch v := existing.(type) {
|
||||
case adapter.UpdatableInbound[option.VMessUser]:
|
||||
if opts, ok := inb.Options.(*option.VMessInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.VLESSUser]:
|
||||
if opts, ok := inb.Options.(*option.VLESSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TrojanUser]:
|
||||
if opts, ok := inb.Options.(*option.TrojanInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.Hysteria2User]:
|
||||
if opts, ok := inb.Options.(*option.Hysteria2InboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableShadowsocksInbound:
|
||||
if opts, ok := inb.Options.(*option.ShadowsocksInboundOptions); ok {
|
||||
err = v.UpdateUsersByOptions(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.TUICUser]:
|
||||
if opts, ok := inb.Options.(*option.TUICInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[option.AnyTLSUser]:
|
||||
if opts, ok := inb.Options.(*option.AnyTLSInboundOptions); ok {
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
case adapter.UpdatableInbound[auth.User]:
|
||||
switch opts := inb.Options.(type) {
|
||||
case *option.NaiveInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.SocksInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
case *option.HTTPMixedInboundOptions:
|
||||
err = v.UpdateUsers(opts.Users)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
slog.Warn("incremental update failed, falling back to recreate", "tag", tag, "error", err)
|
||||
}
|
||||
|
||||
// Remove the existing inbound first so im.Create() can bind the same port.
|
||||
// im.Create() cannot atomically swap a TCP listener — it tries to start the
|
||||
// new socket before the old one is closed, causing "address already in use".
|
||||
// The brief listen gap (< 1 ms) is far less disruptive than a full restart.
|
||||
_ = im.Remove(tag) // ignore error when tag doesn't exist yet
|
||||
logger := nopFactory.NewLogger(fmt.Sprintf("inbound/%s[%s]", inb.Type, tag))
|
||||
if err := im.Create(s.ctx, router, logger, tag, inb.Type, inb.Options); err != nil {
|
||||
return fmt.Errorf("recreate inbound %s: %w", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Trackers remain registered on the Router (which survives ReloadUsers).
|
||||
// Only update the user map — do NOT re-register or traffic is double-counted.
|
||||
if s.connTracker != nil {
|
||||
s.connTracker.SetUserMap(buildUserMap(users))
|
||||
}
|
||||
|
||||
slog.Info("sing-box reloaded (users and routes hot-swapped)", "users", len(users))
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerTracker wires the ConnTracker to the current Router exactly once.
|
||||
// The ConnTracker handles both byte counting and optional rate limiting
|
||||
// in a single wrapper, so no additional trackers are needed.
|
||||
func (s *SingBox) registerTracker(ctx context.Context) {
|
||||
if s.trackerRegistered {
|
||||
return
|
||||
}
|
||||
router := service.FromContext[adapter.Router](ctx)
|
||||
if router == nil {
|
||||
return
|
||||
}
|
||||
if s.connTracker != nil {
|
||||
router.AppendTracker(s.connTracker)
|
||||
}
|
||||
s.trackerRegistered = true
|
||||
}
|
||||
|
||||
func (s *SingBox) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stop()
|
||||
}
|
||||
|
||||
func (s *SingBox) stop() {
|
||||
if s.box == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: close all listen sockets so no new connections are accepted.
|
||||
// im.Close() sets m.started=false so the subsequent box.Close() call is a no-op
|
||||
// for inbounds (avoids double-close), but box.Close() still tears down routers
|
||||
// and outbounds.
|
||||
if im := service.FromContext[adapter.InboundManager](s.ctx); im != nil {
|
||||
_ = im.Close()
|
||||
}
|
||||
|
||||
// Step 2: wait for in-flight connections to drain naturally (best-effort).
|
||||
if s.connTracker != nil {
|
||||
deadline := time.Now().Add(drainTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.connTracker.ActiveCount() == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: hard-close everything that is still open.
|
||||
s.box.Close()
|
||||
s.box = nil
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
s.ctx = nil
|
||||
}
|
||||
|
||||
func (s *SingBox) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.box != nil
|
||||
}
|
||||
|
||||
// connTrackerLocked returns the connTracker under a read-optimised lock.
|
||||
func (s *SingBox) connTrackerSafe() *ConnTracker {
|
||||
s.mu.Lock()
|
||||
ct := s.connTracker
|
||||
s.mu.Unlock()
|
||||
return ct
|
||||
}
|
||||
|
||||
// SetSpeedLimitFunc configures per-user bandwidth throttling.
|
||||
// The function is forwarded to the ConnTracker, which embeds the rate limiter
|
||||
// in each tracked connection wrapper. This merges byte counting and rate limiting
|
||||
// into a single CountFunc callback, allowing splice/zero-copy paths.
|
||||
func (s *SingBox) SetSpeedLimitFunc(fn func(uuid string) *rate.Limiter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.speedLimitFunc = fn
|
||||
if s.connTracker != nil {
|
||||
s.connTracker.SetSpeedLimitFunc(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// GetConnections returns a snapshot of all active and recently-closed
|
||||
// connections directly from the in-process ConnTracker.
|
||||
func (s *SingBox) GetConnections(_ context.Context) ([]kernel.Connection, error) {
|
||||
ct := s.connTrackerSafe()
|
||||
if ct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
conns := ct.Snapshot()
|
||||
slog.Debug("GetConnections snapshot", "count", len(conns))
|
||||
return conns, nil
|
||||
}
|
||||
|
||||
// CloseConnection force-closes a specific connection by its ID.
|
||||
func (s *SingBox) CloseConnection(_ context.Context, connID string) error {
|
||||
ct := s.connTrackerSafe()
|
||||
if ct == nil {
|
||||
return fmt.Errorf("not running")
|
||||
}
|
||||
if !ct.CloseByID(connID) {
|
||||
slog.Debug("CloseConnection: connection not found (already closed?)", "id", connID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildUserMap creates a UUID→userID mapping used by ConnTracker to attribute
|
||||
// connections to the correct user. All sing-box protocols use the user's UUID
|
||||
// as the inbound name/username, so this covers every protocol.
|
||||
func buildUserMap(users []panel.User) map[string]int {
|
||||
m := make(map[string]int, len(users))
|
||||
for _, u := range users {
|
||||
m[u.UUID] = u.ID
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// M is a shorthand for building JSON-like maps
|
||||
type M = map[string]interface{}
|
||||
|
||||
func buildConfig(kcfg config.KernelConfig, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
outbounds := []M{
|
||||
{"protocol": "freedom", "tag": "direct"},
|
||||
{"protocol": "blackhole", "tag": "block"},
|
||||
}
|
||||
|
||||
// Panel-defined custom outbounds (structured, converted to Xray native)
|
||||
for _, co := range nc.CustomOutbounds {
|
||||
outbounds = append(outbounds, outboundConfigToXray(co))
|
||||
}
|
||||
// Static outbounds from local config file (already kernel-native format)
|
||||
for _, co := range kcfg.CustomOutbound {
|
||||
outbounds = append(outbounds, M(co))
|
||||
}
|
||||
|
||||
cfg := M{
|
||||
"log": M{
|
||||
"loglevel": xrayLogLevel(kcfg.LogLevel),
|
||||
},
|
||||
"stats": M{},
|
||||
"policy": M{
|
||||
"levels": M{
|
||||
"0": M{
|
||||
"statsUserUplink": true,
|
||||
"statsUserDownlink": true,
|
||||
},
|
||||
},
|
||||
"system": M{
|
||||
"statsInboundUplink": true,
|
||||
"statsInboundDownlink": true,
|
||||
"statsOutboundUplink": true,
|
||||
"statsOutboundDownlink": true,
|
||||
},
|
||||
},
|
||||
"outbounds": outbounds,
|
||||
}
|
||||
|
||||
inbound := buildInbound(nc, users, certFile, keyFile)
|
||||
if inbound != nil {
|
||||
cfg["inbounds"] = []M{inbound}
|
||||
} else {
|
||||
slog.Warn("xray: unsupported protocol, no inbound configured — node will not accept connections",
|
||||
"protocol", nc.Protocol,
|
||||
"supported", "vmess, vless, trojan, shadowsocks, socks, http")
|
||||
}
|
||||
|
||||
// Merge panel routes and static config routes
|
||||
cfg["routing"] = buildRouting(nc.Routes, mergeRouteList(nc.CustomRoutes, kcfg.CustomRoute))
|
||||
|
||||
mergeCustomXray(cfg, kcfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// outboundConfigToXray converts a structured OutboundConfig (from the panel)
|
||||
// into an Xray outbound object. Xray uses a nested layout where protocol-
|
||||
// specific fields go inside a "settings" key, and chain proxying uses "proxySettings".
|
||||
func outboundConfigToXray(oc panel.OutboundConfig) M {
|
||||
m := M{
|
||||
"protocol": oc.Protocol,
|
||||
"tag": oc.Tag,
|
||||
}
|
||||
if len(oc.Settings) > 0 {
|
||||
m["settings"] = oc.Settings
|
||||
}
|
||||
if oc.ProxyTag != "" {
|
||||
m["proxySettings"] = M{"tag": oc.ProxyTag}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func mergeRouteList(a, b []map[string]any) []map[string]any {
|
||||
res := make([]map[string]any, 0, len(a)+len(b))
|
||||
res = append(res, a...)
|
||||
res = append(res, b...)
|
||||
return res
|
||||
}
|
||||
|
||||
// mergeCustomXray deep-merges a custom Xray config file into the generated config.
|
||||
// Merge strategy (compatible with V2bX/XrayR custom DNS etc.):
|
||||
// - dns: custom replaces auto-generated
|
||||
// - outbounds: custom entries appended
|
||||
// - routing.rules: custom rules prepended (matched first)
|
||||
// - policy, api: custom deep-merges
|
||||
// - inbounds: NOT merged (panel-managed, authoritative)
|
||||
func mergeCustomXray(cfg M, kcfg config.KernelConfig) {
|
||||
custom, err := kernel.LoadCustomConfig(kcfg.CustomConfig)
|
||||
if err != nil {
|
||||
slog.Error("failed to load custom xray config", "error", err)
|
||||
return
|
||||
}
|
||||
if custom == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// dns — custom replaces (same as V2bX DnsConfigPath)
|
||||
if v, ok := custom["dns"]; ok {
|
||||
cfg["dns"] = v
|
||||
}
|
||||
|
||||
// outbounds — append custom entries
|
||||
if v, ok := custom["outbounds"]; ok {
|
||||
if existing, ok := cfg["outbounds"].([]M); ok {
|
||||
cfg["outbounds"] = kernel.MergeAppendList(existing, v)
|
||||
}
|
||||
}
|
||||
|
||||
// routing — merge sub-fields
|
||||
if customRouting, ok := custom["routing"]; ok {
|
||||
if customRoutingMap, ok := customRouting.(map[string]any); ok {
|
||||
mergeCustomXrayRouting(cfg, customRoutingMap)
|
||||
}
|
||||
}
|
||||
|
||||
// other top-level keys (policy, api, transport, etc.) — custom overrides,
|
||||
// but we protect auto-generated inbounds, stats, log, routing, outbounds
|
||||
protected := map[string]bool{
|
||||
"inbounds": true, "outbounds": true, "routing": true,
|
||||
"dns": true, "log": true, "stats": true, "policy": true,
|
||||
}
|
||||
for k, v := range custom {
|
||||
if !protected[k] {
|
||||
cfg[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mergeCustomXrayRouting(cfg M, customRouting map[string]any) {
|
||||
routing, ok := cfg["routing"].(M)
|
||||
if !ok {
|
||||
routing = M{}
|
||||
cfg["routing"] = routing
|
||||
}
|
||||
|
||||
// rules — custom rules prepended (so they match before panel rules)
|
||||
if v, ok := customRouting["rules"]; ok {
|
||||
if existing, ok := routing["rules"].([]M); ok {
|
||||
routing["rules"] = kernel.MergePrependList(existing, v)
|
||||
}
|
||||
}
|
||||
|
||||
// domainStrategy, balancers, etc. — custom overrides
|
||||
for k, v := range customRouting {
|
||||
if k == "rules" {
|
||||
continue
|
||||
}
|
||||
routing[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func xrayLogLevel(singboxLevel string) string {
|
||||
switch singboxLevel {
|
||||
case "trace", "debug":
|
||||
return "debug"
|
||||
case "info":
|
||||
return "info"
|
||||
case "warn":
|
||||
return "warning"
|
||||
case "error":
|
||||
return "error"
|
||||
case "fatal", "panic":
|
||||
return "error"
|
||||
default:
|
||||
return "warning"
|
||||
}
|
||||
}
|
||||
|
||||
func buildInbound(nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
listenAddr := "::"
|
||||
if nc.ListenIP != "" {
|
||||
listenAddr = nc.ListenIP
|
||||
}
|
||||
base := M{
|
||||
"tag": nc.Protocol + "-in",
|
||||
"listen": listenAddr,
|
||||
"port": nc.ServerPort,
|
||||
"protocol": nc.Protocol,
|
||||
}
|
||||
|
||||
switch nc.Protocol {
|
||||
case "vmess":
|
||||
return buildVMess(base, nc, users, certFile, keyFile)
|
||||
case "vless":
|
||||
return buildVLESS(base, nc, users, certFile, keyFile)
|
||||
case "trojan":
|
||||
return buildTrojan(base, nc, users, certFile, keyFile)
|
||||
case "shadowsocks":
|
||||
return buildShadowsocks(base, nc, users)
|
||||
case "socks":
|
||||
return buildSocks(base, users)
|
||||
case "http":
|
||||
return buildHTTP(base, nc, users, certFile, keyFile)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// userEmail returns the stats-tracking email for a user.
|
||||
// Format: "user@<id>" so we can parse back the user ID from stats counters.
|
||||
func userEmail(userID int) string {
|
||||
return fmt.Sprintf("user@%d", userID)
|
||||
}
|
||||
|
||||
func buildVMess(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
clients = append(clients, M{
|
||||
"id": u.UUID,
|
||||
"alterId": 0,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
return base
|
||||
}
|
||||
|
||||
func buildVLESS(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
client := M{
|
||||
"id": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
}
|
||||
if nc.Flow != "" {
|
||||
client["flow"] = nc.Flow
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
base["settings"] = M{
|
||||
"clients": clients,
|
||||
"decryption": "none",
|
||||
}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
return base
|
||||
}
|
||||
|
||||
func buildTrojan(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
clients = append(clients, M{
|
||||
"password": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{"clients": clients}
|
||||
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
return base
|
||||
}
|
||||
|
||||
func buildShadowsocks(base M, nc *panel.NodeConfig, users []panel.User) M {
|
||||
// Single-user mode for traditional ciphers
|
||||
if !strings.HasPrefix(nc.Cipher, "2022-blake3-") {
|
||||
if len(users) > 0 {
|
||||
base["settings"] = M{
|
||||
"method": nc.Cipher,
|
||||
"password": users[0].UUID,
|
||||
"email": userEmail(users[0].ID),
|
||||
"network": "tcp,udp",
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// Multi-user mode for 2022-blake3 ciphers
|
||||
clients := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
clients = append(clients, M{
|
||||
"password": u.UUID,
|
||||
"email": userEmail(u.ID),
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
"method": nc.Cipher,
|
||||
"password": nc.ServerKey,
|
||||
"clients": clients,
|
||||
"network": "tcp,udp",
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func buildSocks(base M, users []panel.User) M {
|
||||
base["protocol"] = "socks"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
accounts = append(accounts, M{
|
||||
"user": u.UUID,
|
||||
"pass": u.UUID,
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
"auth": "password",
|
||||
"accounts": accounts,
|
||||
"udp": true,
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func buildHTTP(base M, nc *panel.NodeConfig, users []panel.User, certFile, keyFile string) M {
|
||||
base["protocol"] = "http"
|
||||
accounts := make([]M, 0, len(users))
|
||||
for _, u := range users {
|
||||
accounts = append(accounts, M{
|
||||
"user": u.UUID,
|
||||
"pass": u.UUID,
|
||||
})
|
||||
}
|
||||
base["settings"] = M{
|
||||
"accounts": accounts,
|
||||
}
|
||||
|
||||
if nc.TLS == 1 {
|
||||
applyStreamSettings(base, nc, certFile, keyFile)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func applyStreamSettings(base M, nc *panel.NodeConfig, certFile, keyFile string) {
|
||||
ss := M{}
|
||||
|
||||
// Network / transport
|
||||
network := nc.Network
|
||||
if network == "" {
|
||||
network = "tcp"
|
||||
}
|
||||
ss["network"] = network
|
||||
|
||||
switch network {
|
||||
case "ws":
|
||||
wsSettings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
wsSettings["path"] = v
|
||||
}
|
||||
headers := M{}
|
||||
if v, ok := nc.NetworkSettings["headers"]; ok {
|
||||
if headersMap, ok := v.(map[string]interface{}); ok {
|
||||
for k, val := range headersMap {
|
||||
headers[k] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
headers["Host"] = v
|
||||
}
|
||||
if len(headers) > 0 {
|
||||
wsSettings["headers"] = headers
|
||||
}
|
||||
}
|
||||
ss["wsSettings"] = wsSettings
|
||||
|
||||
case "grpc":
|
||||
grpcSettings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["service_name"]; ok {
|
||||
grpcSettings["serviceName"] = v
|
||||
}
|
||||
}
|
||||
ss["grpcSettings"] = grpcSettings
|
||||
|
||||
case "httpupgrade":
|
||||
huSettings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
huSettings["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
huSettings["host"] = v
|
||||
}
|
||||
}
|
||||
ss["httpupgradeSettings"] = huSettings
|
||||
|
||||
case "h2", "http":
|
||||
ss["network"] = "h2"
|
||||
h2Settings := M{}
|
||||
if nc.NetworkSettings != nil {
|
||||
if v, ok := nc.NetworkSettings["path"]; ok {
|
||||
h2Settings["path"] = v
|
||||
}
|
||||
if v, ok := nc.NetworkSettings["host"]; ok {
|
||||
h2Settings["host"] = []interface{}{v}
|
||||
}
|
||||
}
|
||||
ss["httpSettings"] = h2Settings
|
||||
|
||||
case "tcp":
|
||||
// default, no extra settings
|
||||
}
|
||||
|
||||
// TLS
|
||||
if nc.TLS == 1 {
|
||||
tlsSettings := M{}
|
||||
serverName := nc.ServerName
|
||||
if serverName == "" && nc.Host != "" {
|
||||
serverName = nc.Host
|
||||
}
|
||||
if serverName != "" {
|
||||
tlsSettings["serverName"] = serverName
|
||||
}
|
||||
if nc.TLSSettings != nil {
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok && sn != "" {
|
||||
tlsSettings["serverName"] = sn
|
||||
}
|
||||
}
|
||||
if certFile != "" && keyFile != "" {
|
||||
tlsSettings["certificates"] = []M{
|
||||
{
|
||||
"certificateFile": certFile,
|
||||
"keyFile": keyFile,
|
||||
},
|
||||
}
|
||||
}
|
||||
ss["security"] = "tls"
|
||||
ss["tlsSettings"] = tlsSettings
|
||||
} else if nc.TLS == 2 {
|
||||
ss["security"] = "reality"
|
||||
ss["realitySettings"] = buildRealitySettings(nc)
|
||||
}
|
||||
|
||||
base["streamSettings"] = ss
|
||||
}
|
||||
|
||||
func buildRealitySettings(nc *panel.NodeConfig) M {
|
||||
reality := M{"show": false}
|
||||
|
||||
if nc.TLSSettings == nil {
|
||||
return reality
|
||||
}
|
||||
|
||||
if pk, ok := nc.TLSSettings["private_key"]; ok {
|
||||
reality["privateKey"] = pk
|
||||
}
|
||||
if sid, ok := nc.TLSSettings["short_id"]; ok {
|
||||
switch v := sid.(type) {
|
||||
case string:
|
||||
reality["shortIds"] = []string{v}
|
||||
case []interface{}:
|
||||
ids := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
ids = append(ids, fmt.Sprintf("%v", item))
|
||||
}
|
||||
reality["shortIds"] = ids
|
||||
}
|
||||
}
|
||||
|
||||
if dest, ok := nc.TLSSettings["dest"]; ok {
|
||||
destStr := fmt.Sprintf("%v", dest)
|
||||
reality["dest"] = destStr
|
||||
}
|
||||
if sn, ok := nc.TLSSettings["server_name"]; ok {
|
||||
reality["serverNames"] = []string{fmt.Sprintf("%v", sn)}
|
||||
if _, exists := reality["dest"]; !exists {
|
||||
reality["dest"] = fmt.Sprintf("%v:443", sn)
|
||||
}
|
||||
}
|
||||
|
||||
return reality
|
||||
}
|
||||
|
||||
func buildRouting(rules []panel.RouteRule, customRules []map[string]any) M {
|
||||
var xrayRules []M
|
||||
|
||||
// Custom route rules (from Panel CustomRoutes or local config) have HIGHEST priority
|
||||
for _, cr := range customRules {
|
||||
xrayRules = append(xrayRules, M(cr))
|
||||
}
|
||||
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"ip": []string{
|
||||
"10.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"172.16.0.0/12",
|
||||
"192.0.0.0/24",
|
||||
"192.168.0.0/16",
|
||||
"198.18.0.0/15",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
"::1/128",
|
||||
},
|
||||
"outboundTag": "block",
|
||||
})
|
||||
|
||||
for _, rule := range rules {
|
||||
if len(rule.Match) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
var ips, domains []string
|
||||
for _, m := range rule.Match {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(m, "geoip:") {
|
||||
ips = append(ips, m) // "geoip:cn" → ip field, passed as-is
|
||||
} else if strings.HasPrefix(m, "geosite:") {
|
||||
domains = append(domains, m) // "geosite:google" → domain field, passed as-is
|
||||
} else if strings.Contains(m, "/") {
|
||||
ips = append(ips, m)
|
||||
} else {
|
||||
m = strings.TrimPrefix(m, "*.")
|
||||
domains = append(domains, "domain:"+m)
|
||||
}
|
||||
}
|
||||
|
||||
outbound := "block"
|
||||
if rule.Action == "direct" {
|
||||
outbound = "direct"
|
||||
} else if rule.Action == "proxy" && rule.ActionValue != "" {
|
||||
outbound = rule.ActionValue
|
||||
}
|
||||
|
||||
if len(domains) > 0 {
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"domain": domains,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
if len(ips) > 0 {
|
||||
xrayRules = append(xrayRules, M{
|
||||
"type": "field",
|
||||
"ip": ips,
|
||||
"outboundTag": outbound,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return M{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": xrayRules,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
var testKernelCfg = config.KernelConfig{
|
||||
Type: "xray",
|
||||
LogLevel: "warn",
|
||||
}
|
||||
|
||||
var testUsers = []panel.User{
|
||||
{ID: 1, UUID: "279d4f89-3a2c-488d-a67c-2d39a72acdde"},
|
||||
{ID: 5, UUID: "4d5965c8-a60c-452a-a943-af83ec0bb0db"},
|
||||
}
|
||||
|
||||
func TestBuildConfig_AllProtocols_ValidJSON(t *testing.T) {
|
||||
protocols := []struct {
|
||||
name string
|
||||
nc panel.NodeConfig
|
||||
}{
|
||||
{
|
||||
name: "vmess",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
Network: "ws",
|
||||
TLS: 1,
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"path": "/vmess",
|
||||
"host": "example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vless",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
Network: "tcp",
|
||||
TLS: 2,
|
||||
Flow: "xtls-rprx-vision",
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "test-pk",
|
||||
"short_id": "abcd",
|
||||
"server_name": "www.example.com",
|
||||
"dest": "www.example.com:443",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trojan",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "trojan",
|
||||
ServerPort: 443,
|
||||
Network: "grpc",
|
||||
TLS: 1,
|
||||
ServerName: "example.com",
|
||||
NetworkSettings: map[string]interface{}{
|
||||
"service_name": "trojan-grpc",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shadowsocks-aes",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "shadowsocks-2022",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "test-server-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "socks",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "socks",
|
||||
ServerPort: 1080,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "http",
|
||||
nc: panel.NodeConfig{
|
||||
Protocol: "http",
|
||||
ServerPort: 8080,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range protocols {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cfg := buildConfig(testKernelCfg, &tc.nc, testUsers, "/cert.pem", "/key.pem")
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Check required top-level fields
|
||||
for _, key := range []string{"log", "stats", "policy", "outbounds", "routing"} {
|
||||
if _, ok := parsed[key]; !ok {
|
||||
t.Errorf("missing top-level key: %s", key)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := parsed["inbounds"]; !ok {
|
||||
t.Error("missing inbounds")
|
||||
}
|
||||
|
||||
t.Logf("config size: %d bytes", len(data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_VMess_Users(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
|
||||
if ib["protocol"] != "vmess" {
|
||||
t.Errorf("expected protocol vmess, got %v", ib["protocol"])
|
||||
}
|
||||
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
clients := settings["clients"].([]interface{})
|
||||
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("expected 2 clients, got %d", len(clients))
|
||||
}
|
||||
|
||||
c1 := clients[0].(map[string]interface{})
|
||||
if c1["email"] != "user@1" {
|
||||
t.Errorf("expected email user@1, got %v", c1["email"])
|
||||
}
|
||||
if c1["id"] != "279d4f89-3a2c-488d-a67c-2d39a72acdde" {
|
||||
t.Errorf("unexpected UUID: %v", c1["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_VLESS_Flow(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "vless",
|
||||
ServerPort: 443,
|
||||
Flow: "xtls-rprx-vision",
|
||||
TLS: 2,
|
||||
TLSSettings: map[string]interface{}{
|
||||
"private_key": "pk",
|
||||
"server_name": "example.com",
|
||||
},
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
clients := settings["clients"].([]interface{})
|
||||
c1 := clients[0].(map[string]interface{})
|
||||
|
||||
if c1["flow"] != "xtls-rprx-vision" {
|
||||
t.Errorf("expected flow xtls-rprx-vision, got %v", c1["flow"])
|
||||
}
|
||||
|
||||
ss := ib["streamSettings"].(map[string]interface{})
|
||||
if ss["security"] != "reality" {
|
||||
t.Errorf("expected security reality, got %v", ss["security"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_Default(t *testing.T) {
|
||||
routing := buildRouting(nil, nil)
|
||||
rules := routing["rules"].([]M)
|
||||
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("expected 1 default rule, got %d", len(rules))
|
||||
}
|
||||
|
||||
if rules[0]["outboundTag"] != "block" {
|
||||
t.Errorf("expected block outbound, got %v", rules[0]["outboundTag"])
|
||||
}
|
||||
ips := rules[0]["ip"].([]string)
|
||||
if len(ips) < 5 {
|
||||
t.Errorf("expected multiple private CIDRs, got %d", len(ips))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRouting_WithRules(t *testing.T) {
|
||||
rules := []panel.RouteRule{
|
||||
{
|
||||
ID: 1,
|
||||
Match: []string{"*.baidu.com", "*.qq.com", "10.0.0.0/8"},
|
||||
Action: "block",
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Match: []string{"*.google.com"},
|
||||
Action: "direct",
|
||||
},
|
||||
}
|
||||
|
||||
routing := buildRouting(rules, nil)
|
||||
xrayRules := routing["rules"].([]M)
|
||||
|
||||
// 1 default + 2 domain rules + 1 IP rule = 4
|
||||
if len(xrayRules) != 4 {
|
||||
t.Fatalf("expected 4 rules, got %d", len(xrayRules))
|
||||
}
|
||||
|
||||
// Rule 1: domains block
|
||||
r1 := xrayRules[1]
|
||||
domains := r1["domain"].([]string)
|
||||
if len(domains) != 2 {
|
||||
t.Fatalf("expected 2 domains, got %d", len(domains))
|
||||
}
|
||||
if domains[0] != "domain:baidu.com" || domains[1] != "domain:qq.com" {
|
||||
t.Errorf("unexpected domains: %v", domains)
|
||||
}
|
||||
if r1["outboundTag"] != "block" {
|
||||
t.Errorf("expected block, got %v", r1["outboundTag"])
|
||||
}
|
||||
|
||||
// Rule 2: IP block
|
||||
r2 := xrayRules[2]
|
||||
ips := r2["ip"].([]string)
|
||||
if len(ips) != 1 || ips[0] != "10.0.0.0/8" {
|
||||
t.Errorf("unexpected IPs: %v", ips)
|
||||
}
|
||||
|
||||
// Rule 3: direct
|
||||
r3 := xrayRules[3]
|
||||
if r3["outboundTag"] != "direct" {
|
||||
t.Errorf("expected direct, got %v", r3["outboundTag"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_LogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"debug", "debug"},
|
||||
{"info", "info"},
|
||||
{"warn", "warning"},
|
||||
{"error", "error"},
|
||||
{"", "warning"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := xrayLogLevel(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("xrayLogLevel(%q) = %q, want %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_StatsEnabled(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "vmess",
|
||||
ServerPort: 10086,
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
// Verify stats is enabled
|
||||
if _, ok := parsed["stats"]; !ok {
|
||||
t.Error("stats not enabled")
|
||||
}
|
||||
|
||||
// Verify policy enables user stats
|
||||
policy := parsed["policy"].(map[string]interface{})
|
||||
levels := policy["levels"].(map[string]interface{})
|
||||
level0 := levels["0"].(map[string]interface{})
|
||||
|
||||
if v, ok := level0["statsUserUplink"]; !ok || v != true {
|
||||
t.Error("statsUserUplink not enabled")
|
||||
}
|
||||
if v, ok := level0["statsUserDownlink"]; !ok || v != true {
|
||||
t.Error("statsUserDownlink not enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_Shadowsocks_SingleUser(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "aes-128-gcm",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
|
||||
// Single-user mode: password directly, no clients array
|
||||
if settings["method"] != "aes-128-gcm" {
|
||||
t.Errorf("expected method aes-128-gcm, got %v", settings["method"])
|
||||
}
|
||||
if settings["password"] != testUsers[0].UUID {
|
||||
t.Errorf("expected password %s, got %v", testUsers[0].UUID, settings["password"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig_Shadowsocks_MultiUser(t *testing.T) {
|
||||
nc := panel.NodeConfig{
|
||||
Protocol: "shadowsocks",
|
||||
ServerPort: 8388,
|
||||
Cipher: "2022-blake3-aes-128-gcm",
|
||||
ServerKey: "server-key",
|
||||
}
|
||||
cfg := buildConfig(testKernelCfg, &nc, testUsers, "", "")
|
||||
data, _ := json.Marshal(cfg)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
json.Unmarshal(data, &parsed)
|
||||
|
||||
inbounds := parsed["inbounds"].([]interface{})
|
||||
ib := inbounds[0].(map[string]interface{})
|
||||
settings := ib["settings"].(map[string]interface{})
|
||||
|
||||
if settings["password"] != "server-key" {
|
||||
t.Errorf("expected server key, got %v", settings["password"])
|
||||
}
|
||||
clients := settings["clients"].([]interface{})
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("expected 2 clients, got %d", len(clients))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
_ "unsafe"
|
||||
|
||||
xrayDispatcher "github.com/xtls/xray-core/app/dispatcher"
|
||||
"github.com/xtls/xray-core/common"
|
||||
"github.com/xtls/xray-core/common/buf"
|
||||
"github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/session"
|
||||
"github.com/xtls/xray-core/features/routing"
|
||||
"github.com/xtls/xray-core/transport"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
)
|
||||
|
||||
// Access xray's internal config creator registry so we can replace the
|
||||
// default dispatcher factory with ours. This runs AFTER xray's init()
|
||||
// functions because our package imports xray (dependency order guarantee).
|
||||
//
|
||||
//go:linkname typeCreatorRegistry github.com/xtls/xray-core/common.typeCreatorRegistry
|
||||
var typeCreatorRegistry map[reflect.Type]common.ConfigCreator
|
||||
|
||||
var origDispatcherFactory common.ConfigCreator
|
||||
|
||||
// globalLimitDispatcher is set when the factory creates a LimitDispatcher.
|
||||
// The Xray kernel reads it to configure limits and get connections.
|
||||
var globalLimitDispatcher atomic.Pointer[LimitDispatcher]
|
||||
|
||||
func init() {
|
||||
configType := reflect.TypeOf((*xrayDispatcher.Config)(nil))
|
||||
origDispatcherFactory = typeCreatorRegistry[configType]
|
||||
typeCreatorRegistry[configType] = limitDispatcherFactory
|
||||
}
|
||||
|
||||
func limitDispatcherFactory(ctx context.Context, config interface{}) (interface{}, error) {
|
||||
orig, err := origDispatcherFactory(ctx, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inner, ok := orig.(routing.Dispatcher)
|
||||
if !ok {
|
||||
return orig, nil
|
||||
}
|
||||
ld := &LimitDispatcher{
|
||||
inner: orig,
|
||||
innerDisp: inner,
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
globalLimitDispatcher.Store(ld)
|
||||
slog.Debug("xray: limit dispatcher installed")
|
||||
return ld, nil
|
||||
}
|
||||
|
||||
// LimitDispatcher wraps xray's DefaultDispatcher to add per-user device
|
||||
// limit enforcement, speed limiting, and per-connection source IP tracking.
|
||||
// It hooks into Dispatch/DispatchLink which are called AFTER the protocol
|
||||
// handler has identified the user, so it works for ALL protocols.
|
||||
type LimitDispatcher struct {
|
||||
inner interface{} // original DefaultDispatcher (Feature + Dispatcher)
|
||||
innerDisp routing.Dispatcher // same object, typed as Dispatcher
|
||||
|
||||
mu sync.RWMutex
|
||||
conns map[string]*dispatchedConn // connID → conn
|
||||
userIPs map[string]map[string]int // email → sourceIP → count
|
||||
deviceLimits map[string]int // email → max devices
|
||||
speedLimits map[string]int // email → Mbps
|
||||
speedBuckets sync.Map // email → *rate.Limiter
|
||||
emailToUID map[string]int // email → panel user ID
|
||||
|
||||
connIDSeq atomic.Uint64
|
||||
}
|
||||
|
||||
type dispatchedConn struct {
|
||||
id string
|
||||
email string
|
||||
sourceIP string
|
||||
userID int
|
||||
upload atomic.Int64
|
||||
download atomic.Int64
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
// ─── routing.Dispatcher ──────────────────────────────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) Dispatch(ctx context.Context, dest net.Destination) (*transport.Link, error) {
|
||||
email, sourceIP, isTCP, err := d.identifyAndCheck(ctx, dest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
link, err := d.innerDisp.Dispatch(ctx, dest)
|
||||
if err != nil {
|
||||
if email != "" && isTCP {
|
||||
d.delConn(email, sourceIP)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
d.wrapLink(ctx, link, email, sourceIP, isTCP)
|
||||
}
|
||||
return link, nil
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) DispatchLink(ctx context.Context, dest net.Destination, link *transport.Link) error {
|
||||
email, sourceIP, isTCP, err := d.identifyAndCheck(ctx, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
d.wrapLink(ctx, link, email, sourceIP, isTCP)
|
||||
}
|
||||
return d.innerDisp.DispatchLink(ctx, dest, link)
|
||||
}
|
||||
|
||||
// identifyAndCheck extracts user identity from the session context, enforces
|
||||
// device limits, and returns the user's email, source IP, and TCP flag.
|
||||
// Returns a non-nil error only when the connection should be rejected.
|
||||
func (d *LimitDispatcher) identifyAndCheck(ctx context.Context, dest net.Destination) (email, sourceIP string, isTCP bool, err error) {
|
||||
si := session.InboundFromContext(ctx)
|
||||
if si == nil || si.User == nil || len(si.User.Email) == 0 {
|
||||
return "", "", false, nil
|
||||
}
|
||||
email = si.User.Email
|
||||
sourceIP = si.Source.Address.IP().String()
|
||||
isTCP = dest.Network == net.Network_TCP
|
||||
|
||||
if d.checkDeviceLimit(email, sourceIP, isTCP) {
|
||||
slog.Info("xray: device limit exceeded", "email", email, "ip", sourceIP)
|
||||
return "", "", false, errors.New("device limit exceeded for " + email)
|
||||
}
|
||||
return email, sourceIP, isTCP, nil
|
||||
}
|
||||
|
||||
// wrapLink instruments a transport.Link with per-connection byte counting,
|
||||
// rate limiting, and lifecycle tracking. Must only be called when email != "".
|
||||
func (d *LimitDispatcher) wrapLink(ctx context.Context, link *transport.Link, email, sourceIP string, isTCP bool) {
|
||||
connID := "xd-" + strconv.FormatUint(d.connIDSeq.Add(1), 36)
|
||||
dc := &dispatchedConn{
|
||||
id: connID,
|
||||
email: email,
|
||||
sourceIP: sourceIP,
|
||||
userID: d.getUID(email),
|
||||
}
|
||||
|
||||
d.mu.Lock()
|
||||
d.conns[connID] = dc
|
||||
d.mu.Unlock()
|
||||
|
||||
onClose := func() {
|
||||
if dc.closed.CompareAndSwap(false, true) {
|
||||
if isTCP {
|
||||
d.delConn(email, sourceIP)
|
||||
}
|
||||
d.mu.Lock()
|
||||
delete(d.conns, connID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
limiter := d.getBucket(email)
|
||||
|
||||
link.Reader = &statsCloseReader{
|
||||
Reader: link.Reader,
|
||||
counter: &dc.upload,
|
||||
limiter: limiter,
|
||||
ctx: ctx,
|
||||
}
|
||||
link.Writer = &statsCloseWriter{
|
||||
Writer: link.Writer,
|
||||
counter: &dc.download,
|
||||
limiter: limiter,
|
||||
ctx: ctx,
|
||||
onClose: onClose,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── features.Feature (delegated) ───────────────────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) Type() interface{} { return routing.DispatcherType() }
|
||||
|
||||
func (d *LimitDispatcher) Start() error {
|
||||
if s, ok := d.inner.(interface{ Start() error }); ok {
|
||||
return s.Start()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) Close() error {
|
||||
if c, ok := d.inner.(interface{ Close() error }); ok {
|
||||
return c.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Limit management (called by Xray kernel) ──────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) UpdateLimits(emailToUID map[string]int, deviceLimits, speedLimits map[string]int) {
|
||||
d.mu.Lock()
|
||||
d.emailToUID = emailToUID
|
||||
d.deviceLimits = deviceLimits
|
||||
d.speedLimits = speedLimits
|
||||
d.mu.Unlock()
|
||||
|
||||
d.speedBuckets.Range(func(key, _ interface{}) bool {
|
||||
if _, ok := speedLimits[key.(string)]; !ok {
|
||||
d.speedBuckets.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) ResetConns() {
|
||||
d.mu.Lock()
|
||||
d.conns = make(map[string]*dispatchedConn)
|
||||
d.userIPs = make(map[string]map[string]int)
|
||||
d.mu.Unlock()
|
||||
|
||||
d.speedBuckets.Range(func(key, _ interface{}) bool {
|
||||
d.speedBuckets.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) Snapshot() []kernel.Connection {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
|
||||
result := make([]kernel.Connection, 0, len(d.conns))
|
||||
for _, c := range d.conns {
|
||||
result = append(result, kernel.Connection{
|
||||
ID: c.id,
|
||||
UserID: c.userID,
|
||||
SourceIP: c.sourceIP,
|
||||
Upload: c.upload.Load(),
|
||||
Download: c.download.Load(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) CloseConn(id string) bool {
|
||||
d.mu.RLock()
|
||||
c, ok := d.conns[id]
|
||||
d.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
c.closed.Store(true)
|
||||
d.delConn(c.email, c.sourceIP)
|
||||
d.mu.Lock()
|
||||
delete(d.conns, id)
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
func (d *LimitDispatcher) checkDeviceLimit(email, sourceIP string, isTCP bool) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
limit, hasLimit := d.deviceLimits[email]
|
||||
if !hasLimit || limit <= 0 {
|
||||
if isTCP {
|
||||
if d.userIPs[email] == nil {
|
||||
d.userIPs[email] = make(map[string]int)
|
||||
}
|
||||
d.userIPs[email][sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
ips := d.userIPs[email]
|
||||
if ips == nil {
|
||||
ips = make(map[string]int)
|
||||
d.userIPs[email] = ips
|
||||
}
|
||||
|
||||
if ips[sourceIP] > 0 {
|
||||
if isTCP {
|
||||
ips[sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if len(ips) < limit {
|
||||
if isTCP {
|
||||
ips[sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Over limit — deterministic: allow lowest IPs lexicographically
|
||||
ipList := make([]string, 0, len(ips)+1)
|
||||
for ip := range ips {
|
||||
ipList = append(ipList, ip)
|
||||
}
|
||||
ipList = append(ipList, sourceIP)
|
||||
sort.Strings(ipList)
|
||||
|
||||
for i := 0; i < limit && i < len(ipList); i++ {
|
||||
if ipList[i] == sourceIP {
|
||||
if isTCP {
|
||||
ips[sourceIP]++
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) delConn(email, sourceIP string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if ips, ok := d.userIPs[email]; ok {
|
||||
ips[sourceIP]--
|
||||
if ips[sourceIP] <= 0 {
|
||||
delete(ips, sourceIP)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
delete(d.userIPs, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) getUID(email string) int {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
return d.emailToUID[email]
|
||||
}
|
||||
|
||||
func (d *LimitDispatcher) getBucket(email string) *rate.Limiter {
|
||||
d.mu.RLock()
|
||||
mbps, ok := d.speedLimits[email]
|
||||
d.mu.RUnlock()
|
||||
if !ok || mbps <= 0 {
|
||||
return nil
|
||||
}
|
||||
bytesPerSec := int(mbps) * 1_000_000 / 8
|
||||
burst := bytesPerSec
|
||||
if burst < 64*1024 {
|
||||
burst = 64 * 1024
|
||||
}
|
||||
v, loaded := d.speedBuckets.LoadOrStore(email, rate.NewLimiter(rate.Limit(bytesPerSec), burst))
|
||||
lim := v.(*rate.Limiter)
|
||||
if loaded {
|
||||
// Update existing limiter in case speed limit changed.
|
||||
lim.SetLimit(rate.Limit(bytesPerSec))
|
||||
lim.SetBurst(burst)
|
||||
}
|
||||
return lim
|
||||
}
|
||||
|
||||
// ─── I/O wrappers ───────────────────────────────────────────────────────────
|
||||
|
||||
type statsCloseReader struct {
|
||||
buf.Reader
|
||||
counter *atomic.Int64
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (r *statsCloseReader) ReadMultiBuffer() (buf.MultiBuffer, error) {
|
||||
mb, err := r.Reader.ReadMultiBuffer()
|
||||
if n := int64(mb.Len()); n > 0 {
|
||||
r.counter.Add(n)
|
||||
if r.limiter != nil {
|
||||
waitN := int(n)
|
||||
if burst := r.limiter.Burst(); waitN > burst {
|
||||
waitN = burst
|
||||
}
|
||||
_ = r.limiter.WaitN(r.ctx, waitN)
|
||||
}
|
||||
}
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (r *statsCloseReader) Close() error { return common.Close(r.Reader) }
|
||||
func (r *statsCloseReader) Interrupt() { common.Interrupt(r.Reader) }
|
||||
|
||||
type statsCloseWriter struct {
|
||||
buf.Writer
|
||||
counter *atomic.Int64
|
||||
limiter *rate.Limiter
|
||||
ctx context.Context
|
||||
onClose func()
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
func (w *statsCloseWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
|
||||
n := int64(mb.Len())
|
||||
if w.limiter != nil {
|
||||
waitN := int(n)
|
||||
if burst := w.limiter.Burst(); waitN > burst {
|
||||
waitN = burst
|
||||
}
|
||||
_ = w.limiter.WaitN(w.ctx, waitN)
|
||||
}
|
||||
w.counter.Add(n)
|
||||
return w.Writer.WriteMultiBuffer(mb)
|
||||
}
|
||||
|
||||
func (w *statsCloseWriter) Close() error {
|
||||
if w.closed.CompareAndSwap(false, true) {
|
||||
w.onClose()
|
||||
}
|
||||
return common.Close(w.Writer)
|
||||
}
|
||||
|
||||
func (w *statsCloseWriter) Interrupt() {
|
||||
if w.closed.CompareAndSwap(false, true) {
|
||||
w.onClose()
|
||||
}
|
||||
common.Interrupt(w.Writer)
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
func TestLimitDispatcher_DeviceLimitCheck(t *testing.T) {
|
||||
ld := &LimitDispatcher{
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
|
||||
users := []panel.User{
|
||||
{ID: 1, UUID: "uuid-1", DeviceLimit: 2, SpeedLimit: 0},
|
||||
{ID: 2, UUID: "uuid-2", DeviceLimit: 0, SpeedLimit: 10},
|
||||
}
|
||||
|
||||
emailToUID := make(map[string]int)
|
||||
deviceLimits := make(map[string]int)
|
||||
speedLimits := make(map[string]int)
|
||||
for _, u := range users {
|
||||
email := userEmail(u.ID)
|
||||
emailToUID[email] = u.ID
|
||||
if u.DeviceLimit > 0 {
|
||||
deviceLimits[email] = u.DeviceLimit
|
||||
}
|
||||
if u.SpeedLimit > 0 {
|
||||
speedLimits[email] = u.SpeedLimit
|
||||
}
|
||||
}
|
||||
ld.UpdateLimits(emailToUID, deviceLimits, speedLimits)
|
||||
|
||||
email1 := userEmail(1)
|
||||
|
||||
// First IP should be allowed
|
||||
if ld.checkDeviceLimit(email1, "1.1.1.1", true) {
|
||||
t.Error("first IP should be allowed")
|
||||
}
|
||||
|
||||
// Second IP should be allowed (limit=2)
|
||||
if ld.checkDeviceLimit(email1, "2.2.2.2", true) {
|
||||
t.Error("second IP should be allowed")
|
||||
}
|
||||
|
||||
// Third unique IP should be rejected
|
||||
if !ld.checkDeviceLimit(email1, "3.3.3.3", true) {
|
||||
t.Error("third IP should be rejected (limit=2)")
|
||||
}
|
||||
|
||||
// Same IP as first should be allowed (already connected)
|
||||
if ld.checkDeviceLimit(email1, "1.1.1.1", true) {
|
||||
t.Error("same IP should always be allowed")
|
||||
}
|
||||
|
||||
// User 2 has no device limit — should always be allowed
|
||||
email2 := userEmail(2)
|
||||
for i := 0; i < 10; i++ {
|
||||
ip := "10.0.0." + string(rune('0'+i))
|
||||
if ld.checkDeviceLimit(email2, ip, true) {
|
||||
t.Errorf("user with no device limit should always be allowed (ip=%s)", ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_DelConn(t *testing.T) {
|
||||
ld := &LimitDispatcher{
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
|
||||
email := userEmail(1)
|
||||
deviceLimits := map[string]int{email: 2}
|
||||
ld.UpdateLimits(map[string]int{email: 1}, deviceLimits, nil)
|
||||
|
||||
// Add 2 IPs
|
||||
ld.checkDeviceLimit(email, "1.1.1.1", true)
|
||||
ld.checkDeviceLimit(email, "2.2.2.2", true)
|
||||
|
||||
// Third should be rejected
|
||||
if !ld.checkDeviceLimit(email, "3.3.3.3", true) {
|
||||
t.Error("third IP should be rejected")
|
||||
}
|
||||
|
||||
// Remove first IP
|
||||
ld.delConn(email, "1.1.1.1")
|
||||
|
||||
// Now third IP should be allowed
|
||||
if ld.checkDeviceLimit(email, "3.3.3.3", true) {
|
||||
t.Error("after deleting one IP, new IP should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_SpeedBucket(t *testing.T) {
|
||||
ld := &LimitDispatcher{
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
|
||||
email1 := userEmail(1)
|
||||
email2 := userEmail(2)
|
||||
|
||||
speedLimits := map[string]int{email1: 10} // 10 Mbps
|
||||
ld.UpdateLimits(map[string]int{email1: 1, email2: 2}, nil, speedLimits)
|
||||
|
||||
// User 1 has speed limit — should get a limiter
|
||||
limiter := ld.getBucket(email1)
|
||||
if limiter == nil {
|
||||
t.Error("user with speed limit should get a limiter")
|
||||
}
|
||||
|
||||
// Same user should get the same limiter (cached)
|
||||
limiter2 := ld.getBucket(email1)
|
||||
if limiter != limiter2 {
|
||||
t.Error("same user should get cached limiter")
|
||||
}
|
||||
|
||||
// User 2 has no speed limit — should get nil
|
||||
limiter3 := ld.getBucket(email2)
|
||||
if limiter3 != nil {
|
||||
t.Error("user without speed limit should get nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_Snapshot(t *testing.T) {
|
||||
ld := &LimitDispatcher{
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
|
||||
// Add some connections
|
||||
ld.conns["conn-1"] = &dispatchedConn{
|
||||
id: "conn-1", email: "user@1", sourceIP: "1.1.1.1", userID: 1,
|
||||
}
|
||||
ld.conns["conn-2"] = &dispatchedConn{
|
||||
id: "conn-2", email: "user@2", sourceIP: "2.2.2.2", userID: 2,
|
||||
}
|
||||
ld.conns["conn-1"].upload.Store(1000)
|
||||
ld.conns["conn-1"].download.Store(2000)
|
||||
|
||||
snapshot := ld.Snapshot()
|
||||
if len(snapshot) != 2 {
|
||||
t.Fatalf("expected 2 connections, got %d", len(snapshot))
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, c := range snapshot {
|
||||
if c.ID == "conn-1" {
|
||||
found = true
|
||||
if c.UserID != 1 || c.SourceIP != "1.1.1.1" || c.Upload != 1000 || c.Download != 2000 {
|
||||
t.Errorf("unexpected connection data: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("conn-1 not found in snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_CloseConn(t *testing.T) {
|
||||
ld := &LimitDispatcher{
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
|
||||
email := userEmail(1)
|
||||
ld.userIPs[email] = map[string]int{"1.1.1.1": 1}
|
||||
ld.conns["conn-1"] = &dispatchedConn{
|
||||
id: "conn-1", email: email, sourceIP: "1.1.1.1", userID: 1,
|
||||
}
|
||||
|
||||
ok := ld.CloseConn("conn-1")
|
||||
if !ok {
|
||||
t.Error("CloseConn should return true for existing connection")
|
||||
}
|
||||
|
||||
if len(ld.conns) != 0 {
|
||||
t.Error("connection should be removed after close")
|
||||
}
|
||||
|
||||
ok = ld.CloseConn("nonexistent")
|
||||
if ok {
|
||||
t.Error("CloseConn should return false for nonexistent connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitDispatcher_ResetConns(t *testing.T) {
|
||||
ld := &LimitDispatcher{
|
||||
conns: make(map[string]*dispatchedConn),
|
||||
userIPs: make(map[string]map[string]int),
|
||||
}
|
||||
ld.conns["c1"] = &dispatchedConn{id: "c1"}
|
||||
ld.userIPs["user@1"] = map[string]int{"1.1.1.1": 1}
|
||||
|
||||
ld.ResetConns()
|
||||
|
||||
if len(ld.conns) != 0 {
|
||||
t.Error("conns should be empty after reset")
|
||||
}
|
||||
if len(ld.userIPs) != 0 {
|
||||
t.Error("userIPs should be empty after reset")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
xrayCore "github.com/xtls/xray-core/core"
|
||||
"github.com/xtls/xray-core/features/stats"
|
||||
"github.com/xtls/xray-core/infra/conf/serial"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
_ "github.com/xtls/xray-core/main/distro/all"
|
||||
|
||||
"github.com/cedar2025/xboard-node/internal/config"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel"
|
||||
"github.com/cedar2025/xboard-node/internal/kernel/geodata"
|
||||
"github.com/cedar2025/xboard-node/internal/panel"
|
||||
)
|
||||
|
||||
// drainTimeout is how long stop() waits for in-flight connections to finish
|
||||
// naturally before hard-killing the xray instance.
|
||||
const drainTimeout = 5 * time.Second
|
||||
|
||||
// Xray implements kernel.Kernel by embedding xray-core as a Go library.
|
||||
//
|
||||
// Device limits and speed limits are enforced through a custom dispatcher
|
||||
// wrapper (LimitDispatcher) that hooks into session.InboundFromContext(ctx)
|
||||
// — the same approach used by V2bX and XrayR. This works for ALL protocols
|
||||
// because the dispatcher is called AFTER the protocol handler has identified
|
||||
// the user and recorded the source IP in the session context.
|
||||
type Xray struct {
|
||||
cfg config.KernelConfig
|
||||
|
||||
mu sync.Mutex
|
||||
instance *xrayCore.Instance
|
||||
users []panel.User
|
||||
protocol string
|
||||
|
||||
inboundTag string
|
||||
|
||||
// limitDispatcher is the per-instance LimitDispatcher created during
|
||||
// xrayCore.New(). Stored here (instead of using the global pointer)
|
||||
// so that multi-node mode with multiple Xray instances works correctly.
|
||||
limitDispatcher *LimitDispatcher
|
||||
|
||||
// lastKernelHash caches a hash of the kernel-affecting config + user
|
||||
// identities. Used by Reload to skip unnecessary full restarts.
|
||||
lastKernelHash string
|
||||
|
||||
// cumTraffic keeps running totals for the aggregate stats fallback.
|
||||
cumTraffic map[int][2]int64
|
||||
}
|
||||
|
||||
func New(cfg config.KernelConfig) *Xray {
|
||||
return &Xray{
|
||||
cfg: cfg,
|
||||
cumTraffic: make(map[int][2]int64),
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xray) Name() string { return "xray" }
|
||||
|
||||
func (x *Xray) ApplyConfig(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
|
||||
// Auto-download geo databases and set XRAY_LOCATION_ASSET when panel
|
||||
// routes reference geoip:/geosite: entries.
|
||||
if kernel.NeedsGeoIP(nodeConfig.Routes) || kernel.NeedsGeoSite(nodeConfig.Routes) {
|
||||
geoDataDir := x.cfg.GeoDataDir
|
||||
if err := geodata.Ensure(geoDataDir,
|
||||
kernel.NeedsGeoIP(nodeConfig.Routes),
|
||||
kernel.NeedsGeoSite(nodeConfig.Routes),
|
||||
"xray",
|
||||
); err != nil {
|
||||
slog.Warn("geo database unavailable; geoip/geosite rules may not match", "error", err)
|
||||
}
|
||||
os.Setenv("XRAY_LOCATION_ASSET", geoDataDir)
|
||||
}
|
||||
|
||||
cfgMap := buildConfig(x.cfg, nodeConfig, users, certFile, keyFile)
|
||||
data, err := json.Marshal(cfgMap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("xray config generated", "len", len(data))
|
||||
|
||||
pbConfig, err := serial.LoadJSONConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse xray config: %w", err)
|
||||
}
|
||||
|
||||
x.stop()
|
||||
|
||||
// Hold the global creation mutex around xrayCore.New() + capture so that
|
||||
// concurrent Xray instances (multi-node) don't clobber each other's
|
||||
// LimitDispatcher reference.
|
||||
xrayCreationMu.Lock()
|
||||
instance, err := xrayCore.New(pbConfig)
|
||||
if err != nil {
|
||||
xrayCreationMu.Unlock()
|
||||
return fmt.Errorf("create xray instance: %w", err)
|
||||
}
|
||||
x.limitDispatcher = globalLimitDispatcher.Load()
|
||||
xrayCreationMu.Unlock()
|
||||
|
||||
if err := instance.Start(); err != nil {
|
||||
instance.Close()
|
||||
return fmt.Errorf("start xray: %w", err)
|
||||
}
|
||||
|
||||
x.instance = instance
|
||||
x.users = users
|
||||
x.protocol = nodeConfig.Protocol
|
||||
x.inboundTag = nodeConfig.Protocol + "-in"
|
||||
x.cumTraffic = make(map[int][2]int64)
|
||||
x.lastKernelHash = computeKernelHash(nodeConfig, users)
|
||||
|
||||
// Configure the limit dispatcher with user limits
|
||||
x.updateDispatcherLimits(users)
|
||||
|
||||
slog.Info("xray started (in-process)",
|
||||
"users", len(users),
|
||||
"protocol", nodeConfig.Protocol,
|
||||
"device_limits", "enabled (all protocols via dispatcher hook)",
|
||||
"speed_limits", "enabled (all protocols via dispatcher hook)",
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Xray) Reload(nodeConfig *panel.NodeConfig, users []panel.User, certFile, keyFile string) error {
|
||||
// Always update dispatcher limits (instant, no restart).
|
||||
x.updateDispatcherLimits(users)
|
||||
|
||||
// Skip full restart if kernel-affecting fields are unchanged.
|
||||
// This covers user-only limit changes where identities (ID, UUID) and
|
||||
// config (routes, protocol settings, port) haven't changed.
|
||||
newHash := computeKernelHash(nodeConfig, users)
|
||||
x.mu.Lock()
|
||||
same := x.lastKernelHash == newHash
|
||||
x.mu.Unlock()
|
||||
if same {
|
||||
x.mu.Lock()
|
||||
x.users = users
|
||||
x.mu.Unlock()
|
||||
slog.Info("xray: config/users unchanged, limits updated without restart")
|
||||
return nil
|
||||
}
|
||||
|
||||
return x.ApplyConfig(nodeConfig, users, certFile, keyFile)
|
||||
}
|
||||
|
||||
func (x *Xray) Stop() {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
x.stop()
|
||||
}
|
||||
|
||||
func (x *Xray) stop() {
|
||||
if x.instance != nil {
|
||||
// Drain: wait for in-flight connections to finish naturally.
|
||||
if x.limitDispatcher != nil {
|
||||
deadline := time.Now().Add(drainTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
x.limitDispatcher.mu.RLock()
|
||||
n := len(x.limitDispatcher.conns)
|
||||
x.limitDispatcher.mu.RUnlock()
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
x.instance.Close()
|
||||
x.instance = nil
|
||||
}
|
||||
if x.limitDispatcher != nil {
|
||||
x.limitDispatcher.ResetConns()
|
||||
x.limitDispatcher = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xray) IsRunning() bool {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
return x.instance != nil
|
||||
}
|
||||
|
||||
// GetConnections returns per-connection data from the dispatcher.
|
||||
// Each connection has a real source IP and user ID, enabling device limit
|
||||
// enforcement by the upper-layer limiter.
|
||||
func (x *Xray) GetConnections(ctx context.Context) ([]kernel.Connection, error) {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
|
||||
if x.instance == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Primary: use LimitDispatcher's per-connection tracking
|
||||
if x.limitDispatcher != nil {
|
||||
conns := x.limitDispatcher.Snapshot()
|
||||
if len(conns) > 0 {
|
||||
return conns, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: aggregate stats (for edge cases)
|
||||
return x.getAggregateStats()
|
||||
}
|
||||
|
||||
func (x *Xray) getAggregateStats() ([]kernel.Connection, error) {
|
||||
sm := x.instance.GetFeature(stats.ManagerType())
|
||||
if sm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
statsManager, ok := sm.(stats.Manager)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var conns []kernel.Connection
|
||||
for _, u := range x.users {
|
||||
email := userEmail(u.ID)
|
||||
|
||||
var deltaUp, deltaDown int64
|
||||
if c := statsManager.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>uplink", email)); c != nil {
|
||||
deltaUp = c.Set(0)
|
||||
}
|
||||
if c := statsManager.GetCounter(fmt.Sprintf("user>>>%s>>>traffic>>>downlink", email)); c != nil {
|
||||
deltaDown = c.Set(0)
|
||||
}
|
||||
|
||||
if deltaUp > 0 || deltaDown > 0 {
|
||||
cum := x.cumTraffic[u.ID]
|
||||
cum[0] += deltaUp
|
||||
cum[1] += deltaDown
|
||||
x.cumTraffic[u.ID] = cum
|
||||
}
|
||||
|
||||
cum := x.cumTraffic[u.ID]
|
||||
if cum[0] > 0 || cum[1] > 0 {
|
||||
conns = append(conns, kernel.Connection{
|
||||
ID: fmt.Sprintf("xray-%d", u.ID),
|
||||
UserID: u.ID,
|
||||
Upload: cum[0],
|
||||
Download: cum[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
return conns, nil
|
||||
}
|
||||
|
||||
func (x *Xray) CloseConnection(_ context.Context, connID string) error {
|
||||
x.mu.Lock()
|
||||
ld := x.limitDispatcher
|
||||
x.mu.Unlock()
|
||||
if ld != nil {
|
||||
ld.CloseConn(connID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Xray) SetSpeedLimitFunc(_ func(string) *rate.Limiter) {}
|
||||
|
||||
var _ kernel.Kernel = (*Xray)(nil)
|
||||
|
||||
// updateDispatcherLimits configures the global LimitDispatcher with
|
||||
// per-user device limits and speed limits.
|
||||
func (x *Xray) updateDispatcherLimits(users []panel.User) {
|
||||
x.mu.Lock()
|
||||
ld := x.limitDispatcher
|
||||
x.mu.Unlock()
|
||||
if ld == nil {
|
||||
return
|
||||
}
|
||||
|
||||
emailToUID := make(map[string]int, len(users))
|
||||
deviceLimits := make(map[string]int)
|
||||
speedLimits := make(map[string]int)
|
||||
|
||||
for _, u := range users {
|
||||
email := userEmail(u.ID)
|
||||
emailToUID[email] = u.ID
|
||||
// SOCKS/HTTP protocols use the raw UUID as User.Email in xray's session,
|
||||
// while VMess/VLESS/Trojan use the "user@<id>" format. Map both so that
|
||||
// limits work for all protocols.
|
||||
emailToUID[u.UUID] = u.ID
|
||||
if u.DeviceLimit > 0 {
|
||||
deviceLimits[email] = u.DeviceLimit
|
||||
deviceLimits[u.UUID] = u.DeviceLimit
|
||||
}
|
||||
if u.SpeedLimit > 0 {
|
||||
speedLimits[email] = u.SpeedLimit
|
||||
speedLimits[u.UUID] = u.SpeedLimit
|
||||
}
|
||||
}
|
||||
|
||||
ld.UpdateLimits(emailToUID, deviceLimits, speedLimits)
|
||||
}
|
||||
|
||||
// xrayCreationMu serialises xrayCore.New() + globalLimitDispatcher capture
|
||||
// so that concurrent Xray instances in multi-node mode each capture their
|
||||
// own LimitDispatcher.
|
||||
var xrayCreationMu sync.Mutex
|
||||
|
||||
// computeKernelHash returns a hash of the config + user identities (ID, UUID)
|
||||
// that would require a kernel restart if changed. Limit-only changes are
|
||||
// excluded so they can be applied without restart.
|
||||
func computeKernelHash(nc *panel.NodeConfig, users []panel.User) string {
|
||||
h := sha256.New()
|
||||
configData, _ := json.Marshal(nc)
|
||||
h.Write(configData)
|
||||
sorted := make([]panel.User, len(users))
|
||||
copy(sorted, users)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ID < sorted[j].ID })
|
||||
for _, u := range sorted {
|
||||
fmt.Fprintf(h, "%d:%s,", u.ID, u.UUID)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
Reference in New Issue
Block a user