mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 08:16:35 +00:00
- 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.
303 lines
11 KiB
Go
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"})
|
|
}
|