somratpro Copilot commited on
Commit
7f99b73
·
1 Parent(s): 96b5930

feat: enhance Cloudflare proxy with shared secret support, add uptime monitoring features, and improve startup timeout handling

Browse files
Files changed (8) hide show
  1. .env.example +11 -0
  2. README.md +14 -0
  3. SECURITY.md +3 -1
  4. cloudflare-proxy.js +5 -0
  5. cloudflare-worker.js +37 -0
  6. health-server.js +97 -4
  7. n8n-sync.py +42 -7
  8. 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
- - **Use a strong password** generate with `openssl rand -base64 24`
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
- res.writeHead(200, { "Content-Type": "application/json" });
565
- return res.end(JSON.stringify({ status: "ok", ...getStatus() }));
 
 
 
 
 
 
 
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(JSON.stringify({ message: "API key is required." }));
 
 
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.write_text(json.dumps(payload), encoding="utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
 
42
  def dataset_repo_id() -> str:
@@ -159,16 +184,25 @@ def restore() -> bool:
159
  return False
160
 
161
 
162
- def sync_once(last_fingerprint: str | None = None) -> str:
 
 
 
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
- export N8N_SECURE_COOKIE="${N8N_SECURE_COOKIE:-false}"
 
 
 
 
 
 
 
 
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!"