@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 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
+ }