mirror of
https://github.com/wweir/sower.git
synced 2026-10-19 16:14:33 +00:00
- 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.
269 lines
9.6 KiB
Go
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
|
|
}
|