feat(wg): set WireGuard up from the dashboard (setup form + bridge action)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
01778bde80
commit
8e05cf62ec
|
|
@ -33,6 +33,15 @@ class Index extends Component
|
|||
|
||||
public string $newSubnet = '';
|
||||
|
||||
// first-time setup form
|
||||
public string $setupSubnet = '10.99.0.0/24';
|
||||
|
||||
public string $setupPort = '51820';
|
||||
|
||||
public string $setupEndpoint = '';
|
||||
|
||||
public string $setupPeer = 'client-1';
|
||||
|
||||
/** id of an in-flight write-request we are polling for. */
|
||||
public ?string $pendingId = null;
|
||||
|
||||
|
|
@ -64,6 +73,35 @@ class Index extends Component
|
|||
$this->audit('wg.add-peer', $name);
|
||||
}
|
||||
|
||||
public function setupWg(WgBridge $bridge): void
|
||||
{
|
||||
$this->validate([
|
||||
'setupSubnet' => ['required', 'regex:#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#'],
|
||||
'setupPort' => ['required', 'regex:/^\d{1,5}$/'],
|
||||
'setupEndpoint' => ['nullable', 'regex:/^[A-Za-z0-9.:_-]{1,128}$/'],
|
||||
'setupPeer' => ['required', 'regex:/^[A-Za-z0-9._-]{1,64}$/'],
|
||||
], [
|
||||
'setupSubnet.regex' => __('wireguard.subnet_invalid'),
|
||||
'setupPort.regex' => __('wireguard.port_invalid'),
|
||||
'setupEndpoint.regex' => __('wireguard.endpoint_invalid'),
|
||||
'setupPeer.regex' => __('wireguard.peer_name_invalid'),
|
||||
]);
|
||||
if ((int) $this->setupPort < 1 || (int) $this->setupPort > 65535) {
|
||||
$this->addError('setupPort', __('wireguard.port_invalid'));
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->throttle()) {
|
||||
return;
|
||||
}
|
||||
$this->pendingId = $bridge->request('setup', [
|
||||
'subnet' => $this->setupSubnet, 'port' => $this->setupPort,
|
||||
'endpoint' => $this->setupEndpoint, 'name' => $this->setupPeer,
|
||||
]);
|
||||
$this->pendingAction = 'setup';
|
||||
$this->audit('wg.setup', $this->setupSubnet);
|
||||
}
|
||||
|
||||
/** Opens the wire-elements/modal confirm dialog for peer removal. */
|
||||
public function confirmRemovePeer(string $name): void
|
||||
{
|
||||
|
|
@ -264,11 +302,12 @@ class Index extends Component
|
|||
return;
|
||||
}
|
||||
$this->pendingId = null;
|
||||
if ($res['ok'] && $this->pendingAction === 'add-peer' && $res['config'] !== null) {
|
||||
if ($res['ok'] && in_array($this->pendingAction, ['add-peer', 'setup'], true) && $res['config'] !== null) {
|
||||
$this->resultConfig = $res['config'];
|
||||
$this->resultQr = $res['qr'];
|
||||
} elseif (! $res['ok']) {
|
||||
$this->dispatch('notify', message: __('wireguard.action_failed'), level: 'error');
|
||||
$msg = ($res['message'] ?? '') !== '' ? $res['message'] : __('wireguard.action_failed');
|
||||
$this->dispatch('notify', message: $msg, level: 'error');
|
||||
} else {
|
||||
$this->dispatch('notify', message: __('wireguard.action_done'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ use Illuminate\Support\Str;
|
|||
class WgBridge
|
||||
{
|
||||
/** Actions the UI may request; the host whitelists the same set. */
|
||||
public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet'];
|
||||
public const ACTIONS = ['add-peer', 'remove-peer', 'gate-up', 'gate-down', 'set-endpoint', 'set-port', 'set-subnet', 'setup'];
|
||||
|
||||
private function dir(): string
|
||||
{
|
||||
|
|
@ -110,6 +110,25 @@ class WgBridge
|
|||
}
|
||||
|
||||
return ['subnet' => $subnet];
|
||||
case 'setup':
|
||||
$subnet = (string) ($args['subnet'] ?? '');
|
||||
$port = (string) ($args['port'] ?? '');
|
||||
$endpoint = (string) ($args['endpoint'] ?? '');
|
||||
$name = (string) ($args['name'] ?? '');
|
||||
if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid subnet');
|
||||
}
|
||||
if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) {
|
||||
throw new \InvalidArgumentException('invalid port');
|
||||
}
|
||||
if ($endpoint !== '' && preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $endpoint) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid endpoint');
|
||||
}
|
||||
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid name');
|
||||
}
|
||||
|
||||
return ['subnet' => $subnet, 'port' => $port, 'endpoint' => $endpoint, 'name' => $name];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -68,4 +68,13 @@ return [
|
|||
'subnet_invalid' => 'Ungültiges Subnetz (CIDR).',
|
||||
'subnet_confirm_title' => 'Subnetz ändern?',
|
||||
'subnet_confirm_body' => 'Der Server bekommt eine neue Adresse und der Tunnel wird neu gestartet. ALLE bestehenden Peers verlieren den Zugang und müssen neu angelegt werden.',
|
||||
'setup_title' => 'WireGuard einrichten',
|
||||
'setup_intro' => 'Tunnel direkt hier einrichten. Das Gate bleibt danach AUS — das Panel ist weiter öffentlich, bis du es einschaltest.',
|
||||
'setup_subnet' => 'Subnetz',
|
||||
'setup_port' => 'Listen-Port (UDP)',
|
||||
'setup_endpoint' => 'Öffentlicher Endpoint (optional)',
|
||||
'setup_endpoint_hint' => 'Leer = automatisch erkannte öffentliche IP. Hinter NAT/Cloud-LB manuell setzen.',
|
||||
'setup_peer' => 'Name des ersten Clients',
|
||||
'setup_submit' => 'Einrichten',
|
||||
'setup_or_ssh' => 'Alternativ per SSH:',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -68,4 +68,13 @@ return [
|
|||
'subnet_invalid' => 'Invalid subnet (CIDR).',
|
||||
'subnet_confirm_title' => 'Change subnet?',
|
||||
'subnet_confirm_body' => 'The server gets a new address and the tunnel restarts. ALL existing peers lose access and must be re-created.',
|
||||
'setup_title' => 'Set up WireGuard',
|
||||
'setup_intro' => 'Set the tunnel up right here. The gate stays OFF afterwards — the panel remains public until you turn it on.',
|
||||
'setup_subnet' => 'Subnet',
|
||||
'setup_port' => 'Listen port (UDP)',
|
||||
'setup_endpoint' => 'Public endpoint (optional)',
|
||||
'setup_endpoint_hint' => 'Empty = auto-detected public IP. Behind NAT/cloud LB set it manually.',
|
||||
'setup_peer' => 'First client name',
|
||||
'setup_submit' => 'Set up',
|
||||
'setup_or_ssh' => 'Or over SSH:',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -24,9 +24,43 @@
|
|||
</div>
|
||||
|
||||
@if (! $status['configured'])
|
||||
<x-panel :title="__('wireguard.not_configured_title')">
|
||||
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.not_configured_body') }}</p>
|
||||
<pre class="mt-3 overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] text-ink-2">sudo clusev wg setup</pre>
|
||||
<x-panel :title="__('wireguard.setup_title')">
|
||||
<p class="text-sm leading-relaxed text-ink-2">{{ __('wireguard.setup_intro') }}</p>
|
||||
<form wire:submit="setupWg" class="mt-4 space-y-3">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_subnet') }}</label>
|
||||
<input wire:model="setupSubnet" type="text" maxlength="18" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
|
||||
@error('setupSubnet') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_port') }}</label>
|
||||
<input wire:model="setupPort" type="text" inputmode="numeric" maxlength="5" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
|
||||
@error('setupPort') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_endpoint') }}</label>
|
||||
<input wire:model="setupEndpoint" type="text" maxlength="128" placeholder="auto" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@error('setupEndpoint') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="font-mono text-[11px] text-ink-3">{{ __('wireguard.setup_peer') }}</label>
|
||||
<input wire:model="setupPeer" type="text" maxlength="64" class="mt-1 w-full rounded-md border border-line bg-inset px-3 py-1.5 font-mono text-sm text-ink focus:border-accent/40 focus:outline-none" />
|
||||
@error('setupPeer') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-ink-4">{{ __('wireguard.setup_endpoint_hint') }}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<x-btn variant="primary" type="submit" wire:loading.attr="disabled" wire:target="setupWg">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" />{{ __('wireguard.setup_submit') }}
|
||||
</x-btn>
|
||||
@if ($pendingId)
|
||||
<span class="font-mono text-[11px] text-ink-3" wire:poll.2s="pollResult">{{ __('wireguard.pending') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
<p class="mt-4 font-mono text-[11px] text-ink-4">{{ __('wireguard.setup_or_ssh') }}</p>
|
||||
<pre class="mt-1 overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] text-ink-2">sudo clusev wg setup</pre>
|
||||
</x-panel>
|
||||
@else
|
||||
@if ($status['stale'])
|
||||
|
|
|
|||
|
|
@ -82,4 +82,19 @@ class WgBridgeTest extends TestCase
|
|||
$this->assertNull(app(WgBridge::class)->result('../etc/passwd'));
|
||||
$this->assertNull(app(WgBridge::class)->result('x'));
|
||||
}
|
||||
|
||||
public function test_setup_request_validates_and_writes(): void
|
||||
{
|
||||
$id = app(WgBridge::class)->request('setup', ['subnet' => '10.99.0.0/24', 'port' => '51820', 'endpoint' => '1.2.3.4:51820', 'name' => 'client-1']);
|
||||
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id);
|
||||
$req = json_decode((string) file_get_contents(storage_path('app/restart-signal/wg-request.json')), true);
|
||||
$this->assertSame('setup', $req['action']);
|
||||
$this->assertSame('10.99.0.0/24', $req['subnet']);
|
||||
}
|
||||
|
||||
public function test_setup_request_rejects_a_bad_subnet(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
app(WgBridge::class)->request('setup', ['subnet' => 'nope', 'port' => '51820', 'name' => 'c']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class WireguardPageTest extends TestCase
|
|||
Livewire::test(Index::class)
|
||||
->assertOk()
|
||||
->assertViewHas('status', fn ($s) => $s['configured'] === false)
|
||||
->assertSee(__('wireguard.not_configured_title'));
|
||||
->assertSee(__('wireguard.setup_title'));
|
||||
}
|
||||
|
||||
public function test_renders_peers_when_configured(): void
|
||||
|
|
@ -143,4 +143,22 @@ class WireguardPageTest extends TestCase
|
|||
->assertSet('resultConfig', fn ($v) => str_contains((string) $v, 'PrivateKey'));
|
||||
@unlink(storage_path("app/restart-signal/wg-result-{$id}.json"));
|
||||
}
|
||||
|
||||
public function test_setup_writes_a_request_and_audits(): void
|
||||
{
|
||||
Livewire::test(Index::class)
|
||||
->set('setupSubnet', '10.99.0.0/24')->set('setupPort', '51820')->set('setupEndpoint', '1.2.3.4:51820')->set('setupPeer', 'client-1')
|
||||
->call('setupWg')
|
||||
->assertSet('pendingId', fn ($v) => is_string($v) && $v !== '');
|
||||
$this->assertTrue(AuditEvent::where('action', 'wg.setup')->exists());
|
||||
@unlink(storage_path('app/restart-signal/wg-request.json'));
|
||||
}
|
||||
|
||||
public function test_setup_rejects_a_bad_subnet(): void
|
||||
{
|
||||
Livewire::test(Index::class)
|
||||
->set('setupSubnet', 'nope')->set('setupPort', '51820')->set('setupPeer', 'c')
|
||||
->call('setupWg')
|
||||
->assertHasErrors('setupSubnet');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue