pi-bridge 0.1.1
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 +126 -0
- package/bin/pii.js +71 -0
- package/package.json +33 -0
- package/src/local/argv.js +45 -0
- package/src/local/child-process-patch.js +75 -0
- package/src/local/cleanup.js +56 -0
- package/src/local/fake-dir.js +51 -0
- package/src/local/fs-patch.js +639 -0
- package/src/local/http-client.js +130 -0
- package/src/local/path-mapper.js +54 -0
- package/src/local/preload.js +73 -0
- package/src/local/setup.js +79 -0
- package/src/local/ssh.js +20 -0
- package/src/local/tunnel.js +69 -0
- package/src/local/upload.js +81 -0
- package/src/remote/auth.js +33 -0
- package/src/remote/guard.js +49 -0
- package/src/remote/handlers.js +188 -0
- package/src/remote/index.js +43 -0
- package/src/shared/protocol.js +21 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Synchronous HTTP GET to the local file server.
|
|
7
|
+
*
|
|
8
|
+
* The child process writes a JSON envelope to stdout:
|
|
9
|
+
* { ok: true, body: "..." }
|
|
10
|
+
* { ok: false, status: 403, body: "..." }
|
|
11
|
+
*
|
|
12
|
+
* This keeps ALL output on stdout so execFileSync can capture it with
|
|
13
|
+
* stdio:'pipe', preventing HTTP error text from leaking into the terminal.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} port - Server port (127.0.0.1)
|
|
16
|
+
* @param {string} token - Auth token for x-token header
|
|
17
|
+
* @param {string} urlPath - URL path + query string, e.g. '/list?path=%2Froot'
|
|
18
|
+
* @returns {string} - Response body as a UTF-8 string
|
|
19
|
+
* @throws - On HTTP errors or non-2xx status codes
|
|
20
|
+
*/
|
|
21
|
+
const MAX_RETRIES = 5;
|
|
22
|
+
const RETRY_DELAY_MS = 600;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run execFileSync with retries when the tunnel is reconnecting after sleep/wake.
|
|
26
|
+
* ECONNREFUSED (status 0, body contains "ECONNREFUSED") means the tunnel is not
|
|
27
|
+
* yet back up — wait and retry.
|
|
28
|
+
*/
|
|
29
|
+
function defaultSleep() {
|
|
30
|
+
try {
|
|
31
|
+
execFileSync(process.execPath, ['-e', `setTimeout(()=>{},${RETRY_DELAY_MS})`], {
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
} catch (_) {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {() => string} fn - Returns a JSON envelope string
|
|
40
|
+
* @param {() => void} sleep - Injected sleep (defaults to real delay; override in tests)
|
|
41
|
+
*/
|
|
42
|
+
function withRetry(fn, sleep) {
|
|
43
|
+
sleep = sleep || defaultSleep;
|
|
44
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
45
|
+
const raw = fn();
|
|
46
|
+
const result = JSON.parse(raw);
|
|
47
|
+
const isConnRefused = !result.ok && result.status === 0 &&
|
|
48
|
+
typeof result.body === 'string' && result.body.includes('ECONNREFUSED');
|
|
49
|
+
if (!isConnRefused || attempt === MAX_RETRIES) return raw;
|
|
50
|
+
sleep();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function httpGet(port, token, urlPath) {
|
|
55
|
+
const script = `
|
|
56
|
+
const http = require('http');
|
|
57
|
+
let body = '';
|
|
58
|
+
let statusCode = 0;
|
|
59
|
+
const req = http.get(
|
|
60
|
+
{ hostname:'127.0.0.1', port:${JSON.stringify(port)}, path:${JSON.stringify(urlPath)},
|
|
61
|
+
headers:{'x-token':${JSON.stringify(token)}} },
|
|
62
|
+
(res) => {
|
|
63
|
+
statusCode = res.statusCode;
|
|
64
|
+
res.setEncoding('utf8');
|
|
65
|
+
res.on('data', (d) => { body += d; });
|
|
66
|
+
res.on('end', () => {
|
|
67
|
+
process.stdout.write(JSON.stringify({ ok: statusCode >= 200 && statusCode < 300, status: statusCode, body }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
req.on('error', (e) => {
|
|
72
|
+
process.stdout.write(JSON.stringify({ ok: false, status: 0, body: e.message }));
|
|
73
|
+
});
|
|
74
|
+
`;
|
|
75
|
+
const raw = withRetry(() => execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }));
|
|
76
|
+
const result = JSON.parse(raw);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
const err = new Error('HTTP ' + result.status + ': ' + result.body);
|
|
79
|
+
err.statusCode = result.status;
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
return result.body;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Synchronous HTTP POST to the local file server.
|
|
87
|
+
*
|
|
88
|
+
* @param {number} port - Server port (127.0.0.1)
|
|
89
|
+
* @param {string} token - Auth token for x-token header
|
|
90
|
+
* @param {string} urlPath - URL path + query string
|
|
91
|
+
* @param {string} body - Request body (UTF-8 string)
|
|
92
|
+
* @returns {string} - Response body
|
|
93
|
+
* @throws - On HTTP errors or non-2xx status codes
|
|
94
|
+
*/
|
|
95
|
+
function httpPost(port, token, urlPath, body) {
|
|
96
|
+
const script = `
|
|
97
|
+
const http = require('http');
|
|
98
|
+
const body = ${JSON.stringify(body)};
|
|
99
|
+
const bodyBuf = Buffer.from(body, 'utf8');
|
|
100
|
+
let resBody = '';
|
|
101
|
+
let statusCode = 0;
|
|
102
|
+
const req = http.request(
|
|
103
|
+
{ method:'POST', hostname:'127.0.0.1', port:${JSON.stringify(port)}, path:${JSON.stringify(urlPath)},
|
|
104
|
+
headers:{'x-token':${JSON.stringify(token)},'Content-Type':'text/plain; charset=utf-8','Content-Length':bodyBuf.length} },
|
|
105
|
+
(res) => {
|
|
106
|
+
statusCode = res.statusCode;
|
|
107
|
+
res.setEncoding('utf8');
|
|
108
|
+
res.on('data', (d) => { resBody += d; });
|
|
109
|
+
res.on('end', () => {
|
|
110
|
+
process.stdout.write(JSON.stringify({ ok: statusCode >= 200 && statusCode < 300, status: statusCode, body: resBody }));
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
req.on('error', (e) => {
|
|
115
|
+
process.stdout.write(JSON.stringify({ ok: false, status: 0, body: e.message }));
|
|
116
|
+
});
|
|
117
|
+
req.write(bodyBuf);
|
|
118
|
+
req.end();
|
|
119
|
+
`;
|
|
120
|
+
const raw = withRetry(() => execFileSync(process.execPath, ['-e', script], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }));
|
|
121
|
+
const result = JSON.parse(raw);
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
const err = new Error('HTTP ' + result.status + ': ' + result.body);
|
|
124
|
+
err.statusCode = result.status;
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
return result.body;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { httpGet, httpPost, _withRetry: withRetry, _MAX_RETRIES: MAX_RETRIES };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a remote POSIX path to a fake local path under fakeRoot.
|
|
7
|
+
* Handles OS path separators (backslash on Windows, forward slash on Linux/Mac).
|
|
8
|
+
*
|
|
9
|
+
* @param {string} remotePath - Absolute POSIX path on the remote, e.g. '/root/project/file.txt'
|
|
10
|
+
* @param {string} fakeRoot - Local fake root directory, e.g. '/tmp/pi-bridge'
|
|
11
|
+
* @returns {string} - Local path, e.g. '/tmp/pi-bridge/root/project/file.txt'
|
|
12
|
+
*/
|
|
13
|
+
function toFakePath(remotePath, fakeRoot) {
|
|
14
|
+
// Split on '/' and filter empty segments (leading slash produces one)
|
|
15
|
+
const segments = remotePath.split('/').filter(Boolean);
|
|
16
|
+
return path.join(fakeRoot, ...segments);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a fake local path back to a remote POSIX path.
|
|
21
|
+
* Strips fakeRoot prefix and normalises separators to forward slashes.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} fakePath - Local path, e.g. '/tmp/pi-bridge/root/project/file.txt'
|
|
24
|
+
* @param {string} fakeRoot - Local fake root directory, e.g. '/tmp/pi-bridge'
|
|
25
|
+
* @returns {string} - Remote POSIX path, e.g. '/root/project/file.txt'
|
|
26
|
+
*/
|
|
27
|
+
function toRemotePath(fakePath, fakeRoot) {
|
|
28
|
+
// Ensure fakeRoot does not have a trailing separator for consistent slicing
|
|
29
|
+
const root = fakeRoot.endsWith(path.sep)
|
|
30
|
+
? fakeRoot.slice(0, -1)
|
|
31
|
+
: fakeRoot;
|
|
32
|
+
|
|
33
|
+
const relative = fakePath.slice(root.length);
|
|
34
|
+
// On Windows path.sep is '\'; normalise to '/'
|
|
35
|
+
return relative.split(path.sep).join('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check whether a given path is under the fake root.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} p - Path to test
|
|
42
|
+
* @param {string} fakeRoot - Local fake root directory
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function isFakePath(p, fakeRoot) {
|
|
46
|
+
// Normalise fakeRoot to not have trailing separator
|
|
47
|
+
const root = fakeRoot.endsWith(path.sep)
|
|
48
|
+
? fakeRoot.slice(0, -1)
|
|
49
|
+
: fakeRoot;
|
|
50
|
+
|
|
51
|
+
return p === root || p.startsWith(root + path.sep);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { toFakePath, toRemotePath, isFakePath };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* preload.js — Entry point for pi-bridge, loaded via node --require.
|
|
5
|
+
*
|
|
6
|
+
* When --ssh is not present in process.argv, this module is a no-op so that
|
|
7
|
+
* pi can still be used locally without the bridge.
|
|
8
|
+
*
|
|
9
|
+
* Orchestration (all synchronous from the caller's perspective):
|
|
10
|
+
* 1. Parse + strip --ssh user@host:/path from process.argv
|
|
11
|
+
* 2. spawnSync setup.js to upload + start remote server → { port, token, remoteCwd }
|
|
12
|
+
* 3. Create fake local directory skeleton
|
|
13
|
+
* 4. process.chdir to fake local cwd
|
|
14
|
+
* 5. Spawn SSH tunnel (local:port → remote:port)
|
|
15
|
+
* 6. Patch fs.* to intercept fake-root paths
|
|
16
|
+
* 7. Register cleanup handler on process exit
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { spawnSync } = require('child_process');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
const { parseAndStripSshArg } = require('./argv');
|
|
23
|
+
const { createFakeDir } = require('./fake-dir');
|
|
24
|
+
const { startTunnel } = require('./tunnel');
|
|
25
|
+
const { patchFs } = require('./fs-patch');
|
|
26
|
+
const { patchChildProcess } = require('./child-process-patch');
|
|
27
|
+
const { registerCleanup } = require('./cleanup');
|
|
28
|
+
|
|
29
|
+
// --- Step 1: Parse --ssh flag ---
|
|
30
|
+
const sshArg = parseAndStripSshArg();
|
|
31
|
+
if (!sshArg) {
|
|
32
|
+
// No --ssh flag → run pi locally, bridge is inactive
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { remote, remoteCwd } = sshArg;
|
|
37
|
+
|
|
38
|
+
// --- Step 2: Upload + start remote server via setup.js ---
|
|
39
|
+
const setupScript = path.join(__dirname, 'setup.js');
|
|
40
|
+
const setupResult = spawnSync(process.execPath, [setupScript, remote, remoteCwd], {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
timeout: 30000, // 30 seconds
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (setupResult.status !== 0) {
|
|
46
|
+
const errMsg = (setupResult.stderr || '').trim() || 'setup.js exited with status ' + setupResult.status;
|
|
47
|
+
throw new Error('pi-bridge: ' + errMsg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let config;
|
|
51
|
+
try {
|
|
52
|
+
config = JSON.parse(setupResult.stdout.trim());
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new Error('pi-bridge: Failed to parse setup output: ' + setupResult.stdout);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { port, token, sessionId, remotePid } = config;
|
|
58
|
+
|
|
59
|
+
// --- Step 3: Create fake local directory ---
|
|
60
|
+
const { fakeRoot, fakeLocalCwd } = createFakeDir(remoteCwd);
|
|
61
|
+
|
|
62
|
+
// --- Step 4: Change working directory to fake local cwd ---
|
|
63
|
+
process.chdir(fakeLocalCwd);
|
|
64
|
+
|
|
65
|
+
// --- Step 5: Start SSH tunnel ---
|
|
66
|
+
const tunnelProcess = startTunnel(remote, port);
|
|
67
|
+
|
|
68
|
+
// --- Step 6: Patch fs.* and child_process ---
|
|
69
|
+
patchFs(fakeRoot, port, token);
|
|
70
|
+
patchChildProcess(fakeRoot, remote);
|
|
71
|
+
|
|
72
|
+
// --- Step 7: Register cleanup ---
|
|
73
|
+
registerCleanup({ fakeRoot, tunnelProcess, remote, sessionId, remotePid });
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* setup.js — Orchestrator called via spawnSync from preload.js
|
|
5
|
+
*
|
|
6
|
+
* Usage: node setup.js <remote> <remoteCwd>
|
|
7
|
+
*
|
|
8
|
+
* Steps:
|
|
9
|
+
* 1. Upload the bundled remote server script via SSH
|
|
10
|
+
* 2. Start the server on the remote (background process + poll for output)
|
|
11
|
+
* 3. Parse PORT:TOKEN:PID from remote stdout
|
|
12
|
+
* 4. Print { port, token, remoteCwd, sessionId, remotePid } as JSON to stdout
|
|
13
|
+
*
|
|
14
|
+
* Each session gets a unique ID to isolate its log file, preventing races
|
|
15
|
+
* when multiple pi sessions target the same remote working directory.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const crypto = require('crypto');
|
|
19
|
+
const { execFileSync } = require('child_process');
|
|
20
|
+
const { uploadBundle } = require('./upload');
|
|
21
|
+
|
|
22
|
+
const remote = process.argv[2];
|
|
23
|
+
const remoteCwd = process.argv[3];
|
|
24
|
+
|
|
25
|
+
if (!remote || !remoteCwd) {
|
|
26
|
+
process.stderr.write('Usage: node setup.js <remote> <remoteCwd>\n');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const REMOTE_SCRIPT = '/tmp/pi-bridge-server.js';
|
|
31
|
+
|
|
32
|
+
// Unique per-session log file — prevents races when multiple sessions
|
|
33
|
+
// target the same remote working directory simultaneously.
|
|
34
|
+
const sessionId = crypto.randomBytes(4).toString('hex');
|
|
35
|
+
const REMOTE_LOG = '/tmp/pi-bridge-out-' + sessionId + '.txt';
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
// Step 1: Upload the bundled server script
|
|
39
|
+
uploadBundle(remote, REMOTE_SCRIPT);
|
|
40
|
+
|
|
41
|
+
// Step 2: Start server in background on remote.
|
|
42
|
+
// The server prints "PORT:TOKEN:PID\n" on startup then stays alive.
|
|
43
|
+
// We poll the session-unique log file to avoid racing with other sessions.
|
|
44
|
+
const startCmd =
|
|
45
|
+
'node ' + REMOTE_SCRIPT + ' ' + remoteCwd + ' >' + REMOTE_LOG + ' 2>&1 & ' +
|
|
46
|
+
'disown; ' +
|
|
47
|
+
'for i in $(seq 1 30); do ' +
|
|
48
|
+
' line=$(head -1 ' + REMOTE_LOG + ' 2>/dev/null); ' +
|
|
49
|
+
' if [ -n "$line" ]; then echo "$line"; break; fi; ' +
|
|
50
|
+
' sleep 0.1; ' +
|
|
51
|
+
'done';
|
|
52
|
+
|
|
53
|
+
const output = execFileSync('ssh', ['-o', 'BatchMode=yes', remote, startCmd], {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
}).trim();
|
|
56
|
+
|
|
57
|
+
if (!output) {
|
|
58
|
+
throw new Error('Remote server did not print PORT:TOKEN:PID within timeout');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const parts = output.split(':');
|
|
62
|
+
if (parts.length < 3) {
|
|
63
|
+
throw new Error('Unexpected remote output (expected PORT:TOKEN:PID): ' + output);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const port = parseInt(parts[0], 10);
|
|
67
|
+
const token = parts[1];
|
|
68
|
+
const remotePid = parseInt(parts[2], 10);
|
|
69
|
+
|
|
70
|
+
if (!port || !token || !remotePid) {
|
|
71
|
+
throw new Error('Failed to parse PORT:TOKEN:PID from: ' + output);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Step 3: Print result as JSON so preload.js can parse it
|
|
75
|
+
process.stdout.write(JSON.stringify({ port, token, remoteCwd, sessionId, remotePid }) + '\n');
|
|
76
|
+
} catch (err) {
|
|
77
|
+
process.stderr.write('pi-bridge setup failed: ' + err.message + '\n');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
package/src/local/ssh.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute a shell command on a remote machine over SSH synchronously.
|
|
7
|
+
* Uses BatchMode=yes to prevent interactive prompts (fail fast if no key auth).
|
|
8
|
+
*
|
|
9
|
+
* @param {string} remote - SSH target, e.g. 'user@host'
|
|
10
|
+
* @param {string} cmd - Shell command to run on remote
|
|
11
|
+
* @returns {string} - stdout as a trimmed string
|
|
12
|
+
* @throws - On non-zero exit or SSH error
|
|
13
|
+
*/
|
|
14
|
+
function sshExecSync(remote, cmd) {
|
|
15
|
+
return execFileSync('ssh', ['-o', 'BatchMode=yes', remote, cmd], {
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { sshExecSync };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn, execFileSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
const SSH_ARGS = (remote, port) => [
|
|
6
|
+
'-o', 'BatchMode=yes',
|
|
7
|
+
'-o', 'ExitOnForwardFailure=yes',
|
|
8
|
+
'-N',
|
|
9
|
+
'-L', `${port}:127.0.0.1:${port}`,
|
|
10
|
+
remote,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Start an SSH port-forward tunnel with auto-reconnect.
|
|
15
|
+
*
|
|
16
|
+
* If the tunnel process exits (sleep/wake, network drop, etc.) it is
|
|
17
|
+
* automatically restarted after a short back-off delay. The returned
|
|
18
|
+
* handle exposes a `kill()` method used by cleanup.js to stop reconnecting
|
|
19
|
+
* and tear down the current process.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} remote - SSH target, e.g. 'user@host'
|
|
22
|
+
* @param {number} port - Port number to forward (same port both sides)
|
|
23
|
+
* @param {object} [opts]
|
|
24
|
+
* @param {Function} [opts._spawn] - Override spawn (for testing)
|
|
25
|
+
* @returns {{ kill: () => void }}
|
|
26
|
+
*/
|
|
27
|
+
function startTunnel(remote, port, { _spawn = spawn } = {}) {
|
|
28
|
+
let child = null;
|
|
29
|
+
let stopped = false;
|
|
30
|
+
let backoff = 1000; // ms — resets to 1s on each successful reconnect
|
|
31
|
+
|
|
32
|
+
function spawn_() {
|
|
33
|
+
child = _spawn('ssh', SSH_ARGS(remote, port), {
|
|
34
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
35
|
+
detached: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
child.on('exit', (code) => {
|
|
39
|
+
if (stopped) return;
|
|
40
|
+
// Back off up to 10s, then retry indefinitely
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
if (stopped) return;
|
|
43
|
+
backoff = Math.min(backoff * 2, 10000);
|
|
44
|
+
spawn_();
|
|
45
|
+
}, backoff);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Reset backoff once the tunnel has been alive for a while
|
|
49
|
+
setTimeout(() => { backoff = 1000; }, 5000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
spawn_();
|
|
53
|
+
|
|
54
|
+
// Brief sync sleep so the tunnel is ready before the first HTTP call.
|
|
55
|
+
try {
|
|
56
|
+
execFileSync(process.execPath, ['-e', 'setTimeout(()=>{},500)'], {
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
});
|
|
59
|
+
} catch (_) {}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kill() {
|
|
63
|
+
stopped = true;
|
|
64
|
+
if (child) child.kill();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { startTunnel };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
// Root of the pi-bridge project (two levels up from src/local/)
|
|
8
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..', '..');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read the remote source files and bundle them into a single self-contained
|
|
12
|
+
* CommonJS script.
|
|
13
|
+
*
|
|
14
|
+
* Each file's code is inlined directly — module.exports lines are removed,
|
|
15
|
+
* and const/let declarations are converted to var so they hoist to the
|
|
16
|
+
* top-level scope and are accessible by later files in the bundle.
|
|
17
|
+
*
|
|
18
|
+
* Dependency order:
|
|
19
|
+
* 1. shared/protocol.js → exports: ENDPOINTS, TOKEN_HEADER, ERRORS
|
|
20
|
+
* 2. remote/auth.js → exports: generateToken, createAuthMiddleware
|
|
21
|
+
* 3. remote/guard.js → exports: createGuard
|
|
22
|
+
* 4. remote/handlers.js → exports: createHandlers
|
|
23
|
+
* 5. remote/index.js → entry point (no exports)
|
|
24
|
+
*
|
|
25
|
+
* @returns {string} The bundled script as a string
|
|
26
|
+
*/
|
|
27
|
+
function buildBundle() {
|
|
28
|
+
const files = [
|
|
29
|
+
path.join(PROJECT_ROOT, 'src', 'shared', 'protocol.js'),
|
|
30
|
+
path.join(PROJECT_ROOT, 'src', 'remote', 'auth.js'),
|
|
31
|
+
path.join(PROJECT_ROOT, 'src', 'remote', 'guard.js'),
|
|
32
|
+
path.join(PROJECT_ROOT, 'src', 'remote', 'handlers.js'),
|
|
33
|
+
path.join(PROJECT_ROOT, 'src', 'remote', 'index.js'),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const parts = ["'use strict';\n"];
|
|
37
|
+
|
|
38
|
+
for (const filePath of files) {
|
|
39
|
+
let src = fs.readFileSync(filePath, 'utf8');
|
|
40
|
+
|
|
41
|
+
// 1. Remove 'use strict' — we add one at the top
|
|
42
|
+
src = src.replace(/^'use strict';\n?/gm, '');
|
|
43
|
+
|
|
44
|
+
// 2. Remove module.exports = { ... }; lines entirely
|
|
45
|
+
src = src.replace(/^module\.exports\s*=\s*\{[^}]+\};\s*\n?/gm, '');
|
|
46
|
+
|
|
47
|
+
// 3. Replace top-level const/let declarations with var so they hoist
|
|
48
|
+
// to the bundle's top-level scope. Only replace declarations that
|
|
49
|
+
// are at the start of a line (top-level, not inside functions/blocks).
|
|
50
|
+
src = src.replace(/^const\s+/gm, 'var ');
|
|
51
|
+
src = src.replace(/^let\s+/gm, 'var ');
|
|
52
|
+
|
|
53
|
+
parts.push('// --- ' + path.basename(filePath) + ' ---');
|
|
54
|
+
parts.push(src.trim());
|
|
55
|
+
parts.push('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parts.join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Upload the bundled remote server script to the remote machine via SSH.
|
|
63
|
+
* Uses base64 encoding to safely transfer content over the shell.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} remote - SSH target, e.g. 'user@host'
|
|
66
|
+
* @param {string} [destPath] - Remote destination path (default: /tmp/pi-bridge-server.js)
|
|
67
|
+
*/
|
|
68
|
+
function uploadBundle(remote, destPath) {
|
|
69
|
+
destPath = destPath || '/tmp/pi-bridge-server.js';
|
|
70
|
+
const bundle = buildBundle();
|
|
71
|
+
const encoded = Buffer.from(bundle, 'utf8').toString('base64');
|
|
72
|
+
|
|
73
|
+
// Use printf to write the base64 string then pipe through base64 -d
|
|
74
|
+
const cmd = "printf '%s' '" + encoded + "' | base64 -d > " + destPath;
|
|
75
|
+
|
|
76
|
+
execFileSync('ssh', ['-o', 'BatchMode=yes', remote, cmd], {
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { buildBundle, uploadBundle };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generate a cryptographically random token.
|
|
7
|
+
* @returns {string} 64-character hex string
|
|
8
|
+
*/
|
|
9
|
+
function generateToken() {
|
|
10
|
+
return crypto.randomBytes(32).toString('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create an auth middleware that validates the x-token header.
|
|
15
|
+
* Returns a function (req, res, next) that calls next() on success or
|
|
16
|
+
* writes 401 on failure.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} token - The expected token value
|
|
19
|
+
* @returns {function(req, res, next): void}
|
|
20
|
+
*/
|
|
21
|
+
function createAuthMiddleware(token) {
|
|
22
|
+
return function authMiddleware(req, res, next) {
|
|
23
|
+
const provided = req.headers['x-token'];
|
|
24
|
+
if (!provided || provided !== token) {
|
|
25
|
+
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
26
|
+
res.end('Unauthorized');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
next();
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { generateToken, createAuthMiddleware };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a path guard middleware that restricts access to files within remoteCwd.
|
|
7
|
+
* Resolves the path from the query parameter 'path' (or 'from' for rename).
|
|
8
|
+
* Sends 403 if the resolved path is outside remoteCwd.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} remoteCwd - Absolute path on the remote that is the allowed root
|
|
11
|
+
* @returns {function(req, res, next): void}
|
|
12
|
+
*/
|
|
13
|
+
function createGuard(remoteCwd) {
|
|
14
|
+
const normalizedCwd = path.resolve(remoteCwd);
|
|
15
|
+
|
|
16
|
+
return function guardMiddleware(req, res, next) {
|
|
17
|
+
const url = new URL(req.url, 'http://localhost');
|
|
18
|
+
// For rename we need to check both 'from' and 'to'
|
|
19
|
+
const pathsToCheck = [];
|
|
20
|
+
|
|
21
|
+
const p = url.searchParams.get('path');
|
|
22
|
+
if (p !== null) pathsToCheck.push(p);
|
|
23
|
+
|
|
24
|
+
const from = url.searchParams.get('from');
|
|
25
|
+
if (from !== null) pathsToCheck.push(from);
|
|
26
|
+
|
|
27
|
+
const to = url.searchParams.get('to');
|
|
28
|
+
if (to !== null) pathsToCheck.push(to);
|
|
29
|
+
|
|
30
|
+
// If no path params provided, let handler deal with it (will likely 400)
|
|
31
|
+
if (pathsToCheck.length === 0) {
|
|
32
|
+
next();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const p of pathsToCheck) {
|
|
37
|
+
const resolved = path.resolve(p);
|
|
38
|
+
if (!resolved.startsWith(normalizedCwd + path.sep) && resolved !== normalizedCwd) {
|
|
39
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
40
|
+
res.end('Forbidden');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { createGuard };
|