// Package server is the "slow path": a local HTTP server hosting the preset // editor, opened in the user's default browser. // // It exists because the fast path must not involve a browser and the editor // must not involve a MessageBox. Switching a preset is two clicks in the tray; // managing fifty of them needs a real UI, and an embedded web app is the only // way to get one without linking a GUI toolkit and giving up cross-compilation. // // The server is not running most of the time. The tray starts it on demand and // it shuts itself down once the browser stops sending heartbeats. package server import ( "context" "crypto/rand" "crypto/subtle" "embed" "encoding/hex" "encoding/json" "fmt" "io" "io/fs" "log" "net" "net/http" "strings" "sync" "time" "gitea.apointless.space/bsncubed/ipswap/internal/config" "gitea.apointless.space/bsncubed/ipswap/internal/netcfg" "gitea.apointless.space/bsncubed/ipswap/internal/preset" ) //go:embed web var webFS embed.FS const ( // idleTimeout is how long the server survives without a heartbeat. The // browser beats every 30s, so this tolerates a few missed beats before // concluding the tab is gone. idleTimeout = 5 * time.Minute // tokenHeader carries the session token on mutating requests. tokenHeader = "X-Ipswap-Token" ) // Server hosts the editor. type Server struct { store *preset.Store mgr netcfg.Manager paths config.Paths // onSettingsChange lets the tray react to a settings save, e.g. to // rewrite the Run key when start-with-Windows is toggled. onSettingsChange func(config.Settings) // onPresetsChange asks the tray to rebuild its menu. onPresetsChange func() mu sync.Mutex settings config.Settings token string srv *http.Server ln net.Listener lastBeat time.Time stop chan struct{} } // New builds a server. It does not listen until Start is called. func New(store *preset.Store, mgr netcfg.Manager, paths config.Paths, settings config.Settings) *Server { return &Server{store: store, mgr: mgr, paths: paths, settings: settings} } // OnSettingsChange registers a callback fired after settings are saved. func (s *Server) OnSettingsChange(f func(config.Settings)) { s.onSettingsChange = f } // OnPresetsChange registers a callback fired after any preset mutation. func (s *Server) OnPresetsChange(f func()) { s.onPresetsChange = f } // URL returns the address to open, including the session token. It is empty // when the server is not running. func (s *Server) URL() string { s.mu.Lock() defer s.mu.Unlock() if s.ln == nil { return "" } return fmt.Sprintf("http://127.0.0.1:%d/?t=%s", s.ln.Addr().(*net.TCPAddr).Port, s.token) } // Start binds a random loopback port and serves until Stop or an idle timeout. // Calling it while already running just returns the existing URL, so clicking // "Manage presets…" twice reuses the one session. func (s *Server) Start() (string, error) { s.mu.Lock() if s.ln != nil { url := fmt.Sprintf("http://127.0.0.1:%d/?t=%s", s.ln.Addr().(*net.TCPAddr).Port, s.token) s.lastBeat = time.Now() s.mu.Unlock() return url, nil } // Loopback only. This binds no external interface at any point, which // matters given the app spends its life on customer networks. ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { s.mu.Unlock() return "", fmt.Errorf("binding a local port: %w", err) } token, err := newToken() if err != nil { ln.Close() s.mu.Unlock() return "", err } s.ln = ln s.token = token s.lastBeat = time.Now() s.stop = make(chan struct{}) s.srv = &http.Server{ Handler: s.routes(), ReadHeaderTimeout: 10 * time.Second, } stop := s.stop srv := s.srv port := ln.Addr().(*net.TCPAddr).Port s.mu.Unlock() go func() { if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { log.Printf("editor server stopped: %v", err) } }() go s.watchIdle(stop) log.Printf("editor server listening on 127.0.0.1:%d", port) return fmt.Sprintf("http://127.0.0.1:%d/?t=%s", port, token), nil } // Stop shuts the server down. It is safe to call when not running. func (s *Server) Stop() { s.mu.Lock() srv, stop := s.srv, s.stop s.srv, s.ln, s.stop, s.token = nil, nil, nil, "" s.mu.Unlock() if stop != nil { close(stop) } if srv == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() _ = srv.Shutdown(ctx) log.Printf("editor server shut down") } func (s *Server) watchIdle(stop <-chan struct{}) { t := time.NewTicker(30 * time.Second) defer t.Stop() for { select { case <-stop: return case <-t.C: s.mu.Lock() idle := time.Since(s.lastBeat) s.mu.Unlock() if idle > idleTimeout { log.Printf("editor server idle for %s, shutting down", idle.Round(time.Second)) s.Stop() return } } } } func newToken() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("generating a session token: %w", err) } return hex.EncodeToString(b[:]), nil } // Settings returns the current settings. func (s *Server) Settings() config.Settings { s.mu.Lock() defer s.mu.Unlock() return s.settings } // SetSettings replaces the settings the server serves, for when something // outside the editor changes them. func (s *Server) SetSettings(c config.Settings) { s.mu.Lock() s.settings = c s.mu.Unlock() } func (s *Server) beat() { s.mu.Lock() s.lastBeat = time.Now() s.mu.Unlock() } // --- routing --- func (s *Server) routes() http.Handler { mux := http.NewServeMux() assets, err := fs.Sub(webFS, "web") if err != nil { // Only reachable if the embed directive and the directory disagree, // which is a build-time mistake, not a runtime condition. panic(err) } mux.Handle("GET /", http.FileServer(http.FS(assets))) mux.HandleFunc("POST /api/heartbeat", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) mux.HandleFunc("GET /api/presets", s.handleListPresets) mux.HandleFunc("POST /api/presets", s.handlePutPreset) mux.HandleFunc("PUT /api/presets/{id}", s.handlePutPreset) mux.HandleFunc("DELETE /api/presets/{id}", s.handleDeletePreset) mux.HandleFunc("GET /api/adapters", s.handleAdapters) mux.HandleFunc("GET /api/settings", s.handleGetSettings) mux.HandleFunc("PUT /api/settings", s.handlePutSettings) mux.HandleFunc("GET /api/export", s.handleExport) mux.HandleFunc("POST /api/import", s.handleImport) return s.withAuth(mux) } // withAuth gates every request on the session token and refreshes the idle // timer. // // The token arrives once in the URL and is then stored in a SameSite=Strict // cookie. Mutating requests additionally require the token in a header, which // a cross-site page cannot set: a cookie alone would let any website in the // browser POST to this port and rewrite the user's presets. func (s *Server) withAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.mu.Lock() token := s.token s.mu.Unlock() if token == "" { http.Error(w, "server is shutting down", http.StatusServiceUnavailable) return } if q := r.URL.Query().Get("t"); q != "" && tokenEqual(q, token) { http.SetCookie(w, &http.Cookie{ Name: "ipswap_session", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, }) s.beat() next.ServeHTTP(w, r) return } c, err := r.Cookie("ipswap_session") if err != nil || !tokenEqual(c.Value, token) { http.Error(w, "unauthorised: reopen the editor from the tray menu", http.StatusUnauthorized) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { if !tokenEqual(r.Header.Get(tokenHeader), token) { http.Error(w, "missing session header", http.StatusForbidden) return } } s.beat() next.ServeHTTP(w, r) }) } func tokenEqual(got, want string) bool { return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 } // --- handlers --- func (s *Server) handleListPresets(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "presets": s.store.All(), "groups": s.store.Groups(), }) } func (s *Server) handlePutPreset(w http.ResponseWriter, r *http.Request) { var p preset.Preset if err := readJSON(r, &p); err != nil { writeErr(w, http.StatusBadRequest, err) return } if id := r.PathValue("id"); id != "" { p.ID = id } saved, err := s.store.Put(p) if err != nil { writeErr(w, http.StatusBadRequest, err) return } s.notifyPresets() writeJSON(w, http.StatusOK, saved) } func (s *Server) handleDeletePreset(w http.ResponseWriter, r *http.Request) { if err := s.store.Delete(r.PathValue("id")); err != nil { writeErr(w, http.StatusNotFound, err) return } s.notifyPresets() writeJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (s *Server) handleAdapters(w http.ResponseWriter, r *http.Request) { adapters, err := s.mgr.Adapters() if err != nil { writeErr(w, http.StatusInternalServerError, err) return } type row struct { netcfg.Adapter Current string `json:"current"` } out := make([]row, 0, len(adapters)) for _, a := range adapters { r := row{Adapter: a} if live, err := s.mgr.Current(a.Name); err == nil { r.Current = live.Summary() } out = append(out, r) } writeJSON(w, http.StatusOK, map[string]any{"adapters": out}) } func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, s.Settings()) } func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { c := s.Settings() if err := readJSON(r, &c); err != nil { writeErr(w, http.StatusBadRequest, err) return } if err := c.Save(s.paths.Config); err != nil { writeErr(w, http.StatusInternalServerError, err) return } s.SetSettings(c) if s.onSettingsChange != nil { s.onSettingsChange(c) } writeJSON(w, http.StatusOK, c) } func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { var groups []string if g := strings.TrimSpace(r.URL.Query().Get("groups")); g != "" { groups = strings.Split(g, ",") } b, err := s.store.Export(groups...) if err != nil { writeErr(w, http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Disposition", `attachment; filename="ipswap-presets.json"`) w.Write(b) } func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { mode := preset.ImportMode(r.URL.Query().Get("mode")) if mode == "" { mode = preset.ImportMerge } body, err := io.ReadAll(io.LimitReader(r.Body, 8<<20)) if err != nil { writeErr(w, http.StatusBadRequest, err) return } res, err := s.store.Import(body, mode) if err != nil { // Import validates the whole file first, so a rejection here means // nothing on disk changed. writeErr(w, http.StatusBadRequest, err) return } s.notifyPresets() writeJSON(w, http.StatusOK, res) } func (s *Server) notifyPresets() { if s.onPresetsChange != nil { s.onPresetsChange() } } // --- helpers --- func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(v); err != nil { log.Printf("writing response: %v", err) } } func writeErr(w http.ResponseWriter, status int, err error) { writeJSON(w, status, map[string]string{"error": err.Error()}) } func readJSON(r *http.Request, v any) error { dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) if err := dec.Decode(v); err != nil { return fmt.Errorf("invalid request body: %w", err) } return nil }