From 99e898eabd5c0e4b54d5f24013ae88d9cd54d3e5 Mon Sep 17 00:00:00 2001 From: gilangjavier Date: Mon, 23 Mar 2026 20:09:33 +0700 Subject: [PATCH] fix(system): add fallback endpoints for internet status check Fixes #258 --- README.md | 4 +- admin/app/services/system_service.ts | 79 +++++++++++++++++++++------- admin/docs/release-notes.md | 5 ++ 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bdca4c8..2050514 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,9 @@ Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resou ## About Internet Usage & Privacy Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry. -To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace` and checks for a successful response. +To test internet connectivity (used only to show an “offline” hint in the UI), N.O.M.A.D. performs a lightweight HTTP request. It tries Cloudflare’s utility endpoint (`https://1.1.1.1/cdn-cgi/trace`) first, and if that fails (e.g. the host is blocked in your network), it falls back to other endpoints (GitHub API and the Docker registry) before reporting offline. + +You can also override the first probe URL via the `INTERNET_STATUS_TEST_URL` environment variable. ## About Security By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed. diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 84157af..7bc58b9 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -29,38 +29,77 @@ export class SystemService { } async getInternetStatus(): Promise { - const DEFAULT_TEST_URL = 'https://1.1.1.1/cdn-cgi/trace' + // NOTE: We treat "any HTTP response" as online (even 401/403/404/etc), because + // some networks block specific hosts (e.g. 1.1.1.1) or return non-200 responses. + // This is used for an "offline" UI hint, so false negatives are worse than + // occasional false positives. + const DEFAULT_TEST_URLS = [ + 'https://1.1.1.1/cdn-cgi/trace', + 'https://api.github.com/', + 'https://registry-1.docker.io/v2/', + ] const MAX_ATTEMPTS = 3 + const TIMEOUT_MS = 5000 - let testUrl = DEFAULT_TEST_URL - let customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim() + const customTestUrlRaw = env.get('INTERNET_STATUS_TEST_URL')?.trim() - // check that customTestUrl is a valid URL, if provided - if (customTestUrl && customTestUrl !== '') { + const testUrls: string[] = [...DEFAULT_TEST_URLS] + + if (customTestUrlRaw) { try { - new URL(customTestUrl) - testUrl = customTestUrl - } catch (error) { + const customUrl = new URL(customTestUrlRaw).toString() + // Put custom URL first + testUrls.unshift(customUrl) + } catch { logger.warn( - `Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URL.` + `Invalid INTERNET_STATUS_TEST_URL: ${customTestUrlRaw}. Falling back to default URLs.` ) } } - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - try { - const res = await axios.get(testUrl, { timeout: 5000 }) - return res.status === 200 - } catch (error) { - logger.warn( - `Internet status check attempt ${attempt}/${MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : error}` - ) + // De-dupe URLs while preserving order + const seen = new Set() + const dedupedUrls = testUrls.filter((u) => { + if (seen.has(u)) return false + seen.add(u) + return true + }) - if (attempt < MAX_ATTEMPTS) { - // delay before next attempt - await new Promise((resolve) => setTimeout(resolve, 1000)) + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + for (const url of dedupedUrls) { + try { + await axios.get(url, { + timeout: TIMEOUT_MS, + validateStatus: () => true, + headers: { + 'User-Agent': 'project-nomad-internet-status-check', + }, + }) + return true + } catch (error) { + // Avoid log spam: only warn on the final attempt. + if (attempt === MAX_ATTEMPTS) { + logger.warn( + `Internet status check failed for ${url}: ${error instanceof Error ? error.message : error}` + ) + } } } + + if (attempt < MAX_ATTEMPTS) { + // delay before next attempt + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + } + + // Final fallback: DNS resolution using system-configured resolvers. + // This catches cases where a specific HTTP endpoint is blocked but DNS/internet is working. + try { + const { lookup } = await import('node:dns/promises') + await lookup('github.com') + return true + } catch { + // ignore } logger.warn('All internet status check attempts failed.') diff --git a/admin/docs/release-notes.md b/admin/docs/release-notes.md index 5f74a57..49f434b 100644 --- a/admin/docs/release-notes.md +++ b/admin/docs/release-notes.md @@ -1,5 +1,10 @@ # Release Notes +## Unreleased + +### Bug Fixes +- **System**: Improve the internet connectivity check to avoid false “offline” warnings when specific endpoints (e.g. 1.1.1.1) are blocked. + ## Version 1.30.0 - March 20, 2026 ### Features