Files
weircon-random-proxy/service/admin.go
T
Asger Weirsøe 3f170293f5
release / release (push) Successful in 1m19s
rotate egress over a larger WG pool, with graceful drain and live status
Keep the N always-on tunnel slots fixed but let each slot's WireGuard config
rotate through a larger pool, so a 10-concurrent provider cap (e.g. Proton) can
still cycle 50-100 profiles.

- lxc/rotate.sh + weircon-rotate.{service,timer}: round-robin one slot at a
  time through wg-pool/, repointing a symlink and restarting only that slot.
- service: proxyManager tracks per-slot in-flight + drain/undrain state; a
  localhost admin server (WEIRCON_ADMIN_LISTEN) lets rotate.sh drain a slot
  before teardown and warm it back in after, so no request is routed to a
  tunnel mid-rotation. Slots self-heal if undrain never arrives.
- GET /status: poll-friendly JSON of per-slot egress IP/state plus inferred
  next-rotation slot + ETA, fed by a background egress-IP prober.
- docs + env examples for all new knobs.
2026-06-01 10:58:51 +02:00

68 lines
2.0 KiB
Go

package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
)
// newAdminHandler exposes the rotation control surface. It is intended to bind
// to localhost only (WEIRCON_ADMIN_LISTEN) and is called by rotate.sh:
//
// POST /drain?id=N take slot N out of service; blocks until in-flight
// drains (bounded by WEIRCON_DRAIN_TIMEOUT_SEC) + settle.
// 200 {"id":N,"drained":true,"inflight":0} on a clean drain,
// "drained":false with the residual count on timeout.
// POST /undrain?id=N return slot N to service after the warmup window.
// GET /status snapshot of every slot.
func newAdminHandler(cfg Config, mgr *proxyManager) http.Handler {
mux := http.NewServeMux()
slotID := func(w http.ResponseWriter, r *http.Request) (int, bool) {
raw := r.URL.Query().Get("id")
id, err := strconv.Atoi(raw)
if err != nil || id < 0 || id >= mgr.n {
http.Error(w, fmt.Sprintf("id must be 0-%d", mgr.n-1), http.StatusBadRequest)
return 0, false
}
return id, true
}
writeJSON := func(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
mux.HandleFunc("/drain", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
id, ok := slotID(w, r)
if !ok {
return
}
residual := mgr.drain(id, cfg.DrainTimeout)
writeJSON(w, map[string]any{"id": id, "drained": residual == 0, "inflight": residual})
})
mux.HandleFunc("/undrain", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
}
id, ok := slotID(w, r)
if !ok {
return
}
mgr.undrain(id)
writeJSON(w, map[string]any{"id": id, "warmup_sec": int(cfg.DrainWarmup.Seconds())})
})
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, mgr.snapshot())
})
return mux
}