Add HTTP resync endpoint for manual rotation triggering
Build and Push Docker Images / build-and-push (push) Successful in 1m30s

Expose port 8080 with `/resync` endpoint to trigger on-demand WireGuard rotations. Add RESYNC_PORT and RESYNC_BIND environment variables. Implement thread-safe rotation locking to prevent concurrent runs. Update README with endpoint usage examples.
This commit is contained in:
Bram committed 2026-09-19 21:19:03 +02:00
1 parent b20ebbfca6
commit 2dbaa0ebb0
3 files changed
+121 -7

No files matched your search

@@ -26,5 +26,8 @@ ENV PIA_CA_PATH=/config/cache/ca.rsa.4096.crt
ENV LATENCY_SWITCH_MARGIN_MS=15 ENV LATENCY_SWITCH_MARGIN_MS=15
ENV HEALTH_CHECK_INTERVAL=10 ENV HEALTH_CHECK_INTERVAL=10
ENV UNHEALTHY_ROTATE_COOLDOWN=60 ENV UNHEALTHY_ROTATE_COOLDOWN=60
ENV RESYNC_PORT=8080
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
@@ -54,6 +54,22 @@ docker run --rm --entrypoint /opt/venv/bin/python \
| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Cooldown bij PIA rate-limit | | `RATE_LIMIT_WAIT_SECONDS` | `3600` | Cooldown bij PIA rate-limit |
| `RATE_LIMIT_PATH` | `/config/cache/pia-rate-limit.json` | Persistente rate-limit cooldown | | `RATE_LIMIT_PATH` | `/config/cache/pia-rate-limit.json` | Persistente rate-limit cooldown |
| `TZ` | `Europe/Brussels` | Tijdzone | | `TZ` | `Europe/Brussels` | Tijdzone |
| `RESYNC_PORT` | `8080` | HTTP-poort voor handmatige resync (`/resync`). Leeg = uitschakelen |
| `RESYNC_BIND` | `0.0.0.0` | Bind-adres van de resync-HTTP-server |
## Handmatige resync
Start de container en roep de endpoint aan om on demand een force-rotatie te triggeren (dezelfde actie als een `unhealthy`-recovery):
```bash
# vanuit een andere container in hetzelfde Docker-netwerk (bijv. de Gluetun-container)
curl -X POST http://gluetun-pia-wireguard-rotator:8080/resync
# of vanaf de Docker-host als je de poort mapped
curl -X POST http://localhost:8080/resync
```
De endpoint geeft `202 Accepted` terug; de daadwerkelijke rotatie gebeurt in de achtergrond. `GET /` toont de beschikbare endpoints.
## Compose ## Compose
@@ -86,6 +102,7 @@ docker run --rm --entrypoint /opt/venv/bin/python \
- Wacht tot `UNHEALTHY_ROTATE_COOLDOWN` of tot healthy - Wacht tot `UNHEALTHY_ROTATE_COOLDOWN` of tot healthy
- Nog unhealthy → stap 2: exclude die IP, force runner-up (#2 latency) - Nog unhealthy → stap 2: exclude die IP, force runner-up (#2 latency)
4. **Cron due:** latency opnieuw meten; **zelfde beste server** → geen token/addKey/restarts 4. **Cron due:** latency opnieuw meten; **zelfde beste server** → geen token/addKey/restarts
5. Rate-limit: persistente cooldown, daarna retry 5. **HTTP-resync:** `POST /resync` triggert een force-rotatie op de achtergrond
6. Rate-limit: persistente cooldown, daarna retry
**Let op:** zet gluetun niet in `RESTART_CONTAINERS`. Elke echte rotatie geeft korte downtime. **Let op:** zet gluetun niet in `RESTART_CONTAINERS`. Elke echte rotatie geeft korte downtime.
@@ -8,9 +8,12 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import threading
import time import time
from datetime import datetime from datetime import datetime
import http.server
from pathlib import Path from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
@@ -19,6 +22,9 @@ import pia
EXIT_RATE_LIMITED = pia.EXIT_RATE_LIMITED EXIT_RATE_LIMITED = pia.EXIT_RATE_LIMITED
_ROTATION_LOCK = threading.Lock()
_RESYNC_EVENT = threading.Event()
CRON_MACROS = { CRON_MACROS = {
"@yearly": "0 0 1 1 *", "@yearly": "0 0 1 1 *",
"@annually": "0 0 1 1 *", "@annually": "0 0 1 1 *",
@@ -81,6 +87,68 @@ def unhealthy_cooldown_seconds() -> float:
return max(0.0, float(os.environ.get("UNHEALTHY_ROTATE_COOLDOWN", "60"))) return max(0.0, float(os.environ.get("UNHEALTHY_ROTATE_COOLDOWN", "60")))
def resync_port() -> int | None:
raw = os.environ.get("RESYNC_PORT", "8080").strip()
if not raw:
return None
try:
port = int(raw)
except ValueError as exc:
raise SystemExit(f"Invalid RESYNC_PORT '{raw}' (expected integer)") from exc
if not (1 <= port <= 65535):
raise SystemExit(f"Invalid RESYNC_PORT {port} (expected 1-65535)")
return port
def resync_bind_address() -> str:
return os.environ.get("RESYNC_BIND", "0.0.0.0").strip() or "0.0.0.0"
class ResyncHandler(http.server.BaseHTTPRequestHandler):
def _respond(self, status: int, body: dict[str, Any]) -> None:
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(body).encode("utf-8"))
def _path(self) -> str:
return self.path.split("?", 1)[0]
def do_GET(self) -> None: # noqa: N802
path = self._path()
if path == "/resync":
_RESYNC_EVENT.set()
self._respond(202, {"status": "accepted", "message": "Resync requested"})
elif path == "/":
self._respond(
200,
{
"service": "gluetun-pia-wireguard-rotator",
"endpoints": ["/resync"],
},
)
else:
self._respond(404, {"error": "not found"})
def do_POST(self) -> None: # noqa: N802
path = self._path()
if path == "/resync":
_RESYNC_EVENT.set()
self._respond(202, {"status": "accepted", "message": "Resync requested"})
else:
self._respond(404, {"error": "not found"})
def log_message(self, fmt: str, *args: Any) -> None:
log(fmt % args)
def start_resync_server(port: int, bind: str) -> None:
server = http.server.HTTPServer((bind, port), ResyncHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True, name="resync-http")
thread.start()
log(f"Resync endpoint listening on http://{bind}:{port}/resync")
def gluetun_container_name() -> str: def gluetun_container_name() -> str:
return os.environ.get("GLUETUN_CONTAINER", "m3u-filter-vpn").strip() or "m3u-filter-vpn" return os.environ.get("GLUETUN_CONTAINER", "m3u-filter-vpn").strip() or "m3u-filter-vpn"
@@ -142,6 +210,12 @@ def run_rotation(reason: str, extra_args: list[str] | None = None) -> None:
raise SystemExit(f"Rotation failed with exit code {result.returncode}") raise SystemExit(f"Rotation failed with exit code {result.returncode}")
def run_rotation_locked(reason: str, extra_args: list[str] | None = None) -> None:
"""Run a rotation while holding the global lock to avoid concurrent runs."""
with _ROTATION_LOCK:
run_rotation(reason, extra_args)
def wait_until_healthy_or_timeout(timeout_s: float) -> str | None: def wait_until_healthy_or_timeout(timeout_s: float) -> str | None:
"""Sleep up to timeout_s, returning early if Gluetun becomes non-unhealthy.""" """Sleep up to timeout_s, returning early if Gluetun becomes non-unhealthy."""
deadline = time.time() + timeout_s deadline = time.time() + timeout_s
@@ -161,7 +235,7 @@ def handle_unhealthy() -> None:
"""Two-step recovery: re-auth best server, then runner-up if still unhealthy.""" """Two-step recovery: re-auth best server, then runner-up if still unhealthy."""
log(f"Gluetun container {gluetun_container_name()} is unhealthy; starting recovery") log(f"Gluetun container {gluetun_container_name()} is unhealthy; starting recovery")
run_rotation( run_rotation_locked(
"Unhealthy recovery step 1: force rotate to best server (new keypair)", "Unhealthy recovery step 1: force rotate to best server (new keypair)",
["--force"], ["--force"],
) )
@@ -180,7 +254,7 @@ def handle_unhealthy() -> None:
else: else:
log("Still unhealthy; step 2 without exclude (no server_ip in state)") log("Still unhealthy; step 2 without exclude (no server_ip in state)")
run_rotation( run_rotation_locked(
"Unhealthy recovery step 2: force rotate to runner-up", "Unhealthy recovery step 2: force rotate to runner-up",
exclude_args, exclude_args,
) )
@@ -193,30 +267,50 @@ def main() -> None:
expr = resolve_cron_expr() expr = resolve_cron_expr()
tz = zone() tz = zone()
interval = health_check_interval() interval = health_check_interval()
port = resync_port()
bind = resync_bind_address()
# Fail fast on bad cron / TZ before rotating. # Fail fast on bad cron / TZ before rotating.
nxt = next_run(expr, datetime.now(tz)) nxt = next_run(expr, datetime.now(tz))
log( log(
f"Starting gluetun PIA WireGuard rotator " f"Starting gluetun PIA WireGuard rotator "
f"(TZ={tz.key}, ROTATE_CRON='{expr}', HEALTH_CHECK_INTERVAL={interval:g}s)" f"(TZ={tz.key}, ROTATE_CRON='{expr}', HEALTH_CHECK_INTERVAL={interval:g}s, "
f"RESYNC_PORT={port or 'off'})"
) )
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}") log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")
run_rotation("Running rotation on startup", ["--force"]) if port:
start_resync_server(port, bind)
run_rotation_locked("Running rotation on startup", ["--force"])
nxt = next_run(expr, datetime.now(tz)) nxt = next_run(expr, datetime.now(tz))
step = min(interval, 1.0)
while True: while True:
time.sleep(interval) # Sleep in short chunks so a resync request wakes us early.
deadline = time.time() + interval
while time.time() < deadline and not _RESYNC_EVENT.is_set():
remaining = deadline - time.time()
time.sleep(min(step, remaining, 1.0))
now = datetime.now(tz) now = datetime.now(tz)
if _RESYNC_EVENT.is_set():
_RESYNC_EVENT.clear()
run_rotation_locked("Resync requested via HTTP", ["--force"])
nxt = next_run(expr, datetime.now(tz))
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")
continue
status = gluetun_health_status() status = gluetun_health_status()
if status == "unhealthy": if status == "unhealthy":
handle_unhealthy() handle_unhealthy()
_RESYNC_EVENT.clear()
nxt = next_run(expr, datetime.now(tz)) nxt = next_run(expr, datetime.now(tz))
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}") log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")
continue continue
if now >= nxt: if now >= nxt:
run_rotation("Running scheduled rotation", ["--skip-same-server"]) run_rotation_locked("Running scheduled rotation", ["--skip-same-server"])
nxt = next_run(expr, datetime.now(tz)) nxt = next_run(expr, datetime.now(tz))
log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}") log(f"Next scheduled rotation at {nxt.isoformat(timespec='seconds')}")