feat: enhance Cloudflare proxy with shared secret support, add uptime monitoring features, and improve startup timeout handling
Browse files- .env.example +11 -0
- README.md +14 -0
- SECURITY.md +3 -1
- cloudflare-proxy.js +5 -0
- cloudflare-worker.js +37 -0
- health-server.js +97 -4
- n8n-sync.py +42 -7
- start.sh +26 -1
.env.example
CHANGED
|
@@ -41,8 +41,19 @@ N8N_LOG_LEVEL=error
|
|
| 41 |
# -----------------------------------------------------------------------------
|
| 42 |
# Your Cloudflare Worker URL (e.g. h8n-proxy.somrat.workers.dev)
|
| 43 |
CLOUDFLARE_PROXY_URL=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
# Comma-separated list of domains to proxy. Use "*" to proxy everything.
|
| 45 |
CLOUDFLARE_PROXY_DOMAINS=api.telegram.org,discord.com,discordapp.com
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# BUILD-TIME VARIABLE (HF Spaces: add as Variable, not Secret)
|
| 47 |
# -----------------------------------------------------------------------------
|
| 48 |
|
|
|
|
| 41 |
# -----------------------------------------------------------------------------
|
| 42 |
# Your Cloudflare Worker URL (e.g. h8n-proxy.somrat.workers.dev)
|
| 43 |
CLOUDFLARE_PROXY_URL=
|
| 44 |
+
# Optional shared secret that should match Worker CLOUDFLARE_PROXY_SECRET
|
| 45 |
+
# If unset, proxy still works but without app-to-worker auth
|
| 46 |
+
# Generate with: openssl rand -hex 24
|
| 47 |
+
CLOUDFLARE_PROXY_SECRET=
|
| 48 |
# Comma-separated list of domains to proxy. Use "*" to proxy everything.
|
| 49 |
CLOUDFLARE_PROXY_DOMAINS=api.telegram.org,discord.com,discordapp.com
|
| 50 |
+
|
| 51 |
+
# Dashboard helper hardening
|
| 52 |
+
UPTIMEROBOT_SETUP_ENABLED=true
|
| 53 |
+
UPTIMEROBOT_RATE_LIMIT_PER_MINUTE=5
|
| 54 |
+
|
| 55 |
+
# Max seconds to wait for n8n readiness at startup
|
| 56 |
+
N8N_STARTUP_TIMEOUT=180
|
| 57 |
# BUILD-TIME VARIABLE (HF Spaces: add as Variable, not Secret)
|
| 58 |
# -----------------------------------------------------------------------------
|
| 59 |
|
README.md
CHANGED
|
@@ -59,6 +59,7 @@ Navigate to your new Space's **Settings**, scroll down to **Variables and secret
|
|
| 59 |
|
| 60 |
- `HF_TOKEN` – Your HuggingFace token with **Write** access (to enable automatic backup).
|
| 61 |
- `CLOUDFLARE_PROXY_URL` – *(Optional but Recommended)* Your Cloudflare Worker URL to bypass platform blocks. check [Setup Guide](#-cloudflare-proxy-setup).
|
|
|
|
| 62 |
|
| 63 |
### Step 3: Deploy & Initialize
|
| 64 |
|
|
@@ -87,6 +88,15 @@ Hugging Face Free Tier blocks outgoing connections to some services (Telegram, D
|
|
| 87 |
5. Click on "Deploy" button.
|
| 88 |
6. Copy the Worker URL (e.g., `https://h8n-proxy.yourname.workers.dev`).
|
| 89 |
7. Add this URL as the `CLOUDFLARE_PROXY_URL` secret in your Hugging8n Space settings.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
## 💾 Persistent Backup
|
| 92 |
|
|
@@ -120,7 +130,11 @@ Customize your instance with these environment variables:
|
|
| 120 |
| `GENERIC_TIMEZONE` | `UTC` | Timezone for your n8n instance |
|
| 121 |
| `N8N_LOG_LEVEL` | `error` | Set to `info` or `debug` for more details |
|
| 122 |
| `CLOUDFLARE_PROXY_DOMAINS` | (default list) | Comma-separated domains to proxy (or `*` for all) |
|
|
|
|
| 123 |
| `SPACE_HOST_OVERRIDE` | — | Override detected host for custom domains |
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
## 💻 Local Development
|
| 126 |
|
|
|
|
| 59 |
|
| 60 |
- `HF_TOKEN` – Your HuggingFace token with **Write** access (to enable automatic backup).
|
| 61 |
- `CLOUDFLARE_PROXY_URL` – *(Optional but Recommended)* Your Cloudflare Worker URL to bypass platform blocks. check [Setup Guide](#-cloudflare-proxy-setup).
|
| 62 |
+
- `CLOUDFLARE_PROXY_SECRET` – *(Optional, Security Recommended)* Shared secret used between Space and Worker to prevent proxy abuse.
|
| 63 |
|
| 64 |
### Step 3: Deploy & Initialize
|
| 65 |
|
|
|
|
| 88 |
5. Click on "Deploy" button.
|
| 89 |
6. Copy the Worker URL (e.g., `https://h8n-proxy.yourname.workers.dev`).
|
| 90 |
7. Add this URL as the `CLOUDFLARE_PROXY_URL` secret in your Hugging8n Space settings.
|
| 91 |
+
8. (Optional, Recommended) In Cloudflare Worker settings, add a secret binding named `CLOUDFLARE_PROXY_SECRET`.
|
| 92 |
+
9. (Optional, Recommended) Add the same value in your Space secrets as `CLOUDFLARE_PROXY_SECRET`.
|
| 93 |
+
|
| 94 |
+
If you skip steps 8-9, proxying still works. The secret simply adds request authentication between your app and worker.
|
| 95 |
+
|
| 96 |
+
Optional Worker vars for tighter control:
|
| 97 |
+
|
| 98 |
+
- `ALLOWED_TARGETS` (comma-separated, defaults to Telegram/Discord hosts)
|
| 99 |
+
- `ALLOW_PROXY_ALL` (`false` by default; set `true` only if you fully trust your setup)
|
| 100 |
|
| 101 |
## 💾 Persistent Backup
|
| 102 |
|
|
|
|
| 130 |
| `GENERIC_TIMEZONE` | `UTC` | Timezone for your n8n instance |
|
| 131 |
| `N8N_LOG_LEVEL` | `error` | Set to `info` or `debug` for more details |
|
| 132 |
| `CLOUDFLARE_PROXY_DOMAINS` | (default list) | Comma-separated domains to proxy (or `*` for all) |
|
| 133 |
+
| `CLOUDFLARE_PROXY_SECRET` | — | Optional shared secret for app-to-worker proxy authentication |
|
| 134 |
| `SPACE_HOST_OVERRIDE` | — | Override detected host for custom domains |
|
| 135 |
+
| `N8N_STARTUP_TIMEOUT` | `180` | Max seconds to wait for n8n readiness before fail-fast |
|
| 136 |
+
| `UPTIMEROBOT_SETUP_ENABLED` | `true` | Enable/disable dashboard helper endpoint |
|
| 137 |
+
| `UPTIMEROBOT_RATE_LIMIT_PER_MINUTE` | `5` | Per-IP rate limit for helper endpoint |
|
| 138 |
|
| 139 |
## 💻 Local Development
|
| 140 |
|
SECURITY.md
CHANGED
|
@@ -15,10 +15,12 @@ We'll respond within 48 hours and work on a fix.
|
|
| 15 |
When deploying Hugging8n:
|
| 16 |
|
| 17 |
- **Enable basic auth** — set `N8N_BASIC_AUTH_USER` and `N8N_BASIC_AUTH_PASSWORD` to protect your n8n instance from unauthorized access
|
| 18 |
-
- **
|
| 19 |
- **Set your Space to Private** — prevents unauthorized access to your n8n instance from the web
|
| 20 |
- **Keep your HF token scoped** — use fine-grained tokens with minimum permissions (read/write to your backup dataset only)
|
| 21 |
- **Set a strong `N8N_ENCRYPTION_KEY`** — protects your stored credentials; if lost, credentials cannot be recovered
|
|
|
|
|
|
|
| 22 |
- **Don't commit `.env` files** — the `.gitignore` already excludes them
|
| 23 |
- **Review n8n credentials** — periodically audit credentials stored in n8n
|
| 24 |
|
|
|
|
| 15 |
When deploying Hugging8n:
|
| 16 |
|
| 17 |
- **Enable basic auth** — set `N8N_BASIC_AUTH_USER` and `N8N_BASIC_AUTH_PASSWORD` to protect your n8n instance from unauthorized access
|
| 18 |
+
- **Create a strong n8n owner password** during first-run setup
|
| 19 |
- **Set your Space to Private** — prevents unauthorized access to your n8n instance from the web
|
| 20 |
- **Keep your HF token scoped** — use fine-grained tokens with minimum permissions (read/write to your backup dataset only)
|
| 21 |
- **Set a strong `N8N_ENCRYPTION_KEY`** — protects your stored credentials; if lost, credentials cannot be recovered
|
| 22 |
+
- **Optionally set `CLOUDFLARE_PROXY_SECRET` in both Space and Worker** — recommended to prevent Worker URL abuse as an open proxy
|
| 23 |
+
- **Keep secure cookies enabled on HTTPS** — default is secure in this project; only disable for local non-HTTPS testing
|
| 24 |
- **Don't commit `.env` files** — the `.gitignore` already excludes them
|
| 25 |
- **Review n8n credentials** — periodically audit credentials stored in n8n
|
| 26 |
|
cloudflare-proxy.js
CHANGED
|
@@ -19,6 +19,7 @@ if (
|
|
| 19 |
}
|
| 20 |
|
| 21 |
const DEBUG = process.env.CLOUDFLARE_PROXY_DEBUG === "true";
|
|
|
|
| 22 |
|
| 23 |
// Allow user to define what to proxy. Use "*" to proxy everything except internal HF traffic.
|
| 24 |
const PROXY_DOMAINS =
|
|
@@ -109,6 +110,10 @@ if (PROXY_URL) {
|
|
| 109 |
"x-target-host": hostname,
|
| 110 |
};
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
// Always use HTTPS for the proxy connection
|
| 113 |
return originalHttpsRequest.call(https, newOptions, callback);
|
| 114 |
}
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
const DEBUG = process.env.CLOUDFLARE_PROXY_DEBUG === "true";
|
| 22 |
+
const PROXY_SHARED_SECRET = (process.env.CLOUDFLARE_PROXY_SECRET || "").trim();
|
| 23 |
|
| 24 |
// Allow user to define what to proxy. Use "*" to proxy everything except internal HF traffic.
|
| 25 |
const PROXY_DOMAINS =
|
|
|
|
| 110 |
"x-target-host": hostname,
|
| 111 |
};
|
| 112 |
|
| 113 |
+
if (PROXY_SHARED_SECRET) {
|
| 114 |
+
newOptions.headers["x-proxy-key"] = PROXY_SHARED_SECRET;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
// Always use HTTPS for the proxy connection
|
| 118 |
return originalHttpsRequest.call(https, newOptions, callback);
|
| 119 |
}
|
cloudflare-worker.js
CHANGED
|
@@ -13,11 +13,48 @@ export default {
|
|
| 13 |
async fetch(request, env, ctx) {
|
| 14 |
const url = new URL(request.url);
|
| 15 |
const targetHost = request.headers.get("x-target-host");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
let targetBase = "";
|
| 18 |
|
| 19 |
if (targetHost) {
|
| 20 |
// Use the host provided in the header (preferred)
|
|
|
|
|
|
|
|
|
|
| 21 |
targetBase = `https://${targetHost}`;
|
| 22 |
} else {
|
| 23 |
// Fallback: Guess based on path (legacy support)
|
|
|
|
| 13 |
async fetch(request, env, ctx) {
|
| 14 |
const url = new URL(request.url);
|
| 15 |
const targetHost = request.headers.get("x-target-host");
|
| 16 |
+
const proxySecret = (
|
| 17 |
+
env.CLOUDFLARE_PROXY_SECRET ||
|
| 18 |
+
env.PROXY_SHARED_SECRET ||
|
| 19 |
+
""
|
| 20 |
+
).trim();
|
| 21 |
+
|
| 22 |
+
// Secret check is optional: when unset, requests are allowed without x-proxy-key.
|
| 23 |
+
if (proxySecret) {
|
| 24 |
+
const providedSecret = request.headers.get("x-proxy-key") || "";
|
| 25 |
+
if (providedSecret !== proxySecret) {
|
| 26 |
+
return new Response("Unauthorized", { status: 401 });
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const allowedTargetsRaw = (
|
| 31 |
+
env.ALLOWED_TARGETS ||
|
| 32 |
+
"api.telegram.org,discord.com,discordapp.com,gateway.discord.gg,status.discord.com"
|
| 33 |
+
).trim();
|
| 34 |
+
const allowProxyAll =
|
| 35 |
+
String(env.ALLOW_PROXY_ALL || "false").toLowerCase() === "true";
|
| 36 |
+
const allowedTargets = allowedTargetsRaw
|
| 37 |
+
.split(",")
|
| 38 |
+
.map((value) => value.trim().toLowerCase())
|
| 39 |
+
.filter(Boolean);
|
| 40 |
+
|
| 41 |
+
const isAllowedHost = (hostname) => {
|
| 42 |
+
if (!hostname) return false;
|
| 43 |
+
const normalized = String(hostname).trim().toLowerCase();
|
| 44 |
+
if (!normalized) return false;
|
| 45 |
+
if (allowProxyAll) return true;
|
| 46 |
+
return allowedTargets.some(
|
| 47 |
+
(domain) => normalized === domain || normalized.endsWith(`.${domain}`),
|
| 48 |
+
);
|
| 49 |
+
};
|
| 50 |
|
| 51 |
let targetBase = "";
|
| 52 |
|
| 53 |
if (targetHost) {
|
| 54 |
// Use the host provided in the header (preferred)
|
| 55 |
+
if (!isAllowedHost(targetHost)) {
|
| 56 |
+
return new Response("Target host is not allowed.", { status: 403 });
|
| 57 |
+
}
|
| 58 |
targetBase = `https://${targetHost}`;
|
| 59 |
} else {
|
| 60 |
// Fallback: Guess based on path (legacy support)
|
health-server.js
CHANGED
|
@@ -8,6 +8,14 @@ const TARGET_PORT = Number(process.env.N8N_PORT || 5678);
|
|
| 8 |
const TARGET_HOST = "127.0.0.1";
|
| 9 |
const SYNC_STATUS_FILE = "/tmp/hugging8n-sync-status.json";
|
| 10 |
const startTime = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
function parseRequestUrl(url) {
|
| 13 |
try {
|
|
@@ -30,6 +38,60 @@ function getStatus() {
|
|
| 30 |
};
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
function renderDashboard(data) {
|
| 34 |
const { status } = data.sync;
|
| 35 |
const getBadge = (status) => {
|
|
@@ -523,6 +585,9 @@ async function createUptimeRobotMonitor(apiKey, host) {
|
|
| 523 |
const cleanHost = String(host || "")
|
| 524 |
.replace(/^https?:\/\//, "")
|
| 525 |
.replace(/\/.*$/, "");
|
|
|
|
|
|
|
|
|
|
| 526 |
if (!cleanHost) throw new Error("Missing Space host.");
|
| 527 |
const monitorUrl = `https://${cleanHost}/health`;
|
| 528 |
const existing = await postUptimeRobot("/v2/getMonitors", {
|
|
@@ -561,14 +626,23 @@ const server = http.createServer(async (req, res) => {
|
|
| 561 |
|
| 562 |
// 1. Dashboard Routes
|
| 563 |
if (pathname === "/health") {
|
| 564 |
-
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
}
|
| 567 |
if (pathname === "/status") {
|
| 568 |
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
|
|
|
| 569 |
return res.end(
|
| 570 |
JSON.stringify({
|
| 571 |
uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
|
|
|
|
| 572 |
sync: getStatus(),
|
| 573 |
}),
|
| 574 |
);
|
|
@@ -576,11 +650,30 @@ const server = http.createServer(async (req, res) => {
|
|
| 576 |
if (pathname === "/uptimerobot/setup" && req.method === "POST") {
|
| 577 |
void (async () => {
|
| 578 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
const body = await readRequestBody(req);
|
| 580 |
const { apiKey } = JSON.parse(body || "{}");
|
| 581 |
-
if (!apiKey) {
|
| 582 |
res.writeHead(400, { "Content-Type": "application/json" });
|
| 583 |
-
return res.end(
|
|
|
|
|
|
|
| 584 |
}
|
| 585 |
const result = await createUptimeRobotMonitor(apiKey, req.headers.host);
|
| 586 |
res.writeHead(200, { "Content-Type": "application/json" });
|
|
|
|
| 8 |
const TARGET_HOST = "127.0.0.1";
|
| 9 |
const SYNC_STATUS_FILE = "/tmp/hugging8n-sync-status.json";
|
| 10 |
const startTime = Date.now();
|
| 11 |
+
const UPTIMEROBOT_SETUP_ENABLED =
|
| 12 |
+
String(process.env.UPTIMEROBOT_SETUP_ENABLED || "true").toLowerCase() ===
|
| 13 |
+
"true";
|
| 14 |
+
const UPTIMEROBOT_RATE_WINDOW_MS = 60 * 1000;
|
| 15 |
+
const UPTIMEROBOT_RATE_MAX = Number(
|
| 16 |
+
process.env.UPTIMEROBOT_RATE_LIMIT_PER_MINUTE || 5,
|
| 17 |
+
);
|
| 18 |
+
const uptimerobotRateMap = new Map();
|
| 19 |
|
| 20 |
function parseRequestUrl(url) {
|
| 21 |
try {
|
|
|
|
| 38 |
};
|
| 39 |
}
|
| 40 |
|
| 41 |
+
function probeN8nHealth(timeoutMs = 1500) {
|
| 42 |
+
return new Promise((resolve) => {
|
| 43 |
+
const request = http.get(
|
| 44 |
+
{
|
| 45 |
+
hostname: TARGET_HOST,
|
| 46 |
+
port: TARGET_PORT,
|
| 47 |
+
path: "/healthz",
|
| 48 |
+
timeout: timeoutMs,
|
| 49 |
+
},
|
| 50 |
+
(response) => {
|
| 51 |
+
response.resume();
|
| 52 |
+
resolve(response.statusCode >= 200 && response.statusCode < 400);
|
| 53 |
+
},
|
| 54 |
+
);
|
| 55 |
+
request.on("timeout", () => {
|
| 56 |
+
request.destroy();
|
| 57 |
+
resolve(false);
|
| 58 |
+
});
|
| 59 |
+
request.on("error", () => resolve(false));
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function getRequesterIp(req) {
|
| 64 |
+
return (
|
| 65 |
+
req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
|
| 66 |
+
req.socket.remoteAddress ||
|
| 67 |
+
"unknown"
|
| 68 |
+
);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function isRateLimited(req) {
|
| 72 |
+
const now = Date.now();
|
| 73 |
+
const ip = getRequesterIp(req);
|
| 74 |
+
const bucket = uptimerobotRateMap.get(ip) || [];
|
| 75 |
+
const recent = bucket.filter((ts) => now - ts < UPTIMEROBOT_RATE_WINDOW_MS);
|
| 76 |
+
recent.push(now);
|
| 77 |
+
uptimerobotRateMap.set(ip, recent);
|
| 78 |
+
return recent.length > UPTIMEROBOT_RATE_MAX;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function isAllowedUptimeSetupOrigin(req) {
|
| 82 |
+
const host = String(req.headers.host || "").toLowerCase();
|
| 83 |
+
const origin = String(req.headers.origin || "").toLowerCase();
|
| 84 |
+
const referer = String(req.headers.referer || "").toLowerCase();
|
| 85 |
+
if (!host) return false;
|
| 86 |
+
if (origin && !origin.includes(host)) return false;
|
| 87 |
+
if (referer && !referer.includes(host)) return false;
|
| 88 |
+
return true;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function isValidUptimeApiKey(key) {
|
| 92 |
+
return /^[A-Za-z0-9_-]{20,128}$/.test(String(key || ""));
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
function renderDashboard(data) {
|
| 96 |
const { status } = data.sync;
|
| 97 |
const getBadge = (status) => {
|
|
|
|
| 585 |
const cleanHost = String(host || "")
|
| 586 |
.replace(/^https?:\/\//, "")
|
| 587 |
.replace(/\/.*$/, "");
|
| 588 |
+
if (!cleanHost.endsWith(".hf.space")) {
|
| 589 |
+
throw new Error("Uptime setup is only supported on .hf.space hosts.");
|
| 590 |
+
}
|
| 591 |
if (!cleanHost) throw new Error("Missing Space host.");
|
| 592 |
const monitorUrl = `https://${cleanHost}/health`;
|
| 593 |
const existing = await postUptimeRobot("/v2/getMonitors", {
|
|
|
|
| 626 |
|
| 627 |
// 1. Dashboard Routes
|
| 628 |
if (pathname === "/health") {
|
| 629 |
+
const n8nReady = await probeN8nHealth();
|
| 630 |
+
res.writeHead(n8nReady ? 200 : 503, { "Content-Type": "application/json" });
|
| 631 |
+
return res.end(
|
| 632 |
+
JSON.stringify({
|
| 633 |
+
status: n8nReady ? "ok" : "degraded",
|
| 634 |
+
n8nReady,
|
| 635 |
+
...getStatus(),
|
| 636 |
+
}),
|
| 637 |
+
);
|
| 638 |
}
|
| 639 |
if (pathname === "/status") {
|
| 640 |
const uptime = Math.floor((Date.now() - startTime) / 1000);
|
| 641 |
+
const n8nReady = await probeN8nHealth();
|
| 642 |
return res.end(
|
| 643 |
JSON.stringify({
|
| 644 |
uptime: `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`,
|
| 645 |
+
n8nReady,
|
| 646 |
sync: getStatus(),
|
| 647 |
}),
|
| 648 |
);
|
|
|
|
| 650 |
if (pathname === "/uptimerobot/setup" && req.method === "POST") {
|
| 651 |
void (async () => {
|
| 652 |
try {
|
| 653 |
+
if (!UPTIMEROBOT_SETUP_ENABLED) {
|
| 654 |
+
res.writeHead(403, { "Content-Type": "application/json" });
|
| 655 |
+
return res.end(
|
| 656 |
+
JSON.stringify({ message: "Uptime setup is disabled." }),
|
| 657 |
+
);
|
| 658 |
+
}
|
| 659 |
+
if (isRateLimited(req)) {
|
| 660 |
+
res.writeHead(429, { "Content-Type": "application/json" });
|
| 661 |
+
return res.end(JSON.stringify({ message: "Too many requests." }));
|
| 662 |
+
}
|
| 663 |
+
if (!isAllowedUptimeSetupOrigin(req)) {
|
| 664 |
+
res.writeHead(403, { "Content-Type": "application/json" });
|
| 665 |
+
return res.end(
|
| 666 |
+
JSON.stringify({ message: "Invalid request origin." }),
|
| 667 |
+
);
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
const body = await readRequestBody(req);
|
| 671 |
const { apiKey } = JSON.parse(body || "{}");
|
| 672 |
+
if (!isValidUptimeApiKey(apiKey)) {
|
| 673 |
res.writeHead(400, { "Content-Type": "application/json" });
|
| 674 |
+
return res.end(
|
| 675 |
+
JSON.stringify({ message: "A valid API key is required." }),
|
| 676 |
+
);
|
| 677 |
}
|
| 678 |
const result = await createUptimeRobotMonitor(apiKey, req.headers.host);
|
| 679 |
res.writeHead(200, { "Content-Type": "application/json" });
|
n8n-sync.py
CHANGED
|
@@ -36,7 +36,32 @@ def write_status(status: str, message: str) -> None:
|
|
| 36 |
"message": message,
|
| 37 |
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 38 |
}
|
| 39 |
-
STATUS_FILE.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
def dataset_repo_id() -> str:
|
|
@@ -159,16 +184,25 @@ def restore() -> bool:
|
|
| 159 |
return False
|
| 160 |
|
| 161 |
|
| 162 |
-
def sync_once(
|
|
|
|
|
|
|
|
|
|
| 163 |
if not HF_TOKEN:
|
| 164 |
write_status("disabled", "HF_TOKEN is not configured.")
|
| 165 |
-
return last_fingerprint or ""
|
| 166 |
|
| 167 |
repo_id = ensure_repo_exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
current_fingerprint = fingerprint_dir(N8N_HOME)
|
| 169 |
if last_fingerprint is not None and current_fingerprint == last_fingerprint:
|
| 170 |
write_status("synced", "No state changes detected.")
|
| 171 |
-
return last_fingerprint
|
| 172 |
|
| 173 |
write_status("syncing", f"Uploading state to {repo_id}")
|
| 174 |
snapshot_dir = create_snapshot_dir(N8N_HOME)
|
|
@@ -184,7 +218,7 @@ def sync_once(last_fingerprint: str | None = None) -> str:
|
|
| 184 |
finally:
|
| 185 |
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 186 |
write_status("success", f"Uploaded state to {repo_id}")
|
| 187 |
-
return current_fingerprint
|
| 188 |
|
| 189 |
|
| 190 |
def handle_signal(_sig, _frame) -> None:
|
|
@@ -196,11 +230,12 @@ def loop() -> int:
|
|
| 196 |
signal.signal(signal.SIGINT, handle_signal)
|
| 197 |
|
| 198 |
last_fingerprint = fingerprint_dir(N8N_HOME)
|
|
|
|
| 199 |
write_status("configured", f"Backup loop active with {INTERVAL}s interval.")
|
| 200 |
|
| 201 |
while not STOP_EVENT.is_set():
|
| 202 |
try:
|
| 203 |
-
last_fingerprint = sync_once(last_fingerprint)
|
| 204 |
except Exception as exc:
|
| 205 |
write_status("error", f"Sync failed: {exc}")
|
| 206 |
print(f"Sync failed: {exc}", file=sys.stderr)
|
|
@@ -220,7 +255,7 @@ def main() -> int:
|
|
| 220 |
if command == "restore":
|
| 221 |
return 0 if restore() else 1
|
| 222 |
if command == "sync-once":
|
| 223 |
-
sync_once(None)
|
| 224 |
return 0
|
| 225 |
if command == "loop":
|
| 226 |
return loop()
|
|
|
|
| 36 |
"message": message,
|
| 37 |
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
| 38 |
}
|
| 39 |
+
tmp_path = STATUS_FILE.with_suffix(".tmp")
|
| 40 |
+
tmp_path.write_text(json.dumps(payload), encoding="utf-8")
|
| 41 |
+
tmp_path.replace(STATUS_FILE)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def metadata_marker(root: Path) -> tuple[int, int, int]:
|
| 45 |
+
if not root.exists():
|
| 46 |
+
return (0, 0, 0)
|
| 47 |
+
|
| 48 |
+
file_count = 0
|
| 49 |
+
total_size = 0
|
| 50 |
+
newest_mtime = 0
|
| 51 |
+
for path in root.rglob("*"):
|
| 52 |
+
if not path.is_file():
|
| 53 |
+
continue
|
| 54 |
+
rel = path.relative_to(root).as_posix()
|
| 55 |
+
if rel.startswith(".cache/"):
|
| 56 |
+
continue
|
| 57 |
+
try:
|
| 58 |
+
stat = path.stat()
|
| 59 |
+
except OSError:
|
| 60 |
+
continue
|
| 61 |
+
file_count += 1
|
| 62 |
+
total_size += int(stat.st_size)
|
| 63 |
+
newest_mtime = max(newest_mtime, int(stat.st_mtime_ns))
|
| 64 |
+
return (file_count, total_size, newest_mtime)
|
| 65 |
|
| 66 |
|
| 67 |
def dataset_repo_id() -> str:
|
|
|
|
| 184 |
return False
|
| 185 |
|
| 186 |
|
| 187 |
+
def sync_once(
|
| 188 |
+
last_fingerprint: str | None = None,
|
| 189 |
+
last_marker: tuple[int, int, int] | None = None,
|
| 190 |
+
) -> tuple[str, tuple[int, int, int]]:
|
| 191 |
if not HF_TOKEN:
|
| 192 |
write_status("disabled", "HF_TOKEN is not configured.")
|
| 193 |
+
return (last_fingerprint or "", last_marker or (0, 0, 0))
|
| 194 |
|
| 195 |
repo_id = ensure_repo_exists()
|
| 196 |
+
|
| 197 |
+
current_marker = metadata_marker(N8N_HOME)
|
| 198 |
+
if last_marker is not None and current_marker == last_marker:
|
| 199 |
+
write_status("synced", "No state changes detected.")
|
| 200 |
+
return (last_fingerprint or "", current_marker)
|
| 201 |
+
|
| 202 |
current_fingerprint = fingerprint_dir(N8N_HOME)
|
| 203 |
if last_fingerprint is not None and current_fingerprint == last_fingerprint:
|
| 204 |
write_status("synced", "No state changes detected.")
|
| 205 |
+
return (last_fingerprint, current_marker)
|
| 206 |
|
| 207 |
write_status("syncing", f"Uploading state to {repo_id}")
|
| 208 |
snapshot_dir = create_snapshot_dir(N8N_HOME)
|
|
|
|
| 218 |
finally:
|
| 219 |
shutil.rmtree(snapshot_dir, ignore_errors=True)
|
| 220 |
write_status("success", f"Uploaded state to {repo_id}")
|
| 221 |
+
return (current_fingerprint, current_marker)
|
| 222 |
|
| 223 |
|
| 224 |
def handle_signal(_sig, _frame) -> None:
|
|
|
|
| 230 |
signal.signal(signal.SIGINT, handle_signal)
|
| 231 |
|
| 232 |
last_fingerprint = fingerprint_dir(N8N_HOME)
|
| 233 |
+
last_marker = metadata_marker(N8N_HOME)
|
| 234 |
write_status("configured", f"Backup loop active with {INTERVAL}s interval.")
|
| 235 |
|
| 236 |
while not STOP_EVENT.is_set():
|
| 237 |
try:
|
| 238 |
+
last_fingerprint, last_marker = sync_once(last_fingerprint, last_marker)
|
| 239 |
except Exception as exc:
|
| 240 |
write_status("error", f"Sync failed: {exc}")
|
| 241 |
print(f"Sync failed: {exc}", file=sys.stderr)
|
|
|
|
| 255 |
if command == "restore":
|
| 256 |
return 0 if restore() else 1
|
| 257 |
if command == "sync-once":
|
| 258 |
+
sync_once(None, None)
|
| 259 |
return 0
|
| 260 |
if command == "loop":
|
| 261 |
return loop()
|
start.sh
CHANGED
|
@@ -9,6 +9,7 @@ N8N_HOME="/home/node/.n8n"
|
|
| 9 |
N8N_PORT="${N8N_PORT:-5678}"
|
| 10 |
PUBLIC_PORT="${PUBLIC_PORT:-7861}"
|
| 11 |
SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
|
|
|
|
| 12 |
|
| 13 |
mkdir -p "$N8N_HOME"
|
| 14 |
|
|
@@ -27,7 +28,15 @@ export N8N_PORT
|
|
| 27 |
export N8N_PROTOCOL="${N8N_PROTOCOL:-https}"
|
| 28 |
export N8N_PROXY_HOPS="${N8N_PROXY_HOPS:-1}"
|
| 29 |
export N8N_LISTEN_ADDRESS="${N8N_LISTEN_ADDRESS:-0.0.0.0}"
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
export N8N_DIAGNOSTICS_ENABLED="${N8N_DIAGNOSTICS_ENABLED:-false}"
|
| 32 |
export N8N_PERSONALIZATION_ENABLED="${N8N_PERSONALIZATION_ENABLED:-false}"
|
| 33 |
export N8N_USER_FOLDER="$N8N_HOME"
|
|
@@ -55,6 +64,7 @@ echo "n8n port : ${N8N_PORT}"
|
|
| 55 |
echo "Public port : ${PUBLIC_PORT}"
|
| 56 |
echo "Timezone : ${GENERIC_TIMEZONE}"
|
| 57 |
echo "Sync every : ${SYNC_INTERVAL}s"
|
|
|
|
| 58 |
|
| 59 |
if [ -n "${HF_TOKEN:-}" ]; then
|
| 60 |
echo "Restoring persisted n8n state from HF Dataset..."
|
|
@@ -100,7 +110,22 @@ N8N_PID=$!
|
|
| 100 |
|
| 101 |
# Readiness probe
|
| 102 |
echo "Waiting for n8n to be ready on port ${N8N_PORT}..."
|
|
|
|
| 103 |
until curl -sf "http://127.0.0.1:${N8N_PORT}/healthz" > /dev/null 2>&1; do
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
sleep 1
|
| 105 |
done
|
| 106 |
echo "n8n is ready!"
|
|
|
|
| 9 |
N8N_PORT="${N8N_PORT:-5678}"
|
| 10 |
PUBLIC_PORT="${PUBLIC_PORT:-7861}"
|
| 11 |
SYNC_INTERVAL="${SYNC_INTERVAL:-180}"
|
| 12 |
+
N8N_STARTUP_TIMEOUT="${N8N_STARTUP_TIMEOUT:-180}"
|
| 13 |
|
| 14 |
mkdir -p "$N8N_HOME"
|
| 15 |
|
|
|
|
| 28 |
export N8N_PROTOCOL="${N8N_PROTOCOL:-https}"
|
| 29 |
export N8N_PROXY_HOPS="${N8N_PROXY_HOPS:-1}"
|
| 30 |
export N8N_LISTEN_ADDRESS="${N8N_LISTEN_ADDRESS:-0.0.0.0}"
|
| 31 |
+
if [ -z "${N8N_SECURE_COOKIE:-}" ]; then
|
| 32 |
+
if [ "${N8N_PROTOCOL}" = "https" ]; then
|
| 33 |
+
export N8N_SECURE_COOKIE="true"
|
| 34 |
+
else
|
| 35 |
+
export N8N_SECURE_COOKIE="false"
|
| 36 |
+
fi
|
| 37 |
+
else
|
| 38 |
+
export N8N_SECURE_COOKIE
|
| 39 |
+
fi
|
| 40 |
export N8N_DIAGNOSTICS_ENABLED="${N8N_DIAGNOSTICS_ENABLED:-false}"
|
| 41 |
export N8N_PERSONALIZATION_ENABLED="${N8N_PERSONALIZATION_ENABLED:-false}"
|
| 42 |
export N8N_USER_FOLDER="$N8N_HOME"
|
|
|
|
| 64 |
echo "Public port : ${PUBLIC_PORT}"
|
| 65 |
echo "Timezone : ${GENERIC_TIMEZONE}"
|
| 66 |
echo "Sync every : ${SYNC_INTERVAL}s"
|
| 67 |
+
echo "Startup wait: ${N8N_STARTUP_TIMEOUT}s"
|
| 68 |
|
| 69 |
if [ -n "${HF_TOKEN:-}" ]; then
|
| 70 |
echo "Restoring persisted n8n state from HF Dataset..."
|
|
|
|
| 110 |
|
| 111 |
# Readiness probe
|
| 112 |
echo "Waiting for n8n to be ready on port ${N8N_PORT}..."
|
| 113 |
+
start_ts="$(date +%s)"
|
| 114 |
until curl -sf "http://127.0.0.1:${N8N_PORT}/healthz" > /dev/null 2>&1; do
|
| 115 |
+
now_ts="$(date +%s)"
|
| 116 |
+
elapsed="$((now_ts - start_ts))"
|
| 117 |
+
if [ "$elapsed" -ge "$N8N_STARTUP_TIMEOUT" ]; then
|
| 118 |
+
echo "n8n did not become ready within ${N8N_STARTUP_TIMEOUT}s. Exiting."
|
| 119 |
+
kill -TERM "$N8N_PID" 2>/dev/null || true
|
| 120 |
+
wait "$N8N_PID" 2>/dev/null || true
|
| 121 |
+
exit 1
|
| 122 |
+
fi
|
| 123 |
+
|
| 124 |
+
if ! kill -0 "$N8N_PID" 2>/dev/null; then
|
| 125 |
+
echo "n8n process exited before readiness check passed. Exiting."
|
| 126 |
+
exit 1
|
| 127 |
+
fi
|
| 128 |
+
|
| 129 |
sleep 1
|
| 130 |
done
|
| 131 |
echo "n8n is ready!"
|