Files
VoCat/internal/server/server.go
T

631 lines
20 KiB
Go

package server
import (
"bytes"
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"mime"
"net/http"
"path"
"strings"
"sync"
"time"
"vocat/internal/auth"
"vocat/internal/exportproxy"
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/store"
"vocat/internal/update"
"vocat/internal/vowifi"
)
const (
sessionCookieName = "vocat_session"
csrfCookieName = "vocat_csrf"
csrfHeaderName = "X-CSRF-Token"
)
type Options struct {
Store *store.Store
Auth *auth.Service
Devices DeviceController
VoWiFi VoWiFiController
Logs *loghub.Hub
Assets fs.FS
Logger *slog.Logger
SecureCookies bool
MaxRequestBodyBytes int64
Extensions *extensions.Manager
ExportProxy *exportproxy.Manager
DeveloperEnabled bool
UpdateRepository string
UpdateToken string
HTTPS *httpsmode.Manager
}
// Server is the single HTTP handler for the JSON API and embedded SPA.
type Server struct {
store *store.Store
auth *auth.Service
devices DeviceController
vowifi VoWiFiController
logs *loghub.Hub
assets fs.FS
indexHTML []byte
fileServer http.Handler
logger *slog.Logger
secureCookies bool
maxRequestBodyBytes int64
startedAt time.Time
handler http.Handler
websheets *websheetManager
accessMu sync.RWMutex
access parsedAccessConfig
loginLimiter *loginRateLimiter
extensions *extensions.Manager
exportProxy *exportproxy.Manager
developerEnabled bool
updateRepository string
updateToken string
updateCheck func(context.Context, string, string, string) (update.CheckResult, error)
updateApply func(context.Context, *slog.Logger, update.Options, bool) (update.CheckResult, error)
updateRestart func(*slog.Logger) error
updateMu sync.Mutex
updateApplying bool
https *httpsmode.Manager
netTraffic *liveNetTracker
publicIPMu sync.RWMutex
publicIPs map[string]cachedPublicIP
}
func New(options Options) (*Server, error) {
if options.Store == nil {
return nil, errors.New("server: store is required")
}
if options.Auth == nil {
return nil, errors.New("server: auth service is required")
}
if options.Assets == nil {
return nil, errors.New("server: SPA assets are required")
}
indexHTML, err := fs.ReadFile(options.Assets, "index.html")
if err != nil {
return nil, fmt.Errorf("server: read embedded index.html: %w", err)
}
if options.Logger == nil {
options.Logger = slog.New(slog.NewTextHandler(io.Discard, nil))
}
if options.MaxRequestBodyBytes <= 0 {
options.MaxRequestBodyBytes = 1 << 20
}
if strings.TrimSpace(options.UpdateRepository) == "" {
options.UpdateRepository = update.DefaultRepository
}
server := &Server{
store: options.Store,
auth: options.Auth,
devices: options.Devices,
vowifi: options.VoWiFi,
logs: options.Logs,
assets: options.Assets,
indexHTML: indexHTML,
fileServer: http.FileServer(http.FS(options.Assets)),
logger: options.Logger,
secureCookies: options.SecureCookies,
maxRequestBodyBytes: options.MaxRequestBodyBytes,
startedAt: time.Now().UTC(),
websheets: newWebsheetManager(),
loginLimiter: newLoginRateLimiter(),
extensions: options.Extensions,
exportProxy: options.ExportProxy,
developerEnabled: options.DeveloperEnabled,
updateRepository: strings.TrimSpace(options.UpdateRepository),
updateToken: strings.TrimSpace(options.UpdateToken),
https: options.HTTPS,
netTraffic: newLiveNetTracker(),
publicIPs: make(map[string]cachedPublicIP),
updateCheck: update.CheckLatest,
updateApply: update.ApplyLatest,
updateRestart: update.RestartService,
}
server.loadAccessConfig(context.Background())
server.loadUILanguage(context.Background())
mux := http.NewServeMux()
mux.HandleFunc("/api/health", server.handleHealth)
mux.HandleFunc("/api/auth/login", server.handleLogin)
mux.HandleFunc("/api/auth/session", server.handleSession)
mux.HandleFunc("/api/auth/logout", server.handleLogout)
mux.HandleFunc("/api", server.handleAPI)
mux.HandleFunc("/api/", server.handleAPI)
mux.HandleFunc("/websheets/", server.handleWebsheet)
mux.HandleFunc("/plugin-assets/", server.handlePluginAsset)
mux.HandleFunc("/", server.handleSPA)
server.handler = server.recoverPanics(
server.securityHeaders(server.accessControl(server.logRequests(mux))),
)
return server, nil
}
// VoWiFiController is the asynchronous runtime boundary used by the HTTP
// layer. State transitions continue after the request completes and are
// surfaced by the normal device status endpoints.
type VoWiFiController interface {
State(string) (vowifi.State, error)
RequestEnabled(string, bool) (vowifi.State, error)
RequestReconnect(string) (vowifi.State, error)
}
type VoWiFiCallController interface {
Calls(string) ([]vowifi.Call, error)
DialCall(context.Context, string, string) (vowifi.Call, error)
AnswerCall(context.Context, string, string) (vowifi.Call, error)
HangupCall(context.Context, string, string) error
}
type VoWiFiCallMediaController interface {
CallMedia(context.Context, string, string) (vowifi.CallMedia, error)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handler.ServeHTTP(w, r)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if !requireMethod(w, r, http.MethodGet) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.store.Ready(ctx); err != nil {
s.logger.Error("health check failed", "error", err)
writeError(w, http.StatusServiceUnavailable, "unavailable", "service is not ready")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"status": "ok",
"database": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
},
})
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !requireMethod(w, r, http.MethodPost) {
return
}
var request struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := s.decodeJSON(w, r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
limiterKey := s.loginKey(r, request.Username)
if retryAfter, locked := s.loginLimiter.checkLocked(limiterKey); locked {
s.auditAuth(r, request.Username, "locked")
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())+1))
writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later")
return
}
credentials, err := s.auth.Login(r.Context(), request.Username, request.Password)
if errors.Is(err, auth.ErrInvalidCredentials) {
lockout, newlyLocked := s.loginLimiter.recordFailure(limiterKey)
s.auditAuth(r, request.Username, "failure")
if newlyLocked {
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(lockout.Seconds())))
writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later")
return
}
writeError(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
return
}
if err != nil {
s.logger.Error("login failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
return
}
s.loginLimiter.recordSuccess(limiterKey)
s.auditAuth(r, credentials.Principal.Username, "success")
s.setAuthCookies(w, credentials.SessionToken, credentials.CSRFToken, credentials.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"user": credentials.Principal,
"csrf_token": credentials.CSRFToken,
"expires_at": credentials.ExpiresAt.Format(time.RFC3339),
"authenticated": true,
"status": "ok",
},
})
}
// loginKey builds the rate-limit key from the client address and username so
// brute-force attempts against one account from one source are throttled.
func (s *Server) loginKey(r *http.Request, username string) string {
address := s.currentAccessConfig().clientIP(r)
return address.String() + "|" + strings.ToLower(strings.TrimSpace(username))
}
func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !requireMethod(w, r, http.MethodGet) {
return
}
sessionToken, ok := s.sessionToken(w, r)
if !ok {
return
}
existingCSRF := ""
if cookie, cookieErr := r.Cookie(csrfCookieName); cookieErr == nil {
existingCSRF = cookie.Value
}
session, csrfToken, err := s.auth.CSRFToken(r.Context(), sessionToken, existingCSRF)
if errors.Is(err, auth.ErrUnauthorized) {
s.authenticationRequired(w, r)
return
}
if err != nil {
s.logger.Error("load session failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
return
}
s.setCSRFCookie(w, csrfToken, session.ExpiresAt)
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"user": session.Principal,
"csrf_token": csrfToken,
"expires_at": session.ExpiresAt.Format(time.RFC3339),
"authenticated": true,
},
})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if !requireMethod(w, r, http.MethodPost) {
return
}
sessionToken, ok := s.sessionToken(w, r)
if !ok {
return
}
csrfToken, ok := s.validateDoubleSubmitCSRF(w, r)
if !ok {
return
}
if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil {
switch {
case errors.Is(err, auth.ErrUnauthorized):
s.authenticationRequired(w, r)
case errors.Is(err, auth.ErrInvalidCSRF):
writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
default:
s.logger.Error("logout validation failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
return
}
if err := s.auth.Logout(r.Context(), sessionToken); err != nil {
s.logger.Error("logout failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
return
}
s.clearAuthCookies(w)
writeJSON(w, http.StatusOK, map[string]any{
"data": map[string]bool{"logged_out": true},
})
}
func (s *Server) handleAPINotFound(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "API endpoint not found")
}
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
// The UI language preference is not sensitive; exposing the read side lets
// the login page render in the persisted language before authentication.
if r.Method == http.MethodGet &&
strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") == "settings/preferences" {
s.writeUIPreferences(w, r)
return
}
if !s.requireAuthenticated(w, r) {
return
}
if r.Method != http.MethodGet &&
r.Method != http.MethodHead &&
r.Method != http.MethodOptions {
sessionToken, ok := s.sessionToken(w, r)
if !ok {
return
}
csrfToken, ok := s.validateDoubleSubmitCSRF(w, r)
if !ok {
return
}
if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil {
switch {
case errors.Is(err, auth.ErrUnauthorized):
s.authenticationRequired(w, r)
case errors.Is(err, auth.ErrInvalidCSRF):
writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
default:
s.logger.Error("API CSRF validation failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
return
}
}
if s.routeDeviceAPI(w, r) {
return
}
if s.routeGeneralAPI(w, r) {
return
}
s.handleAPINotFound(w, r)
}
func (s *Server) handleSPA(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name != "." && fs.ValidPath(name) {
if info, err := fs.Stat(s.assets, name); err == nil && info.Mode().IsRegular() {
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
s.fileServer.ServeHTTP(w, r)
return
}
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Content-Type", mime.TypeByExtension(".html"))
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(s.indexHTML))
}
func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
contentType := r.Header.Get("Content-Type")
if contentType != "" {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil ||
(mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json")) {
return errors.New("Content-Type must be application/json")
}
}
r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
return fmt.Errorf("request body exceeds %d bytes", s.maxRequestBodyBytes)
}
return errors.New("request body must contain one valid JSON object")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return errors.New("request body must contain one valid JSON object")
}
return nil
}
func (s *Server) sessionToken(w http.ResponseWriter, r *http.Request) (string, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
s.authenticationRequired(w, r)
return "", false
}
return cookie.Value, true
}
func (s *Server) requireAuthenticated(w http.ResponseWriter, r *http.Request) bool {
sessionToken, ok := s.sessionToken(w, r)
if !ok {
return false
}
if _, err := s.auth.Authenticate(r.Context(), sessionToken); err != nil {
if errors.Is(err, auth.ErrUnauthorized) {
s.authenticationRequired(w, r)
} else {
s.logger.Error("request authentication failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
return false
}
return true
}
// authenticationRequired preserves JSON semantics for API clients while
// making a direct browser navigation land on the login screen instead of a
// raw {"error":...} document. Frontend fetches explicitly request JSON and
// are handled by the shared vocat:unauthorized event.
func (s *Server) authenticationRequired(w http.ResponseWriter, r *http.Request) {
s.clearAuthCookies(w)
w.Header().Set("Cache-Control", "no-store")
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/html") {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required")
}
func (s *Server) validateDoubleSubmitCSRF(w http.ResponseWriter, r *http.Request) (string, bool) {
headerToken := r.Header.Get(csrfHeaderName)
cookie, err := r.Cookie(csrfCookieName)
if err != nil || headerToken == "" || cookie.Value == "" ||
subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie.Value)) != 1 {
writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed")
return "", false
}
return headerToken, true
}
func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken string, csrfToken string, expiresAt time.Time) {
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionToken,
Path: "/",
Expires: expiresAt,
MaxAge: maxAge,
HttpOnly: true,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
})
s.setCSRFCookie(w, csrfToken, expiresAt)
}
func (s *Server) setCSRFCookie(w http.ResponseWriter, csrfToken string, expiresAt time.Time) {
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: csrfToken,
Path: "/",
Expires: expiresAt,
MaxAge: maxAge,
HttpOnly: false,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) clearAuthCookies(w http.ResponseWriter) {
for _, name := range []string{sessionCookieName, csrfCookieName} {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
Expires: time.Unix(1, 0),
MaxAge: -1,
HttpOnly: name == sessionCookieName,
Secure: s.secureCookies,
SameSite: http.SameSiteStrictMode,
})
}
}
func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool {
if r.Method == allowed {
return true
}
w.Header().Set("Allow", allowed)
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return false
}
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
func (w *statusWriter) WriteHeader(status int) {
if w.status != 0 {
return
}
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *statusWriter) Write(data []byte) (int, error) {
if w.status == 0 {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
writer := &statusWriter{ResponseWriter: w}
next.ServeHTTP(writer, r)
status := writer.status
if status == 0 {
status = http.StatusOK
}
s.logger.Info(
"http request",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"duration", time.Since(startedAt),
)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()")
if strings.HasPrefix(r.URL.Path, "/websheets/") || strings.HasPrefix(r.URL.Path, "/plugin-assets/") {
// The self-hosted E911 websheet is embedded in an iframe by the SPA, so
// it must be frameable same-origin. Every other route stays DENY.
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
w.Header().Set(
"Content-Security-Policy",
"default-src 'self'; base-uri 'self'; frame-ancestors 'self'; "+
"object-src 'none'; form-action 'self'; "+
"script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+
"img-src 'self' data:; connect-src 'self'",
)
} else {
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set(
"Content-Security-Policy",
"default-src 'self'; base-uri 'self'; frame-ancestors 'none'; "+
"object-src 'none'; form-action 'self'; "+
"script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+
"img-src 'self' data:; connect-src 'self'",
)
}
if s.secureCookies && s.https == nil {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) recoverPanics(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
s.logger.Error("panic while serving request", "panic", recovered)
writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred")
}
}()
next.ServeHTTP(w, r)
})
}