feat(wg): WgBridge — app side of the write-bridge (validate + request + result)
parent
a6bb8b7da4
commit
ed5506e558
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* The app side of the WireGuard write-bridge. `request()` writes a strictly-validated request to
|
||||
* run/wg-request.json (a host watcher runs it); `result()` reads the host-written result for an id
|
||||
* the app issued. Validation here is the FIRST gate — the host re-validates every value too.
|
||||
*/
|
||||
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'];
|
||||
|
||||
private function dir(): string
|
||||
{
|
||||
return storage_path('app/restart-signal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a write-request; returns the request id. Throws InvalidArgumentException on a bad
|
||||
* action or arg (defence-in-depth; the host re-validates).
|
||||
*
|
||||
* @param array<string,string> $args
|
||||
*/
|
||||
public function request(string $action, array $args = []): string
|
||||
{
|
||||
if (! in_array($action, self::ACTIONS, true)) {
|
||||
throw new \InvalidArgumentException('unknown action');
|
||||
}
|
||||
$clean = $this->validateArgs($action, $args);
|
||||
|
||||
$id = Str::random(32);
|
||||
$payload = array_merge(['id' => $id, 'action' => $action, 'at' => time()], $clean);
|
||||
|
||||
@mkdir($this->dir(), 0775, true);
|
||||
file_put_contents($this->dir().'/wg-request.json', json_encode($payload, JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the host result for an id THIS app issued. Returns null until the result exists.
|
||||
* Includes the QR sidecar SVG when present (add-peer). Never throws.
|
||||
*
|
||||
* @return array{ok:bool, message:string, config:?string, qr:?string}|null
|
||||
*/
|
||||
public function result(string $id): ?array
|
||||
{
|
||||
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $id) !== 1) {
|
||||
return null;
|
||||
}
|
||||
$file = $this->dir()."/wg-result-{$id}.json";
|
||||
if (! is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
$data = json_decode((string) @file_get_contents($file), true);
|
||||
if (! is_array($data) || ($data['id'] ?? null) !== $id) {
|
||||
return null;
|
||||
}
|
||||
$qrFile = $this->dir()."/wg-qr-{$id}.svg";
|
||||
$qr = is_file($qrFile) ? (string) @file_get_contents($qrFile) : null;
|
||||
|
||||
return [
|
||||
'ok' => (bool) ($data['ok'] ?? false),
|
||||
'message' => (string) ($data['message'] ?? ''),
|
||||
'config' => isset($data['config']) && is_string($data['config']) ? $data['config'] : null,
|
||||
'qr' => $qr,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $args
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private function validateArgs(string $action, array $args): array
|
||||
{
|
||||
$name = (string) ($args['name'] ?? '');
|
||||
switch ($action) {
|
||||
case 'add-peer':
|
||||
case 'remove-peer':
|
||||
if (preg_match('/^[A-Za-z0-9._-]{1,64}$/', $name) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid name');
|
||||
}
|
||||
|
||||
return ['name' => $name];
|
||||
case 'gate-up':
|
||||
case 'gate-down':
|
||||
return [];
|
||||
case 'set-endpoint':
|
||||
$ep = (string) ($args['endpoint'] ?? '');
|
||||
if (preg_match('/^[A-Za-z0-9.:_-]{1,128}$/', $ep) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid endpoint');
|
||||
}
|
||||
|
||||
return ['endpoint' => $ep];
|
||||
case 'set-port':
|
||||
$port = (string) ($args['port'] ?? '');
|
||||
if (preg_match('/^\d{1,5}$/', $port) !== 1 || (int) $port < 1 || (int) $port > 65535) {
|
||||
throw new \InvalidArgumentException('invalid port');
|
||||
}
|
||||
|
||||
return ['port' => $port];
|
||||
case 'set-subnet':
|
||||
$subnet = (string) ($args['subnet'] ?? '');
|
||||
if (preg_match('#^\d{1,3}(\.\d{1,3}){3}/\d{1,2}$#', $subnet) !== 1) {
|
||||
throw new \InvalidArgumentException('invalid subnet');
|
||||
}
|
||||
|
||||
return ['subnet' => $subnet];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Services\WgBridge;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WgBridgeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private string $dir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->dir = storage_path('app/restart-signal');
|
||||
@mkdir($this->dir, 0775, true);
|
||||
foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach (glob($this->dir.'/wg-*') ?: [] as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_request_writes_a_well_formed_request_and_returns_an_id(): void
|
||||
{
|
||||
$id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
|
||||
|
||||
$this->assertMatchesRegularExpression('/^[A-Za-z0-9]{16,64}$/', $id);
|
||||
$req = json_decode((string) file_get_contents($this->dir.'/wg-request.json'), true);
|
||||
$this->assertSame($id, $req['id']);
|
||||
$this->assertSame('add-peer', $req['action']);
|
||||
$this->assertSame('laptop', $req['name']);
|
||||
}
|
||||
|
||||
public function test_request_rejects_an_unknown_action(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
app(WgBridge::class)->request('rm -rf', ['name' => 'x']);
|
||||
}
|
||||
|
||||
public function test_request_rejects_an_invalid_name(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
app(WgBridge::class)->request('add-peer', ['name' => 'bad name; rm']);
|
||||
}
|
||||
|
||||
public function test_request_rejects_bad_port_and_subnet_and_endpoint(): void
|
||||
{
|
||||
$b = app(WgBridge::class);
|
||||
foreach ([['set-port', ['port' => '70000']], ['set-port', ['port' => 'abc']], ['set-subnet', ['subnet' => 'not-a-cidr']], ['set-endpoint', ['endpoint' => 'a b']]] as [$a, $args]) {
|
||||
try {
|
||||
$b->request($a, $args);
|
||||
$this->fail("expected rejection for $a");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function test_result_only_returns_for_the_matching_id(): void
|
||||
{
|
||||
$id = app(WgBridge::class)->request('add-peer', ['name' => 'laptop']);
|
||||
file_put_contents($this->dir."/wg-result-{$id}.json", json_encode(['id' => $id, 'ok' => true, 'message' => 'ok', 'config' => 'cfg', 'at' => time()]));
|
||||
|
||||
$this->assertNull(app(WgBridge::class)->result('AAAAAAAAAAAAAAAAdifferent'));
|
||||
$r = app(WgBridge::class)->result($id);
|
||||
$this->assertTrue($r['ok']);
|
||||
$this->assertSame('cfg', $r['config']);
|
||||
}
|
||||
|
||||
public function test_result_rejects_a_malformed_id_without_filesystem_access(): void
|
||||
{
|
||||
$this->assertNull(app(WgBridge::class)->result('../etc/passwd'));
|
||||
$this->assertNull(app(WgBridge::class)->result('x'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue