diff --git a/README.md b/README.md index ba7738e..b380417 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,9 @@ For answers to common questions about Project N.O.M.A.D., please see our [FAQ](F ## 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. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond. You can override the endpoint used for this check with the `INTERNET_STATUS_TEST_URL` environment variable. +To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond. + +You can override the endpoint used for this check in two ways. The connectivity test URL can be configured from the UI under **Settings → Advanced** (stored locally on your instance), or you can set the `INTERNET_STATUS_TEST_URL` environment variable. When set, the environment variable always takes precedence over the UI-configured value. If neither is set, the built-in defaults above are used. ## 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 its resources), you can block/open ports to control which services are exposed. diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index b4a2058..4675d4b 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -6,6 +6,7 @@ import { SystemService } from '#services/system_service' import { getSettingSchema, updateSettingSchema, validateSettingValue } from '#validators/settings' import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' +import env from '#start/env' @inject() export default class SettingsController { @@ -110,6 +111,19 @@ export default class SettingsController { }) } + async advanced({ inertia }: HttpContext) { + // When the env var is set it always takes precedence over the stored value, + // so surface that to the UI to disable the field and explain the override. + const envOverride = Boolean(env.get('INTERNET_STATUS_TEST_URL')?.trim()) + const internetStatusTestUrl = await KVStore.getValue('system.internetStatusTestUrl') + return inertia.render('settings/advanced', { + advanced: { + internetStatusTestUrl: internetStatusTestUrl ?? '', + internetStatusTestUrlEnvOverride: envOverride, + }, + }) + } + async getSetting({ request, response }: HttpContext) { const { key } = await getSettingSchema.validate({ key: request.qs().key }); const value = await KVStore.getValue(key); diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 7e7412b..6f9d041 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -49,31 +49,37 @@ export class SystemService { const MAX_ATTEMPTS = 3 let testUrls = DEFAULT_TEST_URLS - const customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim() - // If a custom test URL is provided and valid, use it exclusively. This - // preserves the existing override behavior for operators who intentionally - // point connectivity checks at a specific endpoint. + // Resolve the test endpoint in priority order: the INTERNET_STATUS_TEST_URL + // env var always wins (legacy override for operators who intentionally point + // connectivity checks at a specific endpoint), then the UI-configurable value + // stored in KVStore, and finally the built-in defaults. + const envTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim() + const kvTestUrl = (await KVStore.getValue('system.internetStatusTestUrl'))?.trim() + const customTestUrl = envTestUrl || kvTestUrl + + // If a custom test URL is provided and valid, use it exclusively. if (customTestUrl && customTestUrl !== '') { try { new URL(customTestUrl) testUrls = [customTestUrl] } catch (error) { logger.warn( - `Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URLs.` + `Invalid internet status test URL: ${customTestUrl}. Falling back to default URLs.` ) } } for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { try { - // Probe all endpoints in parallel and resolve as soon as the first one + // Probe all test endpoints in parallel and resolve as soon as the first one // responds. Any HTTP response (including non-2xx) means we reached the // internet, so accept all status codes rather than requiring a strict 200. await Promise.any( - testUrls.map((testUrl) => - axios.get(testUrl, { timeout: 5000, validateStatus: () => true }) - ) + testUrls.map((testUrl) => { + logger.debug(`[SystemService] Checking internet connectivity via: ${testUrl}`) + return axios.get(testUrl, { timeout: 5000, validateStatus: () => true }) + }) ) return true } catch (error) { diff --git a/admin/app/validators/settings.ts b/admin/app/validators/settings.ts index fa56772..089e922 100644 --- a/admin/app/validators/settings.ts +++ b/admin/app/validators/settings.ts @@ -36,6 +36,24 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string | } return null } + case 'system.internetStatusTestUrl': { + // Empty clears the setting (reverts to env var / built-in defaults). + if (value === '' || value === undefined || value === null) { + return null + } + if (typeof value !== 'string') { + return 'Test URL must be a string.' + } + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Test URL must use http or https.' + } + } catch { + return 'Test URL must be a valid URL (e.g. "https://example.com").' + } + return null + } case 'contentAutoUpdate.maxBytesPerWindow': { // Per-window download budget in bytes. 0 = unlimited. const num = Number(value) diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index e40b81e..35b9617 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -1,3 +1,3 @@ import { KVStoreKey } from "../types/kv_store.js"; -export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow']; \ No newline at end of file +export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow']; \ No newline at end of file diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index b014444..6dc868d 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -1,4 +1,5 @@ import { + IconAdjustments, IconArrowBigUpLines, IconBox, IconChartBar, @@ -42,6 +43,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode current: false, }, { name: 'System', href: '/settings/system', icon: IconSettings, current: false }, + { name: 'Advanced', href: '/settings/advanced', icon: IconAdjustments, current: false }, { name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false }, { name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false }, ] diff --git a/admin/inertia/pages/settings/advanced.tsx b/admin/inertia/pages/settings/advanced.tsx new file mode 100644 index 0000000..1f9da31 --- /dev/null +++ b/admin/inertia/pages/settings/advanced.tsx @@ -0,0 +1,130 @@ +import { Head } from '@inertiajs/react' +import { useState } from 'react' +import SettingsLayout from '~/layouts/SettingsLayout' +import StyledButton from '~/components/StyledButton' +import StyledSectionHeader from '~/components/StyledSectionHeader' +import Alert from '~/components/Alert' +import Input from '~/components/inputs/Input' +import { useNotifications } from '~/context/NotificationContext' +import { useMutation } from '@tanstack/react-query' +import api from '~/lib/api' + +export default function AdvancedPage(props: { + advanced: { + internetStatusTestUrl: string + internetStatusTestUrlEnvOverride: boolean + } +}) { + const { addNotification } = useNotifications() + const { internetStatusTestUrlEnvOverride } = props.advanced + + const [internetStatusTestUrl, setInternetStatusTestUrl] = useState( + props.advanced.internetStatusTestUrl ?? '' + ) + const [testUrlError, setTestUrlError] = useState(null) + + // Mirror the backend validation (admin/app/validators/settings.ts) for instant + // feedback. The backend remains the source of truth and returns 422 on failure. + function validateTestUrl(value: string): string | null { + if (value.trim() === '') return null // empty clears the setting + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Test URL must use http or https.' + } + } catch { + return 'Test URL must be a valid URL (e.g. "https://example.com").' + } + return null + } + + const updateTestUrlMutation = useMutation({ + mutationFn: async (value: string) => { + return await api.updateSetting('system.internetStatusTestUrl', value) + }, + onSuccess: () => { + addNotification({ message: 'Setting updated successfully.', type: 'success' }) + }, + onError: (error: any) => { + const msg = + error?.response?.data?.message || + error?.message || + 'There was an error updating the setting. Please try again.' + setTestUrlError(msg) + addNotification({ message: msg, type: 'error' }) + }, + }) + + function handleSaveTestUrl() { + const trimmed = internetStatusTestUrl.trim() + const validationError = validateTestUrl(trimmed) + if (validationError) { + setTestUrlError(validationError) + return + } + setTestUrlError(null) + updateTestUrlMutation.mutate(trimmed) + } + + return ( + + +
+
+

Advanced

+

+ Advanced configuration for operators. These settings are optional — the defaults work + for most deployments. +

+ + +
+

+ N.O.M.A.D. periodically checks whether it can reach the internet. By default it probes + Cloudflare's utility endpoint with a few fallbacks. Set a custom endpoint below if your + network blocks the defaults. Leave blank to use the built-in defaults. +

+ + {internetStatusTestUrlEnvOverride && ( + + )} + +
+
+ { + setInternetStatusTestUrl(e.target.value) + setTestUrlError(null) + }} + /> + {testUrlError &&

{testUrlError}

} +
+ + Save + +
+
+
+
+
+ ) +} diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index 80a20ff..3bf700e 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -363,6 +363,16 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim {loading && !modal && }
+ {!isOnline && ( + + )} + {/* ── Hero / controls panel ─────────────────────────────────────────── */}
{/* Green header band */} @@ -383,7 +393,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
-
@@ -442,8 +451,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim key={cat.id} onClick={() => setActiveCategory(cat.id)} className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${activeCategory === cat.id - ? 'bg-desert-green text-white border-desert-green' - : 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light' + ? 'bg-desert-green text-white border-desert-green' + : 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light' }`} > {cat.label} @@ -899,8 +908,8 @@ function AppCard({ return (
{/* Installed accent spine (rounded to follow the card corners — the card no longer clips @@ -1065,15 +1074,15 @@ function AppCard({ { migrationInstructionsHref ? ( e.stopPropagation()} - className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary" - > - - {migrationInstructionsText || 'Migration instructions'} - + href={migrationInstructionsHref} + target="_blank" + rel="noopener noreferrer" + onClick={(e) => e.stopPropagation()} + className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary" + > + + {migrationInstructionsText || 'Migration instructions'} + ) : (null) } {!service.is_custom && onToggleAutoUpdate ? ( @@ -1147,8 +1156,8 @@ function DropdownItem({ onClick() }} className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${danger - ? 'text-desert-red hover:bg-desert-red/10' - : 'text-text-primary hover:bg-surface-secondary' + ? 'text-desert-red hover:bg-desert-red/10' + : 'text-text-primary hover:bg-surface-secondary' }`} > {icon} diff --git a/admin/start/routes.ts b/admin/start/routes.ts index e4f23c7..6c19135 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -57,6 +57,7 @@ router router.get('/zim/remote-explorer', [SettingsController, 'zimRemote']) router.get('/benchmark', [SettingsController, 'benchmark']) router.get('/support', [SettingsController, 'support']) + router.get('/advanced', [SettingsController, 'advanced']) }) .prefix('/settings') diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts index 5c32dbe..1156c3e 100644 --- a/admin/types/kv_store.ts +++ b/admin/types/kv_store.ts @@ -7,6 +7,7 @@ export const KV_STORE_SCHEMA = { 'system.updateAvailable': 'boolean', 'system.latestVersion': 'string', 'system.earlyAccess': 'boolean', + 'system.internetStatusTestUrl': 'string', 'autoUpdate.enabled': 'boolean', 'autoUpdate.windowStart': 'string', 'autoUpdate.windowEnd': 'string',