RKNnoVPN/runtime/internal/config/config.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
}