@kadi.build/tunnel-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/package.json +38 -0
- package/src/KadiClientMode.js +162 -0
- package/src/KadiConfigGenerator.js +112 -0
- package/src/KadiOutputParser.js +138 -0
- package/src/KadiSSHMode.js +191 -0
- package/src/ReconnectionManager.js +132 -0
- package/src/index.js +13 -0
- package/src/utils.js +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# @kadi/tunnel-client
|
|
2
|
+
|
|
3
|
+
Supporting classes for **KadiTunnelService** — the self-hosted HTTPS tunnel provider for KĀDI agents.
|
|
4
|
+
|
|
5
|
+
This package provides the SSH mode, frpc client mode, config generation, output parsing, reconnection management, and utility functions that `KadiTunnelService.js` depends on.
|
|
6
|
+
|
|
7
|
+
> **KadiTunnelService.js itself is NOT part of this package.** It is a standalone drop-in file — just like `NgrokTunnelService.js` and `ServeoTunnelService.js` — that you copy into your project's services directory.
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Your project (e.g. @kadi.build/local-remote-file-manager-ability)
|
|
13
|
+
├── BaseTunnelService.js ← base class (your project)
|
|
14
|
+
├── errors.js ← error classes (your project)
|
|
15
|
+
└── services/
|
|
16
|
+
├── NgrokTunnelService.js ← drop-in file, uses `ngrok` npm package
|
|
17
|
+
├── ServeoTunnelService.js ← drop-in file, uses system `ssh`
|
|
18
|
+
└── KadiTunnelService.js ← drop-in file, uses `@kadi/tunnel-client`
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @kadi/tunnel-client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then copy `KadiTunnelService.js` into your project's services directory (alongside the other tunnel services).
|
|
28
|
+
|
|
29
|
+
## What this package exports
|
|
30
|
+
|
|
31
|
+
| Export | Description |
|
|
32
|
+
|--------|-------------|
|
|
33
|
+
| `KadiSSHMode` | SSH gateway tunnel mode (`ssh -R`) — zero binary dependencies |
|
|
34
|
+
| `KadiClientMode` | frpc binary tunnel mode — enhanced features, native reconnect |
|
|
35
|
+
| `KadiConfigGenerator` | Generates frpc TOML configuration |
|
|
36
|
+
| `KadiOutputParser` | Parses frpc/ssh stdout for tunnel URLs and status |
|
|
37
|
+
| `ReconnectionManager` | Exponential backoff reconnection with jitter |
|
|
38
|
+
| `detectFrpcBinary` | Detects whether `frpc` is available on `$PATH` |
|
|
39
|
+
| `generateSubdomain` | Deterministic subdomain generation from agent ID + port |
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
In your copied `KadiTunnelService.js`:
|
|
44
|
+
|
|
45
|
+
```javascript
|
|
46
|
+
// These come from your project (same as Ngrok/Serveo services)
|
|
47
|
+
import { BaseTunnelService } from '../BaseTunnelService.js';
|
|
48
|
+
import { TransientTunnelError, PermanentTunnelError, ... } from '../errors.js';
|
|
49
|
+
|
|
50
|
+
// These come from this npm package
|
|
51
|
+
import {
|
|
52
|
+
KadiSSHMode,
|
|
53
|
+
KadiClientMode,
|
|
54
|
+
ReconnectionManager,
|
|
55
|
+
detectFrpcBinary,
|
|
56
|
+
generateSubdomain
|
|
57
|
+
} from '@kadi/tunnel-client';
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Connection Modes
|
|
61
|
+
|
|
62
|
+
### SSH Mode (default, zero dependencies)
|
|
63
|
+
Uses `ssh -R` to create a reverse tunnel through the KĀDI SSH gateway. No binary installation needed — works anywhere `ssh` is available.
|
|
64
|
+
|
|
65
|
+
### frpc Mode (enhanced)
|
|
66
|
+
Uses the [frp](https://github.com/fatedier/frp) client binary for enhanced features including native reconnection, health checks, and multiplexed connections. Requires `frpc` on `$PATH`.
|
|
67
|
+
|
|
68
|
+
### Auto Mode
|
|
69
|
+
Tries frpc first, falls back to SSH if the binary is not found.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
When instantiating `KadiTunnelService` in your project:
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
const service = new KadiTunnelService({
|
|
77
|
+
agentId: 'my-agent',
|
|
78
|
+
frp: {
|
|
79
|
+
serverAddr: 'broker.kadi.build',
|
|
80
|
+
serverPort: 7000,
|
|
81
|
+
sshPort: 2200,
|
|
82
|
+
token: 'your-tunnel-token',
|
|
83
|
+
tunnelDomain: 'tunnel.kadi.build',
|
|
84
|
+
mode: 'auto' // 'auto' | 'ssh' | 'frpc'
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const tunnel = await service.connect({ port: 3000 });
|
|
89
|
+
console.log(tunnel.url); // https://<subdomain>.tunnel.kadi.build
|
|
90
|
+
console.log(tunnel.mode); // 'ssh' or 'frpc'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Option | Default | Description |
|
|
94
|
+
|--------|---------|-------------|
|
|
95
|
+
| `frp.serverAddr` | *(required)* | Tunnel server hostname |
|
|
96
|
+
| `frp.serverPort` | `7000` | frp server bind port |
|
|
97
|
+
| `frp.sshPort` | `2200` | SSH gateway port |
|
|
98
|
+
| `frp.token` | *(required)* | Authentication token |
|
|
99
|
+
| `frp.tunnelDomain` | *(required)* | Wildcard tunnel domain |
|
|
100
|
+
| `frp.mode` | `'auto'` | `'auto'`, `'ssh'`, or `'frpc'` |
|
|
101
|
+
| `agentId` | `'agent'` | Agent ID for subdomain generation |
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
# Run all tests (unit + integration)
|
|
107
|
+
npm test
|
|
108
|
+
|
|
109
|
+
# Unit tests only
|
|
110
|
+
npm run test:unit
|
|
111
|
+
|
|
112
|
+
# SSH mode tests
|
|
113
|
+
npm run test:ssh
|
|
114
|
+
|
|
115
|
+
# frpc client mode tests
|
|
116
|
+
npm run test:frpc
|
|
117
|
+
|
|
118
|
+
# Integration tests (requires running tunnel server)
|
|
119
|
+
npm run test:integration
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Server Setup
|
|
123
|
+
|
|
124
|
+
See the [server README](../server/README.md) for VPS deployment instructions (frps + Caddy + wildcard TLS).
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
UNLICENSED
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kadi.build/tunnel-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Supporting classes for KadiTunnelService — SSH mode, frpc client mode, reconnection, config generation, and output parsing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/index.js",
|
|
12
|
+
"src/KadiSSHMode.js",
|
|
13
|
+
"src/KadiClientMode.js",
|
|
14
|
+
"src/KadiConfigGenerator.js",
|
|
15
|
+
"src/KadiOutputParser.js",
|
|
16
|
+
"src/ReconnectionManager.js",
|
|
17
|
+
"src/utils.js",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test tests/",
|
|
22
|
+
"test:unit": "node --test tests/test-kadi-tunnel-service.js tests/test-kadi-output-parser.js tests/test-kadi-config-generator.js tests/test-reconnection.js",
|
|
23
|
+
"test:ssh": "node --test tests/test-kadi-ssh-mode.js",
|
|
24
|
+
"test:frpc": "node --test tests/test-kadi-client-mode.js",
|
|
25
|
+
"test:integration": "node --test tests/test-integration.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": ["kadi", "tunnel", "frp", "ssh", "reverse-proxy", "frpc"],
|
|
28
|
+
"license": "UNLICENSED",
|
|
29
|
+
"author": "KĀDI",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://gitlab.com/anthropic-kadi/kadi-tunnel.git",
|
|
33
|
+
"directory": "client"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview frpc binary mode strategy for KadiTunnelService
|
|
3
|
+
*
|
|
4
|
+
* Uses the `frpc` binary (when available) for enhanced tunnel features:
|
|
5
|
+
* - Built-in auto-reconnect
|
|
6
|
+
* - Connection pooling
|
|
7
|
+
* - Dashboard integration
|
|
8
|
+
*
|
|
9
|
+
* Generates a per-tunnel TOML config file and spawns `frpc -c <config>`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import { promises as fs } from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import {
|
|
17
|
+
TransientTunnelError,
|
|
18
|
+
PermanentTunnelError,
|
|
19
|
+
ConnectionTimeoutError,
|
|
20
|
+
AuthenticationFailedError
|
|
21
|
+
} from '../errors.js';
|
|
22
|
+
import { KadiConfigGenerator } from './KadiConfigGenerator.js';
|
|
23
|
+
import { KadiOutputParser } from './KadiOutputParser.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* frpc binary connection mode for KADI tunnels
|
|
27
|
+
*/
|
|
28
|
+
export class KadiClientMode {
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {Object} config
|
|
32
|
+
* @param {string} config.serverAddr - frps server hostname
|
|
33
|
+
* @param {number} config.serverPort - frps bind port (default 7000)
|
|
34
|
+
* @param {string} config.token - Authentication token
|
|
35
|
+
* @param {string} config.tunnelDomain - Tunnel subdomain host
|
|
36
|
+
*/
|
|
37
|
+
constructor(config) {
|
|
38
|
+
this.serverAddr = config.serverAddr;
|
|
39
|
+
this.serverPort = config.serverPort || 7000;
|
|
40
|
+
this.token = config.token;
|
|
41
|
+
this.tunnelDomain = config.tunnelDomain;
|
|
42
|
+
this.configGenerator = new KadiConfigGenerator(config);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a tunnel via frpc binary
|
|
47
|
+
*
|
|
48
|
+
* @param {Object} options
|
|
49
|
+
* @param {number} options.localPort - Local port to tunnel
|
|
50
|
+
* @param {string} options.subdomain - Subdomain for the tunnel
|
|
51
|
+
* @param {string} options.proxyName - Proxy name for frp dashboard
|
|
52
|
+
* @param {number} options.timeout - Connection timeout in ms
|
|
53
|
+
* @returns {Promise<ChildProcess>} The spawned frpc process
|
|
54
|
+
*/
|
|
55
|
+
async connect(options) {
|
|
56
|
+
const { localPort, subdomain, proxyName, timeout } = options;
|
|
57
|
+
|
|
58
|
+
// Generate temporary config file
|
|
59
|
+
const configPath = await this.configGenerator.generate({
|
|
60
|
+
localPort,
|
|
61
|
+
subdomain,
|
|
62
|
+
proxyName,
|
|
63
|
+
tunnelId: proxyName // Use proxyName for config file naming
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
let resolved = false;
|
|
68
|
+
let frpcProcess = null;
|
|
69
|
+
let timeoutHandle = null;
|
|
70
|
+
|
|
71
|
+
console.log(`🔗 frpc binary: frpc -c ${configPath}`);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
frpcProcess = spawn('frpc', ['-c', configPath], {
|
|
75
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let output = '';
|
|
79
|
+
let errorOutput = '';
|
|
80
|
+
|
|
81
|
+
// --- stdout ---
|
|
82
|
+
frpcProcess.stdout.on('data', (data) => {
|
|
83
|
+
const text = data.toString();
|
|
84
|
+
output += text;
|
|
85
|
+
console.log('frpc stdout:', text.trim());
|
|
86
|
+
|
|
87
|
+
if (!resolved && KadiOutputParser.isFrpcConnected(output)) {
|
|
88
|
+
resolved = true;
|
|
89
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
90
|
+
resolve(frpcProcess);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// --- stderr ---
|
|
95
|
+
frpcProcess.stderr.on('data', (data) => {
|
|
96
|
+
const text = data.toString();
|
|
97
|
+
errorOutput += text;
|
|
98
|
+
console.log('frpc stderr:', text.trim());
|
|
99
|
+
|
|
100
|
+
// Check for auth failure
|
|
101
|
+
if (KadiOutputParser.isAuthFailure(text)) {
|
|
102
|
+
if (!resolved) {
|
|
103
|
+
resolved = true;
|
|
104
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
105
|
+
frpcProcess.kill('SIGTERM');
|
|
106
|
+
reject(new AuthenticationFailedError('Token authentication failed'));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// --- process error ---
|
|
112
|
+
frpcProcess.on('error', (error) => {
|
|
113
|
+
if (!resolved) {
|
|
114
|
+
resolved = true;
|
|
115
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
116
|
+
|
|
117
|
+
if (error.code === 'ENOENT') {
|
|
118
|
+
reject(new PermanentTunnelError('frpc binary not found in PATH. Install frpc or use SSH mode.'));
|
|
119
|
+
} else {
|
|
120
|
+
reject(new TransientTunnelError(`frpc process error: ${error.message}`, error));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// --- process exit ---
|
|
126
|
+
frpcProcess.on('exit', (code) => {
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
resolved = true;
|
|
129
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
130
|
+
|
|
131
|
+
const msg = `frpc exited with code ${code}. Output: ${errorOutput || output}`;
|
|
132
|
+
reject(new TransientTunnelError(msg));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// --- timeout ---
|
|
137
|
+
timeoutHandle = setTimeout(() => {
|
|
138
|
+
if (!resolved) {
|
|
139
|
+
resolved = true;
|
|
140
|
+
if (frpcProcess) frpcProcess.kill('SIGTERM');
|
|
141
|
+
reject(new ConnectionTimeoutError('frpc', timeout));
|
|
142
|
+
}
|
|
143
|
+
}, timeout);
|
|
144
|
+
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!resolved) {
|
|
147
|
+
resolved = true;
|
|
148
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
149
|
+
reject(new TransientTunnelError(`Failed to spawn frpc: ${error.message}`, error));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Clean up the temporary config file for a tunnel
|
|
157
|
+
* @param {string} tunnelId
|
|
158
|
+
*/
|
|
159
|
+
async cleanupConfig(tunnelId) {
|
|
160
|
+
await this.configGenerator.cleanup(tunnelId);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Generates temporary frpc TOML configuration files
|
|
3
|
+
*
|
|
4
|
+
* Each tunnel gets its own config file at /tmp/frpc-{tunnelId}.toml
|
|
5
|
+
* which is cleaned up when the tunnel is disconnected.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generates per-tunnel frpc configuration files
|
|
14
|
+
*/
|
|
15
|
+
export class KadiConfigGenerator {
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} config
|
|
19
|
+
* @param {string} config.serverAddr - frps server hostname
|
|
20
|
+
* @param {number} config.serverPort - frps bind port
|
|
21
|
+
* @param {string} config.token - Authentication token
|
|
22
|
+
* @param {string} config.tunnelDomain - Tunnel subdomain host
|
|
23
|
+
*/
|
|
24
|
+
constructor(config) {
|
|
25
|
+
this.serverAddr = config.serverAddr;
|
|
26
|
+
this.serverPort = config.serverPort || 7000;
|
|
27
|
+
this.token = config.token;
|
|
28
|
+
this.tunnelDomain = config.tunnelDomain;
|
|
29
|
+
this.configDir = os.tmpdir();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a TOML config file for a tunnel
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} options
|
|
36
|
+
* @param {number} options.localPort - Local port to tunnel
|
|
37
|
+
* @param {string} options.subdomain - Subdomain for the tunnel
|
|
38
|
+
* @param {string} options.proxyName - Proxy name
|
|
39
|
+
* @param {string} options.tunnelId - Tunnel identifier (for file naming)
|
|
40
|
+
* @returns {Promise<string>} Path to the generated config file
|
|
41
|
+
*/
|
|
42
|
+
async generate(options) {
|
|
43
|
+
const { localPort, subdomain, proxyName, tunnelId } = options;
|
|
44
|
+
const configPath = path.join(this.configDir, `frpc-${tunnelId}.toml`);
|
|
45
|
+
|
|
46
|
+
const toml = this._buildToml(localPort, subdomain, proxyName);
|
|
47
|
+
|
|
48
|
+
await fs.writeFile(configPath, toml, 'utf8');
|
|
49
|
+
console.log(`📝 Generated frpc config: ${configPath}`);
|
|
50
|
+
|
|
51
|
+
return configPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Remove a tunnel's config file
|
|
56
|
+
* @param {string} tunnelId
|
|
57
|
+
*/
|
|
58
|
+
async cleanup(tunnelId) {
|
|
59
|
+
const configPath = path.join(this.configDir, `frpc-${tunnelId}.toml`);
|
|
60
|
+
try {
|
|
61
|
+
await fs.unlink(configPath);
|
|
62
|
+
console.log(`🗑️ Cleaned up frpc config: ${configPath}`);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error.code !== 'ENOENT') {
|
|
65
|
+
console.warn(`Warning: Failed to clean up ${configPath}:`, error.message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the TOML configuration string
|
|
72
|
+
*
|
|
73
|
+
* Uses `subdomain` instead of `customDomains` because frps has
|
|
74
|
+
* `subDomainHost` configured. Using customDomains for a domain
|
|
75
|
+
* that belongs to the subdomain host is rejected by frps.
|
|
76
|
+
*
|
|
77
|
+
* @param {number} localPort
|
|
78
|
+
* @param {string} subdomain
|
|
79
|
+
* @param {string} proxyName
|
|
80
|
+
* @returns {string} TOML configuration content
|
|
81
|
+
* @private
|
|
82
|
+
*/
|
|
83
|
+
_buildToml(localPort, subdomain, proxyName) {
|
|
84
|
+
const fullDomain = `${subdomain}.${this.tunnelDomain}`;
|
|
85
|
+
return `# Auto-generated by KĀDI Tunnel Service
|
|
86
|
+
# Tunnel: ${proxyName}
|
|
87
|
+
# Domain: ${fullDomain}
|
|
88
|
+
|
|
89
|
+
serverAddr = "${this.serverAddr}"
|
|
90
|
+
serverPort = ${this.serverPort}
|
|
91
|
+
|
|
92
|
+
auth.method = "token"
|
|
93
|
+
auth.token = "${this.token}"
|
|
94
|
+
|
|
95
|
+
# Logging
|
|
96
|
+
log.to = "console"
|
|
97
|
+
log.level = "info"
|
|
98
|
+
|
|
99
|
+
# Auto-reconnect
|
|
100
|
+
loginFailExit = false
|
|
101
|
+
transport.heartbeatInterval = 30
|
|
102
|
+
transport.heartbeatTimeout = 90
|
|
103
|
+
|
|
104
|
+
[[proxies]]
|
|
105
|
+
name = "${proxyName}"
|
|
106
|
+
type = "http"
|
|
107
|
+
localIP = "127.0.0.1"
|
|
108
|
+
localPort = ${localPort}
|
|
109
|
+
subdomain = "${subdomain}"
|
|
110
|
+
`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Output parser for frp SSH gateway and frpc binary output
|
|
3
|
+
*
|
|
4
|
+
* Parses stdout/stderr from both SSH gateway mode and frpc binary mode
|
|
5
|
+
* to detect connection success, authentication failures, and errors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Static utility class for parsing tunnel process output
|
|
10
|
+
*/
|
|
11
|
+
export class KadiOutputParser {
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if SSH gateway output indicates successful connection
|
|
15
|
+
*
|
|
16
|
+
* Expected SSH gateway output:
|
|
17
|
+
* frp (via SSH) (Ctrl+C to quit)
|
|
18
|
+
* User:
|
|
19
|
+
* ProxyName: agent-abc-tunnel-1
|
|
20
|
+
* Type: http
|
|
21
|
+
* RemoteAddress: :80
|
|
22
|
+
*
|
|
23
|
+
* @param {string} output - Combined stdout/stderr output
|
|
24
|
+
* @returns {boolean} True if connection is confirmed
|
|
25
|
+
*/
|
|
26
|
+
static isSSHConnected(output) {
|
|
27
|
+
// Look for the frp SSH gateway confirmation pattern
|
|
28
|
+
return (
|
|
29
|
+
output.includes('frp (via SSH)') &&
|
|
30
|
+
output.includes('ProxyName:') &&
|
|
31
|
+
output.includes('Type:')
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Check if frpc binary output indicates successful connection
|
|
37
|
+
*
|
|
38
|
+
* Expected frpc output includes lines like:
|
|
39
|
+
* [I] [proxy_name] start proxy success
|
|
40
|
+
* or
|
|
41
|
+
* start proxy success
|
|
42
|
+
*
|
|
43
|
+
* @param {string} output - Combined stdout/stderr output
|
|
44
|
+
* @returns {boolean} True if connection is confirmed
|
|
45
|
+
*/
|
|
46
|
+
static isFrpcConnected(output) {
|
|
47
|
+
return (
|
|
48
|
+
output.includes('start proxy success') ||
|
|
49
|
+
output.includes('proxy started') ||
|
|
50
|
+
output.includes('login to server success')
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if output indicates an authentication failure
|
|
56
|
+
*
|
|
57
|
+
* @param {string} output - Process output text
|
|
58
|
+
* @returns {boolean} True if auth failure detected
|
|
59
|
+
*/
|
|
60
|
+
static isAuthFailure(output) {
|
|
61
|
+
const lowerOutput = output.toLowerCase();
|
|
62
|
+
return (
|
|
63
|
+
lowerOutput.includes('authorization failed') ||
|
|
64
|
+
lowerOutput.includes('auth failed') ||
|
|
65
|
+
lowerOutput.includes('token auth failed') ||
|
|
66
|
+
lowerOutput.includes('invalid token') ||
|
|
67
|
+
lowerOutput.includes('authentication failed')
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse the proxy name from SSH gateway output
|
|
73
|
+
*
|
|
74
|
+
* @param {string} output
|
|
75
|
+
* @returns {string|null} Proxy name or null
|
|
76
|
+
*/
|
|
77
|
+
static parseProxyName(output) {
|
|
78
|
+
const match = output.match(/ProxyName:\s*(.+)/);
|
|
79
|
+
return match ? match[1].trim() : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse the proxy type from SSH gateway output
|
|
84
|
+
*
|
|
85
|
+
* @param {string} output
|
|
86
|
+
* @returns {string|null} Proxy type or null
|
|
87
|
+
*/
|
|
88
|
+
static parseProxyType(output) {
|
|
89
|
+
const match = output.match(/Type:\s*(.+)/);
|
|
90
|
+
return match ? match[1].trim() : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Parse the remote address from SSH gateway output
|
|
95
|
+
*
|
|
96
|
+
* @param {string} output
|
|
97
|
+
* @returns {string|null} Remote address or null
|
|
98
|
+
*/
|
|
99
|
+
static parseRemoteAddress(output) {
|
|
100
|
+
const match = output.match(/RemoteAddress:\s*(.+)/);
|
|
101
|
+
return match ? match[1].trim() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract error message from frpc output
|
|
106
|
+
*
|
|
107
|
+
* @param {string} output
|
|
108
|
+
* @returns {string|null} Error message or null
|
|
109
|
+
*/
|
|
110
|
+
static parseError(output) {
|
|
111
|
+
// frpc error format: [E] [service.go:xxx] message
|
|
112
|
+
const match = output.match(/\[E\]\s*(?:\[[\w.]+:\d+\]\s*)?(.+)/);
|
|
113
|
+
return match ? match[1].trim() : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if the error indicates a duplicate proxy name
|
|
118
|
+
*
|
|
119
|
+
* @param {string} output
|
|
120
|
+
* @returns {boolean}
|
|
121
|
+
*/
|
|
122
|
+
static isDuplicateProxy(output) {
|
|
123
|
+
return output.includes('proxy name already in use') ||
|
|
124
|
+
output.includes('proxy already exists');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if the error indicates a subdomain/custom_domain conflict
|
|
129
|
+
* This happens when --custom_domain is used for a domain that belongs
|
|
130
|
+
* to the configured subDomainHost (should use --sd instead)
|
|
131
|
+
*
|
|
132
|
+
* @param {string} output
|
|
133
|
+
* @returns {boolean}
|
|
134
|
+
*/
|
|
135
|
+
static isSubdomainHostConflict(output) {
|
|
136
|
+
return output.includes('should not belong to subdomain host');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview SSH Gateway mode strategy for KadiTunnelService
|
|
3
|
+
*
|
|
4
|
+
* Uses frp's SSH tunnel gateway feature to create tunnels via plain `ssh -R`.
|
|
5
|
+
* Zero dependency — only requires the SSH client that ships with macOS/Linux.
|
|
6
|
+
*
|
|
7
|
+
* Pattern closely follows ServeoTunnelService.js for process management.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from 'child_process';
|
|
11
|
+
import {
|
|
12
|
+
TransientTunnelError,
|
|
13
|
+
SSHUnavailableError,
|
|
14
|
+
ConnectionTimeoutError,
|
|
15
|
+
AuthenticationFailedError
|
|
16
|
+
} from '../errors.js';
|
|
17
|
+
import { KadiOutputParser } from './KadiOutputParser.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* SSH Gateway connection mode for KADI tunnels
|
|
21
|
+
*/
|
|
22
|
+
export class KadiSSHMode {
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {Object} config
|
|
26
|
+
* @param {string} config.serverAddr - frps server hostname
|
|
27
|
+
* @param {number} config.sshPort - SSH gateway port (default 2200)
|
|
28
|
+
* @param {string} config.token - Authentication token
|
|
29
|
+
* @param {string} config.tunnelDomain - Tunnel subdomain host
|
|
30
|
+
*/
|
|
31
|
+
constructor(config) {
|
|
32
|
+
this.serverAddr = config.serverAddr;
|
|
33
|
+
this.sshPort = config.sshPort || 2200;
|
|
34
|
+
this.token = config.token;
|
|
35
|
+
this.tunnelDomain = config.tunnelDomain;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a tunnel via SSH gateway
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {number} options.localPort - Local port to tunnel
|
|
43
|
+
* @param {string} options.subdomain - Subdomain for the tunnel
|
|
44
|
+
* @param {string} options.proxyName - Proxy name for frp dashboard
|
|
45
|
+
* @param {number} options.timeout - Connection timeout in ms
|
|
46
|
+
* @returns {Promise<ChildProcess>} The spawned SSH process
|
|
47
|
+
*/
|
|
48
|
+
connect(options) {
|
|
49
|
+
const { localPort, subdomain, proxyName, timeout } = options;
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let resolved = false;
|
|
53
|
+
let sshProcess = null;
|
|
54
|
+
let timeoutHandle = null;
|
|
55
|
+
|
|
56
|
+
const sshArgs = this._buildSSHArgs(localPort, subdomain, proxyName);
|
|
57
|
+
console.log(`🔗 SSH gateway: ssh ${sshArgs.join(' ')}`);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
sshProcess = spawn('ssh', sshArgs, {
|
|
61
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
let output = '';
|
|
65
|
+
let errorOutput = '';
|
|
66
|
+
|
|
67
|
+
// --- stdout ---
|
|
68
|
+
sshProcess.stdout.on('data', (data) => {
|
|
69
|
+
const text = data.toString();
|
|
70
|
+
output += text;
|
|
71
|
+
console.log('kadi-ssh stdout:', text.trim());
|
|
72
|
+
|
|
73
|
+
if (!resolved && KadiOutputParser.isSSHConnected(output)) {
|
|
74
|
+
resolved = true;
|
|
75
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
76
|
+
resolve(sshProcess);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// --- stderr ---
|
|
81
|
+
sshProcess.stderr.on('data', (data) => {
|
|
82
|
+
const text = data.toString();
|
|
83
|
+
errorOutput += text;
|
|
84
|
+
output += text;
|
|
85
|
+
console.log('kadi-ssh stderr:', text.trim());
|
|
86
|
+
|
|
87
|
+
// Check for auth failure
|
|
88
|
+
if (KadiOutputParser.isAuthFailure(text)) {
|
|
89
|
+
if (!resolved) {
|
|
90
|
+
resolved = true;
|
|
91
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
92
|
+
sshProcess.kill('SIGTERM');
|
|
93
|
+
reject(new AuthenticationFailedError('Token authentication failed'));
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!resolved && KadiOutputParser.isSSHConnected(output)) {
|
|
99
|
+
resolved = true;
|
|
100
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
101
|
+
resolve(sshProcess);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// --- process error (e.g., ENOENT = ssh not found) ---
|
|
106
|
+
sshProcess.on('error', (error) => {
|
|
107
|
+
if (!resolved) {
|
|
108
|
+
resolved = true;
|
|
109
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
110
|
+
|
|
111
|
+
if (error.code === 'ENOENT') {
|
|
112
|
+
reject(new SSHUnavailableError());
|
|
113
|
+
} else {
|
|
114
|
+
reject(new TransientTunnelError(`SSH process error: ${error.message}`, error));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- process exit ---
|
|
120
|
+
sshProcess.on('exit', (code) => {
|
|
121
|
+
if (!resolved) {
|
|
122
|
+
resolved = true;
|
|
123
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
124
|
+
|
|
125
|
+
const msg = `SSH tunnel exited with code ${code}. Output: ${errorOutput || output}`;
|
|
126
|
+
reject(new TransientTunnelError(msg));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// --- timeout ---
|
|
131
|
+
timeoutHandle = setTimeout(() => {
|
|
132
|
+
if (!resolved) {
|
|
133
|
+
resolved = true;
|
|
134
|
+
if (sshProcess) sshProcess.kill('SIGTERM');
|
|
135
|
+
reject(new ConnectionTimeoutError('kadi-ssh', timeout));
|
|
136
|
+
}
|
|
137
|
+
}, timeout);
|
|
138
|
+
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!resolved) {
|
|
141
|
+
resolved = true;
|
|
142
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
143
|
+
reject(new TransientTunnelError(`Failed to spawn SSH: ${error.message}`, error));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Build SSH command arguments for the KADI SSH tunnel gateway
|
|
151
|
+
*
|
|
152
|
+
* Command pattern:
|
|
153
|
+
* ssh -R :80:localhost:{port} v0@{server} -p {sshPort} \
|
|
154
|
+
* http --sd {subdomain} \
|
|
155
|
+
* --token {token} --proxy_name {name} \
|
|
156
|
+
* -o StrictHostKeyChecking=no -o ServerAliveInterval=30
|
|
157
|
+
*
|
|
158
|
+
* Note: Uses --sd (subdomain) instead of --custom_domain because frps
|
|
159
|
+
* has subDomainHost configured. The frps gateway rejects --custom_domain
|
|
160
|
+
* for domains that belong to the subdomain host.
|
|
161
|
+
*
|
|
162
|
+
* Note: Do NOT use -N flag. The frp SSH gateway sends interactive
|
|
163
|
+
* confirmation output (ProxyName, Type, RemoteAddress) that we need
|
|
164
|
+
* to parse for connection status. -N would suppress this output.
|
|
165
|
+
*
|
|
166
|
+
* @param {number} localPort
|
|
167
|
+
* @param {string} subdomain
|
|
168
|
+
* @param {string} proxyName
|
|
169
|
+
* @returns {string[]} SSH arguments array
|
|
170
|
+
* @private
|
|
171
|
+
*/
|
|
172
|
+
_buildSSHArgs(localPort, subdomain, proxyName) {
|
|
173
|
+
return [
|
|
174
|
+
// SSH options MUST come before destination and remote command
|
|
175
|
+
'-o', 'StrictHostKeyChecking=no',
|
|
176
|
+
'-o', 'UserKnownHostsFile=/dev/null',
|
|
177
|
+
'-o', 'ServerAliveInterval=30',
|
|
178
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
179
|
+
// SSH reverse port forward
|
|
180
|
+
'-R', `:80:localhost:${localPort}`,
|
|
181
|
+
// Connect to frps SSH gateway as user "v0"
|
|
182
|
+
`v0@${this.serverAddr}`,
|
|
183
|
+
'-p', String(this.sshPort),
|
|
184
|
+
// frp proxy type + options (this is the remote command)
|
|
185
|
+
'http',
|
|
186
|
+
'--sd', subdomain,
|
|
187
|
+
'--token', this.token,
|
|
188
|
+
'--proxy_name', proxyName
|
|
189
|
+
];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Reconnection manager with exponential backoff
|
|
3
|
+
*
|
|
4
|
+
* Used by KadiTunnelService to automatically retry SSH-mode tunnels
|
|
5
|
+
* when the process exits unexpectedly. frpc mode has built-in reconnect.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Manages reconnection attempts with exponential backoff
|
|
10
|
+
*/
|
|
11
|
+
export class ReconnectionManager {
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {number} [options.maxRetries=5] - Maximum retry attempts
|
|
16
|
+
* @param {number} [options.baseDelay=1000] - Initial delay in ms
|
|
17
|
+
* @param {number} [options.maxDelay=30000] - Maximum delay cap in ms
|
|
18
|
+
* @param {number} [options.backoffMultiplier=2] - Backoff multiplier
|
|
19
|
+
*/
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.maxRetries = options.maxRetries ?? 5;
|
|
22
|
+
this.baseDelay = options.baseDelay ?? 1000;
|
|
23
|
+
this.maxDelay = options.maxDelay ?? 30000;
|
|
24
|
+
this.backoffMultiplier = options.backoffMultiplier ?? 2;
|
|
25
|
+
|
|
26
|
+
this._attempt = 0;
|
|
27
|
+
this._stopped = false;
|
|
28
|
+
this._currentTimeout = null;
|
|
29
|
+
this._waitResolve = null; // Reference to current _wait() resolver for cancellation
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Retry a function with exponential backoff
|
|
34
|
+
*
|
|
35
|
+
* @param {Function} fn - Async function to retry (should return a result on success)
|
|
36
|
+
* @returns {Promise<*>} Result of the successful function call
|
|
37
|
+
* @throws {Error} After max retries exhausted
|
|
38
|
+
*/
|
|
39
|
+
async retry(fn) {
|
|
40
|
+
this._attempt = 0;
|
|
41
|
+
this._stopped = false;
|
|
42
|
+
|
|
43
|
+
while (this._attempt < this.maxRetries && !this._stopped) {
|
|
44
|
+
this._attempt++;
|
|
45
|
+
const delay = this._calculateDelay(this._attempt);
|
|
46
|
+
|
|
47
|
+
console.log(`🔄 Reconnection attempt ${this._attempt}/${this.maxRetries} (delay: ${delay}ms)`);
|
|
48
|
+
|
|
49
|
+
// Wait before retrying
|
|
50
|
+
await this._wait(delay);
|
|
51
|
+
|
|
52
|
+
if (this._stopped) break;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = await fn();
|
|
56
|
+
console.log(`✅ Reconnection succeeded on attempt ${this._attempt}`);
|
|
57
|
+
this._attempt = 0; // Reset on success
|
|
58
|
+
return result;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.warn(`⚠️ Reconnection attempt ${this._attempt} failed: ${error.message}`);
|
|
61
|
+
|
|
62
|
+
if (this._attempt >= this.maxRetries) {
|
|
63
|
+
throw new Error(`Reconnection failed after ${this.maxRetries} attempts. Last error: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
throw new Error('Reconnection stopped');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Stop any in-progress reconnection
|
|
73
|
+
*/
|
|
74
|
+
stop() {
|
|
75
|
+
this._stopped = true;
|
|
76
|
+
if (this._currentTimeout) {
|
|
77
|
+
clearTimeout(this._currentTimeout);
|
|
78
|
+
this._currentTimeout = null;
|
|
79
|
+
}
|
|
80
|
+
// Resolve any pending _wait() promise so retry() can exit
|
|
81
|
+
if (this._waitResolve) {
|
|
82
|
+
this._waitResolve();
|
|
83
|
+
this._waitResolve = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get current attempt count
|
|
89
|
+
* @returns {number}
|
|
90
|
+
*/
|
|
91
|
+
get attempt() {
|
|
92
|
+
return this._attempt;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Check if reconnection is active
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
get isActive() {
|
|
100
|
+
return this._attempt > 0 && !this._stopped;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Calculate delay for a given attempt using exponential backoff
|
|
105
|
+
* Sequence: 1s → 2s → 4s → 8s → 16s → 30s (capped)
|
|
106
|
+
*
|
|
107
|
+
* @param {number} attempt - Attempt number (1-based)
|
|
108
|
+
* @returns {number} Delay in ms
|
|
109
|
+
* @private
|
|
110
|
+
*/
|
|
111
|
+
_calculateDelay(attempt) {
|
|
112
|
+
const delay = this.baseDelay * Math.pow(this.backoffMultiplier, attempt - 1);
|
|
113
|
+
return Math.min(delay, this.maxDelay);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wait for a specified duration (cancellable)
|
|
118
|
+
* @param {number} ms
|
|
119
|
+
* @returns {Promise<void>}
|
|
120
|
+
* @private
|
|
121
|
+
*/
|
|
122
|
+
_wait(ms) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
this._waitResolve = resolve;
|
|
125
|
+
this._currentTimeout = setTimeout(() => {
|
|
126
|
+
this._currentTimeout = null;
|
|
127
|
+
this._waitResolve = null;
|
|
128
|
+
resolve();
|
|
129
|
+
}, ms);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview @kadi/tunnel-client — package entry point
|
|
3
|
+
*
|
|
4
|
+
* Exports the supporting classes and utilities used by KadiTunnelService.
|
|
5
|
+
* KadiTunnelService itself is a standalone drop-in file (not part of this package).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { KadiSSHMode } from './KadiSSHMode.js';
|
|
9
|
+
export { KadiClientMode } from './KadiClientMode.js';
|
|
10
|
+
export { KadiConfigGenerator } from './KadiConfigGenerator.js';
|
|
11
|
+
export { KadiOutputParser } from './KadiOutputParser.js';
|
|
12
|
+
export { ReconnectionManager } from './ReconnectionManager.js';
|
|
13
|
+
export { detectFrpcBinary, generateSubdomain } from './utils.js';
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Utility functions for KadiTunnelService
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import crypto from 'crypto';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect if the `frpc` binary is available in PATH
|
|
10
|
+
*
|
|
11
|
+
* @returns {Promise<boolean>} True if frpc is available
|
|
12
|
+
*/
|
|
13
|
+
export async function detectFrpcBinary() {
|
|
14
|
+
try {
|
|
15
|
+
execSync('command -v frpc', { stdio: 'pipe' });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detect if the `ssh` command is available in PATH
|
|
24
|
+
*
|
|
25
|
+
* @returns {Promise<boolean>} True if ssh is available
|
|
26
|
+
*/
|
|
27
|
+
export async function detectSSHBinary() {
|
|
28
|
+
try {
|
|
29
|
+
execSync('command -v ssh', { stdio: 'pipe' });
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate a random subdomain string
|
|
38
|
+
* Produces a short, URL-safe identifier like "a7b3x9"
|
|
39
|
+
*
|
|
40
|
+
* @param {number} [length=8] - Length of the subdomain
|
|
41
|
+
* @returns {string} Random subdomain
|
|
42
|
+
*/
|
|
43
|
+
export function generateSubdomain(length = 8) {
|
|
44
|
+
// Use lowercase alphanumeric characters (DNS-safe)
|
|
45
|
+
const bytes = crypto.randomBytes(Math.ceil(length * 3 / 4));
|
|
46
|
+
return bytes
|
|
47
|
+
.toString('base64url')
|
|
48
|
+
.slice(0, length)
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z0-9]/g, 'x'); // Ensure DNS-safe characters only
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Validate a subdomain string for DNS compatibility
|
|
55
|
+
*
|
|
56
|
+
* @param {string} subdomain
|
|
57
|
+
* @returns {boolean} True if valid
|
|
58
|
+
*/
|
|
59
|
+
export function isValidSubdomain(subdomain) {
|
|
60
|
+
// DNS label rules: lowercase alphanumeric + hyphens, 1-63 chars, no leading/trailing hyphen
|
|
61
|
+
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the frpc binary version (if available)
|
|
66
|
+
*
|
|
67
|
+
* @returns {string|null} Version string or null
|
|
68
|
+
*/
|
|
69
|
+
export function getFrpcVersion() {
|
|
70
|
+
try {
|
|
71
|
+
const output = execSync('frpc --version', { stdio: 'pipe', encoding: 'utf8' });
|
|
72
|
+
return output.trim();
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|