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) => {