From 4bf7c32f155021f2aa05d9d22f896c8f7f4f5e3e Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:31:21 +0100 Subject: [PATCH] docker: run the server in compose --- README.md | 19 ++++++++++ bin/start.js | 54 ++++++++++++++++++-------- docker/Dockerfile | 13 +++++++ docker/entrypoint.sh | 10 +++++ docker/write-config.js | 42 +++++++++++++++++++++ index.js | 25 ++++++++----- lib/config.js | 6 ++- lib/config.test.js | 24 ++++++++++++ test/docker-config.test.js | 77 ++++++++++++++++++++++++++++++++++++++ test/http-server.test.js | 49 ++++++++++++++++++++++++ 10 files changed, 293 insertions(+), 26 deletions(-) create mode 100644 docker/Dockerfile create mode 100755 docker/entrypoint.sh create mode 100644 docker/write-config.js create mode 100644 test/docker-config.test.js create mode 100644 test/http-server.test.js diff --git a/README.md b/README.md index 6fdcc86..48a0bd6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,25 @@ tables in a database that already has them. > **TLS warning** – The auto-generated certificate is self-signed. Accept the browser security warning for the initial setup, then replace it with a trusted certificate (see [TLS](#tls) below). +### docker + +`docker/Dockerfile` starts the server with `/data` as its config directory. On +the first start, the entrypoint writes `etc/nictool.json` for a remote API from +these environment variables: + +| Variable | Default | +| -------------------- | -------- | +| `NICTOOL_API_HOST` | `api` | +| `NICTOOL_API_PORT` | `3000` | +| `NICTOOL_API_SCHEME` | `http` | +| `NICTOOL_CONFIG_DIR` | `/data` | +| `NICTOOL_HTTP_PORT` | `8080` | +| `NICTOOL_BIND_HOST` | hostname | + +Set `NICTOOL_TLS=false` to serve plain HTTP. Otherwise the normal certificate +discovery and generation apply. An existing `etc/nictool.json` is never +replaced by the entrypoint. + --- ## Configuration diff --git a/bin/start.js b/bin/start.js index f90238a..93a9162 100755 --- a/bin/start.js +++ b/bin/start.js @@ -67,22 +67,29 @@ try { // TLS – discover existing certs or generate a self-signed one // --------------------------------------------------------------------------- +const useTLS = process.env.NICTOOL_TLS?.toLowerCase() !== 'false' const osHostname = os.hostname() const tlsDir = path.join(configDir, 'etc', 'tls') -const discovered = await discoverTLS(tlsDir, osHostname) -let tls, host - -if (discovered) { - const { hostname: certHost, ...pemMaterial } = discovered - tls = pemMaterial - host = certHost +let tls = null +let host = osHostname + +if (useTLS) { + const discovered = await discoverTLS(tlsDir, osHostname) + if (discovered) { + const { hostname: certHost, ...pemMaterial } = discovered + tls = pemMaterial + host = certHost + } else { + console.log(`Generating self-signed cert for ${osHostname}`) + tls = await generateTLS(tlsDir, osHostname) + } } else { - console.log(`Generating self-signed cert for ${osHostname}`) - tls = await generateTLS(tlsDir, osHostname) - host = osHostname + console.log('TLS disabled by NICTOOL_TLS=false') } +const bindHost = process.env.NICTOOL_BIND_HOST || host + // --------------------------------------------------------------------------- // NicTool bootstrap config (nictool.json) // --------------------------------------------------------------------------- @@ -90,13 +97,18 @@ if (discovered) { const nicConfig = await readBootstrap(configDir) // --------------------------------------------------------------------------- -// Port selection – prefer 443, fall back to 8443 +// Port selection – prefer 443/8443 for HTTPS and 8080 for HTTP // --------------------------------------------------------------------------- -const port = - (await resolvePort(host, 443)) ?? - (await resolvePort(host, 8443)) ?? - (await randomAvailablePort(host)) +let port +if (useTLS) { + port = + (await resolvePort(bindHost, 443)) ?? + (await resolvePort(bindHost, 8443)) ?? + (await randomAvailablePort(bindHost)) +} else { + port = parsePort(process.env.NICTOOL_HTTP_PORT, 8080) +} // --------------------------------------------------------------------------- // If already configured, skip the configurator and go straight to services @@ -134,6 +146,7 @@ if (nicConfig?.configured === true) { configDir, tls, host, + bindHost, port, nicConfig, apiServer, @@ -159,6 +172,7 @@ if (nicConfig?.configured === true) { configDir, tls, host, + bindHost, port, nicConfig, supervisor, @@ -442,3 +456,13 @@ function resolvePort(bindHost, preferred) { probe.listen(preferred, bindHost) }) } + +function parsePort(value, fallback) { + if (value === undefined || value === '') return fallback + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.error(`Invalid NICTOOL_HTTP_PORT: ${value}`) + process.exit(1) + } + return port +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..d4a6934 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-trixie-slim + +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends bind9-utils openssl \ + && rm -rf /var/lib/apt/lists/* +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build && chmod +x docker/entrypoint.sh + +EXPOSE 8080 8443 +ENTRYPOINT ["./docker/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..36070d9 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu + +config_dir="${NICTOOL_CONFIG_DIR:-/data}" +export NICTOOL_CONFIG_DIR="$config_dir" + +node docker/write-config.js +npm run build + +exec node bin/start.js -c "$config_dir" diff --git a/docker/write-config.js b/docker/write-config.js new file mode 100644 index 0000000..0237ee2 --- /dev/null +++ b/docker/write-config.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises' +import path from 'node:path' + +import { bootstrapPath, toJson } from '../lib/config.js' + +const configDir = path.resolve(process.env.NICTOOL_CONFIG_DIR ?? '/data') +const configFile = bootstrapPath(configDir) +const port = parsePort(process.env.NICTOOL_API_PORT ?? '3000') +const scheme = process.env.NICTOOL_API_SCHEME ?? 'http' + +if (!['http', 'https'].includes(scheme)) { + throw new Error(`NICTOOL_API_SCHEME must be http or https, got ${scheme}`) +} + +const config = { + configured: true, + api: { + mode: 'remote', + scheme, + host: process.env.NICTOOL_API_HOST ?? 'api', + port, + }, +} + +await fs.mkdir(path.dirname(configFile), { recursive: true }) +try { + await fs.writeFile(configFile, toJson(config), { flag: 'wx' }) + console.log(`Generated ${configFile}`) +} catch (err) { + if (err.code !== 'EEXIST') throw err + console.log(`Using existing ${configFile}`) +} + +function parsePort(value) { + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + throw new Error(`NICTOOL_API_PORT must be an integer from 1 to 65535, got ${value}`) + } + return parsed +} diff --git a/index.js b/index.js index ad34fa9..389af32 100644 --- a/index.js +++ b/index.js @@ -38,12 +38,13 @@ const MIME = { } /** - * Start the NicTool bootstrap configurator over HTTPS. + * Start the NicTool server over HTTP or HTTPS. * * @param {object} opts * @param {string} opts.configDir Absolute path to the NicTool data root. - * @param {{ cert: string, key: string }} opts.tls PEM-encoded TLS material. - * @param {string} opts.host Hostname the server is bound to. + * @param {{ cert: string, key: string }|null} opts.tls PEM-encoded TLS material. + * @param {string} opts.host Hostname shown in the server URL. + * @param {string} [opts.bindHost] Address to bind; defaults to opts.host. * @param {number} opts.port Port to listen on (443 or 8443). * @param {object} [opts.nicConfig] Parsed nictool.toml contents, or null. * @param {object} [opts.apiServer] Initialized (but not listening) Hapi server for in-process API. @@ -52,12 +53,13 @@ const MIME = { * response has flushed. May set ctx.apiServer. * @param {Function} [opts.startApi] Called with (config); resolves { apiServer, apiRemoteUrl, error }. * @param {Function} [opts.stopApi] Called with (ctx) to shut down a locally started API. - * @returns {Promise} + * @returns {Promise} */ export async function startServer({ configDir, tls, host, + bindHost = host, port, nicConfig = null, apiServer = null, @@ -90,16 +92,20 @@ export async function startServer({ ctx.storeConfig = (await readApiConfig(configDir).catch(() => null))?.store ?? null } - const server = https.createServer({ cert: tls.cert, key: tls.key }, (req, res) => - handleRequest(req, res, ctx), - ) + const handler = (req, res) => handleRequest(req, res, ctx) + const useTLS = Boolean(tls?.cert && tls?.key) + const server = useTLS + ? https.createServer({ cert: tls.cert, key: tls.key }, handler) + : http.createServer(handler) await new Promise((resolve, reject) => { server.once('error', reject) - server.listen(port, host, resolve) + server.listen(port, bindHost, resolve) }) - const url = `https://${host}${port === 443 ? '' : `:${port}`}` + const scheme = useTLS ? 'https' : 'http' + const defaultPort = useTLS ? 443 : 80 + const url = `${scheme}://${host}${port === defaultPort ? '' : `:${port}`}` console.log(`Configurator: ${url}`) return server @@ -532,6 +538,7 @@ function validateConfig(config) { then: Joi.number().port().required(), otherwise: Joi.number().port().optional(), }), + scheme: Joi.string().valid('http', 'https').optional(), }).required(), store: Joi.object({ type: Joi.string().valid('json', 'toml', 'directory', 'mysql').required(), diff --git a/lib/config.js b/lib/config.js index 6c2c545..99abccc 100644 --- a/lib/config.js +++ b/lib/config.js @@ -156,8 +156,10 @@ export function buildRemoteApiConfig(store) { */ export function buildRemoteUrl(config) { if (normalizeApiMode(config?.api?.mode) !== 'remote') return null - const { host, port } = config.api ?? {} + const { host, port, scheme: configuredScheme } = config.api ?? {} if (!host || !port) return null - const scheme = /^(localhost|127\.|::1)/.test(host) ? 'http' : 'https' + const scheme = + configuredScheme ?? (/^(localhost|127\.|::1)/.test(host) ? 'http' : 'https') + if (!['http', 'https'].includes(scheme)) return null return `${scheme}://${host}:${port}` } diff --git a/lib/config.test.js b/lib/config.test.js index d70bc7e..11a80ec 100644 --- a/lib/config.test.js +++ b/lib/config.test.js @@ -230,6 +230,30 @@ engine = "native" ) }) + it(`honours an explicit scheme for an internal remote API`, () => { + assert.equal( + buildRemoteUrl({ + api: { mode: 'remote', scheme: 'http', host: 'api', port: 3000 }, + }), + 'http://api:3000', + ) + assert.equal( + buildRemoteUrl({ + api: { mode: 'remote', scheme: 'https', host: 'api', port: 3000 }, + }), + 'https://api:3000', + ) + }) + + it(`rejects an unknown remote API scheme`, () => { + assert.equal( + buildRemoteUrl({ + api: { mode: 'remote', scheme: 'file', host: 'api', port: 3000 }, + }), + null, + ) + }) + it(`is null when the remote is underspecified`, () => { assert.equal( buildRemoteUrl({ api: { mode: 'remote', host: 'api.example.com' } }), diff --git a/test/docker-config.test.js b/test/docker-config.test.js new file mode 100644 index 0000000..79645b8 --- /dev/null +++ b/test/docker-config.test.js @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { after, beforeEach, describe, it } from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const script = path.join(root, 'docker', 'write-config.js') +const tmpDirs = [] +let dir + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-docker-config-')) + tmpDirs.push(dir) +}) + +after(() => { + for (const tmp of tmpDirs) fs.rmSync(tmp, { recursive: true, force: true }) +}) + +describe('docker bootstrap config', () => { + it('writes a configured remote HTTP API', () => { + const result = runWriter({ + NICTOOL_API_HOST: 'api-internal', + NICTOOL_API_PORT: '3010', + }) + + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(readConfig(), { + configured: true, + api: { + mode: 'remote', + scheme: 'http', + host: 'api-internal', + port: 3010, + }, + }) + }) + + it('does not replace an existing operator config', () => { + const file = path.join(dir, 'etc', 'nictool.json') + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, '{"configured":false}\n') + + const result = runWriter({ NICTOOL_API_HOST: 'other-api' }) + + assert.equal(result.status, 0, result.stderr) + assert.deepEqual(readConfig(), { configured: false }) + }) + + it('rejects invalid ports and schemes', () => { + let result = runWriter({ NICTOOL_API_PORT: 'not-a-port' }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /NICTOOL_API_PORT/) + + result = runWriter({ NICTOOL_API_SCHEME: 'file' }) + assert.notEqual(result.status, 0) + assert.match(result.stderr, /NICTOOL_API_SCHEME/) + }) +}) + +function runWriter(extraEnv) { + return spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { + ...process.env, + NICTOOL_CONFIG_DIR: dir, + ...extraEnv, + }, + }) +} + +function readConfig() { + return JSON.parse(fs.readFileSync(path.join(dir, 'etc', 'nictool.json'), 'utf8')) +} diff --git a/test/http-server.test.js b/test/http-server.test.js new file mode 100644 index 0000000..ff6add3 --- /dev/null +++ b/test/http-server.test.js @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import http from 'node:http' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { after, before, test } from 'node:test' + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'nt-http-server-')) +let api +let server +let base + +before(async () => { + api = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ path: req.url })) + }) + await new Promise((resolve) => api.listen(0, '127.0.0.1', resolve)) + + const { startServer } = await import(new URL('../index.js', import.meta.url)) + server = await startServer({ + configDir: tmp, + tls: null, + host: 'localhost', + bindHost: '127.0.0.1', + port: 0, + nicConfig: { configured: true, api: { mode: 'remote' } }, + apiRemoteUrl: `http://127.0.0.1:${api.address().port}`, + }) + base = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + if (server) await new Promise((resolve) => server.close(resolve)) + if (api) await new Promise((resolve) => api.close(resolve)) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('serves plain HTTP on the requested bind address', async () => { + assert.equal(server.address().address, '127.0.0.1') + assert.equal((await fetch(`${base}/nt/service`)).status, 200) +}) + +test('proxies to a remote HTTP API', async () => { + const response = await fetch(`${base}/api/documentation`) + + assert.equal(response.status, 200) + assert.deepEqual(await response.json(), { path: '/documentation' }) +})