From 0fb92bd2812cf873b161401936233addfa2e7a5e Mon Sep 17 00:00:00 2001 From: Rehpotsirhc <86068565+Rehpotsirhc-z@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:23:06 -0400 Subject: [PATCH] fix(gluetun): use X-API-Key and remove non-existing WireGuard endpoints The Gluetun client authenticated with `Authorization: Bearer `, but Gluetun reads the API key from the `X-API-Key` header. Thus the VPN card only worked when there was no key. Removed the `/v1/wireguard/status` and `/v1/wireguard/portforwarded` endpoints, which do not exist. The now-redundant Gluetun protocol selector is removed from the setup wizard and settings page. --- CHANGELOG.md | 5 ++ .../Controller/AdminSettingsController.php | 1 - symfony/src/Controller/SetupController.php | 1 - symfony/src/Service/Media/GluetunClient.php | 52 ++++++----- symfony/templates/setup/downloads.html.twig | 11 +-- .../tests/Service/Media/GluetunClientTest.php | 86 +++++++++++++++++++ .../translations/messages+intl-icu.en.yaml | 5 +- .../translations/messages+intl-icu.fr.yaml | 5 +- 8 files changed, 123 insertions(+), 43 deletions(-) create mode 100644 symfony/tests/Service/Media/GluetunClientTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e923b9f..9a96669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to Prismarr are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **Gluetun integration with API key set, and incorrect endpoints.** The Gluetun client authenticated using `Authorization: Bearer `, but Gluetun expects it as `X-API-Key`, so it would return a 401 error when an API key is required. Additionally, referring to the older [Control Server Docs](https://github.com/qdm12/gluetun-wiki/blob/7025b1c0e4427d4477e47d4bbd2ef3f1b5c4da71/setup/advanced/control-server.md#openvpn-and-wireguard), WireGuard doesn't get its own endpoint, so the `/v1/wireguard/status` and `/v1/wireguard/portforwarded` calls were incorrect. The client now sends `X-API-Key` and uses the unified `/v1/vpn/status` and `/v1/portforward` endpoints, with the legacy `/v1/openvpn/` paths as a fallback. With that, the protocol selector in the settings becomes redundant and was removed. + ## [1.1.1] - 2026-06-10 ### Fixed diff --git a/symfony/src/Controller/AdminSettingsController.php b/symfony/src/Controller/AdminSettingsController.php index cda45db..4513596 100644 --- a/symfony/src/Controller/AdminSettingsController.php +++ b/symfony/src/Controller/AdminSettingsController.php @@ -81,7 +81,6 @@ class AdminSettingsController extends AbstractController 'gluetun' => [ ['key' => 'gluetun_url', 'type' => 'text', 'label' => 'admin.field.url'], ['key' => 'gluetun_api_key', 'type' => 'password', 'label' => 'admin.field.api_key_if_protected'], - ['key' => 'gluetun_protocol', 'type' => 'text', 'label' => 'admin.field.protocol'], ], ]; diff --git a/symfony/src/Controller/SetupController.php b/symfony/src/Controller/SetupController.php index 0c9634e..3e852be 100644 --- a/symfony/src/Controller/SetupController.php +++ b/symfony/src/Controller/SetupController.php @@ -313,7 +313,6 @@ public function downloads(Request $request): Response 'qbittorrent_password' => '', 'gluetun_url' => '', 'gluetun_api_key' => '', - 'gluetun_protocol' => '', // Usenet download clients (optional, like qBittorrent above). 'sabnzbd_url' => '', 'sabnzbd_api_key' => '', diff --git a/symfony/src/Service/Media/GluetunClient.php b/symfony/src/Service/Media/GluetunClient.php index b9e03c8..891a499 100644 --- a/symfony/src/Service/Media/GluetunClient.php +++ b/symfony/src/Service/Media/GluetunClient.php @@ -31,7 +31,6 @@ class GluetunClient implements ResetInterface private ?string $baseUrl = null; private string $apiKey = ''; - private string $protocol = ''; private bool $configLoaded = false; public function __construct( @@ -44,7 +43,6 @@ private function ensureConfig(): void if ($this->configLoaded) return; $this->baseUrl = $this->config->get('gluetun_url'); $this->apiKey = $this->config->get('gluetun_api_key') ?? ''; - $this->protocol = $this->config->get('gluetun_protocol') ?? ''; $this->configLoaded = true; } @@ -53,7 +51,6 @@ public function reset(): void $this->configLoaded = false; $this->baseUrl = null; $this->apiKey = ''; - $this->protocol = ''; $this->publicIpCache = null; $this->publicIpCacheAt = 0.0; $this->statusCache = null; @@ -83,8 +80,9 @@ public function getPublicIp(): ?array /** * VPN status — 'running', 'stopped', 'crashed'. - * Uses /v1/vpn/status (protocol-agnostic, Gluetun v3.40+) then falls back to protocol-specific. - * 10s cache (avoids up to 3 sequential cURL requests on every /api/vpn). + * Uses the unified /v1/vpn/status (protocol-agnostic, Gluetun v3.40+), then + * falls back to the legacy /v1/openvpn/status for pre-v3.40 OpenVPN installs. + * 10s cache (avoids sequential cURL requests on every /api/vpn). */ public function getVpnStatus(): ?string { @@ -93,13 +91,7 @@ public function getVpnStatus(): ?string return $this->statusCache; } - $this->ensureConfig(); - $fallback = match (strtolower($this->protocol)) { - 'openvpn' => ['/v1/openvpn/status'], - 'wireguard' => ['/v1/wireguard/status'], - default => ['/v1/openvpn/status', '/v1/wireguard/status'], - }; - foreach (array_merge(['/v1/vpn/status'], $fallback) as $path) { + foreach ($this->statusPaths() as $path) { $data = $this->get($path); if ($data !== null && isset($data['status'])) { $this->statusCache = (string)$data['status']; @@ -110,10 +102,15 @@ public function getVpnStatus(): ?string return $this->statusCache; } + private function statusPaths(): array + { + return ['/v1/vpn/status', '/v1/openvpn/status']; + } + /** * Port forwarded by the VPN provider (the one Gluetun should push to qBit via port-update). - * Gluetun v3.40+ exposes /v1/portforward (protected by default — HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH config required). - * Falls back to the legacy /v1/openvpn/portforwarded and /v1/wireguard/portforwarded per GLUETUN_PROTOCOL. + * Gluetun v3.40+ exposes the unified /v1/portforward (protected by default — HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH config required). + * Falls back to the legacy /v1/openvpn/portforwarded endpoint. * 10s cache. */ public function getForwardedPort(): ?int @@ -123,13 +120,7 @@ public function getForwardedPort(): ?int return $this->portCache; } - $this->ensureConfig(); - $legacy = match (strtolower($this->protocol)) { - 'openvpn' => ['/v1/openvpn/portforwarded'], - 'wireguard' => ['/v1/wireguard/portforwarded'], - default => ['/v1/openvpn/portforwarded', '/v1/wireguard/portforwarded'], - }; - foreach (array_merge(['/v1/portforward'], $legacy) as $path) { + foreach ($this->portPaths() as $path) { $data = $this->get($path); if ($data !== null && isset($data['port'])) { $this->portCache = (int)$data['port']; @@ -140,6 +131,11 @@ public function getForwardedPort(): ?int return $this->portCache; } + private function portPaths(): array + { + return ['/v1/portforward', '/v1/openvpn/portforwarded']; + } + /** * Full aggregate ready for the UI. */ @@ -177,8 +173,9 @@ private function get(string $path): ?array CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, ]; - if ($this->apiKey !== '') { - $opts[CURLOPT_HTTPHEADER] = ['Authorization: Bearer ' . $this->apiKey]; + $headers = $this->authHeaders(); + if ($headers !== []) { + $opts[CURLOPT_HTTPHEADER] = $headers; } $ch = curl_init($url); curl_setopt_array($ch, $opts); @@ -192,4 +189,13 @@ private function get(string $path): ?array } return json_decode($body, true) ?: null; } + + /** + * cURL header list carrying the Gluetun API key, or [] when no key is set. + */ + private function authHeaders(): array + { + $this->ensureConfig(); + return $this->apiKey !== '' ? ['X-API-Key: ' . $this->apiKey] : []; + } } diff --git a/symfony/templates/setup/downloads.html.twig b/symfony/templates/setup/downloads.html.twig index deb2d13..966bdeb 100644 --- a/symfony/templates/setup/downloads.html.twig +++ b/symfony/templates/setup/downloads.html.twig @@ -130,20 +130,11 @@
{{ ico.icon('shield', '', 18) }} {{ 'setup.step.downloads.gluetun.section'|trans }} {{ 'setup.step.downloads.gluetun.section_suffix'|trans }}
{{ 'setup.step.downloads.gluetun.subtitle'|trans }}
-
+
-
- - -
`, but + * Gluetun reads the key from the `X-API-Key` header, so any configured key + * would not work and the integration only worked if the authentication was + * set to "none". + * 2. The endpoint fallbacks `/v1/wireguard/status` and + * `/v1/wireguard/portforwarded` don't exist. + */ +#[AllowMockObjectsWithoutExpectations] +class GluetunClientTest extends TestCase +{ + private function makeClient(?string $apiKey): GluetunClient + { + $config = $this->createMock(ConfigService::class); + $config->method('get')->willReturnMap([ + ['gluetun_url', 'http://gluetun:8000'], + ['gluetun_api_key', $apiKey], + ]); + + return new GluetunClient($config, $this->createMock(LoggerInterface::class)); + } + + private function invokePrivate(GluetunClient $client, string $method): mixed + { + return (new \ReflectionMethod($client, $method))->invoke($client); + } + + public function testAuthHeaderUsesXApiKeyNotBearer(): void + { + $headers = $this->invokePrivate($this->makeClient('s3cret-key'), 'authHeaders'); + + $this->assertSame(['X-API-Key: s3cret-key'], $headers); + $this->assertStringNotContainsStringIgnoringCase( + 'authorization', + implode("\n", $headers), + 'Gluetun does not accept Authorization: Bearer' + ); + } + + public function testNoAuthHeaderWhenKeyIsBlank(): void + { + foreach (['', null] as $blank) { + $headers = $this->invokePrivate($this->makeClient($blank), 'authHeaders'); + $this->assertSame([], $headers); + } + } + + public function testStatusPathsAreUnifiedFirstWithNoWireguardEndpoint(): void + { + $paths = $this->invokePrivate($this->makeClient(null), 'statusPaths'); + + $this->assertSame(['/v1/vpn/status', '/v1/openvpn/status'], $paths); + $this->assertNoWireguardPath($paths); + } + + public function testPortPathsAreUnifiedFirstWithNoWireguardEndpoint(): void + { + $paths = $this->invokePrivate($this->makeClient(null), 'portPaths'); + + $this->assertSame(['/v1/portforward', '/v1/openvpn/portforwarded'], $paths); + $this->assertNoWireguardPath($paths); + } + + private function assertNoWireguardPath(array $paths): void + { + foreach ($paths as $path) { + $this->assertStringNotContainsString( + '/wireguard/', + $path, + 'Gluetun does not have /v1/wireguard/* control endpoints.' + ); + } + } +} diff --git a/symfony/translations/messages+intl-icu.en.yaml b/symfony/translations/messages+intl-icu.en.yaml index 329a508..1130e8a 100644 --- a/symfony/translations/messages+intl-icu.en.yaml +++ b/symfony/translations/messages+intl-icu.en.yaml @@ -302,11 +302,9 @@ setup: section_suffix: 'VPN — optional' subtitle: 'Leave blank if qBittorrent does not route through a VPN.' url: URL (Control Server) - protocol: Protocol - protocol_auto: Auto api_key: API key api_key_optional: (optional) - api_key_placeholder: 'Bearer token if the Control Server is protected' + api_key_placeholder: 'X-API-Key value if the Control Server is protected' finish: eyebrow: 'Step 7 of 7' title: "You're all set!" @@ -701,7 +699,6 @@ admin: api_key: API key username: Username password: Password - protocol: 'Protocol (openvpn/wireguard)' api_key_if_protected: 'API key (if protected)' clear: Clear tmdb: diff --git a/symfony/translations/messages+intl-icu.fr.yaml b/symfony/translations/messages+intl-icu.fr.yaml index 769fb87..d456ac4 100644 --- a/symfony/translations/messages+intl-icu.fr.yaml +++ b/symfony/translations/messages+intl-icu.fr.yaml @@ -301,11 +301,9 @@ setup: section_suffix: 'VPN — optionnel' subtitle: 'Laissez vide si qBittorrent ne passe pas par un VPN.' url: URL (Control Server) - protocol: Protocole - protocol_auto: Auto api_key: Clé API api_key_optional: (optionnel) - api_key_placeholder: 'Bearer token si le Control Server est protégé' + api_key_placeholder: 'Valeur X-API-Key si le Control Server est protégé' finish: eyebrow: 'Étape 7 sur 7' title: 'Tout est prêt !' @@ -700,7 +698,6 @@ admin: api_key: Clé API username: Utilisateur password: Mot de passe - protocol: 'Protocole (openvpn/wireguard)' api_key_if_protected: 'Clé API (si protégé)' clear: Effacer tmdb: