This commit is contained in:
@@ -7,17 +7,22 @@ RUN apk add --no-cache git ca-certificates \
|
||||
|
||||
FROM docker:cli
|
||||
|
||||
RUN apk add --no-cache bash ca-certificates tzdata jq
|
||||
RUN apk add --no-cache bash ca-certificates tzdata jq python3 py3-pip \
|
||||
&& python3 -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir 'croniter==6.2.4' \
|
||||
&& apk del py3-pip
|
||||
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
COPY --from=builder /go/bin/pia-wg-config /usr/local/bin/pia-wg-config
|
||||
COPY rotate.sh entrypoint.sh /usr/local/bin/
|
||||
COPY rotate.sh entrypoint.py /usr/local/bin/
|
||||
|
||||
RUN chmod +x /usr/local/bin/rotate.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/rotate.sh /usr/local/bin/entrypoint.py
|
||||
|
||||
ENV TZ=Europe/Brussels
|
||||
ENV WG_CONFIG_PATH=/config/wireguard/wg0.conf
|
||||
ENV ROTATOR_STATE_PATH=/config/rotator-state.json
|
||||
ENV GLUETUN_CONTAINER=m3u-filter-vpn
|
||||
ENV ROTATE_AT=03:00
|
||||
ENV ROTATE_CRON="0 3 * * *"
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
ENTRYPOINT ["/opt/venv/bin/python", "/usr/local/bin/entrypoint.py"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gluetun-pia-wireguard-rotator
|
||||
|
||||
Sidecar die dagelijks (en bij container-start) een willekeurige PIA WireGuard-region kiest via [pia-wg-config](https://github.com/ccarpinteri/pia-wg-config), `wg0.conf` op het gedeelde gluetun-volume schrijft, en een configureerbare lijst containers herstart.
|
||||
Sidecar die op een cron-schema (en bij container-start) een willekeurige PIA WireGuard-region kiest via [pia-wg-config](https://github.com/ccarpinteri/pia-wg-config), `wg0.conf` op het gedeelde gluetun-volume schrijft, en een configureerbare lijst containers herstart.
|
||||
|
||||
## Vereisten
|
||||
|
||||
@@ -35,10 +35,12 @@ docker run --rm --entrypoint pia-wg-config bramkel/gluetun-pia-wireguard-rotator
|
||||
| `GLUETUN_CONTAINER` | `m3u-filter-vpn` | Gluetun-container; wordt **altijd als eerste** herstart |
|
||||
| `WG_CONFIG_PATH` | `/config/wireguard/wg0.conf` | Pad waar `wg0.conf` wordt geschreven |
|
||||
| `ROTATOR_STATE_PATH` | `/config/rotator-state.json` | Laatste rotatie-metadata |
|
||||
| `ROTATE_AT` | `03:00` | Dagelijks rotatietijdstip (`HH:MM`, in `TZ`) |
|
||||
| `ROTATE_CRON` | `0 3 * * *` | 5-veld cron-expressie (minuut uur dag-van-maand maand dag-van-week), in `TZ`. Macros: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` |
|
||||
| `RATE_LIMIT_WAIT_SECONDS` | `3600` | Wachttijd bij PIA rate-limit (`429` / `too_many_attempts`) vóór retry |
|
||||
| `TZ` | `Europe/Brussels` | Tijdzone voor scheduling |
|
||||
|
||||
`ROTATE_CRON` voorbeelden: `0 */6 * * *` (elke 6 uur), `0 3 * * 1-5` (weekdagen 03:00), `@hourly`. Quote de waarde in Compose (`"0 3 * * *"`) zodat YAML `*` niet speciaal interpreteert. Oude `ROTATE_AT=HH:MM` werkt nog als `ROTATE_CRON` leeg is.
|
||||
|
||||
## Output
|
||||
|
||||
- `wireguard/wg0.conf` op het gedeelde volume — Gluetun leest dit als `/gluetun/wireguard/wg0.conf` en dit **overschrijft** `WIREGUARD_*` environment variables
|
||||
@@ -62,7 +64,7 @@ Zie [`docker-compose.example.yml`](docker-compose.example.yml) voor een volledig
|
||||
- PIA_REGIONS=netherlands,france,belgium
|
||||
- GLUETUN_CONTAINER=downloaders-vpn
|
||||
- RESTART_CONTAINERS=SabNZBd,qbittorrent,nzbhydra2,Spotweb
|
||||
- ROTATE_AT=03:00
|
||||
- 'ROTATE_CRON=0 3 * * *'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- /var/dockers/m3u-filter-pia:/config
|
||||
@@ -100,7 +102,7 @@ Optioneel host-`.env`-keys (`WIREGUARD_*`) opruimen als die niet meer gebruikt w
|
||||
## Gedrag
|
||||
|
||||
1. Bij start: direct roteren (nieuwe config + container-restarts)
|
||||
2. Daarna: elke dag om `ROTATE_AT` opnieuw
|
||||
2. Daarna: volgens `ROTATE_CRON` (standaard dagelijks om 03:00)
|
||||
3. Willekeurige region uit `PIA_REGIONS`, nooit dezelfde als de vorige (uit `rotator-state.json`; uitzondering: maar één region geconfigureerd)
|
||||
4. `GLUETUN_CONTAINER` wordt altijd als eerste herstart, daarna containers uit `RESTART_CONTAINERS`
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schedule PIA WireGuard rotations via ROTATE_CRON (croniter)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from croniter import croniter
|
||||
|
||||
# Matches rotate.sh EXIT_RATE_LIMITED (EX_TEMPFAIL)
|
||||
EXIT_RATE_LIMITED = 75
|
||||
|
||||
CRON_MACROS = {
|
||||
"@yearly": "0 0 1 1 *",
|
||||
"@annually": "0 0 1 1 *",
|
||||
"@monthly": "0 0 1 * *",
|
||||
"@weekly": "0 0 * * 0",
|
||||
"@daily": "0 0 * * *",
|
||||
"@midnight": "0 0 * * *",
|
||||
"@hourly": "0 * * * *",
|
||||
}
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(f"[{datetime.now().astimezone().isoformat(timespec='seconds')}] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def zone() -> ZoneInfo:
|
||||
name = os.environ.get("TZ") or "UTC"
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception as exc:
|
||||
raise SystemExit(f"Invalid TZ '{name}': {exc}") from exc
|
||||
|
||||
|
||||
def hhmm_to_cron(value: str) -> str:
|
||||
match = re.fullmatch(r"([0-9]{1,2}):([0-9]{2})", value.strip())
|
||||
if not match:
|
||||
raise ValueError(f"Invalid ROTATE_AT '{value}' (expected HH:MM)")
|
||||
hour = int(match.group(1))
|
||||
minute = int(match.group(2))
|
||||
if not (0 <= hour <= 23 and 0 <= minute <= 59):
|
||||
raise ValueError(f"Invalid ROTATE_AT '{value}' (expected HH:MM)")
|
||||
return f"{minute} {hour} * * *"
|
||||
|
||||
|
||||
def resolve_cron_expr() -> str:
|
||||
cron = os.environ.get("ROTATE_CRON", "").strip()
|
||||
at = os.environ.get("ROTATE_AT", "").strip()
|
||||
if cron:
|
||||
expr = cron
|
||||
elif at:
|
||||
expr = hhmm_to_cron(at)
|
||||
else:
|
||||
expr = "0 3 * * *"
|
||||
|
||||
expr = CRON_MACROS.get(expr.lower(), expr)
|
||||
if not croniter.is_valid(expr):
|
||||
raise SystemExit(f"Invalid ROTATE_CRON '{expr}' (expected a 5-field cron expression)")
|
||||
return expr
|
||||
|
||||
|
||||
def next_run(expr: str, after: datetime) -> datetime:
|
||||
return croniter(expr, after).get_next(datetime)
|
||||
|
||||
|
||||
def sleep_until_next_rotate(expr: str) -> None:
|
||||
tz = zone()
|
||||
now = datetime.now(tz)
|
||||
nxt = next_run(expr, now)
|
||||
wait_s = max(0.0, (nxt - now).total_seconds())
|
||||
log(f"Next rotation at {nxt.isoformat(timespec='seconds')} (cron '{expr}', TZ={tz.key}) in {int(wait_s)}s")
|
||||
time.sleep(wait_s)
|
||||
|
||||
|
||||
def run_rotation(reason: str) -> None:
|
||||
wait_s = int(os.environ.get("RATE_LIMIT_WAIT_SECONDS", "3600"))
|
||||
while True:
|
||||
log(reason)
|
||||
result = subprocess.run(["/usr/local/bin/rotate.sh"], check=False)
|
||||
if result.returncode == 0:
|
||||
return
|
||||
if result.returncode == EXIT_RATE_LIMITED:
|
||||
log(f"PIA rate-limited (too many attempts); waiting {wait_s}s before retry")
|
||||
time.sleep(wait_s)
|
||||
reason = "Retrying rotation after rate-limit wait"
|
||||
continue
|
||||
raise SystemExit(f"Rotation failed with exit code {result.returncode}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
expr = resolve_cron_expr()
|
||||
# Fail fast on bad cron / TZ before rotating.
|
||||
next_run(expr, datetime.now(zone()))
|
||||
log(f"Starting gluetun PIA WireGuard rotator (TZ={zone().key}, ROTATE_CRON='{expr}')")
|
||||
|
||||
run_rotation("Running rotation on startup")
|
||||
while True:
|
||||
sleep_until_next_rotate(expr)
|
||||
run_rotation("Running scheduled rotation")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
log() { echo "[$(date -Is)] $*" >&2; }
|
||||
|
||||
sleep_until_next_rotate() {
|
||||
local rotate_at="${ROTATE_AT:-03:00}"
|
||||
local target_h target_m
|
||||
IFS=: read -r target_h target_m <<<"$rotate_at"
|
||||
target_h=$((10#$target_h))
|
||||
target_m=$((10#$target_m))
|
||||
|
||||
local now_h now_m now_s target_s wait_s
|
||||
now_h=$(date +%H)
|
||||
now_m=$(date +%M)
|
||||
now_h=$((10#$now_h))
|
||||
now_m=$((10#$now_m))
|
||||
now_s=$((now_h * 3600 + now_m * 60))
|
||||
target_s=$((target_h * 3600 + target_m * 60))
|
||||
|
||||
if ((now_s < target_s)); then
|
||||
wait_s=$((target_s - now_s))
|
||||
else
|
||||
wait_s=$((86400 - now_s + target_s))
|
||||
fi
|
||||
|
||||
log "Next rotation at ${rotate_at} (${TZ:-UTC}) in ${wait_s}s"
|
||||
sleep "$wait_s"
|
||||
}
|
||||
|
||||
# Matches rotate.sh EXIT_RATE_LIMITED (EX_TEMPFAIL)
|
||||
readonly EXIT_RATE_LIMITED=75
|
||||
|
||||
run_rotation() {
|
||||
local reason="$1"
|
||||
local wait_s="${RATE_LIMIT_WAIT_SECONDS:-3600}"
|
||||
local rc
|
||||
|
||||
while true; do
|
||||
log "$reason"
|
||||
rc=0
|
||||
/usr/local/bin/rotate.sh || rc=$?
|
||||
case "$rc" in
|
||||
0)
|
||||
return 0
|
||||
;;
|
||||
"$EXIT_RATE_LIMITED")
|
||||
log "PIA rate-limited (too many attempts); waiting ${wait_s}s before retry"
|
||||
sleep "$wait_s"
|
||||
reason="Retrying rotation after rate-limit wait"
|
||||
;;
|
||||
*)
|
||||
>&2 echo "Rotation failed with exit code $rc"
|
||||
return "$rc"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
log "Starting gluetun PIA WireGuard rotator (TZ=${TZ:-UTC}, ROTATE_AT=${ROTATE_AT:-03:00})"
|
||||
|
||||
run_rotation "Running rotation on startup"
|
||||
|
||||
while true; do
|
||||
sleep_until_next_rotate
|
||||
run_rotation "Running scheduled daily rotation"
|
||||
done
|
||||
Reference in New Issue
Block a user