diff --git a/README.md b/README.md index 939726c..c6aefa2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Live web UI for `events.log` and `openhab.log` built with Node.js, Express, and | Variable | Default | Description | | --- | --- | --- | | `PORT` | `9001` | HTTP port used by the application | +| `BIND_ADDRESS` | `0.0.0.0` | Network interface address the server listens on. Set to `127.0.0.1` to restrict access to the local host, e.g. when a reverse proxy runs on the same machine. An unbindable address (typo, address not present on this host) makes startup fail loudly instead of binding somewhere unexpected. | | `OPENHAB_LOG_DIR` | `/var/log/openhab` | Fallback directory for log files | | `EVENTS_LOG_PATH` | `/var/log/openhab/events.log` | Full path to `events.log` | | `OPENHAB_LOG_PATH` | `/var/log/openhab/openhab.log` | Full path to `openhab.log` | diff --git a/deploy/systemd/openhab-log-viewer.service b/deploy/systemd/openhab-log-viewer.service index 63ea339..40e56712 100644 --- a/deploy/systemd/openhab-log-viewer.service +++ b/deploy/systemd/openhab-log-viewer.service @@ -20,6 +20,8 @@ Environment=CLIENT_MAX_RENDERED_LINES=500 # Uncomment to reject requests whose Host header is not on the allowlist # (DNS-rebinding mitigation). Comma-separated, no port; use bracketed IPv6: # Environment=ALLOWED_HOSTS=openhab-pi,192.168.1.10 +# Uncomment to restrict the listener, e.g. when a reverse proxy runs on this host: +# Environment=BIND_ADDRESS=127.0.0.1 EnvironmentFile=-/etc/default/openhab-log-viewer ExecStart=/usr/bin/node dist/server/index.cjs Restart=always diff --git a/src/server/config.test.ts b/src/server/config.test.ts index e130f01..31ceabd 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -7,6 +7,7 @@ import { loadConfig } from './config.js'; const CONFIG_ENV_KEYS = [ 'PORT', + 'BIND_ADDRESS', 'INITIAL_LINES_PER_FILE', 'MAX_BUFFERED_LINES', 'CLIENT_MAX_RENDERED_LINES', @@ -45,6 +46,7 @@ describe('loadConfig defaults', () => { it('uses documented defaults when no env vars are set', () => { const config = loadConfig(); assert.equal(config.port, 9001); + assert.equal(config.bindAddress, '0.0.0.0'); assert.equal(config.initialLinesPerFile, 500); assert.equal(config.maxBufferedLines, 2000); assert.equal(config.clientMaxRenderedLines, 500); @@ -143,6 +145,22 @@ describe('loadConfig health details parsing', () => { }); }); +describe('loadConfig bind address parsing', () => { + it('defaults to 0.0.0.0 when unset', () => { + assert.equal(loadConfig().bindAddress, '0.0.0.0'); + }); + + it('uses the configured address', () => { + process.env.BIND_ADDRESS = '127.0.0.1'; + assert.equal(loadConfig().bindAddress, '127.0.0.1'); + }); + + it('treats a whitespace-only value as unset', () => { + process.env.BIND_ADDRESS = ' '; + assert.equal(loadConfig().bindAddress, '0.0.0.0'); + }); +}); + describe('loadConfig allowed hosts parsing', () => { it('defaults to an empty list', () => { assert.deepEqual(loadConfig().allowedHosts, []); diff --git a/src/server/config.ts b/src/server/config.ts index 7bbb19f..42da54d 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -117,6 +117,10 @@ export function loadConfig(): AppConfig { return { port: clampInteger('PORT', 9001, 1, 65_535), + // Default '0.0.0.0' preserves current behavior (bind all interfaces). No + // further validation: an unbindable address fails loudly at listen() time + // (see the server's 'error' handler), so any string is passed through. + bindAddress: process.env.BIND_ADDRESS?.trim() || '0.0.0.0', initialLinesPerFile: clampInteger('INITIAL_LINES_PER_FILE', 500, 1, 100_000), maxBufferedLines: clampInteger('MAX_BUFFERED_LINES', 2_000, 100, 1_000_000), // The browser hard-caps rendering at CLIENT_MAX_RENDERED_LINES_CAP (500) for diff --git a/src/server/index.ts b/src/server/index.ts index 5a2dafb..1f2e981 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -79,8 +79,13 @@ async function main(): Promise { app.use(htmlLimiter); app.use(createSpaFallback(clientDistDir)); - const server = app.listen(config.port, () => { - console.log(`OpenHab Log Viewer listening on port ${config.port}`); + const server = app.listen(config.port, config.bindAddress, () => { + console.log(`OpenHab Log Viewer listening on ${config.bindAddress}:${config.port}`); + }); + + server.on('error', (error) => { + console.error('[startup] Failed to bind server:', error); + process.exit(1); }); const shutdown = createShutdown({ tailers, sseHub, server }); diff --git a/src/server/routes.test.ts b/src/server/routes.test.ts index a4dd140..1d4e5cd 100644 --- a/src/server/routes.test.ts +++ b/src/server/routes.test.ts @@ -13,6 +13,7 @@ const NO_HEARTBEAT = 1_000_000_000; function baseConfig(overrides: Partial = {}): AppConfig { return { port: 9001, + bindAddress: '0.0.0.0', initialLinesPerFile: 500, maxBufferedLines: 2000, clientMaxRenderedLines: 500, diff --git a/src/server/types.ts b/src/server/types.ts index 857ff5f..5bb3fc4 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -12,6 +12,7 @@ export interface SourceConfig { export interface AppConfig { port: number; + bindAddress: string; initialLinesPerFile: number; maxBufferedLines: number; clientMaxRenderedLines: number;