Skip to content

Commit 963653f

Browse files
committed
Merge PR #53: Gluetun X-API-Key auth + correct VPN endpoints
2 parents 57273d4 + 0fb92bd commit 963653f

8 files changed

Lines changed: 119 additions & 43 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111
- **Whole-server lockup on Unraid when the data volume sits on a FUSE share (`/mnt/user`)**. PHP's native file session handler holds an exclusive `flock` on the session file for the entire request, and the dashboard fires ~6 widget fragments in parallel, so they all serialised on that one lock while their slow Radarr/Sonarr calls ran. On Unraid the session file lives on the shfs/FUSE share, where `flock` contention is expensive enough to peg every core and freeze the whole machine (mapping the volume to `/mnt/cache` "fixed" it only by bypassing FUSE). A new `SessionLockReleaseSubscriber` now closes the session right after authentication on read-only GET requests, releasing the lock immediately so the parallel fragments stop fighting over it. POSTs, the setup wizard and internal routes keep the session open and write normally. Unraid users should still map the data volume to `/mnt/cache/...` rather than `/mnt/user/...`.
12+
- **Gluetun integration with API key set, and incorrect endpoints.** The Gluetun client authenticated using `Authorization: Bearer <key>`, 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.
1213

1314
## [1.1.1] - 2026-06-10
1415

symfony/src/Controller/AdminSettingsController.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,6 @@ class AdminSettingsController extends AbstractController
8181
'gluetun' => [
8282
['key' => 'gluetun_url', 'type' => 'text', 'label' => 'admin.field.url'],
8383
['key' => 'gluetun_api_key', 'type' => 'password', 'label' => 'admin.field.api_key_if_protected'],
84-
['key' => 'gluetun_protocol', 'type' => 'text', 'label' => 'admin.field.protocol'],
8584
],
8685
];
8786

symfony/src/Controller/SetupController.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,6 @@ public function downloads(Request $request): Response
313313
'qbittorrent_password' => '',
314314
'gluetun_url' => '',
315315
'gluetun_api_key' => '',
316-
'gluetun_protocol' => '',
317316
// Usenet download clients (optional, like qBittorrent above).
318317
'sabnzbd_url' => '',
319318
'sabnzbd_api_key' => '',

symfony/src/Service/Media/GluetunClient.php

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ class GluetunClient implements ResetInterface
3131

3232
private ?string $baseUrl = null;
3333
private string $apiKey = '';
34-
private string $protocol = '';
3534
private bool $configLoaded = false;
3635

3736
public function __construct(
@@ -44,7 +43,6 @@ private function ensureConfig(): void
4443
if ($this->configLoaded) return;
4544
$this->baseUrl = $this->config->get('gluetun_url');
4645
$this->apiKey = $this->config->get('gluetun_api_key') ?? '';
47-
$this->protocol = $this->config->get('gluetun_protocol') ?? '';
4846
$this->configLoaded = true;
4947
}
5048

@@ -53,7 +51,6 @@ public function reset(): void
5351
$this->configLoaded = false;
5452
$this->baseUrl = null;
5553
$this->apiKey = '';
56-
$this->protocol = '';
5754
$this->publicIpCache = null;
5855
$this->publicIpCacheAt = 0.0;
5956
$this->statusCache = null;
@@ -83,8 +80,9 @@ public function getPublicIp(): ?array
8380

8481
/**
8582
* VPN status — 'running', 'stopped', 'crashed'.
86-
* Uses /v1/vpn/status (protocol-agnostic, Gluetun v3.40+) then falls back to protocol-specific.
87-
* 10s cache (avoids up to 3 sequential cURL requests on every /api/vpn).
83+
* Uses the unified /v1/vpn/status (protocol-agnostic, Gluetun v3.40+), then
84+
* falls back to the legacy /v1/openvpn/status for pre-v3.40 OpenVPN installs.
85+
* 10s cache (avoids sequential cURL requests on every /api/vpn).
8886
*/
8987
public function getVpnStatus(): ?string
9088
{
@@ -93,13 +91,7 @@ public function getVpnStatus(): ?string
9391
return $this->statusCache;
9492
}
9593

