Files
sower/internal/admin/server.go
T
wweir 4c19815319 fix(sower,admin): keep CONNECT tunnel head, bound SOCKS5 handshake, harden admin config and login
- CONNECT through the HTTP proxy no longer drops the tunnel's first
  bytes: bytes the bufio reader already pulled past the request head
  (pipelined tunnel data) are replayed to the target before relaying.
  Both the dedicated HTTP proxy and the SOCKS5-port HTTP fallback paths.
- The SOCKS5 entry keeps its read deadline across the whole handshake
  (auth + request) instead of clearing it after the first byte, bounding
  slowloris clients that stall mid-negotiation.
- Config override validation now covers dns_reverse, remote_addr,
  remote_tls_server_name, and every rule list, so a malformed override
  can no longer be persisted and crash-loop the next startup.
- admin.state_file is read-only in the console: the state store loads at
  startup, so the runtime override could never take effect.
- Login is rate-limited per remote IP (5 failures lock 15 minutes) and
  the login endpoint carries the Origin guard; the session map refuses
  new logins instead of growing past its bound.
- The admin HTTP server sets IdleTimeout and MaxHeaderBytes.
2026-08-12 17:15:54 +08:00

314 lines
10 KiB
Go

package admin
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/sower-proxy/sower/web"
)
const (
sessionCookieName = "sower_admin"
sessionTTL = 24 * time.Hour
// sessionCookieMaxAge is how long the browser keeps the session cookie.
// It is deliberately much longer than sessionTTL: the cookie is only a
// bearer-token carrier, and the server-side session map (with sliding
// expiry) is the real gate. A short MaxAge would log the user out after
// 24h even while the server session stays refreshed.
sessionCookieMaxAge = 30 * 24 * time.Hour
maxSessionPurge = 1024
maxBodyBytes = 64 << 10
maxRulesPerBatch = 100
maxRuleLength = 253
defaultPageSize = 200
maxPageSize = 1000
// trafficCacheTTL equals the SSE traffic tick period, so the shared
// default-view cache still lets one client per window advance the history
// ring at the same cadence as a single uncached stream.
trafficCacheTTL = 5 * time.Second
)
// Category identifies a routing rule category managed through the admin API.
type Category string
const (
CategoryBlock Category = "block"
CategoryDirect Category = "direct"
CategoryProxy Category = "proxy"
)
func (c Category) valid() bool {
switch c {
case CategoryBlock, CategoryDirect, CategoryProxy:
return true
default:
return false
}
}
// RuleManager is the rule operations surface the admin server needs. The
// concrete adapter lives in cmd/sower; tests inject a fake.
type RuleManager interface {
// RuleSearch returns a page of retained rules for the category, filtered
// by q and ordered by sortBy in direction dir (see RuleSort constants).
RuleSearch(category Category, q string, offset, limit int, sortBy RuleSort, dir SortDir) ([]RuleEntry, uint64, error)
RuleAdd(category Category, rules ...string) error
RuleRemove(category Category, rule string) (bool, error)
// RuleRemoveMany removes a batch atomically from the persisted state and
// returns only the effective runtime removals.
RuleRemoveMany(category Category, rules ...string) ([]string, error)
RuleCount(category Category) uint64
// RuleChanges returns the persisted rule deltas relative to the boot
// baseline.
RuleChanges() RuleChangeSet
// RuleReset clears the deltas of one category, or of all categories when
// category is empty, rebuilding the runtime rule sets to the baseline.
RuleReset(category Category) error
TestDomain(domain string) (DomainTest, error)
}
// Options configures the admin server.
type Options struct {
Password string
TemporaryPassword bool
Version string
Date string
Rules RuleManager
Stats *Stats
SessionFile string
CookieSecure bool
// Config enables the config display/edit endpoints when non-nil.
Config ConfigManager
// Restart triggers a process restart when non-nil; the endpoint returns
// 501 when nil. The callback must return before the process exits so the
// HTTP response can be delivered.
Restart func() error
// Hostnames resolves client IPs to hostnames for the traffic console;
// reverse lookups are skipped when nil.
Hostnames HostnameResolver
}
// Server serves the admin API and the embedded frontend on one listener.
type Server struct {
opts Options
sessions map[string]time.Time
sessionFile string
mu sync.Mutex
trafficMu sync.Mutex
traffic cachedTraffic
hostnames *hostnameCache
throttle *loginThrottle
http *http.Server
}
type cachedTraffic struct {
at time.Time
snapshot TrafficSnapshot
}
func NewServer(opts Options) *Server {
s := &Server{
opts: opts,
sessions: make(map[string]time.Time),
sessionFile: opts.SessionFile,
throttle: newLoginThrottle(),
}
if opts.Hostnames != nil {
s.hostnames = newHostnameCache(opts.Hostnames)
}
s.loadSessions()
mux := http.NewServeMux()
// The login endpoint carries the same Origin guard as the other
// mutating endpoints so a cross-site page cannot drive password
// guessing through the victim's browser.
mux.HandleFunc("POST /api/session", s.mutateGuard(s.handleLogin))
mux.HandleFunc("GET /api/session", s.mutateGuard(s.auth(s.handleSession)))
mux.HandleFunc("DELETE /api/session", s.handleLogout)
mux.HandleFunc("GET /api/login-info", s.handleLoginInfo)
mux.HandleFunc("GET /api/status", s.mutateGuard(s.auth(s.handleStatus)))
mux.HandleFunc("GET /api/rules", s.mutateGuard(s.auth(s.handleRulesList)))
mux.HandleFunc("POST /api/rules", s.mutateGuard(s.auth(s.handleRulesAdd)))
mux.HandleFunc("DELETE /api/rules", s.mutateGuard(s.auth(s.handleRulesRemove)))
mux.HandleFunc("GET /api/rules/changes", s.mutateGuard(s.auth(s.handleRulesChanges)))
mux.HandleFunc("POST /api/rules/reset", s.mutateGuard(s.auth(s.handleRulesReset)))
mux.HandleFunc("GET /api/rules/test", s.mutateGuard(s.auth(s.handleRulesTest)))
mux.HandleFunc("GET /api/rules/miss", s.mutateGuard(s.auth(s.handleRuleMiss)))
mux.HandleFunc("GET /api/traffic", s.mutateGuard(s.auth(s.handleTraffic)))
mux.HandleFunc("GET /api/totals", s.mutateGuard(s.auth(s.handleTotals)))
mux.HandleFunc("GET /api/history", s.mutateGuard(s.auth(s.handleHistory)))
mux.HandleFunc("GET /api/stream", s.mutateGuard(s.auth(s.handleStream)))
if s.opts.Config != nil {
mux.HandleFunc("GET /api/config", s.mutateGuard(s.auth(s.handleConfigGet)))
mux.HandleFunc("PATCH /api/config", s.mutateGuard(s.auth(s.handleConfigPatch)))
}
if s.opts.Restart != nil {
mux.HandleFunc("POST /api/restart", s.mutateGuard(s.auth(s.handleRestart)))
}
if s.opts.Stats != nil {
mux.HandleFunc("GET /metrics", s.handleMetrics)
}
mux.HandleFunc("/", s.handleStatic)
s.http = &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
// IdleTimeout bounds keep-alive connections between requests; the
// SSE stream is an active response and is unaffected. MaxHeaderBytes
// bounds the request head so a hostile client cannot grow memory
// with oversized headers.
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 64 << 10,
}
return s
}
// Serve accepts connections on ln until Shutdown or a fatal serve error.
func (s *Server) Serve(ln net.Listener) error {
err := s.http.Serve(ln)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// Shutdown gracefully stops the server.
func (s *Server) Shutdown(ctx context.Context) error {
return s.http.Shutdown(ctx)
}
// handleLoginInfo reports whether the admin console runs with a
// startup-generated temporary password, so the login page can tell the user
// where to find it. It is intentionally public: the login page needs it
// before authenticating, and it leaks no credential material.
func (s *Server) handleLoginInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"temporaryPassword": s.opts.TemporaryPassword,
})
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.statusPayload())
}
// statusPayload is the /api/status body, reused by the SSE stream.
func (s *Server) statusPayload() map[string]any {
return map[string]any{
"version": s.opts.Version,
"date": s.opts.Date,
"uptime": int64(time.Since(statStart(s.opts.Stats)).Seconds()),
"rules": map[string]uint64{
string(CategoryBlock): s.opts.Rules.RuleCount(CategoryBlock),
string(CategoryDirect): s.opts.Rules.RuleCount(CategoryDirect),
string(CategoryProxy): s.opts.Rules.RuleCount(CategoryProxy),
},
}
}
// handleMetrics serves the Prometheus exposition format. It is intentionally
// unauthenticated: scrapers cannot carry the session cookie, and the exposed
// metrics are aggregate counters without per-domain or credential data.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if s.opts.Stats == nil {
writeError(w, http.StatusInternalServerError, "stats unavailable")
return
}
s.opts.Stats.Metrics().ServeHTTP(w, r)
}
// --- static frontend ---
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
// Unknown API paths must not fall through to the SPA shell.
if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, http.StatusNotFound, "unknown API endpoint")
return
}
fsys, err := web.Dist()
if err != nil {
slog.Error("load embedded web dist", "error", err)
writeError(w, http.StatusInternalServerError, "embedded web assets unavailable")
return
}
// The embedded FS is read-only and r.URL.Path is already normalized by
// net/http, so traversal cannot escape the dist directory.
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
if !fs.ValidPath(p) {
writeError(w, http.StatusNotFound, "not found")
return
}
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:")
if _, err := fs.Stat(fsys, p); err != nil {
p = "index.html" // SPA fallback for client-side routes
}
http.ServeFileFS(w, r, fsys, p)
}
// --- helpers ---
func statStart(stats *Stats) time.Time {
if stats == nil {
return time.Now()
}
return stats.start
}
type rulesResponse struct {
Category Category `json:"category"`
Rules []RuleEntry `json:"rules"`
Total uint64 `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
writeError(w, http.StatusBadRequest, "unexpected trailing JSON data")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
slog.Debug("write JSON response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}