somratpro commited on
Commit
544bf0f
·
1 Parent(s): 51ec4bc

feat: implement real-time channel status monitoring and add authentication failure cooldowns to guardian and health server

Browse files
Files changed (4) hide show
  1. README.md +1 -0
  2. health-server.js +146 -16
  3. start.sh +4 -3
  4. wa-guardian.js +29 -0
README.md CHANGED
@@ -289,6 +289,7 @@ HuggingClaw keeps the Space awake without external cron tools:
289
  - **Backup restore failing:** Make sure `HF_USERNAME` and `HF_TOKEN` are correct (token needs write access to your Dataset).
290
  - **Space keeps sleeping:** Check logs for `Keep-alive` messages. Ensure `KEEP_ALIVE_INTERVAL` isn’t set to `0`.
291
  - **Auth errors / proxy:** If you see reverse-proxy auth errors, add the logged IPs under `TRUSTED_PROXIES` (from logs `remote=x.x.x.x`).
 
292
  - **UI blocked (CORS):** Set `ALLOWED_ORIGINS=https://your-space-name.hf.space`.
293
  - **Version mismatches:** Pin a specific OpenClaw build with the `OPENCLAW_VERSION` Variable in HF Spaces, or `--build-arg OPENCLAW_VERSION=...` locally.
294
 
 
289
  - **Backup restore failing:** Make sure `HF_USERNAME` and `HF_TOKEN` are correct (token needs write access to your Dataset).
290
  - **Space keeps sleeping:** Check logs for `Keep-alive` messages. Ensure `KEEP_ALIVE_INTERVAL` isn’t set to `0`.
291
  - **Auth errors / proxy:** If you see reverse-proxy auth errors, add the logged IPs under `TRUSTED_PROXIES` (from logs `remote=x.x.x.x`).
292
+ - **Control UI says too many failed authentication attempts:** Wait for the retry window to expire, then open the Space in an incognito window or clear site storage for your Space before entering the current `GATEWAY_TOKEN` again.
293
  - **UI blocked (CORS):** Set `ALLOWED_ORIGINS=https://your-space-name.hf.space`.
294
  - **Version mismatches:** Pin a specific OpenClaw build with the `OPENCLAW_VERSION` Variable in HF Spaces, or `--build-arg OPENCLAW_VERSION=...` locally.
295
 
health-server.js CHANGED
@@ -2,13 +2,24 @@
2
  const http = require("http");
3
  const fs = require("fs");
4
  const net = require("net");
 
5
 
6
  const PORT = 7861;
7
  const GATEWAY_PORT = 7860;
8
  const GATEWAY_HOST = "127.0.0.1";
9
  const startTime = Date.now();
10
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
 
11
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
 
 
 
 
 
 
 
 
 
12
 
