add ssh-tunnel-manager

pull/40/head
韩天峰 4 weeks ago
parent 854b8c5b6b
commit f89a7d69b9
  1. 4
      examples/ssh-tunnel-qt/.gitignore
  2. 105
      examples/ssh-tunnel-qt/README.md
  3. 79
      examples/ssh-tunnel-qt/app/SshOutputParser.php
  4. 162
      examples/ssh-tunnel-qt/app/TunnelApplication.php
  5. 234
      examples/ssh-tunnel-qt/app/TunnelRepository.php
  6. 199
      examples/ssh-tunnel-qt/app/TunnelRule.php
  7. 739
      examples/ssh-tunnel-qt/cpp-src/qt_tunnel.cc
  8. 39
      examples/ssh-tunnel-qt/main.php
  9. 18
      examples/ssh-tunnel-qt/php-src/qt_tunnel.stub.php
  10. 23
      examples/ssh-tunnel-qt/project.yml
  11. 208
      examples/ssh-tunnel-qt/tests/domain_test.php
  12. 82
      examples/ssh-tunnel-qt/tests/startup_test.php

@ -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…
Cancel
Save