mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<NestFastifyApplication>(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 });
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user