feat(deploy): serve Homelable under a configurable base path

Homelable could only own the root of an origin. Behind an existing proxy
at `https://home.example/homelab/` the built `index.html` still asked for
`/assets/*`, which fell through to whatever owned the root — and when
that answered `text/html` for a `<script>` under `nosniff`, the browser
failed the load on an HTTP 200. The only workaround was patching the
checkout and rebuilding on every upgrade.

One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base`. Vite
rewrites the asset URLs it emits; everything the app builds by hand goes
through the new `utils/basePath.ts` — the axios instances, the WebSocket
URL, the live-view route, the local brand icons, and the OIDC login href.
`resolveServerPath` covers what the *backend* hands back, which is always
root-absolute because it cannot know where the SPA is mounted: uploaded
floor-plan URLs already stored in a canvas are resolved at render time,
so plans predating the move keep loading. The OIDC callback used to
redirect to `/`, dropping subpath users at the origin root after login;
it now reads the prefix back out of `OIDC_REDIRECT_URI`.

The default is `/`, and stays a no-op there by construction: every helper
returns the string it returned before, the root build output is unchanged
and both nginx sites are byte-identical to what shipped — the Docker
image copies `docker/nginx.conf` verbatim and the installer keeps its
original heredoc. Only a non-default prefix takes the generated config.

Those generated configs use `root`, never `alias`, since `alias` plus
`try_files` mis-resolves `$uri` — the Docker build lands the bundle in
the matching subdirectory, and the installer symlinks it under
`/var/www/homelable`. Both accept either reverse-proxy style, prefix
forwarded intact or already stripped, with no redirect loop between them,
and `absolute_redirect off` stops the no-slash 301 from eating the port.

Closes #334.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-09-04 11:23:16 +02:00
committed by Pouzor - Rémy Jardient
parent 3cbda60277
commit 4e8d841807
25 changed files with 674 additions and 27 deletions
+5
View File
@@ -8,6 +8,11 @@ SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Serve the UI under a subpath instead of the root of the origin, e.g. /homelab/.
# Read by docker-compose at *build* time (docker compose build frontend), since the
# prefix is baked into the frontend bundle. See INSTALLATION.md.
# VITE_BASE_PATH=/homelab/
# Auth — local mode is the backward-compatible default.
AUTH_MODE=local
# Local credentials: admin / admin by default.
+30 -8
View File
@@ -7,6 +7,12 @@ FROM --platform=$BUILDPLATFORM node:20-slim AS builder
ARG VITE_STANDALONE=false
ENV VITE_STANDALONE=$VITE_STANDALONE
# Subpath support: build with VITE_BASE_PATH=/homelab/ to serve Homelable under a
# prefix instead of the root of an origin. The default '/' produces the same build
# as before the option existed.
ARG VITE_BASE_PATH=/
ENV VITE_BASE_PATH=$VITE_BASE_PATH
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
@@ -18,15 +24,31 @@ RUN npm run build
# Stage 2: serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY --from=builder /app/dist /tmp/dist
# Nginx config: use standalone config (no backend proxy) or full config
# Nginx config: standalone (no backend proxy) or full, served at the root or under the
# VITE_BASE_PATH prefix. At the root the build lands in /usr/share/nginx/html and the
# shipped .conf is copied verbatim, so an existing deployment gets exactly what it had
# before this option existed. Under a prefix the build lands in the matching
# subdirectory, which keeps the generated config on plain `root` semantics.
ARG VITE_STANDALONE=false
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY docker/nginx.standalone.conf /etc/nginx/conf.d/nginx.standalone.conf
RUN if [ "$VITE_STANDALONE" = "true" ]; then \
cp /etc/nginx/conf.d/nginx.standalone.conf /etc/nginx/conf.d/default.conf; \
fi && \
rm /etc/nginx/conf.d/nginx.standalone.conf
ARG VITE_BASE_PATH=/
COPY docker/nginx.conf docker/nginx.standalone.conf /tmp/nginx-conf/
COPY docker/nginx.subpath.conf.template docker/nginx.standalone.subpath.conf.template /tmp/nginx-conf/
# ash with pipefail: the base-path normalization below pipes printf into sed.
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
RUN set -eu; \
if [ "$VITE_STANDALONE" = "true" ]; then variant=nginx.standalone; else variant=nginx; fi; \
base=$(printf '%s' "${VITE_BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g'); \
[ -n "$base" ] || base=/; \
mkdir -p "/usr/share/nginx/html${base}"; \
cp -a /tmp/dist/. "/usr/share/nginx/html${base}"; \
if [ "$base" = "/" ]; then \
cp "/tmp/nginx-conf/${variant}.conf" /etc/nginx/conf.d/default.conf; \
else \
sed -e "s#__BASE_NO_SLASH__#${base%/}#g" -e "s#__BASE__#${base}#g" \
"/tmp/nginx-conf/${variant}.subpath.conf.template" > /etc/nginx/conf.d/default.conf; \
fi; \
rm -rf /tmp/dist /tmp/nginx-conf
EXPOSE 80
+78
View File
@@ -189,6 +189,7 @@ sudo HTTP_PORT=8080 ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["10.0.0.0/24"]' \
| `ADMIN_PASSWORD` | prompt, else `admin` | Initial password for user `admin` |
| `SCANNER_RANGES` | prompt, else guessed | JSON array of CIDRs |
| `SKIP_NGINX=1` | off | Do not install or touch nginx |
| `BASE_PATH` | `/` | Serve under a subpath — see [Serving under a subpath](#serving-under-a-subpath) |
### Afterwards
@@ -283,6 +284,83 @@ the service.
---
## Serving under a subpath
By default Homelable owns the root of its origin (`https://homelable.example/`).
To put it behind an existing reverse proxy on a shared hostname — one cert, one
dynamic-DNS name, one open port, every service on its own prefix — build it with
a base path.
The base path is **baked into the build**: the browser has no way to guess it, so
it cannot be a runtime setting. Changing it means rebuilding the frontend.
### Docker
```bash
# in .env, next to the backend settings
VITE_BASE_PATH=/homelab/
docker compose build frontend && docker compose up -d
```
Homelable then answers on `http://<host>:3000/homelab/`. The generated nginx
config accepts both reverse-proxy styles, so either of these works in front of
it:
```nginx
# prefix forwarded intact — no trailing slash on proxy_pass
location /homelab/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# or: prefix stripped — trailing slash on proxy_pass
location /homelab/ {
proxy_pass http://127.0.0.1:3000/;
...
}
```
The pre-built images (`docker-compose.prebuilt.yml`) are built for the root, so a
subpath needs a local build.
### Bare metal / LXC
```bash
sudo BASE_PATH=/homelab/ bash scripts/install-baremetal.sh
```
The script bakes the prefix into the build and writes the matching nginx site.
The build stays in `/opt/homelable/frontend/dist`; the site serves it through
`/var/www/homelable/homelab`, a symlink refreshed on every run. Re-running the
script with a different `BASE_PATH` (or none) rewrites both.
### Development
```bash
cd frontend && VITE_BASE_PATH=/homelab/ npm run dev # http://localhost:5173/homelab/
```
The Vite dev proxy follows the same prefix and strips it before forwarding to
uvicorn on `:8000`.
### What to expect
- WebSocket status updates, uploaded floor plans and the read-only live view
(`/homelab/view`) all follow the prefix.
- TLS in front still means adding your hostname to `CORS_ORIGINS` in
`backend/.env`, exactly as at the root.
- OIDC: `OIDC_REDIRECT_URI` must carry the prefix
(`https://home.example/homelab/api/v1/auth/oidc/callback`), and so must the
redirect URI registered with your provider.
- Floor plans uploaded before the move keep working — stored URLs are resolved
against the base path at render time.
---
## Configuration
All configuration is done via `.env` (copied from `.env.example`):
+5 -2
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel
from starlette.responses import RedirectResponse
from app.api.deps import AuthContext, get_auth_context
from app.core.config import settings
from app.core.config import app_base_path_of, settings
from app.core.security import (
clear_oidc_session_cookie,
create_access_token,
@@ -103,7 +103,10 @@ async def oidc_callback(request: Request) -> Response:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
request.session.clear()
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
# Back to the SPA — which is not necessarily at the root of the origin.
response = RedirectResponse(
url=app_base_path_of(settings.oidc_redirect_uri), status_code=status.HTTP_303_SEE_OTHER
)
set_oidc_session_cookie(response, session_token)
return response
+20
View File
@@ -17,6 +17,26 @@ def origin_of(url: str) -> str:
return ""
return f"{parts.scheme}://{parts.netloc}"
OIDC_CALLBACK_PATH = "/api/v1/auth/oidc/callback"
def app_base_path_of(redirect_uri: str) -> str:
"""
Path the SPA is served under, read back from OIDC_REDIRECT_URI.
The frontend can be mounted under a prefix (`VITE_BASE_PATH=/homelab/`), and the
backend only ever learns about it through the callback URL the operator
configured — `https://home.example/homelab/api/v1/auth/oidc/callback` means the
app lives at `/homelab/`. Always returns a path with a trailing slash; `/` when
the URI is unset, relative, or not a callback URL.
"""
path = urlsplit(redirect_uri).path
if not path.endswith(OIDC_CALLBACK_PATH):
return "/"
base = path[: -len(OIDC_CALLBACK_PATH)]
return f"{base}/" if base else "/"
def _read_version() -> str:
for candidate in [
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
+34
View File
@@ -272,6 +272,40 @@ async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_s
assert protected.status_code == 200
async def test_oidc_callback_returns_to_the_app_base_path(client: AsyncClient, oidc_settings):
"""The SPA can be mounted under a prefix — the post-login redirect follows it."""
oidc_settings.oidc_redirect_uri = "http://test/homelab/api/v1/auth/oidc/callback"
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
callback = await client.get("/api/v1/auth/oidc/callback")
assert callback.status_code == 303
assert callback.headers["location"] == "/homelab/"
@pytest.mark.parametrize(
("redirect_uri", "expected"),
[
("", "/"),
("https://home.example/api/v1/auth/oidc/callback", "/"),
("https://home.example/homelab/api/v1/auth/oidc/callback", "/homelab/"),
("https://home.example/apps/lab/api/v1/auth/oidc/callback", "/apps/lab/"),
("/api/v1/auth/oidc/callback", "/"),
("https://home.example/somewhere/else", "/"),
],
)
def test_app_base_path_of(redirect_uri: str, expected: str):
from app.core.config import app_base_path_of
assert app_base_path_of(redirect_uri) == expected
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
token = {} if userinfo is None else {"userinfo": userinfo}
+4
View File
@@ -49,6 +49,10 @@ services:
build:
context: .
dockerfile: Dockerfile.frontend
args:
# Serve Homelable under a subpath, e.g. VITE_BASE_PATH=/homelab/ in .env.
# Defaults to the root — see docs/INSTALLATION.md.
VITE_BASE_PATH: ${VITE_BASE_PATH:-/}
restart: unless-stopped
ports:
- "3000:80"
@@ -0,0 +1,26 @@
# Standalone (no backend) Homelable served under a subpath (__BASE__).
# Generated at image build time — see docker/nginx.subpath.conf.template.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Relative Location headers — the port and scheme belong to whatever proxy is
# in front, not to this server block.
absolute_redirect off;
location = __BASE_NO_SLASH__ {
return 301 __BASE__;
}
# SPA fallback under the prefix — no backend proxy
location __BASE__ {
try_files $uri $uri/ __BASE__index.html;
}
# Same, for a front proxy that strips the prefix before forwarding
location / {
root /usr/share/nginx/html__BASE_NO_SLASH__;
try_files $uri $uri/ /index.html;
}
}
+86
View File
@@ -0,0 +1,86 @@
# Homelable served under a subpath (__BASE__) instead of the root of the origin.
#
# Generated at image build time by Dockerfile.frontend when VITE_BASE_PATH is set:
# __BASE__ is the prefix with its trailing slash, __BASE_NO_SLASH__ without it. A
# default build keeps docker/nginx.conf and this file is never read.
#
# The static build is copied to /usr/share/nginx/html__BASE__, so every block below
# uses plain `root` semantics — `alias` plus `try_files` mis-resolves $uri.
#
# Both reverse-proxy styles work against this config:
# - prefix forwarded intact (proxy_pass http://homelable;) -> the __BASE__ blocks
# - prefix stripped (proxy_pass http://homelable/;) -> the trailing blocks
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Relative Location headers — the port and scheme belong to whatever proxy is
# in front, not to this server block.
absolute_redirect off;
# --- prefix forwarded intact ------------------------------------------------
location = __BASE_NO_SLASH__ {
return 301 __BASE__;
}
# WebSocket (must come before the API block to take priority)
location __BASE__api/v1/status/ws/ {
proxy_pass http://backend:8000/api/v1/status/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location __BASE__api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Legacy /ws/ path
location __BASE__ws/ {
proxy_pass http://backend:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# SPA fallback under the prefix
location __BASE__ {
try_files $uri $uri/ __BASE__index.html;
}
# --- prefix already stripped by the front proxy -----------------------------
# No redirect to __BASE__ here: the front proxy would strip it again and loop.
location /api/v1/status/ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /ws/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location / {
root /usr/share/nginx/html__BASE_NO_SLASH__;
try_files $uri $uri/ /index.html;
}
}
+5 -2
View File
@@ -9,6 +9,7 @@ import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { copyToClipboard } from '@/utils/clipboard'
import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl'
import { withBase } from '@/utils/basePath'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
@@ -772,7 +773,9 @@ export default function App() {
if (STANDALONE) {
// Standalone reads canvas from localStorage; pass the active design id so
// the read-only tab renders the same canvas the user is viewing.
const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view'
const url = activeDesignId
? withBase(`view?design=${encodeURIComponent(activeDesignId)}`)
: withBase('view')
window.open(url, '_blank', 'noopener,noreferrer')
return
}
@@ -784,7 +787,7 @@ export default function App() {
}
const params = new URLSearchParams({ key: res.data.key })
if (activeDesignId) params.set('design', activeDesignId)
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
window.open(withBase(`view?${params.toString()}`), '_blank', 'noopener,noreferrer')
} catch {
toast.error('Failed to open live view')
}
+13
View File
@@ -70,6 +70,19 @@ describe('api/client', () => {
expect(publicApi.defaults.baseURL).toBe('/api/v1')
})
it('mounts both instances under a configured base path', async () => {
const before = instances.length
vi.stubEnv('BASE_URL', '/homelab/')
vi.resetModules()
await import('../client')
const [subApi, subPublicApi] = instances.slice(before)
expect(subApi.defaults.baseURL).toBe('/homelab/api/v1')
expect(subApi.defaults.withCredentials).toBe(true)
expect(subPublicApi.defaults.baseURL).toBe('/homelab/api/v1')
vi.unstubAllEnvs()
vi.resetModules()
})
it('exports `api` matching the first created instance', () => {
expect(mod.api).toBe(api)
})
+3 -2
View File
@@ -1,15 +1,16 @@
import axios from 'axios'
import { API_BASE_URL } from '@/utils/basePath'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMode, AuthUser } from '@/stores/authStore'
import type { InventoryEntry } from '@/types'
export const api = axios.create({
baseURL: '/api/v1',
baseURL: API_BASE_URL,
withCredentials: true,
})
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
const publicApi = axios.create({ baseURL: API_BASE_URL })
api.interceptors.request.use((config) => {
const { token, csrfToken } = useAuthStore.getState()
+2 -1
View File
@@ -7,6 +7,7 @@ import { Logo } from '@/components/ui/Logo'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/lib/utils'
import { API_BASE_URL, resolveServerPath } from '@/utils/basePath'
export function LoginPage() {
const [username, setUsername] = useState('')
@@ -111,7 +112,7 @@ export function LoginPage() {
Continue through the configured OpenID Connect provider.
</p>
<a
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
href={resolveServerPath(oidcLoginUrl ?? `${API_BASE_URL}/auth/oidc/login`)}
className={cn(
buttonVariants(),
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
import { resolveServerPath } from '@/utils/basePath'
interface ResizeState {
startMouseX: number
@@ -152,7 +153,7 @@ export function FloorMapLayer() {
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
>
<img
src={imageData}
src={resolveServerPath(imageData)}
alt="Floor plan"
draggable={false}
style={{
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { FloorMapLayer } from '../FloorMapLayer'
import { useCanvasStore } from '@/stores/canvasStore'
import type { FloorMapConfig } from '@/types'
@@ -43,6 +43,16 @@ describe('FloorMapLayer', () => {
expect(container.querySelector('img')).toBeNull()
})
it('renders the stored image URL as-is at the root, base64 plans included', () => {
setFloorMap()
const { rerender } = render(<FloorMapLayer />)
expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('/api/v1/media/abc.png')
act(() => setFloorMap({ imageData: 'data:image/png;base64,abc' }))
rerender(<FloorMapLayer />)
expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('data:image/png;base64,abc')
})
it('hides resize handles until the plan is selected, then shows them (unlocked)', () => {
setFloorMap()
render(<FloorMapLayer />)
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
import type { Design, DesignType, FloorMapConfig } from '@/types'
import { resolveServerPath } from '@/utils/basePath'
/** Canvas kinds offered on create. `electrical` shares the network renderer. */
const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [
@@ -294,7 +295,7 @@ export function DesignModal({
) : (
<>
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 160 }}>
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
<img src={resolveServerPath(imageData)} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
</div>
<div className="flex items-center gap-2">
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useStatusPolling } from '../useStatusPolling'
// Served under /homelab/ — the WebSocket URL has to carry the prefix too, or it
// lands on whatever else owns the root of the origin.
vi.mock('@/utils/basePath', () => ({ API_BASE_URL: '/homelab/api/v1' }))
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: () => ({
setNodeStatus: vi.fn(),
notifyScanDeviceFound: vi.fn(),
setServiceStatuses: vi.fn(),
}),
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({ isAuthenticated: true, authMethod: 'local', token: 'test-token' }),
}))
class MockWebSocket {
static instances: MockWebSocket[] = []
url: string
onopen: (() => void) | null = null
onmessage: ((e: { data: string }) => void) | null = null
onerror: ((e: unknown) => void) | null = null
send = vi.fn()
close = vi.fn()
constructor(url: string) {
this.url = url
MockWebSocket.instances.push(this)
}
}
describe('useStatusPolling under a base path', () => {
beforeEach(() => {
MockWebSocket.instances = []
vi.stubGlobal('WebSocket', MockWebSocket)
Object.defineProperty(window, 'location', {
value: { protocol: 'http:', host: 'home.example' },
writable: true,
})
})
afterEach(() => vi.restoreAllMocks())
it('prefixes the WebSocket URL with the base path', () => {
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
expect(MockWebSocket.instances[0].url).toBe('ws://home.example/homelab/api/v1/status/ws/status')
})
})
+2 -1
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { ServiceStatus } from '@/types'
import { API_BASE_URL } from '@/utils/basePath'
interface ServiceStatusEntry {
port?: number
@@ -58,7 +59,7 @@ export function useStatusPolling() {
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host // includes port when non-standard
const url = `${protocol}://${host}/api/v1/status/ws/status`
const url = `${protocol}://${host}${API_BASE_URL}/status/ws/status`
const ws = new WebSocket(url)
wsRef.current = ws
+2 -1
View File
@@ -3,8 +3,9 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import LiveView from './components/LiveView.tsx'
import { isLiveViewPath } from './utils/basePath.ts'
const isLiveView = window.location.pathname === '/view'
const isLiveView = isLiveViewPath(window.location.pathname)
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import {
BASE_PATH,
API_BASE_URL,
normalizeBasePath,
withBase,
resolveServerPath,
isLiveViewPath,
} from '@/utils/basePath'
const SUB = '/homelab/'
describe('normalizeBasePath', () => {
it('falls back to the root for an empty or missing value', () => {
expect(normalizeBasePath(undefined)).toBe('/')
expect(normalizeBasePath(null)).toBe('/')
expect(normalizeBasePath('')).toBe('/')
expect(normalizeBasePath(' ')).toBe('/')
expect(normalizeBasePath('/')).toBe('/')
})
it('adds the leading and trailing slash', () => {
expect(normalizeBasePath('homelab')).toBe('/homelab/')
expect(normalizeBasePath('/homelab')).toBe('/homelab/')
expect(normalizeBasePath('homelab/')).toBe('/homelab/')
expect(normalizeBasePath('/homelab/')).toBe('/homelab/')
})
it('keeps nested prefixes and collapses duplicate slashes', () => {
expect(normalizeBasePath('/apps/homelab')).toBe('/apps/homelab/')
expect(normalizeBasePath('//apps//homelab//')).toBe('/apps/homelab/')
})
})
describe('default (root) base', () => {
it('is the root, so every helper returns the pre-base-path string', () => {
expect(BASE_PATH).toBe('/')
expect(API_BASE_URL).toBe('/api/v1')
expect(withBase('view')).toBe('/view')
expect(withBase('brand/homelable.svg')).toBe('/brand/homelable.svg')
expect(isLiveViewPath('/view')).toBe(true)
expect(isLiveViewPath('/')).toBe(false)
expect(resolveServerPath('/api/v1/media/abc.png')).toBe('/api/v1/media/abc.png')
})
})
describe('withBase', () => {
it('joins onto the base with exactly one slash', () => {
expect(withBase('view', SUB)).toBe('/homelab/view')
expect(withBase('/view', SUB)).toBe('/homelab/view')
expect(withBase('//view', SUB)).toBe('/homelab/view')
expect(withBase('', SUB)).toBe('/homelab/')
})
it('keeps the query string intact', () => {
expect(withBase('view?key=abc&design=1', SUB)).toBe('/homelab/view?key=abc&design=1')
})
})
describe('resolveServerPath', () => {
it('prefixes root-absolute paths the backend returns', () => {
expect(resolveServerPath('/api/v1/media/abc.png', SUB)).toBe('/homelab/api/v1/media/abc.png')
expect(resolveServerPath('/api/v1/auth/oidc/login', SUB)).toBe('/homelab/api/v1/auth/oidc/login')
})
it('leaves an already-prefixed path alone', () => {
expect(resolveServerPath('/homelab/api/v1/media/abc.png', SUB)).toBe('/homelab/api/v1/media/abc.png')
expect(resolveServerPath('/homelab', SUB)).toBe('/homelab')
})
it('leaves anything that is not a root-absolute path alone', () => {
expect(resolveServerPath('data:image/png;base64,AAAA', SUB)).toBe('data:image/png;base64,AAAA')
expect(resolveServerPath('blob:http://x/y', SUB)).toBe('blob:http://x/y')
expect(resolveServerPath('https://cdn.example/x.svg', SUB)).toBe('https://cdn.example/x.svg')
expect(resolveServerPath('//cdn.example/x.svg', SUB)).toBe('//cdn.example/x.svg')
expect(resolveServerPath('relative/x.svg', SUB)).toBe('relative/x.svg')
expect(resolveServerPath('', SUB)).toBe('')
})
})
describe('isLiveViewPath', () => {
it('matches the live view under the base only', () => {
expect(isLiveViewPath('/homelab/view', SUB)).toBe(true)
expect(isLiveViewPath('/view', SUB)).toBe(false)
expect(isLiveViewPath('/homelab/', SUB)).toBe(false)
expect(isLiveViewPath('/homelab/viewer', SUB)).toBe(false)
})
})
describe('module wiring', () => {
afterEach(() => {
vi.unstubAllEnvs()
vi.resetModules()
})
it('derives BASE_PATH and API_BASE_URL from import.meta.env.BASE_URL', async () => {
vi.stubEnv('BASE_URL', '/homelab/')
vi.resetModules()
const mod = await import('@/utils/basePath')
expect(mod.BASE_PATH).toBe('/homelab/')
expect(mod.API_BASE_URL).toBe('/homelab/api/v1')
expect(mod.withBase('view')).toBe('/homelab/view')
expect(mod.isLiveViewPath('/homelab/view')).toBe(true)
expect(mod.resolveServerPath('/api/v1/media/a.png')).toBe('/homelab/api/v1/media/a.png')
})
})
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import {
BRAND_ICON_PREFIX,
isBrandIconKey,
@@ -34,6 +34,16 @@ describe('brand icon helpers', () => {
expect(brandIconUrl('homelable')).toBe('/brand/homelable.svg')
})
it('serves the local icon from under a configured base path', async () => {
vi.stubEnv('BASE_URL', '/homelab/')
vi.resetModules()
const { brandIconUrl: scoped } = await import('@/utils/nodeIcons')
expect(scoped('homelable')).toBe('/homelab/brand/homelable.svg')
expect(scoped('plex')).toBe('https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg')
vi.unstubAllEnvs()
vi.resetModules()
})
it('isLocalBrandIcon only matches the locally-served slugs', () => {
expect(LOCAL_BRAND_ICONS).toContain('homelable')
expect(isLocalBrandIcon('homelable')).toBe(true)
+52
View File
@@ -0,0 +1,52 @@
/**
* Base path — Homelable can be served under a subpath (`https://home.example/homelab/`)
* instead of the root of an origin.
*
* The single knob is the build-time `VITE_BASE_PATH` env var, which becomes Vite's
* `base` (see `vite.config.ts`). Vite derives `import.meta.env.BASE_URL` from it and
* rewrites the asset URLs it emits itself; everything the app builds by hand — API
* calls, the WebSocket URL, the live-view route, `public/` assets — goes through the
* helpers below.
*
* The default is `/`, where every helper returns exactly the string it returned before
* the base path existed. Only path bases are supported (not a full CDN URL).
*/
/** Normalize a raw base into a `/`-delimited path that always ends in `/`. */
export function normalizeBasePath(raw?: string | null): string {
const value = (raw ?? '').trim()
if (!value || value === '/') return '/'
return `/${value}/`.replace(/\/{2,}/g, '/')
}
/** The active base path. Always starts and ends with `/`; `/` when unset. */
export const BASE_PATH = normalizeBasePath(import.meta.env.BASE_URL)
/** Join a path onto the base: `withBase('view')` → `/homelab/view`. */
export function withBase(path: string, base: string = BASE_PATH): string {
return `${base}${path.replace(/^\/+/, '')}`
}
/** Base URL of the REST API — what the axios instances are created with. */
export const API_BASE_URL = withBase('api/v1')
/**
* Resolve a root-absolute path the *backend* handed us (an uploaded media URL, the
* OIDC login URL) against the base path. The backend has no idea where the SPA is
* mounted, so it always answers `/api/v1/...`.
*
* Anything else — a `data:` URL, an absolute `http(s)://` URL, a protocol-relative
* `//host/...` one, a relative path, or a path already carrying the prefix — is
* returned untouched.
*/
export function resolveServerPath(url: string, base: string = BASE_PATH): string {
if (!url || base === '/') return url
if (!url.startsWith('/') || url.startsWith('//')) return url
if (url === base.slice(0, -1) || url.startsWith(base)) return url
return `${base}${url.slice(1)}`
}
/** Is this pathname the read-only live view (`/view`, `/homelab/view`)? */
export function isLiveViewPath(pathname: string, base: string = BASE_PATH): boolean {
return pathname === withBase('view', base)
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { NodeType } from '@/types'
import { withBase } from '@/utils/basePath'
import {
// Infrastructure (node types)
Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle,
@@ -236,7 +237,7 @@ export function isLocalBrandIcon(slug: string): boolean {
}
export function brandIconUrl(slug: string): string {
if (isLocalBrandIcon(slug)) return `/brand/${slug}.svg`
if (isLocalBrandIcon(slug)) return withBase(`brand/${slug}.svg`)
return `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/${slug}.svg`
}
+15 -2
View File
@@ -6,7 +6,16 @@ import tailwindcss from '@tailwindcss/vite'
const appVersion = fs.readFileSync(path.resolve(__dirname, '../VERSION'), 'utf-8').trim()
// Serve Homelable under a subpath (`VITE_BASE_PATH=/homelab/`) instead of the root of
// an origin. Defaults to '/', where the built output is byte-for-byte what it was
// before this knob existed. Mirrors `normalizeBasePath` in src/utils/basePath.ts —
// duplicated rather than imported because that module reads `import.meta.env`, which
// does not exist while this config is being evaluated by Node.
const rawBase = (process.env.VITE_BASE_PATH ?? '').trim()
const base = !rawBase || rawBase === '/' ? '/' : `/${rawBase}/`.replace(/\/{2,}/g, '/')
export default defineConfig({
base,
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
@@ -27,15 +36,19 @@ export default defineConfig({
},
},
server: {
// Keyed off the base so `npm run dev` keeps working when one is set: the app then
// calls /<base>/api/v1, and the prefix is stripped before hitting uvicorn.
proxy: {
'/api': {
[`${base}api`]: {
target: 'http://localhost:8000',
changeOrigin: true,
ws: true,
rewrite: (p: string) => p.replace(base, '/'),
},
'/ws': {
[`${base}ws`]: {
target: 'ws://localhost:8000',
ws: true,
rewrite: (p: string) => p.replace(base, '/'),
},
},
},
+105 -3
View File
@@ -19,6 +19,9 @@
# BACKEND_PORT uvicorn listen port, loopback only (default: 8000)
# HTTP_PORT nginx listen port (default: 3000)
# SERVER_NAME nginx server_name (default: _)
# BASE_PATH serve under a subpath instead of the root of the origin,
# e.g. BASE_PATH=/homelab/ (default: /). Baked into the
# frontend build and into the generated nginx site.
# ADMIN_PASSWORD initial admin password (default: prompt, "admin" on empty)
# SCANNER_RANGES JSON array of CIDRs to scan (default: prompt, guessed from
# the primary interface)
@@ -37,6 +40,11 @@ BACKEND_PORT="${BACKEND_PORT:-8000}"
HTTP_PORT="${HTTP_PORT:-3000}"
SERVER_NAME="${SERVER_NAME:-_}"
SKIP_NGINX="${SKIP_NGINX:-0}"
# Normalized to a leading + trailing slash ('/' when unset), mirroring
# normalizeBasePath() in frontend/src/utils/basePath.ts.
BASE_PATH="$(printf '%s' "${BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g')"
[[ -n "$BASE_PATH" ]] || BASE_PATH="/"
BASE_PATH_NO_SLASH="${BASE_PATH%/}"
NODE_MAJOR=20
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
@@ -180,7 +188,7 @@ if [[ -f "$FRONTEND_DIR/package-lock.json" ]]; then
else
( cd "$FRONTEND_DIR" && npm install --silent )
fi
( cd "$FRONTEND_DIR" && npm run build )
( cd "$FRONTEND_DIR" && VITE_BASE_PATH="$BASE_PATH" npm run build )
[[ -d "$FRONTEND_DIR/dist" ]] || fail "Frontend build produced no $FRONTEND_DIR/dist."
chown -R "$SERVICE_USER":"$SERVICE_USER" "$INSTALL_DIR"
@@ -222,7 +230,8 @@ systemctl restart "$SERVICE_NAME"
if [[ "$SKIP_NGINX" != "1" ]]; then
SITE="/etc/nginx/sites-available/homelable"
log "Writing $SITE"
cat >"$SITE" <<EOF
if [[ "$BASE_PATH" == "/" ]]; then
cat >"$SITE" <<EOF
server {
listen $HTTP_PORT;
server_name $SERVER_NAME;
@@ -262,6 +271,94 @@ server {
}
}
EOF
else
# Under a prefix the SPA is served from a webroot where the build hangs off
# $BASE_PATH, so nginx keeps plain `root` semantics — `alias` plus `try_files`
# mis-resolves \$uri. The symlink is refreshed on every run.
WEBROOT="/var/www/homelable"
mkdir -p "$(dirname "${WEBROOT}${BASE_PATH_NO_SLASH}")"
ln -sfn "$FRONTEND_DIR/dist" "${WEBROOT}${BASE_PATH_NO_SLASH}"
chmod -R o+rX "$WEBROOT"
cat >"$SITE" <<EOF
server {
listen $HTTP_PORT;
server_name $SERVER_NAME;
root $WEBROOT;
index index.html;
# Relative Location headers — the port and scheme belong to whatever proxy is
# in front, not to this server block.
absolute_redirect off;
client_max_body_size 20M;
# --- served under $BASE_PATH ---------------------------------------------
location = $BASE_PATH_NO_SLASH {
return 301 $BASE_PATH;
}
# WebSocket (must come before the API block to take priority)
location ${BASE_PATH}api/v1/status/ws/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT/api/v1/status/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location ${BASE_PATH}api/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT/api/;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
# Legacy /ws/ path
location ${BASE_PATH}ws/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
}
# SPA fallback under the prefix
location $BASE_PATH {
try_files \$uri \$uri/ ${BASE_PATH}index.html;
}
# --- prefix already stripped by a front proxy ----------------------------
# No redirect to $BASE_PATH here: the front proxy would strip it again and loop.
location /api/v1/status/ws/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location /api/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location /ws/ {
proxy_pass http://127.0.0.1:$BACKEND_PORT;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
}
location / {
root $FRONTEND_DIR/dist;
try_files \$uri \$uri/ /index.html;
}
}
EOF
fi
ln -sf "$SITE" /etc/nginx/sites-enabled/homelable
if [[ "$HTTP_PORT" == "80" ]]; then rm -f /etc/nginx/sites-enabled/default; fi
nginx -t
@@ -297,9 +394,14 @@ Homelable installed.
EOF
if [[ "$SKIP_NGINX" != "1" ]]; then
cat <<EOF
Web UI: http://${HOST_IP:-<host-ip>}:${HTTP_PORT}
Web UI: http://${HOST_IP:-<host-ip>}:${HTTP_PORT}${BASE_PATH}
nginx site: /etc/nginx/sites-available/homelable
EOF
if [[ "$BASE_PATH" != "/" ]]; then
cat <<EOF
Base path: $BASE_PATH (webroot /var/www/homelable, symlinked to the build)
EOF
fi
else
cat <<EOF
nginx: skipped — proxy your own front end to 127.0.0.1:$BACKEND_PORT