somratpro commited on
Commit
2ab78d1
·
1 Parent(s): 7552177

feat: implement auth failure cooldowns, add loopback trusted proxies, and enhance health server status reporting

Browse files
Files changed (4) hide show
  1. README.md +1 -0
  2. health-server.js +168 -23
  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,19 +2,30 @@
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 {
15
- return new URL(url, "http://localhost").pathname;
16
  } catch {
17
- return "/";
18
  }
19
  }
20
 
@@ -22,11 +33,16 @@ function isDashboardRoute(pathname) {
22
  return pathname === "/dashboard" || pathname === "/dashboard/";
23
  }
24
 
 
 
 
 
25
  function isLocalRoute(pathname) {
26
  return (
27
  pathname === "/health" ||
28
  pathname === "/status" ||
29
- isDashboardRoute(pathname)
 
30
  );
31
  }
32
 
@@ -60,6 +76,117 @@ function readSyncStatus() {
60
  return { status: "unknown", message: "No sync data yet" };
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  function renderDashboard() {
64
  return `
65
  <!DOCTYPE html>
@@ -260,7 +387,7 @@ function renderDashboard() {
260
  <span class="stat-label">Telegram</span>
261
  <span id="tg-status">Loading...</span>
262
  </div>
263
- <a href="/" class="stat-btn">Open Control UI</a>
264
  </div>
265
 
266
  <div class="stat-card" style="width: 100%;">
@@ -286,13 +413,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';
@@ -418,7 +550,8 @@ function proxyUpgrade(req, socket, head) {
418
  }
419
 
420
  const server = http.createServer((req, res) => {
421
- const pathname = getPathname(req.url || "/");
 
422
  const uptime = Math.floor((Date.now() - startTime) / 1000);
423
  const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
424
 
@@ -436,16 +569,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
 
@@ -455,11 +591,20 @@ const server = http.createServer((req, res) => {
455
  return;
456
  }
457
 
 
 
 
 
 
 
 
 
 
458
  proxyHttp(req, res);
459
  });
460
 
461
  server.on("upgrade", (req, socket, head) => {
462
- const pathname = getPathname(req.url || "/");
463
  if (isLocalRoute(pathname)) {
464
  socket.destroy();
465
  return;
 
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 parseRequestUrl(url) {
25
  try {
26
+ return new URL(url, "http://localhost");
27
  } catch {
28
+ return new URL("http://localhost/");
29
  }
30
  }
31
 
 
33
  return pathname === "/dashboard" || pathname === "/dashboard/";
34
  }
35
 
36
+ function isControlRoute(pathname) {
37
+ return pathname === "/control" || pathname === "/control/";
38
+ }
39
+
40
  function isLocalRoute(pathname) {
41
  return (
42
  pathname === "/health" ||
43
  pathname === "/status" ||
44
+ isDashboardRoute(pathname) ||
45
+ isControlRoute(pathname)
46
  );
47
  }
48
 
 
76
  return { status: "unknown", message: "No sync data yet" };
77
  }
78
 
79
+ function normalizeChannelStatus(channel, configured) {
80
+ return {
81
+ configured: configured || !!channel,
82
+ connected: !!(channel && channel.connected),
83
+ };
84
+ }
85
+
86
+ function extractErrorMessage(msg) {
87
+ if (!msg || typeof msg !== "object") return "Unknown error";
88
+ if (typeof msg.error === "string") return msg.error;
89
+ if (msg.error && typeof msg.error.message === "string") return msg.error.message;
90
+ if (typeof msg.message === "string") return msg.message;
91
+ return "Unknown error";
92
+ }
93
+
94
+ function createGatewayConnection() {
95
+ return new Promise((resolve, reject) => {
96
+ const { WebSocket } = require("/home/node/.openclaw/openclaw-app/node_modules/ws");
97
+ const ws = new WebSocket(`ws://${GATEWAY_HOST}:${GATEWAY_PORT}`);
98
+ let resolved = false;
99
+
100
+ ws.on("message", (data) => {
101
+ const msg = JSON.parse(data.toString());
102
+
103
+ if (msg.type === "event" && msg.event === "connect.challenge") {
104
+ ws.send(JSON.stringify({
105
+ type: "req",
106
+ id: randomUUID(),
107
+ method: "connect",
108
+ params: {
109
+ auth: { token: GATEWAY_TOKEN },
110
+ client: { id: "health-server", platform: "linux", mode: "backend", version: "1.0.0" },
111
+ scopes: ["operator.read"],
112
+ },
113
+ }));
114
+ return;
115
+ }
116
+
117
+ if (!resolved && msg.type === "res" && msg.ok === false) {
118
+ resolved = true;
119
+ ws.close();
120
+ reject(new Error(extractErrorMessage(msg)));
121
+ return;
122
+ }
123
+
124
+ if (!resolved && msg.type === "res" && msg.ok) {
125
+ resolved = true;
126
+ resolve(ws);
127
+ }
128
+ });
129
+
130
+ ws.on("error", (error) => {
131
+ if (!resolved) reject(error);
132
+ });
133
+
134
+ setTimeout(() => {
135
+ if (!resolved) {
136
+ ws.close();
137
+ reject(new Error("Timeout"));
138
+ }
139
+ }, 10000);
140
+ });
141
+ }
142
+
143
+ function callGatewayRpc(ws, method, params) {
144
+ return new Promise((resolve, reject) => {
145
+ const id = randomUUID();
146
+ const handler = (data) => {
147
+ const msg = JSON.parse(data.toString());
148
+ if (msg.id === id) {
149
+ ws.removeListener("message", handler);
150
+ resolve(msg);
151
+ }
152
+ };
153
+
154
+ ws.on("message", handler);
155
+ ws.send(JSON.stringify({ type: "req", id, method, params }));
156
+
157
+ setTimeout(() => {
158
+ ws.removeListener("message", handler);
159
+ reject(new Error("RPC Timeout"));
160
+ }, 15000);
161
+ });
162
+ }
163
+
164
+ async function getGatewayChannelStatus() {
165
+ if (Date.now() < gatewayStatusCache.expiresAt) {
166
+ return gatewayStatusCache.value;
167
+ }
168
+
169
+ let ws;
170
+ try {
171
+ ws = await createGatewayConnection();
172
+ const statusRes = await callGatewayRpc(ws, "channels.status", {});
173
+ const channels = (statusRes.payload || statusRes.result)?.channels || {};
174
+ const value = {
175
+ whatsapp: normalizeChannelStatus(channels.whatsapp, true),
176
+ telegram: normalizeChannelStatus(channels.telegram, TELEGRAM_ENABLED),
177
+ };
178
+ gatewayStatusCache = {
179
+ expiresAt: Date.now() + GATEWAY_STATUS_CACHE_MS,
180
+ value,
181
+ };
182
+ return value;
183
+ } catch {
184
+ return gatewayStatusCache.value;
185
+ } finally {
186
+ if (ws) ws.close();
187
+ }
188
+ }
189
+
190
  function renderDashboard() {
191
  return `
192
  <!DOCTYPE html>
 
387
  <span class="stat-label">Telegram</span>
388
  <span id="tg-status">Loading...</span>
389
  </div>
390
+ <a href="/control" class="stat-btn">Open Control UI</a>
391
  </div>
392
 
393
  <div class="stat-card" style="width: 100%;">
 
413
  document.getElementById('model-id').textContent = data.model;
414
  document.getElementById('uptime').textContent = data.uptime;
415
 
416
+ function renderChannelStatus(channel, configuredLabel) {
417
+ if (channel && channel.connected) {
418
+ return '<div class="status-badge status-online"><div class="pulse"></div>Active</div>';
419
+ }
420
+ if (channel && channel.configured) {
421
+ return '<div class="status-badge status-syncing">' + configuredLabel + '</div>';
422
+ }
423
+ return '<div class="status-badge status-offline">Disabled</div>';
424
+ }
425
 
426
+ document.getElementById('wa-status').innerHTML = renderChannelStatus(data.whatsapp, 'Ready to pair');
427
+ document.getElementById('tg-status').innerHTML = renderChannelStatus(data.telegram, 'Configured');
 
428
 
429
  const syncData = data.sync;
430
  let badgeClass = 'status-offline';
 
550
  }
551
 
552
  const server = http.createServer((req, res) => {
553
+ const parsedUrl = parseRequestUrl(req.url || "/");
554
+ const pathname = parsedUrl.pathname;
555
  const uptime = Math.floor((Date.now() - startTime) / 1000);
556
  const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
557
 
 
569
  }
570
 
571
  if (pathname === "/status") {
572
+ void (async () => {
573
+ const channelStatus = await getGatewayChannelStatus();
574
+ res.writeHead(200, { "Content-Type": "application/json" });
575
+ res.end(
576
+ JSON.stringify({
577
+ model: LLM_MODEL,
578
+ whatsapp: channelStatus.whatsapp,
579
+ telegram: channelStatus.telegram,
580
+ sync: readSyncStatus(),
581
+ uptime: uptimeHuman,
582
+ }),
583
+ );
584
+ })();
585
  return;
586
  }
587
 
 
591
  return;
592
  }
593
 
594
+ if ((pathname === "/" || isControlRoute(pathname)) && req.method === "GET" && !parsedUrl.searchParams.get("token") && GATEWAY_TOKEN) {
595
+ res.writeHead(302, {
596
+ Location: `/?token=${encodeURIComponent(GATEWAY_TOKEN)}`,
597
+ "Cache-Control": "no-store",
598
+ });
599
+ res.end();
600
+ return;
601
+ }
602
+
603
  proxyHttp(req, res);
604
  });
605
 
606
  server.on("upgrade", (req, socket, head) => {
607
+ const pathname = parseRequestUrl(req.url || "/").pathname;
608
  if (isLocalRoute(pathname)) {
609
  socket.destroy();
610
  return;
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;