package web import ( "context" "encoding/json" "errors" "fmt" "net" "net/http" "strconv" "strings" "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) } // 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 } // 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 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("/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")) s.srv = &http.Server{ Addr: ":" + strconv.Itoa(s.port), Handler: 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) } }() return nil } // Stop gracefully shuts the server down. func (s *Server) Stop() { 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) } func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" && r.URL.Path != "/index.html" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(IndexHTML) } 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 ` 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 in index.html. // Static JSON, no template needed. const manifestJSON = `{ "name": "SimpleRemoter", "short_name": "Remoter", "start_url": "/", "display": "standalone", "orientation": "any", "background_color": "#1a1a2e", "theme_color": "#1a1a2e" }` 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([]byte(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) } }