From ed5506e55808d7a6afbef23586977ce569318ba0 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 21 Jun 2026 00:32:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(wg):=20WgBridge=20=E2=80=94=20app=20side?= =?UTF-8?q?=20of=20the=20write-bridge=20(validate=20+=20request=20+=20resu?= =?UTF-8?q?lt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/WgBridge.php | 117 +++++++++++++++++++++++++++++++++ tests/Feature/WgBridgeTest.php | 85 ++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 app/Services/WgBridge.php create mode 100644 tests/Feature/WgBridgeTest.php diff --git a/app/Services/WgBridge.php b/app/Services/WgBridge.php new file mode 100644 index 0000000..7e10299 --- /dev/null +++ b/app/Services/WgBridge.php @@ -0,0 +1,117 @@ + $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 $args + * @return array + */ + 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 []; + } +} diff --git a/tests/Feature/WgBridgeTest.php b/tests/Feature/WgBridgeTest.php new file mode 100644 index 0000000..5437456 --- /dev/null +++ b/tests/Feature/WgBridgeTest.php @@ -0,0 +1,85 @@ +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')); + } +}