From 04a0f21c8bbcf594a11121a6343905ba7d334ed9 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:26:12 +0000 Subject: [PATCH] feat(server): allow configuring the listen address Allow native and host-network deployments to select an IPv4 or IPv6 bind address through HOST while preserving the existing wildcard default. Keep Docker health checks aligned with the configured listener and document how to restrict host port publishing independently of the container address. Cover validation, real bootstrap sockets, and health-check commands with tests. Closes #1315 --- .env.example | 5 ++ Dockerfile | 8 ++- docs/DEVELOPMENT.md | 27 ++++---- server/.env.example | 3 + server/src/config/config.test.ts | 14 ++++ server/src/config/config.ts | 1 + server/src/config/docker-healthcheck.test.ts | 38 +++++++++++ server/src/config/env.validation.test.ts | 15 +++++ server/src/config/env.validation.ts | 6 ++ server/src/main.test.ts | 67 ++++++++++++++++++++ server/src/main.ts | 2 +- 11 files changed, 173 insertions(+), 13 deletions(-) create mode 100644 server/src/config/docker-healthcheck.test.ts create mode 100644 server/src/main.test.ts diff --git a/.env.example b/.env.example index 585ffcc23..359998ffb 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,13 @@ APP_IMAGE=ghcr.io/bookorbit/bookorbit:latest # App publish port on host +# For access only from this host (such as a host reverse proxy), use 127.0.0.1:3000. APP_PORT=3000 +# Optional listen address inside the container (IPv4 or IPv6, without brackets). +# Keep the default for bridge networking; use APP_PORT above to restrict host exposure. +# HOST=0.0.0.0 + # Host folder that appears as /books in the container. # Point this at your library root; use an absolute path on NAS/Portainer installs. BOOKS_HOST_PATH=./books diff --git a/Dockerfile b/Dockerfile index a82ca071c..20ee3917a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -80,7 +80,13 @@ RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh /app/bin/ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD wget -q -T 4 -O /dev/null "http://127.0.0.1:${PORT:-3000}/api/v1/health" + CMD host="$(printf '%s' "${HOST:-}" | tr -d '[:space:]')"; \ + case "$host" in \ + ''|0.0.0.0) host=127.0.0.1 ;; \ + ::) host='[::1]' ;; \ + *:*) host="[$host]" ;; \ + esac; \ + wget -q -T 4 -O /dev/null "http://${host}:${PORT:-3000}/api/v1/health" ENTRYPOINT ["/sbin/tini", "-s", "--"] CMD ["sh", "/app/entrypoint.sh"] diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f4058adec..ef5c9912a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -224,17 +224,22 @@ pnpm --filter @bookorbit/types dev These are the variables you are most likely to need during development. See `server/.env.example` for the full list with comments. -| Variable | Purpose | Default (dev) | -| --------------------- | ---------------------------------- | --------------------------------------------------------- | -| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` | -| `PORT` | Server listen port | `3000` | -| `NODE_ENV` | Runtime mode | `development` | -| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` | -| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` | -| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` | -| `APP_URL` | Base URL for email links | `http://localhost:5173` | -| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` | -| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` | +| Variable | Purpose | Default (dev) | +| --------------------- | ------------------------------------ | --------------------------------------------------------- | +| `DATABASE_URL` | PostgreSQL connection string | `postgres://bookorbit:bookorbit@localhost:5432/bookorbit` | +| `PORT` | Server listen port | `3000` | +| `HOST` | Server listen address (IPv4 or IPv6) | `0.0.0.0` | +| `NODE_ENV` | Runtime mode | `development` | +| `JWT_SECRET` | Signing key for auth tokens | `change-me-in-production` | +| `APP_DATA_PATH` | Storage for covers, avatars, cache | `../local/data` | +| `LIBRARY_BROWSE_ROOT` | Library folder picker root | `/` | +| `APP_URL` | Base URL for email links | `http://localhost:5173` | +| `SWAGGER_ENABLED` | Serve Swagger UI and OpenAPI JSON | `false` | +| `DISABLE_LOCAL_AUTH` | Disable password authentication | `false` | + +For a native server accessible only on this machine, set `HOST=127.0.0.1` in `server/.env`. `HOST` accepts an IPv4 or IPv6 address without a port or brackets, such as `::1` for IPv6 loopback. An unset or blank value keeps the existing `0.0.0.0` default. `PORT` continues to select the listen port. + +With Docker bridge networking, keep `HOST` unset and set `APP_PORT=127.0.0.1:3000` in the deployment `.env` to publish only on host loopback. `HOST` controls the container's listener, while `APP_PORT` controls host port publishing. Binding to loopback inside the container prevents normal port forwarding and access from other containers. A reverse proxy in another container can instead use the shared Docker network with the app's published port removed. With host networking, `HOST` controls the host's listener directly. --- diff --git a/server/.env.example b/server/.env.example index e75ef26b3..e53e98817 100644 --- a/server/.env.example +++ b/server/.env.example @@ -3,6 +3,9 @@ DATABASE_URL=postgres://bookorbit:bookorbit@localhost:5432/bookorbit # App PORT=3000 +# Listen address (IPv4 or IPv6, without brackets). Unset or blank keeps 0.0.0.0. +# Use 127.0.0.1 for local-only native deployments. +# HOST=0.0.0.0 NODE_ENV=development # API docs (optional) diff --git a/server/src/config/config.test.ts b/server/src/config/config.test.ts index c66335c88..5f38a3171 100644 --- a/server/src/config/config.test.ts +++ b/server/src/config/config.test.ts @@ -7,6 +7,7 @@ const ORIGINAL_ENV = process.env; function resetEnv(): void { process.env = { ...ORIGINAL_ENV }; delete process.env.NODE_ENV; + delete process.env.HOST; delete process.env.APP_URL; delete process.env.APP_VERSION; delete process.env.OIDC_ALLOW_LOCAL_ISSUERS; @@ -46,6 +47,7 @@ describe('config', () => { it('uses app defaults, including local-build fallback version', () => { expect(appConfig()).toEqual({ nodeEnv: 'development', + host: '0.0.0.0', appUrl: 'http://localhost:5173', version: 'Local build', githubReleasesRepo: 'bookorbit/bookorbit', @@ -59,6 +61,7 @@ describe('config', () => { it('reads app values from environment when provided', () => { process.env.NODE_ENV = 'production'; + process.env.HOST = '127.0.0.1'; process.env.APP_URL = 'https://bookorbit.local'; process.env.APP_VERSION = 'v2.3.4'; process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'true'; @@ -70,6 +73,7 @@ describe('config', () => { expect(appConfig()).toEqual({ nodeEnv: 'production', + host: '127.0.0.1', appUrl: 'https://bookorbit.local', version: 'v2.3.4', githubReleasesRepo: 'acme/app', @@ -81,6 +85,16 @@ describe('config', () => { }); }); + it.each(['', ' '])('preserves wildcard binding for blank HOST %j', (host) => { + process.env.HOST = host; + expect(appConfig().host).toBe('0.0.0.0'); + }); + + it.each(['127.0.0.1', '192.0.2.10', '::1', '::'])('reads and trims bind address %s', (host) => { + process.env.HOST = ` ${host} `; + expect(appConfig().host).toBe(host); + }); + it('falls back to false when OIDC_ALLOW_LOCAL_ISSUERS is invalid', () => { process.env.OIDC_ALLOW_LOCAL_ISSUERS = 'maybe'; expect(appConfig().oidcAllowLocalIssuers).toBe(false); diff --git a/server/src/config/config.ts b/server/src/config/config.ts index b24155b60..d446d722a 100644 --- a/server/src/config/config.ts +++ b/server/src/config/config.ts @@ -3,6 +3,7 @@ import { join, resolve } from 'path'; export const appConfig = registerAs('app', () => ({ nodeEnv: process.env.NODE_ENV ?? 'development', + host: process.env.HOST?.trim() || '0.0.0.0', appUrl: process.env.APP_URL ?? 'http://localhost:5173', version: process.env.APP_VERSION ?? 'Local build', githubReleasesRepo: process.env.GITHUB_RELEASES_REPO?.trim() || 'bookorbit/bookorbit', diff --git a/server/src/config/docker-healthcheck.test.ts b/server/src/config/docker-healthcheck.test.ts new file mode 100644 index 000000000..8ed9f4946 --- /dev/null +++ b/server/src/config/docker-healthcheck.test.ts @@ -0,0 +1,38 @@ +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const dockerfile = readFileSync(resolve(__dirname, '../../../Dockerfile'), 'utf8'); +const healthcheck = dockerfile.match(/HEALTHCHECK[^\n]*\\\n\s*CMD ([\s\S]*?)(?:\n\n|$)/)?.[1].replace(/\\\n/g, '\n'); + +describe('Docker healthcheck bind address', () => { + it.each([ + { host: undefined, port: undefined, expected: '127.0.0.1:3000' }, + { host: '', port: '', expected: '127.0.0.1:3000' }, + { host: ' ', port: '31015', expected: '127.0.0.1:31015' }, + { host: '0.0.0.0', port: '31015', expected: '127.0.0.1:31015' }, + { host: '127.0.0.1', port: '31015', expected: '127.0.0.1:31015' }, + { host: ' 192.0.2.10 ', port: '31015', expected: '192.0.2.10:31015' }, + { host: '::', port: '31015', expected: '[::1]:31015' }, + { host: '::1', port: '31015', expected: '[::1]:31015' }, + { host: '2001:db8::1', port: '31015', expected: '[2001:db8::1]:31015' }, + ])('probes $expected with HOST=$host and PORT=$port', ({ host, port, expected }) => { + expect(healthcheck).toBeDefined(); + const env = { ...process.env }; + delete env.HOST; + delete env.PORT; + if (host !== undefined) env.HOST = host; + if (port !== undefined) env.PORT = port; + + const args = execFileSync('sh', ['-c', `wget() { printf '%s\\n' "$@"; }\n${healthcheck}`], { env, encoding: 'utf8' }) + .trim() + .split('\n'); + + expect(args).toEqual(['-q', '-T', '4', '-O', '/dev/null', `http://${expected}/api/v1/health`]); + }); + + it('propagates a failed HTTP probe to Docker', () => { + expect(healthcheck).toBeDefined(); + expect(() => execFileSync('sh', ['-c', `wget() { return 1; }\n${healthcheck}`])).toThrow(); + }); +}); diff --git a/server/src/config/env.validation.test.ts b/server/src/config/env.validation.test.ts index ed2e4360a..d4c9c013d 100644 --- a/server/src/config/env.validation.test.ts +++ b/server/src/config/env.validation.test.ts @@ -6,6 +6,21 @@ const BASE_ENV = { }; describe('validateEnv', () => { + it('allows HOST to remain unset', () => { + expect(validateEnv(BASE_ENV).HOST).toBeUndefined(); + }); + + it.each(['', ' ', '0.0.0.0', '127.0.0.1', '192.0.2.10', '::', '::1', '2001:db8::1', ' 127.0.0.1 '])('accepts bind address %j', (HOST) => { + expect(validateEnv({ ...BASE_ENV, HOST }).HOST).toBe(HOST.trim()); + }); + + it.each(['localhost', 'https://127.0.0.1', '127.0.0.1:3000', '[::1]', '256.0.0.1', '127.0.0.1/8', '127. 0.0.1'])( + 'rejects invalid bind address %j', + (HOST) => { + expect(() => validateEnv({ ...BASE_ENV, HOST })).toThrow('HOST must be an IPv4 or IPv6 address without a port or brackets'); + }, + ); + it('accepts common postgres URL formats used by existing setups', () => { const urls = [ 'postgres://bookorbit:bookorbit@localhost:5432/bookorbit', diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts index f1bbe27e4..c12d3054e 100644 --- a/server/src/config/env.validation.ts +++ b/server/src/config/env.validation.ts @@ -1,5 +1,6 @@ import { parseIntoClientConfig } from 'pg-connection-string'; import { isAbsolute } from 'node:path'; +import { isIP } from 'node:net'; import { z } from 'zod'; const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']; @@ -45,6 +46,11 @@ function trustProxyEnv() { const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + HOST: z + .string() + .trim() + .refine((value) => value === '' || isIP(value) !== 0, 'HOST must be an IPv4 or IPv6 address without a port or brackets') + .optional(), DATABASE_URL: z.string().refine(isValidPostgresConnectionString, 'DATABASE_URL must be a valid PostgreSQL connection string').optional(), JWT_SECRET: z .string() diff --git a/server/src/main.test.ts b/server/src/main.test.ts new file mode 100644 index 000000000..9b5839c4a --- /dev/null +++ b/server/src/main.test.ts @@ -0,0 +1,67 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import type { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify'; +import type { AddressInfo } from 'node:net'; + +vi.mock('./app.module', () => ({ AppModule: class {} })); + +describe('server bind address', () => { + let app: NestFastifyApplication | undefined; + let moduleFixture: TestingModule; + + beforeEach(() => { + vi.resetModules(); + vi.stubEnv('NODE_ENV', 'test'); + vi.stubEnv('PORT', '0'); + vi.stubEnv('JWT_SECRET', 'bind-address-test-secret'); + vi.stubEnv('SWAGGER_ENABLED', 'false'); + }); + + afterEach(async () => { + await app?.close(); + if (!app) await moduleFixture?.close(); + app = undefined; + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it.each([ + { host: undefined, expected: '0.0.0.0' }, + { host: '', expected: '0.0.0.0' }, + { host: '127.0.0.1', expected: '127.0.0.1' }, + { host: ' 127.0.0.1 ', expected: '127.0.0.1' }, + { host: '::1', expected: '::1' }, + ])('starts the real bootstrap with HOST=$host on $expected', async ({ host, expected }) => { + vi.stubEnv('HOST', host); + const { NestFactory } = await import('@nestjs/core'); + const { ConfigModule } = await import('@nestjs/config'); + const { Logger } = await import('nestjs-pino'); + const { appConfig } = await import('./config/config'); + const { validateEnv } = await import('./config/env.validation'); + + moduleFixture = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ ignoreEnvFile: true, validate: validateEnv, load: [appConfig] })], + providers: [{ provide: Logger, useValue: { log: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn(), verbose: vi.fn() } }], + }).compile(); + const create = vi.spyOn(NestFactory, 'create').mockImplementation((_module, adapter) => { + const fastifyAdapter = adapter as FastifyAdapter; + fastifyAdapter.getInstance().get('/bind-probe', () => ({ ok: true })); + app = moduleFixture.createNestApplication(fastifyAdapter, { logger: false }); + vi.spyOn(app, 'listen'); + return Promise.resolve(app); + }); + + await import('./main'); + await vi.waitFor(() => { + expect(create).toHaveBeenCalledOnce(); + expect(app?.getHttpServer().listening).toBe(true); + }); + + const address = app!.getHttpServer().address() as AddressInfo; + expect(address.address).toBe(expected); + expect(app!.listen).toHaveBeenCalledWith('0', expected); + const requestHost = expected === '::1' ? '[::1]' : '127.0.0.1'; + const response = await fetch(`http://${requestHost}:${address.port}/bind-probe`, { signal: AbortSignal.timeout(3000) }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true }); + }); +}); diff --git a/server/src/main.ts b/server/src/main.ts index 65186ff59..20a660f5d 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -121,7 +121,7 @@ async function bootstrap() { } app.enableShutdownHooks(); - await app.listen(process.env.PORT ?? 3000, '0.0.0.0'); + await app.listen(process.env.PORT ?? 3000, appConfiguration.host); } bootstrap().catch((err: unknown) => {