diff --git a/examples/ssh-tunnel-qt/.gitignore b/examples/ssh-tunnel-qt/.gitignore new file mode 100644 index 00000000..d11bf24b --- /dev/null +++ b/examples/ssh-tunnel-qt/.gitignore @@ -0,0 +1,4 @@ +*.o +/build/ +/ssh_tunnel_manager +/ssh_tunnel_manager.exe diff --git a/examples/ssh-tunnel-qt/README.md b/examples/ssh-tunnel-qt/README.md new file mode 100644 index 00000000..1f8220e0 --- /dev/null +++ b/examples/ssh-tunnel-qt/README.md @@ -0,0 +1,105 @@ +# TypePHP SSH Tunnel Manager(Qt) + +这是一个 TypePHP 桌面应用示例。Qt/C++ 只承担窗口、控件、UI 事件队列和 +`QProcess` 桥接;规则校验、CRUD、JSON 持久化、SSH 参数生成、自动启动和运行 +状态均由 TypePHP 实现。 + +## 功能 + +- 本地转发:把服务器可访问的目标端口映射到本地监听端口(OpenSSH `-L`)。 +- 远程转发:把本地可访问的目标端口映射到服务器监听端口(OpenSSH `-R`)。 +- SOCKS5:通过 SSH 服务器建立本地 SOCKS5 代理(OpenSSH `-D`)。 +- 新建、查看、编辑和删除规则。 +- 单条隧道启动、停止、状态展示和 SSH 输出日志。 +- 支持中文输入法组合事件(Linux IBus/Fcitx、Windows TSF/IMM)。 +- 表格列宽可拖动调整,刷新规则时不会重置列宽。 +- 映射端点分别显示为“本机地址”和“远程地址”;远程转发会按实际方向交换显示。 +- 状态列使用状态灯:运行中为绿色,已停止或错误为红色,过渡状态为橙色。 +- 日志按隧道独立保存在内存中,选择表格行时只显示该隧道最近 500 条日志;当前日志增量追加,切换隧道时才进行一次有限量重绘。 +- 可清理当前选中隧道的日志,不影响其他隧道或正在运行的 SSH 进程。 +- SOCKS5 连接失败日志会显示请求的域名或 IP 及端口,并过滤用于关联目标的 OpenSSH 调试输出。 +- 新建、编辑规则时可勾选“调试”,为该隧道输出完整的 OpenSSH DEBUG2 日志;该选项随规则持久化。 +- 启动/停止按钮随所选隧道状态联动,禁止重复启动或停止。 +- 可选私钥和 SSH 自定义端口。 +- 启动时检查全部持久化规则,未运行的隧道会自动启动。 +- JSON 配置原子写入、`fsync` 和双份备份,主配置损坏时自动恢复。 +- 使用 `QProcess(program, arguments)`,不经过 shell。 + +## 分层 + +```text +main.php / app/ + TypePHP:业务规则、CRUD、持久化、SSH argv、状态机 + │ +php-src/qt_tunnel.stub.php + 原生函数声明 + │ +cpp-src/qt_tunnel.cc + Qt Widgets、对话框、事件队列、QProcess +``` + +C++ 层不会拼接 SSH 命令,也不会读写配置文件。对话框提交的数据以事件返回给 +TypePHP,经过验证后才会写入仓库。 + +## 依赖 + +- TypePHP 编译器及其 PHP 8.4/Phpx 构建环境 +- Qt 6 Widgets 开发包 +- OpenSSH 客户端(运行时需要 `ssh` 位于 `PATH`) + +Ubuntu/Debian: + +```bash +sudo apt install qt6-base-dev openssh-client +``` + +## 构建 + +项目默认使用 Debian/Ubuntu 的 Qt 6 系统路径: + +```bash +php ../../bin/tpc.php project.yml --debug -o ssh_tunnel_manager +./ssh_tunnel_manager +``` + +Qt 安装在其他目录时,运行下面的命令获取参数,并相应调整 `project.yml`: + +```bash +pkg-config --cflags --libs Qt6Widgets +``` + +Windows 使用 Qt 6 MSVC SDK 时,将 `cxx-flags` 改为 Qt 的 `include`、 +`include/QtCore`、`include/QtGui`、`include/QtWidgets`,将 `ld-flags` 改为 +`Qt6Widgets.lib`、`Qt6Gui.lib`、`Qt6Core.lib`。部署时执行: + +```powershell +windeployqt ssh_tunnel_manager.exe +``` + +Windows 10/11 可在“可选功能”中安装 OpenSSH Client。 + +## 配置文件 + +- Linux/macOS:`$XDG_CONFIG_HOME/typephp/ssh-tunnel-manager.json`,未设置时使用 + `~/.config/typephp/ssh-tunnel-manager.json` +- Windows:`%APPDATA%\TypePHP\ssh-tunnel-manager.json` +- 测试或便携运行:通过 `TYPEPHP_SSH_TUNNEL_CONFIG` 指定完整路径 + +同目录下的 `.bak` 文件是完整镜像备份。读取主配置失败时,应用会验证备份并 +自动恢复主文件。所有成功保存的规则都会在下一次启动时自动建立,不再提供 +容易造成遗漏的单条“自动启动”开关。 + +配置中不保存密码。推荐使用 SSH 私钥和 `ssh-agent`;应用为避免弹出不可见的 +终端密码提示,固定启用 `BatchMode=yes`。 + +远程转发是否能监听非回环地址由服务器的 `sshd_config` 中 `GatewayPorts` +设置决定;本工具不会绕过服务器安全策略。 + +## 业务层测试 + +该测试不依赖 Qt,可以直接用 PHP 执行: + +```bash +php tests/domain_test.php +php tests/startup_test.php +``` diff --git a/examples/ssh-tunnel-qt/app/SshOutputParser.php b/examples/ssh-tunnel-qt/app/SshOutputParser.php new file mode 100644 index 00000000..c4f572a2 --- /dev/null +++ b/examples/ssh-tunnel-qt/app/SshOutputParser.php @@ -0,0 +1,79 @@ +> */ + private array $socksTargets = []; + + public function parse( + string $tunnelId, + string $line, + bool $isSocks5, + bool $showDebug = false + ): ?string + { + $line = trim($line); + if ($line === '' || !$isSocks5) { + return $line === '' ? null : $line; + } + + $matches = []; + if (preg_match( + '/^(?:debug\d+: )?channel (\d+): dynamic request: socks[45] host (.+) port (\d+) command \d+$/', + $line, + $matches + ) === 1) { + $channel = (string) $matches[1]; + $host = (string) $matches[2]; + $port = (string) $matches[3]; + $this->socksTargets[$tunnelId][$channel] = $this->formatEndpoint($host, $port); + return $showDebug ? $line : null; + } + + if (preg_match( + '/^(?:debug\d+: )?channel (\d+): open failed: (.+)$/', + $line, + $matches + ) === 1) { + $channel = (string) $matches[1]; + $reason = (string) $matches[2]; + $target = (string) ($this->socksTargets[$tunnelId][$channel] ?? ''); + unset($this->socksTargets[$tunnelId][$channel]); + if ($target !== '') { + return 'SOCKS5 连接失败:' . $target . '(' . $reason . ')'; + } + return $line; + } + + if (preg_match('/^debug\d+: channel (\d+): free:/', $line, $matches) === 1) { + unset($this->socksTargets[$tunnelId][(string) $matches[1]]); + return $showDebug ? $line : null; + } + + // -vv is enabled only to obtain the SOCKS destination. Do not expose + // unrelated OpenSSH handshake/channel diagnostics in the UI. + if (preg_match('/^debug\d+:/', $line) === 1) { + return $showDebug ? $line : null; + } + return $line; + } + + public function clear(string $tunnelId): void + { + unset($this->socksTargets[$tunnelId]); + } + + private function formatEndpoint(string $host, string $port): string + { + if (str_contains($host, ':') + && !str_starts_with($host, '[') + && !str_ends_with($host, ']')) { + return '[' . $host . ']:' . $port; + } + return $host . ':' . $port; + } +} diff --git a/examples/ssh-tunnel-qt/app/TunnelApplication.php b/examples/ssh-tunnel-qt/app/TunnelApplication.php new file mode 100644 index 00000000..78d2fb6c --- /dev/null +++ b/examples/ssh-tunnel-qt/app/TunnelApplication.php @@ -0,0 +1,162 @@ +repository = $repository; + $this->sshOutputParser = new SshOutputParser(); + $this->window = qt_tunnel_create('TypePHP SSH Tunnel Manager'); + } + + public function run(): int + { + $this->refresh(); + foreach ($this->repository->all() as $rule) { + // Every persisted rule is desired state. A newly started manager + // owns no SSH child process yet, so all rules must be reconciled. + $this->start($rule->id); + } + + while (qt_tunnel_is_open($this->window)) { + qt_tunnel_process_events($this->window); + while (true) { + $event = qt_tunnel_poll_event($this->window); + if ($event === []) { + break; + } + $this->handleEvent($event); + } + } + + foreach (array_keys($this->states) as $id) { + if (($this->states[$id] ?? 'stopped') !== 'stopped') { + qt_tunnel_stop_process($this->window, (string) $id); + } + } + qt_tunnel_destroy($this->window); + return 0; + } + + private function handleEvent(array $event): void + { + $type = (string) ($event['type'] ?? ''); + $id = (string) ($event['id'] ?? ''); + + try { + if ($type === 'create') { + $this->repository->create(new TunnelRule((array) $event['payload'])); + $this->refresh(); + } elseif ($type === 'update') { + if (($this->states[$id] ?? 'stopped') !== 'stopped') { + throw new RuntimeException('请先停止隧道再编辑'); + } + $payload = (array) $event['payload']; + $payload['id'] = $id; + $this->repository->update(new TunnelRule($payload)); + $this->refresh(); + } elseif ($type === 'delete') { + $this->stop($id); + $this->repository->delete($id); + unset($this->states[$id]); + $this->refresh(); + } elseif ($type === 'start') { + $this->start($id); + } elseif ($type === 'stop') { + $this->stop($id); + } elseif ($type === 'process_started') { + $this->states[$id] = 'running'; + $this->log($id, 'SSH 隧道已建立'); + $this->refresh(); + } elseif ($type === 'process_stopped') { + $this->states[$id] = 'stopped'; + $this->sshOutputParser->clear($id); + $this->log($id, 'SSH 进程已停止'); + $this->refresh(); + } elseif ($type === 'process_error') { + $this->states[$id] = 'error'; + $this->sshOutputParser->clear($id); + $message = (string) ($event['message'] ?? 'SSH 进程错误'); + $this->log($id, $message); + qt_tunnel_show_error($this->window, $message); + $this->refresh(); + } elseif ($type === 'process_output') { + $rule = $this->repository->find($id); + $message = $this->sshOutputParser->parse( + $id, + (string) ($event['message'] ?? ''), + $rule !== null && $rule->type === TunnelRule::TYPE_SOCKS5, + $rule !== null && $rule->debug + ); + if ($message !== null) { + $this->log($id, $message); + } + } + } catch (Throwable $error) { + qt_tunnel_show_error($this->window, $error->getMessage()); + } + } + + private function start(string $id): void + { + if (($this->states[$id] ?? 'stopped') === 'running' + || ($this->states[$id] ?? 'stopped') === 'starting') { + return; + } + $rule = $this->repository->find($id); + if ($rule === null) { + throw new RuntimeException('规则不存在:' . $id); + } + + $this->states[$id] = 'starting'; + $this->refresh(); + $this->log($id, '正在连接 ' . $rule->sshUser . '@' . $rule->sshHost); + if (!qt_tunnel_start_process($this->window, $id, 'ssh', $rule->sshArguments())) { + $this->states[$id] = 'error'; + $this->refresh(); + throw new RuntimeException('无法启动 ssh,请确认 OpenSSH 客户端已安装并位于 PATH'); + } + } + + private function stop(string $id): void + { + $state = (string) ($this->states[$id] ?? 'stopped'); + if ($state === 'stopped') { + return; + } + $this->states[$id] = 'stopping'; + qt_tunnel_stop_process($this->window, $id); + $this->refresh(); + } + + private function refresh(): void + { + $rows = []; + foreach ($this->repository->all() as $rule) { + $row = $rule->toArray(); + $row['type_label'] = $rule->typeLabel(); + $row['local_address_label'] = $rule->localAddressLabel(); + $row['remote_address_label'] = $rule->remoteAddressLabel(); + $row['server_label'] = $rule->sshUser . '@' . $rule->sshHost . ':' . $rule->sshPort; + $row['status'] = (string) ($this->states[$rule->id] ?? 'stopped'); + $rows[] = $row; + } + qt_tunnel_set_rules($this->window, $rows); + } + + private function log(string $id, string $message): void + { + if ($message !== '') { + qt_tunnel_append_log($this->window, $id, $message); + } + } +} diff --git a/examples/ssh-tunnel-qt/app/TunnelRepository.php b/examples/ssh-tunnel-qt/app/TunnelRepository.php new file mode 100644 index 00000000..a93f0b2e --- /dev/null +++ b/examples/ssh-tunnel-qt/app/TunnelRepository.php @@ -0,0 +1,234 @@ +file = $file; + } + + /** @return array */ + public function all(): array + { + $primaryError = null; + if (is_file($this->file)) { + try { + return $this->readRules($this->file); + } catch (Throwable $error) { + $primaryError = $error; + } + } + + $backup = $this->backupFile(); + if (is_file($backup)) { + try { + $rules = $this->readRules($backup); + $json = file_get_contents($backup); + if ($json === false) { + throw new RuntimeException('无法读取备份配置:' . $backup); + } + $this->ensureDirectory(); + $temporary = $this->file . '.recover.tmp'; + $this->writeDurably($temporary, $json); + $this->replaceFile($temporary, $this->file); + return $rules; + } catch (Throwable $backupError) { + if ($primaryError !== null) { + throw new RuntimeException( + '主配置和备份配置均已损坏:' + . $primaryError->getMessage() . ';' + . $backupError->getMessage() + ); + } + throw $backupError; + } + } + + if ($primaryError !== null) { + throw $primaryError; + } + return []; + } + + /** @return array */ + private function readRules(string $file): array + { + $json = file_get_contents($file); + if ($json === false || trim($json) === '') { + throw new RuntimeException('配置文件为空或无法读取:' . $file); + } + $rows = json_decode($json, true); + if (!is_array($rows)) { + throw new RuntimeException('配置文件不是有效的 JSON:' . $file); + } + + $rules = []; + foreach ($rows as $row) { + if (is_array($row)) { + $rules[] = new TunnelRule($row); + } + } + return $rules; + } + + public function find(string $id): ?TunnelRule + { + foreach ($this->all() as $rule) { + if ($rule->id === $id) { + return $rule; + } + } + return null; + } + + public function create(TunnelRule $rule): void + { + $rules = $this->all(); + foreach ($rules as $current) { + if ($current->id === $rule->id) { + throw new RuntimeException('规则 ID 已存在:' . $rule->id); + } + } + $rules[] = $rule; + $this->save($rules); + } + + public function update(TunnelRule $rule): void + { + $rules = $this->all(); + $found = false; + foreach ($rules as $index => $current) { + if ($current->id === $rule->id) { + $rules[$index] = $rule; + $found = true; + break; + } + } + if (!$found) { + throw new RuntimeException('规则不存在:' . $rule->id); + } + $this->save($rules); + } + + public function delete(string $id): void + { + $rules = []; + $found = false; + foreach ($this->all() as $rule) { + if ($rule->id === $id) { + $found = true; + } else { + $rules[] = $rule; + } + } + if (!$found) { + throw new RuntimeException('规则不存在:' . $id); + } + $this->save($rules); + } + + /** @param array $rules */ + private function save(array $rules): void + { + $this->ensureDirectory(); + + $rows = []; + foreach ($rules as $rule) { + $rows[] = $rule->toArray(); + } + $json = json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('无法序列化隧道配置'); + } + $json .= "\n"; + + // Keep a valid fallback before replacing the primary file. After the + // primary replacement succeeds, mirror the new version to the backup + // as well. At every interruption point at least one complete copy + // remains available. + $backupJson = $json; + if (is_file($this->file)) { + try { + $this->readRules($this->file); + $current = file_get_contents($this->file); + if ($current !== false) { + $backupJson = $current; + } + } catch (Throwable) { + // Never copy a corrupt primary over the valid backup. + } + } + + $backupTemporary = $this->backupFile() . '.tmp'; + $this->writeDurably($backupTemporary, $backupJson); + $this->replaceFile($backupTemporary, $this->backupFile()); + + $temporary = $this->file . '.tmp'; + $this->writeDurably($temporary, $json); + $this->replaceFile($temporary, $this->file); + + $this->writeDurably($backupTemporary, $json); + $this->replaceFile($backupTemporary, $this->backupFile()); + } + + private function ensureDirectory(): void + { + $directory = dirname($this->file); + if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException('无法创建配置目录:' . $directory); + } + } + + private function backupFile(): string + { + return $this->file . '.bak'; + } + + private function writeDurably(string $file, string $content): void + { + $handle = fopen($file, 'wb'); + if ($handle === false) { + throw new RuntimeException('无法打开配置文件:' . $file); + } + + $length = strlen($content); + $offset = 0; + while ($offset < $length) { + $written = fwrite($handle, substr($content, $offset)); + if ($written === false || $written === 0) { + fclose($handle); + throw new RuntimeException('无法完整写入配置:' . $file); + } + $offset += $written; + } + if (!fflush($handle)) { + fclose($handle); + throw new RuntimeException('无法刷新配置:' . $file); + } + if (function_exists('fsync') && !fsync($handle)) { + fclose($handle); + throw new RuntimeException('无法同步配置到磁盘:' . $file); + } + fclose($handle); + chmod($file, 0600); + } + + private function replaceFile(string $source, string $destination): void + { + // PHP's Windows rename cannot consistently replace an existing file + // across all supported runtimes. The durable backup written above + // makes this fallback recoverable. + if (PHP_OS_FAMILY === 'Windows' && is_file($destination) && !unlink($destination)) { + throw new RuntimeException('无法替换已有配置:' . $destination); + } + if (!rename($source, $destination)) { + throw new RuntimeException('无法替换配置:' . $destination); + } + } +} diff --git a/examples/ssh-tunnel-qt/app/TunnelRule.php b/examples/ssh-tunnel-qt/app/TunnelRule.php new file mode 100644 index 00000000..7e67cabb --- /dev/null +++ b/examples/ssh-tunnel-qt/app/TunnelRule.php @@ -0,0 +1,199 @@ +id = (string) ($data['id'] ?? ''); + $this->name = trim((string) ($data['name'] ?? '')); + $this->type = (string) ($data['type'] ?? self::TYPE_LOCAL); + $this->sshHost = trim((string) ($data['ssh_host'] ?? '')); + $this->sshPort = (int) ($data['ssh_port'] ?? 22); + $this->sshUser = trim((string) ($data['ssh_user'] ?? '')); + $this->identityFile = trim((string) ($data['identity_file'] ?? '')); + $this->debug = (bool) ($data['debug'] ?? false); + + if (array_key_exists('local_host', $data) || array_key_exists('remote_host', $data)) { + $this->localHost = trim((string) ($data['local_host'] ?? '127.0.0.1')); + $this->localPort = (int) ($data['local_port'] ?? 0); + $this->remoteHost = trim((string) ($data['remote_host'] ?? '')); + $this->remotePort = (int) ($data['remote_port'] ?? 0); + } elseif ($this->type === self::TYPE_REMOTE) { + // Migrate the old -R-oriented bind/target schema to endpoints as + // users understand them: target was local, bind was remote. + $this->localHost = trim((string) ($data['target_host'] ?? '127.0.0.1')); + $this->localPort = (int) ($data['target_port'] ?? 0); + $this->remoteHost = trim((string) ($data['bind_host'] ?? '127.0.0.1')); + $this->remotePort = (int) ($data['bind_port'] ?? 0); + } else { + // Local forwarding and SOCKS used bind as the local endpoint. + $this->localHost = trim((string) ($data['bind_host'] ?? '127.0.0.1')); + $this->localPort = (int) ($data['bind_port'] ?? 0); + $this->remoteHost = trim((string) ($data['target_host'] ?? '')); + $this->remotePort = (int) ($data['target_port'] ?? 0); + } + + if ($this->id === '') { + $this->id = str_replace('.', '', uniqid('rule_', true)); + } + $this->validate(); + } + + public function validate(): void + { + if ($this->name === '') { + throw new InvalidArgumentException('规则名称不能为空'); + } + if (!in_array($this->type, [self::TYPE_LOCAL, self::TYPE_REMOTE, self::TYPE_SOCKS5], true)) { + throw new InvalidArgumentException('不支持的隧道类型:' . $this->type); + } + if ($this->sshHost === '') { + throw new InvalidArgumentException('SSH 服务器不能为空'); + } + if ($this->sshUser === '') { + throw new InvalidArgumentException('SSH 用户不能为空'); + } + $this->assertPort($this->sshPort, 'SSH 端口'); + $this->assertPort($this->localPort, '本机端口'); + if ($this->localHost === '') { + throw new InvalidArgumentException('本机地址不能为空'); + } + if ($this->type !== self::TYPE_SOCKS5) { + if ($this->remoteHost === '') { + throw new InvalidArgumentException('远程地址不能为空'); + } + $this->assertPort($this->remotePort, '远程端口'); + } + } + + private function assertPort(int $port, string $field): void + { + if ($port < 1 || $port > 65535) { + throw new InvalidArgumentException($field . '必须在 1 到 65535 之间'); + } + } + + /** + * Generate arguments for QProcess. No shell is involved, so values such + * as file names and hosts are passed as individual arguments. + */ + public function sshArguments(): array + { + $arguments = [ + '-N', + '-T', + '-o', 'ExitOnForwardFailure=yes', + '-o', 'ServerAliveInterval=30', + '-o', 'ServerAliveCountMax=3', + '-o', 'BatchMode=yes', + '-p', (string) $this->sshPort, + ]; + + if ($this->identityFile !== '') { + $arguments[] = '-i'; + $arguments[] = $this->identityFile; + } + + if ($this->debug || $this->type === self::TYPE_SOCKS5) { + // SOCKS needs DEBUG2 internally so a failed channel can be + // correlated with its requested destination. + $arguments[] = '-vv'; + } + + if ($this->type === self::TYPE_LOCAL) { + $arguments[] = '-L'; + $arguments[] = $this->formatForwardHost($this->localHost) . ':' . $this->localPort + . ':' . $this->formatForwardHost($this->remoteHost) . ':' . $this->remotePort; + } elseif ($this->type === self::TYPE_REMOTE) { + $arguments[] = '-R'; + $arguments[] = $this->formatForwardHost($this->remoteHost) . ':' . $this->remotePort + . ':' . $this->formatForwardHost($this->localHost) . ':' . $this->localPort; + } else { + $arguments[] = '-D'; + $arguments[] = $this->formatForwardHost($this->localHost) . ':' . $this->localPort; + } + + $arguments[] = $this->sshUser . '@' . $this->sshHost; + return $arguments; + } + + private function formatForwardHost(string $host): string + { + if (str_contains($host, ':') + && !str_starts_with($host, '[') + && !str_ends_with($host, ']')) { + return '[' . $host . ']'; + } + return $host; + } + + public function typeLabel(): string + { + if ($this->type === self::TYPE_LOCAL) { + return '服务器端口 → 本地端口'; + } + if ($this->type === self::TYPE_REMOTE) { + return '本地端口 → 服务器端口'; + } + return '服务器 → 本地 SOCKS5 代理'; + } + + public function localAddressLabel(): string + { + return $this->displayAddress($this->localHost, $this->localPort); + } + + public function remoteAddressLabel(): string + { + if ($this->type === self::TYPE_SOCKS5) { + return $this->displayAddress($this->sshHost, $this->sshPort); + } + return $this->displayAddress($this->remoteHost, $this->remotePort); + } + + private function displayAddress(string $host, int $port): string + { + return $this->formatForwardHost($host) . ':' . $port; + } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'type' => $this->type, + 'ssh_host' => $this->sshHost, + 'ssh_port' => $this->sshPort, + 'ssh_user' => $this->sshUser, + 'identity_file' => $this->identityFile, + 'debug' => $this->debug, + 'local_host' => $this->localHost, + 'local_port' => $this->localPort, + 'remote_host' => $this->remoteHost, + 'remote_port' => $this->remotePort, + ]; + } +} diff --git a/examples/ssh-tunnel-qt/cpp-src/qt_tunnel.cc b/examples/ssh-tunnel-qt/cpp-src/qt_tunnel.cc new file mode 100644 index 00000000..9c3ee369 --- /dev/null +++ b/examples/ssh-tunnel-qt/cpp-src/qt_tunnel.cc @@ -0,0 +1,739 @@ +#include "phpx.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using php::Array; +using php::Bool; +using php::Box; +using php::String; +using php::var; +using php::Variant; + +namespace { + +static int qt_argc = 1; +static char qt_program_name[] = "typephp-ssh-tunnel-manager"; +static char *qt_argv[] = {qt_program_name, nullptr}; +static QApplication *qt_application = nullptr; + +QString toQString(const Variant &value) { + if (value.isNull() || value.isUndef()) { + return {}; + } + return QString::fromUtf8(value.toCString()); +} + +String toPhpString(const QString &value) { + const QByteArray utf8 = value.toUtf8(); + return String(utf8.constData(), static_cast(utf8.size())); +} + +struct RuleForm { + QString id; + QString name; + QString type = "local"; + QString sshHost; + int sshPort = 22; + QString sshUser; + QString identityFile; + bool debug = false; + QString localHost = "127.0.0.1"; + int localPort = 1080; + QString remoteHost = "127.0.0.1"; + int remotePort = 80; +}; + +RuleForm fromPhpRule(const Array &rule) { + RuleForm result; + result.id = toQString(rule.get("id")); + result.name = toQString(rule.get("name")); + result.type = toQString(rule.get("type")); + result.sshHost = toQString(rule.get("ssh_host")); + result.sshPort = static_cast(rule.get("ssh_port").toInt()); + result.sshUser = toQString(rule.get("ssh_user")); + result.identityFile = toQString(rule.get("identity_file")); + const Variant debug = rule.get("debug"); + result.debug = !debug.isNull() && !debug.isUndef() && debug.toBool(); + result.localHost = toQString(rule.get("local_host")); + result.localPort = static_cast(rule.get("local_port").toInt()); + result.remoteHost = toQString(rule.get("remote_host")); + result.remotePort = static_cast(rule.get("remote_port").toInt()); + return result; +} + +Array toPhpRule(const RuleForm &rule) { + Array result; + result.set("id", toPhpString(rule.id)); + result.set("name", toPhpString(rule.name)); + result.set("type", toPhpString(rule.type)); + result.set("ssh_host", toPhpString(rule.sshHost)); + result.set("ssh_port", rule.sshPort); + result.set("ssh_user", toPhpString(rule.sshUser)); + result.set("identity_file", toPhpString(rule.identityFile)); + result.set("debug", rule.debug); + result.set("local_host", toPhpString(rule.localHost)); + result.set("local_port", rule.localPort); + result.set("remote_host", toPhpString(rule.remoteHost)); + result.set("remote_port", rule.remotePort); + return result; +} + +class RuleDialog final : public QDialog { + public: + explicit RuleDialog(QWidget *parent, const RuleForm &initial) : QDialog(parent) { + setWindowTitle(initial.id.isEmpty() ? tr("新建 SSH 隧道") : tr("编辑 SSH 隧道")); + setMinimumWidth(520); + + name_ = new QLineEdit(initial.name); + type_ = new QComboBox(); + type_->addItem(tr("服务器端口映射为本地端口"), "local"); + type_->addItem(tr("本地端口映射为服务器端口"), "remote"); + type_->addItem(tr("服务器作为本地 SOCKS5 代理"), "socks5"); + const int typeIndex = type_->findData(initial.type); + type_->setCurrentIndex(typeIndex < 0 ? 0 : typeIndex); + + sshHost_ = new QLineEdit(initial.sshHost); + sshPort_ = portSpin(initial.sshPort); + sshUser_ = new QLineEdit(initial.sshUser); + identityFile_ = new QLineEdit(initial.identityFile); + debug_ = new QCheckBox(tr("输出 OpenSSH 调试信息")); + debug_->setChecked(initial.debug); + auto *identityBrowse = new QPushButton(tr("浏览…")); + auto *identityRow = new QWidget(); + auto *identityLayout = new QHBoxLayout(identityRow); + identityLayout->setContentsMargins(0, 0, 0, 0); + identityLayout->addWidget(identityFile_); + identityLayout->addWidget(identityBrowse); + + localHost_ = new QLineEdit(initial.localHost); + localPort_ = portSpin(initial.localPort); + remoteHost_ = new QLineEdit(initial.remoteHost); + remotePort_ = portSpin(initial.remotePort); + + // QLineEdit enables input methods by default, but setting the + // attribute explicitly is important when this widget is hosted by an + // embedded PHP runtime instead of QApplication::exec(). + const QList textInputs = {name_, sshHost_, sshUser_, identityFile_, localHost_, remoteHost_}; + for (QLineEdit *input : textInputs) { + input->setAttribute(Qt::WA_InputMethodEnabled, true); + input->setInputMethodHints(Qt::ImhNone); + } + + auto *form = new QFormLayout(); + form->addRow(tr("规则名称"), name_); + form->addRow(tr("映射类型"), type_); + form->addRow(tr("SSH 服务器"), sshHost_); + form->addRow(tr("SSH 端口"), sshPort_); + form->addRow(tr("SSH 用户"), sshUser_); + form->addRow(tr("私钥文件"), identityRow); + form->addRow(tr("调试"), debug_); + form->addRow(tr("本机地址"), localHost_); + form->addRow(tr("本机端口"), localPort_); + form->addRow(tr("远程地址"), remoteHost_); + form->addRow(tr("远程端口"), remotePort_); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Cancel); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(identityBrowse, &QPushButton::clicked, this, [this]() { + const QString path = QFileDialog::getOpenFileName(this, tr("选择 SSH 私钥"), identityFile_->text()); + if (!path.isEmpty()) { + identityFile_->setText(path); + } + }); + connect( + type_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { updateTargetFields(); }); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(form); + layout->addWidget(buttons); + updateTargetFields(); + } + + RuleForm value(const QString &id) const { + RuleForm result; + result.id = id; + result.name = name_->text().trimmed(); + result.type = type_->currentData().toString(); + result.sshHost = sshHost_->text().trimmed(); + result.sshPort = sshPort_->value(); + result.sshUser = sshUser_->text().trimmed(); + result.identityFile = identityFile_->text().trimmed(); + result.debug = debug_->isChecked(); + result.localHost = localHost_->text().trimmed(); + result.localPort = localPort_->value(); + result.remoteHost = remoteHost_->text().trimmed(); + result.remotePort = remotePort_->value(); + return result; + } + + private: + static QSpinBox *portSpin(int value) { + auto *spin = new QSpinBox(); + spin->setRange(1, 65535); + spin->setValue(value > 0 ? value : 1); + return spin; + } + + void updateTargetFields() { + const bool enabled = type_->currentData().toString() != "socks5"; + remoteHost_->setEnabled(enabled); + remotePort_->setEnabled(enabled); + } + + QLineEdit *name_; + QComboBox *type_; + QLineEdit *sshHost_; + QSpinBox *sshPort_; + QLineEdit *sshUser_; + QLineEdit *identityFile_; + QCheckBox *debug_; + QLineEdit *localHost_; + QSpinBox *localPort_; + QLineEdit *remoteHost_; + QSpinBox *remotePort_; +}; + +class TunnelWindowBox final : public Box { + public: + static QString tr(const char *text) { + return QObject::tr(text); + } + + explicit TunnelWindowBox(const QString &title) { + window_ = new QMainWindow(); + window_->setWindowTitle(title); + window_->resize(1120, 720); + + auto *central = new QWidget(); + auto *layout = new QVBoxLayout(central); + auto *titleLabel = new QLabel(tr("

