feat(supply-depot): add MeshCore Web with self-signed HTTPS

Adds the MeshCore web client to the Supply Depot catalog (host port 8500),
alongside the existing Meshtastic apps. Uses aXistem's prebuilt image of Liam
Cottle's MeshCore client (MeshCore is a sibling LoRa mesh project to Meshtastic).

The image is stock nginx serving a static Flutter build over HTTP, but the
client reaches radios via Web Bluetooth / Web Serial, which browsers only allow
from a secure (HTTPS) context. So we serve it over HTTPS: a new preinstall hook
generates a self-signed cert + a small SSL nginx config into storage/meshcore-web,
both bind-mounted into the container (the config over the image's default.conf),
publishing 443. Same one-time browser-warning approach as Vaultwarden, whose
openssl cert generation is refactored into a shared _ensureSelfSignedCert helper.

Also adds a NOMAD-specific docs section + Manage>Docs anchor, and registers the
IconAntenna icon. Meshtastic Web left unchanged.

Validated on NOMAD3 (v1.33.0-rc.1): the image + SSL config + self-signed cert
serves the MeshCore Flutter app over HTTPS 200 with working SPA fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Sherwood 2026-06-09 13:59:06 -07:00 committed by jakeaturner
parent 83576ec33d
commit bd65c885be
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
8 changed files with 181 additions and 44 deletions

View File

@ -12,6 +12,7 @@ import {
BOOKS_STORAGE_PATH,
CALIBRE_EMPTY_LIBRARY_ASSET_PATH,
VAULTWARDEN_STORAGE_PATH,
MESHCORE_WEB_STORAGE_PATH,
MEDIA_STORAGE_PATH,
JELLYFIN_MEDIA_SUBFOLDERS,
} from '../utils/fs.js'
@ -19,7 +20,7 @@ import { KiwixLibraryService } from './kiwix_library_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { exec } from 'child_process'
import { promisify } from 'util'
import { readFile, mkdir, copyFile, chown, chmod, access } from 'node:fs/promises'
import { readFile, mkdir, copyFile, chown, chmod, access, writeFile } from 'node:fs/promises'
import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
@ -697,6 +698,15 @@ export class DockerService {
)
}
if (service.service_name === SERVICE_NAMES.MESHCORE_WEB) {
await this._runPreinstallActions__MeshCoreWeb()
this._broadcast(
service.service_name,
'preinstall-complete',
`Pre-install actions for MeshCore Web completed successfully.`
)
}
// GPU-aware configuration for Ollama
let finalImage = service.container_image
let gpuHostConfig = containerConfig?.HostConfig || {}
@ -1030,18 +1040,58 @@ export class DockerService {
}
}
/**
* Ensure a self-signed TLS cert (cert.pem + key.pem) exists in `certDir`, generating one if not.
* Used by apps that need a secure (HTTPS) context but run on a LAN appliance with no public DNS to
* get a trusted cert for. Idempotent: an existing pair is left untouched, so the cert is stable
* across reinstalls (no fresh browser warning each time) and a cert an admin swapped in by hand is
* never clobbered. The private key is locked to 0600; the cert stays world-readable.
*/
private async _ensureSelfSignedCert(
certDir: string,
commonName: string
): Promise<{ certPath: string; keyPath: string }> {
const certPath = join(certDir, 'cert.pem')
const keyPath = join(certDir, 'key.pem')
await mkdir(certDir, { recursive: true })
const alreadyHasCert = await Promise.all([
access(certPath)
.then(() => true)
.catch(() => false),
access(keyPath)
.then(() => true)
.catch(() => false),
]).then(([c, k]) => c && k)
if (alreadyHasCert) return { certPath, keyPath }
// 10-year self-signed cert. CN/SAN are cosmetic for a self-signed cert (the browser warns
// regardless), but a SAN keeps it structurally valid for clients that require one.
const execAsync = promisify(exec)
await execAsync(
`openssl req -x509 -newkey rsa:2048 -nodes ` +
`-keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
`-subj "/CN=${commonName}" ` +
`-addext "subjectAltName=DNS:nomad,DNS:localhost"`
)
await chmod(keyPath, 0o600)
await chmod(certPath, 0o644)
return { certPath, keyPath }
}
/**
* Vaultwarden's web vault refuses to run without a secure context over plain HTTP it shows
* "You need to enable HTTPS!" and you can't register or unlock. We give it a self-signed TLS
* cert in its /data volume so it serves HTTPS out of the box (paired with the ROCKET_TLS env and
* the `https:8480` ui_location set in the seeder). Users click through a one-time browser warning;
* after that the vault is fully functional offline. Self-signed is the only option on a LAN
* appliance with no public DNS to get a trusted cert for.
* after that the vault is fully functional offline.
*/
private async _runPreinstallActions__Vaultwarden(): Promise<void> {
const dataDir = join(process.cwd(), VAULTWARDEN_STORAGE_PATH)
const certPath = join(dataDir, 'cert.pem')
const keyPath = join(dataDir, 'key.pem')
this._broadcast(
SERVICE_NAMES.VAULTWARDEN,
@ -1050,48 +1100,11 @@ export class DockerService {
)
try {
await mkdir(dataDir, { recursive: true })
// Don't regenerate if a cert is already present — keeps the same cert across reinstalls so
// users don't get a fresh browser warning every time, and never clobbers a cert an admin
// may have swapped in themselves.
const alreadyHasCert = await Promise.all([
access(certPath)
.then(() => true)
.catch(() => false),
access(keyPath)
.then(() => true)
.catch(() => false),
]).then(([c, k]) => c && k)
if (alreadyHasCert) {
this._broadcast(
SERVICE_NAMES.VAULTWARDEN,
'preinstall',
`Existing Vaultwarden TLS certificate found — leaving it as-is.`
)
return
}
// 10-year self-signed cert. CN/SAN are cosmetic for a self-signed cert (the browser warns
// regardless), but a SAN keeps it structurally valid for clients that require one.
const execAsync = promisify(exec)
await execAsync(
`openssl req -x509 -newkey rsa:2048 -nodes ` +
`-keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
`-subj "/CN=Project NOMAD Vaultwarden" ` +
`-addext "subjectAltName=DNS:nomad,DNS:localhost"`
)
// Lock the private key down; the cert can stay world-readable. Vaultwarden runs as root and
// reads both from /data, so no chown is needed (admin writes them as root too).
await chmod(keyPath, 0o600)
await chmod(certPath, 0o644)
await this._ensureSelfSignedCert(dataDir, 'Project NOMAD Vaultwarden')
this._broadcast(
SERVICE_NAMES.VAULTWARDEN,
'preinstall',
`Generated a self-signed HTTPS certificate for Vaultwarden.`
`Vaultwarden HTTPS certificate is ready.`
)
} catch (error: any) {
this._broadcast(
@ -1103,6 +1116,63 @@ export class DockerService {
}
}
/**
* The MeshCore web client (aXistem's prebuilt image) is stock nginx serving a static Flutter build
* over plain HTTP. The client reaches a radio over Web Bluetooth / Web Serial, which browsers only
* permit from a secure (HTTPS) context so over plain HTTP the app loads but can't connect to a
* thing. We generate a self-signed cert and a small SSL nginx config here; the seeder bind-mounts
* both into the container (the config over the image's default.conf) so it serves the same static
* files over HTTPS instead. Same one-time-browser-warning approach as Vaultwarden.
*/
private async _runPreinstallActions__MeshCoreWeb(): Promise<void> {
const appDir = join(process.cwd(), MESHCORE_WEB_STORAGE_PATH)
const certDir = join(appDir, 'certs')
const nginxConfPath = join(appDir, 'nginx-ssl.conf')
this._broadcast(
SERVICE_NAMES.MESHCORE_WEB,
'preinstall',
`Running pre-install actions for MeshCore Web...`
)
try {
await this._ensureSelfSignedCert(certDir, 'Project NOMAD MeshCore Web')
// SSL server block bind-mounted over the image's default.conf. Serves the Flutter build that
// already lives at /usr/share/nginx/html in the image, over HTTPS only, with the SPA fallback
// that single-page apps need. Cert paths match the /certs bind mount set in the seeder.
const nginxConf =
[
'server {',
' listen 443 ssl;',
' server_name _;',
' ssl_certificate /certs/cert.pem;',
' ssl_certificate_key /certs/key.pem;',
' root /usr/share/nginx/html;',
' index index.html;',
' location / {',
' try_files $uri $uri/ /index.html;',
' }',
'}',
].join('\n') + '\n'
await writeFile(nginxConfPath, nginxConf)
await chmod(nginxConfPath, 0o644)
this._broadcast(
SERVICE_NAMES.MESHCORE_WEB,
'preinstall',
`MeshCore Web HTTPS certificate and config are ready.`
)
} catch (error: any) {
this._broadcast(
SERVICE_NAMES.MESHCORE_WEB,
'preinstall-error',
`Failed to prepare MeshCore Web: ${error.message}`
)
throw new Error(`Pre-install action failed: ${error.message}`)
}
}
/**
* Jellyfin works best when each library points at its own subfolder. Pointing one library at the
* whole media root and another at a subfolder inside it makes Jellyfin report a "duplicate path"

View File

@ -17,6 +17,11 @@ export const CALIBRE_EMPTY_LIBRARY_ASSET_PATH = 'assets/calibre/metadata.db'
// Vaultwarden's /data volume. A self-signed TLS cert is generated here on install so the
// web vault has the secure context (HTTPS) it requires — see _runPreinstallActions__Vaultwarden.
export const VAULTWARDEN_STORAGE_PATH = '/storage/vaultwarden'
// MeshCore Web's working dir. On install a self-signed cert (certs/) and an SSL nginx config
// (nginx-ssl.conf) are generated here, then bind-mounted into the container so the static client is
// served over HTTPS — required for its Web Bluetooth/Serial connections. See
// _runPreinstallActions__MeshCoreWeb.
export const MESHCORE_WEB_STORAGE_PATH = '/storage/meshcore-web'
export async function listDirectoryContents(path: string): Promise<FileEntry[]> {
const entries = await readdir(path, { withFileTypes: true })

View File

@ -13,6 +13,7 @@ export const SERVICE_NAMES = {
EXCALIDRAW: 'nomad_excalidraw',
MESHTASTIC_WEB: 'nomad_meshtastic_web',
MESHTASTICD: 'nomad_meshtasticd',
MESHCORE_WEB: 'nomad_meshcore_web',
HOMEBOX: 'nomad_homebox',
VAULTWARDEN: 'nomad_vaultwarden',
JELLYFIN: 'nomad_jellyfin',

View File

@ -17,6 +17,7 @@ export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
[SERVICE_NAMES.VAULTWARDEN]: 'vaultwarden',
[SERVICE_NAMES.JELLYFIN]: 'jellyfin',
[SERVICE_NAMES.MESHTASTIC_WEB]: 'meshtastic-web',
[SERVICE_NAMES.MESHCORE_WEB]: 'meshcore-web',
}
// Returns the in-app docs link for a service, or null if it has no documentation section.

View File

@ -415,6 +415,43 @@ export default class ServiceSeeder extends BaseSeeder {
category: 'networking',
depends_on: null,
},
{
service_name: SERVICE_NAMES.MESHCORE_WEB,
friendly_name: 'MeshCore Web',
powered_by: 'MeshCore',
display_order: 32,
description: 'Browser-based client for MeshCore mesh radio devices',
icon: 'IconAntenna',
// aXistem's prebuilt image of Liam Cottle's MeshCore web client (MeshCore is a sibling LoRa
// mesh project to Meshtastic).
container_image: 'ghcr.io/axistem-dev/meshcore-web:latest',
source_repo: 'https://github.com/aXistem-dev/meshcore-web',
container_command: null,
container_config: JSON.stringify({
HostConfig: {
RestartPolicy: { Name: 'unless-stopped' },
// The image is stock nginx:alpine serving the Flutter build over HTTP on 80. MeshCore's
// client reaches a radio via Web Bluetooth / Web Serial, which browsers only permit from a
// secure (HTTPS) context — so we serve it over HTTPS. _runPreinstallActions__MeshCoreWeb
// writes a self-signed cert + an SSL server config into storage/meshcore-web; we bind both
// in (the config over the image's default.conf) and publish 443. The https: prefix on
// ui_location builds an https:// Open link (one-time cert warning, same as Vaultwarden).
PortBindings: { '443/tcp': [{ HostPort: '8500' }] },
Binds: [
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/nginx-ssl.conf:/etc/nginx/conf.d/default.conf:ro`,
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/certs:/certs:ro`,
],
},
ExposedPorts: { '443/tcp': {} },
}),
ui_location: 'https:8500',
installed: false,
installation_status: 'idle',
is_dependency_service: false,
is_custom: false,
category: 'networking',
depends_on: null,
},
{
service_name: SERVICE_NAMES.HOMEBOX,
friendly_name: 'Homebox',

View File

@ -242,3 +242,23 @@ A browser-based control panel for [Meshtastic](https://meshtastic.org) devices.
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and this app's preferences live in your browser. There's no NOMAD folder to manage.
**Works offline:** Fully offline, which is the entire point of Meshtastic. The app is served from your NOMAD, and talking to your radios happens over your local network or radio, never the internet. The only online bits are the links in the footer (Vercel, legal), which don't matter for using your mesh.
## MeshCore Web {% #meshcore-web %}
A browser-based client for [MeshCore](https://meshcore.co.uk) radios. MeshCore is another take on off-grid, long-range LoRa mesh messaging, a sibling to Meshtastic: small radios that form their own network and pass text and location for miles with no cell service, no internet, and no fees. This app is how you configure a MeshCore radio and read and send messages from a full-size screen. If you're not already running MeshCore gear, the Meshtastic client above is the more common starting point. This one is here for people who use MeshCore.
**Official site:** [meshcore.co.uk](https://meshcore.co.uk) · **Source:** [github.com/aXistem-dev/meshcore-web](https://github.com/aXistem-dev/meshcore-web) (a packaged build of Liam Cottle's MeshCore client)
**You need a MeshCore radio to use this.** Like the Meshtastic client, this is just the control panel. With no radio connected, there's nothing for it to talk to.
**First time you open it, you'll see a security warning. That's expected, here's why:** MeshCore connects to your radio over USB or Bluetooth, and browsers only let a web page use USB or Bluetooth when the page is loaded over a secure (HTTPS) connection. So NOMAD serves this app over HTTPS, and because your NOMAD is a private device with no public web address, it uses a self-signed certificate that browsers warn about the first time they see it. To get past it once:
1. Click **Open** on the MeshCore Web card. Your browser shows something like *"Your connection is not private"* or *"Not secure."*
2. Click **Advanced**, then **Proceed to (your NOMAD's address)**. (On some browsers the button says "Continue" or "Accept the Risk.")
3. You'll land in MeshCore Web. Your browser remembers your choice, so you won't see the warning again on that device.
**Connecting your radio:** Use **Chrome or Edge**, which have the best support for browser USB and Bluetooth. Plug the radio into the computer you're browsing from (USB), or have it nearby (Bluetooth), then connect to it from inside the app. The radio connects to **the computer you're using**, not to the NOMAD itself, so connect from a device that has the radio plugged in or in Bluetooth range. Some phones are stricter about self-signed certificates and may refuse to connect; a desktop Chrome or Edge is the most reliable.
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and the app's preferences live in your browser. There's no NOMAD folder to manage.
**Works offline:** Fully offline, which is the whole point of MeshCore. The app is served from your NOMAD and talks to your radio directly over USB or Bluetooth, never the internet.

View File

@ -1,4 +1,5 @@
import {
IconAntenna,
IconArrowUp,
IconBook,
IconBooks,
@ -72,6 +73,7 @@ import {
* very limited subset of the full Tabler Icons library.
*/
export const icons = {
IconAntenna,
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,

View File

@ -33,6 +33,7 @@ MEMORY_CAP = "2g" # generous cap; some apps (Stirling) OOM under 1g and fal
KNOWN_NEEDS_SETUP = {
"nomad_kiwix_server": "needs a ZIM library (managed separately by NOMAD)",
"nomad_meshtasticd": "needs a config.yaml with a MAC address",
"nomad_meshcore_web": "serves HTTPS on 443 only with the bind-mounted SSL config (absent in a bare probe)",
}