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.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user