From 0196e3c816c1cbaf301b57aac0fa4e2127cf8e91 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Tue, 11 Aug 2026 12:46:07 -0500 Subject: [PATCH 1/7] add an existing app working --- .gitattributes | 3 + Dockerfile | 5 +- admin/app/controllers/system_controller.ts | 41 +++++ admin/app/services/docker_service.ts | 26 ++- admin/app/validators/system.ts | 16 ++ admin/inertia/components/ExistingAppModal.tsx | 162 ++++++++++++++++++ admin/inertia/lib/api.ts | 16 ++ admin/inertia/pages/supply-depot.tsx | 21 +++ admin/start/routes.ts | 6 + install/management_compose.yaml | 14 +- install/management_compose_new.yaml | 137 +++++++++++++++ 11 files changed, 432 insertions(+), 15 deletions(-) create mode 100644 .gitattributes create mode 100644 admin/inertia/components/ExistingAppModal.tsx create mode 100644 install/management_compose_new.yaml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d736644 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +Dockerfile text eol=lf +.gitattributes text eol=lf diff --git a/Dockerfile b/Dockerfile index dbfaca2..5f2ae73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -110,7 +110,8 @@ COPY install/calibre-empty-library/metadata.db /app/assets/calibre/metadata.db # Copy entrypoint script and ensure it's executable COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh -RUN chmod +x /usr/local/bin/entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh \ + && chmod +x /usr/local/bin/entrypoint.sh EXPOSE 8080 -ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] \ No newline at end of file +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 22f2cde..9b211cf 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -13,6 +13,7 @@ import { checkLatestVersionValidator, customAppValidator, deleteCustomAppValidator, + existingAppValidator, installServiceValidator, preflightCustomValidator, preflightValidator, @@ -450,6 +451,46 @@ export default class SystemController { return response.status(400).send({ success: false, message: result.message }) } + /** Register an existing Docker container in Supply Depot as a managed app entry. */ + async createExistingApp({ request, response }: HttpContext) { + const payload = await request.validateUsing(existingAppValidator) + + const existing = await Service.query().where('service_name', payload.container_name).first() + if (existing) { + return response.status(409).send({ + success: false, + message: `A service named "${payload.container_name}" already exists. Choose a different container name.`, + }) + } + + const inspect = await this.dockerService.inspectContainerByName(payload.container_name) + if (!inspect) { + return response.status(404).send({ + success: false, + message: `Docker container ${payload.container_name} not found.`, + }) + } + + await Service.create({ + service_name: payload.container_name, + friendly_name: payload.friendly_name, + container_image: inspect.Config?.Image || '', + container_config: null, + ui_location: payload.container_name, + icon: payload.icon || 'IconBrandDocker', + installed: true, + installation_status: 'idle', + is_dependency_service: false, + is_custom: true, + category: payload.category ?? 'custom', + depends_on: null, + }) + + this.dockerService.invalidateServicesStatusCache() + + return response.send({ success: true, message: `Existing app ${payload.friendly_name} added.`, service_name: payload.container_name }) + } + /** Delete a custom app: stop + remove its container, then delete the DB record. */ async deleteCustomApp({ request, response }: HttpContext) { const payload = await request.validateUsing(deleteCustomAppValidator) diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index bba8532..52ad918 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -165,9 +165,10 @@ export class DockerService { } /** - * Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_') - * Results are cached for 5 seconds and concurrent callers share a single in-flight request, - * preventing Docker socket congestion during rapid page navigation. + * Fetches the status of all Docker containers on the host and stores their + * names so the system can detect existing app containers by name. + * Results are cached for 5 seconds and concurrent callers share a single + * in-flight request, preventing Docker socket congestion during rapid page navigation. */ async getServicesStatus(): Promise<{ service_name: string; status: string }[]> { const now = Date.now() @@ -201,9 +202,11 @@ export class DockerService { const containers = await this.docker.listContainers({ all: true }) const containerMap = new Map() containers.forEach((container) => { - const name = container.Names[0]?.replace('/', '') - if (name && name.startsWith('nomad_')) { - containerMap.set(name, container) + for (const rawName of container.Names ?? []) { + const name = rawName?.replace(/^\//, '') + if (name) { + containerMap.set(name, container) + } } }) @@ -2026,6 +2029,17 @@ export class DockerService { return containers.find((c) => c.Names.includes(`/${serviceName}`)) ?? null } + async findContainerByName(serviceName: string) { + return this._findContainerByName(serviceName) + } + + async inspectContainerByName(serviceName: string) { + const info = await this._findContainerByName(serviceName) + if (!info) return null + const container = this.docker.getContainer(info.Id) + return container.inspect() + } + /** * Decode the multiplexed stream Docker returns for non-TTY container logs. Each frame is an * 8-byte header ([streamType, 0,0,0, big-endian payloadSize]) followed by the payload. diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index 45a1144..4ba96e1 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -95,6 +95,22 @@ export const customAppValidator = vine.compile( }) ) +export const existingAppValidator = vine.compile( + vine.object({ + container_name: vine + .string() + .trim() + .regex(/^[A-Za-z0-9_.-]+$/) + .minLength(1) + .maxLength(100), + friendly_name: vine.string().trim().minLength(1).maxLength(100), + category: vine + .enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom']) + .optional(), + icon: vine.string().trim().optional(), + }) +) + // Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty // value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller. export const setServiceCustomUrlValidator = vine.compile( diff --git a/admin/inertia/components/ExistingAppModal.tsx b/admin/inertia/components/ExistingAppModal.tsx new file mode 100644 index 0000000..3757030 --- /dev/null +++ b/admin/inertia/components/ExistingAppModal.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react' +import StyledModal from './StyledModal' +import StyledButton from './StyledButton' +import api from '~/lib/api' +import Input from './inputs/Input' +import Select from './inputs/Select' +import DynamicIcon, { DynamicIconName } from './DynamicIcon' + +const CATEGORY_OPTIONS = [ + { value: 'custom', label: 'Custom' }, + { value: 'productivity', label: 'Productivity' }, + { value: 'media', label: 'Media' }, + { value: 'security', label: 'Security' }, + { value: 'networking', label: 'Networking' }, + { value: 'utility', label: 'Utility' }, + { value: 'ai', label: 'AI' }, + { value: 'education', label: 'Education' }, +] + +const ICON_OPTIONS = [ + { value: 'IconBrandDocker', label: 'Docker (default)' }, + { value: 'IconBox', label: 'Box' }, + { value: 'IconServer', label: 'Server' }, + { value: 'IconDatabase', label: 'Database' }, + { value: 'IconCode', label: 'Code' }, + { value: 'IconTool', label: 'Tool' }, + { value: 'IconWorld', label: 'Web' }, + { value: 'IconShieldLock', label: 'Security' }, + { value: 'IconMovie', label: 'Media' }, + { value: 'IconBook', label: 'Book' }, + { value: 'IconNotes', label: 'Notes' }, + { value: 'IconCpu', label: 'Compute' }, + { value: 'IconRobot', label: 'AI / Bot' }, + { value: 'IconWifi', label: 'Network' }, + { value: 'IconHome', label: 'Home' }, +] + +interface ExistingAppModalProps { + open: boolean + onClose: () => void + onCreated: (serviceName: string) => void + showError: (msg: string) => void +} + +export default function ExistingAppModal({ + open, + onClose, + onCreated, + showError, +}: ExistingAppModalProps) { + const [containerName, setContainerName] = useState('') + const [friendlyName, setFriendlyName] = useState('') + const [category, setCategory] = useState('custom') + const [icon, setIcon] = useState('IconBrandDocker') + const [submitting, setSubmitting] = useState(false) + + useEffect(() => { + if (!open) return + setContainerName('') + setFriendlyName('') + setCategory('custom') + setIcon('IconBrandDocker') + setSubmitting(false) + }, [open]) + + async function handleSubmit() { + if (!containerName.trim() || !friendlyName.trim()) { + showError('Container name and display name are required.') + return + } + + setSubmitting(true) + try { + const result = await api.createExistingApp({ + container_name: containerName.trim(), + friendly_name: friendlyName.trim(), + category, + icon, + }) + + if (result?.success && result.service_name) { + onCreated(result.service_name) + } else { + showError(result?.message || 'Failed to add existing app.') + } + } catch (err: any) { + showError(err?.message || 'Unexpected error adding existing app.') + } finally { + setSubmitting(false) + } + } + + return ( + +
+
+ setContainerName(e.target.value)} + required + /> + setFriendlyName(e.target.value)} + required + /> +
+ +
+ setIcon(newVal)} + options={ICON_OPTIONS} + className="flex-1 min-w-0" + /> +
+ +
+
+
+ +

+ Add an existing Docker container by its name so it appears in the Supply Depot and + on the home dashboard. +

