f267c430d9
Three features plus the design-system swap. Installer. Inno-compile.iss builds a per-user installer into %APPDATA%\ipswap with PrivilegesRequired=lowest, so it never shows a UAC prompt. That matches the app's asInvoker manifest, and it is also what lets the in-app updater replace the .exe later without elevation — a Program Files install could not. It offers a desktop shortcut and an optional start-with-Windows entry, and reuses the app's own single-instance mutex as AppMutex so setup notices a running copy, since a running .exe cannot be overwritten. Uninstall leaves presets.json, config.json and the log in place. Launching. Opening ipswap opens the editor in a browser, starting the tray first if it is not already up; if it is, the running copy opens the browser and the second process exits. internal/instance does this with a loopback control listener whose port and token live in a mode-600 session.json, plus a session-local named mutex on Windows to settle a launch race. A stale session file from a crash is detected by a failed call and treated as "no primary", so it can never wedge startup. The Run-key entry now passes --background, because a browser tab at every login is not wanted. Switching from the browser. New endpoints for active state, apply preview and apply. The browser confirms against the same before/after text the tray's MessageBox shows, built from a live read at prompt time. Apply requires the session token in a header like every other mutation — it changes the machine's network, so it is not a weaker case than editing a preset. A netsh refusal for lack of elevation comes back as 409 with a flag, so the page can point at "Relaunch as administrator" instead of showing a generic failure. To avoid two copies of that sequence, internal/switcher now owns everything between "the user said yes" and "the adapter changed", and both front ends call it. It serialises applies: two interleaved netsh sequences on one adapter would leave it matching neither preset, and there are now two ways to start one. CSS. apointless.css is vendored from bsncubed/css and left untouched; the previous file was the boarding-pass stylesheet and its class names did not match this markup. ipswap.css adds only what the design system does not ship — page chrome, tables, modals, stat tiles, a success alert — on its tokens, so re-pulling apointless.css restyles the app. Not verified: the installer is uncompiled (no Inno Setup or wine on this box) and the UI is not visually rendered (headless Firefox hangs here). Both are checked as far as the tooling allows — assets and endpoints serve, and every DOM id the JS touches exists in the markup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
512 lines
14 KiB
Go
512 lines
14 KiB
Go
// 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"
|
|
"errors"
|
|
"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"
|
|
"gitea.apointless.space/bsncubed/ipswap/internal/switcher"
|
|
)
|
|
|
|
//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
|
|
sw *switcher.Switcher
|
|
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()
|
|
// onApplied tells the tray to re-read the active preset after the browser
|
|
// switched one, so the check mark and tooltip do not go stale.
|
|
onApplied 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, sw *switcher.Switcher, mgr netcfg.Manager, paths config.Paths, settings config.Settings) *Server {
|
|
return &Server{store: store, sw: sw, 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 }
|
|
|
|
// OnApplied registers a callback fired after a successful apply.
|
|
func (s *Server) OnApplied(f func()) { s.onApplied = 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)
|
|
|
|
// Switching from the browser. The preview is a separate GET so the
|
|
// confirmation the user sees is built from a live read, exactly like the
|
|
// tray's MessageBox, rather than from whatever the list happened to say.
|
|
mux.HandleFunc("GET /api/active", s.handleActive)
|
|
mux.HandleFunc("GET /api/presets/{id}/preview", s.handlePreview)
|
|
mux.HandleFunc("POST /api/presets/{id}/apply", s.handleApply)
|
|
|
|
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})
|
|
}
|
|
|
|
// handleActive reports which preset is currently applied, so the editor can
|
|
// mark it and so a switch initiated there updates without a reload.
|
|
func (s *Server) handleActive(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.sw.Active())
|
|
}
|
|
|
|
// handlePreview returns the before/after the browser confirms against.
|
|
//
|
|
// The tray blocks on a native MessageBox before applying. The browser cannot,
|
|
// so it gets the same diff as data and confirms in the page. Same safety net,
|
|
// same live read — only the widget differs.
|
|
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) {
|
|
p, live, err := s.sw.Preview(r.PathValue("id"))
|
|
if err != nil {
|
|
if errors.Is(err, switcher.ErrNotFound) {
|
|
writeErr(w, http.StatusNotFound, err)
|
|
return
|
|
}
|
|
writeErr(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"preset": p,
|
|
"current": live,
|
|
"text": netcfg.ConfirmText(p, live),
|
|
"matches": netcfg.Matches(p, live),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
|
|
if err := s.sw.Apply(id); err != nil {
|
|
switch {
|
|
case errors.Is(err, switcher.ErrNotFound):
|
|
writeErr(w, http.StatusNotFound, err)
|
|
case errors.Is(err, netcfg.ErrElevationRequired):
|
|
// 409 rather than 500: the request was fine, the process just is
|
|
// not elevated. The page turns this into the relaunch hint.
|
|
writeJSON(w, http.StatusConflict, map[string]any{
|
|
"error": err.Error(),
|
|
"elevation_required": true,
|
|
})
|
|
default:
|
|
writeErr(w, http.StatusInternalServerError, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if s.onApplied != nil {
|
|
s.onApplied()
|
|
}
|
|
writeJSON(w, http.StatusOK, s.sw.Active())
|
|
}
|
|
|
|
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
|
|
}
|