96-
$this->ensureConfig();
97-
$fallback = match (strtolower($this->protocol)) {
98-
'openvpn' => ['/v1/openvpn/status'],
99-
'wireguard' => ['/v1/wireguard/status'],
100-
default => ['/v1/openvpn/status', '/v1/wireguard/status'],
101-
};
102-
foreach (array_merge(['/v1/vpn/status'], $fallback) as $path) {
94+
foreach ($this->statusPaths() as $path) {
10395
$data = $this->get($path);
10496
if ($data !== null && isset($data['status'])) {
10597
$this->statusCache = (string)$data['status'];
@@ -110,10 +102,15 @@ public function getVpnStatus(): ?string
110102
return $this->statusCache;
111103
}
112104

105+
private function statusPaths(): array
106+
{
107+
return ['/v1/vpn/status', '/v1/openvpn/status'];
108+
}
109+
113110
/**
114111
* Port forwarded by the VPN provider (the one Gluetun should push to qBit via port-update).
115-
* Gluetun v3.40+ exposes /v1/portforward (protected by default — HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH config required).
116-
* Falls back to the legacy /v1/openvpn/portforwarded and /v1/wireguard/portforwarded per GLUETUN_PROTOCOL.
112+
* Gluetun v3.40+ exposes the unified /v1/portforward (protected by default — HTTP_CONTROL_SERVER_AUTH_CONFIG_FILEPATH config required).
113+
* Falls back to the legacy /v1/openvpn/portforwarded endpoint.
117114
* 10s cache.
118115
*/
119116
public function getForwardedPort(): ?int
@@ -123,13 +120,7 @@ public function getForwardedPort(): ?int
123120
return $this->portCache;
124121
}
125122

126-
$this->ensureConfig();
127-
$legacy = match (strtolower($this->protocol)) {
128-
'openvpn' => ['/v1/openvpn/portforwarded'],
129-
'wireguard' => ['/v1/wireguard/portforwarded'],
130-
default => ['/v1/openvpn/portforwarded', '/v1/wireguard/portforwarded'],
131-
};
132-
foreach (array_merge(['/v1/portforward'], $legacy) as $path) {
123+
foreach ($this->portPaths() as $path) {
133124
$data = $this->get($path);
134125
if ($data !== null && isset($data['port'])) {
135126
$this->portCache = (int)$data['port'];
@@ -140,6 +131,11 @@ public function getForwardedPort(): ?int
140131
return $this->portCache;
141132
}
142133

134+
private function portPaths(): array
135+
{
136+
return ['/v1/portforward', '/v1/openvpn/portforwarded'];
137+
}
138+
143139
/**
144140
* Full aggregate ready for the UI.
145141
*/
@@ -177,8 +173,9 @@ private function get(string $path): ?array
177173
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
178174
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
179175
];
180-
if ($this->apiKey !== '') {
181-
$opts[CURLOPT_HTTPHEADER] = ['Authorization: Bearer ' . $this->apiKey];
176+
$headers = $this->authHeaders();
177+
if ($headers !== []) {
178+
$opts[CURLOPT_HTTPHEADER] = $headers;
182179
}
183180
$ch = curl_init($url);
184181
curl_setopt_array($ch, $opts);
@@ -192,4 +189,13 @@ private function get(string $path): ?array
192189
}
193190
return json_decode($body, true) ?: null;
194191
}
192+
193+
/**
194+
* cURL header list carrying the Gluetun API key, or [] when no key is set.
195+
*/
196+
private function authHeaders(): array
197+
{
198+
$this->ensureConfig();
199+
return $this->apiKey !== '' ? ['X-API-Key: ' . $this->apiKey] : [];
200+
}
195201
}