+ +
+ ) +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index a68d66c..c3ed8b0 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1175,6 +1175,22 @@ class API { })() } + async createExistingApp(payload: { + container_name: string + friendly_name: string + category?: string + icon?: string + }) { + return catchInternal(async () => { + const response = await this.client.post<{ + success: boolean + message: string + service_name: string + }>('/system/services/existing', payload) + return response.data + })() + } + async setServiceCustomUrl(service_name: string, custom_url: string | null) { return catchInternal(async () => { const response = await this.client.put<{ success: boolean; custom_url: string | null }>( diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index 3bf700e..d932448 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -28,6 +28,7 @@ import InstallActivityFeed from '~/components/InstallActivityFeed' import LoadingSpinner from '~/components/LoadingSpinner' import Alert from '~/components/Alert' import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal' +import ExistingAppModal from '~/components/ExistingAppModal' import AppUrlModal from '~/components/AppUrlModal' import ServiceLogsModal from '~/components/ServiceLogsModal' import ServiceStatsModal from '~/components/ServiceStatsModal' @@ -108,6 +109,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim const [checkingUpdates, setCheckingUpdates] = useState(false) const [openDropdown, setOpenDropdown] = useState(null) const [customAppOpen, setCustomAppOpen] = useState(false) + const [existingAppOpen, setExistingAppOpen] = useState(false) const [editApp, setEditApp] = useState(null) // App whose custom launch URL is being configured (null while the modal is closed). const [urlApp, setUrlApp] = useState(null) @@ -324,6 +326,11 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim // Page will reload when installation completes via broadcast } + function handleExistingAppCreated() { + setExistingAppOpen(false) + window.location.reload() + } + async function handleEdit(service: ServiceSlim) { setOpenDropdown(null) setLoading(true) @@ -442,6 +449,13 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim > Add Custom App + setExistingAppOpen(true)} + > + Add Existing App + {/* Category filters */} @@ -799,6 +813,13 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim showError={showError} /> + setExistingAppOpen(false)} + onCreated={handleExistingAppCreated} + showError={showError} + /> + {/* Custom app edit modal */} /zim, /models). Pointing at an + # empty folder gives an empty Information Library, not your existing content. + # 2. Change the host path on the left of the colon below to the new location. + # 3. Set it EXACTLY the same in NOMAD_STORAGE_PATH and the disk-collector volume (both below). + # Paths are case-sensitive (/mnt/Data != /mnt/data); a mismatch makes Docker create a new + # empty folder, which is the usual cause of "my content disappeared after moving it". + - C:/opt/project-nomad/data/storage:/app/storage + - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon + - nomad-update-shared:/app/update-shared # Shared volume for update communication + environment: + - NODE_ENV=production + # NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin + # normally auto-detects that mount, so this is a fallback used only if the container can't be + # inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place. + - NOMAD_STORAGE_PATH=C:/opt/project-nomad/data/storage + # PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host) + - PORT=8080 + - LOG_LEVEL=info + # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! + - APP_KEY=1q2w3e4r5t6y7u8i9o0p + # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking + - HOST=0.0.0.0 + # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) + - URL=http://localhost:8080 + - DB_HOST=mysql + # If you change the MySQL port, make sure to update this accordingly + - DB_PORT=3306 + - DB_DATABASE=nomad + - DB_USER=nomad_user + # Needs to match the MYSQL_PASSWORD in the mysql service! + - DB_PASSWORD=replaceme + - DB_NAME=nomad + - DB_SSL=false + - REDIS_HOST=redis + # If you change the Redis port, make sure to update this accordingly + - REDIS_PORT=6379 + - DISABLE_COMPRESSION=false # Most reverse proxies (Nginx, Caddy, etc.) will skip compression if the response is already compressed so this is usally a win all around, but if this causes issues with your setup you can set it to "true" to disable gzip in the admin server + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] + interval: 30s + timeout: 10s + retries: 3 + dozzle: + # Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the "Service Logs & Metrics" link in Settings that launches Dozzle will not work. + image: amir20/dozzle:v10.0 + container_name: nomad_dozzle + restart: unless-stopped + ports: + - "9999:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon + environment: + - DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN + - DOZZLE_ENABLE_SHELL=false # Disabled — shell access + Docker socket = privilege escalation + mysql: + image: mysql:8.0 + container_name: nomad_mysql + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=replaceme + - MYSQL_DATABASE=nomad + - MYSQL_USER=nomad_user + # Needs to match DB_PASSWORD in the admin service! + - MYSQL_PASSWORD=replaceme + volumes: + - C:/opt/project-nomad/data/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated. + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 30s + timeout: 10s + retries: 10 + redis: + image: redis:7-alpine + container_name: nomad_redis + restart: unless-stopped + volumes: + - C:/opt/project-nomad/data/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated. + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + updater: + # Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI + image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest + pull_policy: always + container_name: nomad_updater + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon + - C:/opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly + - nomad-update-shared:/shared # Shared volume for communication with admin container + disk-collector: + # Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI. + # It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality. + image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest + pull_policy: always + container_name: nomad_disk_collector + restart: unless-stopped + volumes: + - /:/host:ro # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible + # If you relocated storage (see the admin service above), set this host path to match EXACTLY, + # or the host disk-usage figures shown in the UI will point at the wrong location. + - C:/opt/project-nomad/data/storage:/storage + +volumes: + nomad-update-shared: + driver: local From 09d5639ab994186d2bc00ece86122db5ab67cb1a Mon Sep 17 00:00:00 2001 From: 1dabread Date: Tue, 11 Aug 2026 13:13:23 -0500 Subject: [PATCH 2/7] fixed app location --- admin/app/controllers/system_controller.ts | 8 ++- admin/app/services/docker_service.ts | 20 ++++++++ admin/app/services/system_service.ts | 50 +++++++++++++++++++ admin/inertia/components/ExistingAppModal.tsx | 4 +- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 9b211cf..a06cdd1 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -470,19 +470,23 @@ export default class SystemController { message: `Docker container ${payload.container_name} not found.`, }) } + const publishedHostPort = DockerService.getFirstPublishedHostPort(inspect) await Service.create({ service_name: payload.container_name, friendly_name: payload.friendly_name, container_image: inspect.Config?.Image || '', container_config: null, - ui_location: payload.container_name, + // Published existing apps are launchable from the Command Center. Containers without + // a published host port remain manageable in Supply Depot but do not get a dead tile. + ui_location: publishedHostPort, icon: payload.icon || 'IconBrandDocker', installed: true, installation_status: 'idle', is_dependency_service: false, is_custom: true, category: payload.category ?? 'custom', + display_order: publishedHostPort ? 49 : null, depends_on: null, }) @@ -845,4 +849,4 @@ export default class SystemController { cpus: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : undefined, } } -} \ No newline at end of file +} diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 52ad918..7f59f09 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -2040,6 +2040,26 @@ export class DockerService { return container.inspect() } + /** + * Return the first host port published by a Docker container inspect payload. + * Existing apps are already running, so their launch target comes from Docker's + * active port bindings rather than NOMAD's generated container config. + */ + static getFirstPublishedHostPort(inspect: any): string | null { + const ports = inspect?.NetworkSettings?.Ports ?? {} + const bindings = Object.values(ports).flat() as Array<{ + HostIp?: string + HostPort?: string + } | null> + const published = bindings + .filter((binding): binding is { HostIp?: string; HostPort: string } => + Boolean(binding?.HostPort) + ) + .sort((a, b) => Number.parseInt(a.HostPort, 10) - Number.parseInt(b.HostPort, 10)) + + return published[0]?.HostPort ?? null + } + /** * Decode the multiplexed stream Docker returns for non-TTY container logs. Each frame is an * 8-byte header ([streamType, 0,0,0, big-endian payloadSize]) followed by the payload. diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 47a950f..d01f27a 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -319,6 +319,7 @@ export class SystemService { async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise { const statuses = await this._syncContainersWithDatabase() // Sync and reuse the fetched status list + await this._syncExistingPublishedAppLinks() const query = Service.query() .orderBy('display_order', 'asc') @@ -388,6 +389,55 @@ export class SystemService { return toReturn } + /** + * Backfill launch metadata for existing Docker containers added before published ports were + * detected. A published existing app gets a Command Center link and a pre-system sort order; + * unpublished containers stay manageable in Supply Depot without a dead dashboard tile. + */ + private async _syncExistingPublishedAppLinks(): Promise { + try { + const existingApps = await Service.query() + .where('installed', true) + .where('is_custom', true) + .where('is_dependency_service', false) + .whereNull('container_config') + + for (const service of existingApps) { + const inspect = await this.dockerService.inspectContainerByName(service.service_name) + if (!inspect) continue + + const publishedHostPort = DockerService.getFirstPublishedHostPort(inspect) + let changed = false + + if (publishedHostPort && service.ui_location !== publishedHostPort) { + service.ui_location = publishedHostPort + changed = true + } + if ( + publishedHostPort && + (service.display_order === null || service.display_order >= 50) + ) { + service.display_order = 49 + changed = true + } + if (!publishedHostPort && service.ui_location === service.service_name) { + service.ui_location = null + changed = true + } + + if (changed) { + await service.save() + } + } + } catch (error) { + logger.warn( + `[SystemService] Existing app launch metadata sync failed: ${ + error instanceof Error ? error.message : error + }` + ) + } + } + static getAppVersion(): string { try { if (this.appVersion) { diff --git a/admin/inertia/components/ExistingAppModal.tsx b/admin/inertia/components/ExistingAppModal.tsx index 3757030..a50d9cc 100644 --- a/admin/inertia/components/ExistingAppModal.tsx +++ b/admin/inertia/components/ExistingAppModal.tsx @@ -153,8 +153,8 @@ export default function ExistingAppModal({

- Add an existing Docker container by its name so it appears in the Supply Depot and - on the home dashboard. + Add an existing Docker container by its name so it appears in the Supply Depot. + Published containers also appear on the home dashboard.

From 0ecd62e796f24edb1cbf3cbaec967269495f5120 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 13:24:00 -0500 Subject: [PATCH 3/7] admin login working --- admin/.env.example | 5 +- .../app/controllers/admin_auth_controller.ts | 48 +++++++ admin/app/controllers/settings_controller.ts | 15 +- .../middleware/require_admin_middleware.ts | 20 +++ admin/app/services/admin_auth_service.ts | 48 +++++++ admin/config/inertia.ts | 9 +- admin/config/session.ts | 76 +++++------ admin/inertia/pages/home.tsx | 128 +++++++++++++++++- admin/public/admin-profile.png | Bin 0 -> 2943 bytes admin/start/env.ts | 10 +- admin/start/kernel.ts | 6 +- admin/start/routes.ts | 119 ++++++++-------- admin/types/system.ts | 8 +- install/install_nomad.sh | 4 + install/management_compose.yaml | 4 + install/management_compose_new.yaml | 6 +- 16 files changed, 397 insertions(+), 109 deletions(-) create mode 100644 admin/app/controllers/admin_auth_controller.ts create mode 100644 admin/app/middleware/require_admin_middleware.ts create mode 100644 admin/app/services/admin_auth_service.ts create mode 100644 admin/public/admin-profile.png diff --git a/admin/.env.example b/admin/.env.example index f91ebe6..35ebc63 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -8,6 +8,9 @@ LOG_LEVEL=info APP_KEY=some_random_key NODE_ENV=development SESSION_DRIVER=cookie +# Admin Login credentials. ADMIN_PASS must be non-empty before admin login works. +ADMIN_USER=admin +ADMIN_PASS=replaceme DB_HOST=localhost DB_PORT=3306 DB_USER=root @@ -27,4 +30,4 @@ NOMAD_STORAGE_PATH=/opt/project-nomad/storage # CREATOR_PACKS_APP_KEY at build time; leave unset for a build that can't install # packs. CREATOR_PACKS_WORKER_BASE overrides the entitlement Worker origin. # CREATOR_PACKS_APP_KEY= -# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev \ No newline at end of file +# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev diff --git a/admin/app/controllers/admin_auth_controller.ts b/admin/app/controllers/admin_auth_controller.ts new file mode 100644 index 0000000..b83dfe0 --- /dev/null +++ b/admin/app/controllers/admin_auth_controller.ts @@ -0,0 +1,48 @@ +import AdminAuthService from '#services/admin_auth_service' +import type { HttpContext } from '@adonisjs/core/http' + +export default class AdminAuthController { + async login({ request, response, session }: HttpContext) { + const user = String(request.input('user', '')).trim() + const password = String(request.input('password', '')) + const redirectTo = this.safeRedirect(request.input('redirect')) + + if (!AdminAuthService.authenticate(user, password)) { + session.flash('errors', { + password: AdminAuthService.isConfigured() + ? 'The admin user or password was incorrect.' + : 'Admin login is not configured. Set ADMIN_USER and ADMIN_PASS in Docker Compose.', + }) + return response.redirect().back() + } + + await session.regenerate() + session.put('admin.isLoggedIn', true) + + return response.redirect().toPath(redirectTo) + } + + async logout({ response, session }: HttpContext) { + session.forget('admin.isLoggedIn') + await session.regenerate() + + return response.redirect().toPath('/home') + } + + /** + * Restrict redirects to local paths and keep auth routes from looping. + */ + private safeRedirect(value: unknown): string { + const redirectTo = typeof value === 'string' ? value : '/home' + + if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) { + return '/home' + } + + if (redirectTo.startsWith('/admin/login') || redirectTo.startsWith('/admin/logout')) { + return '/home' + } + + return redirectTo + } +} diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index bab7c5e..2ae73ba 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -10,6 +10,12 @@ import env from '#start/env' @inject() export default class SettingsController { + private static publicWritableSettings = new Set([ + 'chat.lastModel', + 'rag.defaultIngestPolicy', + 'ui.theme', + ]) + constructor( private systemService: SystemService, private mapService: MapService, @@ -140,8 +146,15 @@ export default class SettingsController { return response.status(200).send({ key, value }); } - async updateSetting({ request, response }: HttpContext) { + async updateSetting({ request, response, session }: HttpContext) { const reqData = await request.validateUsing(updateSettingSchema) + if ( + !session.get('admin.isLoggedIn') && + !SettingsController.publicWritableSettings.has(reqData.key) + ) { + return response.status(403).send({ success: false, message: 'Admin login is required.' }) + } + const valueError = validateSettingValue(reqData.key, reqData.value) if (valueError) { return response.status(422).send({ success: false, message: valueError }) diff --git a/admin/app/middleware/require_admin_middleware.ts b/admin/app/middleware/require_admin_middleware.ts new file mode 100644 index 0000000..324c556 --- /dev/null +++ b/admin/app/middleware/require_admin_middleware.ts @@ -0,0 +1,20 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class RequireAdminMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + if (ctx.session.get('admin.isLoggedIn')) { + return next() + } + + if (ctx.request.accepts(['html', 'json']) === 'json') { + return ctx.response.status(403).send({ + success: false, + message: 'Admin login is required.', + }) + } + + const redirectTo = encodeURIComponent(ctx.request.url(true)) + return ctx.response.redirect().toPath(`/home?adminLogin=1&redirect=${redirectTo}`) + } +} diff --git a/admin/app/services/admin_auth_service.ts b/admin/app/services/admin_auth_service.ts new file mode 100644 index 0000000..62177a4 --- /dev/null +++ b/admin/app/services/admin_auth_service.ts @@ -0,0 +1,48 @@ +import { timingSafeEqual } from 'node:crypto' +import env from '#start/env' + +const FALLBACK_ADMIN_USER = 'admin' + +export default class AdminAuthService { + /** + * Resolve the configured admin username. The password must still be provided + * with ADMIN_PASS before login is enabled. + */ + static user(): string { + return env.get('ADMIN_USER', FALLBACK_ADMIN_USER).trim() || FALLBACK_ADMIN_USER + } + + /** + * Admin login is disabled until ADMIN_PASS has a non-empty value. + */ + static isConfigured(): boolean { + return Boolean(env.get('ADMIN_PASS')?.trim()) + } + + /** + * Compare supplied credentials to environment-controlled credentials. + */ + static authenticate(user: string, password: string): boolean { + const configuredPassword = env.get('ADMIN_PASS')?.trim() + + if (!configuredPassword) { + return false + } + + return ( + this.secureCompare(user, this.user()) && + this.secureCompare(password, configuredPassword) + ) + } + + private static secureCompare(input: string, expected: string): boolean { + const inputBuffer = Buffer.from(input) + const expectedBuffer = Buffer.from(expected) + + if (inputBuffer.length !== expectedBuffer.length) { + return false + } + + return timingSafeEqual(inputBuffer, expectedBuffer) + } +} diff --git a/admin/config/inertia.ts b/admin/config/inertia.ts index 11ad747..674a028 100644 --- a/admin/config/inertia.ts +++ b/admin/config/inertia.ts @@ -1,6 +1,8 @@ import KVStore from '#models/kv_store' +import AdminAuthService from '#services/admin_auth_service' import { SystemService } from '#services/system_service' import { defineConfig } from '@adonisjs/inertia' +import type { HttpContext } from '@adonisjs/core/http' import type { InferSharedProps } from '@adonisjs/inertia/types' let _assistantNameCache: { value: string; expiresAt: number } | null = null @@ -21,6 +23,11 @@ const inertiaConfig = defineConfig({ sharedData: { appVersion: () => SystemService.getAppVersion(), environment: process.env.NODE_ENV || 'production', + admin: ({ session }: HttpContext) => ({ + isConfigured: AdminAuthService.isConfigured(), + isLoggedIn: Boolean(session?.get('admin.isLoggedIn')), + user: AdminAuthService.user(), + }), aiAssistantName: async () => { const now = Date.now() if (_assistantNameCache && now < _assistantNameCache.expiresAt) { @@ -46,4 +53,4 @@ export default inertiaConfig declare module '@adonisjs/inertia/types' { export interface SharedProps extends InferSharedProps {} -} \ No newline at end of file +} diff --git a/admin/config/session.ts b/admin/config/session.ts index fc49762..7c2c017 100644 --- a/admin/config/session.ts +++ b/admin/config/session.ts @@ -1,48 +1,42 @@ -// import env from '#start/env' -// import app from '@adonisjs/core/services/app' -// import { defineConfig, stores } from '@adonisjs/session' +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig, stores } from '@adonisjs/session' -// const sessionConfig = defineConfig({ -// enabled: false, -// cookieName: 'adonis-session', +const sessionConfig = defineConfig({ + enabled: true, + cookieName: 'nomad-admin-session', -// /** -// * When set to true, the session id cookie will be deleted -// * once the user closes the browser. -// */ -// clearWithBrowser: false, + /** + * Keep the browser session available until it expires or the admin logs out. + */ + clearWithBrowser: false, -// /** -// * Define how long to keep the session data alive without -// * any activity. -// */ -// age: '2h', + /** + * Define how long to keep session data alive without activity. + */ + age: '2h', -// /** -// * Configuration for session cookie and the -// * cookie store -// */ -// cookie: { -// path: '/', -// httpOnly: true, -// secure: app.inProduction, -// sameSite: 'lax', -// }, + /** + * HTTP-only cookies keep the admin session marker out of client JavaScript. + */ + cookie: { + path: '/', + httpOnly: true, + secure: app.inProduction, + sameSite: 'lax', + }, -// /** -// * The store to use. Make sure to validate the environment -// * variable in order to infer the store name without any -// * errors. -// */ -// store: env.get('SESSION_DRIVER'), + /** + * Cookie storage avoids adding a users table for a single local admin gate. + */ + store: env.get('SESSION_DRIVER', 'cookie'), -// /** -// * List of configured stores. Refer documentation to see -// * list of available stores and their config. -// */ -// stores: { -// cookie: stores.cookie(), -// }, -// }) + /** + * List of configured stores. + */ + stores: { + cookie: stores.cookie(), + }, +}) -// export default sessionConfig +export default sessionConfig diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index cf3f158..ec1dcb8 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -7,7 +7,8 @@ import { IconSettings, IconWifiOff, } from '@tabler/icons-react' -import { Head, Link, router, usePage } from '@inertiajs/react' +import { FormEvent, useEffect, useMemo, useState } from 'react' +import { Head, Link, router, useForm, usePage } from '@inertiajs/react' import AppLayout from '~/layouts/AppLayout' import { getServiceLink } from '~/lib/navigation' import { ServiceSlim } from '../../types/services' @@ -21,6 +22,8 @@ import { import { useQueryClient } from '@tanstack/react-query' import api from '~/lib/api' import Alert from '~/components/Alert' +import Input from '~/components/inputs/Input' +import StyledModal from '~/components/StyledModal' import WhatsNewBanner from '~/components/WhatsNewBanner' import { SERVICE_NAMES } from '../../constants/service_names' @@ -95,6 +98,8 @@ const SYSTEM_ITEMS = [ }, ] +const ADMIN_ONLY_LABELS = new Set(['Easy Setup', 'Supply Depot', 'Settings']) + interface DashboardItem { label: string to: string @@ -119,7 +124,52 @@ export default function Home(props: { const updateInfo = useUpdateAvailable(); const rerunBanner = useBenchmarkRerunBanner() const queryClient = useQueryClient() - const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props + const { admin, aiAssistantName } = usePage<{ + admin: { isConfigured: boolean; isLoggedIn: boolean; user: string } + aiAssistantName: string + }>().props + const [adminLoginOpen, setAdminLoginOpen] = useState(false) + const adminLoginRedirect = useMemo(() => { + const params = new URLSearchParams(window.location.search) + const redirectTo = params.get('redirect') || '/home' + + if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) { + return '/home' + } + + return redirectTo + }, []) + const adminLoginForm = useForm({ + user: admin.user || 'admin', + password: '', + redirect: adminLoginRedirect, + }) + + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get('adminLogin') === '1' && !admin.isLoggedIn) { + setAdminLoginOpen(true) + } + }, [admin.isLoggedIn]) + + const handleAdminLogin = (event?: FormEvent) => { + event?.preventDefault() + adminLoginForm.post('/admin/login', { + preserveScroll: true, + onSuccess: () => setAdminLoginOpen(false), + onFinish: () => adminLoginForm.reset('password'), + }) + } + + const openAdminLogin = () => { + adminLoginForm.clearErrors() + adminLoginForm.setData('redirect', '/home') + setAdminLoginOpen(true) + } + + const handleAdminLogout = () => { + router.post('/admin/logout', {}, { preserveScroll: true }) + } const handleDismissRerunBanner = async () => { await api.updateSetting('benchmark.rerunBannerDismissed', true) @@ -168,8 +218,10 @@ export default function Home(props: { items.push(DRUG_REFERENCE_ITEM) } - // Add system items - items.push(...SYSTEM_ITEMS) + // Add system items, hiding admin-only controls until the admin logs in. + items.push( + ...SYSTEM_ITEMS.filter((item) => admin.isLoggedIn || !ADMIN_ONLY_LABELS.has(item.label)) + ) // Sort all items by display order items.sort((a, b) => a.displayOrder - b.displayOrder) @@ -177,8 +229,72 @@ export default function Home(props: { return ( +
+ +
+ setAdminLoginOpen(false)} + onClose={() => setAdminLoginOpen(false)} + onConfirm={() => handleAdminLogin()} + > +
+ adminLoginForm.setData('user', event.target.value)} + autoComplete="username" + required + /> + adminLoginForm.setData('password', event.target.value)} + autoComplete="current-password" + error={Boolean(adminLoginForm.errors.password)} + required + /> + {adminLoginForm.errors.password && ( +

{adminLoginForm.errors.password}

+ )} + {!admin.isConfigured && !adminLoginForm.errors.password && ( +

+ Admin login is not configured. +

+ )} +
+
{ - updateInfo?.updateAvailable && ( + admin.isLoggedIn && updateInfo?.updateAvailable && (
{ - rerunBanner?.show && ( + admin.isLoggedIn && rerunBanner?.show && (
AP)QRTVxr@GU5!5>zPnswHWK^t4PVl}bUQm}aUm zrY1Gfw5GCgP@^Z8(z^0A$RS^&gmnuQlOT;O8XqP3fSQC!Q?vjDdE5*4e$ATy`{l4% z`@GLSkIOgn&)oa}`<%7TKIiPc_FCUs+w&?40EYk=0-z1RXnxxG-62KmS4#jG3}7sP z%K^*=@H+s{B08SsF>>sed9J}lUsu(h`ZEDc1Mmod9^`|60k{#sL;!~uech!3fc*iC z1F#T47u-W+F6PbI-|MRYa58|I05+gJ_#bsT+3PC+@E!oa2GE=EX?~i=-^O959l+K6 zwDY^$*hboRCy*n$bpm*oS62Y|0Dz@2I;9)H3IJ09oD1N10R0ol?ayOnSo}7CKgPUC zzXforR~OI>y*c6=@gjf@03XTIbOmrIW!wPZr3iY7?h61M?Nt>3UI*X;j>|#V;hR+V zD?rbFwCv=e*&3qjCYs>=yea{}u>e+vT-;`k`9r*t0Kn@3Ob4(fME5@dIL5020DPQJ zk_{UHTnpd`ucQEQ1m~`>>p0IVD>!*h3z#c~paFPtNpjOK` z?Nt;2P6P1wfb07`fWy2p0KiZHe+zhm+KZU1vxZy)fFB0DEsx0@RJ4*K0Q|$EpjcPu zcr^nWYa*NF_>8Q1ic;)2054b+`!j$ydNl=rHv#yI<=UlB+FUDibikV-bIGAz%>m$G z0QUrVlZU!eWFW^&4Y$-`zKCgA&bRc%s@z@sbd^77QQ#*4TD(3604+3qZPr(QCUJn^ zTl?b8FmB3CmcF^p%h9^fOuoC5cwy@XG|XacPLV!4KM(VElf{}kI8$NO4?%0z*M`h| zU-X7*j>7-e*|bdx0K+X&(1*kWo`rx*EwADD)F-bg0DR0cykC?>a7i1g+B4HWS)64Q zfXe{P;kf+(KlhOGBfq^PlP5B{oi8J0eLZ<3Q~?(_w@Xl~cxa{yjJ zc6eFB%8su=ycZ^xB%$h<*yJj>gob4|H%3QqjSvs_V~(r1IK#s7AeeY(oRbKr$;7p} zYQCp`Xi>=Uvy2n>NB{$T#|{ASR`Ph4g$#3xTrFq;d`FT0%Q%d}UWJh0BnA$<3qo}I z1%MM`oa+R3ye+6SKi{i)O^+iL;)LjQQj~*d6=miEm~81d2?%{S#d*&PpsZe;?N!7w zgJ(oB)y!zvb{A9~vJ1B>N7yR(uJB43*u?NlNFzfuku=Rnc-DRR33ygw%fi5LbZ-kr59MfQ1$+(ki= zz%Jzp&3#o4;7&!+SvktnOzJ!Gl4nc_GEDigLk)Q|-eID{fs%uixnoP7^A@Q~uNpG9 zyWmza|HQ;cwT9Oz4aYiAP)OD$<`raKPjDdj97XPQt>IsdMqMtlSuIb@xenxxSLEK{ zK<)q~KjjfE+tp04T#>hOlr2O6Ke_=N=s@m4%H+N*Z@H8$!74wb?+5uCNb-t_sg9Ie zXUP4x3%QhSrdY3aB=@^Yf1mAdAW10BhYiX$17Is%$fayE{=LDGT+RHw)Zaj&Y$lv= zR23i311{uJwnQ2I*z8E|o0a~a>2DyOQFB6f zXZ-k-3&kk=(Sn}R7G*8+unXCgzgnF;shAlzSnN=-+m+Y%?GEG$QNqNndCT?5H%WCn z{p2naw>VTx0!_|?VQAzKnz$ryxgihYK~1-?i8&4xdy67}b)F$sMkUuMa-AjHSo~>2 znsJEmrM%mDMDT?iB{i|_?Dvg?($VMeS3=t_-9mPtd*bAR;ddic1YhJX*Lp4vq>Dwo&C|*0N_A{ z82E(NP;)DMs1U7d3`whql$Id=oJbA#Ar&n7H=nTid(2nqg)*3lbpg$I(^8Z^A80$^ zH_F{R-MQv9g-iiT|8@o)AU+}!w>#Ck#;~Mad^+fWfy$D;t4!6Ws|s=#%>0|pp3OST(Skw~5(N4t$%%52Q~#?pEln^e9YbIl+0{mFHex@tJ%1-8=?>CUdpkTI7nI zBafB^9Btzm0LztwugYEXJp)zaSU6^7Mq43u@m$lHEp~Xp@$=xW%+hXNkTR~(9<$`qPVQ+q zn?u@oQkMp4QBAIf<+qdA_6i>Rs3kw$2GQ{cX>N>#MYAw%jq|OEs@Uw^NuOuldlWp$ z3OZ+GnRrTK`52~@Jrz-x^&HA#hqRT7a?+Q>(_X|ShNTj#2#nN)3MgFf$67`OPfVq7 zJl#f8rkUrcK@5P=DH-o7l-7QT5)RB#KrpuGVfmo#jz8nt&<1zg9A zX=->m0f5g0?4sGtO~Wd)odp_+Jy!;p(_Mky+9JU^hdlvrMe6lSQJ|SEOco8jnN8Li zvhc(|WW$U)?4E!|Dzg@TZKAytTXv9HXnUP6^lAn)*pIeh`AcO`P}t}iCVDjm^paA7 z2g{(GR=K%Jkf`itk|9>4t^WLOer{0!@CKzsMH4-Afz)9R=}(u7&2W)j>xOwHqH_jq zz4~~SH;G3hG1g{ffoT)F>s^)T3DT;=`QMhgRpH5%Va6BK!xnpwyH(?v!h6~cv zQ>pW&@e?@NK9&=CZWk#*9h9|iFW;y@BdcpH^XTW&J+n6AjoQVwOL^?qNW9+n7UZ~^ p9>!udV!_Er{+91b>jenI{{aAhQbk4)0B-;Q002ovPDHLkV1hZIhb903 literal 0 HcmV?d00001 diff --git a/admin/start/env.ts b/admin/start/env.ts index 40a323a..24f6533 100644 --- a/admin/start/env.ts +++ b/admin/start/env.ts @@ -33,7 +33,15 @@ export default await Env.create(new URL('../', import.meta.url), { | Variables for configuring session package |---------------------------------------------------------- */ - //SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const), + SESSION_DRIVER: Env.schema.enum.optional(['cookie'] as const), + + /* + |---------------------------------------------------------- + | Variables for configuring the built-in admin login + |---------------------------------------------------------- + */ + ADMIN_USER: Env.schema.string.optional(), + ADMIN_PASS: Env.schema.string.optional(), /* |---------------------------------------------------------- diff --git a/admin/start/kernel.ts b/admin/start/kernel.ts index cde1fb0..d92f534 100644 --- a/admin/start/kernel.ts +++ b/admin/start/kernel.ts @@ -37,7 +37,7 @@ server.use([ */ router.use([ () => import('@adonisjs/core/bodyparser_middleware'), - // () => import('@adonisjs/session/session_middleware'), + () => import('@adonisjs/session/session_middleware'), () => import('@adonisjs/shield/shield_middleware'), () => import('#middleware/compression_middleware'), ]) @@ -46,4 +46,6 @@ router.use([ * Named middleware collection must be explicitly assigned to * the routes or the routes group. */ -export const middleware = router.named({}) +export const middleware = router.named({ + admin: () => import('#middleware/require_admin_middleware'), +}) diff --git a/admin/start/routes.ts b/admin/start/routes.ts index de415d9..24ce7af 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -7,6 +7,7 @@ | */ import BenchmarkController from '#controllers/benchmark_controller' +import AdminAuthController from '#controllers/admin_auth_controller' import ChatsController from '#controllers/chats_controller' import ConditionsController from '#controllers/conditions_controller' import DocsController from '#controllers/docs_controller' @@ -28,6 +29,7 @@ import ZimController from '#controllers/zim_controller' import router from '@adonisjs/core/services/router' import transmit from '@adonisjs/transmit/services/main' import { documented } from '#start/openapi/documented' +import { middleware } from '#start/kernel' import { remoteDownloadValidator, remoteDownloadWithMetadataValidator, @@ -101,22 +103,29 @@ router.get('/home', [HomeController, 'home']) router.on('/about').renderInertia('about') router.get('/chat', [ChatsController, 'inertia']) router.get('/maps', [MapsController, 'index']) -router.get('/supply-depot', [SupplyDepotController, 'index']) +router.post('/admin/login', [AdminAuthController, 'login']) +router.post('/admin/logout', [AdminAuthController, 'logout']) +router.get('/supply-depot', [SupplyDepotController, 'index']).use(middleware.admin()) router.on('/knowledge-base').redirectToPath('/chat?knowledge_base=true') // redirect for legacy knowledge-base links -router.get('/easy-setup', [EasySetupController, 'index']) -router.get('/easy-setup/complete', [EasySetupController, 'complete']) +router.get('/easy-setup', [EasySetupController, 'index']).use(middleware.admin()) +router.get('/easy-setup/complete', [EasySetupController, 'complete']).use(middleware.admin()) documented( - router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories']), + router + .get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories']) + .use(middleware.admin()), { summary: 'List curated easy-setup categories', tags: ['easy-setup'], } ) -documented(router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']), { - summary: 'Refresh content manifests', - tags: ['easy-setup'], -}) +documented( + router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']).use(middleware.admin()), + { + summary: 'Refresh content manifests', + tags: ['easy-setup'], + } +) router .group(() => { documented(router.post('/check', [CollectionUpdatesController, 'checkForUpdates']), { @@ -135,6 +144,7 @@ router }) }) .prefix('/api/content-updates') + .use(middleware.admin()) router .group(() => { @@ -152,6 +162,7 @@ router router.get('/advanced', [SettingsController, 'advanced']) }) .prefix('/settings') + .use(middleware.admin()) router .group(() => { @@ -181,26 +192,26 @@ router summary: 'Fetch the latest map collections', tags: ['maps'], }) - documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']), { + documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']).use(middleware.admin()), { summary: 'Download base map assets', tags: ['maps'], request: remoteDownloadValidatorOptional, }) - documented(router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap']), { + documented(router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap']).use(middleware.admin()), { summary: 'Provision the world base map', tags: ['maps'], }) - documented(router.post('/download-remote', [MapsController, 'downloadRemote']), { + documented(router.post('/download-remote', [MapsController, 'downloadRemote']).use(middleware.admin()), { summary: 'Queue a remote map download', tags: ['maps'], request: remoteDownloadValidator, }) - documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']), { + documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']).use(middleware.admin()), { summary: 'Preflight a remote map download', tags: ['maps'], request: remoteDownloadValidator, }) - documented(router.post('/download-collection', [MapsController, 'downloadCollection']), { + documented(router.post('/download-collection', [MapsController, 'downloadCollection']).use(middleware.admin()), { summary: 'Download a map collection', tags: ['maps'], request: downloadCollectionValidator, @@ -209,7 +220,7 @@ router summary: 'Get global map information', tags: ['maps'], }) - documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']), { + documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']).use(middleware.admin()), { summary: 'Download the global map', tags: ['maps'], }) @@ -221,12 +232,12 @@ router summary: 'List country groups', tags: ['maps'], }) - documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']), { + documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']).use(middleware.admin()), { summary: 'Preflight a map region extraction', tags: ['maps'], request: mapExtractPreflightValidator, }) - documented(router.post('/extract', [MapsController, 'extractRegion']), { + documented(router.post('/extract', [MapsController, 'extractRegion']).use(middleware.admin()), { summary: 'Extract a map region', tags: ['maps'], request: mapExtractValidator, @@ -247,7 +258,7 @@ router summary: 'Delete a map marker', tags: ['maps'], }) - documented(router.delete('/:filename', [MapsController, 'delete']), { + documented(router.delete('/:filename', [MapsController, 'delete']).use(middleware.admin()), { summary: 'Delete a map file', tags: ['maps'], params: filenameParamValidator, @@ -534,110 +545,110 @@ router summary: 'List services', tags: ['system'], }) - documented(router.post('/services/affect', [SystemController, 'affectService']), { + documented(router.post('/services/affect', [SystemController, 'affectService']).use(middleware.admin()), { summary: 'Start, stop, or restart a service', tags: ['system'], request: affectServiceValidator, }) - documented(router.post('/services/install', [SystemController, 'installService']), { + documented(router.post('/services/install', [SystemController, 'installService']).use(middleware.admin()), { summary: 'Install a service', tags: ['system'], request: installServiceValidator, }) - documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']), { + documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']).use(middleware.admin()), { summary: 'Force reinstall a service', tags: ['system'], request: installServiceValidator, }) - documented(router.post('/services/uninstall', [SystemController, 'uninstallService']), { + documented(router.post('/services/uninstall', [SystemController, 'uninstallService']).use(middleware.admin()), { summary: 'Uninstall a service', tags: ['system'], request: uninstallServiceValidator, }) - documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']), { + documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']).use(middleware.admin()), { summary: 'Check for service updates', tags: ['system'], }) - documented(router.get('/services/preflight', [SystemController, 'preflightCheck']), { + documented(router.get('/services/preflight', [SystemController, 'preflightCheck']).use(middleware.admin()), { summary: 'Preflight a service install', tags: ['system'], query: preflightValidator, }) - documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']), { + documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']).use(middleware.admin()), { summary: 'Suggest an available custom port', tags: ['system'], }) - documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']), { + documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']).use(middleware.admin()), { summary: 'Preflight a custom app install', tags: ['system'], request: preflightCustomValidator, }) - documented(router.post('/services/custom', [SystemController, 'createCustomApp']), { + documented(router.post('/services/custom', [SystemController, 'createCustomApp']).use(middleware.admin()), { summary: 'Create a custom app', tags: ['system'], request: customAppValidator, }) - documented(router.post('/services/existing', [SystemController, 'createExistingApp']), { + documented(router.post('/services/existing', [SystemController, 'createExistingApp']).use(middleware.admin()), { summary: 'Add an existing Docker container as an app', tags: ['system'], request: existingAppValidator, }) - documented(router.put('/services/custom', [SystemController, 'updateCustomApp']), { + documented(router.put('/services/custom', [SystemController, 'updateCustomApp']).use(middleware.admin()), { summary: 'Update a custom app', tags: ['system'], request: updateCustomAppValidator, }) - documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']), { + documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']).use(middleware.admin()), { summary: 'Pull the latest version of a custom app', tags: ['system'], request: installServiceValidator, }) - documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']), { + documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']).use(middleware.admin()), { summary: 'Delete a custom app', tags: ['system'], request: deleteCustomAppValidator, }) - documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']), { + documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']).use(middleware.admin()), { summary: 'Get a custom app', tags: ['system'], }) - documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']), { + documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']).use(middleware.admin()), { summary: 'Set a service custom URL', tags: ['system'], request: setServiceCustomUrlValidator, }) - documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']), { + documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']).use(middleware.admin()), { summary: 'Get service logs', tags: ['system'], query: serviceLogsValidator, }) - documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']), { + documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']).use(middleware.admin()), { summary: 'Get service stats', tags: ['system'], }) - documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']), { + documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']).use(middleware.admin()), { summary: 'List available service versions', tags: ['system'], }) - documented(router.post('/services/update', [SystemController, 'updateService']), { + documented(router.post('/services/update', [SystemController, 'updateService']).use(middleware.admin()), { summary: 'Update a service', tags: ['system'], request: updateServiceValidator, }) - documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']), { + documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']).use(middleware.admin()), { summary: 'Set service auto-update', tags: ['system'], request: setServiceAutoUpdateValidator, }) - documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']), { + documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']).use(middleware.admin()), { summary: 'Get app auto-update status', tags: ['system'], }) - documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']), { + documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']).use(middleware.admin()), { summary: 'Get content auto-update status', tags: ['system'], }) - documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']), { + documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']).use(middleware.admin()), { summary: 'Subscribe to release notes', tags: ['system'], request: subscribeToReleaseNotesValidator, @@ -647,19 +658,19 @@ router tags: ['system'], query: checkLatestVersionValidator, }) - documented(router.post('/update', [SystemController, 'requestSystemUpdate']), { + documented(router.post('/update', [SystemController, 'requestSystemUpdate']).use(middleware.admin()), { summary: 'Request a system update', tags: ['system'], }) - documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']), { + documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']).use(middleware.admin()), { summary: 'Get system update status', tags: ['system'], }) - documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']), { + documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']).use(middleware.admin()), { summary: 'Get system update logs', tags: ['system'], }) - documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']), { + documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']).use(middleware.admin()), { summary: 'Get system auto-update status', tags: ['system'], }) @@ -691,18 +702,18 @@ router summary: 'List curated ZIM categories', tags: ['zim'], }) - documented(router.post('/download-remote', [ZimController, 'downloadRemote']), { + documented(router.post('/download-remote', [ZimController, 'downloadRemote']).use(middleware.admin()), { summary: 'Queue a remote ZIM download', tags: ['zim'], request: remoteDownloadWithMetadataValidator, }) - documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']), { + documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']).use(middleware.admin()), { summary: 'Download a ZIM category tier', tags: ['zim'], request: downloadCategoryTierValidator, }) - documented(router.post('/upload', [ZimController, 'upload']), { + documented(router.post('/upload', [ZimController, 'upload']).use(middleware.admin()), { summary: 'Upload a ZIM file', tags: ['zim'], }) @@ -710,7 +721,7 @@ router summary: 'Get Wikipedia ZIM state', tags: ['zim'], }) - documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']), { + documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']).use(middleware.admin()), { summary: 'Select a Wikipedia ZIM edition', tags: ['zim'], request: selectWikipediaValidator, @@ -720,12 +731,12 @@ router summary: 'List custom ZIM libraries', tags: ['zim'], }) - documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']), { + documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']).use(middleware.admin()), { summary: 'Add a custom ZIM library', tags: ['zim'], request: addCustomLibraryValidator, }) - documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']), { + documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']).use(middleware.admin()), { summary: 'Remove a custom ZIM library', tags: ['zim'], params: idParamValidator, @@ -736,12 +747,12 @@ router query: browseLibraryValidator, }) - documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']), { + documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']).use(middleware.admin()), { summary: 'Rescan the ZIM library', tags: ['zim'], }) - documented(router.delete('/:filename', [ZimController, 'delete']), { + documented(router.delete('/:filename', [ZimController, 'delete']).use(middleware.admin()), { summary: 'Delete a ZIM file', tags: ['zim'], params: filenameParamValidator, @@ -755,11 +766,11 @@ router summary: 'List creator packs', tags: ['creator-packs'], }) - documented(router.post('/:id/install', [CreatorPacksController, 'install']), { + documented(router.post('/:id/install', [CreatorPacksController, 'install']).use(middleware.admin()), { summary: 'Install a creator pack', tags: ['creator-packs'], }) - documented(router.delete('/:id', [CreatorPacksController, 'uninstall']), { + documented(router.delete('/:id', [CreatorPacksController, 'uninstall']).use(middleware.admin()), { summary: 'Uninstall a creator pack', tags: ['creator-packs'], }) diff --git a/admin/types/system.ts b/admin/types/system.ts index fb7dfb8..cea726e 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -24,6 +24,12 @@ export type SystemInformationResponse = { export type UsePageProps = { appVersion: string environment: string + admin: { + isConfigured: boolean + isLoggedIn: boolean + user: string + } + aiAssistantName: string } export type LSBlockDevice = { @@ -160,4 +166,4 @@ export type ContentAutoUpdateStatus = { lastError: string | null autoDisabledReason: string | null resources: ContentAutoUpdateResourceStatus[] -} \ No newline at end of file +} diff --git a/install/install_nomad.sh b/install/install_nomad.sh index df34cb5..6e1a3da 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -420,6 +420,7 @@ download_management_compose_file() { local app_key=$(generateRandomPass) local db_root_password=$(generateRandomPass) local db_user_password=$(generateRandomPass) + admin_password=$(generateRandomPass) # If MySQL data directory exists from a previous install attempt, remove it. # MySQL only initializes credentials on first startup when the data dir is empty. @@ -434,6 +435,7 @@ download_management_compose_file() { echo -e "${YELLOW}#${RESET} Configuring docker-compose file env variables...\\n" sed -i "s|URL=replaceme|URL=http://${local_ip_address}:8080|g" "$compose_file_path" sed -i "s|APP_KEY=replaceme|APP_KEY=${app_key}|g" "$compose_file_path" + sed -i "s|ADMIN_PASS=replaceme|ADMIN_PASS=${admin_password}|g" "$compose_file_path" sed -i "s|DB_PASSWORD=replaceme|DB_PASSWORD=${db_user_password}|g" "$compose_file_path" sed -i "s|MYSQL_ROOT_PASSWORD=replaceme|MYSQL_ROOT_PASSWORD=${db_root_password}|g" "$compose_file_path" @@ -605,6 +607,8 @@ success_message() { echo -e "${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\n\n" echo -e "${GREEN}#${RESET} Project NOMAD's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${NOMAD_DIR}/start_nomad.sh${RESET}\\n" echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\n" + echo -e "${GREEN}#${RESET} Admin Login user: ${WHITE_R}admin${RESET}" + echo -e "${GREEN}#${RESET} Admin Login password: ${WHITE_R}${admin_password}${RESET}\\n" echo -e "${GREEN}#${RESET} Thank you for supporting Project NOMAD!\\n" } diff --git a/install/management_compose.yaml b/install/management_compose.yaml index 363abfb..45196ef 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -34,6 +34,7 @@ services: - nomad-update-shared:/app/update-shared # Shared volume for update communication environment: - NODE_ENV=production + - SESSION_DRIVER=cookie # NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin # normally auto-detects that mount, so this is a fallback used only if the container can't be # inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place. @@ -43,6 +44,9 @@ services: - LOG_LEVEL=info # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - APP_KEY=1q2w3e4r5t6y7u8i9o0p + # Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine. + - ADMIN_USER=admin + - ADMIN_PASS=replaceme # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking - HOST=0.0.0.0 # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) diff --git a/install/management_compose_new.yaml b/install/management_compose_new.yaml index 51288a0..6185da1 100644 --- a/install/management_compose_new.yaml +++ b/install/management_compose_new.yaml @@ -9,7 +9,7 @@ name: project-nomad services: admin: - image: nomad:1.0 + image: nomad:1.1 container_name: nomad_admin restart: unless-stopped extra_hosts: @@ -33,6 +33,7 @@ services: - nomad-update-shared:/app/update-shared # Shared volume for update communication environment: - NODE_ENV=production + - SESSION_DRIVER=cookie # NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin # normally auto-detects that mount, so this is a fallback used only if the container can't be # inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place. @@ -42,6 +43,9 @@ services: - LOG_LEVEL=info # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - APP_KEY=1q2w3e4r5t6y7u8i9o0p + # Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine. + - ADMIN_USER=admin + - ADMIN_PASS=1q2w3e4r5t6y7u8i9o0p # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking - HOST=0.0.0.0 # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) From 79a09717a24fb61f6a29655756b4e95d996ea52c Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 14:43:29 -0500 Subject: [PATCH 4/7] updated home grid to display all custom and existing apps before "easy setup" --- admin/app/controllers/system_controller.ts | 31 +++++++++++++++++++++- admin/app/services/system_service.ts | 26 +++++++++--------- admin/inertia/pages/home.tsx | 5 +++- install/management_compose_new.yaml | 2 +- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index a06cdd1..dea0cee 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -36,6 +36,8 @@ import type { HttpContext } from '@adonisjs/core/http' import logger from '@adonisjs/core/services/logger' import Service from '#models/service' +const CUSTOM_APP_HOME_DISPLAY_ORDER = 49 + @inject() export default class SystemController { constructor( @@ -441,6 +443,7 @@ export default class SystemController { is_dependency_service: false, is_custom: true, category: payload.category ?? 'custom', + display_order: uiLocation ? CUSTOM_APP_HOME_DISPLAY_ORDER : null, depends_on: null, }) @@ -486,7 +489,7 @@ export default class SystemController { is_dependency_service: false, is_custom: true, category: payload.category ?? 'custom', - display_order: publishedHostPort ? 49 : null, + display_order: publishedHostPort ? CUSTOM_APP_HOME_DISPLAY_ORDER : null, depends_on: null, }) @@ -569,6 +572,19 @@ export default class SystemController { } service.custom_url = normalized + if ( + normalized && + (service.display_order === null || service.display_order >= 50) + ) { + service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER + } + if ( + !normalized && + !service.ui_location && + service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER + ) { + service.display_order = null + } await service.save() return response.send({ success: true, custom_url: service.custom_url }) @@ -704,6 +720,19 @@ export default class SystemController { ? `${prevScheme}:${uiLocation}` : uiLocation service.category = payload.category ?? service.category ?? 'custom' + if ( + (service.ui_location || service.custom_url) && + (service.display_order === null || service.display_order >= 50) + ) { + service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER + } + if ( + !service.ui_location && + !service.custom_url && + service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER + ) { + service.display_order = null + } if (payload.icon) service.icon = payload.icon // Flag as user-modified so the seeder stops overwriting this app's config on future runs. service.is_user_modified = true diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index d01f27a..92798e7 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -24,6 +24,8 @@ import { isNewerVersion } from '../utils/version.js' import { invalidateAssistantNameCache } from '../../config/inertia.js' import { KiwixLibraryService } from '#services/kiwix_library_service' +const CUSTOM_APP_HOME_DISPLAY_ORDER = 49 + @inject() export class SystemService { private static appVersion: string | null = null @@ -319,7 +321,7 @@ export class SystemService { async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise { const statuses = await this._syncContainersWithDatabase() // Sync and reuse the fetched status list - await this._syncExistingPublishedAppLinks() + await this._syncCustomAppHomeLinks() const query = Service.query() .orderBy('display_order', 'asc') @@ -390,23 +392,21 @@ export class SystemService { } /** - * Backfill launch metadata for existing Docker containers added before published ports were - * detected. A published existing app gets a Command Center link and a pre-system sort order; - * unpublished containers stay manageable in Supply Depot without a dead dashboard tile. + * Backfill launch metadata for custom app records. Launchable apps get the same pre-system + * Command Center sort order whether they were created by NOMAD or registered from an existing + * Docker container. */ - private async _syncExistingPublishedAppLinks(): Promise { + private async _syncCustomAppHomeLinks(): Promise { try { - const existingApps = await Service.query() + const customApps = await Service.query() .where('installed', true) .where('is_custom', true) .where('is_dependency_service', false) - .whereNull('container_config') - for (const service of existingApps) { + for (const service of customApps) { const inspect = await this.dockerService.inspectContainerByName(service.service_name) - if (!inspect) continue + const publishedHostPort = inspect ? DockerService.getFirstPublishedHostPort(inspect) : null - const publishedHostPort = DockerService.getFirstPublishedHostPort(inspect) let changed = false if (publishedHostPort && service.ui_location !== publishedHostPort) { @@ -414,10 +414,10 @@ export class SystemService { changed = true } if ( - publishedHostPort && + (publishedHostPort || service.ui_location || service.custom_url) && (service.display_order === null || service.display_order >= 50) ) { - service.display_order = 49 + service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER changed = true } if (!publishedHostPort && service.ui_location === service.service_name) { @@ -431,7 +431,7 @@ export class SystemService { } } catch (error) { logger.warn( - `[SystemService] Existing app launch metadata sync failed: ${ + `[SystemService] Custom app launch metadata sync failed: ${ error instanceof Error ? error.message : error }` ) diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index cf3f158..99b4002 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -24,6 +24,8 @@ import Alert from '~/components/Alert' import WhatsNewBanner from '~/components/WhatsNewBanner' import { SERVICE_NAMES } from '../../constants/service_names' +const APP_FALLBACK_DISPLAY_ORDER = 49 + // Maps is a Core Capability (display_order: 4) const MAPS_ITEM = { label: 'Maps', @@ -153,7 +155,8 @@ export default function Home(props: { ), installed: service.installed, - displayOrder: service.display_order ?? 100, + // Launchable apps without an explicit order still belong before system tiles. + displayOrder: service.display_order ?? APP_FALLBACK_DISPLAY_ORDER, poweredBy: service.powered_by ?? null, }) }) diff --git a/install/management_compose_new.yaml b/install/management_compose_new.yaml index 51288a0..cab6f16 100644 --- a/install/management_compose_new.yaml +++ b/install/management_compose_new.yaml @@ -9,7 +9,7 @@ name: project-nomad services: admin: - image: nomad:1.0 + image: nomad:app-button container_name: nomad_admin restart: unless-stopped extra_hosts: From 839cdb3495a806fe4ff32ba74d4984a5f8fb1a92 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 19:26:07 -0500 Subject: [PATCH 5/7] fixed some errors around adding apps. updated existing apps to not be managed by nomad. change so that they are marked as existing and it is displayed in the supply depot. removed unused options from the manage dropdown --- admin/app/controllers/system_controller.ts | 45 ++++++++++++++-- admin/app/jobs/check_service_updates_job.ts | 4 +- admin/app/models/service.ts | 9 ++++ admin/app/services/app_auto_update_service.ts | 10 +++- admin/app/services/docker_service.ts | 12 +++++ admin/app/services/system_service.ts | 2 + ...00004_add_existing_app_flag_to_services.ts | 27 ++++++++++ admin/database/seeders/service_seeder.ts | 1 + admin/inertia/components/CustomAppModal.tsx | 21 ++++++-- admin/inertia/lib/api.ts | 43 +++++++++++++--- admin/inertia/pages/supply-depot.tsx | 51 ++++++++++++------- admin/types/services.ts | 1 + 12 files changed, 192 insertions(+), 34 deletions(-) create mode 100644 admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index dea0cee..54f89ed 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -488,6 +488,7 @@ export default class SystemController { installation_status: 'idle', is_dependency_service: false, is_custom: true, + is_existing: true, category: payload.category ?? 'custom', display_order: publishedHostPort ? CUSTOM_APP_HOME_DISPLAY_ORDER : null, depends_on: null, @@ -498,7 +499,7 @@ export default class SystemController { return response.send({ success: true, message: `Existing app ${payload.friendly_name} added.`, service_name: payload.container_name }) } - /** Delete a custom app: stop + remove its container, then delete the DB record. */ + /** Delete a custom app, or unregister an existing app without touching its container. */ async deleteCustomApp({ request, response }: HttpContext) { const payload = await request.validateUsing(deleteCustomAppValidator) @@ -510,10 +511,13 @@ export default class SystemController { return response.status(403).send({ error: 'Only custom apps can be deleted.' }) } - await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false) + if (!service.is_existing) { + await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false) + } await service.delete() - return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` }) + const action = service.is_existing ? 'removed from Supply Depot' : 'deleted' + return response.send({ success: true, message: `Custom app ${payload.service_name} ${action}` }) } /** Uninstall a curated catalog app: stop + remove its container (optionally its image) and @@ -601,6 +605,9 @@ export default class SystemController { if (!service.is_custom) { return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' }) } + if (service.is_existing) { + return response.status(403).send({ success: false, message: 'Existing apps are not recreated or updated by NOMAD.' }) + } const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, { forcePull: true, @@ -668,6 +675,37 @@ export default class SystemController { if (service.is_dependency_service) { return response.status(403).send({ success: false, message: 'This service cannot be edited.' }) } + if (service.is_existing) { + service.friendly_name = payload.friendly_name + service.container_image = payload.image + service.category = payload.category ?? service.category ?? 'custom' + if (payload.icon) service.icon = payload.icon + + const inspect = await this.dockerService.inspectContainerByName(payload.service_name) + const publishedHostPort = inspect ? DockerService.getFirstPublishedHostPort(inspect) : null + service.ui_location = publishedHostPort + if ( + (service.ui_location || service.custom_url) && + (service.display_order === null || service.display_order >= 50) + ) { + service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER + } + if ( + !service.ui_location && + !service.custom_url && + service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER + ) { + service.display_order = null + } + service.is_user_modified = true + await service.save() + + return response.send({ + success: true, + message: `Existing app ${payload.service_name} updated.`, + service_name: payload.service_name, + }) + } // Reject duplicate host ports within the request. const hostPorts = (payload.ports ?? []).map((p) => p.host) @@ -868,6 +906,7 @@ export default class SystemController { return { service_name: service.service_name, friendly_name: service.friendly_name, + is_existing: service.is_existing, image: service.container_image, category: service.category ?? 'custom', icon: service.icon ?? 'IconBrandDocker', diff --git a/admin/app/jobs/check_service_updates_job.ts b/admin/app/jobs/check_service_updates_job.ts index cdbf68e..1da18ba 100644 --- a/admin/app/jobs/check_service_updates_job.ts +++ b/admin/app/jobs/check_service_updates_job.ts @@ -26,7 +26,9 @@ export class CheckServiceUpdatesJob { // Determine host architecture const hostArch = await this.getHostArch(dockerService) - const installedServices = await Service.query().where('installed', true) + const installedServices = await Service.query() + .where('installed', true) + .where('is_existing', false) let updatesFound = 0 for (const service of installedServices) { diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 8d192ef..a03abb5 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -75,6 +75,15 @@ export default class Service extends BaseModel { }) declare is_custom: boolean + // True for Docker containers the user registered after creating them outside NOMAD. These + // records are metadata-only: NOMAD may start/stop them, but must not recreate or delete them. + @column({ + serialize(value) { + return Boolean(value) + }, + }) + declare is_existing: boolean + @column({ serialize(value) { return Boolean(value) diff --git a/admin/app/services/app_auto_update_service.ts b/admin/app/services/app_auto_update_service.ts index c6317b6..9e1fa60 100644 --- a/admin/app/services/app_auto_update_service.ts +++ b/admin/app/services/app_auto_update_service.ts @@ -179,7 +179,10 @@ export class AppAutoUpdateService { /** Installed, opted-in apps that are eligible to update right now. */ async getEligibleApps(config: AppAutoUpdateConfig, now: DateTime): Promise { - const apps = await Service.query().where('installed', true).where('auto_update_enabled', true) + const apps = await Service.query() + .where('installed', true) + .where('auto_update_enabled', true) + .where('is_existing', false) const targets: AppUpdateTarget[] = [] for (const service of apps) { const verdict = this.appEligibility(service, config.cooloffHours, now) @@ -340,7 +343,10 @@ export class AppAutoUpdateService { const config = await this.getConfig() const now = DateTime.now() - const apps = await Service.query().where('installed', true).where('auto_update_enabled', true) + const apps = await Service.query() + .where('installed', true) + .where('auto_update_enabled', true) + .where('is_existing', false) const appStatuses: AppAutoUpdateAppStatus[] = apps.map((service) => { const verdict = this.appEligibility(service, config.cooloffHours, now) return { diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 7f59f09..a3809e2 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -362,6 +362,12 @@ export class DockerService { message: `Service ${serviceName} not found`, } } + if (service.is_existing) { + return { + success: false, + message: `Existing app ${serviceName} is registered only and cannot be force reinstalled by NOMAD`, + } + } // Check if installation is already in progress if (this.activeInstallations.has(serviceName)) { @@ -1588,6 +1594,12 @@ export class DockerService { if (!service.installed) { return { success: false, message: `Service ${serviceName} is not installed` } } + if (service.is_existing) { + return { + success: false, + message: `Existing app ${serviceName} is registered only and cannot be updated by NOMAD`, + } + } if (this.activeInstallations.has(serviceName)) { return { success: false, message: `Service ${serviceName} already has an operation in progress` } } diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 92798e7..d9d3b2c 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -342,6 +342,7 @@ export class SystemService { 'available_update_version', 'auto_update_enabled', 'is_custom', + 'is_existing', 'is_user_modified', 'is_deprecated', 'category' @@ -382,6 +383,7 @@ export class SystemService { available_update_version: service.available_update_version, auto_update_enabled: service.auto_update_enabled, is_custom: service.is_custom, + is_existing: service.is_existing, is_user_modified: service.is_user_modified, is_deprecated: service.is_deprecated, category: service.category, diff --git a/admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts b/admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts new file mode 100644 index 0000000..e982bc8 --- /dev/null +++ b/admin/database/migrations/1772000000004_add_existing_app_flag_to_services.ts @@ -0,0 +1,27 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.boolean('is_existing').notNullable().defaultTo(false) + }) + + this.defer(async (db) => { + // Earlier Add Existing App records were saved as custom apps with no generated + // container_config. Backfill those so they keep their external-container semantics. + await db + .from(this.tableName) + .where('is_custom', true) + .whereNull('container_config') + .update({ is_existing: true }) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('is_existing') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 77362b1..68586c8 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -13,6 +13,7 @@ type ServiceSeedRecord = Omit< | 'available_update_version' | 'update_checked_at' | 'metadata' + | 'is_existing' | 'is_user_modified' | 'is_deprecated' | 'custom_url' diff --git a/admin/inertia/components/CustomAppModal.tsx b/admin/inertia/components/CustomAppModal.tsx index 77ada8f..4cd5634 100644 --- a/admin/inertia/components/CustomAppModal.tsx +++ b/admin/inertia/components/CustomAppModal.tsx @@ -25,6 +25,7 @@ interface EnvVar { export interface CustomAppInitial { service_name: string friendly_name: string | null + is_existing?: boolean image: string category: string icon: string @@ -84,6 +85,7 @@ export default function CustomAppModal({ initial = null, }: CustomAppModalProps) { const isEdit = mode === 'edit' + const isExisting = isEdit && Boolean(initial?.is_existing) const [friendlyName, setFriendlyName] = useState('') const [image, setImage] = useState('') const [category, setCategory] = useState('custom') @@ -132,6 +134,13 @@ export default function CustomAppModal({ // conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting. useEffect(() => { if (!open) return + if (isExisting) { + setPortConflicts([]) + setResourceWarnings([]) + setBlocked([]) + setCheckingPreflight(false) + return + } const validPorts = ports .map((p) => parseInt(p.host, 10)) .filter((p) => !isNaN(p)) @@ -162,7 +171,7 @@ export default function CustomAppModal({ }, 400) return () => clearTimeout(handle) - }, [open, ports, volumes, image]) + }, [open, isExisting, ports, volumes, image]) function resetForm() { setFriendlyName('') @@ -226,7 +235,7 @@ export default function CustomAppModal({ showError('Name and image are required.') return } - if (blocked.length > 0) { + if (!isExisting && blocked.length > 0) { showError('Resolve the blocked issues before installing.') return } @@ -282,17 +291,19 @@ export default function CustomAppModal({ const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0 const hasBlocks = blocked.length > 0 const canSubmit = - friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall) + friendlyName.trim() && + image.trim() && + (isExisting || (!hasBlocks && (!hasWarnings || forceInstall))) return ( { + try { const response = await this.client.post<{ success: boolean message: string service_name: string }>('/system/services/custom', payload) return response.data - })() + } catch (error) { + if (error instanceof AxiosError && error.response?.data) { + return error.response.data as { + success: false + message: string + warnings?: string[] + portConflicts?: Array<{ port: number; usedBy: string }> + blocked?: string[] + } + } + console.error('Error creating custom app:', error) + return undefined + } } async createExistingApp(payload: { @@ -1181,14 +1193,20 @@ class API { category?: string icon?: string }) { - return catchInternal(async () => { + try { const response = await this.client.post<{ success: boolean message: string service_name: string }>('/system/services/existing', payload) return response.data - })() + } catch (error) { + if (error instanceof AxiosError && error.response?.data) { + return error.response.data as { success: false; message: string } + } + console.error('Error adding existing app:', error) + return undefined + } } async setServiceCustomUrl(service_name: string, custom_url: string | null) { @@ -1264,6 +1282,7 @@ class API { app: { service_name: string friendly_name: string | null + is_existing: boolean image: string category: string icon: string @@ -1291,14 +1310,26 @@ class API { cpus?: number force?: boolean }) { - return catchInternal(async () => { + try { const response = await this.client.put<{ success: boolean message: string service_name: string }>('/system/services/custom', payload) return response.data - })() + } catch (error) { + if (error instanceof AxiosError && error.response?.data) { + return error.response.data as { + success: false + message: string + warnings?: string[] + portConflicts?: Array<{ port: number; usedBy: string }> + blocked?: string[] + } + } + console.error('Error updating custom app:', error) + return undefined + } } } diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index d932448..6c12533 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -703,7 +703,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim {/* Delete custom app modal */} {modal?.type === 'delete' && ( { if (loading) return @@ -711,24 +711,33 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim setModal(null) }} onConfirm={() => handleDelete(modal.service)} - confirmText="Delete" + confirmText={modal.service.is_existing ? 'Remove' : 'Delete'} confirmIcon="IconTrash" confirmVariant="danger" confirmLoading={loading} icon={} >
-

This will permanently remove this custom app.

-

The container will be stopped and removed. Host volume data will remain on disk.

- + {modal.service.is_existing ? ( + <> +

This will remove this existing app from Supply Depot.

+

The Docker container and image will not be stopped, removed, or changed.

+ + ) : ( + <> +

This will permanently remove this custom app.

+

The container will be stopped and removed. Host volume data will remain on disk.

+ + + )}
)} @@ -898,6 +907,7 @@ function AppCard({ const isRunning = service.status === 'running' const isStopped = service.installed && !isRunning const catColor = service.category ? CATEGORY_COLORS[service.category] ?? CATEGORY_COLORS.custom : CATEGORY_COLORS.custom + const customKindLabel = service.is_existing ? 'existing' : 'custom' const isDropdownOpen = openDropdown === service.service_name // Port pill: an ui_location may carry an explicit scheme ("https:8480") — show just the port, // with a lock when it's served over HTTPS, rather than the raw "https:8480" string. @@ -994,7 +1004,7 @@ function AppCard({ )} {service.is_custom ? ( - custom + {customKindLabel} ) : null} {service.is_user_modified && !service.is_custom ? ( @@ -1132,12 +1142,19 @@ function AppCard({ onClick={onUpdateVersion} /> ) : null} - {service.is_custom ? ( + {service.is_custom && !service.is_existing ? ( } label="Update (pull latest)" onClick={onUpdate} /> ) : null} - } label="Force Reinstall" onClick={onReinstall} danger /> + {!service.is_existing ? ( + } label="Force Reinstall" onClick={onReinstall} danger /> + ) : null} {service.is_custom ? ( - } label="Delete" onClick={onDelete} danger /> + } + label={service.is_existing ? 'Remove' : 'Delete'} + onClick={onDelete} + danger + /> ) : ( } label="Uninstall" onClick={onUninstall} danger /> )} diff --git a/admin/types/services.ts b/admin/types/services.ts index 0883c90..6c7dc7b 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -17,6 +17,7 @@ export type ServiceSlim = Pick< | 'available_update_version' | 'auto_update_enabled' | 'is_custom' + | 'is_existing' | 'is_user_modified' | 'is_deprecated' | 'category' From e7c453e4cc312c438cf58dd463aa6733023fcab4 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 20:02:49 -0500 Subject: [PATCH 6/7] removed custom compose yaml --- install/management_compose_new.yaml | 141 ---------------------------- 1 file changed, 141 deletions(-) delete mode 100644 install/management_compose_new.yaml diff --git a/install/management_compose_new.yaml b/install/management_compose_new.yaml deleted file mode 100644 index e385529..0000000 --- a/install/management_compose_new.yaml +++ /dev/null @@ -1,141 +0,0 @@ -# Project NOMAD management services Docker Compose configuration -# -# This compose file defines the admin server, database, and other supporting services required to run Project NOMAD -# You can use this with `docker-compose up -d` to start all the necessary services with a single command after installation. -# -# Note: we recommend leaving all of the environment variables as-is except for any "replaceme" values, -# which must be updated for the admin server to start successfully. The default values are optimized for ease of installation and use, -# but you can customize them as needed (e.g. changing ports, database connection details, log level, etc.) -name: project-nomad -services: - admin: - image: nomad:app-button - container_name: nomad_admin - restart: unless-stopped - extra_hosts: - - "host.docker.internal:host-gateway" # Enables host.docker.internal on Linux - ports: - - "8080:8080" - volumes: - # RELOCATING STORAGE: the host path on the LEFT of the colon below is the single source of - # truth for where all NOMAD data lives (ZIMs, AI models, notes, etc.). At runtime the admin - # inspects this mount and points every child app (Kiwix, Ollama, etc.) at the same host - # location automatically, so you do NOT edit each service. To move storage: - # 1. Stop NOMAD (`docker compose down`) and MOVE the existing data to the new location, - # keeping the subfolders intact (e.g. /zim, /models). Pointing at an - # empty folder gives an empty Information Library, not your existing content. - # 2. Change the host path on the left of the colon below to the new location. - # 3. Set it EXACTLY the same in NOMAD_STORAGE_PATH and the disk-collector volume (both below). - # Paths are case-sensitive (/mnt/Data != /mnt/data); a mismatch makes Docker create a new - # empty folder, which is the usual cause of "my content disappeared after moving it". - - C:/opt/project-nomad/data/storage:/app/storage - - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon - - nomad-update-shared:/app/update-shared # Shared volume for update communication - environment: - - NODE_ENV=production - - SESSION_DRIVER=cookie - # NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin - # normally auto-detects that mount, so this is a fallback used only if the container can't be - # inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place. - - NOMAD_STORAGE_PATH=C:/opt/project-nomad/data/storage - # PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host) - - PORT=8080 - - LOG_LEVEL=info - # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - - APP_KEY=1q2w3e4r5t6y7u8i9o0p - # Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine. - - ADMIN_USER=admin - - ADMIN_PASS=1q2w3e4r5t6y7u8i9o0p - # # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking - - HOST=0.0.0.0 - # URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080) - - URL=http://localhost:8080 - - DB_HOST=mysql - # If you change the MySQL port, make sure to update this accordingly - - DB_PORT=3306 - - DB_DATABASE=nomad - - DB_USER=nomad_user - # Needs to match the MYSQL_PASSWORD in the mysql service! - - DB_PASSWORD=replaceme - - DB_NAME=nomad - - DB_SSL=false - - REDIS_HOST=redis - # If you change the Redis port, make sure to update this accordingly - - REDIS_PORT=6379 - - DISABLE_COMPRESSION=false # Most reverse proxies (Nginx, Caddy, etc.) will skip compression if the response is already compressed so this is usally a win all around, but if this causes issues with your setup you can set it to "true" to disable gzip in the admin server - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] - interval: 30s - timeout: 10s - retries: 3 - dozzle: - # Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the "Service Logs & Metrics" link in Settings that launches Dozzle will not work. - image: amir20/dozzle:v10.0 - container_name: nomad_dozzle - restart: unless-stopped - ports: - - "9999:8080" - volumes: - - /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon - environment: - - DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN - - DOZZLE_ENABLE_SHELL=false # Disabled — shell access + Docker socket = privilege escalation - mysql: - image: mysql:8.0 - container_name: nomad_mysql - restart: unless-stopped - environment: - - MYSQL_ROOT_PASSWORD=replaceme - - MYSQL_DATABASE=nomad - - MYSQL_USER=nomad_user - # Needs to match DB_PASSWORD in the admin service! - - MYSQL_PASSWORD=replaceme - volumes: - - C:/opt/project-nomad/data/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated. - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] - interval: 30s - timeout: 10s - retries: 10 - redis: - image: redis:7-alpine - container_name: nomad_redis - restart: unless-stopped - volumes: - - C:/opt/project-nomad/data/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated. - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 30s - timeout: 10s - retries: 3 - updater: - # Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI - image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest - pull_policy: always - container_name: nomad_updater - restart: unless-stopped - volumes: - - /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon - - C:/opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly - - nomad-update-shared:/shared # Shared volume for communication with admin container - disk-collector: - # Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI. - # It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality. - image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest - pull_policy: always - container_name: nomad_disk_collector - restart: unless-stopped - volumes: - - /:/host:ro # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible - # If you relocated storage (see the admin service above), set this host path to match EXACTLY, - # or the host disk-usage figures shown in the UI will point at the wrong location. - - C:/opt/project-nomad/data/storage:/storage - -volumes: - nomad-update-shared: - driver: local From 21c9242851a5bc83fa6a3f0806698c3928712436 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 20:26:43 -0500 Subject: [PATCH 7/7] fixed cookie https error. admin login should work now --- admin/app/utils/cookie_security.ts | 10 ++++++++++ admin/config/app.ts | 5 +++-- admin/config/session.ts | 6 ++++-- admin/tests/unit/cookie_security.spec.ts | 20 ++++++++++++++++++++ install/management_compose_new.yaml | 2 +- 5 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 admin/app/utils/cookie_security.ts create mode 100644 admin/tests/unit/cookie_security.spec.ts diff --git a/admin/app/utils/cookie_security.ts b/admin/app/utils/cookie_security.ts new file mode 100644 index 0000000..62f7d7a --- /dev/null +++ b/admin/app/utils/cookie_security.ts @@ -0,0 +1,10 @@ +/** + * Decide whether cookies should be marked Secure from the public URL users visit. + */ +export function shouldUseSecureCookies(publicUrl: string): boolean { + try { + return new URL(publicUrl).protocol === 'https:' + } catch { + return false + } +} diff --git a/admin/config/app.ts b/admin/config/app.ts index 1292af7..1a21c07 100644 --- a/admin/config/app.ts +++ b/admin/config/app.ts @@ -1,7 +1,7 @@ import env from '#start/env' -import app from '@adonisjs/core/services/app' import { Secret } from '@adonisjs/core/helpers' import { defineConfig } from '@adonisjs/core/http' +import { shouldUseSecureCookies } from '../app/utils/cookie_security.js' /** * The app key is used for encrypting cookies, generating signed URLs, @@ -11,6 +11,7 @@ import { defineConfig } from '@adonisjs/core/http' * changed. Therefore it is recommended to keep the app key secure. */ export const appKey = new Secret(env.get('APP_KEY')) +const secureCookies = shouldUseSecureCookies(env.get('URL')) /** * The configuration settings used by the HTTP server @@ -34,7 +35,7 @@ export const http = defineConfig({ path: '/', maxAge: '2h', httpOnly: true, - secure: app.inProduction, + secure: secureCookies, sameSite: 'lax', }, }) diff --git a/admin/config/session.ts b/admin/config/session.ts index 7c2c017..acff597 100644 --- a/admin/config/session.ts +++ b/admin/config/session.ts @@ -1,7 +1,9 @@ import env from '#start/env' -import app from '@adonisjs/core/services/app' +import { shouldUseSecureCookies } from '../app/utils/cookie_security.js' import { defineConfig, stores } from '@adonisjs/session' +const secureCookies = shouldUseSecureCookies(env.get('URL')) + const sessionConfig = defineConfig({ enabled: true, cookieName: 'nomad-admin-session', @@ -22,7 +24,7 @@ const sessionConfig = defineConfig({ cookie: { path: '/', httpOnly: true, - secure: app.inProduction, + secure: secureCookies, sameSite: 'lax', }, diff --git a/admin/tests/unit/cookie_security.spec.ts b/admin/tests/unit/cookie_security.spec.ts new file mode 100644 index 0000000..30feabc --- /dev/null +++ b/admin/tests/unit/cookie_security.spec.ts @@ -0,0 +1,20 @@ +import * as assert from 'node:assert/strict' +import { test } from 'node:test' + +import { shouldUseSecureCookies } from '../../app/utils/cookie_security.js' + +test('enables secure cookies for HTTPS public URLs', () => { + assert.equal(shouldUseSecureCookies('https://nomad.example.com'), true) + assert.equal(shouldUseSecureCookies('https://nomad.example.com:8443/admin'), true) +}) + +test('disables secure cookies for HTTP public URLs', () => { + assert.equal(shouldUseSecureCookies('http://home'), false) + assert.equal(shouldUseSecureCookies('http://localhost:8080'), false) + assert.equal(shouldUseSecureCookies('http://192.168.1.10:8080'), false) +}) + +test('disables secure cookies when the public URL is invalid', () => { + assert.equal(shouldUseSecureCookies('replaceme'), false) + assert.equal(shouldUseSecureCookies(''), false) +}) diff --git a/install/management_compose_new.yaml b/install/management_compose_new.yaml index e385529..2b3d54d 100644 --- a/install/management_compose_new.yaml +++ b/install/management_compose_new.yaml @@ -9,7 +9,7 @@ name: project-nomad services: admin: - image: nomad:app-button + image: nomad:admin-user container_name: nomad_admin restart: unless-stopped extra_hosts: