pending-dns 1.2.5 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/codeql/codeql-config.yml +11 -0
- package/.github/workflows/codeql.yml +52 -0
- package/.github/workflows/deploy.yml +16 -3
- package/.github/workflows/release.yaml +43 -0
- package/.github/workflows/test.yml +75 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +109 -0
- package/README.md +111 -9
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/bin/pending-dns.js +1 -1
- package/config/default.toml +43 -0
- package/config/test.toml +35 -0
- package/eslint.config.js +38 -0
- package/lib/api-server.js +198 -23
- package/lib/cached-resolver.js +5 -3
- package/lib/certs.js +12 -20
- package/lib/dns-handler.js +362 -32
- package/lib/dns-server.js +120 -43
- package/lib/dns-tcp-server.js +1 -1
- package/lib/dns-udp-server.js +1 -1
- package/lib/dnssec-wire.js +321 -0
- package/lib/dnssec.js +461 -0
- package/lib/lock.js +37 -0
- package/lib/logger.js +3 -0
- package/lib/public-server.js +20 -2
- package/lib/sentry.js +72 -0
- package/lib/tools.js +1 -1
- package/lib/zone-store.js +90 -7
- package/package.json +46 -33
- package/release-please-config.json +14 -0
- package/server.js +5 -24
- package/systemd/pending-dns.service +4 -4
- package/test/api.test.js +231 -0
- package/test/cached-resolver.test.js +57 -0
- package/test/certs.test.js +34 -0
- package/test/dns-handler.test.js +171 -0
- package/test/dns-server.test.js +162 -0
- package/test/dnssec-handler.test.js +550 -0
- package/test/dnssec-wire.test.js +163 -0
- package/test/dnssec.test.js +213 -0
- package/test/helpers.js +27 -0
- package/test/sentry.test.js +21 -0
- package/test/tools.test.js +48 -0
- package/test/zone-store.test.js +245 -0
- package/workers/api.js +3 -1
- package/workers/dns.js +2 -24
- package/workers/health.js +3 -26
- package/workers/public.js +3 -25
- package/.eslintrc +0 -14
- package/Gruntfile.js +0 -16
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
name: "PendingDNS CodeQL config"
|
|
2
|
+
|
|
3
|
+
# Exclude code that is not part of the production runtime from analysis.
|
|
4
|
+
# These paths generate only false-positive noise:
|
|
5
|
+
# - the test suite intentionally disables TLS verification and resolves
|
|
6
|
+
# throwaway domains
|
|
7
|
+
# - the example scripts are developer helpers, not shipped code
|
|
8
|
+
paths-ignore:
|
|
9
|
+
- test
|
|
10
|
+
- '**/test/**'
|
|
11
|
+
- example
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: "CodeQL Advanced"
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ "master" ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ "master" ]
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: '40 17 * * 6'
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
analyze:
|
|
13
|
+
name: Analyze (${{ matrix.language }})
|
|
14
|
+
# Skip release-please's auto-generated release PR: it only changes CHANGELOG/version,
|
|
15
|
+
# so there is no new code to scan. (Matches the guard in test.yml.)
|
|
16
|
+
if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }}
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
permissions:
|
|
19
|
+
# required for all workflows
|
|
20
|
+
security-events: write
|
|
21
|
+
|
|
22
|
+
# required to fetch internal or private CodeQL packs
|
|
23
|
+
packages: read
|
|
24
|
+
|
|
25
|
+
# only required for workflows in private repositories
|
|
26
|
+
actions: read
|
|
27
|
+
contents: read
|
|
28
|
+
|
|
29
|
+
strategy:
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
include:
|
|
33
|
+
- language: actions
|
|
34
|
+
build-mode: none
|
|
35
|
+
- language: javascript-typescript
|
|
36
|
+
build-mode: none
|
|
37
|
+
|
|
38
|
+
steps:
|
|
39
|
+
- name: Checkout repository
|
|
40
|
+
uses: actions/checkout@v4
|
|
41
|
+
|
|
42
|
+
- name: Initialize CodeQL
|
|
43
|
+
uses: github/codeql-action/init@v4
|
|
44
|
+
with:
|
|
45
|
+
languages: ${{ matrix.language }}
|
|
46
|
+
build-mode: ${{ matrix.build-mode }}
|
|
47
|
+
config-file: ./.github/codeql/codeql-config.yml
|
|
48
|
+
|
|
49
|
+
- name: Perform CodeQL Analysis
|
|
50
|
+
uses: github/codeql-action/analyze@v4
|
|
51
|
+
with:
|
|
52
|
+
category: "/language:${{matrix.language}}"
|
|
@@ -5,14 +5,27 @@ on:
|
|
|
5
5
|
|
|
6
6
|
name: Deploy instance
|
|
7
7
|
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
8
15
|
jobs:
|
|
9
16
|
deploy:
|
|
10
17
|
name: Deploy
|
|
11
|
-
runs-on: ubuntu-
|
|
18
|
+
runs-on: ubuntu-24.04
|
|
19
|
+
timeout-minutes: 15
|
|
12
20
|
|
|
13
21
|
steps:
|
|
14
22
|
- name: Checkout
|
|
15
|
-
uses: actions/checkout@
|
|
23
|
+
uses: actions/checkout@v6
|
|
24
|
+
|
|
25
|
+
- name: Use Node.js 24
|
|
26
|
+
uses: actions/setup-node@v6
|
|
27
|
+
with:
|
|
28
|
+
node-version: 24
|
|
16
29
|
|
|
17
30
|
- name: Install SSH key
|
|
18
31
|
uses: shimataro/ssh-key-action@v2
|
|
@@ -29,7 +42,7 @@ jobs:
|
|
|
29
42
|
id: deploy
|
|
30
43
|
run: |
|
|
31
44
|
echo $GITHUB_SHA > commit.txt
|
|
32
|
-
npm install --
|
|
45
|
+
npm install --omit=dev
|
|
33
46
|
tar czf /tmp/${SERVICE_NAME}.tar.gz --exclude .git .
|
|
34
47
|
scp /tmp/${SERVICE_NAME}.tar.gz deploy@${TARGET_HOST_01}:
|
|
35
48
|
scp /tmp/${SERVICE_NAME}.tar.gz deploy@${TARGET_HOST_02}:
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
on:
|
|
2
|
+
push:
|
|
3
|
+
branches:
|
|
4
|
+
- master
|
|
5
|
+
|
|
6
|
+
permissions:
|
|
7
|
+
contents: write
|
|
8
|
+
pull-requests: write
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
name: Manage Release
|
|
12
|
+
jobs:
|
|
13
|
+
release_please:
|
|
14
|
+
name: Create or prepare release
|
|
15
|
+
runs-on: ubuntu-24.04
|
|
16
|
+
outputs:
|
|
17
|
+
major: ${{ steps.release.outputs.major }}
|
|
18
|
+
minor: ${{ steps.release.outputs.minor }}
|
|
19
|
+
patch: ${{ steps.release.outputs.patch }}
|
|
20
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
21
|
+
steps:
|
|
22
|
+
# Uses release-please-config.json and .release-please-manifest.json
|
|
23
|
+
# from the repository root (manifest mode).
|
|
24
|
+
- uses: googleapis/release-please-action@v4
|
|
25
|
+
id: release
|
|
26
|
+
|
|
27
|
+
publish:
|
|
28
|
+
name: Publish NPM package
|
|
29
|
+
runs-on: ubuntu-24.04
|
|
30
|
+
needs: release_please
|
|
31
|
+
if: ${{ needs.release_please.outputs.release_created }}
|
|
32
|
+
permissions:
|
|
33
|
+
contents: read
|
|
34
|
+
# Required for npm provenance / OIDC trusted publishing.
|
|
35
|
+
id-token: write
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v6
|
|
38
|
+
- uses: actions/setup-node@v6
|
|
39
|
+
with:
|
|
40
|
+
node-version: 24
|
|
41
|
+
registry-url: "https://registry.npmjs.org"
|
|
42
|
+
- run: npm ci
|
|
43
|
+
- run: npm publish --provenance --access public
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
name: Run Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
12
|
+
cancel-in-progress: true
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
# Skip CI for release-please's auto-generated release PR / branch: it only bumps the
|
|
16
|
+
# version + CHANGELOG (no code change), so re-running the suite is wasted work.
|
|
17
|
+
license_check:
|
|
18
|
+
name: License Compliance Check
|
|
19
|
+
if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }}
|
|
20
|
+
runs-on: ubuntu-24.04
|
|
21
|
+
timeout-minutes: 10
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v6
|
|
24
|
+
- name: Use Node.js 24
|
|
25
|
+
uses: actions/setup-node@v6
|
|
26
|
+
with:
|
|
27
|
+
node-version: 24
|
|
28
|
+
cache: npm
|
|
29
|
+
- run: npm install
|
|
30
|
+
- name: Run License Checks
|
|
31
|
+
run: npm run licenses
|
|
32
|
+
|
|
33
|
+
lint:
|
|
34
|
+
name: Lint
|
|
35
|
+
if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }}
|
|
36
|
+
runs-on: ubuntu-24.04
|
|
37
|
+
timeout-minutes: 10
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v6
|
|
40
|
+
- name: Use Node.js 24
|
|
41
|
+
uses: actions/setup-node@v6
|
|
42
|
+
with:
|
|
43
|
+
node-version: 24
|
|
44
|
+
cache: npm
|
|
45
|
+
- run: npm install
|
|
46
|
+
- name: Run ESLint
|
|
47
|
+
run: npm run lint
|
|
48
|
+
|
|
49
|
+
unit:
|
|
50
|
+
name: Unit Tests
|
|
51
|
+
if: ${{ !startsWith(github.ref_name, 'release-please--') && !startsWith(github.head_ref, 'release-please--') }}
|
|
52
|
+
runs-on: ubuntu-24.04
|
|
53
|
+
timeout-minutes: 10
|
|
54
|
+
services:
|
|
55
|
+
redis:
|
|
56
|
+
image: redis
|
|
57
|
+
options: >-
|
|
58
|
+
--health-cmd "redis-cli ping"
|
|
59
|
+
--health-interval 10s
|
|
60
|
+
--health-timeout 5s
|
|
61
|
+
--health-retries 5
|
|
62
|
+
ports:
|
|
63
|
+
- 6379:6379
|
|
64
|
+
steps:
|
|
65
|
+
- uses: actions/checkout@v6
|
|
66
|
+
- name: Use Node.js 24
|
|
67
|
+
uses: actions/setup-node@v6
|
|
68
|
+
with:
|
|
69
|
+
node-version: 24
|
|
70
|
+
cache: npm
|
|
71
|
+
- run: npm install
|
|
72
|
+
- name: Run tests
|
|
73
|
+
run: npm test
|
|
74
|
+
env:
|
|
75
|
+
NODE_ENV: test
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.4.0](https://github.com/postalsys/pending-dns/compare/v1.3.0...v1.4.0) (2026-06-20)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* add DNSSEC online signing, TLSA records, and EDNS UDP truncation ([414beb7](https://github.com/postalsys/pending-dns/commit/414beb7114d56f35e2d02e945abfb4125ee51404))
|
|
9
|
+
* replace Bugsnag with self-hosted Sentry error reporting ([3b374c7](https://github.com/postalsys/pending-dns/commit/3b374c737ff0a22509f79976bee18ab9d425f8bd))
|
|
10
|
+
|
|
11
|
+
## [1.3.0](https://github.com/postalsys/pending-dns/compare/pending-dns-v1.2.5...pending-dns-v1.3.0) (2026-06-18)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Features
|
|
15
|
+
|
|
16
|
+
* replace Bugsnag with self-hosted Sentry error reporting ([3b374c7](https://github.com/postalsys/pending-dns/commit/3b374c737ff0a22509f79976bee18ab9d425f8bd))
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
PendingDNS is a lightweight, API-driven **authoritative** DNS server (UDP + TCP), bundled with a REST API for managing zone records, a public HTTP/HTTPS redirect/proxy server, automatic Let's Encrypt certificate generation, and periodic health checks. All state lives in Redis.
|
|
6
|
+
|
|
7
|
+
## Commands
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm start # run the whole app (node server.js)
|
|
11
|
+
npm test # run the test suite (requires a local Redis on 127.0.0.1:6379)
|
|
12
|
+
npm run lint # ESLint 9 (flat config: eslint.config.js)
|
|
13
|
+
npm run licenses # regenerate licenses.txt (license-report)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Running the app or tests requires a reachable Redis and a config with a valid `acme.email` (the bootstrap in `server.js` exits otherwise). For local runs, either set it in `config/development.toml` or pass `--acme.email=you@example.com`.
|
|
17
|
+
|
|
18
|
+
### Tests
|
|
19
|
+
|
|
20
|
+
- Built on the Node.js built-in test runner (`node:test`). `npm test` runs `NODE_ENV=test node --test --test-force-exit --test-concurrency=1 test/*.test.js`.
|
|
21
|
+
- `NODE_ENV=test` loads `config/test.toml`, which points Redis at a **dedicated database (db 15)**. `test/helpers.js#flushTestDb` wipes db 15 between cases and **refuses to run unless the Redis URL ends in `/15`** — so tests never touch dev (db 2) or prod data. Always run tests with `NODE_ENV=test`.
|
|
22
|
+
- `--test-concurrency=1` is required because all test files share db 15. `--test-force-exit` is required because requiring `lib/db` opens persistent ioredis connections; every test file must also call `closeDb()` in an `after()` hook or the process hangs.
|
|
23
|
+
- Some `cached-resolver`/`dns-server` tests do real DNS lookups (e.g. `one.one.one.one`); they are written to skip or tolerate missing network.
|
|
24
|
+
|
|
25
|
+
Run a single file or filter by test name:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
NODE_ENV=test node --test --test-force-exit test/zone-store.test.js
|
|
29
|
+
NODE_ENV=test node --test --test-force-exit --test-name-pattern="wildcard" test/*.test.js
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Building standalone executables
|
|
33
|
+
|
|
34
|
+
Executables are built with `@yao-pkg/pkg` (the maintained `pkg` fork) via the `pkg` block in `package.json`, targeting node24. The org-wide `../emailengine-api/upload.sh <repo>` script installs `@yao-pkg/pkg` globally, then runs `npm run build-source` followed by `npm run build-dist` (or `build-dist-fast` for an unsigned test build) and signs/notarizes/uploads the artifacts. Output binaries are named `pending-dns-<target>` under `ee-dist/`.
|
|
35
|
+
|
|
36
|
+
Because workers are loaded through **dynamic `require()` / `worker_threads`** (see below), `pkg.scripts` must list `lib/**/*.js` and `workers/**/*.js` or the worker code is missing from the snapshot. Runtime files read via `__dirname` (`lib/lua/health.lua`, `config/*.pem`) must be in `pkg.assets`. Do **not** add `pkg.options` like `max_old_space_size`: the workers forward `argv`, and an injected V8 flag leaks in as a bogus module path and crashes every worker.
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
### Process model (the most important thing to understand)
|
|
41
|
+
|
|
42
|
+
`server.js` is the supervisor. For each enabled subsystem (`api`, `dns`, `public`, `health`) it spawns a **worker thread** running `workers/<type>.js`, and restarts it on exit. Each `workers/<type>.js` is itself a small bootstrap that:
|
|
43
|
+
|
|
44
|
+
1. installs crash handlers + optional Sentry error reporting via `lib/sentry.js#initSentry` (only when `SENTRY_DSN` / `[sentry] dsn` is set; uses Sentry's uncaught-exception / unhandled-rejection integrations so crashes are reported, then `closeProcess`'s `if (!logger.errorReportingEnabled)` lets Sentry flush+exit),
|
|
45
|
+
2. uses Node `cluster` to fork `config[type].workers` processes (or runs in-thread when `workers === 1` and no user/group drop is configured),
|
|
46
|
+
3. dynamically `require`s the implementation: ``require(`../lib/${type}-server.js`)`` (or `-worker.js` for `health`) and calls it,
|
|
47
|
+
4. drops privileges via `config.process.user`/`group` after ports are bound.
|
|
48
|
+
|
|
49
|
+
So `lib/<type>-server.js` are entry points reached **only through dynamic requires** — static analysis (and `pkg`) won't find them. The implementations are: `lib/api-server.js`, `lib/dns-server.js`, `lib/public-server.js`, `lib/health-worker.js`.
|
|
50
|
+
|
|
51
|
+
### Configuration (`wild-config`)
|
|
52
|
+
|
|
53
|
+
Config is loaded by `wild-config` from `config/default.toml` merged with `config/<NODE_ENV>.toml`, then overridden by CLI args (`--dns.port=53`) and `appconf_*` env vars. `NODE_CONFIG_PATH` points to an external file in production (see `systemd/pending-dns.service`). Values are coerced to the type of the default (a string env var for a numeric default becomes a number). Config is read from `process.cwd()/config`, **not** from `__dirname` — relevant for packaged binaries, which need a `config/` directory next to them.
|
|
54
|
+
|
|
55
|
+
### Redis data model (`lib/zone-store.js`)
|
|
56
|
+
|
|
57
|
+
This is the heart of the system and is non-obvious:
|
|
58
|
+
|
|
59
|
+
- **Domain names are stored label-reversed**: `www.example.com` → `com.example.www` (`domainToName`/`nameToDomain`). This lets `resolveZone` walk *up* from a name to its registered zone by progressively dropping the most-specific label. A "zone" is any name with a `d:<name>:z` set; the shortest possible zone is the 2-label boundary (e.g. `example.com`).
|
|
60
|
+
- Keys: `d:<name>:z` is a **set of record keys** belonging to that zone; `d:<name>:r:<TYPE>` is a **hash** of `hid → JSON.stringify(valueArray)`. Each record's value is a positional array whose meaning depends on type (e.g. A = `[address, healthCheckUri]`, MX = `[exchange, priority]`, CAA = `[value, tag, flags]`, TLSA = `[usage, selector, matchingType, certificate]`, URL = `[url, code, proxy]`).
|
|
61
|
+
- A record's public **ID is `base64url(name \x01 TYPE \x01 hid)`** (`getFullId`/`parseFullId`); `hid` is a `nanoid()`. IDs are opaque and stable only while domain+type are unchanged (an `update` that changes either deletes and re-adds, producing a new ID).
|
|
62
|
+
- **Wildcards** are single-label: a record stored under subdomain `*.foo` matches `anything.foo.<zone>` only (`resolve` retries with the last label replaced by `*`).
|
|
63
|
+
- **Read/write split**: reads go to `db.redisRead`, writes to `db.redisWrite` (configurable as separate master/replica URLs). The health-check Lua script `lib/lua/health.lua` is registered as a custom command `nextHealth` on the write client.
|
|
64
|
+
|
|
65
|
+
### DNS request handling (`lib/dns-handler.js` + custom servers)
|
|
66
|
+
|
|
67
|
+
`lib/dns-udp-server.js` and `lib/dns-tcp-server.js` are hand-rolled servers that parse/serialize with `dns2`'s `Packet` (the project does **not** use dns2's built-in server). `lib/dns-server.js` wires them to `dnsHandler` and returns the bound server handles.
|
|
68
|
+
|
|
69
|
+
`processQuestion` does more than a lookup: for an A/AAAA query it also pulls `CNAME`, `ANAME`, and `URL` records; it follows CNAME chains recursively (depth-limited); A/AAAA results are health-filtered and shuffled; MX is priority-sorted. When nothing is stored it synthesizes fallbacks — `NS` from `config.ns`, a `SOA` from `config.soa`, default Let's Encrypt `CAA` records, and `version.bind`-style CHAOS TXT answers.
|
|
70
|
+
|
|
71
|
+
Two pseudo-record types: **ANAME** (apex alias) is resolved to real A/AAAA at query time via `lib/cached-resolver.js` (a Redis-cached wrapper over Node's `dns.Resolver`, with soft/hard TTLs for both hits and errors). **URL** records answer A/AAAA with the redirect server IPs from `config.public.hosts`; the actual redirect/proxy happens in `lib/public-server.js`.
|
|
72
|
+
|
|
73
|
+
### DNSSEC online signing (`lib/dnssec.js`, `lib/dnssec-wire.js`)
|
|
74
|
+
|
|
75
|
+
DNSSEC is **online (query-time) signing**, gated on `[dnssec] enabled` AND a per-zone key AND the client's EDNS **DO** bit. `lib/dnssec-wire.js` is a pure, dependency-free (only `crypto` + `ipaddr.js`) wire/crypto layer: canonical RDATA/name encoding (RFC 4034 6.2), the `ALGS` table for algorithms 8/13/15, key-tag + DS-digest computation, NSEC bitmaps, and RRSIG encoding. **Invariant:** signatures are computed over the canonical uncompressed lowercased wire form produced here, never over dns2's serialization (validators decompress + downcase before verifying, so the two agree).
|
|
76
|
+
|
|
77
|
+
`lib/dnssec.js` owns per-zone keys and signing orchestration. Keys live in Redis: `d:dnssec:<revname>` is the state hash (`enabled`, `algorithm`, `activeKeyTag`) and `d:dnssec:<revname>:keys` is a hash of `hid → JSON` (private key PEM + public material). A zone has one **CSK** per algorithm; an algorithm rollover keeps both and signs with every algorithm (RFC 6840 5.11) until `removeKey`. `getSigner` assembles + caches the signer per process (short `signerCacheTtl`, since API and DNS run as separate workers); `enableZone`/`removeKey`/`disableZone` mutate under a shared Redis lock (`lib/lock.js`) and invalidate the cache.
|
|
78
|
+
|
|
79
|
+
The query-time path is `lib/dns-handler.js#signResponse` (DO queries only): it serves+self-signs DNSKEY at the apex, signs each in-zone RRset (`signSection`, but never a below-apex delegation NS — RFC 4035 2.2), and proves denial-of-existence as **NODATA (NOERROR) "black lies"** — a signed SOA + a compact NSEC at the queried name (`bitmapTypeNums`/`nsecRecord`), never NXDOMAIN or NSEC3, because the server synthesizes CAA/SOA for any name. The NSEC bitmap lists only **answerable** types and must exclude the queried-absent type (e.g. a `URL` record only advertises the `config.public.hosts` families) or a validator SERVFAILs. The API (`lib/api-server.js`) exposes `GET/POST/DELETE /v1/zone/{zone}/dnssec` and `DELETE .../dnssec/key/{keyTag}`.
|
|
80
|
+
|
|
81
|
+
**TLSA** records are stored as `[usage, selector, matchingType, certificate]` (even-length hex, guarded in the API Joi schema, the store's `add`/`update`, and `encodeTLSARdata`). dns2 has no TLSA/RRSIG/NSEC/DS encoder, so those are emitted as `{ type:<num>, data:<Buffer> }` and routed through dns2's raw-RDATA (RFC 3597) fallback; `dns-handler.js` merges `wire.EXTRA_TYPES` into its type maps to recognize them.
|
|
82
|
+
|
|
83
|
+
**EDNS / truncation** (`lib/dns-server.js`): `parseEdns` reads the OPT (DO bit + advertised payload size); `finalizeResponse` replaces the additional section with our own OPT and, for UDP, truncates with TC=1 above the smaller of the requestor's advertised size and our configured `udpPayloadSize` (anti-fragmentation), so the client retries over TCP.
|
|
84
|
+
|
|
85
|
+
### Certificates & the public server
|
|
86
|
+
|
|
87
|
+
`lib/certs.js` issues Let's Encrypt certs via the **dns-01** challenge, using the zone store itself as the ACME DNS provider (it writes/reads the `_acme-challenge` TXT records). Concurrent issuance is guarded with an `ioredfour` Redis lock (the shared `lib/lock.js`, also used by DNSSEC key management; callers namespace their own lock keys); results and a per-domain renewal lock are cached in Redis. `lib/public-server.js` uses an SNI callback to load the right cert per hostname (falling back to a bundled self-signed cert in `config/`), then serves URL-record redirects or reverse-proxies (`proxy=true`), with TLS session tickets stored in Redis.
|
|
88
|
+
|
|
89
|
+
### Testability seams
|
|
90
|
+
|
|
91
|
+
Production code exposes hooks used only by tests: `lib/api-server.js` exports `createServer()` (build the Hapi server without `start()`, for `server.inject()`); `lib/dns-server.js`'s `init()` awaits binding and returns `{ udpServer, tcpServer }`, and also attaches `.testables` (`parseEdns`, `finalizeResponse`); `lib/dns-handler.js` (`signResponse`, `bitmapTypeNums`, `processQuestion`, ...), `lib/certs.js`, and `lib/dnssec.js` attach a `.testables` object. `lib/dnssec-wire.js` is pure and is tested directly.
|
|
92
|
+
|
|
93
|
+
## CI / release
|
|
94
|
+
|
|
95
|
+
GitHub Actions mirror the `postalsys/emailengine` conventions and run on Node 24: `test.yml` (license check + lint + tests with a Redis service), `codeql.yml`, and `release.yaml` (release-please in manifest mode — see `release-please-config.json` + `.release-please-manifest.json`; publishes to npm via OIDC trusted publishing, which must be configured registry-side). `deploy.yml` tarballs the repo and ships it to the two name servers. Security policy lives in `SECURITY.md` + a GPG-signed `SECURITY.txt`.
|
|
96
|
+
|
|
97
|
+
## Commit conventions
|
|
98
|
+
|
|
99
|
+
- Use **Conventional Commits** (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `ci:`, etc.). release-please derives the version bump and `CHANGELOG.md` from these prefixes, so `feat:`/`fix:` commits landing on `master` are what open a release PR.
|
|
100
|
+
- **Do not** add Claude (or any AI assistant) as a co-author / co-contributor in commit messages.
|
|
101
|
+
- For commits that do not change runtime behaviour (docs, comments, CI/workflow tweaks, formatting), append `[skip ci]` to the commit message to avoid triggering the workflows. **Exception:** never add `[skip ci]` to a `feat:`/`fix:` commit — those must run so the release workflow fires.
|
|
102
|
+
- Run `npm run lint` and `npm test` before committing (the test run needs a local Redis; see the Tests section).
|
|
103
|
+
- After pushing, check the workflow runs (`gh run list --branch <branch>`) and report their status. If a run fails for an unrelated infrastructure reason (auth errors, HTTP 403, "account suspended"), check <https://www.githubstatus.com/> for an active incident before assuming the change is at fault.
|
|
104
|
+
|
|
105
|
+
## Code style
|
|
106
|
+
|
|
107
|
+
- No emojis in code or documentation — printable ASCII only.
|
|
108
|
+
- Use a single hyphen-minus (`-`) as a dash in user-facing strings and docs; never em dashes, en dashes, or double hyphens (`--`).
|
|
109
|
+
- Never swallow errors at the global `uncaughtException` / `unhandledRejection` handlers to keep a worker alive — those handlers are the last line of defence and the worker is expected to exit. Fix the error at its source (add the missing `try/catch`, `.catch()`, or `error` listener at the call site) instead.
|
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@ Lightweight API driven Authoritative DNS server.
|
|
|
6
6
|
|
|
7
7
|
- All records can be edited over **REST API**
|
|
8
8
|
- All **changes are effective immediatelly** (or as long as it takes for Redis - eg. the backend for storing data - to distribute changes from master to replica instances)
|
|
9
|
-
- **Basic record types** (A, AAAA, CNAME, TXT, MX, CAA)
|
|
9
|
+
- **Basic record types** (A, AAAA, CNAME, TXT, MX, CAA, NS, TLSA)
|
|
10
10
|
- **ANAME pseudo-record** for apex domains
|
|
11
11
|
- **URL pseudo-record** for HTTP and HTTPS redirects. Valid HTTPS certificates are generated automatically, HTTPS host gets A+ rating from SSLabs.
|
|
12
12
|
- URL record can be turned into a **Cloudflare-like proxying** by using `proxy=true` flag. Though, while Cloudflare makes things faster then PendingDNS makes things slightly slower due to not caching anything.
|
|
13
13
|
- Periodic **health checks** to filter out unhealthy A/AAAA records
|
|
14
|
+
- **DNSSEC** with online (live) signing, enabled per zone over the API
|
|
14
15
|
- **Lightweight**
|
|
15
16
|
- Can be **geographically distributed**. All writes go to central Redis master, all reads are done from local Redis replica
|
|
16
17
|
- Request **certificates over API**
|
|
@@ -19,19 +20,20 @@ Lightweight API driven Authoritative DNS server.
|
|
|
19
20
|
|
|
20
21
|
- No support for zone files, all records must be managed over API
|
|
21
22
|
- Only the most basic and common record types
|
|
22
|
-
-
|
|
23
|
+
- DNSSEC uses online signing; denial of existence is NODATA (NOERROR) with compact NSEC "black lies" (no NSEC3, no true NXDOMAIN). Algorithm rollover is supported by re-enabling a zone with a new algorithm; there is no automated scheduled key rollover
|
|
23
24
|
- Only plain old DNS over UDP and TCP, no DoH, no DoT
|
|
25
|
+
- UDP responses are capped at the smaller of the requestor's advertised EDNS payload size and the server's configured `[dnssec] udpPayloadSize` (1232 by default; 512 when the requestor advertises no EDNS), and truncated with TC=1 above that limit, per RFC 1035; clients then retry over TCP
|
|
24
26
|
- Barely tested on [Project Pending](https://projectpending.com/). Do not use this for mission critical domains. PendingDNS is only good for leftover domains, ie. for development and testing.
|
|
25
27
|
|
|
26
28
|
## Requirements
|
|
27
29
|
|
|
28
|
-
- **Node.js**,
|
|
30
|
+
- **Node.js**, v18 or newer
|
|
29
31
|
- **Redis**, any version should do as only basic commands are used
|
|
30
32
|
|
|
31
33
|
## Usage
|
|
32
34
|
|
|
33
35
|
```
|
|
34
|
-
$ npm install --
|
|
36
|
+
$ npm install --omit=dev
|
|
35
37
|
$ npm start
|
|
36
38
|
```
|
|
37
39
|
|
|
@@ -45,9 +47,9 @@ As root run the following commands to set up PendingDNS:
|
|
|
45
47
|
|
|
46
48
|
```
|
|
47
49
|
$ cd /opt
|
|
48
|
-
$ git clone
|
|
50
|
+
$ git clone https://github.com/postalsys/pending-dns.git
|
|
49
51
|
$ cd pending-dns
|
|
50
|
-
$ npm install --
|
|
52
|
+
$ npm install --omit=dev
|
|
51
53
|
$ cp systemd/pending-dns.service /etc/systemd/system
|
|
52
54
|
$ cp config/default.toml /etc/pending-dns.toml
|
|
53
55
|
```
|
|
@@ -110,6 +112,28 @@ Without proper setup domain registrars do not allow your name server domain name
|
|
|
110
112
|
|
|
111
113
|

|
|
112
114
|
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
Install all dependencies (including dev dependencies):
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
$ npm install
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Tests
|
|
124
|
+
|
|
125
|
+
The test suite runs with the built-in Node.js test runner and needs a **local Redis** instance listening on `127.0.0.1:6379`. Tests use a dedicated database (`db 15`) which is flushed between runs, so it does not touch development or production data.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
$ npm test
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Linting
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
$ npm run lint
|
|
135
|
+
```
|
|
136
|
+
|
|
113
137
|
## API
|
|
114
138
|
|
|
115
139
|
You can see the entire API docs from the swagger page at http://127.0.0.1:5080/docs
|
|
@@ -129,7 +153,8 @@ $ curl -X GET "http://127.0.0.1:5080/v1/zone/mailtanker.com/records"
|
|
|
129
153
|
{
|
|
130
154
|
"id": "Y29tLm1haWx0YW5rZXIBQQEzc3lKWkkzbGo",
|
|
131
155
|
"type": "A",
|
|
132
|
-
"address": "18.203.150.145"
|
|
156
|
+
"address": "18.203.150.145",
|
|
157
|
+
"healthCheck": false
|
|
133
158
|
},
|
|
134
159
|
{
|
|
135
160
|
"id": "Y29tLm1haWx0YW5rZXIud3d3AUNOQU1FAXhhV1lnbnFaMA",
|
|
@@ -165,7 +190,7 @@ $ curl -X POST "http://127.0.0.1:5080/v1/zone/mailtanker.com/records" -H "Conten
|
|
|
165
190
|
All record types have the following properties
|
|
166
191
|
|
|
167
192
|
- **subdomain** (optional) subdomain this record applies to. If blank, or "@" or missing then the record is created for zone domain.
|
|
168
|
-
- **type** one of A, AAAA, CNAME, ANAME, URL, MX, TXT, CAA, NS
|
|
193
|
+
- **type** one of A, AAAA, CNAME, ANAME, URL, MX, TXT, CAA, NS, TLSA
|
|
169
194
|
|
|
170
195
|
#### Type specific options
|
|
171
196
|
|
|
@@ -203,7 +228,16 @@ All record types have the following properties
|
|
|
203
228
|
**CAA**
|
|
204
229
|
|
|
205
230
|
- **value** is the domain name of the provider, eg. `letsencrypt.org`
|
|
206
|
-
- **tag** is the CAA tag,
|
|
231
|
+
- **tag** is the CAA tag, one of `issue`, `issuewild` or `iodef`
|
|
232
|
+
- **flags** (Number, default is `0`) is the CAA flags octet (0-255)
|
|
233
|
+
|
|
234
|
+
**TLSA**
|
|
235
|
+
|
|
236
|
+
- **subdomain** typically uses the DANE form `_port._proto`, eg. `_443._tcp.www`
|
|
237
|
+
- **usage** (Number, 0-3) certificate usage: 0 PKIX-TA, 1 PKIX-EE, 2 DANE-TA, 3 DANE-EE
|
|
238
|
+
- **selector** (Number, 0-1) 0 for the full certificate, 1 for the SubjectPublicKeyInfo
|
|
239
|
+
- **matchingType** (Number, 0-2) 0 full, 1 SHA-256, 2 SHA-512
|
|
240
|
+
- **certificate** (String) the certificate association data as a hex string
|
|
207
241
|
|
|
208
242
|
**URL**
|
|
209
243
|
|
|
@@ -276,6 +310,74 @@ $ curl -X POST "http://127.0.0.1:5080/v1/acme" -H "Content-Type: application/jso
|
|
|
276
310
|
}
|
|
277
311
|
```
|
|
278
312
|
|
|
313
|
+
### DNSSEC
|
|
314
|
+
|
|
315
|
+
PendingDNS signs answers **online** (at query time) for zones that have DNSSEC enabled. DNSSEC must be turned on globally (`[dnssec] enabled = true`) and then enabled per zone over the API; enabling a zone generates a signing key (a CSK) that is stored in Redis and used to sign every RRset on the fly. Signing only happens for clients that set the EDNS DO bit. Denial of existence is always **NODATA (NOERROR)** with a signed compact NSEC ("black lies"): because the server can synthesize CAA/NS/SOA for any name, no name is treated as truly nonexistent, so there is no NXDOMAIN and no NSEC3.
|
|
316
|
+
|
|
317
|
+
After enabling a zone you must copy the returned **DS** record to the parent zone at your registrar to complete the chain of trust.
|
|
318
|
+
|
|
319
|
+
**Enable DNSSEC for a zone**
|
|
320
|
+
|
|
321
|
+
**POST /v1/zone/{zone}/dnssec**
|
|
322
|
+
|
|
323
|
+
The optional `algorithm` selects the signing algorithm: `13` ECDSA P-256/SHA-256 (default), `15` Ed25519, or `8` RSASHA256.
|
|
324
|
+
|
|
325
|
+
```
|
|
326
|
+
$ curl -X POST "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec" -H "Content-Type: application/json" -d '{
|
|
327
|
+
"algorithm": 13
|
|
328
|
+
}'
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{
|
|
333
|
+
"zone": "mailtanker.com",
|
|
334
|
+
"enabled": true,
|
|
335
|
+
"algorithm": 13,
|
|
336
|
+
"keyTag": 48234,
|
|
337
|
+
"ds": [{ "keyTag": 48234, "algorithm": 13, "digestType": 2, "digest": "a4f5...", "presentation": "48234 13 2 a4f5..." }],
|
|
338
|
+
"dnskey": [{ "flags": 257, "protocol": 3, "algorithm": 13, "publicKey": "GjL2...", "keyTag": 48234, "presentation": "257 3 13 GjL2..." }]
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Get DNSSEC status (DS and DNSKEY records)**
|
|
343
|
+
|
|
344
|
+
**GET /v1/zone/{zone}/dnssec**
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
$ curl -X GET "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec"
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Returns the same `enabled`, `algorithm`, `keyTag`, `ds` and `dnskey` shape as the enable response. For a zone that has never had DNSSEC enabled, `enabled` is `false` and the `ds`/`dnskey` arrays are empty.
|
|
351
|
+
|
|
352
|
+
**Disable DNSSEC for a zone**
|
|
353
|
+
|
|
354
|
+
**DELETE /v1/zone/{zone}/dnssec**
|
|
355
|
+
|
|
356
|
+
```
|
|
357
|
+
$ curl -X DELETE "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec"
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
```json
|
|
361
|
+
{
|
|
362
|
+
"zone": "mailtanker.com",
|
|
363
|
+
"disabled": true
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**Roll the signing key to a new algorithm**
|
|
368
|
+
|
|
369
|
+
Re-POST to the enable endpoint with a different `algorithm`. A new key is generated and kept alongside the old one, and the zone is signed with **both** algorithms (every RRset gets an RRSIG per algorithm) so validation keeps working during the rollover. The response lists every key in `ds`/`dnskey`. Publish the new **DS** at the registrar, wait for the old DS TTL to expire, then remove the old key.
|
|
370
|
+
|
|
371
|
+
**Remove a signing key (finish a rollover)**
|
|
372
|
+
|
|
373
|
+
**DELETE /v1/zone/{zone}/dnssec/key/{keyTag}**
|
|
374
|
+
|
|
375
|
+
Removes a non-active key. Removing the active key or the last remaining key is refused.
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
$ curl -X DELETE "http://127.0.0.1:5080/v1/zone/mailtanker.com/dnssec/key/48234"
|
|
379
|
+
```
|
|
380
|
+
|
|
279
381
|
## Acknowledgments
|
|
280
382
|
|
|
281
383
|
- All DNS parsing / compiling is done using [dns2](https://www.npmjs.com/package/dns2) module by [Liu Song](https://github.com/song940)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
PendingDNS is a lightweight, API-driven authoritative DNS server. It also runs a
|
|
4
|
+
public HTTP/HTTPS redirect/proxy server and generates Let's Encrypt certificates
|
|
5
|
+
over an ACME flow, and it stores all zone data in Redis. Because it is exposed to
|
|
6
|
+
untrusted DNS and HTTP traffic and handles certificate private keys, we take
|
|
7
|
+
security reports seriously and aim to respond quickly.
|
|
8
|
+
|
|
9
|
+
## Supported Versions
|
|
10
|
+
|
|
11
|
+
Security fixes are released only against the latest version. We do not backport
|
|
12
|
+
patches to older releases - upgrading to the current release line is the
|
|
13
|
+
supported way to receive security updates.
|
|
14
|
+
|
|
15
|
+
| Version | Supported |
|
|
16
|
+
| ------- | ------------------ |
|
|
17
|
+
| 1.x | :white_check_mark: |
|
|
18
|
+
| < 1.0 | :x: |
|
|
19
|
+
|
|
20
|
+
If you are on an older version, please upgrade. See the release notes at
|
|
21
|
+
<https://github.com/postalsys/pending-dns/releases> before updating.
|
|
22
|
+
|
|
23
|
+
## Reporting a Vulnerability
|
|
24
|
+
|
|
25
|
+
**Please do not report security vulnerabilities through public GitHub issues,
|
|
26
|
+
pull requests, or discussions.**
|
|
27
|
+
|
|
28
|
+
Report privately through one of the following channels:
|
|
29
|
+
|
|
30
|
+
1. **GitHub Security Advisories (preferred).** Open a private report at
|
|
31
|
+
<https://github.com/postalsys/pending-dns/security/advisories/new>. This keeps
|
|
32
|
+
the discussion private until a fix is published and lets us credit you.
|
|
33
|
+
2. **Email.** Send details to **andris@postalsys.com** (the contact listed in
|
|
34
|
+
[`SECURITY.txt`](SECURITY.txt)). Encrypt sensitive details if possible using
|
|
35
|
+
the key referenced there.
|
|
36
|
+
|
|
37
|
+
When reporting, please include as much of the following as you can:
|
|
38
|
+
|
|
39
|
+
- The affected version(s) and environment (PendingDNS version, Node.js version,
|
|
40
|
+
OS, deployment method - npm or prebuilt binary).
|
|
41
|
+
- The component involved (e.g. the UDP/TCP DNS server, the REST API, the public
|
|
42
|
+
HTTP/HTTPS redirect and proxy server, ACME certificate generation, health
|
|
43
|
+
checks, or the Redis-backed zone store).
|
|
44
|
+
- A clear description of the issue and its impact (e.g. cache poisoning, DNS
|
|
45
|
+
amplification, request smuggling, SSRF via the URL/proxy records or health
|
|
46
|
+
checks, certificate mis-issuance, credential or private-key disclosure,
|
|
47
|
+
injection, information disclosure, denial of service).
|
|
48
|
+
- A minimal proof of concept or reproduction steps.
|
|
49
|
+
- Any suggested remediation, if you have one.
|
|
50
|
+
|
|
51
|
+
We are a small team, so there is no guaranteed response time - sometimes reports
|
|
52
|
+
are handled within hours, sometimes they take longer. Accepted issues are fixed
|
|
53
|
+
in a new release and coordinated through a GitHub Security Advisory, and
|
|
54
|
+
reporters who wish to be named are credited.
|
|
55
|
+
|
|
56
|
+
## CVEs
|
|
57
|
+
|
|
58
|
+
We track and disclose vulnerabilities through GitHub Security Advisories. We do
|
|
59
|
+
not request or manage CVE identifiers ourselves. If you need a CVE assigned for a
|
|
60
|
+
reported issue, please request one yourself - for example, through GitHub's own
|
|
61
|
+
CVE request flow on the published advisory, or another CNA.
|
|
62
|
+
|
|
63
|
+
## Scope
|
|
64
|
+
|
|
65
|
+
In scope: the PendingDNS application source in this repository - the
|
|
66
|
+
authoritative DNS server (UDP and TCP), the REST API for managing zone records,
|
|
67
|
+
the public HTTP/HTTPS redirect and proxy server, ACME/Let's Encrypt certificate
|
|
68
|
+
generation and storage, the health-check subsystem, and the Redis-backed zone
|
|
69
|
+
store.
|
|
70
|
+
|
|
71
|
+
Out of scope:
|
|
72
|
+
|
|
73
|
+
- Vulnerabilities in your own application code or automation that integrates
|
|
74
|
+
with the PendingDNS API.
|
|
75
|
+
- Misconfiguration of your deployment - for example, exposing the management API
|
|
76
|
+
to untrusted networks, an unauthenticated or publicly reachable Redis instance,
|
|
77
|
+
binding the DNS server in a way that enables open-resolver-style abuse, or
|
|
78
|
+
missing TLS on the API.
|
|
79
|
+
- The inherent properties of plain DNS over UDP/TCP without DNSSEC (PendingDNS
|
|
80
|
+
does not implement DNSSEC, DoH, or DoT by design).
|
|
81
|
+
- Issues that require an already-compromised host or pre-existing administrator
|
|
82
|
+
access.
|
|
83
|
+
- Vulnerabilities in third-party services PendingDNS connects to (Let's Encrypt,
|
|
84
|
+
upstream resolvers, health-check targets).
|
|
85
|
+
- Social-engineering reports and missing security headers without a
|
|
86
|
+
demonstrated, concrete impact.
|
|
87
|
+
|
|
88
|
+
Thank you for helping keep PendingDNS and its users safe.
|