Files
wweir 3286851fdf fix(install,config): lock down installed config, atomic binary copy, reject socks5 password, parse bare ipv6
- The installer writes /etc/sower/sowerd.toml with 0600 instead of 0644:
  the file holds the plaintext client-auth password.
- Binary updates are atomic (temp file + rename) so an interrupted update
  cannot leave a truncated sowerd at /usr/local/bin.
- ExecStart quotes both paths for systemd parsing.
- remote.password for a socks5 upstream is rejected at validation instead
  of being silently ignored (the transport only negotiates no-auth).
- splitRemoteAddr accepts bare IPv6 literals (default port) and rejects
  malformed multi-colon addresses like '::::' instead of passing them
  through as hosts.
2026-08-12 17:23:14 +08:00

269 lines
9.6 KiB
Go

package config
import (
"fmt"
"log/slog"
"net"
"strings"
"github.com/sower-proxy/deferlog/v2"
"github.com/sower-proxy/sower/pkg/upstreamtls"
)
type RemoteTLSConfig struct {
ServerName string `usage:"override upstream TLS server name (SNI)"`
ClientHello string `usage:"uTLS client hello: chrome, firefox, ios, android, edge, safari, 360, qq, randomized, randomized_alpn, randomized_no_alpn, golang"`
InsecureSkipVerify bool `default:"false" usage:"skip upstream TLS certificate verification"`
}
type RemoteConfig struct {
Type string `default:"sower" required:"true" usage:"option: sower/socks5"`
Addr string `required:"true" usage:"proxy address, eg: proxy.com or proxy.com:443"`
// Password is sent verbatim to the upstream proxy. It stays a plain
// string (not deferlog.Password): the base64-or-plain heuristic in
// deferlog.Password would silently corrupt passwords that happen to be
// valid canonical base64, breaking auth against servers that compare
// byte-for-byte.
Password string `usage:"remote proxy password"`
TLS RemoteTLSConfig `flag:"tls"`
}
// SowerConfig represents the configuration for sower client
type SowerConfig struct {
LogLevel slog.Level `default:"info" usage:"log level: debug, info, warn, error"`
// IgnoreAdminState skips the admin state file at startup. It is the
// escape hatch when a persisted override breaks the service.
IgnoreAdminState bool `flag:"ignore-admin-state" usage:"ignore the admin state file at startup"`
Remote RemoteConfig
DNS struct {
Disable bool `default:"false" usage:"disable DNS proxy"`
Serve string `usage:"dns server ip"`
Serve6 string `usage:"dns server ipv6, eg: ::1"`
ServeIface string `usage:"use the IP in the net interface, if serve ip not setted. eg: eth0"`
Upstream string `usage:"upstream dns server"`
Fallback string `default:"223.5.5.5" required:"true" usage:"fallback dns server"`
// Reverse is the DNS server used to resolve client IPs to hostnames
// for the traffic console. Typically the local LAN resolver (e.g.
// dnsmasq) so LAN leases and tailnet names resolve; empty disables
// reverse lookups and the console shows raw IPs.
Reverse string `usage:"reverse dns server for client hostname lookup"`
}
Socks5 struct {
Disable bool `default:"false" usage:"disable sock5 proxy"`
Addr string `default:"127.0.0.1:1080" usage:"socks5 listen address"`
} `flag:"socks5"`
Admin struct {
Disable bool `default:"true" usage:"disable admin web server"`
Addr string `default:"127.0.0.1:19090" usage:"admin web server listen address"`
// Admin console password. Accepts plain text verbatim, or canonical
// base64 with an explicit "b64:" prefix (e.g. "b64:c2VjcmV0"). Plain
// values are never reinterpreted, so a password that happens to be
// valid base64 stays exactly as written.
Password deferlog.Password `usage:"admin web server password; empty generates a random one at startup"`
SessionFile string `default:"/etc/sower/sessions.json" usage:"persist admin sessions to this file; one sower process only"`
DisableSessionPersistence bool `default:"false" usage:"disable admin session persistence"`
CookieSecure bool `default:"false" usage:"set Secure on admin session cookies behind a TLS-terminating proxy"`
StateFile string `default:"/etc/sower/admin-state.json" usage:"persist admin rule and config changes to this file, empty disables persistence"`
} `flag:"admin"`
Router struct {
Block struct {
File string `usage:"block list file, local file or remote"`
FilePrefix string `default:"**." usage:"parsed as '<prefix>line_text'"`
FileSkipRules []string `usage:"rules to skip when loading block list file"`
Rules []string `usage:"block list rules"`
}
Direct struct {
File string `usage:"direct list file, local file or remote"`
FilePrefix string `default:"**." usage:"parsed as '<prefix>line_text'"`
FileSkipRules []string `usage:"rules to skip when loading direct list file"`
Rules []string `usage:"direct list rules"`
}
Proxy struct {
File string `usage:"proxy list file, local file or remote"`
FilePrefix string `default:"**." usage:"parsed as '<prefix>line_text'"`
FileSkipRules []string `usage:"rules to skip when loading proxy list file"`
Rules []string `usage:"proxy list rules"`
}
Country struct {
MMDB string `usage:"mmdb file"`
File string `usage:"CIDR block list file, local file or remote"`
FilePrefix string `default:"" usage:"parsed as '<prefix>line_text'"`
Rules []string `usage:"CIDR list rules"`
}
}
}
// AdminSessionFile returns an empty path only when persistence is explicitly
// disabled. The session file is not safe to share across multiple processes.
func (c SowerConfig) AdminSessionFile() string {
if c.Admin.DisableSessionPersistence {
return ""
}
return c.Admin.SessionFile
}
// Validate implements the validation interface for SowerConfig
func (c *SowerConfig) Validate() error {
switch c.Remote.Type {
case "sower", "socks5":
default:
return fmt.Errorf("unsupported remote type %q", c.Remote.Type)
}
if c.Remote.Type == "socks5" && c.Remote.Password != "" {
// The SOCKS5 client transport only negotiates no-auth (RFC 1928);
// a configured password would be silently ignored and the upstream
// handshake would fail only when it demands authentication.
return fmt.Errorf("remote password is not supported for socks5 upstreams; remove remote.password or use remote.type = \"sower\"")
}
remoteHost, err := validateRemoteAddr(c.Remote.Type, c.Remote.Addr)
if err != nil {
return err
}
if c.Remote.TLS.ClientHello != "" {
if err := upstreamtls.ValidateClientHello(c.Remote.TLS.ClientHello); err != nil {
return err
}
}
if c.DNS.ServeIface != "" {
iface, err := net.InterfaceByName(c.DNS.ServeIface)
if err != nil {
return fmt.Errorf("get interface: %w", err)
}
addrs, err := iface.Addrs()
if err != nil {
return fmt.Errorf("get interface addresses: %w", err)
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
return fmt.Errorf("parse interface IP: %w", err)
}
if ip.To4() != nil { // ipv4
if c.DNS.Serve == "" {
c.DNS.Serve = ip.String()
}
} else if ip.IsGlobalUnicast() { // ipv6 must be global unicast
if c.DNS.Serve6 == "" {
c.DNS.Serve6 = ip.String()
}
}
}
}
if err := validateOptionalIP("dns serve", c.DNS.Serve); err != nil {
return err
}
if err := validateOptionalIP("dns serve6", c.DNS.Serve6); err != nil {
return err
}
if err := validateOptionalIP("dns upstream", c.DNS.Upstream); err != nil {
return err
}
if err := validateRequiredIP("dns fallback", c.DNS.Fallback); err != nil {
return err
}
if err := validateOptionalIP("dns reverse", c.DNS.Reverse); err != nil {
return err
}
if !c.DNS.Disable && c.DNS.Serve == "" {
return fmt.Errorf("dns serve ip and serve interface not set")
}
if !c.Socks5.Disable {
if _, _, err := net.SplitHostPort(c.Socks5.Addr); err != nil {
return fmt.Errorf("invalid socks5 listen address %q: %w", c.Socks5.Addr, err)
}
}
if !c.Admin.Disable && c.Admin.Addr != "" {
host, port, err := net.SplitHostPort(c.Admin.Addr)
if err != nil || host == "" {
return fmt.Errorf("invalid admin listen address %q", c.Admin.Addr)
}
// The admin console can share the DNS HTTP proxy listener on port 80
// (admin.addr == dns.serve:80), but it cannot share the HTTPS or DNS
// listeners, which speak different protocols.
if !c.DNS.Disable && c.DNS.Serve != "" && host == c.DNS.Serve {
switch port {
case "443", "53":
return fmt.Errorf("admin listen address %q conflicts with the %s proxy listener on %s", c.Admin.Addr, port, c.DNS.Serve)
}
}
}
c.Router.Direct.Rules = append(c.Router.Direct.Rules,
remoteHost, "**.in-addr.arpa", "**.ip6.arpa")
return nil
}
func validateRemoteAddr(remoteType, addr string) (string, error) {
switch remoteType {
case "socks5":
host, _, err := net.SplitHostPort(addr)
if err != nil {
return "", fmt.Errorf("invalid remote socks5 address %q: %w", addr, err)
}
if host == "" {
return "", fmt.Errorf("invalid remote socks5 address %q", addr)
}
return host, nil
default:
host, _, err := splitRemoteAddr(addr, "443")
if err != nil {
return "", fmt.Errorf("invalid remote tls address %q: %w", addr, err)
}
return host, nil
}
}
func splitRemoteAddr(addr, defaultPort string) (string, string, error) {
if addr == "" {
return "", "", fmt.Errorf("empty address")
}
if host, port, err := net.SplitHostPort(addr); err == nil {
if host == "" || port == "" {
return "", "", fmt.Errorf("invalid address %q", addr)
}
return host, port, nil
}
if strings.HasPrefix(addr, "[") || strings.HasSuffix(addr, "]") {
return "", "", fmt.Errorf("invalid address %q", addr)
}
// A bare IPv6 literal is a valid host with the default port. Anything
// else that still contains a colon is a malformed host:port (e.g. a
// bare IPv6 with a port, which needs [addr]:port) or a garbage string.
if ip := net.ParseIP(addr); ip != nil {
return addr, defaultPort, nil
}
if strings.Contains(addr, ":") {
return "", "", fmt.Errorf("invalid address %q: IPv6 with a port must use [addr]:port", addr)
}
return addr, defaultPort, nil
}
func validateOptionalIP(name, value string) error {
if value == "" {
return nil
}
return validateRequiredIP(name, value)
}
func validateRequiredIP(name, value string) error {
if net.ParseIP(value) == nil {
return fmt.Errorf("invalid %s %q", name, value)
}
return nil
}