feat: configurable internet test url override in new Advanced Settings page

This commit is contained in:
jakeaturner 2026-06-22 20:53:02 +00:00
parent 02c9f72bf0
commit 4a795df793
No known key found for this signature in database
GPG key ID: B1072EBDEECE328D
10 changed files with 210 additions and 27 deletions

View file

@ -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.

View file

@ -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);

View file

@ -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) {

View file

@ -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)

View file

@ -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'];
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'];

View file

@ -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 },
]

View file

@ -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<string | null>(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 (
<SettingsLayout>
<Head title="Advanced Settings | Project N.O.M.A.D." />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">Advanced</h1>
<p className="text-text-muted mb-4">
Advanced configuration for operators. These settings are optional the defaults work
for most deployments.
</p>
<StyledSectionHeader title="Connectivity" className="mt-8 mb-4" />
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
<p className="text-sm text-text-secondary mb-4">
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.
</p>
{internetStatusTestUrlEnvOverride && (
<Alert
type="info"
variant="bordered"
title="Managed by environment variable"
message="The INTERNET_STATUS_TEST_URL environment variable is set and takes precedence over this setting. Remove it to manage the test URL here."
className="!mb-4"
/>
)}
<div className="flex items-end gap-3">
<div className="flex-1">
<Input
name="internetStatusTestUrl"
label="Internet Status Test URL"
helpText="A single http(s) URL used to check connectivity. Any HTTP response counts as online."
placeholder="https://1.1.1.1/cdn-cgi/trace"
value={internetStatusTestUrl}
disabled={internetStatusTestUrlEnvOverride}
error={Boolean(testUrlError)}
onChange={(e) => {
setInternetStatusTestUrl(e.target.value)
setTestUrlError(null)
}}
/>
{testUrlError && <p className="text-sm text-red-600 mt-1">{testUrlError}</p>}
</div>
<StyledButton
variant="primary"
onClick={handleSaveTestUrl}
loading={updateTestUrlMutation.isPending}
disabled={updateTestUrlMutation.isPending || internetStatusTestUrlEnvOverride}
className="mb-0.5"
>
Save
</StyledButton>
</div>
</div>
</main>
</div>
</SettingsLayout>
)
}

View file

@ -363,6 +363,16 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
{loading && !modal && <LoadingSpinner fullscreen text="Working..." />}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{!isOnline && (
<Alert
title="No internet connection. You may not be able to download files."
message=""
type="warning"
variant="solid"
className="!mb-4"
/>
)}
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
{/* Green header band */}
@ -383,7 +393,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
</div>
<div className="relative flex items-center gap-3">
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
<div>
@ -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 (
<div
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${service.installed
? 'border-desert-stone-light'
: 'border-desert-stone-lighter hover:border-desert-stone-light'
? 'border-desert-stone-light'
: 'border-desert-stone-lighter hover:border-desert-stone-light'
}`}
>
{/* Installed accent spine (rounded to follow the card corners the card no longer clips
@ -1065,15 +1074,15 @@ function AppCard({
{
migrationInstructionsHref ? (
<a
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"
>
<IconBook className="h-4 w-4" />
{migrationInstructionsText || 'Migration instructions'}
</a>
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"
>
<IconBook className="h-4 w-4" />
{migrationInstructionsText || 'Migration instructions'}
</a>
) : (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}

View file

@ -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')

View file

@ -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',