$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': case 'gate-ssh-on': case 'gate-ssh-off': 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 (! $this->validPort($port)) { 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]; case 'set-dns': $dns = (string) ($args['dns'] ?? ''); // one or more IPv4 addresses, comma/space separated if (preg_match('/^\d{1,3}(\.\d{1,3}){3}([,\s]+\d{1,3}(\.\d{1,3}){3})*$/', $dns) !== 1) { throw new \InvalidArgumentException('invalid dns'); } return ['dns' => $dns]; 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 (! $this->validPort($port)) { 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 []; } /** A valid 1–65535 UDP port given as a numeric string. */ private function validPort(string $port): bool { return preg_match('/^\d{1,5}$/', $port) === 1 && (int) $port >= 1 && (int) $port <= 65535; } }