mirror of
https://github.com/wweir/sower.git
synced 2026-10-20 18:12:31 +00:00
- 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.
314 lines
10 KiB
Go
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})
|
|
}
|