parent
854b8c5b6b
commit
f89a7d69b9
12 changed files with 1892 additions and 0 deletions
@ -0,0 +1,4 @@ |
||||
*.o |
||||
/build/ |
||||
/ssh_tunnel_manager |
||||
/ssh_tunnel_manager.exe |
||||
@ -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 |
||||
``` |
||||
@ -0,0 +1,79 @@ |
||||
<?php |
||||
|
||||
/** |
||||
* Converts OpenSSH's channel-oriented SOCKS diagnostics into messages that |
||||
* identify the destination requested by the local SOCKS client. |
||||
*/ |
||||
class SshOutputParser |
||||
{ |
||||
/** @var array<string, array<string, string>> */ |
||||
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; |
||||
} |
||||
} |
||||
@ -0,0 +1,162 @@ |
||||
<?php |
||||
|
||||
/** |
||||
* Application controller. CRUD decisions, validation, persistence, SSH |
||||
* argument generation and runtime state all live in TypePHP. |
||||
*/ |
||||
class TunnelApplication |
||||
{ |
||||
private TunnelRepository $repository; |
||||
private mixed $window; |
||||
private array $states = []; |
||||
private SshOutputParser $sshOutputParser; |
||||
|
||||
public function __construct(TunnelRepository $repository) |
||||
{ |
||||
$this->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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,234 @@ |
||||
<?php |
||||
|
||||
/** |
||||
* JSON-backed CRUD repository. Writes use a temporary file and rename so a |
||||
* crash cannot leave a partially written configuration. |
||||
*/ |
||||
class TunnelRepository |
||||
{ |
||||
private string $file; |
||||
|
||||
public function __construct(string $file) |
||||
{ |
||||
$this->file = $file; |
||||
} |
||||
|
||||
/** @return array<TunnelRule> */ |
||||
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<TunnelRule> */ |
||||
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<TunnelRule> $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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,199 @@ |
||||
<?php |
||||
|
||||
/** |
||||
* SSH tunnel rule and command-line generation. |
||||
* |
||||
* This class deliberately contains no Qt-specific code. The GUI only |
||||
* collects values; validation and SSH semantics belong to TypePHP. |
||||
*/ |
||||
class TunnelRule |
||||
{ |
||||
public const TYPE_LOCAL = 'local'; |
||||
public const TYPE_REMOTE = 'remote'; |
||||
public const TYPE_SOCKS5 = 'socks5'; |
||||
|
||||
public string $id; |
||||
public string $name; |
||||
public string $type; |
||||
public string $sshHost; |
||||
public int $sshPort; |
||||
public string $sshUser; |
||||
public string $identityFile; |
||||
public bool $debug; |
||||
public string $localHost; |
||||
public int $localPort; |
||||
public string $remoteHost; |
||||
public int $remotePort; |
||||
|
||||
public function __construct(array $data) |
||||
{ |
||||
$this->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, |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,739 @@ |
||||
#include "phpx.h" |
||||
|
||||
#include <QApplication> |
||||
#include <QCheckBox> |
||||
#include <QComboBox> |
||||
#include <QColor> |
||||
#include <QDialog> |
||||
#include <QDialogButtonBox> |
||||
#include <QDateTime> |
||||
#include <QEventLoop> |
||||
#include <QFileDialog> |
||||
#include <QFont> |
||||
#include <QFormLayout> |
||||
#include <QGroupBox> |
||||
#include <QHBoxLayout> |
||||
#include <QHeaderView> |
||||
#include <QLabel> |
||||
#include <QLineEdit> |
||||
#include <QMainWindow> |
||||
#include <QMessageBox> |
||||
#include <QPlainTextEdit> |
||||
#include <QProcess> |
||||
#include <QPushButton> |
||||
#include <QSet> |
||||
#include <QSignalBlocker> |
||||
#include <QSpinBox> |
||||
#include <QSplitter> |
||||
#include <QStatusBar> |
||||
#include <QStringList> |
||||
#include <QTableWidget> |
||||
#include <QTextCursor> |
||||
#include <QTimer> |
||||
#include <QVBoxLayout> |
||||
|
||||
#include <deque> |
||||
|
||||
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<size_t>(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<int>(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<int>(rule.get("local_port").toInt()); |
||||
result.remoteHost = toQString(rule.get("remote_host")); |
||||
result.remotePort = static_cast<int>(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<QLineEdit *> 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<int>::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("<h2>SSH Tunnel Manager</h2>" |
||||
"<p>规则和 SSH 参数由 TypePHP 管理,Qt 仅提供界面与进程桥接。</p>")); |
||||
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<int, QProcess::ExitStatus>::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<QProcess *> 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<QString, RuleForm> rules_; |
||||
QHash<QString, QString> statuses_; |
||||
QHash<QString, QProcess *> processes_; |
||||
QHash<QString, QByteArray> processOutputBuffers_; |
||||
QHash<QString, QStringList> logs_; |
||||
QString displayedLogId_; |
||||
QSet<QString> stopping_; |
||||
std::deque<Array> events_; |
||||
}; |
||||
|
||||
TunnelWindowBox *windowBox(var box) { |
||||
return box.toBox<TunnelWindowBox>(); |
||||
} |
||||
|
||||
} // 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(); |
||||
} |
||||
@ -0,0 +1,39 @@ |
||||
<?php |
||||
|
||||
function tunnel_config_path(): string |
||||
{ |
||||
$override = getenv('TYPEPHP_SSH_TUNNEL_CONFIG'); |
||||
if (is_string($override) && $override !== '') { |
||||
return $override; |
||||
} |
||||
|
||||
if (PHP_OS_FAMILY === 'Windows') { |
||||
$base = getenv('APPDATA'); |
||||
if (!is_string($base) || $base === '') { |
||||
$base = '.'; |
||||
} |
||||
return $base . DIRECTORY_SEPARATOR . 'TypePHP' . DIRECTORY_SEPARATOR |
||||
. 'ssh-tunnel-manager.json'; |
||||
} |
||||
|
||||
$base = getenv('XDG_CONFIG_HOME'); |
||||
if (!is_string($base) || $base === '') { |
||||
$home = getenv('HOME'); |
||||
$base = (is_string($home) && $home !== '') |
||||
? $home . DIRECTORY_SEPARATOR . '.config' |
||||
: '.'; |
||||
} |
||||
return $base . DIRECTORY_SEPARATOR . 'typephp' . DIRECTORY_SEPARATOR |
||||
. 'ssh-tunnel-manager.json'; |
||||
} |
||||
|
||||
function main(): int |
||||
{ |
||||
try { |
||||
$application = new TunnelApplication(new TunnelRepository(tunnel_config_path())); |
||||
return $application->run(); |
||||
} catch (Throwable $error) { |
||||
fwrite(STDERR, 'SSH Tunnel Manager: ' . $error->getMessage() . PHP_EOL); |
||||
return 1; |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@ |
||||
<?php |
||||
|
||||
/** Native Qt GUI and QProcess bridge. Application logic lives in TypePHP. */ |
||||
function qt_tunnel_create(string $title): mixed {} |
||||
function qt_tunnel_is_open(mixed $window): bool {} |
||||
function qt_tunnel_process_events(mixed $window): void {} |
||||
function qt_tunnel_poll_event(mixed $window): array {} |
||||
function qt_tunnel_set_rules(mixed $window, array $rules): void {} |
||||
function qt_tunnel_start_process( |
||||
mixed $window, |
||||
string $id, |
||||
string $program, |
||||
array $arguments |
||||
): bool {} |
||||
function qt_tunnel_stop_process(mixed $window, string $id): void {} |
||||
function qt_tunnel_append_log(mixed $window, string $id, string $message): void {} |
||||
function qt_tunnel_show_error(mixed $window, string $message): void {} |
||||
function qt_tunnel_destroy(mixed $window): void {} |
||||
@ -0,0 +1,23 @@ |
||||
name: ssh_tunnel_manager |
||||
version: 0.1.0 |
||||
mode: bin |
||||
cxx-std: c++17 |
||||
sources: |
||||
- main.php |
||||
- app |
||||
- php-src |
||||
- cpp-src |
||||
|
||||
# Debian/Ubuntu Qt 6 development package layout. For a non-system Qt SDK, |
||||
# replace these flags with the paths printed by: |
||||
# pkg-config --cflags --libs Qt6Widgets |
||||
cxx-flags: |
||||
- "-fPIC" |
||||
- "-I/usr/include/x86_64-linux-gnu/qt6" |
||||
- "-I/usr/include/x86_64-linux-gnu/qt6/QtCore" |
||||
- "-I/usr/include/x86_64-linux-gnu/qt6/QtGui" |
||||
- "-I/usr/include/x86_64-linux-gnu/qt6/QtWidgets" |
||||
ld-flags: |
||||
- "-lQt6Widgets" |
||||
- "-lQt6Gui" |
||||
- "-lQt6Core" |
||||
@ -0,0 +1,208 @@ |
||||
<?php |
||||
|
||||
require dirname(__DIR__) . '/app/TunnelRule.php'; |
||||
require dirname(__DIR__) . '/app/TunnelRepository.php'; |
||||
require dirname(__DIR__) . '/app/SshOutputParser.php'; |
||||
|
||||
function assert_same(mixed $expected, mixed $actual, string $message): void |
||||
{ |
||||
if ($expected !== $actual) { |
||||
throw new RuntimeException( |
||||
$message . "\nexpected: " . var_export($expected, true) |
||||
. "\nactual: " . var_export($actual, true) |
||||
); |
||||
} |
||||
} |
||||
|
||||
function make_rule(array $overrides): TunnelRule |
||||
{ |
||||
return new TunnelRule(array_merge([ |
||||
'name' => '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"; |
||||
@ -0,0 +1,82 @@ |
||||
<?php |
||||
|
||||
require dirname(__DIR__) . '/app/TunnelRule.php'; |
||||
require dirname(__DIR__) . '/app/TunnelRepository.php'; |
||||
require dirname(__DIR__) . '/app/SshOutputParser.php'; |
||||
|
||||
$startedRuleIds = []; |
||||
$renderedRows = []; |
||||
|
||||
function qt_tunnel_create(string $title): mixed |
||||
{ |
||||
return new stdClass(); |
||||
} |
||||
|
||||
function qt_tunnel_is_open(mixed $window): bool |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
function qt_tunnel_process_events(mixed $window): void {} |
||||
function qt_tunnel_poll_event(mixed $window): array { return []; } |
||||
|
||||
function qt_tunnel_set_rules(mixed $window, array $rules): void |
||||
{ |
||||
global $renderedRows; |
||||
$renderedRows = $rules; |
||||
} |
||||
|
||||
function qt_tunnel_start_process( |
||||
mixed $window, |
||||
string $id, |
||||
string $program, |
||||
array $arguments |
||||
): bool { |
||||
global $startedRuleIds; |
||||
$startedRuleIds[] = $id; |
||||
return true; |
||||
} |
||||
|
||||
function qt_tunnel_stop_process(mixed $window, string $id): void {} |
||||
function qt_tunnel_append_log(mixed $window, string $id, string $message): void {} |
||||
function qt_tunnel_show_error(mixed $window, string $message): void {} |
||||
function qt_tunnel_destroy(mixed $window): void {} |
||||
|
||||
require dirname(__DIR__) . '/app/TunnelApplication.php'; |
||||
|
||||
function startup_rule(string $id, string $type): TunnelRule |
||||
{ |
||||
return new TunnelRule([ |
||||
'id' => $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"; |
||||
Loading…
Reference in new issue