thinknagent 0.1.9 → 0.1.11
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 -12
- 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/tncproject +0 -0
package/Readme.md
CHANGED
|
@@ -20,7 +20,7 @@ npm install -g thinknagent
|
|
|
20
20
|
|
|
21
21
|
**If permission denied:**
|
|
22
22
|
```bash
|
|
23
|
-
chmod +x
|
|
23
|
+
chmod +x (which thinknagent)
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
---
|
|
@@ -55,7 +55,7 @@ chmod +x $(which thinknagent)
|
|
|
55
55
|
**Basic (metrics only):**
|
|
56
56
|
```bash
|
|
57
57
|
thinknagent init \
|
|
58
|
-
--server
|
|
58
|
+
--server YOUR_APP_SERVER \
|
|
59
59
|
--name my-server \
|
|
60
60
|
--room <roomId>
|
|
61
61
|
```
|
|
@@ -63,29 +63,29 @@ thinknagent init \
|
|
|
63
63
|
**With log streaming:**
|
|
64
64
|
```bash
|
|
65
65
|
thinknagent init \
|
|
66
|
-
--server
|
|
66
|
+
--server YOUR_APP_SERVER \
|
|
67
67
|
--name my-server \
|
|
68
68
|
--room <roomId> \
|
|
69
|
-
--logs
|
|
69
|
+
--logs <YOUR_LOG_FILE>
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
**With PM2 app logs:**
|
|
73
73
|
```bash
|
|
74
74
|
thinknagent init \
|
|
75
|
-
--server
|
|
75
|
+
--server YOUR_APP_SERVER \
|
|
76
76
|
--name my-server \
|
|
77
77
|
--room <roomId> \
|
|
78
|
-
--logs
|
|
78
|
+
--logs <YOUR_PM2_LOG_FILE>
|
|
79
79
|
```
|
|
80
80
|
|
|
81
81
|
**With GPU metrics:**
|
|
82
82
|
```bash
|
|
83
83
|
thinknagent init \
|
|
84
|
-
--server
|
|
84
|
+
--server YOUR_APP_SERVER \
|
|
85
85
|
--name my-server \
|
|
86
86
|
--room <roomId> \
|
|
87
87
|
--gpu \
|
|
88
|
-
--logs
|
|
88
|
+
--logs <YOUR_SYS_LOG_FILE>
|
|
89
89
|
```
|
|
90
90
|
|
|
91
91
|
Get your `roomId` from the room URL:
|
|
@@ -137,8 +137,8 @@ Stream any log file to the DevOps Wall in real-time:
|
|
|
137
137
|
/var/log/auth.log # auth/ssh
|
|
138
138
|
/var/log/nginx/access.log # nginx access
|
|
139
139
|
/var/log/nginx/error.log # nginx errors
|
|
140
|
-
/home/ubuntu/.pm2/logs/
|
|
141
|
-
/home/ubuntu/.pm2/logs/
|
|
140
|
+
/home/ubuntu/.pm2/logs/app-out.log # pm2 stdout
|
|
141
|
+
/home/ubuntu/.pm2/logs/app-error.log # pm2 stderr
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
Pass multiple paths comma-separated:
|
|
@@ -206,7 +206,7 @@ Location: `~/.thinknagent/config.json` (permissions: 600)
|
|
|
206
206
|
```json
|
|
207
207
|
{
|
|
208
208
|
"agentId": "uuid-v4",
|
|
209
|
-
"serverUrl": "
|
|
209
|
+
"serverUrl": "ur.server.url",
|
|
210
210
|
"name": "my-server",
|
|
211
211
|
"roomId": "your-room-id",
|
|
212
212
|
"agentToken": "sha256-hmac-signed-token",
|
|
@@ -241,7 +241,7 @@ pm2 startup
|
|
|
241
241
|
|
|
242
242
|
**Using systemd:**
|
|
243
243
|
```bash
|
|
244
|
-
sudo nano /etc/systemd/system/
|
|
244
|
+
sudo nano /etc/systemd/system/app.service
|
|
245
245
|
```
|
|
246
246
|
|
|
247
247
|
```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.11",
|
|
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
|
},
|
package/tncproject
ADDED
|
Binary file
|