Files
SimpleRemoter/server/go/web/server.go
T
yuanyuanxianganddeepseek-v4-pro 8e0f1283c6 Security: Add per-IP connection and login rate limiting
The TCP device port is exposed directly to the public internet, so a single
host must not be able to exhaust the global connection budget, flood new
connection attempts, or brute-force the device authorization passcode.

- server: cap concurrent connections and the accept rate per source IP
  (defaults 256 concurrent / 120 per minute, overridable via
  YAMA_PERIP_MAX_CONNS and YAMA_PERIP_CONN_RATE). Rejections happen at
  accept time, before any buffer or goroutine is allocated, and a rejected
  device simply retries on its reconnect loop.
- cmd: throttle TOKEN_AUTH per source IP (120/min) and answer over-limit
  attempts with a normal failed response so clients get a definitive reply.
- web: periodically sweep the login rate-limit maps so the username-keyed
  one (which is attacker-controlled) cannot grow without bound.

Per-IP defaults are generous enough that a NAT office whose devices share
one source IP is unaffected.

Co-Authored-By: deepseek-v4-pro
2026-10-13 16:25:34 +02:00

303 lines
11 KiB
Go

package web
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth"
)
// Server serves the web remote desktop UI: the embedded index.html, xterm.js
// static assets, the PWA manifest, and JSON APIs backed by the device hub.
// WebSocket signaling and screen streaming will be wired up in later phases.
type Server struct {
port int
log *logger.Logger
srv *http.Server
hub *hub.Hub
auth *wsauth.Authenticator
ws *wsHub
allowedOrigins []string // for WS Origin allowlist; empty = same-origin only
loginIPLimit *wsauth.RateLimiter
loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
sso SSODetector // nil = SSO disabled
sweepDone chan struct{} // closes to stop the login-limiter sweeper
sweepStopOnce sync.Once
}
// Config tunes the server's exposed-on-public-HTTPS hardening knobs.
// All fields are optional; zero values pick reasonable defaults.
type Config struct {
// AllowedOrigins is the comma-separated list of Origin header values
// the WebSocket upgrade will accept in addition to same-origin
// requests. Empty (default) → only same-origin upgrades are allowed,
// which is correct when the web UI and the WS endpoint are served
// from the same host.
AllowedOrigins []string
// LoginIPLimit / LoginUserLimit throttle the get_salt + login flow
// per source IP and per username respectively. Pass nil to disable
// either dimension (e.g. dev mode).
LoginIPLimit *wsauth.RateLimiter
LoginUserLimit *wsauth.RateLimiter
// TrustForwardedFor switches client-IP extraction from RemoteAddr
// (default) to the last entry of X-Forwarded-For. Set true only when
// running behind a reverse proxy that you control; on direct
// exposure the header is client-controlled and would let attackers
// evade per-IP rate limits.
TrustForwardedFor bool
// BasePath mounts the whole web UI (index, manifest, static assets,
// /health, /api/devices, /ws) under a URL prefix, so a reverse proxy
// can serve it from a subpath of an existing domain (e.g.
// https://example.com/remoter). Must be empty or a leading-slash,
// no-trailing-slash path; "" (default) serves from the domain root.
BasePath string
// SSO is an optional detector that maps an already-authenticated portal
// session (cookie/header) into a YAMA web session via /sso. nil disables
// SSO and the front-end falls back to the built-in login form.
SSO SSODetector
}
// New creates an HTTP server bound to the given port. port=0 disables the server.
// The hub provides read access to the online-device registry; the authenticator
// owns user accounts and session tokens.
func New(port int, log *logger.Logger, h *hub.Hub, auth *wsauth.Authenticator) *Server {
return &Server{port: port, log: log, hub: h, auth: auth}
}
// WithConfig applies hardening configuration. Returns the receiver for
// chainable setup. Safe to call before Start; ignored thereafter.
func (s *Server) WithConfig(cfg Config) *Server {
s.allowedOrigins = cfg.AllowedOrigins
s.loginIPLimit = cfg.LoginIPLimit
s.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor
s.basePath = cfg.BasePath
s.sso = cfg.SSO
return s
}
// Start launches the server in a goroutine and returns immediately.
// If port is 0, returns nil without starting anything.
func (s *Server) Start() error {
if s.port == 0 {
s.log.Info("HTTP server disabled (-http-port=0)")
return nil
}
s.ws = newWSHub(s.auth, s.hub, s.log).
withOriginAllowlist(s.allowedOrigins).
withLoginRateLimiters(s.loginIPLimit, s.loginUserLimit).
withTrustForwardedFor(s.trustForwardedFor)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/manifest.json", s.handleManifest)
mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices))
mux.HandleFunc("/sso", s.handleSSO)
mux.HandleFunc("/ws", s.ws.serve)
mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8"))
mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8"))
mux.HandleFunc("/static/xterm-fit.js", staticHandler(xtermFitJS, "application/javascript; charset=utf-8"))
mux.HandleFunc("/static/opus-decoder.min.js", staticHandler(opusDecoderJS, "application/javascript; charset=utf-8"))
s.srv = &http.Server{
Addr: ":" + strconv.Itoa(s.port),
Handler: s.mount(mux),
ReadHeaderTimeout: 10 * time.Second,
}
// Bind synchronously so port-in-use / permission errors propagate to the
// caller instead of being lost inside the goroutine after a misleading
// "started" log line.
ln, err := net.Listen("tcp", s.srv.Addr)
if err != nil {
return fmt.Errorf("listen on :%d: %w", s.port, err)
}
s.log.Info("HTTP server started on :%d", s.port)
go func() {
if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("HTTP server stopped: %v", err)
}
}()
// Prune aged-out login rate-limit entries so the key maps (especially the
// username-keyed one, which is attacker-controlled) stay bounded.
s.sweepDone = make(chan struct{})
go s.sweepLoginLimiters(s.sweepDone)
return nil
}
// sweepLoginLimiters periodically sweeps both web login rate limiters. Runs
// until `stop` is closed (from Stop).
func (s *Server) sweepLoginLimiters(stop <-chan struct{}) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
if s.loginIPLimit != nil {
s.loginIPLimit.Sweep()
}
if s.loginUserLimit != nil {
s.loginUserLimit.Sweep()
}
}
}
}
// Stop gracefully shuts the server down.
func (s *Server) Stop() {
if s.sweepDone != nil {
s.sweepStopOnce.Do(func() { close(s.sweepDone) })
}
if s.ws != nil {
s.ws.stop()
}
if s.srv == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.srv.Shutdown(ctx)
}
// basePathPlaceholder is the token in index.html substituted with the
// configured base path at serve time. Empty base path → "" → root URLs.
const basePathPlaceholder = "__BASE_PATH__"
// mount wraps the root-path mux with the configured base path. When basePath
// is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and
// matching paths are stripped before reaching the mux (方案B, subpath serving).
func (s *Server) mount(h http.Handler) http.Handler {
if s.basePath == "" {
return h
}
base := s.basePath // normalized by cmd: leading "/", no trailing "/"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == base {
http.Redirect(w, r, base+"/", http.StatusMovedPermanently)
return
}
if !strings.HasPrefix(r.URL.Path, base+"/") {
http.NotFound(w, r)
return
}
http.StripPrefix(base, h).ServeHTTP(w, r)
})
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" && r.URL.Path != "/index.html" {
http.NotFound(w, r)
return
}
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
// handleDevices returns a JSON snapshot of currently-online devices. Empty
// array (not null) when no clients are connected — matches what the front-end
// will eventually expect. Auth-gated via requireBearer.
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) {
devices := s.hub.ListDevices()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(devices); err != nil {
s.log.Error("encode /api/devices: %v", err)
}
}
// requireBearer wraps a handler with `Authorization: Bearer <token>` auth
// against the same session-token store the WebSocket uses. Returns 401 on
// missing / invalid / expired tokens. Used to gate REST endpoints that
// previously fell through with no auth (notably /api/devices, which
// otherwise leaks the full online-device list to anyone on the internet).
func (s *Server) requireBearer(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
const prefix = "Bearer "
hdr := r.Header.Get("Authorization")
if !strings.HasPrefix(hdr, prefix) {
s.unauthorized(w)
return
}
token := strings.TrimSpace(hdr[len(prefix):])
if token == "" {
s.unauthorized(w)
return
}
if _, err := s.auth.ValidateToken(token); err != nil {
s.unauthorized(w)
return
}
next(w, r)
}
}
func (s *Server) unauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Bearer realm="yama"`)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}
// PWA manifest. Referenced by <link rel="manifest"> in index.html.
// start_url is rooted at the configured base path so an installed PWA
// launches into the right location; "/" when served from the domain root.
func (s *Server) manifestJSON() []byte {
start := "/"
if s.basePath != "" {
start = s.basePath + "/"
}
return []byte(fmt.Sprintf(`{
"name": "SimpleRemoter",
"short_name": "Remoter",
"start_url": "%s",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a2e",
"theme_color": "#1a1a2e"
}`, start))
}
func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(s.manifestJSON())
}
// staticHandler returns an http.HandlerFunc that serves a fixed byte slice
// with the given content-type. Used for embedded third-party assets (xterm.js etc.)
// that change infrequently — 1-day browser cache is fine.
func staticHandler(body []byte, contentType string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = w.Write(body)
}
}