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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
etc

# Logs
logs
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Unreleased

### [0.6.0] - 2026-07-30

### [0.5.0] - 2026-07-27

- doc(README): updated installed instructions
Expand Down Expand Up @@ -33,3 +35,5 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
[0.2.0]: https://github.com/NicTool/server/releases/tag/v0.2.0
[0.0.2]: https://github.com/NicTool/server/releases/tag/v0.0.2
[0.2.2]: https://github.com/NicTool/server/releases/tag/v0.2.2
[0.5.0]: https://github.com/NicTool/server/releases/tag/v0.5.0
[0.6.0]: https://github.com/NicTool/server/releases/tag/v0.6.0
2 changes: 1 addition & 1 deletion CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This handcrafted artisanal software is brought to you by:

| <img height="80" src="https://avatars.githubusercontent.com/u/261635?v=4"><br><a href="https://github.com/msimerson">msimerson</a> (<a href="https://github.com/NicTool/server/commits?author=msimerson">14</a>) |
| <img height="80" src="https://avatars.githubusercontent.com/u/261635?v=4"><br><a href="https://github.com/msimerson">msimerson</a> (<a href="https://github.com/NicTool/server/commits?author=msimerson">22</a>) |
| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: |

<sub>this file is generated by [.release](https://github.com/msimerson/.release).
Expand Down
42 changes: 29 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ NicTool is an open-source DNS management system. This package provides the **ser

## Nameserver supervisor (lib/nameservers.js)

- Reads nameserver records from the data store, starts/stops each DNS engine.
Each record carries its own runtime config (engine, listen, publisher,
transport, dnssec) alongside the legacy 2.x fields.
- Supports a native in-memory authoritative server plus export engines (bind, knot, nsd, powerdns, tinydns, maradns), wiring up the right Source (json / toml / mysql), Publisher (memory / rfc1035 / tinydns-cdb / powerdns-db), Transport (noop / rsync / axfr / db-replication), and DNSSEC Signer per engine.
- Reads nameserver records from the data store, starts/stops each one.
Each record carries its own runtime config (type, listen, publisher,
transport, dnssec) alongside the legacy 2.x fields. `type` is the
nt_nameserver_export_type name — which software this is, stored once.
- Supports a native in-memory authoritative server plus the exporting types (bind, knot, nsd, powerdns, coredns, djbdns, maradns), wiring up the right Source (json / toml / mysql), Publisher (memory / rfc1035 / tinydns-cdb / powerdns-db / coredns-redis), Transport (noop / rsync / axfr / db-replication / pull), and DNSSEC Signer per type.

Publisher options, set under `[nameserver.publisher]`:

Expand All @@ -37,11 +38,9 @@ Publisher options, set under `[nameserver.publisher]`:
| `maradns` | `path` — directory for `<zone>.csv2` files. MaraDNS reads csv2, not RFC 1035, so this is its own format. `config: {}` also writes a `mararc` declaring the zones, with `chroot_dir` pointing at `path`; `config.bindAddress` and `config.globals` set the rest. **`terminator: ''` for MaraDNS 1.2**, whose csv2 has no record terminator and rejects the `~` that 2.x uses |
| `powerdns-db` | `dsn` (or `host`/`port`/`user`/`password`/`database`) for a PowerDNS **gmysql** backend; `domainType` (default `NATIVE`). This is the push model — the alternative is `nt-powerdns`, the pipe backend below, which leaves the data in NicTool. Use one or the other. |

Publishers default by engine: `native` → memory, `tinydns` → tinydns-cdb,
Publishers default by nameserver type: `native` → memory, `djbdns` → tinydns-cdb,
`maradns` → maradns, everything else → rfc1035.

Not yet implemented, and they throw if selected: the DNSSEC signers, `axfr` transport.

## Data model / config

Config is layered so each file holds only what is needed to reach the next layer:
Expand Down Expand Up @@ -209,8 +208,8 @@ Supports A, AAAA, CNAME, MX, NS, TXT, PTR, SRV, CAA, SOA over UDP and TCP.

```toml
[[nameserver]]
name = "ns1.example.com."
engine = "native"
name = "ns1.example.com."
type = "native"

# Bind UDP and TCP on all interfaces, port 53.
[[nameserver.listen]]
Expand Down Expand Up @@ -238,18 +237,35 @@ cooldown = 5 # minimum seconds between consecutive publishes (burst protec
Multiple native nameservers can run side-by-side by adding more `[[nameserver]]`
blocks — each gets its own UDP/TCP listener set.

### DNSSEC (native engine — future)
### DNSSEC (native engine)

Attach an optional signing stage between the publisher and transport:
Attach a signing stage between the publisher and transport. The native engine
has no external signing tool, so `MemorySigner` signs the in-process zone map
directly and `NativeNS` answers with RRSIG, DNSKEY and NSEC when the query
carries the DO bit.

```toml
[nameserver.dnssec]
enabled = false # set true once MemorySigner is implemented
enabled = true
algorithm = "ECDSAP256SHA256"
keyset = "/var/lib/nictool/dnssec/ns1"
nsec3 = true
nsec3 = false # NSEC only; the in-process signer has no NSEC3
```

`keyset` is a directory of BIND-format key files — the same
`K<zone>.+<alg>+<tag>.{key,private}` pair `dnssec-keygen` writes, so a zone can
move between this signer and the file signer without re-keying:

```sh
dnssec-keygen -a ECDSAP256SHA256 -K /var/lib/nictool/dnssec/ns1 -n ZONE example.com
dnssec-keygen -a ECDSAP256SHA256 -K /var/lib/nictool/dnssec/ns1 -f KSK -n ZONE example.com
```

NicTool signs with those keys but never generates or rolls them: rollover is an
operational decision, not something a publish cycle should make. Elliptic-curve
algorithms only (`ECDSAP256SHA256`, `ECDSAP384SHA384`, `ED25519`, `ED448`) —
RSA keys are reported as unusable rather than silently skipped.

---

### PowerDNS co-process backend
Expand Down
124 changes: 100 additions & 24 deletions bin/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import path from 'node:path'
import process from 'node:process'
import { parseArgs, promisify } from 'node:util'

import { AxfrServer, FileSource, MysqlSource } from '@nictool/dns-nameserver'

import { startServer } from '../index.js'
import ApiProcess from '../lib/api-process.js'
import NameserverSupervisor from '../lib/nameservers.js'
Expand All @@ -18,11 +20,7 @@ import {
readBootstrap,
writeBootstrap,
} from '../lib/config.js'
import {
migrateNameservers,
resolveNameserverConfig,
saveToStore,
} from '../lib/nameserver-config.js'
import { migrateNameservers, resolveNameserverConfig } from '../lib/nameserver-config.js'

const execFileAsync = promisify(execFile)

Expand Down Expand Up @@ -106,13 +104,19 @@ const port =

const supervisor = new NameserverSupervisor()
let apiProcess = null
let axfrServer = null

async function shutdown() {
try {
await supervisor.stop()
} catch {
/* ignore */
}
try {
await axfrServer?.stop()
} catch {
/* ignore */
}
try {
await apiProcess?.stop()
} catch {
Expand All @@ -125,7 +129,7 @@ process.once('SIGINT', shutdown)

if (nicConfig?.configured === true) {
console.log('Already configured — starting services.')
const { apiServer, apiRemoteUrl } = await startAPI(nicConfig)
const { apiServer, apiRemoteUrl, pid } = await startAPI(nicConfig)
await startServer({
configDir,
tls,
Expand All @@ -134,7 +138,10 @@ if (nicConfig?.configured === true) {
nicConfig,
apiServer,
apiRemoteUrl,
apiPid: pid,
supervisor,
startApi: startAPI,
stopApi: stopAPI,
})
await startNameservers(nicConfig)
} else {
Expand All @@ -156,21 +163,16 @@ if (nicConfig?.configured === true) {
nicConfig,
supervisor,
suggestedPorts: { api: suggestedApiPort },
startApi: startAPI,
stopApi: stopAPI,
onSaved: async (config, ctx) => {
if (!ctx.apiServer && !ctx.apiRemoteUrl) {
const started = await startAPI(config)
ctx.apiServer = started.apiServer
ctx.apiRemoteUrl = started.apiRemoteUrl
ctx.apiMode = normalizeApiMode(config.api?.mode)
ctx.apiPid = started.pid ?? null
}
// The configurator collects nameservers, but they belong in the store,
// which only exists once the API is up.
if (ctx.pendingNameservers?.length) {
await saveToStore(ctx.pendingNameservers).catch((err) =>
console.error(`Could not save nameservers: ${err.message}`),
)
ctx.pendingNameservers = null
}

try {
await ctx.supervisor?.stop()
} catch {
Expand Down Expand Up @@ -276,15 +278,71 @@ async function startNameservers(config) {
const migrated = await migrateNameservers(configDir, config, writeBootstrap)
const nsConfig = await resolveNameserverConfig(configDir, migrated)
await supervisor.start(nsConfig)
await startAxfrServer(config, nsConfig)
} catch (err) {
console.error(`Supervisor start failed: ${err.message}`)
}
}

/**
* Answer zone transfers, when `axfr` is configured, so a secondary can pull
* from NicTool rather than from a primary NicTool feeds.
*
* It is not a nameserver and does not go through the supervisor: it publishes
* nothing and reads the Source directly, so it serves whatever is current
* rather than whatever was last published. Authorization comes from the same
* nameserver records the supervisor uses — a peer is matched by address, and
* gets exactly the zones assigned to it.
*/
async function startAxfrServer(config, nsConfig) {
const axfr = config?.axfr
if (!axfr?.listen?.length) return

try {
const source = buildAxfrSource(nsConfig.store)
axfrServer = new AxfrServer({
listen: axfr.listen,
source,
nameservers: nsConfig.nameserver ?? [],
maxMessageSize: axfr.maxMessageSize,
})
axfrServer.on('error', (err) => console.error(`AXFR: ${err.message}`))
axfrServer.on('refused', (d) =>
console.warn(`AXFR refused ${d.zone} to ${d.peer}: ${d.reason}`),
)
await axfrServer.start()
const where = axfrServer
.addresses()
.map((a) => `${a.address}:${a.port}`)
.join(', ')
console.log(`AXFR listener: ${where} (${axfrServer.status().authorized} peers)`)
} catch (err) {
console.error(`AXFR listener failed to start: ${err.message}`)
axfrServer = null
}
}

function buildAxfrSource(store) {
switch (store?.type) {
case 'mysql':
return new MysqlSource(store)
case 'json':
return new FileSource({ path: store.path, format: 'json' })
case 'toml':
case 'directory':
return new FileSource({ path: store.path, format: 'toml' })
default:
throw new Error(`unsupported store type for AXFR: ${store?.type ?? '(missing)'}`)
}
}

/**
* Bring up the API in whichever mode the bootstrap config selects.
*
* @returns {Promise<{apiServer: import('@hapi/hapi').Server|null, apiRemoteUrl: string|null}>}
* Failures are reported rather than thrown: at boot the configurator must stay
* reachable so the operator can correct the config that broke the start.
*
* @returns {Promise<{apiServer: import('@hapi/hapi').Server|null, apiRemoteUrl: string|null, error?: string}>}
*/
async function startAPI(config) {
if (!config?.api) return { apiServer: null, apiRemoteUrl: null }
Expand All @@ -299,21 +357,39 @@ async function startAPI(config) {
if (mode === 'tcp') {
const port = config.api.port
if (!port) {
console.error('api.mode is "tcp" but no api.port is set')
return { apiServer: null, apiRemoteUrl: null }
const error = 'api.mode is "tcp" but no api.port is set'
console.error(error)
return { apiServer: null, apiRemoteUrl: null, error }
}
apiProcess = new ApiProcess({ configDir, port })
apiProcess.on('error', (err) => console.error(`API process error: ${err.message}`))
try {
await apiProcess.start()
return { apiServer: null, apiRemoteUrl: apiProcess.url }
return {
apiServer: null,
apiRemoteUrl: apiProcess.url,
pid: apiProcess.child?.pid ?? null,
}
} catch (err) {
console.error(`API process failed to start: ${err.message}`)
return { apiServer: null, apiRemoteUrl: null }
apiProcess = null
return { apiServer: null, apiRemoteUrl: null, error: err.message }
}
}

return { apiServer: await initInProcessAPI(), apiRemoteUrl: null }
return initInProcessAPI()
}

/**
* Shut down whichever local API startAPI brought up. A remote API is not ours
* to stop, so only the caller's reference to it is dropped.
*/
async function stopAPI(ctx) {
if (apiProcess) {
await apiProcess.stop()
apiProcess = null
}
await ctx.apiServer?.stop()
}

/**
Expand All @@ -329,12 +405,12 @@ function applyApiEnv() {
async function initInProcessAPI() {
try {
const { init: initAPI } = await import('@nictool/api/routes/index.js')
const hapiServer = await initAPI()
const apiServer = await initAPI()
console.log('API initialized in-process')
return hapiServer
return { apiServer, apiRemoteUrl: null, pid: process.pid }
} catch (err) {
console.error(`API init failed: ${err.message}`)
return null
return { apiServer: null, apiRemoteUrl: null, error: err.message }
}
}

Expand Down
Loading
Loading