Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 39 additions & 15 deletions bin/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,36 +67,48 @@ 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)
// ---------------------------------------------------------------------------

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
Expand Down Expand Up @@ -134,6 +146,7 @@ if (nicConfig?.configured === true) {
configDir,
tls,
host,
bindHost,
port,
nicConfig,
apiServer,
Expand All @@ -159,6 +172,7 @@ if (nicConfig?.configured === true) {
configDir,
tls,
host,
bindHost,
port,
nicConfig,
supervisor,
Expand Down Expand Up @@ -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
}
13 changes: 13 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
10 changes: 10 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -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"
42 changes: 42 additions & 0 deletions docker/write-config.js
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 16 additions & 9 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,13 @@
}

/**
* 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.
Expand All @@ -52,12 +53,13 @@
* 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<https.Server>}
* @returns {Promise<http.Server|https.Server>}
*/
export async function startServer({
configDir,
tls,
host,
bindHost = host,
port,
nicConfig = null,
apiServer = null,
Expand Down Expand Up @@ -90,16 +92,20 @@
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
Expand Down Expand Up @@ -486,7 +492,7 @@
)
}

const { startApi: _startApi, _hostname: _h, _suggested: _s, ...submitted } = body

Check warning on line 495 in index.js

View workflow job for this annotation

GitHub Actions / lint / lint

'_s' is assigned a value but never used

Check warning on line 495 in index.js

View workflow job for this annotation

GitHub Actions / lint / lint

'_h' is assigned a value but never used

Check warning on line 495 in index.js

View workflow job for this annotation

GitHub Actions / lint / lint

'_startApi' is assigned a value but never used

const invalid = validateConfig(submitted)
if (invalid) {
Expand Down Expand Up @@ -532,6 +538,7 @@
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(),
Expand Down
6 changes: 4 additions & 2 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
24 changes: 24 additions & 0 deletions lib/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }),
Expand Down
77 changes: 77 additions & 0 deletions test/docker-config.test.js
Original file line number Diff line number Diff line change
@@ -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'))
}
Loading
Loading