Spaces:
Running
Running
feat: implement real-time channel status monitoring and add authentication failure cooldowns to guardian and health server
Browse files- README.md +1 -0
- health-server.js +146 -16
- start.sh +4 -3
- 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 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
|
| 293 |
-
document.getElementById('
|
| 294 |
-
|
| 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 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 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;
|