SSH Tunnel Manager

" + "

规则和 SSH 参数由 TypePHP 管理,Qt 仅提供界面与进程桥接。

")); + layout->addWidget(titleLabel); + + table_ = new QTableWidget(0, 6); + table_->setHorizontalHeaderLabels( + {tr("名称"), tr("类型"), tr("本机地址"), tr("远程地址"), tr("SSH 服务器"), tr("状态")}); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + table_->setSelectionMode(QAbstractItemView::SingleSelection); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); + table_->verticalHeader()->setVisible(false); + // Interactive mode is required for resizing with the header handles. + // ResizeToContents and Stretch would continuously overwrite widths + // chosen by the user when TypePHP refreshes the rows. + table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Interactive); + table_->horizontalHeader()->setSectionsMovable(true); + table_->horizontalHeader()->setStretchLastSection(false); + table_->horizontalHeader()->setMinimumSectionSize(72); + table_->setColumnWidth(0, 170); + table_->setColumnWidth(1, 210); + table_->setColumnWidth(2, 190); + table_->setColumnWidth(3, 190); + table_->setColumnWidth(4, 220); + table_->setColumnWidth(5, 72); + + log_ = new QPlainTextEdit(); + log_->setReadOnly(true); + log_->setMaximumBlockCount(500); + log_->setPlaceholderText(tr("选择一条隧道后显示其日志")); + auto *logGroup = new QGroupBox(tr("所选隧道日志")); + auto *logLayout = new QVBoxLayout(logGroup); + logLayout->setContentsMargins(6, 10, 6, 6); + clearLogButton_ = new QPushButton(tr("清理日志")); + clearLogButton_->setEnabled(false); + logLayout->addWidget(log_); + auto *splitter = new QSplitter(Qt::Vertical); + splitter->addWidget(table_); + splitter->addWidget(logGroup); + splitter->setStretchFactor(0, 4); + splitter->setStretchFactor(1, 1); + layout->addWidget(splitter, 1); + + auto *add = new QPushButton(tr("新建")); + auto *edit = new QPushButton(tr("编辑")); + auto *remove = new QPushButton(tr("删除")); + startButton_ = new QPushButton(tr("启动")); + stopButton_ = new QPushButton(tr("停止")); + startButton_->setEnabled(false); + stopButton_->setEnabled(false); + auto *buttons = new QHBoxLayout(); + buttons->addWidget(add); + buttons->addWidget(edit); + buttons->addWidget(remove); + buttons->addWidget(clearLogButton_); + buttons->addStretch(); + buttons->addWidget(startButton_); + buttons->addWidget(stopButton_); + layout->addLayout(buttons); + + window_->setCentralWidget(central); + window_->statusBar()->showMessage(tr("就绪")); + + QObject::connect(add, &QPushButton::clicked, window_, [this]() { openCreateDialog(); }); + QObject::connect(edit, &QPushButton::clicked, window_, [this]() { openEditDialog(); }); + QObject::connect(remove, &QPushButton::clicked, window_, [this]() { + const QString id = selectedId(); + if (id.isEmpty()) { + showSelectionRequired(); + return; + } + if (QMessageBox::question(window_, tr("删除规则"), tr("确认删除选中的隧道规则?")) == QMessageBox::Yes) { + enqueue("delete", id); + } + }); + QObject::connect(startButton_, &QPushButton::clicked, window_, [this]() { + const QString id = selectedId(); + if (id.isEmpty()) { + showSelectionRequired(); + } else { + startButton_->setEnabled(false); + enqueue("start", id); + } + }); + QObject::connect(stopButton_, &QPushButton::clicked, window_, [this]() { + const QString id = selectedId(); + if (id.isEmpty()) { + showSelectionRequired(); + } else { + startButton_->setEnabled(false); + stopButton_->setEnabled(false); + enqueue("stop", id); + } + }); + QObject::connect(clearLogButton_, &QPushButton::clicked, window_, [this]() { + const QString id = selectedId(); + if (id.isEmpty()) { + return; + } + logs_.remove(id); + log_->clear(); + log_->setPlaceholderText(tr("该隧道暂时没有日志")); + clearLogButton_->setEnabled(false); + }); + QObject::connect(table_, &QTableWidget::cellDoubleClicked, window_, [this](int, int) { openEditDialog(); }); + QObject::connect(table_, &QTableWidget::itemSelectionChanged, window_, [this]() { + renderSelectedLog(); + updateActionButtons(); + }); + QObject::connect(window_, &QObject::destroyed, [this]() { window_ = nullptr; }); + + window_->show(); + } + + ~TunnelWindowBox() override { + cleanup(); + } + + bool isOpen() const { + return window_ != nullptr && window_->isVisible(); + } + + void processEvents() { + if (qt_application) { + // A short real Qt event loop keeps platform input-context socket + // notifiers alive (IBus/Fcitx on Linux and TSF/IMM on Windows). + // A one-shot processEvents() followed by PHP usleep() is not + // sufficient for reliable pre-edit/composition delivery. + QEventLoop eventLoop; + QTimer::singleShot(16, &eventLoop, &QEventLoop::quit); + eventLoop.exec(QEventLoop::AllEvents); + } + } + + Array pollEvent() { + if (events_.empty()) { + return {}; + } + Array result = events_.front(); + events_.pop_front(); + return result; + } + + void setRules(const Array &rules) { + const QString previousSelection = selectedId(); + const QSignalBlocker selectionBlocker(table_); + rules_.clear(); + statuses_.clear(); + table_->setRowCount(0); + int restoredRow = -1; + for (size_t index = 0; index < rules.count(); ++index) { + const Array row = rules.get(index).toArray(); + const RuleForm rule = fromPhpRule(row); + rules_.insert(rule.id, rule); + statuses_.insert(rule.id, toQString(row.get("status"))); + + const int tableRow = table_->rowCount(); + table_->insertRow(tableRow); + setCell(tableRow, 0, rule.name); + table_->item(tableRow, 0)->setData(Qt::UserRole, rule.id); + setCell(tableRow, 1, toQString(row.get("type_label"))); + setCell(tableRow, 2, toQString(row.get("local_address_label"))); + setCell(tableRow, 3, toQString(row.get("remote_address_label"))); + setCell(tableRow, 4, toQString(row.get("server_label"))); + setStatusCell(tableRow, 5, toQString(row.get("status"))); + if (rule.id == previousSelection) { + restoredRow = tableRow; + } + } + if (restoredRow >= 0) { + table_->selectRow(restoredRow); + } + renderSelectedLog(); + updateActionButtons(); + } + + bool startProcess(const QString &id, const QString &program, const Array &arguments) { + if (processes_.contains(id)) { + QProcess *existing = processes_.value(id); + if (existing && existing->state() != QProcess::NotRunning) { + return true; + } + processes_.remove(id); + processOutputBuffers_.remove(id); + if (existing) { + existing->deleteLater(); + } + } + + QStringList args; + for (size_t index = 0; index < arguments.count(); ++index) { + args.append(toQString(arguments.get(index))); + } + + auto *process = new QProcess(window_); + process->setProcessChannelMode(QProcess::MergedChannels); + processes_.insert(id, process); + QObject::connect(process, &QProcess::started, window_, [this, id]() { enqueue("process_started", id); }); + QObject::connect(process, &QProcess::readyRead, window_, [this, id, process]() { + enqueueProcessOutput(id, process, false); + }); + QObject::connect(process, + QOverload::of(&QProcess::finished), + window_, + [this, id, process](int exitCode, QProcess::ExitStatus status) { + enqueueProcessOutput(id, process, true); + const bool requestedStop = stopping_.remove(id); + if (!requestedStop && (status == QProcess::CrashExit || exitCode != 0)) { + enqueue("process_error", id, tr("ssh 已退出,退出码 %1").arg(exitCode)); + } else { + enqueue("process_stopped", id); + } + processes_.remove(id); + process->deleteLater(); + }); + QObject::connect(process, &QProcess::errorOccurred, window_, [this, id, process](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) { + enqueue("process_error", id, tr("无法启动 ssh,请检查 OpenSSH 客户端是否位于 PATH")); + processes_.remove(id); + process->deleteLater(); + } + }); + process->start(program, args); + return true; + } + + void stopProcess(const QString &id) { + QProcess *process = processes_.value(id, nullptr); + if (!process || process->state() == QProcess::NotRunning) { + enqueue("process_stopped", id); + return; + } + stopping_.insert(id); + process->terminate(); + if (!process->waitForFinished(1500)) { + process->kill(); + } + } + + void appendLog(const QString &id, const QString &message) { + QStringList &entries = logs_[id]; + const QString entry = QString("[%1] %2").arg(QDateTime::currentDateTime().toString("HH:mm:ss"), message); + entries.append(entry); + while (entries.size() > 500) { + entries.removeFirst(); + } + + if (selectedId() != id) { + return; + } + clearLogButton_->setEnabled(true); + if (displayedLogId_ != id) { + renderSelectedLog(); + return; + } + log_->appendPlainText(entry); + log_->moveCursor(QTextCursor::End); + } + + void showError(const QString &message) { + QMessageBox::critical(window_, tr("SSH Tunnel Manager"), message); + } + + void cleanup() { + const QList processList = processes_.values(); + processes_.clear(); + for (QProcess *process : processList) { + if (process && process->state() != QProcess::NotRunning) { + process->terminate(); + if (!process->waitForFinished(500)) { + process->kill(); + process->waitForFinished(500); + } + } + } + stopping_.clear(); + if (window_) { + delete window_; + window_ = nullptr; + } + } + + private: + static QString statusLabel(const QString &status) { + if (status == "running") { + return QObject::tr("运行中"); + } + if (status == "starting") { + return QObject::tr("启动中"); + } + if (status == "stopping") { + return QObject::tr("停止中"); + } + if (status == "error") { + return QObject::tr("错误"); + } + return QObject::tr("已停止"); + } + + void setCell(int row, int column, const QString &text) { + table_->setItem(row, column, new QTableWidgetItem(text)); + } + + void setStatusCell(int row, int column, const QString &status) { + auto *item = new QTableWidgetItem(QStringLiteral("●")); + item->setTextAlignment(Qt::AlignCenter); + QFont font = item->font(); + font.setPointSize(16); + item->setFont(font); + + if (status == "running") { + item->setForeground(QColor("#22c55e")); + } else if (status == "starting" || status == "stopping") { + item->setForeground(QColor("#f59e0b")); + } else { + item->setForeground(QColor("#ef4444")); + } + item->setToolTip(statusLabel(status)); + table_->setItem(row, column, item); + } + + QString selectedId() const { + const auto selection = table_->selectionModel()->selectedRows(); + if (selection.isEmpty()) { + return {}; + } + QTableWidgetItem *item = table_->item(selection.first().row(), 0); + return item ? item->data(Qt::UserRole).toString() : QString(); + } + + void renderSelectedLog() { + const QString id = selectedId(); + if (id == displayedLogId_) { + return; + } + displayedLogId_ = id; + log_->clear(); + if (id.isEmpty()) { + log_->setPlaceholderText(tr("选择一条隧道后显示其日志")); + return; + } + log_->setPlaceholderText(tr("该隧道暂时没有日志")); + const auto entries = logs_.constFind(id); + if (entries != logs_.constEnd() && !entries->isEmpty()) { + log_->setPlainText(entries->join('\n')); + log_->moveCursor(QTextCursor::End); + } + } + + void updateActionButtons() { + const QString id = selectedId(); + if (id.isEmpty()) { + startButton_->setEnabled(false); + stopButton_->setEnabled(false); + clearLogButton_->setEnabled(false); + return; + } + + const auto logEntries = logs_.constFind(id); + clearLogButton_->setEnabled(logEntries != logs_.constEnd() && !logEntries->isEmpty()); + const QString status = statuses_.value(id, "stopped"); + if (status == "running" || status == "starting") { + startButton_->setEnabled(false); + stopButton_->setEnabled(true); + } else if (status == "stopping") { + startButton_->setEnabled(false); + stopButton_->setEnabled(false); + } else { + startButton_->setEnabled(true); + stopButton_->setEnabled(false); + } + } + + void showSelectionRequired() { + QMessageBox::information(window_, tr("SSH Tunnel Manager"), tr("请先选择一条规则")); + } + + void openCreateDialog() { + RuleDialog dialog(window_, RuleForm{}); + if (dialog.exec() == QDialog::Accepted) { + enqueue("create", {}, {}, toPhpRule(dialog.value({}))); + } + } + + void openEditDialog() { + const QString id = selectedId(); + if (id.isEmpty()) { + showSelectionRequired(); + return; + } + RuleDialog dialog(window_, rules_.value(id)); + if (dialog.exec() == QDialog::Accepted) { + enqueue("update", id, {}, toPhpRule(dialog.value(id))); + } + } + + void enqueue(const QString &type, const QString &id = {}, const QString &message = {}, const Array &payload = {}) { + Array event; + event.set("type", toPhpString(type)); + if (!id.isEmpty()) { + event.set("id", toPhpString(id)); + } + if (!message.isEmpty()) { + event.set("message", toPhpString(message)); + } + if (payload.count() > 0) { + event.set("payload", payload); + } + events_.push_back(event); + } + + void enqueueProcessOutput(const QString &id, QProcess *process, bool flush) { + QByteArray &pending = processOutputBuffers_[id]; + pending.append(process->readAll()); + qsizetype newline = -1; + while ((newline = pending.indexOf('\n')) >= 0) { + QByteArray line = pending.left(newline); + pending.remove(0, newline + 1); + if (line.endsWith('\r')) { + line.chop(1); + } + if (!line.isEmpty()) { + enqueue("process_output", id, QString::fromUtf8(line)); + } + } + if (flush) { + if (!pending.isEmpty()) { + enqueue("process_output", id, QString::fromUtf8(pending)); + } + processOutputBuffers_.remove(id); + } + } + + QMainWindow *window_ = nullptr; + QTableWidget *table_ = nullptr; + QPlainTextEdit *log_ = nullptr; + QPushButton *startButton_ = nullptr; + QPushButton *stopButton_ = nullptr; + QPushButton *clearLogButton_ = nullptr; + QHash rules_; + QHash statuses_; + QHash processes_; + QHash processOutputBuffers_; + QHash logs_; + QString displayedLogId_; + QSet stopping_; + std::deque events_; +}; + +TunnelWindowBox *windowBox(var box) { + return box.toBox(); +} + +} // namespace + +var php_qt_tunnel_create(String title) { + if (!qt_application) { + qt_application = new QApplication(qt_argc, qt_argv); + qt_application->setApplicationName("TypePHP SSH Tunnel Manager"); + qt_application->setOrganizationName("TypePHP"); + } + return {new TunnelWindowBox(toQString(title))}; +} + +Bool php_qt_tunnel_is_open(var box) { + return windowBox(box)->isOpen(); +} + +void php_qt_tunnel_process_events(var box) { + windowBox(box)->processEvents(); +} + +Array php_qt_tunnel_poll_event(var box) { + return windowBox(box)->pollEvent(); +} + +void php_qt_tunnel_set_rules(var box, Array rules) { + windowBox(box)->setRules(rules); +} + +Bool php_qt_tunnel_start_process(var box, String id, String program, Array arguments) { + return windowBox(box)->startProcess(toQString(id), toQString(program), arguments); +} + +void php_qt_tunnel_stop_process(var box, String id) { + windowBox(box)->stopProcess(toQString(id)); +} + +void php_qt_tunnel_append_log(var box, String id, String message) { + windowBox(box)->appendLog(toQString(id), toQString(message)); +} + +void php_qt_tunnel_show_error(var box, String message) { + windowBox(box)->showError(toQString(message)); +} + +void php_qt_tunnel_destroy(var box) { + windowBox(box)->cleanup(); +} diff --git a/examples/ssh-tunnel-qt/main.php b/examples/ssh-tunnel-qt/main.php new file mode 100644 index 00000000..b54267a0 --- /dev/null +++ b/examples/ssh-tunnel-qt/main.php @@ -0,0 +1,39 @@ +run(); + } catch (Throwable $error) { + fwrite(STDERR, 'SSH Tunnel Manager: ' . $error->getMessage() . PHP_EOL); + return 1; + } +} diff --git a/examples/ssh-tunnel-qt/php-src/qt_tunnel.stub.php b/examples/ssh-tunnel-qt/php-src/qt_tunnel.stub.php new file mode 100644 index 00000000..25ad4357 --- /dev/null +++ b/examples/ssh-tunnel-qt/php-src/qt_tunnel.stub.php @@ -0,0 +1,18 @@ + 'Example', + 'type' => TunnelRule::TYPE_LOCAL, + 'ssh_host' => 'gateway.example.com', + 'ssh_port' => 22, + 'ssh_user' => 'deploy', + 'identity_file' => '/keys/id_ed25519', + 'local_host' => '127.0.0.1', + 'local_port' => 3307, + 'remote_host' => 'db.internal', + 'remote_port' => 3306, + ], $overrides)); +} + +$local = make_rule([]); +assert_same( + [ + '-N', '-T', + '-o', 'ExitOnForwardFailure=yes', + '-o', 'ServerAliveInterval=30', + '-o', 'ServerAliveCountMax=3', + '-o', 'BatchMode=yes', + '-p', '22', + '-i', '/keys/id_ed25519', + '-L', '127.0.0.1:3307:db.internal:3306', + 'deploy@gateway.example.com', + ], + $local->sshArguments(), + 'local forwarding arguments' +); + +$remote = make_rule([ + 'type' => TunnelRule::TYPE_REMOTE, + 'local_host' => '127.0.0.1', + 'local_port' => 3000, + 'remote_host' => '127.0.0.1', + 'remote_port' => 8080, +]); +assert_same( + '-R', + $remote->sshArguments()[14], + 'remote forwarding uses -R' +); +assert_same( + '127.0.0.1:8080:127.0.0.1:3000', + $remote->sshArguments()[15], + 'remote forwarding mapping' +); + +$socks = make_rule([ + 'type' => TunnelRule::TYPE_SOCKS5, + 'local_port' => 1080, + 'remote_host' => '', + 'remote_port' => 0, + 'identity_file' => '', +]); +$socksArguments = $socks->sshArguments(); +assert_same('-vv', $socksArguments[12], 'SOCKS5 enables destination diagnostics'); +assert_same('-D', $socksArguments[13], 'SOCKS5 uses -D'); +assert_same('127.0.0.1:1080', $socksArguments[14], 'SOCKS5 bind address'); + +$outputParser = new SshOutputParser(); +assert_same( + null, + $outputParser->parse( + 'socks-rule', + 'debug2: channel 53: dynamic request: socks5 host missing.example port 443 command 1', + true + ), + 'SOCKS5 destination diagnostic is retained but hidden' +); +assert_same( + 'SOCKS5 连接失败:missing.example:443(connect failed: Name or service not known)', + $outputParser->parse( + 'socks-rule', + 'channel 53: open failed: connect failed: Name or service not known', + true + ), + 'SOCKS5 failure includes domain and port' +); +assert_same( + null, + $outputParser->parse( + 'socks-rule', + 'debug2: channel 40: dynamic request: socks5 host 2001:db8::10 port 8443 command 1', + true + ), + 'IPv6 SOCKS5 destination is retained' +); +assert_same( + 'SOCKS5 连接失败:[2001:db8::10]:8443(connect failed: Connection timed out)', + $outputParser->parse( + 'socks-rule', + 'channel 40: open failed: connect failed: Connection timed out', + true + ), + 'SOCKS5 failure formats IPv6 endpoint' +); +assert_same( + 'channel 9: open failed: connect failed: Connection refused', + $outputParser->parse( + 'socks-rule', + 'channel 9: open failed: connect failed: Connection refused', + true + ), + 'unmatched channel failure remains visible' +); +assert_same( + 'ordinary ssh error', + $outputParser->parse('local-rule', 'ordinary ssh error', false), + 'non-SOCKS output remains unchanged' +); +assert_same( + 'debug2: channel 7: pre_dynamic: have 12', + $outputParser->parse( + 'debug-socks-rule', + 'debug2: channel 7: pre_dynamic: have 12', + true, + true + ), + 'enabled debug output remains visible' +); + +$debugLocal = make_rule(['debug' => true]); +assert_same( + '-vv', + $debugLocal->sshArguments()[14], + 'debug checkbox enables OpenSSH DEBUG2 output for non-SOCKS rules' +); +assert_same(true, $debugLocal->toArray()['debug'], 'debug setting is persisted'); + +$ipv6 = make_rule([ + 'local_host' => '::1', + 'remote_host' => '2001:db8::10', +]); +assert_same( + '[::1]:3307:[2001:db8::10]:3306', + $ipv6->sshArguments()[15], + 'IPv6 forwarding hosts use OpenSSH brackets' +); +assert_same('[::1]:3307', $ipv6->localAddressLabel(), 'local forwarding local address'); +assert_same('[2001:db8::10]:3306', $ipv6->remoteAddressLabel(), 'local forwarding remote address'); +assert_same('127.0.0.1:3000', $remote->localAddressLabel(), 'remote forwarding local address'); +assert_same('127.0.0.1:8080', $remote->remoteAddressLabel(), 'remote forwarding remote address'); +assert_same('127.0.0.1:1080', $socks->localAddressLabel(), 'SOCKS5 local address'); +assert_same('gateway.example.com:22', $socks->remoteAddressLabel(), 'SOCKS5 gateway address'); + +$legacyRemote = new TunnelRule([ + 'name' => 'Legacy remote rule', + 'type' => TunnelRule::TYPE_REMOTE, + 'ssh_host' => 'gateway.example.com', + 'ssh_port' => 22, + 'ssh_user' => 'deploy', + 'bind_host' => '0.0.0.0', + 'bind_port' => 9000, + 'target_host' => '127.0.0.1', + 'target_port' => 9001, +]); +assert_same('127.0.0.1:9001', $legacyRemote->localAddressLabel(), 'legacy target migrates to local'); +assert_same('0.0.0.0:9000', $legacyRemote->remoteAddressLabel(), 'legacy bind migrates to remote'); + +$temporary = sys_get_temp_dir() . '/typephp-ssh-tunnel-' . uniqid('', true) . '.json'; +$repository = new TunnelRepository($temporary); +$repository->create($local); +assert_same(1, count($repository->all()), 'create'); +assert_same(true, is_file($temporary . '.bak'), 'durable backup created'); + +$updated = make_rule(array_merge($local->toArray(), ['name' => 'Updated'])); +$repository->update($updated); +assert_same('Updated', $repository->find($local->id)?->name, 'read/update'); + +$repository->delete($local->id); +assert_same([], $repository->all(), 'delete'); + +file_put_contents($temporary, '{corrupt json'); +assert_same([], $repository->all(), 'corrupt primary recovered from backup'); +assert_same([], json_decode((string) file_get_contents($temporary), true), 'primary restored from backup'); + +unlink($temporary); +unlink($temporary . '.bak'); + +$invalidRejected = false; +try { + make_rule(['local_port' => 70000]); +} catch (InvalidArgumentException) { + $invalidRejected = true; +} +assert_same(true, $invalidRejected, 'invalid port rejected'); + +echo "ssh-tunnel domain tests passed\n"; diff --git a/examples/ssh-tunnel-qt/tests/startup_test.php b/examples/ssh-tunnel-qt/tests/startup_test.php new file mode 100644 index 00000000..dbcec432 --- /dev/null +++ b/examples/ssh-tunnel-qt/tests/startup_test.php @@ -0,0 +1,82 @@ + $id, + 'name' => $id, + 'type' => $type, + 'ssh_host' => 'gateway.example.com', + 'ssh_port' => 22, + 'ssh_user' => 'deploy', + 'identity_file' => '', + 'bind_host' => '127.0.0.1', + 'bind_port' => $type === TunnelRule::TYPE_SOCKS5 ? 1080 : 8080, + 'target_host' => '127.0.0.1', + 'target_port' => 3000, + // Legacy configurations may still contain this field. It must no + // longer disable startup. + 'auto_start' => false, + ]); +} + +$file = sys_get_temp_dir() . '/typephp-ssh-startup-' . uniqid('', true) . '.json'; +$repository = new TunnelRepository($file); +$repository->create(startup_rule('first', TunnelRule::TYPE_LOCAL)); +$repository->create(startup_rule('second', TunnelRule::TYPE_SOCKS5)); + +$application = new TunnelApplication($repository); +$result = $application->run(); + +sort($startedRuleIds); +if ($result !== 0 || $startedRuleIds !== ['first', 'second'] || count($renderedRows) !== 2) { + throw new RuntimeException('all persisted tunnel rules must start during application startup'); +} + +unlink($file); +unlink($file . '.bak'); +echo "ssh-tunnel startup tests passed\n";