506 lines
17 KiB
Go
506 lines
17 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const CurrentSchemaVersion = 5
|
|
|
|
// Config is the canonical root runtime configuration.
|
|
type Config struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Proxy ProxyConfig `json:"proxy"`
|
|
Profile ProfileProjectionConfig `json:"profile,omitempty"`
|
|
Routing RoutingConfig `json:"routing"`
|
|
Apps AppsConfig `json:"apps"`
|
|
DNS DNSConfig `json:"dns"`
|
|
IPv6 IPv6Config `json:"ipv6"`
|
|
Sharing SharingConfig `json:"sharing,omitempty"`
|
|
Health HealthConfig `json:"health"`
|
|
LogLevel string `json:"log_level,omitempty"`
|
|
Autostart bool `json:"autostart"`
|
|
}
|
|
|
|
// ProxyConfig controls the sing-box proxy listener ports.
|
|
type ProxyConfig struct {
|
|
Mode string `json:"mode"` // "tproxy" (matches config.json proxy.mode)
|
|
TProxyPort int `json:"tproxy_port"`
|
|
DNSPort int `json:"dns_port"`
|
|
GID int `json:"gid"` // core process GID (matches config.json proxy.gid)
|
|
Mark int `json:"mark"` // fwmark for policy routing (matches config.json proxy.mark)
|
|
APIPort int `json:"api_port"` // 0 disables sing-box Clash REST API
|
|
APISecret string `json:"api_secret,omitempty"`
|
|
}
|
|
|
|
// ProfileProjectionConfig stores APK-rendered profile projection data needed by
|
|
// the runtime renderer. It is not a user profile database.
|
|
type ProfileProjectionConfig struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
ActiveNodeID string `json:"active_node_id,omitempty"`
|
|
Nodes []json.RawMessage `json:"nodes,omitempty"`
|
|
Inbounds json.RawMessage `json:"inbounds,omitempty"`
|
|
}
|
|
|
|
// ProfileInboundsConfig stores optional localhost-only helper ports.
|
|
type ProfileInboundsConfig struct {
|
|
SocksPort int `json:"socksPort"`
|
|
HTTPPort int `json:"httpPort"`
|
|
AllowLAN bool `json:"allowLan"`
|
|
}
|
|
|
|
// RoutingConfig controls traffic routing rules.
|
|
type RoutingConfig struct {
|
|
Mode string `json:"mode"` // "all", "whitelist", "blacklist", "rules", "direct"
|
|
BypassLAN bool `json:"bypass_lan"`
|
|
BypassChina bool `json:"bypass_china"` // matches config.json routing.bypass_china
|
|
BypassRussia bool `json:"bypass_russia"`
|
|
BlockAds bool `json:"block_ads"`
|
|
CustomDirect []string `json:"custom_direct"` // domains/IPs to route directly
|
|
CustomProxy []string `json:"custom_proxy"` // domains/IPs to force through proxy
|
|
CustomBlock []string `json:"custom_block"` // domains/IPs to block
|
|
// Preserved selections for modes that are not currently active.
|
|
InactiveAppProxyList []string `json:"inactive_app_proxy_list,omitempty"`
|
|
InactiveAppBypassList []string `json:"inactive_app_bypass_list,omitempty"`
|
|
AlwaysDirectApps []string `json:"always_direct_apps,omitempty"`
|
|
AlwaysDirectExcludedApps []string `json:"always_direct_excluded_apps,omitempty"`
|
|
AlwaysDirectSystemApps bool `json:"always_direct_system_apps"`
|
|
}
|
|
|
|
// AppsConfig controls per-app routing (Android split tunnel).
|
|
type AppsConfig struct {
|
|
Mode string `json:"mode"` // "all", "whitelist", "blacklist", "off"
|
|
Packages []string `json:"list"` // package names for whitelist/blacklist (matches config.json apps.list)
|
|
AppGroups map[string]string `json:"app_groups"` // package name -> profile node group outbound
|
|
}
|
|
|
|
// DNSConfig controls DNS resolution.
|
|
type DNSConfig struct {
|
|
HijackPerUID bool `json:"hijack_per_uid"` // per-UID DNS hijack (matches config.json dns.hijack_per_uid)
|
|
ProxyDNS string `json:"proxy_dns"` // DoH URL routed via proxy (matches config.json dns.proxy_dns)
|
|
DirectDNS string `json:"direct_dns"` // DoH URL for direct domains (matches config.json dns.direct_dns)
|
|
BootstrapIP string `json:"bootstrap_ip"` // IP-literal for bootstrapping DoH
|
|
FakeIP bool `json:"fake_ip"` // use fake-ip strategy
|
|
}
|
|
|
|
// IPv6Config controls IPv6 behavior.
|
|
type IPv6Config struct {
|
|
Mode string `json:"mode"` // "mirror", "disable", etc. (matches config.json ipv6.mode)
|
|
}
|
|
|
|
// SharingConfig controls forwarded hotspot/tethering client traffic.
|
|
// It is explicit because forwarding other devices is a different privacy
|
|
// surface from per-app local TPROXY.
|
|
type SharingConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Interfaces []string `json:"interfaces,omitempty"`
|
|
}
|
|
|
|
// HealthConfig controls automatic health checking.
|
|
type HealthConfig struct {
|
|
Enabled bool `json:"enabled"` // matches config.json health.enabled
|
|
IntervalSec int `json:"interval_sec"`
|
|
Threshold int `json:"threshold"` // failure threshold (matches config.json health.threshold)
|
|
URL string `json:"check_url"` // URL to probe (matches config.json health.check_url)
|
|
TimeoutSec int `json:"timeout_sec"`
|
|
DNSProbeDomains []string `json:"dns_probe_domains,omitempty"`
|
|
EgressURLs []string `json:"egress_urls,omitempty"`
|
|
DNSIsHardReadiness bool `json:"dns_is_hard_readiness"`
|
|
}
|
|
|
|
// NodeProfile is an APK-rendered profile node ready for sing-box config
|
|
// rendering.
|
|
type NodeProfile struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Group string `json:"group,omitempty"`
|
|
Tag string `json:"tag,omitempty"`
|
|
Protocol string `json:"protocol"`
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
UUID string `json:"uuid"`
|
|
Username string `json:"username,omitempty"`
|
|
Password string `json:"password,omitempty"`
|
|
Flow string `json:"flow,omitempty"`
|
|
Transport string `json:"transport"`
|
|
TLSServer string `json:"tls_server"`
|
|
Fingerprint string `json:"fingerprint"`
|
|
SSMethod string `json:"ss_method,omitempty"`
|
|
SSPlugin string `json:"ss_plugin,omitempty"`
|
|
SSPluginOpts string `json:"ss_plugin_opts,omitempty"`
|
|
SocksVersion string `json:"socks_version,omitempty"`
|
|
Network string `json:"network,omitempty"`
|
|
OwnerPackage string `json:"owner_package,omitempty"`
|
|
ServerPorts []string `json:"server_ports,omitempty"`
|
|
ObfsType string `json:"obfs_type,omitempty"`
|
|
ObfsPassword string `json:"obfs_password,omitempty"`
|
|
AlterID int `json:"alter_id,omitempty"`
|
|
Security string `json:"security,omitempty"`
|
|
RealityPubKey string `json:"reality_public_key,omitempty"`
|
|
RealityShortID string `json:"reality_short_id,omitempty"`
|
|
WGPrivateKey string `json:"wg_private_key,omitempty"`
|
|
WGPeerPublicKey string `json:"wg_peer_public_key,omitempty"`
|
|
WGPresharedKey string `json:"wg_preshared_key,omitempty"`
|
|
WGLocalAddress []string `json:"wg_local_address,omitempty"`
|
|
WGAllowedIPs string `json:"wg_allowed_ips,omitempty"`
|
|
WGMTU int `json:"wg_mtu,omitempty"`
|
|
WGReserved []int `json:"wg_reserved,omitempty"`
|
|
Extra map[string]string `json:"extra,omitempty"`
|
|
Stale bool `json:"stale,omitempty"`
|
|
RawOutbound json.RawMessage `json:"-"`
|
|
}
|
|
|
|
const XraySidecarSocksPort = 10859
|
|
const DefaultClashAPIPort = 19090
|
|
|
|
// DefaultConfig returns a Config with sensible defaults.
|
|
func DefaultConfig() *Config {
|
|
return &Config{
|
|
SchemaVersion: CurrentSchemaVersion,
|
|
Proxy: ProxyConfig{
|
|
Mode: "tproxy",
|
|
TProxyPort: 10853,
|
|
DNSPort: 10856,
|
|
GID: 23333,
|
|
Mark: 8227,
|
|
APIPort: 0,
|
|
},
|
|
Profile: ProfileProjectionConfig{
|
|
ID: "default",
|
|
Name: "Default",
|
|
},
|
|
Routing: RoutingConfig{
|
|
Mode: "all",
|
|
BypassLAN: true,
|
|
BypassRussia: true,
|
|
AlwaysDirectSystemApps: true,
|
|
},
|
|
Apps: AppsConfig{
|
|
Mode: "all",
|
|
},
|
|
DNS: DNSConfig{
|
|
HijackPerUID: true,
|
|
ProxyDNS: "https://1.1.1.1/dns-query",
|
|
DirectDNS: "https://dns.google/dns-query",
|
|
BootstrapIP: "1.1.1.1",
|
|
FakeIP: false,
|
|
},
|
|
IPv6: IPv6Config{
|
|
Mode: "mirror",
|
|
},
|
|
Sharing: SharingConfig{
|
|
Enabled: false,
|
|
},
|
|
Health: HealthConfig{
|
|
Enabled: true,
|
|
IntervalSec: 30,
|
|
Threshold: 3,
|
|
URL: "https://www.gstatic.com/generate_204",
|
|
TimeoutSec: 5,
|
|
DNSProbeDomains: []string{"connectivitycheck.gstatic.com", "cloudflare.com", "example.com"},
|
|
EgressURLs: []string{
|
|
"https://www.gstatic.com/generate_204",
|
|
"https://cp.cloudflare.com/generate_204",
|
|
},
|
|
DNSIsHardReadiness: false,
|
|
},
|
|
LogLevel: "warn",
|
|
Autostart: false,
|
|
}
|
|
}
|
|
|
|
func (c *Config) SharingModeEnv() string {
|
|
if c != nil && c.Sharing.Enabled {
|
|
return "hotspot"
|
|
}
|
|
return "off"
|
|
}
|
|
|
|
func (c *Config) SharingInterfacesEnv() string {
|
|
if c == nil || len(c.Sharing.Interfaces) == 0 {
|
|
return ""
|
|
}
|
|
values := make([]string, 0, len(c.Sharing.Interfaces))
|
|
for _, iface := range c.Sharing.Interfaces {
|
|
iface = strings.TrimSpace(iface)
|
|
if iface != "" {
|
|
values = append(values, iface)
|
|
}
|
|
}
|
|
return strings.Join(values, " ")
|
|
}
|
|
|
|
// defaultProfileProjectionConfig returns empty profile projection defaults.
|
|
func defaultProfileProjectionConfig() ProfileProjectionConfig {
|
|
return ProfileProjectionConfig{
|
|
ID: "default",
|
|
Name: "Default",
|
|
}
|
|
}
|
|
|
|
// ResolveProfileInbounds returns the effective profile inbound settings with
|
|
// defaults applied even when profile inbounds are absent.
|
|
func (c *Config) ResolveProfileInbounds() ProfileInboundsConfig {
|
|
result := ProfileInboundsConfig{
|
|
SocksPort: 0,
|
|
HTTPPort: 0,
|
|
AllowLAN: false,
|
|
}
|
|
if len(c.Profile.Inbounds) == 0 {
|
|
return result
|
|
}
|
|
var decoded ProfileInboundsConfig
|
|
if err := json.Unmarshal(c.Profile.Inbounds, &decoded); err != nil {
|
|
return result
|
|
}
|
|
if decoded.SocksPort > 0 {
|
|
result.SocksPort = decoded.SocksPort
|
|
}
|
|
if decoded.HTTPPort > 0 {
|
|
result.HTTPPort = decoded.HTTPPort
|
|
}
|
|
// Helper inbounds are diagnostics/local-control surfaces and are always
|
|
// localhost-only in the root runtime.
|
|
result.AllowLAN = false
|
|
return result
|
|
}
|
|
|
|
// Load reads a Config from the given JSON file path.
|
|
// If the file does not exist, it returns DefaultConfig.
|
|
func Load(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return DefaultConfig(), nil
|
|
}
|
|
return nil, fmt.Errorf("config: read %s: %w", path, err)
|
|
}
|
|
|
|
cfg := DefaultConfig()
|
|
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(cfg); err != nil {
|
|
return nil, fmt.Errorf("config: parse %s: %w", path, err)
|
|
}
|
|
var extra json.RawMessage
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
return nil, fmt.Errorf("config: parse %s: config document must contain a single JSON object", path)
|
|
}
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("config: validate: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func decodeStrictJSON(data []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
var extra json.RawMessage
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
return fmt.Errorf("document must contain a single JSON object")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Save writes the Config as formatted JSON to the given file path,
|
|
// creating parent directories as needed.
|
|
func (c *Config) Save(path string) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
|
|
return fmt.Errorf("config: mkdir: %w", err)
|
|
}
|
|
|
|
if c.SchemaVersion == 0 {
|
|
c.SchemaVersion = CurrentSchemaVersion
|
|
}
|
|
data, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("config: marshal: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
if err := writeFileAtomic(path, data, 0600, "config"); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeFileAtomic(path string, data []byte, perm os.FileMode, label string) error {
|
|
tmpPath := path + ".tmp"
|
|
f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return fmt.Errorf("%s: open %s: %w", label, tmpPath, err)
|
|
}
|
|
if _, err := f.Write(data); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return fmt.Errorf("%s: write %s: %w", label, tmpPath, err)
|
|
}
|
|
if err := f.Chmod(perm); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return fmt.Errorf("%s: chmod %s: %w", label, tmpPath, err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
_ = os.Remove(tmpPath)
|
|
return fmt.Errorf("%s: sync %s: %w", label, tmpPath, err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return fmt.Errorf("%s: close %s: %w", label, tmpPath, err)
|
|
}
|
|
if err := os.Rename(tmpPath, path); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return fmt.Errorf("%s: rename %s: %w", label, path, err)
|
|
}
|
|
syncDirBestEffort(filepath.Dir(path))
|
|
return nil
|
|
}
|
|
|
|
func validateProfileProjectionConfig(profile ProfileProjectionConfig) error {
|
|
if len(profile.Inbounds) > 0 {
|
|
var inbounds ProfileInboundsConfig
|
|
if err := decodeStrictJSON(profile.Inbounds, &inbounds); err != nil {
|
|
return fmt.Errorf("profile.inbounds invalid: %w", err)
|
|
}
|
|
if inbounds.SocksPort < 0 || inbounds.SocksPort > 65535 {
|
|
return fmt.Errorf("profile.inbounds.socksPort must be 0-65535, got %d", inbounds.SocksPort)
|
|
}
|
|
if inbounds.HTTPPort < 0 || inbounds.HTTPPort > 65535 {
|
|
return fmt.Errorf("profile.inbounds.httpPort must be 0-65535, got %d", inbounds.HTTPPort)
|
|
}
|
|
if inbounds.AllowLAN {
|
|
return fmt.Errorf("profile.inbounds.allowLan is not supported by root helper inbounds")
|
|
}
|
|
}
|
|
for index, raw := range profile.Nodes {
|
|
if len(bytes.TrimSpace(raw)) == 0 {
|
|
return fmt.Errorf("profile.nodes[%d] is empty", index)
|
|
}
|
|
var node storedProfileNode
|
|
if err := decodeStrictJSON(raw, &node); err != nil {
|
|
return fmt.Errorf("profile.nodes[%d] invalid: %w", index, err)
|
|
}
|
|
if node.Port < 0 || node.Port > 65535 {
|
|
return fmt.Errorf("profile.nodes[%d].port must be 0-65535, got %d", index, node.Port)
|
|
}
|
|
if node.Stale {
|
|
return fmt.Errorf("profile.nodes[%d].stale nodes must be filtered by APK before runtime apply", index)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func syncDirBestEffort(dir string) {
|
|
f, err := os.Open(dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
_ = f.Sync()
|
|
}
|
|
|
|
// Validate checks the Config for obvious misconfigurations.
|
|
func (c *Config) Validate() error {
|
|
if c.SchemaVersion != CurrentSchemaVersion {
|
|
return fmt.Errorf("schema_version must be %d, got %d", CurrentSchemaVersion, c.SchemaVersion)
|
|
}
|
|
if err := validateProfileProjectionConfig(c.Profile); err != nil {
|
|
return err
|
|
}
|
|
if c.Proxy.TProxyPort < 1 || c.Proxy.TProxyPort > 65535 {
|
|
return fmt.Errorf("proxy.tproxy_port must be 1-65535, got %d", c.Proxy.TProxyPort)
|
|
}
|
|
if c.Proxy.DNSPort < 1 || c.Proxy.DNSPort > 65535 {
|
|
return fmt.Errorf("proxy.dns_port must be 1-65535, got %d", c.Proxy.DNSPort)
|
|
}
|
|
if c.Proxy.APIPort < 0 || c.Proxy.APIPort > 65535 {
|
|
return fmt.Errorf("proxy.api_port must be 0-65535, got %d", c.Proxy.APIPort)
|
|
}
|
|
if c.Proxy.APIPort > 0 && strings.TrimSpace(c.Proxy.APISecret) == "" {
|
|
return fmt.Errorf("proxy.api_secret is required when proxy.api_port is enabled")
|
|
}
|
|
|
|
validRoutingMode := map[string]bool{
|
|
"all": true, "whitelist": true, "blacklist": true, "rules": true, "direct": true,
|
|
}
|
|
if !validRoutingMode[c.Routing.Mode] {
|
|
return fmt.Errorf("routing.mode must be all/whitelist/blacklist/rules/direct, got %q", c.Routing.Mode)
|
|
}
|
|
|
|
validAppMode := map[string]bool{
|
|
"all": true, "whitelist": true, "blacklist": true, "off": true,
|
|
}
|
|
if !validAppMode[c.Apps.Mode] {
|
|
return fmt.Errorf("apps.mode must be all/whitelist/blacklist/off, got %q", c.Apps.Mode)
|
|
}
|
|
|
|
if c.Health.IntervalSec < 0 {
|
|
return fmt.Errorf("health.interval_sec must be >= 0, got %d", c.Health.IntervalSec)
|
|
}
|
|
if c.Health.TimeoutSec < 1 {
|
|
return fmt.Errorf("health.timeout_sec must be >= 1, got %d", c.Health.TimeoutSec)
|
|
}
|
|
c.LogLevel = normalizeLogLevel(c.LogLevel)
|
|
return nil
|
|
}
|
|
|
|
func normalizeLogLevel(level string) string {
|
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
|
case "debug", "info", "warn", "warning", "error", "panic", "fatal":
|
|
if strings.EqualFold(strings.TrimSpace(level), "warning") {
|
|
return "warn"
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(level))
|
|
case "none":
|
|
return "panic"
|
|
default:
|
|
return "warn"
|
|
}
|
|
}
|
|
|
|
func (c *Config) EnsureLocalClashAPIForMultiNode() (bool, error) {
|
|
if c == nil {
|
|
return false, nil
|
|
}
|
|
renderable, _, err := renderableProfileNodesWithSkips(c, ProfilesFromConfigNodes(c))
|
|
if err != nil || len(renderable) <= 1 {
|
|
return false, nil
|
|
}
|
|
changed := false
|
|
if c.Proxy.APIPort <= 0 {
|
|
c.Proxy.APIPort = DefaultClashAPIPort
|
|
changed = true
|
|
}
|
|
if strings.TrimSpace(c.Proxy.APISecret) == "" {
|
|
secret, err := randomAPISecret()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
c.Proxy.APISecret = secret
|
|
changed = true
|
|
}
|
|
return changed, nil
|
|
}
|
|
|
|
func randomAPISecret() (string, error) {
|
|
var raw [32]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return "", fmt.Errorf("generate clash API secret: %w", err)
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
|
}
|