thinknagent 0.1.10 → 0.1.12
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 +12 -1
- package/install/setup.sh +31 -20
- package/install/thinknagent.service +4 -14
- package/lib/agent.js +104 -65
- package/lib/logwatcher.js +41 -27
- package/lib/metrics.js +36 -36
- package/lib/shell.js +37 -32
- package/package.json +2 -1
package/Readme.md
CHANGED
|
@@ -181,6 +181,17 @@ Role is assigned by the room Owner at approval time and can be changed anytime f
|
|
|
181
181
|
|
|
182
182
|
---
|
|
183
183
|
|
|
184
|
+
## Security & Privacy
|
|
185
|
+
|
|
186
|
+
The agent is designed with a "zero-trust" approach to protect your server's credentials, logs, and shell sessions.
|
|
187
|
+
|
|
188
|
+
- 🔒 **Double-Gate Authorization**: New agents connect in `PENDING` mode. They cannot stream metrics or logs until explicitly approved by the room Owner. Upon approval, they receive a signed HMAC `agentToken` for secure reconnection.
|
|
189
|
+
- 🛡️ **Remote Shell Environment Insulation**: Spawning a shell strips all server environment variables, passing only a safe allowlist (`PATH`, `HOME`, `SHELL`, `TERM`, `LANG`). Your database credentials, cloud API keys, and environment secrets remain completely safe.
|
|
190
|
+
- 🚫 **Path Traversal & Sensitive File Blocks**: Log files are verified using absolute path checking. They must belong to allowed directories (`/var/log`, `/home`, `/root`, `/tmp`) and are explicitly blocked if they contain sensitive directories or files like `.ssh/id_rsa`, `.env`, `/etc/passwd`, `/etc/shadow`, etc.
|
|
191
|
+
- 💬 **SSL Transport**: All WebSocket and HTTP traffic between the agent and the server is encrypted using HTTPS/WSS.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
184
195
|
## Auth Flow
|
|
185
196
|
thinknagent init
|
|
186
197
|
→ generates agentId
|
|
@@ -241,7 +252,7 @@ pm2 startup
|
|
|
241
252
|
|
|
242
253
|
**Using systemd:**
|
|
243
254
|
```bash
|
|
244
|
-
sudo nano /etc/systemd/system/
|
|
255
|
+
sudo nano /etc/systemd/system/thinknagent.service
|
|
245
256
|
```
|
|
246
257
|
|
|
247
258
|
```ini
|
package/install/setup.sh
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# ThinkNCollab Agent — installer
|
|
3
|
-
# Usage: curl -fsSL https://thinkncollab.com/install-agent.sh | bash -s -- --server https://thinkncollab.com --name my-server
|
|
4
|
-
|
|
3
|
+
# Usage: curl -fsSL https://thinkncollab.com/install-agent.sh | bash -s -- --server https://thinkncollab.com --name my-server --room <roomId>
|
|
5
4
|
set -euo pipefail
|
|
6
5
|
|
|
7
6
|
SERVER=""
|
|
8
7
|
NAME=""
|
|
8
|
+
ROOM=""
|
|
9
9
|
GPU=false
|
|
10
10
|
LOGS=""
|
|
11
11
|
SYSTEMD=false
|
|
12
12
|
|
|
13
|
-
# Parse args
|
|
14
13
|
while [[ $# -gt 0 ]]; do
|
|
15
14
|
case $1 in
|
|
16
|
-
--server)
|
|
17
|
-
--name)
|
|
18
|
-
--
|
|
19
|
-
--
|
|
20
|
-
--
|
|
15
|
+
--server) SERVER="$2"; shift 2 ;;
|
|
16
|
+
--name) NAME="$2"; shift 2 ;;
|
|
17
|
+
--room) ROOM="$2"; shift 2 ;;
|
|
18
|
+
--gpu) GPU=true; shift ;;
|
|
19
|
+
--logs) LOGS="$2"; shift 2 ;;
|
|
20
|
+
--systemd) SYSTEMD=true; shift ;;
|
|
21
21
|
*) echo "Unknown option: $1"; exit 1 ;;
|
|
22
22
|
esac
|
|
23
23
|
done
|
|
24
24
|
|
|
25
25
|
[[ -z "$SERVER" ]] && echo "Error: --server required" && exit 1
|
|
26
26
|
[[ -z "$NAME" ]] && echo "Error: --name required" && exit 1
|
|
27
|
+
[[ -z "$ROOM" ]] && echo "Error: --room required" && exit 1
|
|
27
28
|
|
|
28
29
|
echo ""
|
|
29
30
|
echo " ThinkNCollab Agent Installer"
|
|
30
31
|
echo " ──────────────────────────────"
|
|
31
32
|
|
|
32
|
-
# Check Node.js >= 18
|
|
33
33
|
NODE_VER=$(node --version 2>/dev/null | cut -d. -f1 | tr -d 'v' || echo "0")
|
|
34
34
|
if [[ "$NODE_VER" -lt 18 ]]; then
|
|
35
35
|
echo " Error: Node.js 18+ required (found: $(node --version 2>/dev/null || echo 'not found'))"
|
|
@@ -37,34 +37,45 @@ if [[ "$NODE_VER" -lt 18 ]]; then
|
|
|
37
37
|
fi
|
|
38
38
|
echo " Node.js : $(node --version) ✓"
|
|
39
39
|
|
|
40
|
-
# Install package
|
|
41
40
|
echo " Installing thinknagent..."
|
|
42
41
|
npm install -g thinknagent --silent
|
|
43
42
|
|
|
44
|
-
# Init
|
|
45
43
|
GPU_FLAG=""
|
|
46
44
|
$GPU && GPU_FLAG="--gpu"
|
|
45
|
+
|
|
47
46
|
LOGS_FLAG=""
|
|
48
47
|
[[ -n "$LOGS" ]] && LOGS_FLAG="--logs $LOGS"
|
|
49
48
|
|
|
50
|
-
thinknagent init --server "$SERVER" --name "$NAME" $GPU_FLAG $LOGS_FLAG
|
|
49
|
+
thinknagent init --server "$SERVER" --name "$NAME" --room "$ROOM" $GPU_FLAG $LOGS_FLAG
|
|
51
50
|
|
|
52
|
-
# Optional systemd setup
|
|
53
51
|
if $SYSTEMD; then
|
|
54
52
|
echo ""
|
|
55
53
|
echo " Setting up systemd service..."
|
|
56
|
-
|
|
57
|
-
# Create system user
|
|
58
54
|
id thinknagent &>/dev/null || useradd --system --no-create-home thinknagent
|
|
55
|
+
AGENT_BIN="$(which thinknagent)"
|
|
56
|
+
cat > /etc/systemd/system/thinknagent.service << SVCEOF
|
|
57
|
+
[Unit]
|
|
58
|
+
Description=ThinkNCollab Agent
|
|
59
|
+
After=network-online.target
|
|
60
|
+
Wants=network-online.target
|
|
59
61
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
[Service]
|
|
63
|
+
User=thinknagent
|
|
64
|
+
Group=thinknagent
|
|
65
|
+
ExecStart=${AGENT_BIN} start
|
|
66
|
+
Restart=on-failure
|
|
67
|
+
RestartSec=10
|
|
68
|
+
Environment=NODE_ENV=production
|
|
69
|
+
StandardOutput=journal
|
|
70
|
+
StandardError=journal
|
|
71
|
+
SyslogIdentifier=thinknagent
|
|
63
72
|
|
|
73
|
+
[Install]
|
|
74
|
+
WantedBy=multi-user.target
|
|
75
|
+
SVCEOF
|
|
64
76
|
systemctl daemon-reload
|
|
65
77
|
systemctl enable thinknagent
|
|
66
78
|
systemctl start thinknagent
|
|
67
|
-
|
|
68
79
|
echo " systemd service: enabled + started ✓"
|
|
69
80
|
echo " Logs: journalctl -u thinknagent -f"
|
|
70
81
|
else
|
|
@@ -76,4 +87,4 @@ fi
|
|
|
76
87
|
|
|
77
88
|
echo ""
|
|
78
89
|
echo " ✓ Done. Open ThinkNCollab and approve this agent in your room's DevOps Wall."
|
|
79
|
-
echo ""
|
|
90
|
+
echo ""
|
|
@@ -5,31 +5,21 @@ After=network-online.target
|
|
|
5
5
|
Wants=network-online.target
|
|
6
6
|
|
|
7
7
|
[Service]
|
|
8
|
-
# Run as a dedicated low-privilege user for security
|
|
9
|
-
# Create with: sudo useradd --system --no-create-home thinknagent
|
|
10
8
|
User=thinknagent
|
|
11
9
|
Group=thinknagent
|
|
12
|
-
|
|
13
|
-
# Point to wherever thinknagent was installed globally
|
|
14
|
-
ExecStart=/usr/bin/node /usr/local/bin/thinknagent start
|
|
15
|
-
|
|
16
|
-
# Restart policy
|
|
10
|
+
ExecStart=/usr/bin/env thinknagent start
|
|
17
11
|
Restart=on-failure
|
|
18
12
|
RestartSec=10
|
|
19
13
|
StartLimitInterval=60
|
|
20
14
|
StartLimitBurst=3
|
|
21
15
|
|
|
22
|
-
# Hardening — limit what the process can do
|
|
23
16
|
NoNewPrivileges=true
|
|
24
17
|
ProtectSystem=strict
|
|
25
|
-
ProtectHome=
|
|
26
|
-
ReadWritePaths
|
|
27
|
-
PrivateTmp=true
|
|
18
|
+
ProtectHome=false
|
|
19
|
+
ReadWritePaths=%h/.thinknagent
|
|
28
20
|
|
|
29
|
-
|
|
21
|
+
PrivateTmp=true
|
|
30
22
|
Environment=NODE_ENV=production
|
|
31
|
-
|
|
32
|
-
# Logging — journald will capture stdout/stderr
|
|
33
23
|
StandardOutput=journal
|
|
34
24
|
StandardError=journal
|
|
35
25
|
SyslogIdentifier=thinknagent
|
package/lib/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
3
4
|
const Connection = require('./connect');
|
|
4
5
|
const MetricsPoller = require('./metrics');
|
|
5
6
|
const LogWatcher = require('./logwatcher');
|
|
@@ -7,102 +8,140 @@ const AlertEngine = require('./alerts');
|
|
|
7
8
|
const ShellBridge = require('./shell');
|
|
8
9
|
const store = require('./store');
|
|
9
10
|
|
|
11
|
+
// allowed base dirs for log streaming
|
|
12
|
+
// room owner agent:logs_updated se bahar ke paths reject ho jayenge
|
|
13
|
+
const LOG_PATH_ALLOWLIST = [
|
|
14
|
+
'/var/log',
|
|
15
|
+
'/home',
|
|
16
|
+
'/root',
|
|
17
|
+
'/tmp',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function isSafeLogPath(logPath) {
|
|
21
|
+
const resolved = path.resolve(logPath);
|
|
22
|
+
|
|
23
|
+
// path traversal check — resolved path allowlist mein hona chahiye
|
|
24
|
+
const allowed = LOG_PATH_ALLOWLIST.some(base => resolved.startsWith(base + path.sep) || resolved === base);
|
|
25
|
+
if (!allowed) {
|
|
26
|
+
console.warn(`[agent] Rejected log path (not in allowlist): ${resolved}`);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// sensitive files blocklist
|
|
31
|
+
const BLOCKED = [
|
|
32
|
+
'/etc/passwd', '/etc/shadow', '/etc/sudoers',
|
|
33
|
+
'.ssh', '.gnupg', '.aws', '.env',
|
|
34
|
+
'id_rsa', 'id_ed25519', 'authorized_keys',
|
|
35
|
+
];
|
|
36
|
+
const blocked = BLOCKED.some(b => resolved.includes(b));
|
|
37
|
+
if (blocked) {
|
|
38
|
+
console.warn(`[agent] Rejected sensitive log path: ${resolved}`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function validateRules(rules) {
|
|
46
|
+
if (!Array.isArray(rules)) return [];
|
|
47
|
+
return rules.filter(r =>
|
|
48
|
+
r && typeof r.id === 'string' &&
|
|
49
|
+
typeof r.metric === 'string' &&
|
|
50
|
+
typeof r.op === 'string' &&
|
|
51
|
+
typeof r.value === 'number'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
10
55
|
class Agent {
|
|
11
56
|
constructor() {
|
|
12
57
|
const cfg = store.read();
|
|
13
58
|
|
|
14
|
-
this.
|
|
59
|
+
this.alerts = new AlertEngine({
|
|
60
|
+
connection: null,
|
|
61
|
+
rules: cfg.alerts || [],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.conn = new Connection({
|
|
15
65
|
serverUrl: cfg.serverUrl,
|
|
16
66
|
onReady: ({ role, roomId }) => this._onReady(role, roomId),
|
|
17
67
|
onDisconnect: ({ reason }) => this._onDisconnect(reason),
|
|
18
68
|
onRoleUpdate: ({ role }) => this._onRoleUpdate(role),
|
|
19
69
|
});
|
|
20
70
|
|
|
21
|
-
this.
|
|
22
|
-
connection: this.conn,
|
|
23
|
-
gpu: cfg.gpu || false,
|
|
24
|
-
});
|
|
71
|
+
this.alerts.conn = this.conn;
|
|
25
72
|
|
|
26
|
-
this.
|
|
27
|
-
connection:
|
|
28
|
-
|
|
73
|
+
this.metrics = new MetricsPoller({
|
|
74
|
+
connection: this.conn,
|
|
75
|
+
gpu: cfg.gpu || false,
|
|
76
|
+
onMetricsEmit: (payload) => this.alerts.evaluate(payload),
|
|
29
77
|
});
|
|
30
78
|
|
|
31
|
-
this.
|
|
79
|
+
this.logs = new LogWatcher({
|
|
32
80
|
connection: this.conn,
|
|
33
|
-
|
|
81
|
+
logPaths: (cfg.logs || []).filter(isSafeLogPath),
|
|
34
82
|
});
|
|
35
83
|
|
|
36
84
|
this.shell = new ShellBridge({ connection: this.conn });
|
|
37
|
-
|
|
38
85
|
this._active = false;
|
|
39
86
|
}
|
|
40
87
|
|
|
41
|
-
start() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
start() {
|
|
89
|
+
const cfg = store.read();
|
|
90
|
+
if (!cfg.serverUrl) {
|
|
91
|
+
console.error('[agent] Not initialized.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
47
94
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// ✅ Pehle connect karo
|
|
51
|
-
this.conn.connect();
|
|
52
|
-
|
|
53
|
-
// ✅ Socket ready hone ke baad events bind karo
|
|
54
|
-
// conn.socket ab available hai kyunki connect() sync mein socket banata hai
|
|
55
|
-
this.conn.socket.on('agent:rules_updated', ({ rules }) => {
|
|
56
|
-
store.set('alerts', rules);
|
|
57
|
-
this.alerts.reloadRules(rules);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
this.conn.socket.on('agent:send_metrics_now', () => {
|
|
61
|
-
console.log('[agent] Metrics poll requested');
|
|
62
|
-
this.metrics.pollNow();
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
this.conn.socket.on('agent:logs_updated', ({ logs }) => {
|
|
66
|
-
store.set('logs', logs);
|
|
67
|
-
this.logs.stop();
|
|
68
|
-
this.logs = new LogWatcher({ connection: this.conn, logPaths: logs });
|
|
69
|
-
if (this._active) this.logs.start(); // ✅ sirf tab start karo agar active ho
|
|
70
|
-
});
|
|
95
|
+
console.log(`[agent] Connecting to ${cfg.serverUrl}...`);
|
|
96
|
+
this.conn.connect();
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
98
|
+
this.conn.socket.on('agent:rules_updated', ({ rules }) => {
|
|
99
|
+
const safe = validateRules(rules);
|
|
100
|
+
store.set('alerts', safe);
|
|
101
|
+
this.alerts.reloadRules(safe);
|
|
102
|
+
});
|
|
75
103
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
104
|
+
this.conn.socket.on('agent:send_metrics_now', () => {
|
|
105
|
+
console.log('[agent] Metrics poll requested');
|
|
106
|
+
this.metrics.pollNow();
|
|
107
|
+
});
|
|
82
108
|
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
this.conn.socket.on('agent:logs_updated', ({ logs }) => {
|
|
110
|
+
if (!Array.isArray(logs)) return;
|
|
85
111
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
};
|
|
112
|
+
// har path validate karo — koi bhi unsafe ho toh poori update reject
|
|
113
|
+
const safeLogs = logs.filter(isSafeLogPath);
|
|
114
|
+
if (safeLogs.length !== logs.length) {
|
|
115
|
+
console.warn(`[agent] Dropped ${logs.length - safeLogs.length} unsafe log path(s) from server update`);
|
|
116
|
+
}
|
|
92
117
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
118
|
+
store.set('logs', safeLogs);
|
|
119
|
+
this.logs.stop();
|
|
120
|
+
this.logs = new LogWatcher({ connection: this.conn, logPaths: safeLogs });
|
|
121
|
+
if (this._active) this.logs.start();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
process.on('SIGTERM', () => this._shutdown('SIGTERM'));
|
|
125
|
+
process.on('SIGINT', () => this._shutdown('SIGINT'));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
_onReady(role, roomId) {
|
|
129
|
+
if (this._active) {
|
|
130
|
+
console.log(`[agent] Reconnected. Role: ${role}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
this._active = true;
|
|
134
|
+
console.log(`[agent] Active! Role: ${role} | Room: ${roomId}`);
|
|
135
|
+
this.metrics.start();
|
|
136
|
+
this.logs.start();
|
|
137
|
+
this.shell.start();
|
|
138
|
+
}
|
|
98
139
|
|
|
99
140
|
_onDisconnect(reason) {
|
|
100
141
|
this.shell.killAll();
|
|
101
|
-
// metrics + logs keep their intervals; socket.io will reconnect automatically
|
|
102
142
|
}
|
|
103
143
|
|
|
104
144
|
_onRoleUpdate({ role }) {
|
|
105
|
-
// If role was downgraded to 'monitor', kill open shell sessions
|
|
106
145
|
if (!this.conn.hasRole('shell')) {
|
|
107
146
|
this.shell.killAll();
|
|
108
147
|
}
|
|
@@ -118,4 +157,4 @@ _onReady(role, roomId) {
|
|
|
118
157
|
}
|
|
119
158
|
}
|
|
120
159
|
|
|
121
|
-
module.exports = Agent;
|
|
160
|
+
module.exports = Agent;
|
package/lib/logwatcher.js
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const chokidar = require('chokidar');
|
|
5
6
|
const { EventEmitter } = require('events');
|
|
6
7
|
|
|
7
|
-
const BUFFER_LINES = 100;
|
|
8
|
-
const CHUNK_DELAY = 50;
|
|
8
|
+
const BUFFER_LINES = 100;
|
|
9
|
+
const CHUNK_DELAY = 50;
|
|
9
10
|
|
|
10
11
|
class LogWatcher extends EventEmitter {
|
|
11
12
|
constructor({ connection, logPaths = [] }) {
|
|
12
13
|
super();
|
|
13
14
|
this.conn = connection;
|
|
14
15
|
this.logPaths = logPaths;
|
|
15
|
-
this._watchers = new Map(); // path → {
|
|
16
|
+
this._watchers = new Map(); // path → { watcher, size, timer, lines }
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
start() {
|
|
@@ -27,8 +28,8 @@ class LogWatcher extends EventEmitter {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
stop() {
|
|
30
|
-
for (const [
|
|
31
|
-
|
|
31
|
+
for (const [, w] of this._watchers) {
|
|
32
|
+
w.watcher.close();
|
|
32
33
|
if (w.timer) clearTimeout(w.timer);
|
|
33
34
|
}
|
|
34
35
|
this._watchers.clear();
|
|
@@ -37,43 +38,51 @@ class LogWatcher extends EventEmitter {
|
|
|
37
38
|
_watch(filePath) {
|
|
38
39
|
const absPath = path.resolve(filePath);
|
|
39
40
|
|
|
40
|
-
if (!fs.existsSync(absPath)) {
|
|
41
|
-
console.warn(`[logs] File not found: ${absPath} — will retry when it appears`);
|
|
42
|
-
// retry every 10s in case log file is created later
|
|
43
|
-
setTimeout(() => this._watch(filePath), 10000);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const stat = fs.statSync(absPath);
|
|
48
41
|
const state = {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
watcher: null,
|
|
43
|
+
size: fs.existsSync(absPath) ? fs.statSync(absPath).size : 0,
|
|
44
|
+
timer: null,
|
|
45
|
+
lines: [],
|
|
52
46
|
};
|
|
53
47
|
|
|
54
|
-
//
|
|
55
|
-
|
|
48
|
+
// send tail on startup if file exists
|
|
49
|
+
if (fs.existsSync(absPath)) {
|
|
50
|
+
this._sendTail(absPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const watcher = chokidar.watch(absPath, {
|
|
54
|
+
persistent: true,
|
|
55
|
+
usePolling: false, // inotify on Linux — no polling overhead
|
|
56
|
+
ignoreInitial: true,
|
|
57
|
+
awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 50 },
|
|
58
|
+
});
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
watcher.on('add', () => {
|
|
61
|
+
// file created after watch started (e.g. log rotation)
|
|
62
|
+
state.size = 0;
|
|
63
|
+
this._sendTail(absPath);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
watcher.on('change', (fpath, stats) => {
|
|
67
|
+
const newSize = stats ? stats.size : fs.statSync(absPath).size;
|
|
68
|
+
|
|
69
|
+
if (newSize < state.size) {
|
|
70
|
+
// log rotated — reset
|
|
60
71
|
state.size = 0;
|
|
61
72
|
}
|
|
62
|
-
if (curr.size === prev.size) return;
|
|
63
73
|
|
|
64
|
-
const newBytes =
|
|
74
|
+
const newBytes = newSize - state.size;
|
|
65
75
|
if (newBytes <= 0) return;
|
|
66
76
|
|
|
67
77
|
const buf = Buffer.alloc(newBytes);
|
|
68
78
|
const fd = fs.openSync(absPath, 'r');
|
|
69
79
|
fs.readSync(fd, buf, 0, newBytes, state.size);
|
|
70
80
|
fs.closeSync(fd);
|
|
71
|
-
state.size =
|
|
81
|
+
state.size = newSize;
|
|
72
82
|
|
|
73
83
|
const newLines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
74
84
|
state.lines.push(...newLines);
|
|
75
85
|
|
|
76
|
-
// debounce — batch lines before emit
|
|
77
86
|
clearTimeout(state.timer);
|
|
78
87
|
state.timer = setTimeout(() => {
|
|
79
88
|
const toSend = state.lines.splice(0);
|
|
@@ -86,6 +95,11 @@ class LogWatcher extends EventEmitter {
|
|
|
86
95
|
}, CHUNK_DELAY);
|
|
87
96
|
});
|
|
88
97
|
|
|
98
|
+
watcher.on('error', (err) => {
|
|
99
|
+
console.error(`[logs] Watch error on ${absPath}:`, err.message);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
state.watcher = watcher;
|
|
89
103
|
this._watchers.set(absPath, state);
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -96,7 +110,7 @@ class LogWatcher extends EventEmitter {
|
|
|
96
110
|
if (lines.length) {
|
|
97
111
|
this.conn.emit('agent:logs:tail', {
|
|
98
112
|
file: filePath,
|
|
99
|
-
lines: lines.map(l => ({ ts: null, text: l })),
|
|
113
|
+
lines: lines.map(l => ({ ts: null, text: l })),
|
|
100
114
|
});
|
|
101
115
|
}
|
|
102
116
|
} catch (err) {
|
|
@@ -105,4 +119,4 @@ class LogWatcher extends EventEmitter {
|
|
|
105
119
|
}
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
module.exports = LogWatcher;
|
|
122
|
+
module.exports = LogWatcher;
|
package/lib/metrics.js
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
const si = require('systeminformation');
|
|
4
4
|
|
|
5
|
-
const POLL_INTERVAL = 5000;
|
|
5
|
+
const POLL_INTERVAL = 5000;
|
|
6
6
|
|
|
7
7
|
class MetricsPoller {
|
|
8
|
-
constructor({ connection, gpu = false }) {
|
|
9
|
-
this.conn
|
|
10
|
-
this.gpu
|
|
11
|
-
this.
|
|
12
|
-
this.
|
|
8
|
+
constructor({ connection, gpu = false, onMetricsEmit = null }) {
|
|
9
|
+
this.conn = connection;
|
|
10
|
+
this.gpu = gpu;
|
|
11
|
+
this.onMetricsEmit = onMetricsEmit; // alert engine callback — no monkey-patch needed
|
|
12
|
+
this._timer = null;
|
|
13
|
+
this._history = [];
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
start() {
|
|
16
|
-
this._poll();
|
|
17
|
+
this._poll();
|
|
17
18
|
this._timer = setInterval(() => this._poll(), POLL_INTERVAL);
|
|
18
19
|
console.log('[metrics] Poller started');
|
|
19
20
|
}
|
|
@@ -22,50 +23,49 @@ class MetricsPoller {
|
|
|
22
23
|
if (this._timer) clearInterval(this._timer);
|
|
23
24
|
console.log('[metrics] Poller stopped');
|
|
24
25
|
}
|
|
26
|
+
|
|
25
27
|
pollNow() {
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
+
return this._poll();
|
|
29
|
+
}
|
|
28
30
|
|
|
29
31
|
async _poll() {
|
|
30
32
|
try {
|
|
31
|
-
const [cpu, mem, disk, net,
|
|
33
|
+
const [cpu, mem, disk, net, procs] = await Promise.all([
|
|
32
34
|
si.currentLoad(),
|
|
33
35
|
si.mem(),
|
|
34
36
|
si.fsSize(),
|
|
35
37
|
si.networkStats(),
|
|
36
|
-
si.currentLoad(), // for load average
|
|
37
38
|
si.processes(),
|
|
38
39
|
]);
|
|
39
40
|
|
|
40
41
|
const payload = {
|
|
41
42
|
ts: Date.now(),
|
|
42
43
|
cpu: {
|
|
43
|
-
usage:
|
|
44
|
-
cores:
|
|
45
|
-
loadAvg:
|
|
44
|
+
usage: parseFloat(cpu.currentLoad.toFixed(1)),
|
|
45
|
+
cores: cpu.cpus?.length ?? 0,
|
|
46
|
+
loadAvg: cpu.avgLoad ?? 0,
|
|
46
47
|
},
|
|
47
48
|
memory: {
|
|
48
|
-
total:
|
|
49
|
-
used:
|
|
50
|
-
free:
|
|
51
|
-
usedPct:
|
|
49
|
+
total: mem.total,
|
|
50
|
+
used: mem.used,
|
|
51
|
+
free: mem.free,
|
|
52
|
+
usedPct: parseFloat(((mem.used / mem.total) * 100).toFixed(1)),
|
|
52
53
|
},
|
|
53
54
|
disk: disk.map(d => ({
|
|
54
|
-
fs:
|
|
55
|
-
mount:
|
|
56
|
-
size:
|
|
57
|
-
used:
|
|
58
|
-
usedPct:
|
|
55
|
+
fs: d.fs,
|
|
56
|
+
mount: d.mount,
|
|
57
|
+
size: d.size,
|
|
58
|
+
used: d.used,
|
|
59
|
+
usedPct: parseFloat(d.use?.toFixed(1) ?? 0),
|
|
59
60
|
})),
|
|
60
61
|
network: net.map(n => ({
|
|
61
|
-
iface:
|
|
62
|
-
rxSec:
|
|
63
|
-
txSec:
|
|
62
|
+
iface: n.iface,
|
|
63
|
+
rxSec: n.rx_sec ?? 0,
|
|
64
|
+
txSec: n.tx_sec ?? 0,
|
|
64
65
|
})),
|
|
65
66
|
processes: {
|
|
66
|
-
total:
|
|
67
|
+
total: procs.all,
|
|
67
68
|
running: procs.running,
|
|
68
|
-
// top 5 by CPU
|
|
69
69
|
top: (procs.list || [])
|
|
70
70
|
.sort((a, b) => b.pcpu - a.pcpu)
|
|
71
71
|
.slice(0, 5)
|
|
@@ -73,33 +73,33 @@ class MetricsPoller {
|
|
|
73
73
|
},
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
-
// optional GPU
|
|
77
76
|
if (this.gpu) {
|
|
78
77
|
try {
|
|
79
78
|
const gpuData = await si.graphics();
|
|
80
79
|
payload.gpu = gpuData.controllers?.map(g => ({
|
|
81
|
-
model:
|
|
80
|
+
model: g.model,
|
|
82
81
|
utilizationGpu: g.utilizationGpu ?? null,
|
|
83
|
-
memUsed:
|
|
84
|
-
memTotal:
|
|
85
|
-
tempC:
|
|
82
|
+
memUsed: g.memoryUsed ?? null,
|
|
83
|
+
memTotal: g.memoryTotal ?? null,
|
|
84
|
+
tempC: g.temperatureGpu ?? null,
|
|
86
85
|
}));
|
|
87
86
|
} catch {
|
|
88
87
|
// nvidia-smi not available — silently skip
|
|
89
88
|
}
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
// rolling history (keep last 60 points)
|
|
93
91
|
this._history.push({ ts: payload.ts, cpu: payload.cpu.usage, mem: payload.memory.usedPct });
|
|
94
92
|
if (this._history.length > 60) this._history.shift();
|
|
95
|
-
|
|
96
93
|
payload.history = this._history;
|
|
97
94
|
|
|
95
|
+
// pehle emit karo, phir alert engine ko directly pass karo — no interception
|
|
98
96
|
this.conn.emit('agent:metrics', payload);
|
|
97
|
+
this.onMetricsEmit?.(payload);
|
|
98
|
+
|
|
99
99
|
} catch (err) {
|
|
100
100
|
console.error('[metrics] Poll error:', err.message);
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
module.exports = MetricsPoller;
|
|
105
|
+
module.exports = MetricsPoller;
|
package/lib/shell.js
CHANGED
|
@@ -4,54 +4,65 @@ let pty;
|
|
|
4
4
|
try {
|
|
5
5
|
pty = require('node-pty');
|
|
6
6
|
} catch {
|
|
7
|
-
pty = null;
|
|
7
|
+
pty = null;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
// explicit allowlist — agent process ke secrets PTY mein nahi jayenge
|
|
11
|
+
const PTY_ENV_ALLOWLIST = new Set([
|
|
12
|
+
'PATH', 'HOME', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
13
|
+
'USER', 'LOGNAME', 'HOSTNAME', 'TZ', 'COLORTERM', 'DISPLAY',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function buildSafeEnv() {
|
|
17
|
+
const safe = {};
|
|
18
|
+
for (const key of PTY_ENV_ALLOWLIST) {
|
|
19
|
+
if (process.env[key] !== undefined) safe[key] = process.env[key];
|
|
20
|
+
}
|
|
21
|
+
safe.TERM = 'xterm-256color';
|
|
22
|
+
safe.THINKNCOLLAB_AGENT = '1';
|
|
23
|
+
return safe;
|
|
24
|
+
}
|
|
17
25
|
|
|
18
26
|
class ShellBridge {
|
|
19
27
|
constructor({ connection }) {
|
|
20
|
-
this.conn
|
|
21
|
-
this._sessions = new Map();
|
|
28
|
+
this.conn = connection;
|
|
29
|
+
this._sessions = new Map();
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
start() {
|
|
33
|
+
const s = this.conn.socket;
|
|
34
|
+
|
|
25
35
|
if (!pty) {
|
|
26
36
|
console.warn('[shell] node-pty not installed — shell feature disabled');
|
|
37
|
+
s.on('shell:open', ({ sessionId }) => {
|
|
38
|
+
s.emit('shell:error', {
|
|
39
|
+
sessionId,
|
|
40
|
+
reason: 'node-pty is not installed on this agent. Run: npm install -g node-pty',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
27
43
|
return;
|
|
28
44
|
}
|
|
29
45
|
|
|
30
|
-
const s = this.conn.socket;
|
|
31
|
-
|
|
32
|
-
// Browser requests a new shell session
|
|
33
46
|
s.on('shell:open', ({ sessionId, cols = 80, rows = 24 }) => {
|
|
34
47
|
if (!this.conn.hasRole('shell')) {
|
|
35
|
-
s.emit('shell:error', {
|
|
48
|
+
s.emit('shell:error', {
|
|
49
|
+
sessionId,
|
|
50
|
+
reason: 'Insufficient role — shell access not granted for this agent',
|
|
51
|
+
});
|
|
36
52
|
return;
|
|
37
53
|
}
|
|
38
|
-
|
|
54
|
+
|
|
55
|
+
if (this._sessions.has(sessionId)) return;
|
|
39
56
|
|
|
40
57
|
const proc = pty.spawn(process.env.SHELL || '/bin/bash', [], {
|
|
41
58
|
name: 'xterm-256color',
|
|
42
59
|
cols,
|
|
43
60
|
rows,
|
|
44
|
-
cwd:
|
|
45
|
-
env:
|
|
46
|
-
...process.env,
|
|
47
|
-
TERM: 'xterm-256color',
|
|
48
|
-
THINKNCOLLAB_AGENT: '1', // agent can inject this env so shell scripts know
|
|
49
|
-
},
|
|
61
|
+
cwd: process.env.HOME || '/',
|
|
62
|
+
env: buildSafeEnv(), // only allowlisted vars — no secrets
|
|
50
63
|
});
|
|
51
64
|
|
|
52
|
-
proc.onData(data => {
|
|
53
|
-
s.emit('shell:data', { sessionId, data });
|
|
54
|
-
});
|
|
65
|
+
proc.onData(data => s.emit('shell:data', { sessionId, data }));
|
|
55
66
|
|
|
56
67
|
proc.onExit(({ exitCode }) => {
|
|
57
68
|
s.emit('shell:exit', { sessionId, exitCode });
|
|
@@ -64,24 +75,19 @@ class ShellBridge {
|
|
|
64
75
|
console.log(`[shell] Session ${sessionId} opened (${cols}x${rows})`);
|
|
65
76
|
});
|
|
66
77
|
|
|
67
|
-
// Browser → server → agent: user typed something
|
|
68
78
|
s.on('shell:input', ({ sessionId, data }) => {
|
|
69
79
|
const proc = this._sessions.get(sessionId);
|
|
70
80
|
if (!proc) return;
|
|
71
81
|
proc.write(data);
|
|
72
82
|
});
|
|
73
83
|
|
|
74
|
-
// Browser resized terminal
|
|
75
84
|
s.on('shell:resize', ({ sessionId, cols, rows }) => {
|
|
76
85
|
const proc = this._sessions.get(sessionId);
|
|
77
86
|
if (!proc) return;
|
|
78
87
|
proc.resize(cols, rows);
|
|
79
88
|
});
|
|
80
89
|
|
|
81
|
-
|
|
82
|
-
s.on('shell:close', ({ sessionId }) => {
|
|
83
|
-
this._killSession(sessionId);
|
|
84
|
-
});
|
|
90
|
+
s.on('shell:close', ({ sessionId }) => this._killSession(sessionId));
|
|
85
91
|
|
|
86
92
|
console.log('[shell] Bridge ready');
|
|
87
93
|
}
|
|
@@ -95,10 +101,9 @@ class ShellBridge {
|
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
103
|
|
|
98
|
-
// Kill all sessions on disconnect
|
|
99
104
|
killAll() {
|
|
100
105
|
for (const [id] of this._sessions) this._killSession(id);
|
|
101
106
|
}
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
module.exports = ShellBridge;
|
|
109
|
+
module.exports = ShellBridge;
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinknagent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "ThinkNCollab server agent — metrics, logs, alerts, shell bridge",
|
|
5
5
|
"main": "lib/agent.js",
|
|
6
|
+
"author": "ThinkNCollab Team <team@thinkncollab.com>",
|
|
6
7
|
"bin": {
|
|
7
8
|
"thinknagent": "./bin/thinknagent.js"
|
|
8
9
|
},
|