symfony/templates/setup/downloads.html.twig

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -130,20 +130,11 @@
130130
<div class="wizard-section-title">{{ ico.icon('shield', '', 18) }} {{ 'setup.step.downloads.gluetun.section'|trans }} <span class="text-muted" style="font-weight:400;font-size:.75rem;">{{ 'setup.step.downloads.gluetun.section_suffix'|trans }}</span></div>
131131
<div class="wizard-section-subtitle">{{ 'setup.step.downloads.gluetun.subtitle'|trans }}</div>
132132
<div class="row g-3">
133-
<div class="col-md-7">
133+
<div class="col-md-12">
134134
<label class="form-label" for="gluetun_url">{{ 'setup.step.downloads.gluetun.url'|trans }}</label>
135135
<input type="text" id="gluetun_url" name="gluetun_url" class="form-control"
136136
placeholder="http://host.docker.internal:8000" value="{{ values.gluetun_url }}"/>
137137
</div>
138-
<div class="col-md-5">
139-
<label class="form-label" for="gluetun_protocol">{{ 'setup.step.downloads.gluetun.protocol'|trans }}</label>
140-
<select id="gluetun_protocol" name="gluetun_protocol" class="form-select">
141-
{% set p = values.gluetun_protocol %}
142-
<option value="" {{ p == '' ? 'selected' }}>{{ 'setup.step.downloads.gluetun.protocol_auto'|trans }}</option>
143-
<option value="openvpn" {{ p == 'openvpn' ? 'selected' }}>OpenVPN</option>
144-
<option value="wireguard" {{ p == 'wireguard' ? 'selected' }}>WireGuard</option>
145-
</select>
146-
</div>
147138
<div class="col-md-12">
148139
<label class="form-label" for="gluetun_api_key">{{ 'setup.step.downloads.gluetun.api_key'|trans }} <span class="text-muted">{{ 'setup.step.downloads.gluetun.api_key_optional'|trans }}</span></label>
149140
<input type="text" id="gluetun_api_key" name="gluetun_api_key" class="form-control"
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace App\Tests\Service\Media;
4+
5+
use App\Service\ConfigService;
6+
use App\Service\Media\GluetunClient;
7+
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
8+
use PHPUnit\Framework\TestCase;
9+
use Psr\Log\LoggerInterface;
10+
11+
/**
12+
* Regression suite for the Gluetun control-server integration:
13+
*
14+
* 1. The client used to authenticate with `Authorization: Bearer <key>`, but
15+
* Gluetun reads the key from the `X-API-Key` header, so any configured key
16+
* would not work and the integration only worked if the authentication was
17+
* set to "none".
18+
* 2. The endpoint fallbacks `/v1/wireguard/status` and
19+
* `/v1/wireguard/portforwarded` don't exist.
20+
*/
21+
#[AllowMockObjectsWithoutExpectations]
22+
class GluetunClientTest extends TestCase
23+
{
24+
private function makeClient(?string $apiKey): GluetunClient
25+
{
26+
$config = $this->createMock(ConfigService::class);
27+
$config->method('get')->willReturnMap([
28+
['gluetun_url', 'http://gluetun:8000'],
29+
['gluetun_api_key', $apiKey],
30+
]);
31+
32+
return new GluetunClient($config, $this->createMock(LoggerInterface::class));
33+
}
34+
35+
private function invokePrivate(GluetunClient $client, string $method): mixed
36+
{
37+
return (new \ReflectionMethod($client, $method))->invoke($client);
38+
}
39+
40+
public function testAuthHeaderUsesXApiKeyNotBearer(): void
41+
{
42+
$headers = $this->invokePrivate($this->makeClient('s3cret-key'), 'authHeaders');
43+
44+
$this->assertSame(['X-API-Key: s3cret-key'], $headers);
45+
$this->assertStringNotContainsStringIgnoringCase(
46+
'authorization',
47+
implode("\n", $headers),
48+
'Gluetun does not accept Authorization: Bearer'
49+
);
50+
}
51+
52+
public function testNoAuthHeaderWhenKeyIsBlank(): void
53+
{
54+
foreach (['', null] as $blank) {
55+
$headers = $this->invokePrivate($this->makeClient($blank), 'authHeaders');
56+
$this->assertSame([], $headers);
57+
}
58+
}
59+
60+
public function testStatusPathsAreUnifiedFirstWithNoWireguardEndpoint(): void
61+
{
62+
$paths = $this->invokePrivate($this->makeClient(null), 'statusPaths');
63+
64+
$this->assertSame(['/v1/vpn/status', '/v1/openvpn/status'], $paths);
65+
$this->assertNoWireguardPath($paths);
66+
}
67+
68+
public function testPortPathsAreUnifiedFirstWithNoWireguardEndpoint(): void
69+
{
70+
$paths = $this->invokePrivate($this->makeClient(null), 'portPaths');
71+
72+
$this->assertSame(['/v1/portforward', '/v1/openvpn/portforwarded'], $paths);
73+
$this->assertNoWireguardPath($paths);
74+
}
75+
76+
private function assertNoWireguardPath(array $paths): void
77+
{
78+
foreach ($paths as $path) {
79+
$this->assertStringNotContainsString(
80+
'/wireguard/',
81+
$path,
82+
'Gluetun does not have /v1/wireguard/* control endpoints.'
83+
);
84+
}
85+
}
86+
}

symfony/translations/messages+intl-icu.en.yaml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,9 @@ setup:
302302
section_suffix: 'VPN — optional'
303303
subtitle: 'Leave blank if qBittorrent does not route through a VPN.'
304304
url: URL (Control Server)
305-
protocol: Protocol
306-
protocol_auto: Auto
307305
api_key: API key
308306
api_key_optional: (optional)
309-
api_key_placeholder: 'Bearer token if the Control Server is protected'
307+
api_key_placeholder: 'X-API-Key value if the Control Server is protected'
310308
finish:
311309
eyebrow: 'Step 7 of 7'
312310
title: "You're all set!"
@@ -701,7 +699,6 @@ admin:
701699
api_key: API key
702700
username: Username
703701
password: Password
704-
protocol: 'Protocol (openvpn/wireguard)'
705702
api_key_if_protected: 'API key (if protected)'
706703
clear: Clear
707704
tmdb:

symfony/translations/messages+intl-icu.fr.yaml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,11 +301,9 @@ setup:
301301
section_suffix: 'VPN — optionnel'
302302
subtitle: 'Laissez vide si qBittorrent ne passe pas par un VPN.'
303303
url: URL (Control Server)
304-
protocol: Protocole
305-
protocol_auto: Auto
306304
api_key: Clé API
307305
api_key_optional: (optionnel)
308-
api_key_placeholder: 'Bearer token si le Control Server est protégé'
306+
api_key_placeholder: 'Valeur X-API-Key si le Control Server est protégé'
309307
finish:
310308
eyebrow: 'Étape 7 sur 7'
311309
title: 'Tout est prêt !'
@@ -700,7 +698,6 @@ admin:
700698
api_key: Clé API
701699
username: Utilisateur
702700
password: Mot de passe
703-
protocol: 'Protocole (openvpn/wireguard)'
704701
api_key_if_protected: 'Clé API (si protégé)'
705702
clear: Effacer
706703
tmdb:

0 commit comments

Comments
 (0)