WGX shares its name with several other WireGuard tools, so the project becomes ihasvpn, alongside ihasmail. - Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image ghcr.io/coffey-labs/ihasvpn. - Environment variables move from WGX_* to IHASVPN_*. The default database is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and the session cookie and theme key are renamed, so existing sessions end. - The mark is the ihasmail cat peeking over the edge of a shield, drawn as a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks, social card, favicons and app icons from that one drawing. - The console takes ihasmail's palette: the ihasmail.org teal-navy for dark, its contrast-checked light tiers with the site's light accent, received traffic in the cat's orange and sent in teal. The wordmark weight and font stack follow ihasmail.org. - Detail values wrap at spaces before breaking inside an address, so an IPv6 tunnel address no longer splits mid-number. - The README history note about the earlier WGX installer is gone with the name it explained. Screenshots retaken.
337 lines
10 KiB
Go
337 lines
10 KiB
Go
// Package server is the admin HTTP API and the host for the embedded UI.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Coffey-Labs/ihasvpn/internal/auth"
|
|
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
|
"github.com/Coffey-Labs/ihasvpn/internal/engine"
|
|
"github.com/Coffey-Labs/ihasvpn/internal/server/static"
|
|
)
|
|
|
|
// Server serves the API and UI.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
eng *engine.Engine
|
|
log *slog.Logger
|
|
mux *http.ServeMux
|
|
ipLimit *auth.Limiter
|
|
usrLimit *auth.Limiter
|
|
http *http.Server
|
|
}
|
|
|
|
// New builds the router.
|
|
func New(cfg *config.Config, eng *engine.Engine, log *slog.Logger) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
eng: eng,
|
|
log: log,
|
|
mux: http.NewServeMux(),
|
|
ipLimit: auth.NewLimiter(20, 15*time.Minute),
|
|
usrLimit: auth.NewLimiter(8, 15*time.Minute),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// Handler returns the full middleware chain, for tests and for ListenAndServe.
|
|
func (s *Server) Handler() http.Handler {
|
|
return s.recoverer(s.securityHeaders(s.mux))
|
|
}
|
|
|
|
// ListenAndServe runs until ctx is cancelled.
|
|
func (s *Server) ListenAndServe(ctx context.Context) error {
|
|
s.http = &http.Server{
|
|
Addr: s.cfg.HTTP,
|
|
Handler: s.Handler(),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 30 * time.Second,
|
|
// No WriteTimeout: the SSE stream is long-lived. Handlers that
|
|
// matter bound themselves.
|
|
IdleTimeout: 120 * time.Second,
|
|
MaxHeaderBytes: 64 << 10,
|
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
|
}
|
|
var tlsCfg *tls.Config
|
|
if s.cfg.TLSEnabled() {
|
|
cert, err := s.loadCertificate()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tlsCfg = &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: tls.VersionTLS12,
|
|
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
|
|
}
|
|
s.http.TLSConfig = tlsCfg
|
|
}
|
|
ln, err := net.Listen("tcp", s.cfg.HTTP)
|
|
if err != nil {
|
|
return fmt.Errorf("listen %s: %w", s.cfg.HTTP, err)
|
|
}
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
if tlsCfg != nil {
|
|
errCh <- s.http.ServeTLS(ln, "", "")
|
|
} else {
|
|
errCh <- s.http.Serve(ln)
|
|
}
|
|
}()
|
|
s.log.Info("admin UI listening", "addr", ln.Addr().String(), "tls", tlsCfg != nil)
|
|
select {
|
|
case <-ctx.Done():
|
|
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return s.http.Shutdown(shutdown)
|
|
case err := <-errCh:
|
|
if errors.Is(err, http.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
func (s *Server) routes() {
|
|
m := s.mux
|
|
// Unauthenticated.
|
|
m.HandleFunc("GET /api/health", s.handleHealth)
|
|
m.HandleFunc("GET /api/setup", s.handleSetupStatus)
|
|
m.HandleFunc("POST /api/setup", s.handleSetup)
|
|
m.HandleFunc("POST /api/auth/login", s.handleLogin)
|
|
m.HandleFunc("POST /api/auth/totp", s.handleLoginTOTP)
|
|
m.HandleFunc("GET /metrics", s.handleMetrics)
|
|
|
|
// Session required.
|
|
m.Handle("GET /api/auth/me", s.authed(s.handleMe))
|
|
m.Handle("POST /api/auth/logout", s.authed(s.handleLogout))
|
|
m.Handle("POST /api/auth/password", s.authed(s.handleChangePassword))
|
|
m.Handle("POST /api/auth/totp/setup", s.authed(s.handleTOTPSetup))
|
|
m.Handle("GET /api/auth/totp/qr.png", s.authed(s.handleTOTPQR))
|
|
m.Handle("POST /api/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
|
|
m.Handle("POST /api/auth/totp/disable", s.authed(s.handleTOTPDisable))
|
|
m.Handle("GET /api/auth/sessions", s.authed(s.handleSessions))
|
|
m.Handle("POST /api/auth/sessions/revoke", s.authed(s.handleRevokeSessions))
|
|
|
|
m.Handle("GET /api/status", s.authed(s.handleStatus))
|
|
m.Handle("GET /api/events", s.authed(s.handleEvents))
|
|
m.Handle("GET /api/peers", s.authed(s.handlePeers))
|
|
m.Handle("GET /api/peers/{id}", s.authed(s.handlePeer))
|
|
m.Handle("GET /api/peers/{id}/config", s.authed(s.handlePeerConfig))
|
|
m.Handle("GET /api/peers/{id}/qr.png", s.authed(s.handlePeerQR))
|
|
m.Handle("GET /api/peers/{id}/usage", s.authed(s.handlePeerUsage))
|
|
m.Handle("GET /api/usage", s.authed(s.handleUsage))
|
|
m.Handle("GET /api/usage/peers", s.authed(s.handleUsageByPeer))
|
|
m.Handle("GET /api/settings", s.authed(s.handleGetSettings))
|
|
m.Handle("GET /api/audit", s.authed(s.handleAudit))
|
|
m.Handle("GET /api/users", s.authed(s.handleUsers))
|
|
|
|
// Admin role required.
|
|
m.Handle("POST /api/peers", s.admin(s.handleCreatePeer))
|
|
m.Handle("PUT /api/peers/{id}", s.admin(s.handleUpdatePeer))
|
|
m.Handle("DELETE /api/peers/{id}", s.admin(s.handleDeletePeer))
|
|
m.Handle("POST /api/peers/{id}/enable", s.admin(s.handleEnablePeer))
|
|
m.Handle("POST /api/peers/{id}/disable", s.admin(s.handleDisablePeer))
|
|
m.Handle("POST /api/peers/{id}/reset", s.admin(s.handleResetPeer))
|
|
m.Handle("POST /api/peers/{id}/rotate", s.admin(s.handleRotatePeer))
|
|
m.Handle("PUT /api/settings", s.admin(s.handlePutSettings))
|
|
m.Handle("POST /api/users", s.admin(s.handleCreateUser))
|
|
m.Handle("PUT /api/users/{id}", s.admin(s.handleUpdateUser))
|
|
m.Handle("DELETE /api/users/{id}", s.admin(s.handleDeleteUser))
|
|
|
|
m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusNotFound, "no such endpoint")
|
|
})
|
|
m.Handle("/", s.spa())
|
|
}
|
|
|
|
// spa serves the embedded UI, falling back to index.html for client routes.
|
|
func (s *Server) spa() http.Handler {
|
|
files := static.FS()
|
|
fileServer := http.FileServerFS(files)
|
|
index, _ := fs.ReadFile(files, "index.html")
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
|
return
|
|
}
|
|
p := path.Clean(r.URL.Path)
|
|
if p != "/" {
|
|
if f, err := files.Open(strings.TrimPrefix(p, "/")); err == nil {
|
|
f.Close()
|
|
if strings.HasPrefix(p, "/assets/") {
|
|
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
fileServer.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
if index == nil {
|
|
http.Error(w, "the admin UI has not been built; run `npm run build` in web/", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Write(index)
|
|
})
|
|
}
|
|
|
|
// --- middleware ------------------------------------------------------------
|
|
|
|
func (s *Server) recoverer(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
if rec == http.ErrAbortHandler {
|
|
panic(rec)
|
|
}
|
|
s.log.Error("panic", "path", r.URL.Path, "error", rec)
|
|
writeError(w, http.StatusInternalServerError, "internal error")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
h := w.Header()
|
|
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
|
h.Set("X-Content-Type-Options", "nosniff")
|
|
h.Set("X-Frame-Options", "DENY")
|
|
h.Set("Referrer-Policy", "no-referrer")
|
|
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
|
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
|
if s.cfg.TLSEnabled() || s.cfg.SecureCookies {
|
|
h.Set("Strict-Transport-Security", "max-age=31536000")
|
|
}
|
|
if strings.HasPrefix(r.URL.Path, "/api/") {
|
|
h.Set("Cache-Control", "no-store")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// sameOrigin rejects cross-site state changes. Cookies are SameSite=Strict
|
|
// already; this is the belt to that brace, for browsers that send
|
|
// Sec-Fetch-Site or Origin.
|
|
func (s *Server) sameOrigin(r *http.Request) bool {
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
|
return true
|
|
}
|
|
if site := r.Header.Get("Sec-Fetch-Site"); site != "" {
|
|
return site == "same-origin" || site == "none"
|
|
}
|
|
if origin := r.Header.Get("Origin"); origin != "" {
|
|
host := r.Host
|
|
return strings.EqualFold(strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"), host)
|
|
}
|
|
// Neither header: not a modern browser. A non-browser client cannot be
|
|
// tricked by a third-party page, so allow it.
|
|
return true
|
|
}
|
|
|
|
// clientIP returns the caller's address, honouring proxy headers only from
|
|
// trusted proxies.
|
|
func (s *Server) clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
addr, err := netip.ParseAddr(host)
|
|
if err != nil {
|
|
return host
|
|
}
|
|
if !s.trusted(addr) {
|
|
return addr.String()
|
|
}
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
parts := strings.Split(xff, ",")
|
|
// Walk from the right, skipping trusted hops, to the first address
|
|
// that is not one of our proxies.
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
|
if err != nil {
|
|
break
|
|
}
|
|
if !s.trusted(a) {
|
|
return a.String()
|
|
}
|
|
}
|
|
}
|
|
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
|
|
if a, err := netip.ParseAddr(real); err == nil {
|
|
return a.String()
|
|
}
|
|
}
|
|
return addr.String()
|
|
}
|
|
|
|
func (s *Server) trusted(a netip.Addr) bool {
|
|
for _, p := range s.cfg.TrustedProxies {
|
|
if p.Contains(a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// --- helpers ---------------------------------------------------------------
|
|
|
|
type errorBody struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, errorBody{Error: msg})
|
|
}
|
|
|
|
// readJSON decodes a small JSON body strictly.
|
|
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
|
ct := r.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(ct, "application/json") {
|
|
writeError(w, http.StatusUnsupportedMediaType, "expected application/json")
|
|
return false
|
|
}
|
|
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(v); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad JSON: "+err.Error())
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// engineError maps engine errors to status codes.
|
|
func engineError(w http.ResponseWriter, err error) {
|
|
var ve engine.ErrValidation
|
|
switch {
|
|
case errors.As(err, &ve):
|
|
writeError(w, http.StatusBadRequest, ve.Msg)
|
|
case errors.Is(err, engine.ErrNotFound):
|
|
writeError(w, http.StatusNotFound, "not found")
|
|
default:
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
}
|