13
  function getPathname(url) {
14
  try {
@@ -60,6 +71,117 @@ function readSyncStatus() {
60
  return { status: "unknown", message: "No sync data yet" };
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  function renderDashboard() {
64
  return `
65
  <!DOCTYPE html>
@@ -286,13 +408,18 @@ function renderDashboard() {
286
  document.getElementById('model-id').textContent = data.model;
287
  document.getElementById('uptime').textContent = data.uptime;
288
 
289
- document.getElementById('wa-status').innerHTML = data.whatsapp
290
- ? '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'
291
- : '<div class="status-badge status-offline">Disabled</div>';
 
 
 
 
 
 
292
 
293
- document.getElementById('tg-status').innerHTML = data.telegram
294
- ? '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'
295
- : '<div class="status-badge status-offline">Disabled</div>';
296
 
297
  const syncData = data.sync;
298
  let badgeClass = 'status-offline';
@@ -436,16 +563,19 @@ const server = http.createServer((req, res) => {
436
  }
437
 
438
  if (pathname === "/status") {
439
- res.writeHead(200, { "Content-Type": "application/json" });
440
- res.end(
441
- JSON.stringify({
442
- model: LLM_MODEL,
443
- whatsapp: true,
444
- telegram: TELEGRAM_ENABLED,
445
- sync: readSyncStatus(),
446
- uptime: uptimeHuman,
447
- }),
448
- );
 
 
 
449
  return;
450
  }
451
 
 
2
  const http = require("http");
3
  const fs = require("fs");
4
  const net = require("net");
5
+ const { randomUUID } = require("node:crypto");
6
 
7
  const PORT = 7861;
8
  const GATEWAY_PORT = 7860;
9
  const GATEWAY_HOST = "127.0.0.1";
10
  const startTime = Date.now();
11
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
12
+ const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "";
13
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
14
+ const GATEWAY_STATUS_CACHE_MS = 5000;
15
+
16
+ let gatewayStatusCache = {
17
+ expiresAt: 0,
18
+ value: {
19
+ whatsapp: { configured: true, connected: false },
20
+ telegram: { configured: TELEGRAM_ENABLED, connected: false },
21
+ },
22
+ };
23
 
24
  function getPathname(url) {
25
  try {
 
71
  return { status: "unknown", message: "No sync data yet" };
72
  }
73
 
74
+ function normalizeChannelStatus(channel, configured) {
75
+ return {
76
+ configured: configured || !!channel,
77
+ connected: !!(channel && channel.connected),
78
+ };
79
+ }
80
+
81
+ function extractErrorMessage(msg) {
82
+ if (!msg || typeof msg !== "object") return "Unknown error";
83
+ if (typeof msg.error === "string") return msg.error;
84
+ if (msg.error && typeof msg.error.message === "string") return msg.error.message;
85
+ if (typeof msg.message === "string") return msg.message;
86
+ return "Unknown error";
87
+ }
88
+
89
+ function createGatewayConnection() {
90
+ return new Promise((resolve, reject) => {
91
+ const { WebSocket } = require("/home/node/.openclaw/openclaw-app/node_modules/ws");
92
+ const ws = new WebSocket(`ws://${GATEWAY_HOST}:${GATEWAY_PORT}`);
93
+ let resolved = false;
94
+
95
+ ws.on("message", (data) => {
96
+ const msg = JSON.parse(data.toString());
97
+
98
+ if (msg.type === "event" && msg.event === "connect.challenge") {
99
+ ws.send(JSON.stringify({
100
+ type: "req",
101
+ id: randomUUID(),
102
+ method: "connect",
103
+ params: {
104
+ auth: { token: GATEWAY_TOKEN },
105
+ client: { id: "health-server", platform: "linux", mode: "backend", version: "1.0.0" },
106
+ scopes: ["operator.read"],
107
+ },
108
+ }));
109
+ return;
110
+ }
111
+
112
+ if (!resolved && msg.type === "res" && msg.ok === false) {
113
+ resolved = true;
114
+ ws.close();
115
+ reject(new Error(extractErrorMessage(msg)));
116
+ return;
117
+ }
118
+
119
+ if (!resolved && msg.type === "res" && msg.ok) {
120
+ resolved = true;
121
+ resolve(ws);
122
+ }
123
+ });
124
+
125
+ ws.on("error", (error) => {
126
+ if (!resolved) reject(error);
127
+ });
128
+
129
+ setTimeout(() => {
130
+ if (!resolved) {
131
+ ws.close();
132
+ reject(new Error("Timeout"));
133
+ }
134
+ }, 10000);
135
+ });
136
+ }
137
+
138
+ function callGatewayRpc(ws, method, params) {
139
+ return new Promise((resolve, reject) => {
140
+ const id = randomUUID();
141
+ const handler = (data) => {
142
+ const msg = JSON.parse(data.toString());
143
+ if (msg.id === id) {
144
+ ws.removeListener("message", handler);
145
+ resolve(msg);
146
+ }
147
+ };
148
+
149
+ ws.on("message", handler);
150
+ ws.send(JSON.stringify({ type: "req", id, method, params }));
151
+
152
+ setTimeout(() => {
153
+ ws.removeListener("message", handler);
154
+ reject(new Error("RPC Timeout"));
155
+ }, 15000);
156
+ });
157
+ }
158
+
159
+ async function getGatewayChannelStatus() {
160
+ if (Date.now() < gatewayStatusCache.expiresAt) {
161
+ return gatewayStatusCache.value;
162
+ }
163
+
164
+ let ws;
165
+ try {
166
+ ws = await createGatewayConnection();
167
+ const statusRes = await callGatewayRpc(ws, "channels.status", {});
168
+ const channels = (statusRes.payload || statusRes.result)?.channels || {};
169
+ const value = {
170
+ whatsapp: normalizeChannelStatus(channels.whatsapp, true),
171
+ telegram: normalizeChannelStatus(channels.telegram, TELEGRAM_ENABLED),
172
+ };
173
+ gatewayStatusCache = {
174
+ expiresAt: Date.now() + GATEWAY_STATUS_CACHE_MS,
175
+ value,
176
+ };
177
+ return value;
178
+ } catch {
179
+ return gatewayStatusCache.value;
180
+ } finally {
181
+ if (ws) ws.close();
182
+ }
183
+ }
184
+
185
  function renderDashboard() {
186
  return `
187
  <!DOCTYPE html>
 
408
  document.getElementById('model-id').textContent = data.model;
409
  document.getElementById('uptime').textContent = data.uptime;
410
 
411
+ function renderChannelStatus(channel, configuredLabel) {
412
+ if (channel && channel.connected) {
413
+ return '<div class="status-badge status-online"><div class="pulse"></div>Active</div>';
414
+ }
415
+ if (channel && channel.configured) {
416
+ return '<div class="status-badge status-syncing">' + configuredLabel + '</div>';
417
+ }
418
+ return '<div class="status-badge status-offline">Disabled</div>';
419
+ }
420
 
421
+ document.getElementById('wa-status').innerHTML = renderChannelStatus(data.whatsapp, 'Ready to pair');
422
+ document.getElementById('tg-status').innerHTML = renderChannelStatus(data.telegram, 'Configured');
 
423
 
424
  const syncData = data.sync;
425
  let badgeClass = 'status-offline';
 
563
  }
564
 
565
  if (pathname === "/status") {
566
+ void (async () => {
567
+ const channelStatus = await getGatewayChannelStatus();
568
+ res.writeHead(200, { "Content-Type": "application/json" });
569
+ res.end(
570
+ JSON.stringify({
571
+ model: LLM_MODEL,
572
+ whatsapp: channelStatus.whatsapp,
573
+ telegram: channelStatus.telegram,
574
+ sync: readSyncStatus(),
575
+ uptime: uptimeHuman,
576
+ }),
577
+ );
578
+ })();
579
  return;
580
  }
581
 
start.sh CHANGED
@@ -176,7 +176,7 @@ CONFIG_JSON=$(cat <<'CONFIGEOF'
176
  "controlUi": {
177
  "allowInsecureAuth": true
178
  },
179
- "trustedProxies": ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
180
  },
181
  "channels": {},
182
  "plugins": {
@@ -206,10 +206,11 @@ if [ -n "$OPENCLAW_PASSWORD" ]; then
206
  fi
207
 
208
  # Trusted proxies (optional — fixes "Proxy headers detected from untrusted address" on HF Spaces)
209
- # Set TRUSTED_PROXIES as comma-separated IPs, e.g. "10.20.31.87,10.20.26.157"
 
210
  if [ -n "$TRUSTED_PROXIES" ]; then
211
  PROXIES_JSON=$(echo "$TRUSTED_PROXIES" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -R . | jq -s .)
212
- CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.trustedProxies = $PROXIES_JSON")
213
  fi
214
 
215
  # Allowed origins (optional — lock down Control UI to specific URLs)
 
176
  "controlUi": {
177
  "allowInsecureAuth": true
178
  },
179
+ "trustedProxies": ["127.0.0.1/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
180
  },
181
  "channels": {},
182
  "plugins": {
 
206
  fi
207
 
208
  # Trusted proxies (optional — fixes "Proxy headers detected from untrusted address" on HF Spaces)
209
+ # Set TRUSTED_PROXIES as comma-separated IPs/CIDRs, e.g. "10.20.31.87,10.20.26.157"
210
+ # Loopback proxies stay trusted by default so the local dashboard reverse proxy works correctly.
211
  if [ -n "$TRUSTED_PROXIES" ]; then
212
  PROXIES_JSON=$(echo "$TRUSTED_PROXIES" | tr ',' '\n' | sed 's/^ *//;s/ *$//' | jq -R . | jq -s .)
213
+ CONFIG_JSON=$(echo "$CONFIG_JSON" | jq ".gateway.trustedProxies += $PROXIES_JSON | .gateway.trustedProxies |= unique")
214
  fi
215
 
216
  # Allowed origins (optional — lock down Control UI to specific URLs)
wa-guardian.js CHANGED
@@ -14,9 +14,20 @@ const GATEWAY_URL = "ws://127.0.0.1:7860";
14
  const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
15
  const CHECK_INTERVAL = 5000;
16
  const WAIT_TIMEOUT = 120000;
 
17
 
18
  let isWaiting = false;
19
  let hasShownWaitMessage = false;
 
 
 
 
 
 
 
 
 
 
20
 
21
  async function createConnection() {
22
  return new Promise((resolve, reject) => {
@@ -40,6 +51,13 @@ async function createConnection() {
40
  return;
41
  }
42
 
 
 
 
 
 
 
 
43
  if (!resolved && msg.type === "res" && msg.ok) {
44
  resolved = true;
45
  resolve(ws);
@@ -69,10 +87,13 @@ async function callRpc(ws, method, params) {
69
 
70
  async function checkStatus() {
71
  if (isWaiting) return;
 
72
 
73
  let ws;
74
  try {
75
  ws = await createConnection();
 
 
76
 
77
  // Check if WhatsApp channel exists and its status
78
  const statusRes = await callRpc(ws, "channels.status", {});
@@ -114,6 +135,14 @@ async function checkStatus() {
114
  }
115
 
116
  } catch (e) {
 
 
 
 
 
 
 
 
117
  // Normal timeout or gateway starting up
118
  } finally {
119
  isWaiting = false;
 
14
  const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
15
  const CHECK_INTERVAL = 5000;
16
  const WAIT_TIMEOUT = 120000;
17
+ const AUTH_FAILURE_COOLDOWN = 5 * 60 * 1000;
18
 
19
  let isWaiting = false;
20
  let hasShownWaitMessage = false;
21
+ let authFailureUntil = 0;
22
+ let authFailureLogged = false;
23
+
24
+ function extractErrorMessage(msg) {
25
+ if (!msg || typeof msg !== "object") return "Unknown error";
26
+ if (typeof msg.error === "string") return msg.error;
27
+ if (msg.error && typeof msg.error.message === "string") return msg.error.message;
28
+ if (typeof msg.message === "string") return msg.message;
29
+ return "Unknown error";
30
+ }
31
 
32
  async function createConnection() {
33
  return new Promise((resolve, reject) => {
 
51
  return;
52
  }
53
 
54
+ if (!resolved && msg.type === "res" && msg.ok === false) {
55
+ resolved = true;
56
+ ws.close();
57
+ reject(new Error(extractErrorMessage(msg)));
58
+ return;
59
+ }
60
+
61
  if (!resolved && msg.type === "res" && msg.ok) {
62
  resolved = true;
63
  resolve(ws);
 
87
 
88
  async function checkStatus() {
89
  if (isWaiting) return;
90
+ if (Date.now() < authFailureUntil) return;
91
 
92
  let ws;
93
  try {
94
  ws = await createConnection();
95
+ authFailureUntil = 0;
96
+ authFailureLogged = false;
97
 
98
  // Check if WhatsApp channel exists and its status
99
  const statusRes = await callRpc(ws, "channels.status", {});
 
135
  }
136
 
137
  } catch (e) {
138
+ const message = e && e.message ? e.message : "";
139
+ if (/unauthorized|authentication|too many failed/i.test(message)) {
140
+ authFailureUntil = Date.now() + AUTH_FAILURE_COOLDOWN;
141
+ if (!authFailureLogged) {
142
+ console.log(`[guardian] Authentication failed (${message}). Pausing guardian retries for ${AUTH_FAILURE_COOLDOWN / 60000} minutes.`);
143
+ authFailureLogged = true;
144
+ }
145
+ }
146
  // Normal timeout or gateway starting up
147
  } finally {
148
  isWaiting = false;