LibrePortal/containers/gluetun/scripts/gluetun_providers.sh
librelad e168496824 perf(gluetun): throttle the provider-list fetch off the WebUI-update hot path
webuiLibrePortalUpdate runs the per-app refresh hooks on every update (the
task processor fires it repeatedly), and gluetun's hook silently pulls a
~7MB servers.json from GitHub each time. The hook's stdout is captured by
`result=$($_hook)`, so on a slow link the update just sits with no output
right after "Generated apps-tools.json..." — it reads as a freeze.

- Throttle the fetch to once per CFG_GLUETUN_PROVIDERS_REFRESH_HOURS (24h
  default; 0 = manual-only). Direct callers (Tools refresh, install hook)
  pass GLUETUN_PROVIDERS_FORCE=1 to still get a guaranteed pull.
- curl --compressed (this JSON gzips ~7x) + --connect-timeout 15 so a slow
  or dead link shrinks/fails fast instead of stalling every update.
- Print "Refreshing <app> WebUI data..." from the updater loop (outside the
  output capture) so the step is visible instead of looking hung.

Signed-off-by: librelad <librelad@digitalangels.vip>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 17:58:38 +01:00

123 lines
5.3 KiB
Bash

#!/bin/bash
# Per-app routine WebUI-refresh hook (appWebuiRefresh_<app>): webui_updater calls
# it on every WebUI update while gluetun is installed; the installer (gluetun.sh)
# and the 'gluetun_refresh_providers' tool call it directly too.
#
# Fetches gluetun's upstream servers.json, slims it down to a
# { providers: { <name>: { vpnTypes, countries } } } shape, and writes it
# to the WebUI data dir so the per-app config dropdowns stay honest as
# gluetun adds/removes providers and protocols. Falls back silently to the
# previous snapshot (or the bundled default) on network failure.
appWebuiRefresh_gluetun() {
local output_file="${containers_dir}libreportal/frontend/data/apps/generated/gluetun-providers.json"
local upstream="https://raw.githubusercontent.com/qdm12/gluetun/master/internal/storage/servers.json"
local tmp="$(mktemp)"
local raw="${output_file}.raw.$$"
runFileOp mkdir -p "$(dirname "$output_file")"
if ! command -v jq >/dev/null 2>&1; then
isNotice "jq not installed; skipping gluetun provider refresh."
return 0
fi
# Refresh throttle. The WebUI updater calls this hook on every routine
# update (the task processor fires it repeatedly), but the upstream server
# list is a few MB and changes rarely — re-fetching it each time adds a
# silent, slow-on-a-bad-link stall to every update. So only reach upstream
# once per refresh window. Direct callers that want a guaranteed pull (the
# Tools "refresh" button, the install hook) set GLUETUN_PROVIDERS_FORCE=1.
# CFG_GLUETUN_PROVIDERS_REFRESH_HOURS=0 disables auto-fetch (manual only).
local refresh_hours="${CFG_GLUETUN_PROVIDERS_REFRESH_HOURS:-24}"
local stamp="/tmp/libreportal_gluetun_providers_checked"
if [[ "${GLUETUN_PROVIDERS_FORCE:-0}" != "1" ]]; then
if [[ "$refresh_hours" == "0" ]]; then
return 0
fi
if [[ -s "$output_file" ]]; then
local _now _last
_now=$(date +%s)
_last=$(stat -c '%Y' "$stamp" 2>/dev/null || echo 0)
if (( _now - _last < refresh_hours * 3600 )); then
return 0
fi
fi
fi
# GitHub raw only sends ETag (no Last-Modified), so use If-None-Match
# via a sidecar to skip the multi-MB body when nothing has changed
# upstream. --compressed asks for gzip too (this JSON shrinks ~7x), and
# --connect-timeout fails fast on a dead link instead of hanging.
local etag_file="${output_file}.etag"
local etag=""
[[ -s "$etag_file" ]] && etag=$(<"$etag_file")
local headers="${output_file}.hdr.$$"
local http_code
http_code=$(curl -sSL --compressed \
${etag:+-H "If-None-Match: $etag"} \
--connect-timeout 15 \
--speed-limit 1000 --speed-time 30 \
--retry 2 --retry-delay 2 --retry-all-errors \
-D "$headers" -o "$raw" \
-w '%{http_code}' "$upstream") || http_code=""
# Whatever the outcome (fresh copy, 304, or a failed/slow link), record
# that we just checked so the throttle window restarts — a persistent
# outage then can't re-stall every subsequent update.
touch "$stamp" 2>/dev/null || true
# $raw and $headers live next to $output_file (under containers_dir/
# libreportal/frontend/data/, dockerinstall-owned in rootless). The
# manager can't `rm` them directly without a Permission denied — same
# class of bug as the updateConfigOption sed-i issue. runFileOp routes
# the rm through the right user. $tmp is from mktemp (/tmp), so
# `rm -f $tmp` stays unwrapped.
if [[ "$http_code" == "304" ]]; then
runFileOp rm -f "$raw" "$headers"
return 0
fi
if [[ "$http_code" != "200" ]]; then
isNotice "Upstream fetch failed (${http_code:-no response}); keeping existing snapshot."
runFileOp rm -f "$raw" "$headers"
return 0
fi
local new_etag
new_etag=$(awk 'tolower($1)=="etag:"{print $2}' "$headers" | tr -d '\r')
runFileOp rm -f "$headers"
# servers.json is a top-level object keyed by provider; each provider
# entry has a `servers` array whose items have `vpn` (wireguard|openvpn),
# `country`, `city`, etc. We collapse that into per-provider unique
# vpn-type and country lists. Drop the `version` key (it's not a provider).
if ! jq '
[ to_entries[]
| select(.key != "version")
| { key: .key,
value: {
vpnTypes: ((.value.servers // []) | map(.vpn) | unique | map(select(. != null and . != ""))),
countries: ((.value.servers // []) | map(.country) | unique | map(select(. != null and . != "")))
}
}
]
| from_entries
| { providers: . }
' "$raw" > "$tmp" 2>/dev/null; then
isNotice "Failed to parse gluetun servers.json; keeping existing provider snapshot."
runFileOp rm -f "$raw"
rm -f "$tmp"
return 0
fi
runFileOp rm -f "$raw"
if [ -s "$tmp" ]; then
runFileWrite "$output_file" < "$tmp"; rm -f "$tmp"
[[ -n "$new_etag" ]] && echo "$new_etag" | runFileWrite "$etag_file"
isSuccessful "Refreshed gluetun provider snapshot ($(jq '.providers | length' "$output_file") providers)."
else
rm -f "$tmp"
isNotice "Empty gluetun snapshot generated; ignoring."
fi
}