Files
wweir b16325b413 fix: correct three regressions from the hardening pass
- Config override validation now accepts bracketed IPv6 remote addresses
  ([2001:db8::1]:8443): the host part is checked as an IP literal before
  falling back to hostname rules, so a valid IPv6 remote no longer fails
  validation.
- Directory fake sites serve local relays even when serve_ip is a
  concrete non-loopback address: the local check now recognizes any
  address bound to a local interface, not just loopback, so fallback
  traffic is not wrongly 307-redirected to https.
- The config view's restart handler ignores failures after the component
  is destroyed, so a late 401 from the in-flight restart request can no
  longer trigger a global logout from a dead page.
- The login throttle bounds its failure map (purges expired entries when
  full) so rotating source IPs cannot grow it without bound.
2026-08-12 17:43:24 +08:00

303 lines
11 KiB
Go

package admin
import (
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"github.com/sower-proxy/sower/pkg/upstreamtls"
)
// Apply modes for config fields.
const (
// ApplyImmediate takes effect at runtime without a restart.
ApplyImmediate = "immediate"
// ApplyRestart requires a process restart; shown read-only in v1.
ApplyRestart = "restart"
// ApplyReadonly can never be changed through the console.
ApplyReadonly = "readonly"
)
// Config sources for display.
const (
// SourceConfig marks values from the config file/flags (or defaults).
// aconfig merges defaults before the file, so the two cannot be told
// apart after load.
SourceConfig = "config"
// SourceOverride marks values overridden through the admin console and
// persisted in the admin state file.
SourceOverride = "override"
)
// ConfigField is one rendered config entry. Secret fields never carry a
// Value; Configured reports whether one is set. Type/Options let the console
// render a constrained choice (e.g. an enum select) instead of free text.
type ConfigField struct {
Key string `json:"key"`
Value string `json:"value,omitempty"`
Editable bool `json:"editable"`
ApplyMode string `json:"applyMode"`
Source string `json:"source"`
Constraint string `json:"constraint,omitempty"`
Secret bool `json:"secret,omitempty"`
Configured bool `json:"configured,omitempty"`
Type string `json:"type,omitempty"`
Options []string `json:"options,omitempty"`
}
// ConfigSection groups related fields for display.
type ConfigSection struct {
Name string `json:"name"`
Fields []ConfigField `json:"fields"`
}
// ConfigView is the GET /api/config response. Revision drives optimistic
// concurrency for PATCHes.
type ConfigView struct {
Revision uint64 `json:"revision"`
Sections []ConfigSection `json:"sections"`
}
// ConfigChanges is the whitelisted PATCH payload. A nil pointer leaves the
// override unchanged; a non-nil empty string clears it, reverting the field
// to the file/flag configuration. List fields (rule sources) follow the
// same rule with slices.
type ConfigChanges struct {
LogLevel *string `json:"log_level"`
DNSUpstream *string `json:"dns_upstream"`
DNSFallback *string `json:"dns_fallback"`
DNSReverse *string `json:"dns_reverse"`
RemoteType *string `json:"remote_type"`
RemoteAddr *string `json:"remote_addr"`
RemoteTLSServerName *string `json:"remote_tls_server_name"`
RemoteTLSClientHello *string `json:"remote_tls_client_hello"`
RemoteTLSInsecureSkipVerify *string `json:"remote_tls_insecure_skip_verify"`
DNSServe *string `json:"dns_serve"`
DNSServe6 *string `json:"dns_serve6"`
Socks5Addr *string `json:"socks5_addr"`
AdminSessionFile *string `json:"admin_session_file"`
AdminDisableSessionPersistence *string `json:"admin_disable_session_persistence"`
AdminCookieSecure *string `json:"admin_cookie_secure"`
AdminStateFile *string `json:"admin_state_file"`
RouterBlockFile *string `json:"router_block_file"`
RouterBlockFilePrefix *string `json:"router_block_file_prefix"`
RouterBlockFileSkipRules *[]string `json:"router_block_file_skip_rules"`
RouterBlockRules *[]string `json:"router_block_rules"`
RouterDirectFile *string `json:"router_direct_file"`
RouterDirectFilePrefix *string `json:"router_direct_file_prefix"`
RouterDirectFileSkipRules *[]string `json:"router_direct_file_skip_rules"`
RouterDirectRules *[]string `json:"router_direct_rules"`
RouterProxyFile *string `json:"router_proxy_file"`
RouterProxyFilePrefix *string `json:"router_proxy_file_prefix"`
RouterProxyFileSkipRules *[]string `json:"router_proxy_file_skip_rules"`
RouterProxyRules *[]string `json:"router_proxy_rules"`
RouterCountryMMDB *string `json:"router_country_mmdb"`
RouterCountryFile *string `json:"router_country_file"`
RouterCountryRules *[]string `json:"router_country_rules"`
}
// Validate checks all supplied values. Clearing an override (empty string
// or empty list) is always valid.
func (c ConfigChanges) Validate() error {
if c.LogLevel != nil && *c.LogLevel != "" {
var lv slog.Level
if err := lv.UnmarshalText([]byte(strings.ToUpper(*c.LogLevel))); err != nil {
return fmt.Errorf("invalid log_level %q", *c.LogLevel)
}
}
if c.DNSUpstream != nil && *c.DNSUpstream != "" && net.ParseIP(*c.DNSUpstream) == nil {
return fmt.Errorf("invalid dns_upstream %q", *c.DNSUpstream)
}
if c.DNSFallback != nil && *c.DNSFallback != "" && net.ParseIP(*c.DNSFallback) == nil {
return fmt.Errorf("invalid dns_fallback %q", *c.DNSFallback)
}
if c.DNSReverse != nil && *c.DNSReverse != "" && net.ParseIP(*c.DNSReverse) == nil {
return fmt.Errorf("invalid dns_reverse %q", *c.DNSReverse)
}
if c.DNSServe != nil && *c.DNSServe != "" && net.ParseIP(*c.DNSServe) == nil {
return fmt.Errorf("invalid dns_serve %q", *c.DNSServe)
}
if c.DNSServe6 != nil && *c.DNSServe6 != "" && net.ParseIP(*c.DNSServe6) == nil {
return fmt.Errorf("invalid dns_serve6 %q", *c.DNSServe6)
}
if c.RemoteType != nil && *c.RemoteType != "" {
switch *c.RemoteType {
case "sower", "socks5":
default:
return fmt.Errorf("invalid remote_type %q", *c.RemoteType)
}
}
if c.RemoteAddr != nil && *c.RemoteAddr != "" && !validRemoteAddr(*c.RemoteAddr) {
// An invalid remote address would persist and crash-loop the next
// startup (the dialer is built from it before listeners come up).
return fmt.Errorf("invalid remote_addr %q", *c.RemoteAddr)
}
if c.RemoteTLSServerName != nil && *c.RemoteTLSServerName != "" && !validHostname(*c.RemoteTLSServerName) {
return fmt.Errorf("invalid remote_tls_server_name %q", *c.RemoteTLSServerName)
}
if c.RemoteTLSClientHello != nil && *c.RemoteTLSClientHello != "" {
if err := upstreamtls.ValidateClientHello(*c.RemoteTLSClientHello); err != nil {
return fmt.Errorf("invalid remote_tls_client_hello %q", *c.RemoteTLSClientHello)
}
}
if c.RemoteTLSInsecureSkipVerify != nil && *c.RemoteTLSInsecureSkipVerify != "" {
if _, err := strconv.ParseBool(*c.RemoteTLSInsecureSkipVerify); err != nil {
return fmt.Errorf("invalid remote_tls_insecure_skip_verify %q", *c.RemoteTLSInsecureSkipVerify)
}
}
if c.Socks5Addr != nil && *c.Socks5Addr != "" {
if _, _, err := net.SplitHostPort(*c.Socks5Addr); err != nil {
return fmt.Errorf("invalid socks5_addr %q: %w", *c.Socks5Addr, err)
}
}
if c.AdminDisableSessionPersistence != nil && *c.AdminDisableSessionPersistence != "" {
if _, err := strconv.ParseBool(*c.AdminDisableSessionPersistence); err != nil {
return fmt.Errorf("invalid admin_disable_session_persistence %q", *c.AdminDisableSessionPersistence)
}
}
if c.AdminCookieSecure != nil && *c.AdminCookieSecure != "" {
if _, err := strconv.ParseBool(*c.AdminCookieSecure); err != nil {
return fmt.Errorf("invalid admin_cookie_secure %q", *c.AdminCookieSecure)
}
}
// Rule and skip-rule lists: entries must be single-line and bounded so a
// malformed override cannot inject newlines into the rule files.
validateRuleList := func(name string, rules *[]string) error {
if rules == nil {
return nil
}
for _, r := range *rules {
if r == "" || len(r) > maxRuleLength || strings.ContainsAny(r, "\r\n") {
return fmt.Errorf("invalid %s entry %q", name, r)
}
}
return nil
}
for name, rules := range map[string]*[]string{
"router_block_file_skip_rules": c.RouterBlockFileSkipRules,
"router_block_rules": c.RouterBlockRules,
"router_direct_file_skip_rules": c.RouterDirectFileSkipRules,
"router_direct_rules": c.RouterDirectRules,
"router_proxy_file_skip_rules": c.RouterProxyFileSkipRules,
"router_proxy_rules": c.RouterProxyRules,
"router_country_rules": c.RouterCountryRules,
} {
if err := validateRuleList(name, rules); err != nil {
return err
}
}
return nil
}
// validRemoteAddr mirrors the dialer's upstream address acceptance: host:port
// with both parts present, a bare hostname, or a bare IPv6 literal. Anything
// the dialer would reject at startup (and thus crash-loop the process after a
// persisted override) is rejected here.
func validRemoteAddr(addr string) bool {
if host, port, err := net.SplitHostPort(addr); err == nil {
if host == "" || port == "" {
return false
}
h := strings.Trim(host, "[]")
return net.ParseIP(h) != nil || validHostname(h)
}
if addr == "" || strings.HasPrefix(addr, "[") || strings.HasSuffix(addr, "]") {
return false
}
if strings.Count(addr, ":") == 1 {
return false
}
if net.ParseIP(addr) != nil {
return true
}
return validHostname(addr)
}
// validHostname accepts a domain or bare host label: letters, digits, '-',
// '_' and '.', with bounded label and total lengths. Underscores are allowed
// because internal hosts commonly carry them.
func validHostname(s string) bool {
if s == "" || len(s) > 253 {
return false
}
for _, label := range strings.Split(s, ".") {
if label == "" || len(label) > 63 {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
return false
}
}
}
return true
}
// ConfigManager is the config display/edit surface the admin server needs.
// The concrete adapter lives in cmd/sower; tests inject a fake.
type ConfigManager interface {
ConfigView() ConfigView
// ApplyConfigChanges persists the overrides and applies them to the
// runtime, guarded by the caller's revision. It returns the refreshed
// view. ErrRevisionMismatch reports a stale revision.
ApplyConfigChanges(changes ConfigChanges, revision uint64) (ConfigView, error)
}
// handleConfigGet serves the sanitized effective configuration with
// per-field metadata. Secrets are reported as configured flags, never values.
func (s *Server) handleConfigGet(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.opts.Config.ConfigView())
}
// handleConfigPatch applies whitelisted config changes with optimistic
// concurrency: the caller's revision must match the state revision.
func (s *Server) handleConfigPatch(w http.ResponseWriter, r *http.Request) {
var req struct {
Revision uint64 `json:"revision"`
Changes ConfigChanges `json:"changes"`
}
if !decodeJSON(w, r, &req) {
return
}
if err := req.Changes.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
view, err := s.opts.Config.ApplyConfigChanges(req.Changes, req.Revision)
if errors.Is(err, ErrRevisionMismatch) {
writeError(w, http.StatusConflict, "config changed since load; refresh and retry")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, view)
}
// handleRestart triggers a process restart. The callback is fire-and-forget
// from the handler's perspective: it must return before the process exits so
// the 202 response is delivered first.
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
if s.opts.Restart == nil {
writeError(w, http.StatusNotImplemented, "restart unavailable")
return
}
if err := s.opts.Restart(); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "restarting"})
}