From b3556733de2c48a24ea48b7c4e350fa175020738 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 17 Nov 2024 17:06:09 +0900 Subject: [PATCH 01/24] Update docker compose --- .env | 12 +++++++----- .gitignore | 5 +++-- docker-compose.yml | 10 +++++----- unbound-config/tls/unbound.conf | 14 ++++++++++++++ unbound-config/unix/unbound.conf | 8 ++++++++ 5 files changed, 37 insertions(+), 12 deletions(-) create mode 100644 unbound-config/tls/unbound.conf create mode 100644 unbound-config/unix/unbound.conf diff --git a/.env b/.env index 9ef2dea..e38b604 100644 --- a/.env +++ b/.env @@ -1,6 +1,8 @@ # Unbound Configuration Path -# Select one of the following options: -# - unix: Unix Domain Socket -# - tls: TLS Socket -UNBOUND_CONF_PATH=./unbound-config/unix -# UNBOUND_CONF_PATH=./unbound-config/tls \ No newline at end of file +# default: ./unbound-config/unix +# UNBOUND_CONF_PATH=./unbound-config/unix +# UNBOUND_CONF_PATH=./unbound-config/tls + +# Unbound Version +# default: latest +# UNBOUND_VERSION=latest diff --git a/.gitignore b/.gitignore index 0539f8b..95266e2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,6 @@ dist-ssr coverage/ -unbound-config/* -!unbound-config/unbound.conf +unbound-config/*/* +!unbound-config/tls/unbound.conf +!unbound-config/unix/unbound.conf diff --git a/docker-compose.yml b/docker-compose.yml index e8e3954..24845d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ services: unbound-setup: - image: mvance/unbound:latest + image: mvance/unbound:${UNBOUND_VERSION:-latest} volumes: - - ${UNBOUND_CONF_PATH}:/opt/unbound/etc/unbound + - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound restart: "no" entrypoint: [] command: >- @@ -10,7 +10,7 @@ services: if [ ! -f /opt/unbound/etc/unbound/key/unbound_server.key ]; then echo 'Setup start' && unbound-control-setup && - mkdir /opt/unbound/etc/unbound/key && + mkdir /opt/unbound/etc/unbound/key /opt/unbound/etc/unbound/socket && mv /opt/unbound/etc/unbound/unbound_* /opt/unbound/etc/unbound/key && echo 'Setup complete'; else @@ -18,14 +18,14 @@ services: fi " unbound: - image: mvance/unbound:latest + image: mvance/unbound:${UNBOUND_VERSION:-latest} container_name: unbound ports: - "53:53/tcp" - "53:53/udp" - "8953:8953" volumes: - - ${UNBOUND_CONF_PATH}:/opt/unbound/etc/unbound + - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound restart: unless-stopped depends_on: - unbound-setup diff --git a/unbound-config/tls/unbound.conf b/unbound-config/tls/unbound.conf new file mode 100644 index 0000000..8116293 --- /dev/null +++ b/unbound-config/tls/unbound.conf @@ -0,0 +1,14 @@ +server: + interface: 0.0.0.0 + logfile: "" + verbosity: 1 + + remote-control: + control-enable: yes + # control-interface: /opt/unbound/etc/unbound/unbound.ctl + control-interface: 0.0.0.0 + control-port: 8953 + server-key-file: "/opt/unbound/etc/unbound/key/unbound_server.key" + server-cert-file: "/opt/unbound/etc/unbound/key/unbound_server.pem" + control-key-file: "/opt/unbound/etc/unbound/key/unbound_control.key" + control-cert-file: "/opt/unbound/etc/unbound/key/unbound_control.pem" \ No newline at end of file diff --git a/unbound-config/unix/unbound.conf b/unbound-config/unix/unbound.conf new file mode 100644 index 0000000..4da2a03 --- /dev/null +++ b/unbound-config/unix/unbound.conf @@ -0,0 +1,8 @@ +server: + interface: 0.0.0.0 + logfile: "" + verbosity: 1 + + remote-control: + control-enable: yes + control-interface: /opt/unbound/etc/unbound/socket/unbound.ctl From d884d2d3929b7cb7ba4868b21cdfb9f739d547b6 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 17 Nov 2024 18:06:42 +0900 Subject: [PATCH 02/24] Fix vite.config --- examples/index.cjs | 15 ++++++++++++--- examples/index.mjs | 15 ++++++++++++--- vite.config.ts | 1 + 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/examples/index.cjs b/examples/index.cjs index 2a96d20..cef9bd4 100644 --- a/examples/index.cjs +++ b/examples/index.cjs @@ -1,4 +1,13 @@ -const { hello } = require("../../dist/index.cjs"); +const { UnboundControl } = require("../dist/index.cjs"); -const result = hello(); -console.log(`CJS Result: ${result}`); +const unixSocketName = "../unbound-config/unix/socket/unbound.ctl"; +const control = new UnboundControl(unixSocketName); + +control + .sendCommand("status") + .then((data) => { + console.log(data); + }) + .catch((err) => { + console.error(err); + }); diff --git a/examples/index.mjs b/examples/index.mjs index f513219..8dc6f3d 100644 --- a/examples/index.mjs +++ b/examples/index.mjs @@ -1,4 +1,13 @@ -import { hello } from "../../dist/index.mjs"; +import { UnboundControl } from "../dist/index.mjs"; -const result = hello(); -console.log(`ESM Result: ${result}`); +const unixSocketName = "../unbound-config/unix/socket/unbound.ctl"; +const control = new UnboundControl(unixSocketName); + +control + .sendCommand("status") + .then((data) => { + console.log(data); + }) + .catch((err) => { + console.error(err); + }); diff --git a/vite.config.ts b/vite.config.ts index 22d228e..dd7050a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ }, }, rollupOptions: { + external: ["net", "fs", "path"], output: { dir: "dist", }, From cb64e22de5f0d1eaa43c43cffdc293e2bba4449f Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 00:00:53 +0900 Subject: [PATCH 03/24] Update --- .gitignore | 2 + .vscode/settings.json | 5 +- examples/index.cjs | 52 ++++++++--- examples/index.mjs | 56 +++++++++--- package-lock.json | 146 ++++++++++++++++-------------- package.json | 14 +-- src/client.ts | 148 ++++++++++++++++++++++++++++++ src/control.ts | 161 +++++++++++++++++++++++++++++++++ src/error.ts | 49 ++++++++++ src/index.ts | 12 ++- tests/control.snapshot.test.ts | 58 ++++++++++++ tests/control.test.ts | 64 +++++++++++++ tests/data/1.22.0/stats.yaml | 61 +++++++++++++ tests/data/1.22.0/status.yaml | 19 ++++ tests/data/template.yaml | 6 ++ tests/index.test.ts | 8 -- tests/mockServer.ts | 105 +++++++++++++++++++++ tsconfig.json | 3 +- vite.config.ts | 2 + 19 files changed, 858 insertions(+), 113 deletions(-) create mode 100644 src/client.ts create mode 100644 src/control.ts create mode 100644 src/error.ts create mode 100644 tests/control.snapshot.test.ts create mode 100644 tests/control.test.ts create mode 100644 tests/data/1.22.0/stats.yaml create mode 100644 tests/data/1.22.0/status.yaml create mode 100644 tests/data/template.yaml delete mode 100644 tests/index.test.ts create mode 100644 tests/mockServer.ts diff --git a/.gitignore b/.gitignore index 95266e2..87d359b 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ coverage/ unbound-config/*/* !unbound-config/tls/unbound.conf !unbound-config/unix/unbound.conf +tests/__snapshots__ +tests/key \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9c60733..5575b94 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,8 @@ "editor.formatOnSave": true, "editor.formatOnType": true, "editor.formatOnPaste": true, - "files.eol": "\n" + "files.eol": "\n", + "jest.runMode": { + "type": "on-demand" + } } diff --git a/examples/index.cjs b/examples/index.cjs index cef9bd4..a56dbbd 100644 --- a/examples/index.cjs +++ b/examples/index.cjs @@ -1,13 +1,39 @@ -const { UnboundControl } = require("../dist/index.cjs"); - -const unixSocketName = "../unbound-config/unix/socket/unbound.ctl"; -const control = new UnboundControl(unixSocketName); - -control - .sendCommand("status") - .then((data) => { - console.log(data); - }) - .catch((err) => { - console.error(err); - }); +const path = require("path"); +const { UnboundControlClient, UnboundError } = require("../dist/index.cjs"); + +const baseDir = path.resolve(__dirname, ".."); + +const unixSocketName = path.join( + baseDir, + "unbound-config/unix/socket/unbound.ctl", +); + +const client = new UnboundControlClient(unixSocketName); + +(async () => { + try { + const response = await client.status(); + console.log(response.raw); + console.log(response.json); + } catch (error) { + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } + } +})(); + +(async () => { + try { + const response = await client.status(); + console.log(response.raw); + console.log(response.json); + } catch (error) { + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } + } +})(); diff --git a/examples/index.mjs b/examples/index.mjs index 8dc6f3d..250a881 100644 --- a/examples/index.mjs +++ b/examples/index.mjs @@ -1,13 +1,43 @@ -import { UnboundControl } from "../dist/index.mjs"; - -const unixSocketName = "../unbound-config/unix/socket/unbound.ctl"; -const control = new UnboundControl(unixSocketName); - -control - .sendCommand("status") - .then((data) => { - console.log(data); - }) - .catch((err) => { - console.error(err); - }); +import path from "path"; +import { fileURLToPath } from "url"; +import { UnboundControlClient, UnboundError } from "../dist/index.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const baseDir = path.resolve(__dirname, ".."); + +const unixSocketName = path.join( + baseDir, + "unbound-config/unix/socket/unbound.ctl", +); + +const client = new UnboundControlClient(unixSocketName); + +(async () => { + try { + const response = await client.status(); + console.log(response.raw); + console.log(response.json); + } catch (error) { + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } + } +})(); + +(async () => { + try { + const response = await client.status(); + console.log(response.raw); + console.log(response.json); + } catch (error) { + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } + } +})(); diff --git a/package-lock.json b/package-lock.json index 9713897..359f68d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,11 +21,12 @@ "prettier": "^3.3.3", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "~5.6.2", - "typescript-eslint": "^8.14.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.15.0", "uninstall": "^0.0.0", "vite": "^5.4.10", - "vite-plugin-dts": "^4.3.0" + "vite-plugin-dts": "^4.3.0", + "yaml": "^2.6.1" } }, "node_modules/@ampproject/remapping": { @@ -2307,16 +2308,16 @@ "dev": true }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.14.0.tgz", - "integrity": "sha512-tqp8H7UWFaZj0yNO6bycd5YjMwxa6wIHOLZvWPkidwbgLCsBMetQoGj7DPuAlWa2yGO3H48xmPwjhsSPPCGU5w==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", + "integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.14.0", - "@typescript-eslint/type-utils": "8.14.0", - "@typescript-eslint/utils": "8.14.0", - "@typescript-eslint/visitor-keys": "8.14.0", + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/type-utils": "8.15.0", + "@typescript-eslint/utils": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -2340,15 +2341,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.14.0.tgz", - "integrity": "sha512-2p82Yn9juUJq0XynBXtFCyrBDb6/dJombnz6vbo6mgQEtWHfvHbQuEa9kAOVIt1c9YFwi7H6WxtPj1kg+80+RA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz", + "integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.14.0", - "@typescript-eslint/types": "8.14.0", - "@typescript-eslint/typescript-estree": "8.14.0", - "@typescript-eslint/visitor-keys": "8.14.0", + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "debug": "^4.3.4" }, "engines": { @@ -2368,13 +2369,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.14.0.tgz", - "integrity": "sha512-aBbBrnW9ARIDn92Zbo7rguLnqQ/pOrUguVpbUwzOhkFg2npFDwTgPGqFqE0H5feXcOoJOfX3SxlJaKEVtq54dw==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz", + "integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.14.0", - "@typescript-eslint/visitor-keys": "8.14.0" + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2385,13 +2386,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.14.0.tgz", - "integrity": "sha512-Xcz9qOtZuGusVOH5Uk07NGs39wrKkf3AxlkK79RBK6aJC1l03CobXjJbwBPSidetAOV+5rEVuiT1VSBUOAsanQ==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz", + "integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "8.14.0", - "@typescript-eslint/utils": "8.14.0", + "@typescript-eslint/typescript-estree": "8.15.0", + "@typescript-eslint/utils": "8.15.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -2402,6 +2403,9 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -2409,9 +2413,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.14.0.tgz", - "integrity": "sha512-yjeB9fnO/opvLJFAsPNYlKPnEM8+z4og09Pk504dkqonT02AyL5Z9SSqlE0XqezS93v6CXn49VHvB2G7XSsl0g==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz", + "integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==", "dev": true, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2422,13 +2426,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.14.0.tgz", - "integrity": "sha512-OPXPLYKGZi9XS/49rdaCbR5j/S14HazviBlUQFvSKz3npr3NikF+mrgK7CFVur6XEt95DZp/cmke9d5i3vtVnQ==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz", + "integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.14.0", - "@typescript-eslint/visitor-keys": "8.14.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/visitor-keys": "8.15.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2486,15 +2490,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.14.0.tgz", - "integrity": "sha512-OGqj6uB8THhrHj0Fk27DcHPojW7zKwKkPmHXHvQ58pLYp4hy8CSUdTKykKeh+5vFqTTVmjz0zCOOPKRovdsgHA==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz", + "integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.14.0", - "@typescript-eslint/types": "8.14.0", - "@typescript-eslint/typescript-estree": "8.14.0" + "@typescript-eslint/scope-manager": "8.15.0", + "@typescript-eslint/types": "8.15.0", + "@typescript-eslint/typescript-estree": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2505,16 +2509,21 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.14.0.tgz", - "integrity": "sha512-vG0XZo8AdTH9OE6VFRwAZldNc7qtJ/6NLGWak+BtENuEUXGZgFpihILPiBvKXvJ2nFu27XNGC6rKiwuaoMbYzQ==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz", + "integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==", "dev": true, "dependencies": { - "@typescript-eslint/types": "8.14.0", - "eslint-visitor-keys": "^3.4.3" + "@typescript-eslint/types": "8.15.0", + "eslint-visitor-keys": "^4.2.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2524,18 +2533,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@volar/language-core": { "version": "2.4.10", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.10.tgz", @@ -6357,9 +6354,9 @@ } }, "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -6370,14 +6367,14 @@ } }, "node_modules/typescript-eslint": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.14.0.tgz", - "integrity": "sha512-K8fBJHxVL3kxMmwByvz8hNdBJ8a0YqKzKDX6jRlrjMuNXyd5T2V02HIq37+OiWXvUUOXgOOGiSSOh26Mh8pC3w==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.15.0.tgz", + "integrity": "sha512-wY4FRGl0ZI+ZU4Jo/yjdBu0lVTSML58pu6PgGtJmCufvzfV565pUF6iACQt092uFOd49iLOTX/sEVmHtbSrS+w==", "dev": true, "dependencies": { - "@typescript-eslint/eslint-plugin": "8.14.0", - "@typescript-eslint/parser": "8.14.0", - "@typescript-eslint/utils": "8.14.0" + "@typescript-eslint/eslint-plugin": "8.15.0", + "@typescript-eslint/parser": "8.15.0", + "@typescript-eslint/utils": "8.15.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6386,6 +6383,9 @@ "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, "peerDependenciesMeta": { "typescript": { "optional": true @@ -6662,6 +6662,18 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 4512d4c..e68b6bf 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,10 @@ "build": "tsc && vite build", "lint": "eslint .", "format": "prettier --write .", - "test": "jest", - "sample:esm:node": "node examples/node/index.mjs", - "sample:cjs:node": "node examples/node/index.cjs", + "test": "jest --detectOpenHandles control.test.ts", + "snapshot": "jest --detectOpenHandles control.snapshot.test.ts", + "sample:esm": "node examples/index.mjs", + "sample:cjs": "node examples/index.cjs", "sample": "npm run test:esm:node && npm run test:cjs:node" }, "devDependencies": { @@ -53,10 +54,11 @@ "prettier": "^3.3.3", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", - "typescript": "~5.6.2", - "typescript-eslint": "^8.14.0", + "typescript": "^5.6.2", + "typescript-eslint": "^8.15.0", "uninstall": "^0.0.0", "vite": "^5.4.10", - "vite-plugin-dts": "^4.3.0" + "vite-plugin-dts": "^4.3.0", + "yaml": "^2.6.1" } } diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..5a28045 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,148 @@ +import { UnboundControl } from "./control"; +import { ParseError } from "./error"; + +export interface Response { + raw: string; + json: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +export interface StatusResponse { + version: string; + verbosity: number; + threads: number; + modules: string[]; + uptime: number; + options: string[]; + pid: number; + status: string; +} + +export interface NestedRecord { + [key: string]: string | number | NestedRecord; +} + +export class UnboundControlClient { + private control: UnboundControl; + + constructor( + unixSocketName: string | null = null, + // host: string = "127.0.0.1", + // port: number = 8953, + // tlsConfig?: TLSConfig, + ) { + this.control = new UnboundControl(unixSocketName); + } + + private parseRawToJSON(raw: string): NestedRecord { + const lines = raw.split("\n").filter((line) => line.trim() !== ""); + const result: NestedRecord = {}; + + for (const line of lines) { + const [key, value] = line.split("=").map((str) => str.trim()); + + if (!key) { + throw new ParseError(`Invalid key-value pair: ${line}`); + } + + const keys = key.split("."); + let current: NestedRecord = result; + + for (let i = 0; i < keys.length - 1; i++) { + const k = keys[i]; + if (!(k in current)) { + current[k] = {}; // 中間オブジェクトを作成 + } + current = current[k] as NestedRecord; + } + + const finalKey = keys[keys.length - 1]; + current[finalKey] = isNaN(Number(value)) ? value : Number(value); + } + + return result; + } + + // ==================== Socket connection/disconnection ==================== + public async connect(): Promise { + await this.control.initSocket(); + } + + public async disconnect(): Promise { + await this.control.closeSocket(); + } + + // ==================== Unbound control commands ==================== + + /** + * Display server status + * @returns {Promise} + */ + public async status(): Promise { + const raw = await this.control.sendCommand("status"); + + const lines = raw.split("\n"); + const result: Partial = {}; + + for (const line of lines) { + const [key, value] = line.split(":").map((s) => s.trim()); + + switch (key) { + case "version": + result.version = value; + break; + case "verbosity": + result.verbosity = parseInt(value, 10); + break; + case "threads": + result.threads = parseInt(value, 10); + break; + case "modules": + result.modules = + value + .match(/\[([^\]]+)\]/)?.[1] + .trim() + .split(" ") || []; + break; + case "uptime": + result.uptime = parseInt(value.trim().split(" ")[0], 10); + break; + case "options": + result.options = value + .replace(/[[\]]/g, "") + .trim() + .split(",") + .map((item) => item.trim()); + break; + default: + if (key && key.startsWith("unbound")) { + const match = key.match(/\(pid (\d+)\) is (.*)\.\.\./); + if (match) { + result.pid = parseInt(match[1], 10); + result.status = match[2]; + } + } + } + } + + return { + raw: raw, + json: result, + }; + } + + public async stats(): Promise { + const raw = await this.control.sendCommand("stats"); + return { + raw: raw, + json: this.parseRawToJSON(raw), + }; + } + + public async reload(): Promise { + return this.control.sendCommand("reload"); + } + + public async stop(): Promise { + return this.control.sendCommand("stop"); + } +} diff --git a/src/control.ts b/src/control.ts new file mode 100644 index 0000000..e6002dc --- /dev/null +++ b/src/control.ts @@ -0,0 +1,161 @@ +import net from "net"; +import { ConnectionError, CommandError } from "./error"; +// import tls from "tls"; + +/** + * Configuration for the TLS connection. + */ +// export interface TLSConfig { +// /** Certificate file. */ +// cert: string; + +// /** Key file. */ +// key: string; + +// /** CA certificate file. */ +// ca?: string; + +// /** Reject unauthorized connections. If set to false, the server certificate is not verified. */ +// rejectUnauthorized: boolean; +// } + +/** + * A class to interact with an Unbound control interface via TCP or Unix socket. + * Provides methods to establish connections and send commands to the Unbound DNS resolver. + */ +export class UnboundControl { + /** The host address for TCP connections. */ + // private readonly host: string; + + /** The port number for TCP connections. */ + // private readonly port: number; + + /** The path to the Unix domain socket (if applicable). */ + private readonly unixSocketName: string | null; + + /** The underlying network socket for communication. */ + private socket: net.Socket | null = null; + + /** Optional TLS configuration for secure connections. */ + // private readonly tlsConfig: TLSConfig | null = null; + + /** + * Creates a new instance of the UnboundControl class. + * + * @param unixSocketName - Path to the Unix domain socket. If specified, `host` and `port` are ignored. + * @param host - The host address for TCP connections. Defaults to `127.0.0.1`. + * @param port - The port number for TCP connections. Defaults to `8953`. + * @param tlsConfig - Optional TLS configuration for secure connections. + */ + constructor( + unixSocketName: string | null = null, + // host: string = "127.0.0.1", + // port: number = 8953, + // tlsConfig?: TLSConfig, + ) { + this.unixSocketName = unixSocketName; + // this.host = host; + // this.port = port; + // this.tlsConfig = tlsConfig || null; + } + + /** + * Connects to the Unbound control interface. + * + * - If `unixSocketName` is provided, connects via a Unix domain socket. + * - Otherwise, connects to the specified `host` and `port`. + * + * @returns A promise that resolves when the connection is established. + * @throws An error if the connection fails. + */ + public async initSocket(): Promise { + if (this.socket) { + return this.socket; + } + + return new Promise((resolve, reject) => { + let socket: net.Socket; + + if (this.unixSocketName !== null) { + socket = net.createConnection(this.unixSocketName); + } else { + throw new Error("Not implemented"); + } + // else { + // socket = tls.createConnection({ + // host: this.host, + // port: this.port, + // ...this.tlsConfig, + // }); + // } + + socket.once("connect", () => { + this.socket = socket; + resolve(socket); + }); + + socket.once("error", (err) => { + socket.destroy(); + reject(new ConnectionError(err.message)); + }); + + socket.once("close", () => { + this.socket = null; + }); + }); + } + + /** + * Sends a command to the Unbound control interface and retrieves the raw response. + * + * @param command - The command to send. + * @returns A promise that resolves with the raw response as a string. + * @throws An error if the command cannot be sent or the response cannot be received. + */ + public async sendCommand(command: string): Promise { + const socket = await this.initSocket(); + + return new Promise((resolve, reject) => { + let response = ""; + + socket.write(`UBCT1 ${command}\n`, (err) => { + if (err) { + reject(new CommandError(err.message)); + return; + } + }); + + socket.on("data", (data) => { + response += data.toString(); + }); + + socket.once("end", () => { + socket.end(); + resolve(response); + }); + + socket.once("error", (err) => { + socket.destroy(); + reject(new CommandError(err.message)); + }); + }); + } + + /** + * Disconnects from the Unbound control interface. + * Safely closes the socket if it is currently connected. + * + * @returns A promise that resolves when the socket is successfully closed. + */ + public async closeSocket(): Promise { + return new Promise((resolve) => { + if (this.socket) { + this.socket.end(); + this.socket = null; + resolve(); + } else { + resolve(); + } + }); + } +} diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..9132167 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,49 @@ +/** + * Base error class for all errors + */ +export class UnboundError extends Error { + constructor(message: string) { + super(message); + this.name = "UnboundError"; + } +} + +/** + * Error class for connection errors + */ +export class ConnectionError extends UnboundError { + constructor(message: string) { + super(message); + this.name = "ConnectionError"; + } +} + +/** + * Error class for command errors + */ +export class CommandError extends UnboundError { + constructor(message: string) { + super(message); + this.name = "CommandError"; + } +} + +/** + * Error class for parsing errors + */ +export class ParseError extends UnboundError { + constructor(message: string) { + super(message); + this.name = "ParseError"; + } +} + +/** + * Unsupported command error + */ +export class UnsupportedCommandError extends UnboundError { + constructor(message: string) { + super(message); + this.name = "UnsupportedCommandError"; + } +} diff --git a/src/index.ts b/src/index.ts index ab6b0e1..7d7a834 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ -export function hello() { - return "Hello, World!"; -} +export { UnboundControl } from "./control"; +export { UnboundControlClient } from "./client"; +export { + UnboundError, + ConnectionError, + CommandError, + ParseError, + UnsupportedCommandError, +} from "./error"; diff --git a/tests/control.snapshot.test.ts b/tests/control.snapshot.test.ts new file mode 100644 index 0000000..cfb1894 --- /dev/null +++ b/tests/control.snapshot.test.ts @@ -0,0 +1,58 @@ +import { UnboundControlClient } from "../src/index"; +import fs from "fs"; +import path from "path"; +import YAML from "yaml"; + +const baseDir = path.resolve(__dirname); +const unboundVersion = process.env.UNBOUND_VERSION || "1.22.0"; +const dataDir = path.join(baseDir, "data", unboundVersion); + +interface ParsedData { + data: TestCase[]; +} + +interface TestCase { + title: string; + raw: string; + expected: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +interface ResponseData { + raw: string; + json: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let client: UnboundControlClient; + const unixSocketPath = path.join( + baseDir, + "../unbound-config/unix/socket/unbound.ctl", + ); + + beforeAll(() => { + client = new UnboundControlClient(unixSocketPath); + }); + + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".yaml")); + + for (const file of files) { + const command = file.replace(".yaml", ""); + const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); + const contents = YAML.parse(fileContent) as ParsedData; + + for (const { title } of contents.data) { + it(`${command} test: ${title}`, async () => { + const result = (await client[ + command as keyof UnboundControlClient + ]()) as ResponseData; + + expect(result).toMatchSnapshot(); + // expect(result.raw).toEqual(raw); + // expect(result.json).toEqual(expected); + await client.disconnect(); + }); + } + } +}); diff --git a/tests/control.test.ts b/tests/control.test.ts new file mode 100644 index 0000000..714065a --- /dev/null +++ b/tests/control.test.ts @@ -0,0 +1,64 @@ +import { UnixMockServer, MockServer } from "./mockServer"; +import { UnboundControlClient } from "../src/index"; +import fs from "fs"; +import path from "path"; +import YAML from "yaml"; + +const baseDir = path.resolve(__dirname); +const unboundVersion = process.env.UNBOUND_VERSION || "1.22.0"; +const dataDir = path.join(baseDir, "data", unboundVersion); + +interface ParsedData { + data: TestCase[]; +} + +interface TestCase { + title: string; + raw: string; + expected: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +interface ResponseData { + raw: string; + json: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +describe(`Unix domain socket mock server tests. Unbound version: ${unboundVersion}`, () => { + let server: MockServer; + let client: UnboundControlClient; + const unixSocketPath = "/tmp/mock.sock"; + + beforeAll(() => { + if (fs.existsSync(unixSocketPath)) { + fs.unlinkSync(unixSocketPath); + } + server = new UnixMockServer(unixSocketPath); + client = new UnboundControlClient(unixSocketPath); + }); + + afterEach(async () => { + await server.stop(); + }); + + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".yaml")); + + for (const file of files) { + const command = file.replace(".yaml", ""); + const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); + const contents = YAML.parse(fileContent) as ParsedData; + + for (const { title, raw, expected } of contents.data) { + it(`${command} test: ${title}`, async () => { + server.start(raw); + const result = (await client[ + command as keyof UnboundControlClient + ]()) as ResponseData; + + expect(result.raw).toEqual(raw); + expect(result.json).toEqual(expected); + }); + } + } +}); diff --git a/tests/data/1.22.0/stats.yaml b/tests/data/1.22.0/stats.yaml new file mode 100644 index 0000000..0247027 --- /dev/null +++ b/tests/data/1.22.0/stats.yaml @@ -0,0 +1,61 @@ +data: + - title: Start up + raw: | + total.num.queries=0 + total.num.queries_ip_ratelimited=0 + total.num.queries_cookie_valid=0 + total.num.queries_cookie_client=0 + total.num.queries_cookie_invalid=0 + total.num.cachehits=0 + total.num.cachemiss=0 + total.num.prefetch=0 + total.num.queries_timed_out=0 + total.query.queue_time_us.max=0 + total.num.expired=0 + total.num.recursivereplies=0 + total.requestlist.avg=0 + total.requestlist.max=0 + total.requestlist.overwritten=0 + total.requestlist.exceeded=0 + total.requestlist.current.all=0 + total.requestlist.current.user=0 + total.recursion.time.avg=0.000000 + total.recursion.time.median=0 + total.tcpusage=0 + time.now=1731837810.163703 + time.up=2.858856 + time.elapsed=2.858856 + expected: + total: + num: + queries: 0 + queries_ip_ratelimited: 0 + queries_cookie_valid: 0 + queries_cookie_client: 0 + queries_cookie_invalid: 0 + cachehits: 0 + cachemiss: 0 + prefetch: 0 + queries_timed_out: 0 + expired: 0 + recursivereplies: 0 + query: + queue_time_us: + max: 0 + requestlist: + avg: 0 + max: 0 + overwritten: 0 + exceeded: 0 + current: + all: 0 + user: 0 + recursion: + time: + avg: 0.000000 + median: 0 + tcpusage: 0 + time: + now: 1731837810.163703 + up: 2.858856 + elapsed: 2.858856 \ No newline at end of file diff --git a/tests/data/1.22.0/status.yaml b/tests/data/1.22.0/status.yaml new file mode 100644 index 0000000..a3156a0 --- /dev/null +++ b/tests/data/1.22.0/status.yaml @@ -0,0 +1,19 @@ +data: + - title: Default + raw: | + version: 1.22.0 + verbosity: 1 + threads: 1 + modules: 3 [ subnetcache validator iterator ] + uptime: 3 seconds + options: [ reuseport, control(namedpipe) ] + unbound (pid 1) is running... + expected: + version: 1.22.0 + verbosity: 1 + threads: 1 + modules: [ subnetcache, validator, iterator ] + uptime: 3 + options: [ reuseport, control(namedpipe) ] + pid: 1 + status: running diff --git a/tests/data/template.yaml b/tests/data/template.yaml new file mode 100644 index 0000000..0515b6d --- /dev/null +++ b/tests/data/template.yaml @@ -0,0 +1,6 @@ +data: + - title: Normal status + raw: | + # unbound-control response + expected: | + # unbound-control-ts json reponse \ No newline at end of file diff --git a/tests/index.test.ts b/tests/index.test.ts deleted file mode 100644 index ba29c9f..0000000 --- a/tests/index.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, test } from "@jest/globals"; -import { hello } from "../src/index"; - -describe("hello", () => { - test("returns 'Hello, World!'", () => { - expect(hello()).toBe("Hello, World!"); - }); -}); diff --git a/tests/mockServer.ts b/tests/mockServer.ts new file mode 100644 index 0000000..65d9635 --- /dev/null +++ b/tests/mockServer.ts @@ -0,0 +1,105 @@ +import tls from "tls"; +import fs from "fs"; +import net from "net"; + +export interface MockServer { + start(response: string, options?: tls.TlsOptions): void; + stop(): Promise; +} + +export class UnixMockServer implements MockServer { + private server: net.Server | null = null; + private readonly socketPath: string; + + constructor(socketPath: string = "/tmp/mock.sock") { + this.socketPath = socketPath; + } + + start(response: string): void { + if (fs.existsSync(this.socketPath)) { + fs.unlinkSync(this.socketPath); + } + + this.server = net.createServer((socket) => { + socket.on("data", () => { + socket.write(response); + socket.end(); + }); + + socket.on("end", () => { + socket.destroy(); // 接続を完全に破棄 + }); + + socket.on("error", () => { + socket.destroy(); + }); + }); + + this.server.listen(this.socketPath, () => {}); + } + + stop(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + this.server.close((err) => { + this.server = null; + if (err) { + reject(err); + } else { + resolve(); + } + }); + } else { + resolve(); + } + }); + } +} + +export class TcpTlsMockServer implements MockServer { + private server: tls.Server | null = null; + private readonly port: number; + private readonly host: string; + private readonly tlsOptions: tls.TlsOptions; + + constructor( + host: string = "localhost", + port: number = 8080, + tlsOptions: tls.TlsOptions, + ) { + this.host = host; + this.port = port; + this.tlsOptions = tlsOptions; + } + + start(response: string): void { + this.server = tls.createServer(this.tlsOptions, (socket) => { + socket.on("data", () => { + socket.write(response); + socket.end(); + }); + + socket.on("error", () => {}); + + socket.on("end", () => {}); + }); + + this.server.listen(this.port, this.host, () => {}); + } + + stop(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + this.server.close((err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } else { + resolve(); + } + }); + } +} diff --git a/tsconfig.json b/tsconfig.json index 089dbe7..1c849c2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,8 +16,7 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true + "noFallthroughCasesInSwitch": true }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/vite.config.ts b/vite.config.ts index dd7050a..16faab7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,8 @@ export default defineConfig({ plugins: [ dts({ tsconfigPath: "./tsconfig.json", + rollupTypes: true, + outDir: "dist", }), ], }); From 42be9a3feb1d104a2b53034c500bde8cd73e84f6 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 00:13:10 +0900 Subject: [PATCH 04/24] Fix status command --- package.json | 4 ++-- src/client.ts | 2 +- tests/data/1.22.0/status.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e68b6bf..e3d7b95 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "build": "tsc && vite build", "lint": "eslint .", "format": "prettier --write .", - "test": "jest --detectOpenHandles control.test.ts", - "snapshot": "jest --detectOpenHandles control.snapshot.test.ts", + "test": "jest --detectOpenHandles tests/control.test.ts", + "snapshot": "jest --detectOpenHandles tests/control.snapshot.test.ts", "sample:esm": "node examples/index.mjs", "sample:cjs": "node examples/index.cjs", "sample": "npm run test:esm:node && npm run test:cjs:node" diff --git a/src/client.ts b/src/client.ts index 5a28045..474213d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -110,7 +110,7 @@ export class UnboundControlClient { result.options = value .replace(/[[\]]/g, "") .trim() - .split(",") + .split(" ") .map((item) => item.trim()); break; default: diff --git a/tests/data/1.22.0/status.yaml b/tests/data/1.22.0/status.yaml index a3156a0..9716a40 100644 --- a/tests/data/1.22.0/status.yaml +++ b/tests/data/1.22.0/status.yaml @@ -6,7 +6,7 @@ data: threads: 1 modules: 3 [ subnetcache validator iterator ] uptime: 3 seconds - options: [ reuseport, control(namedpipe) ] + options: [ reuseport control(namedpipe) ] unbound (pid 1) is running... expected: version: 1.22.0 From b56152f736b8be5af46b1aa6af9528c86f49e17c Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 00:14:43 +0900 Subject: [PATCH 05/24] Update readme usage --- README.md | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7fddb3b..3aab85b 100644 --- a/README.md +++ b/README.md @@ -90,22 +90,47 @@ yarn add unbound-control-ts Here's a basic example to demonstrate how to use the library: ```ts -import { UnboundControl } from 'unbound-control-ts'; +import { UnboundControlClient } from 'unbound-control-ts'; -// Initialize the client with the path to the unbound-control binary -const unbound = new UnboundControl('/path/to/unbound-control'); +const client = new UnboundControlClient('/path/to/unbound-control.sock'); -// Fetch and display Unbound statistics -async function getStats() { +(async () => { try { - const stats = await unbound.stats(); - console.log('Unbound Statistics:', stats); + const response = await client.status(); + console.log(response); } catch (error) { - console.error('Error fetching stats:', error); + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } } -} +})(); +``` -getStats(); +output: + +```json +{ + "json": { + "modules": [ + "subnetcache", + "validator", + "iterator", + ], + "options": [ + "reuseport", + "control(namedpipe)", + ], + "pid": 1, + "status": "running", + "threads": 1, + "uptime": 292, + "verbosity": 1, + "version": "1.22.0", + }, + "raw": "version: 1.22.0\nverbosity: 1\nthreads: 1\nmodules: 3 [ subnetcache validator iterator ]\nuptime: 292 seconds\noptions: reuseport control(namedpipe)\nunbound (pid 1) is running...\n", +} ``` ## Development From 35818a94ef69005c6e472efba3610230642d9f51 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 00:14:52 +0900 Subject: [PATCH 06/24] npm run format --- docker-compose.yml | 62 +++++++++++++++++------------------ tests/data/1.22.0/stats.yaml | 2 +- tests/data/1.22.0/status.yaml | 4 +-- tests/data/template.yaml | 2 +- 4 files changed, 35 insertions(+), 35 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 24845d4..a90b2eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,31 @@ -services: - unbound-setup: - image: mvance/unbound:${UNBOUND_VERSION:-latest} - volumes: - - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound - restart: "no" - entrypoint: [] - command: >- - /bin/sh -c " - if [ ! -f /opt/unbound/etc/unbound/key/unbound_server.key ]; then - echo 'Setup start' && - unbound-control-setup && - mkdir /opt/unbound/etc/unbound/key /opt/unbound/etc/unbound/socket && - mv /opt/unbound/etc/unbound/unbound_* /opt/unbound/etc/unbound/key && - echo 'Setup complete'; - else - echo 'Certificates already exist'; - fi - " - unbound: - image: mvance/unbound:${UNBOUND_VERSION:-latest} - container_name: unbound - ports: - - "53:53/tcp" - - "53:53/udp" - - "8953:8953" - volumes: - - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound - restart: unless-stopped - depends_on: - - unbound-setup +services: + unbound-setup: + image: mvance/unbound:${UNBOUND_VERSION:-latest} + volumes: + - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound + restart: "no" + entrypoint: [] + command: >- + /bin/sh -c " + if [ ! -f /opt/unbound/etc/unbound/key/unbound_server.key ]; then + echo 'Setup start' && + unbound-control-setup && + mkdir /opt/unbound/etc/unbound/key /opt/unbound/etc/unbound/socket && + mv /opt/unbound/etc/unbound/unbound_* /opt/unbound/etc/unbound/key && + echo 'Setup complete'; + else + echo 'Certificates already exist'; + fi + " + unbound: + image: mvance/unbound:${UNBOUND_VERSION:-latest} + container_name: unbound + ports: + - "53:53/tcp" + - "53:53/udp" + - "8953:8953" + volumes: + - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound + restart: unless-stopped + depends_on: + - unbound-setup diff --git a/tests/data/1.22.0/stats.yaml b/tests/data/1.22.0/stats.yaml index 0247027..3ca859b 100644 --- a/tests/data/1.22.0/stats.yaml +++ b/tests/data/1.22.0/stats.yaml @@ -58,4 +58,4 @@ data: time: now: 1731837810.163703 up: 2.858856 - elapsed: 2.858856 \ No newline at end of file + elapsed: 2.858856 diff --git a/tests/data/1.22.0/status.yaml b/tests/data/1.22.0/status.yaml index 9716a40..504bcc7 100644 --- a/tests/data/1.22.0/status.yaml +++ b/tests/data/1.22.0/status.yaml @@ -12,8 +12,8 @@ data: version: 1.22.0 verbosity: 1 threads: 1 - modules: [ subnetcache, validator, iterator ] + modules: [subnetcache, validator, iterator] uptime: 3 - options: [ reuseport, control(namedpipe) ] + options: [reuseport, control(namedpipe)] pid: 1 status: running diff --git a/tests/data/template.yaml b/tests/data/template.yaml index 0515b6d..19a63c9 100644 --- a/tests/data/template.yaml +++ b/tests/data/template.yaml @@ -3,4 +3,4 @@ data: raw: | # unbound-control response expected: | - # unbound-control-ts json reponse \ No newline at end of file + # unbound-control-ts json reponse From 24ff9052bd8f0723e8997d057c796110cfdc5f87 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 00:21:41 +0900 Subject: [PATCH 07/24] Update Jest configuration to specify coverage collection from source files --- jest.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.ts b/jest.config.ts index b20373e..99b9dc7 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -22,7 +22,7 @@ const config: Config = { collectCoverage: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, + collectCoverageFrom: ["src/**/*"], // The directory where Jest should output its coverage files coverageDirectory: "coverage", From 76900ce33771b64025d372ab83b3e0fb58c2b661 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 00:50:59 +0900 Subject: [PATCH 08/24] Add unbound server test --- .github/workflows/it.yml | 38 +++++++++++++++++++++++++++ docker-compose.yml | 2 +- package.json | 1 + tests/control.it.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/it.yml create mode 100644 tests/control.it.test.ts diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml new file mode 100644 index 0000000..fd9a740 --- /dev/null +++ b/.github/workflows/it.yml @@ -0,0 +1,38 @@ +name: Test Unbound with Multiple Versions + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test-unbound: + runs-on: ubuntu-latest + + strategy: + matrix: + unbound-version: [1.17.0, 1.18.0, 1.19.3, 1.21.1, 1.22.0] + + steps: + - uses: actions/checkout@v4 + - name: Set up Docker Compose + run: sudo apt-get update && sudo apt-get install -y docker-compose + - name: Start Unbound containers + run: UNBOUND_VERSION=${{ matrix.unbound-version }} docker-compose up -d + - name: Verify Unbound is running + run: docker ps + - name: Test Unbound + run: dig @unbound-${{ matrix.unbound-version }} example.com + - name: Use Node.js 22.x + uses: actions/setup-node@v4 + with: + node-version: 22.x + - name: Install dependencies + run: npm ci + - run: npm run test:it + - name: Stop Unbound + run: | + docker compose stop unbound-${{ matrix.unbound-version }} diff --git a/docker-compose.yml b/docker-compose.yml index a90b2eb..5d8574a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: " unbound: image: mvance/unbound:${UNBOUND_VERSION:-latest} - container_name: unbound + container_name: unbound-${UNBOUND_VERSION:-latest} ports: - "53:53/tcp" - "53:53/udp" diff --git a/package.json b/package.json index e3d7b95..19d21b3 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "lint": "eslint .", "format": "prettier --write .", "test": "jest --detectOpenHandles tests/control.test.ts", + "test:it": "jest --detectOpenHandles tests/control.it.test.ts", "snapshot": "jest --detectOpenHandles tests/control.snapshot.test.ts", "sample:esm": "node examples/index.mjs", "sample:cjs": "node examples/index.cjs", diff --git a/tests/control.it.test.ts b/tests/control.it.test.ts new file mode 100644 index 0000000..6cfbdc9 --- /dev/null +++ b/tests/control.it.test.ts @@ -0,0 +1,56 @@ +import { UnboundControlClient } from "../src/index"; +import fs from "fs"; +import path from "path"; +import YAML from "yaml"; + +const baseDir = path.resolve(__dirname); +const unboundVersion = process.env.UNBOUND_VERSION || "1.22.0"; +const dataDir = path.join(baseDir, "data", unboundVersion); + +interface ParsedData { + data: TestCase[]; +} + +interface TestCase { + title: string; + raw: string; + expected: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// interface ResponseData { +// raw: string; +// json: any; // eslint-disable-line @typescript-eslint/no-explicit-any +// } + +describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let client: UnboundControlClient; + const unixSocketPath = path.join( + baseDir, + "../unbound-config/unix/socket/unbound.ctl", + ); + + beforeAll(() => { + client = new UnboundControlClient(unixSocketPath); + }); + + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".yaml")); + + for (const file of files) { + const command = file.replace(".yaml", ""); + const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); + const contents = YAML.parse(fileContent) as ParsedData; + + // TODO: Check json schema + for (const { title } of contents.data) { + it(`${command} test: ${title}`, async () => { + await expect( + client[command as keyof UnboundControlClient](), + ).resolves.not.toThrow(); + + await client.disconnect(); + }); + } + } +}); From 87a12e74e1ca04b826b4db47440fddc1d0e8396f Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:01:27 +0900 Subject: [PATCH 09/24] Update Unbound configuration to support dynamic DNS and control ports --- .env | 4 ++++ .github/workflows/it.yml | 10 ++++++++-- docker-compose.yml | 6 +++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.env b/.env index e38b604..3632bdd 100644 --- a/.env +++ b/.env @@ -6,3 +6,7 @@ # Unbound Version # default: latest # UNBOUND_VERSION=latest + +# Unbound DNS, Control Port +# UNBOUND_DNS_PORT=53 +# UNBOUND_CONTROL_PORT=8953 \ No newline at end of file diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index fd9a740..d3bc35c 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -21,11 +21,17 @@ jobs: - name: Set up Docker Compose run: sudo apt-get update && sudo apt-get install -y docker-compose - name: Start Unbound containers - run: UNBOUND_VERSION=${{ matrix.unbound-version }} docker-compose up -d + run: | + UNBOUND_VERSION=${{ matrix.unbound-version }} \ + UNBOUND_DNS_PORT=$((5300 + ${{ matrix.unbound-version | split(".") | nth(1) | int }})) \ + UNBOUND_CONTROL_PORT=$((8953 + ${{ matrix.unbound-version | split(".") | nth(1) | int }})) \ + docker-compose up -d - name: Verify Unbound is running run: docker ps - name: Test Unbound - run: dig @unbound-${{ matrix.unbound-version }} example.com + run: | + UNBOUND_DNS_PORT=$((5300 + ${{ matrix.unbound-version | split(".") | nth(1) | int }})) \ + dig @unbound-${{ matrix.unbound-version }} -p $UNBOUND_PORT example.com - name: Use Node.js 22.x uses: actions/setup-node@v4 with: diff --git a/docker-compose.yml b/docker-compose.yml index 5d8574a..106517b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,9 +21,9 @@ services: image: mvance/unbound:${UNBOUND_VERSION:-latest} container_name: unbound-${UNBOUND_VERSION:-latest} ports: - - "53:53/tcp" - - "53:53/udp" - - "8953:8953" + - "${UNBOUND_DNS_PORT:-53}:53/tcp" + - "${UNBOUND_DNS_PORT:-53}:53/udp" + - "${UNBOUND_CONTROL_PORT:-8953}:8953" volumes: - ${UNBOUND_CONF_PATH:-./unbound-config/unix}:/opt/unbound/etc/unbound restart: unless-stopped From d638fd1f61daec888d610c9a99b3468d881581fa Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:06:11 +0900 Subject: [PATCH 10/24] Update GitHub Actions workflow to specify Unbound ports for each version --- .github/workflows/it.yml | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index d3bc35c..5029826 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -14,7 +14,22 @@ jobs: strategy: matrix: - unbound-version: [1.17.0, 1.18.0, 1.19.3, 1.21.1, 1.22.0] + include: + - unbound-version: 1.18.0 + unbound-port: 5318 + unbound-control-port: 8918 + - unbound-version: 1.19.3 + unbound-port: 5319 + unbound-control-port: 8919 + - unbound-version: 1.20.0 + unbound-port: 5320 + unbound-control-port: 8920 + - unbound-version: 1.21.1 + unbound-port: 5321 + unbound-control-port: 8921 + - unbound-version: 1.22.0 + unbound-port: 5322 + unbound-control-port: 8922 steps: - uses: actions/checkout@v4 @@ -23,15 +38,14 @@ jobs: - name: Start Unbound containers run: | UNBOUND_VERSION=${{ matrix.unbound-version }} \ - UNBOUND_DNS_PORT=$((5300 + ${{ matrix.unbound-version | split(".") | nth(1) | int }})) \ - UNBOUND_CONTROL_PORT=$((8953 + ${{ matrix.unbound-version | split(".") | nth(1) | int }})) \ + UNBOUND_DNS_PORT=${{ matrix.unbound-port }} \ + UNBOUND_CONTROL_PORT=${{ matrix.unbound-control-port }} \ docker-compose up -d - name: Verify Unbound is running run: docker ps - name: Test Unbound run: | - UNBOUND_DNS_PORT=$((5300 + ${{ matrix.unbound-version | split(".") | nth(1) | int }})) \ - dig @unbound-${{ matrix.unbound-version }} -p $UNBOUND_PORT example.com + dig @unbound-${{ matrix.unbound-version }} -p ${{ matrix.unbound-port }} example.com - name: Use Node.js 22.x uses: actions/setup-node@v4 with: From f03409d618a900fc65835a23e8172fb47657a11c Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:08:08 +0900 Subject: [PATCH 11/24] Fix dig --- .github/workflows/it.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index 5029826..9df594d 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -45,7 +45,7 @@ jobs: run: docker ps - name: Test Unbound run: | - dig @unbound-${{ matrix.unbound-version }} -p ${{ matrix.unbound-port }} example.com + dig @localhost -p ${{ matrix.unbound-port }} example.com - name: Use Node.js 22.x uses: actions/setup-node@v4 with: From 062fdcdc26464f88f2def912d6ca3e8a74f126c4 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:15:12 +0900 Subject: [PATCH 12/24] Add health check for Unbound in GitHub Actions workflow --- .github/workflows/it.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index 9df594d..f1f77f3 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -33,8 +33,10 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Docker Compose run: sudo apt-get update && sudo apt-get install -y docker-compose + - name: Start Unbound containers run: | UNBOUND_VERSION=${{ matrix.unbound-version }} \ @@ -43,16 +45,37 @@ jobs: docker-compose up -d - name: Verify Unbound is running run: docker ps + + - name: Wait for Unbound to become healthy + run: | + echo "Waiting for Unbound to be ready..." + for i in {1..30}; do + HEALTH=$(docker inspect --format='{{.State.Health.Status}}' unbound-${{ matrix.unbound-version }}) + if [ "$HEALTH" == "healthy" ]; then + echo "Unbound is healthy!" + break + fi + echo "Unbound is not ready yet. Waiting..." + sleep 2 + done + if [ "$HEALTH" != "healthy" ]; then + echo "Unbound did not become healthy in time." + exit 1 + fi + - name: Test Unbound run: | dig @localhost -p ${{ matrix.unbound-port }} example.com + - name: Use Node.js 22.x uses: actions/setup-node@v4 with: node-version: 22.x + - name: Install dependencies run: npm ci - run: npm run test:it + - name: Stop Unbound run: | docker compose stop unbound-${{ matrix.unbound-version }} From bb4c9750efed9b9877894a43989c55b0ba4310f6 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:22:25 +0900 Subject: [PATCH 13/24] Remove commented-out Unbound version configurations in GitHub Actions workflow --- .github/workflows/it.yml | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index f1f77f3..649c318 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -15,18 +15,20 @@ jobs: strategy: matrix: include: - - unbound-version: 1.18.0 - unbound-port: 5318 - unbound-control-port: 8918 - - unbound-version: 1.19.3 - unbound-port: 5319 - unbound-control-port: 8919 - - unbound-version: 1.20.0 - unbound-port: 5320 - unbound-control-port: 8920 - - unbound-version: 1.21.1 - unbound-port: 5321 - unbound-control-port: 8921 + # TODO: Multiple versions of Unbound can be tested by adding more versions here. + # - fix: unbound.conf, test file's socket path + # - unbound-version: 1.18.0 + # unbound-port: 5318 + # unbound-control-port: 8918 + # - unbound-version: 1.19.3 + # unbound-port: 5319 + # unbound-control-port: 8919 + # - unbound-version: 1.20.0 + # unbound-port: 5320 + # unbound-control-port: 8920 + # - unbound-version: 1.21.1 + # unbound-port: 5321 + # unbound-control-port: 8921 - unbound-version: 1.22.0 unbound-port: 5322 unbound-control-port: 8922 From 111d94a3ba87f52ecc8da86da0ed9ac2240e9c70 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:30:32 +0900 Subject: [PATCH 14/24] Set permissions for Unix socket in GitHub Actions workflow --- .github/workflows/it.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index 649c318..4003f90 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -65,6 +65,9 @@ jobs: exit 1 fi + - name: Set permissions for Unix socket + run: chmod 777 "$GITHUB_WORKSPACE/unbound-config/unix/socket/unbound.ctl" + - name: Test Unbound run: | dig @localhost -p ${{ matrix.unbound-port }} example.com From d238c399999a055aa9f672349434461d018149b6 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 01:43:19 +0900 Subject: [PATCH 15/24] Comment out it --- .github/workflows/it.yml | 154 ++++++++++++++++++++------------------- 1 file changed, 80 insertions(+), 74 deletions(-) diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml index 4003f90..1fddac0 100644 --- a/.github/workflows/it.yml +++ b/.github/workflows/it.yml @@ -1,86 +1,92 @@ -name: Test Unbound with Multiple Versions +# TODO: Fix Unix domain socket permissions -on: - push: - branches: - - main - pull_request: - branches: - - main +# name: Test Unbound with Multiple Versions -jobs: - test-unbound: - runs-on: ubuntu-latest +# on: +# push: +# branches: +# - main +# pull_request: +# branches: +# - main - strategy: - matrix: - include: - # TODO: Multiple versions of Unbound can be tested by adding more versions here. - # - fix: unbound.conf, test file's socket path - # - unbound-version: 1.18.0 - # unbound-port: 5318 - # unbound-control-port: 8918 - # - unbound-version: 1.19.3 - # unbound-port: 5319 - # unbound-control-port: 8919 - # - unbound-version: 1.20.0 - # unbound-port: 5320 - # unbound-control-port: 8920 - # - unbound-version: 1.21.1 - # unbound-port: 5321 - # unbound-control-port: 8921 - - unbound-version: 1.22.0 - unbound-port: 5322 - unbound-control-port: 8922 +# jobs: +# test-unbound: +# runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 +# strategy: +# matrix: +# include: +# # TODO: Multiple versions of Unbound can be tested by adding more versions here. +# # - fix: unbound.conf, test file's socket path +# # - unbound-version: 1.18.0 +# # unbound-port: 5318 +# # unbound-control-port: 8918 +# # - unbound-version: 1.19.3 +# # unbound-port: 5319 +# # unbound-control-port: 8919 +# # - unbound-version: 1.20.0 +# # unbound-port: 5320 +# # unbound-control-port: 8920 +# # - unbound-version: 1.21.1 +# # unbound-port: 5321 +# # unbound-control-port: 8921 +# - unbound-version: 1.22.0 +# unbound-port: 5322 +# unbound-control-port: 8922 - - name: Set up Docker Compose - run: sudo apt-get update && sudo apt-get install -y docker-compose +# steps: +# - uses: actions/checkout@v4 - - name: Start Unbound containers - run: | - UNBOUND_VERSION=${{ matrix.unbound-version }} \ - UNBOUND_DNS_PORT=${{ matrix.unbound-port }} \ - UNBOUND_CONTROL_PORT=${{ matrix.unbound-control-port }} \ - docker-compose up -d - - name: Verify Unbound is running - run: docker ps +# - name: Set up Docker Compose +# run: sudo apt-get update && sudo apt-get install -y docker-compose - - name: Wait for Unbound to become healthy - run: | - echo "Waiting for Unbound to be ready..." - for i in {1..30}; do - HEALTH=$(docker inspect --format='{{.State.Health.Status}}' unbound-${{ matrix.unbound-version }}) - if [ "$HEALTH" == "healthy" ]; then - echo "Unbound is healthy!" - break - fi - echo "Unbound is not ready yet. Waiting..." - sleep 2 - done - if [ "$HEALTH" != "healthy" ]; then - echo "Unbound did not become healthy in time." - exit 1 - fi +# - name: Start Unbound containers +# run: | +# UNBOUND_VERSION=${{ matrix.unbound-version }} \ +# UNBOUND_DNS_PORT=${{ matrix.unbound-port }} \ +# UNBOUND_CONTROL_PORT=${{ matrix.unbound-control-port }} \ +# docker-compose up -d +# - name: Verify Unbound is running +# run: docker ps - - name: Set permissions for Unix socket - run: chmod 777 "$GITHUB_WORKSPACE/unbound-config/unix/socket/unbound.ctl" +# - name: Wait for Unbound to become healthy +# run: | +# echo "Waiting for Unbound to be ready..." +# for i in {1..30}; do +# HEALTH=$(docker inspect --format='{{.State.Health.Status}}' unbound-${{ matrix.unbound-version }}) +# if [ "$HEALTH" == "healthy" ]; then +# echo "Unbound is healthy!" +# break +# fi +# echo "Unbound is not ready yet. Waiting..." +# sleep 2 +# done +# if [ "$HEALTH" != "healthy" ]; then +# echo "Unbound did not become healthy in time." +# exit 1 +# fi - - name: Test Unbound - run: | - dig @localhost -p ${{ matrix.unbound-port }} example.com +# - name: Fix permissions inside container +# run: | +# docker exec unbound-${{ matrix.unbound-version }} chmod 777 /opt/unbound/etc/unbound/socket/unbound.ctl - - name: Use Node.js 22.x - uses: actions/setup-node@v4 - with: - node-version: 22.x +# - name: Set permissions for Unix socket +# run: ls -Rl "$GITHUB_WORKSPACE/unbound-config" - - name: Install dependencies - run: npm ci - - run: npm run test:it +# - name: Test Unbound +# run: | +# dig @localhost -p ${{ matrix.unbound-port }} example.com - - name: Stop Unbound - run: | - docker compose stop unbound-${{ matrix.unbound-version }} +# - name: Use Node.js 22.x +# uses: actions/setup-node@v4 +# with: +# node-version: 22.x + +# - name: Install dependencies +# run: npm ci +# - run: npm run test:it + +# - name: Stop Unbound +# run: | +# docker compose stop unbound-${{ matrix.unbound-version }} From 63269fbe33218eec16e6c24ca2c2f7e4c2581dc8 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 21:27:48 +0900 Subject: [PATCH 16/24] Update Unbound configuration and enhance test cases with options and exceptions --- src/client.ts | 616 +++++++++++++++++++++++++++++-- tests/control.it.test.ts | 33 +- tests/control.snapshot.test.ts | 42 ++- tests/control.test.ts | 36 +- tests/data/1.22.0/stats.yaml | 378 ++++++++++++++++++- tests/data/1.22.0/verbosity.yaml | 12 + tests/data/template.yaml | 2 + unbound-config/unix/unbound.conf | 1 + 8 files changed, 1057 insertions(+), 63 deletions(-) create mode 100644 tests/data/1.22.0/verbosity.yaml diff --git a/src/client.ts b/src/client.ts index 474213d..217079e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,24 +1,104 @@ import { UnboundControl } from "./control"; import { ParseError } from "./error"; +/** + * A list of valid configuration options for the `set_option` command. + */ +export type ValidOption = + | "statistics-interval" + | "statistics-cumulative" + | "do-not-query-localhost" + | "harden-short-bufsize" + | "harden-large-queries" + | "harden-glue" + | "harden-dnssec-stripped" + | "harden-below-nxdomain" + | "harden-referral-path" + | "prefetch" + | "prefetch-key" + | "log-queries" + | "hide-identity" + | "hide-version" + | "identity" + | "version" + | "val-log-level" + | "val-log-squelch" + | "ignore-cd-flag" + | "add-holddown" + | "del-holddown" + | "keep-missing" + | "tcp-upstream" + | "ssl-upstream" + | "max-udp-size" + | "ratelimit" + | "ip-ratelimit" + | "cache-max-ttl" + | "cache-min-ttl" + | "cache-max-negative-ttl"; + export interface Response { raw: string; json: any; // eslint-disable-line @typescript-eslint/no-explicit-any } -export interface StatusResponse { - version: string; - verbosity: number; - threads: number; - modules: string[]; - uptime: number; - options: string[]; - pid: number; - status: string; -} +// export interface StatusResponse { +// version: string; +// verbosity: number; +// threads: number; +// modules: string[]; +// uptime: number; +// options: string[]; +// pid: number; +// status: string; +// } + +// export interface StasResponse { +// total: { +// num: { +// queries: number; +// queries_ip_ratelimited: number; +// queries_cookie_valid: number; +// queries_cookie_client: number; +// queries_cookie_invalid: number; +// cachehits: number; +// cachemiss: number; +// prefetch: number; +// queries_timed_out: number; +// expired: number; +// recursivereplies: number; +// }; +// query: { +// queue_time_us: { +// max: number; +// }; +// }; +// requestlist: { +// avg: number; +// max: number; +// overwritten: number; +// exceeded: number; +// current: { +// all: number; +// user: number; +// }; +// }; +// recursion: { +// time: { +// avg: number; +// median: number; +// }; +// }; +// tcpusage: number; +// }; +// time: { +// now: number; +// up: number; +// elapsed: number; +// }; +// } export interface NestedRecord { - [key: string]: string | number | NestedRecord; + [key: string]: string | number | string[] | NestedRecord; } export class UnboundControlClient { @@ -33,6 +113,47 @@ export class UnboundControlClient { this.control = new UnboundControl(unixSocketName); } + /** + * Checks if the provided command is valid. + * + * This function parses the response from the `unbound-control` command to identify + * whether the command is unsupported or invalid in the current version of the software. + * + * @param response - The response received from the `unbound-control` command. + * @returns `true` if the command is valid. + * @throws {CommandError} - Throws an error if the command is invalid or not supported. + */ + // private checkValidCommand(response: string): boolean { + // const match = response.match(/error unknown command '(.+)'/); + // if (match) { + // throw new UnsupportedCommandError(`Unknown command: ${match[1]}`); + // } + + // return true; + // } + + /** + * Checks if the provided IP address is valid. + * @param address - The IP address to check. + * @returns `true` if the IP address is valid. + * @throws {ParseError} - Throws an error if the IP address is invalid. + */ + private checkValidIp(address: string): boolean { + if (address === "all") { + return true; + } + + if (address === "off") { + return true; + } + + if (!address.match(/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) { + throw new ParseError(`Invalid IP address: ${address}`); + } + + return true; + } + private parseRawToJSON(raw: string): NestedRecord { const lines = raw.split("\n").filter((line) => line.trim() !== ""); const result: NestedRecord = {}; @@ -44,13 +165,25 @@ export class UnboundControlClient { throw new ParseError(`Invalid key-value pair: ${line}`); } + // Check if the key starts with "histogram" + if (key.startsWith("histogram")) { + if (!result["histogram"]) { + result["histogram"] = {}; + } + + const histogram = result["histogram"] as NestedRecord; + const histogramKey = key.replace("histogram.", ""); + histogram[histogramKey] = isNaN(Number(value)) ? value : Number(value); + continue; + } + const keys = key.split("."); let current: NestedRecord = result; for (let i = 0; i < keys.length - 1; i++) { const k = keys[i]; if (!(k in current)) { - current[k] = {}; // 中間オブジェクトを作成 + current[k] = {}; } current = current[k] as NestedRecord; } @@ -74,14 +207,109 @@ export class UnboundControlClient { // ==================== Unbound control commands ==================== /** - * Display server status - * @returns {Promise} + * Start the server. + */ + public async start(): Promise { + return this.control.sendCommand("start"); + } + + /** + * Stop the server. The server daemon exits. + */ + public async stop(): Promise { + return this.control.sendCommand("stop"); + } + + /** + * Reload the server. This flushes the cache and reads the config file fresh. + */ + public async reload(): Promise { + return this.control.sendCommand("reload"); + } + + /** + * Reload the server but try to keep the RRset and message cache if (re)configuration allows for it. + */ + public async reload_keep_cache(): Promise { + return this.control.sendCommand("reload_keep_cache"); + } + + /** + * Change verbosity value for logging. + * + * @param level - The verbosity level to set. Valid values are 0 to 5: + * - 0: No verbosity, only errors. + * - 1: Operational information. + * - 2: Detailed operational information. + * - 3: Query-level information. + * - 4: Algorithm-level information. + * - 5: Logs client identification for cache misses. + * + */ + public async verbosity(level: number): Promise { + const raw = await this.control.sendCommand(`verbosity ${level.toString()}`); + const lines = raw.split("\n"); + + if (level < 0 || level >= 6 || level % 1 !== 0) { + throw new ParseError(`Invalid verbosity level: ${level.toString()}`); + } + + if (lines[0].startsWith("error")) { + throw new ParseError(`Invalid verbosity level: ${level.toString()}`); + } + + if (lines.length === 0) { + throw new ParseError("No response received."); + } + + if (lines[0] === "ok") { + return { + raw: raw, + json: { status: "ok" }, + }; + } + + throw new ParseError(`Invalid response: ${raw}`); + } + + /** + * Reopen the logfile, close and open it. + */ + public async log_reopen(): Promise { + return this.control.sendCommand("log_reopen"); + } + + /** + * Print statistics. + */ + public async stats(): Promise { + const raw = await this.control.sendCommand("stats"); + const fixRaw = raw.replace(/num.query.tls=/g, "num.query.tls.num="); + return { + raw: raw, + json: this.parseRawToJSON(fixRaw), + }; + } + + /** + * Peek at statistics. Prints them like the stats command does, but does not reset the internal counters to zero. + */ + public async stats_noreset(): Promise { + const raw = await this.control.sendCommand("stats_noreset"); + return { + raw: raw, + json: this.parseRawToJSON(raw), + }; + } + + /** + * Display server status. Exit code 3 if not running (the connection to the port is refused), 1 on error, 0 if running. */ public async status(): Promise { const raw = await this.control.sendCommand("status"); const lines = raw.split("\n"); - const result: Partial = {}; + const result: Partial = {}; for (const line of lines) { const [key, value] = line.split(":").map((s) => s.trim()); @@ -130,19 +358,357 @@ export class UnboundControlClient { }; } - public async stats(): Promise { - const raw = await this.control.sendCommand("stats"); - return { - raw: raw, - json: this.parseRawToJSON(raw), - }; + // public async local_zone(name: string, type: string): Promise {} + + // public async local_zone_remove(name: string): Promise {} + + // public async local_data(rr: string, data: unknown): Promise {} + + public async local_data_remove(name: string): Promise { + return this.control.sendCommand(`local_data_remove ${name}`); } - public async reload(): Promise { - return this.control.sendCommand("reload"); + /** + * Add local zones read from stdin of unbound-control. + */ + public async local_zones(): Promise { + return this.control.sendCommand("local_zones"); } - public async stop(): Promise { - return this.control.sendCommand("stop"); + /** + * Remove local zones read from stdin of unbound-control. Input is one name per line. For bulk removals. + */ + public async local_zones_remove(): Promise { + return this.control.sendCommand("local_zones_remove"); + } + + public async dump_cache(): Promise { + return this.control.sendCommand("dump_cache"); + } + + public async load_cache(): Promise { + return this.control.sendCommand("load_cache"); + } + + public async lookup(name: string): Promise { + return this.control.sendCommand(`lookup ${name}`); + } + + /** + * Remove the name from the cache. + * + * @param name - The name to remove from the cache. + * @param useCachedb - Whether to also flush the name from `cachedb` cache. Defaults to `false`. + */ + public async flush( + name: string, + useCachedb: boolean = false, + ): Promise { + const command = useCachedb ? `flush +c ${name}` : `flush ${name}`; + return this.control.sendCommand(command); + } + + /** + * Remove the name, type information from the cache. + * @param name - The name to remove from the cache. + * @param type - The type of the record to remove. + * @param useCachedb - Whether to also flush the name from `cachedb` cache. Defaults to `false`. + */ + public async flush_type( + name: string, + type: string, + useCachedb: boolean = false, + ): Promise { + const command = useCachedb + ? `flush_type +c ${name} ${type}` + : `flush_type ${name} ${type}`; + return this.control.sendCommand(command); + } + + /** + * Remove all information at or below the name from the cache. + * @param name + * @param useCachedb + */ + public async flush_zone( + name: string, + useCachedb: boolean = false, + ): Promise { + const command = useCachedb ? `flush_zone +c ${name}` : `flush_zone ${name}`; + return this.control.sendCommand(command); + } + + public async flush_bogus(useCachedb: boolean = false): Promise { + const command = useCachedb ? "flush_bogus +c" : "flush_bogus"; + return this.control.sendCommand(command); + } + + public async flush_negative(useCachedb: boolean = false): Promise { + const command = useCachedb ? "flush_negative +c" : "flush_negative"; + return this.control.sendCommand(command); + } + + public async flush_stats(): Promise { + return this.control.sendCommand("flush_stats"); + } + + public async flush_requestlist(): Promise { + return this.control.sendCommand("flush_requestlist"); + } + + public async dump_requestlist(): Promise { + return this.control.sendCommand("dump_requestlist"); + } + + public async flush_infra(address: string): Promise { + this.checkValidIp(address); + return this.control.sendCommand(`flush_infra ${address}`); + } + + /** + * Show the contents of the infra cache. + */ + public async dump_infra(): Promise { + return this.control.sendCommand("dump_infra"); + } + + /** + * Set the option to the given value without a reload. + * @param option - The configuration option to set. Must be one of the predefined valid options. + * @param value - The value to assign to the option. The type and range depend on the option. + */ + public async set_option(option: ValidOption, value: string): Promise { + return this.control.sendCommand(`set_option ${option}: ${value}`); + } + + /** + * Get the value of the option. + * @param option + * @returns + */ + public async get_option(option: ValidOption): Promise { + return this.control.sendCommand(`get_option ${option}`); + } + + public async list_stubs(): Promise { + return this.control.sendCommand("list_stubs"); + } + + public async list_forwards(): Promise { + return this.control.sendCommand("list_forwards"); + } + + public async list_insecure(): Promise { + return this.control.sendCommand("list_insecure"); + } + + public async list_local_zones(): Promise { + return this.control.sendCommand("list_local_zones"); + } + + public async list_local_data(): Promise { + return this.control.sendCommand("list_local_data"); + } + + public async insecure_add(zone: string): Promise { + return this.control.sendCommand(`insecure_add ${zone}`); + } + + public async insecure_remove(zone: string): Promise { + return this.control.sendCommand(`insecure_remove ${zone}`); + } + + /** + * Add a new forward zone to running Unbound. + * + * @param zone - The zone name to forward (e.g., "example.com"). + * @param addresses - A list of forward addresses. These can be IPv4, IPv6, or nameserver names. + * @param insecure - Whether to mark the zone as domain-insecure. Defaults to `false`. + * @param useTLS - Whether to use TLS for upstream communication. Defaults to `false`. + * @returns A promise that resolves with the server's response. + * @throws {Error} - If the command fails or invalid parameters are provided. + */ + public async forward_add( + zone: string, + addresses: string[], + insecure: boolean = false, + useTLS: boolean = false, + ): Promise { + if (addresses.length === 0) { + throw new ParseError("At least one address must be provided."); + } + + for (const address of addresses) { + this.checkValidIp(address); + } + + // Build the command string with optional flags + const flags = `${insecure ? "+i" : ""}${useTLS ? "+t" : ""}`; + const command = + `forward_add ${flags} ${zone} ${addresses.join(" ")}`.trim(); + + return this.control.sendCommand(command); + } + + public async forward_remove( + zone: string, + insecure: boolean = false, + ): Promise { + const flags = insecure ? "+i" : ""; + return this.control.sendCommand(`forward_remove ${flags} ${zone}`); + } + + /** + * Add a new stub zone to running Unbound. + * + * @param zone - The stub zone name (e.g., "example.com"). + * @param addresses - A list of stub zone addresses. These can be IPv4, IPv6, or nameserver names. + * @param insecure - Whether to mark the zone as domain-insecure. Defaults to `false`. + * @param prime - Whether to set the stub zone as prime. Defaults to `false`. + * @param useTLS - Whether to use TLS for upstream communication. Defaults to `false`. + */ + public async stub_add( + zone: string, + addresses: string[], + insecure: boolean = false, + prime: boolean = false, + useTLS: boolean = false, + ): Promise { + if (addresses.length === 0) { + throw new ParseError("At least one address must be provided."); + } + + for (const address of addresses) { + this.checkValidIp(address); + } + + // Build the command string with optional flags + const flags = `${insecure ? "+i" : ""}${prime ? "+p" : ""}${useTLS ? "+t" : ""}`; + const command = `stub_add ${flags} ${zone} ${addresses.join(" ")}`.trim(); + + return this.control.sendCommand(command); + } + + public async stub_remove( + zone: string, + insecure: boolean = false, + ): Promise { + const flags = insecure ? "+i" : ""; + return this.control.sendCommand(`stub_remove ${flags} ${zone}`); + } + + /** + * Setup forwarding mode. + */ + public async forward(addresses: string | string[]): Promise { + if (typeof addresses === "string") { + this.checkValidIp(addresses); + return this.control.sendCommand(`forward ${addresses}`); + } + + for (const address of addresses) { + this.checkValidIp(address); + } + + return this.control.sendCommand(`forward ${addresses.join(" ")}`); + } + + /** + * List the domains that are ratelimited. + * + * @param allDomains - Whether to include all domains (not just rate-limited ones). Defaults to `false`. + */ + public async ratelimit_list(allDomains: boolean = false): Promise { + const command = `ratelimit_list ${allDomains ? "+a" : ""}`.trim(); + return this.control.sendCommand(command); + } + + public async ip_ratelimit_list(allDomains: boolean = false): Promise { + const command = `ip_ratelimit_list ${allDomains ? "+a" : ""}`.trim(); + return this.control.sendCommand(command); + } + + public async list_auth_zones(): Promise { + return this.control.sendCommand("list_auth_zones"); + } + + public async auth_zone_reload(zone: string): Promise { + return this.control.sendCommand(`auth_zone_reload ${zone}`); + } + + public async auth_zone_transfer(zone: string): Promise { + return this.control.sendCommand(`auth_zone_transfer ${zone}`); + } + + public async rpz_enable(zone: string): Promise { + return this.control.sendCommand(`rpz_enable ${zone}`); + } + + public async rpz_disable(zone: string): Promise { + return this.control.sendCommand(`rpz_disable ${zone}`); + } + + public async view_list_local_zones(view: string): Promise { + return this.control.sendCommand(`view_list_local_zones ${view}`); + } + + public async view_local_zone( + view: string, + name: string, + type: string, + ): Promise { + return this.control.sendCommand(`view_local_zone ${view} ${name} ${type}`); + } + + public async view_local_zone_remove( + view: string, + name: string, + ): Promise { + return this.control.sendCommand(`view_local_zone_remove ${view} ${name}`); + } + + public async view_list_local_data(view: string): Promise { + return this.control.sendCommand(`view_list_local_data ${view}`); + } + + public async view_local_data( + view: string, + rr: string, + data: string[], + ): Promise { + return this.control.sendCommand( + `view_local_data ${view} ${rr} ${data.join(" ")}`.trim(), + ); + } + + public async view_local_data_remove( + view: string, + name: string, + ): Promise { + return this.control.sendCommand(`view_local_data_remove ${view} ${name}`); + } + + public async view_local_datas_remove(view: string): Promise { + return this.control.sendCommand(`view_local_datas_remove ${view}`); + } + + public async view_local_datas(view: string): Promise { + return this.control.sendCommand(`view_local_datas ${view}`); + } + + public async add_cookie_secret(secret: string): Promise { + return this.control.sendCommand(`add_cookie_secret ${secret}`); + } + + public async drop_cookie_secret(): Promise { + return this.control.sendCommand("drop_cookie_secret"); + } + + public async activate_cookie_secret(): Promise { + return this.control.sendCommand("activate_cookie_secret"); + } + + public async print_cookie_secrets(): Promise { + return this.control.sendCommand("print_cookie_secrets"); } } diff --git a/tests/control.it.test.ts b/tests/control.it.test.ts index 6cfbdc9..f390d36 100644 --- a/tests/control.it.test.ts +++ b/tests/control.it.test.ts @@ -13,15 +13,12 @@ interface ParsedData { interface TestCase { title: string; + options: any; // eslint-disable-line @typescript-eslint/no-explicit-any raw: string; expected: any; // eslint-disable-line @typescript-eslint/no-explicit-any + exception: string; } -// interface ResponseData { -// raw: string; -// json: any; // eslint-disable-line @typescript-eslint/no-explicit-any -// } - describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { let client: UnboundControlClient; const unixSocketPath = path.join( @@ -42,13 +39,29 @@ describe(`Unix domain socket docker server tests. Unbound version: ${unboundVers const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); const contents = YAML.parse(fileContent) as ParsedData; - // TODO: Check json schema - for (const { title } of contents.data) { + for (const { title, options, exception } of contents.data) { it(`${command} test: ${title}`, async () => { - await expect( - client[command as keyof UnboundControlClient](), - ).resolves.not.toThrow(); + console.log(`Running ${command} test: ${title}`); + + const method = client[command as keyof UnboundControlClient].bind( + client, + ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + if (typeof method !== "function") { + throw new Error(`Invalid command: ${command}`); + } + + const args = + options !== undefined + ? Array.isArray(options) + ? options + : [options] + : []; + if (exception) { + await expect(method.apply(client, args)).rejects.toThrow(exception); + } else { + await expect(method.apply(client, args)).resolves.not.toThrow(); + } await client.disconnect(); }); } diff --git a/tests/control.snapshot.test.ts b/tests/control.snapshot.test.ts index cfb1894..dada18d 100644 --- a/tests/control.snapshot.test.ts +++ b/tests/control.snapshot.test.ts @@ -13,13 +13,10 @@ interface ParsedData { interface TestCase { title: string; + options: any; // eslint-disable-line @typescript-eslint/no-explicit-any raw: string; expected: any; // eslint-disable-line @typescript-eslint/no-explicit-any -} - -interface ResponseData { - raw: string; - json: any; // eslint-disable-line @typescript-eslint/no-explicit-any + exception: string; } describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { @@ -42,16 +39,33 @@ describe(`Unix domain socket docker server tests. Unbound version: ${unboundVers const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); const contents = YAML.parse(fileContent) as ParsedData; - for (const { title } of contents.data) { + for (const { title, options } of contents.data) { it(`${command} test: ${title}`, async () => { - const result = (await client[ - command as keyof UnboundControlClient - ]()) as ResponseData; - - expect(result).toMatchSnapshot(); - // expect(result.raw).toEqual(raw); - // expect(result.json).toEqual(expected); - await client.disconnect(); + console.log(`Running ${command} test: ${title}`); + + const method = client[command as keyof UnboundControlClient].bind( + client, + ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + if (typeof method !== "function") { + throw new Error(`Invalid command: ${command}`); + } + + const args = + options !== undefined + ? Array.isArray(options) + ? options + : [options] + : []; + + try { + // メソッド呼び出し + const result = await method.apply(client, args); // eslint-disable-line @typescript-eslint/no-unsafe-assignment + expect(result).toMatchSnapshot(); + } catch (error) { + expect((error as Error).message).toMatchSnapshot(); + } finally { + await client.disconnect(); + } }); } } diff --git a/tests/control.test.ts b/tests/control.test.ts index 714065a..c26d6d5 100644 --- a/tests/control.test.ts +++ b/tests/control.test.ts @@ -14,11 +14,13 @@ interface ParsedData { interface TestCase { title: string; + options: any; // eslint-disable-line @typescript-eslint/no-explicit-any raw: string; expected: any; // eslint-disable-line @typescript-eslint/no-explicit-any + exception: string; } -interface ResponseData { +interface Response { raw: string; json: any; // eslint-disable-line @typescript-eslint/no-explicit-any } @@ -49,15 +51,35 @@ describe(`Unix domain socket mock server tests. Unbound version: ${unboundVersio const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); const contents = YAML.parse(fileContent) as ParsedData; - for (const { title, raw, expected } of contents.data) { + for (const { title, options, raw, expected, exception } of contents.data) { it(`${command} test: ${title}`, async () => { server.start(raw); - const result = (await client[ - command as keyof UnboundControlClient - ]()) as ResponseData; - expect(result.raw).toEqual(raw); - expect(result.json).toEqual(expected); + const method = client[command as keyof UnboundControlClient].bind( + client, + ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + if (typeof method !== "function") { + throw new Error(`Invalid command: ${command}`); + } + + const args = + options !== undefined + ? Array.isArray(options) + ? options + : [options] + : []; + + let result; + + if (exception) { + await expect(method.apply(client, args)).rejects.toThrow(exception); + } else { + result = await method.apply(client, args); + expect(result.raw).toEqual(raw); + expect(result.json).toEqual(expected); + } + + await client.disconnect(); }); } } diff --git a/tests/data/1.22.0/stats.yaml b/tests/data/1.22.0/stats.yaml index 3ca859b..baf8c18 100644 --- a/tests/data/1.22.0/stats.yaml +++ b/tests/data/1.22.0/stats.yaml @@ -1,6 +1,27 @@ data: - - title: Start up + - title: Default raw: | + thread0.num.queries=0 + thread0.num.queries_ip_ratelimited=0 + thread0.num.queries_cookie_valid=0 + thread0.num.queries_cookie_client=0 + thread0.num.queries_cookie_invalid=0 + thread0.num.cachehits=0 + thread0.num.cachemiss=0 + thread0.num.prefetch=0 + thread0.num.queries_timed_out=0 + thread0.query.queue_time_us.max=0 + thread0.num.expired=0 + thread0.num.recursivereplies=0 + thread0.requestlist.avg=0 + thread0.requestlist.max=0 + thread0.requestlist.overwritten=0 + thread0.requestlist.exceeded=0 + thread0.requestlist.current.all=0 + thread0.requestlist.current.user=0 + thread0.recursion.time.avg=0.000000 + thread0.recursion.time.median=0 + thread0.tcpusage=0 total.num.queries=0 total.num.queries_ip_ratelimited=0 total.num.queries_cookie_valid=0 @@ -22,10 +43,239 @@ data: total.recursion.time.avg=0.000000 total.recursion.time.median=0 total.tcpusage=0 - time.now=1731837810.163703 - time.up=2.858856 - time.elapsed=2.858856 + time.now=1732438610.074050 + time.up=60262.212517 + time.elapsed=21.873387 expected: + thread0: + num: + queries: 0 + queries_ip_ratelimited: 0 + queries_cookie_valid: 0 + queries_cookie_client: 0 + queries_cookie_invalid: 0 + cachehits: 0 + cachemiss: 0 + prefetch: 0 + queries_timed_out: 0 + expired: 0 + recursivereplies: 0 + query: + queue_time_us: + max: 0 + requestlist: + avg: 0 + max: 0 + overwritten: 0 + exceeded: 0 + current: + all: 0 + user: 0 + recursion: + time: + avg: 0.000000 + median: 0 + tcpusage: 0 + total: + num: + queries: 0 + queries_ip_ratelimited: 0 + queries_cookie_valid: 0 + queries_cookie_client: 0 + queries_cookie_invalid: 0 + cachehits: 0 + cachemiss: 0 + prefetch: 0 + queries_timed_out: 0 + expired: 0 + recursivereplies: 0 + query: + queue_time_us: + max: 0 + requestlist: + avg: 0 + max: 0 + overwritten: 0 + exceeded: 0 + current: + all: 0 + user: 0 + recursion: + time: + avg: 0.000000 + median: 0 + tcpusage: 0 + time: + now: 1732438610.074050 + up: 60262.212517 + elapsed: 21.873387 + - title: Extended statistics + raw: | + thread0.num.queries=0 + thread0.num.queries_ip_ratelimited=0 + thread0.num.queries_cookie_valid=0 + thread0.num.queries_cookie_client=0 + thread0.num.queries_cookie_invalid=0 + thread0.num.cachehits=0 + thread0.num.cachemiss=0 + thread0.num.prefetch=0 + thread0.num.queries_timed_out=0 + thread0.query.queue_time_us.max=0 + thread0.num.expired=0 + thread0.num.recursivereplies=0 + thread0.requestlist.avg=0 + thread0.requestlist.max=0 + thread0.requestlist.overwritten=0 + thread0.requestlist.exceeded=0 + thread0.requestlist.current.all=0 + thread0.requestlist.current.user=0 + thread0.recursion.time.avg=0.000000 + thread0.recursion.time.median=0 + thread0.tcpusage=0 + total.num.queries=0 + total.num.queries_ip_ratelimited=0 + total.num.queries_cookie_valid=0 + total.num.queries_cookie_client=0 + total.num.queries_cookie_invalid=0 + total.num.cachehits=0 + total.num.cachemiss=0 + total.num.prefetch=0 + total.num.queries_timed_out=0 + total.query.queue_time_us.max=0 + total.num.expired=0 + total.num.recursivereplies=0 + total.requestlist.avg=0 + total.requestlist.max=0 + total.requestlist.overwritten=0 + total.requestlist.exceeded=0 + total.requestlist.current.all=0 + total.requestlist.current.user=0 + total.recursion.time.avg=0.000000 + total.recursion.time.median=0 + total.tcpusage=0 + time.now=1732449824.130593 + time.up=266.736798 + time.elapsed=39.933868 + mem.cache.rrset=66104 + mem.cache.message=66104 + mem.mod.iterator=16748 + mem.mod.validator=66384 + mem.mod.respip=0 + mem.mod.subnet=74536 + mem.streamwait=0 + mem.http.query_buffer=0 + mem.http.response_buffer=0 + histogram.000000.000000.to.000000.000001=0 + histogram.000000.000001.to.000000.000002=0 + histogram.000000.000002.to.000000.000004=0 + histogram.000000.000004.to.000000.000008=0 + histogram.000000.000008.to.000000.000016=0 + histogram.000000.000016.to.000000.000032=0 + histogram.000000.000032.to.000000.000064=0 + histogram.000000.000064.to.000000.000128=0 + histogram.000000.000128.to.000000.000256=0 + histogram.000000.000256.to.000000.000512=0 + histogram.000000.000512.to.000000.001024=0 + histogram.000000.001024.to.000000.002048=0 + histogram.000000.002048.to.000000.004096=0 + histogram.000000.004096.to.000000.008192=0 + histogram.000000.008192.to.000000.016384=0 + histogram.000000.016384.to.000000.032768=0 + histogram.000000.032768.to.000000.065536=0 + histogram.000000.065536.to.000000.131072=0 + histogram.000000.131072.to.000000.262144=0 + histogram.000000.262144.to.000000.524288=0 + histogram.000000.524288.to.000001.000000=0 + histogram.000001.000000.to.000002.000000=0 + histogram.000002.000000.to.000004.000000=0 + histogram.000004.000000.to.000008.000000=0 + histogram.000008.000000.to.000016.000000=0 + histogram.000016.000000.to.000032.000000=0 + histogram.000032.000000.to.000064.000000=0 + histogram.000064.000000.to.000128.000000=0 + histogram.000128.000000.to.000256.000000=0 + histogram.000256.000000.to.000512.000000=0 + histogram.000512.000000.to.001024.000000=0 + histogram.001024.000000.to.002048.000000=0 + histogram.002048.000000.to.004096.000000=0 + histogram.004096.000000.to.008192.000000=0 + histogram.008192.000000.to.016384.000000=0 + histogram.016384.000000.to.032768.000000=0 + histogram.032768.000000.to.065536.000000=0 + histogram.065536.000000.to.131072.000000=0 + histogram.131072.000000.to.262144.000000=0 + histogram.262144.000000.to.524288.000000=0 + num.query.tcp=0 + num.query.tcpout=0 + num.query.udpout=0 + num.query.tls=0 + num.query.tls.resume=0 + num.query.ipv6=0 + num.query.https=0 + num.query.flags.QR=0 + num.query.flags.AA=0 + num.query.flags.TC=0 + num.query.flags.RD=0 + num.query.flags.RA=0 + num.query.flags.Z=0 + num.query.flags.AD=0 + num.query.flags.CD=0 + num.query.edns.present=0 + num.query.edns.DO=0 + num.answer.rcode.NOERROR=0 + num.answer.rcode.FORMERR=0 + num.answer.rcode.SERVFAIL=0 + num.answer.rcode.NXDOMAIN=0 + num.answer.rcode.NOTIMPL=0 + num.answer.rcode.REFUSED=0 + num.query.ratelimited=0 + num.answer.secure=0 + num.answer.bogus=0 + num.rrset.bogus=0 + num.query.aggressive.NOERROR=0 + num.query.aggressive.NXDOMAIN=0 + unwanted.queries=0 + unwanted.replies=0 + msg.cache.count=0 + rrset.cache.count=0 + infra.cache.count=7 + key.cache.count=0 + msg.cache.max_collisions=0 + rrset.cache.max_collisions=1 + num.query.authzone.up=0 + num.query.authzone.down=0 + num.query.subnet=0 + num.query.subnet_cache=0 + expected: + thread0: + num: + queries: 0 + queries_ip_ratelimited: 0 + queries_cookie_valid: 0 + queries_cookie_client: 0 + queries_cookie_invalid: 0 + cachehits: 0 + cachemiss: 0 + prefetch: 0 + queries_timed_out: 0 + expired: 0 + recursivereplies: 0 + query: + queue_time_us: + max: 0 + requestlist: + avg: 0 + max: 0 + overwritten: 0 + exceeded: 0 + current: + all: 0 + user: 0 + recursion: + time: + avg: 0.000000 + median: 0 + tcpusage: 0 total: num: queries: 0 @@ -56,6 +306,120 @@ data: median: 0 tcpusage: 0 time: - now: 1731837810.163703 - up: 2.858856 - elapsed: 2.858856 + now: 1732449824.130593 + up: 266.736798 + elapsed: 39.933868 + mem: + cache: + rrset: 66104 + message: 66104 + mod: + iterator: 16748 + validator: 66384 + respip: 0 + subnet: 74536 + streamwait: 0 + http: + query_buffer: 0 + response_buffer: 0 + histogram: + '000000.000000.to.000000.000001': 0 + '000000.000001.to.000000.000002': 0 + '000000.000002.to.000000.000004': 0 + '000000.000004.to.000000.000008': 0 + '000000.000008.to.000000.000016': 0 + '000000.000016.to.000000.000032': 0 + '000000.000032.to.000000.000064': 0 + '000000.000064.to.000000.000128': 0 + '000000.000128.to.000000.000256': 0 + '000000.000256.to.000000.000512': 0 + '000000.000512.to.000000.001024': 0 + '000000.001024.to.000000.002048': 0 + '000000.002048.to.000000.004096': 0 + '000000.004096.to.000000.008192': 0 + '000000.008192.to.000000.016384': 0 + '000000.016384.to.000000.032768': 0 + '000000.032768.to.000000.065536': 0 + '000000.065536.to.000000.131072': 0 + '000000.131072.to.000000.262144': 0 + '000000.262144.to.000000.524288': 0 + '000000.524288.to.000001.000000': 0 + '000001.000000.to.000002.000000': 0 + '000002.000000.to.000004.000000': 0 + '000004.000000.to.000008.000000': 0 + '000008.000000.to.000016.000000': 0 + '000016.000000.to.000032.000000': 0 + '000032.000000.to.000064.000000': 0 + '000064.000000.to.000128.000000': 0 + '000128.000000.to.000256.000000': 0 + '000256.000000.to.000512.000000': 0 + '000512.000000.to.001024.000000': 0 + '001024.000000.to.002048.000000': 0 + '002048.000000.to.004096.000000': 0 + '004096.000000.to.008192.000000': 0 + '008192.000000.to.016384.000000': 0 + '016384.000000.to.032768.000000': 0 + '032768.000000.to.065536.000000': 0 + '065536.000000.to.131072.000000': 0 + '131072.000000.to.262144.000000': 0 + '262144.000000.to.524288.000000': 0 + num: + query: + tcp: 0 + tcpout: 0 + udpout: 0 + tls: + num: 0 + resume: 0 + ipv6: 0 + https: 0 + flags: + QR: 0 + AA: 0 + TC: 0 + RD: 0 + RA: 0 + Z: 0 + AD: 0 + CD: 0 + edns: + present: 0 + DO: 0 + ratelimited: 0 + aggressive: + NOERROR: 0 + NXDOMAIN: 0 + authzone: + up: 0 + down: 0 + subnet: 0 + subnet_cache: 0 + answer: + rcode: + NOERROR: 0 + FORMERR: 0 + SERVFAIL: 0 + NXDOMAIN: 0 + NOTIMPL: 0 + REFUSED: 0 + secure: 0 + bogus: 0 + rrset: + bogus: 0 + unwanted: + queries: 0 + replies: 0 + msg: + cache: + count: 0 + max_collisions: 0 + rrset: + cache: + count: 0 + max_collisions: 1 + infra: + cache: + count: 7 + key: + cache: + count: 0 \ No newline at end of file diff --git a/tests/data/1.22.0/verbosity.yaml b/tests/data/1.22.0/verbosity.yaml new file mode 100644 index 0000000..2aac689 --- /dev/null +++ b/tests/data/1.22.0/verbosity.yaml @@ -0,0 +1,12 @@ +data: + - title: Set verbosity to 0 + options: 0 + raw: | + ok + expected: + status: ok + - title: Set verbosity to 9 + options: 9 + raw: | + ok + exception: "Invalid verbosity level: 9" diff --git a/tests/data/template.yaml b/tests/data/template.yaml index 19a63c9..46315dd 100644 --- a/tests/data/template.yaml +++ b/tests/data/template.yaml @@ -1,6 +1,8 @@ data: - title: Normal status + options: unbound-control command options raw: | # unbound-control response expected: | # unbound-control-ts json reponse + exception: "# exception message" diff --git a/unbound-config/unix/unbound.conf b/unbound-config/unix/unbound.conf index 4da2a03..1dcaafd 100644 --- a/unbound-config/unix/unbound.conf +++ b/unbound-config/unix/unbound.conf @@ -2,6 +2,7 @@ server: interface: 0.0.0.0 logfile: "" verbosity: 1 + # extended-statistics: yes remote-control: control-enable: yes From 64dc49f799a50b56f82a5dfa6d2dd894f7246174 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 24 Nov 2024 23:01:29 +0900 Subject: [PATCH 17/24] Fix response --- src/client.ts | 463 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 351 insertions(+), 112 deletions(-) diff --git a/src/client.ts b/src/client.ts index 217079e..11112fe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -209,29 +209,45 @@ export class UnboundControlClient { /** * Start the server. */ - public async start(): Promise { - return this.control.sendCommand("start"); + public async start(): Promise { + const raw = await this.control.sendCommand("start"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Stop the server. The server daemon exits. */ - public async stop(): Promise { - return this.control.sendCommand("stop"); + public async stop(): Promise { + const raw = await this.control.sendCommand("stop"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Reload the server. This flushes the cache and reads the config file fresh. */ - public async reload(): Promise { - return this.control.sendCommand("reload"); + public async reload(): Promise { + const raw = await this.control.sendCommand("reload"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Reload the server but try to keep the RRset and message cache if (re)configuration allows for it. */ - public async reload_keep_cache(): Promise { - return this.control.sendCommand("reload_keep_cache"); + public async reload_keep_cache(): Promise { + const raw = await this.control.sendCommand("reload_keep_cache"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -275,8 +291,12 @@ export class UnboundControlClient { /** * Reopen the logfile, close and open it. */ - public async log_reopen(): Promise { - return this.control.sendCommand("log_reopen"); + public async log_reopen(): Promise { + const raw = await this.control.sendCommand("log_reopen"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -358,40 +378,64 @@ export class UnboundControlClient { }; } - // public async local_zone(name: string, type: string): Promise {} + // public async local_zone(name: string, type: string): Promise {} - // public async local_zone_remove(name: string): Promise {} + // public async local_zone_remove(name: string): Promise {} - // public async local_data(rr: string, data: unknown): Promise {} + // public async local_data(rr: string, data: unknown): Promise {} - public async local_data_remove(name: string): Promise { - return this.control.sendCommand(`local_data_remove ${name}`); + public async local_data_remove(name: string): Promise { + const raw = await this.control.sendCommand(`local_data_remove ${name}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Add local zones read from stdin of unbound-control. */ - public async local_zones(): Promise { - return this.control.sendCommand("local_zones"); + public async local_zones(): Promise { + const raw = await this.control.sendCommand("local_zones"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Remove local zones read from stdin of unbound-control. Input is one name per line. For bulk removals. */ - public async local_zones_remove(): Promise { - return this.control.sendCommand("local_zones_remove"); + public async local_zones_remove(): Promise { + const raw = await this.control.sendCommand("local_zones_remove"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async dump_cache(): Promise { - return this.control.sendCommand("dump_cache"); + public async dump_cache(): Promise { + const raw = await this.control.sendCommand("dump_cache"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async load_cache(): Promise { - return this.control.sendCommand("load_cache"); + public async load_cache(): Promise { + const raw = await this.control.sendCommand("load_cache"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async lookup(name: string): Promise { - return this.control.sendCommand(`lookup ${name}`); + public async lookup(name: string): Promise { + const raw = await this.control.sendCommand(`lookup ${name}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -403,9 +447,13 @@ export class UnboundControlClient { public async flush( name: string, useCachedb: boolean = false, - ): Promise { + ): Promise { const command = useCachedb ? `flush +c ${name}` : `flush ${name}`; - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -418,11 +466,15 @@ export class UnboundControlClient { name: string, type: string, useCachedb: boolean = false, - ): Promise { + ): Promise { const command = useCachedb ? `flush_type +c ${name} ${type}` : `flush_type ${name} ${type}`; - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -433,43 +485,75 @@ export class UnboundControlClient { public async flush_zone( name: string, useCachedb: boolean = false, - ): Promise { + ): Promise { const command = useCachedb ? `flush_zone +c ${name}` : `flush_zone ${name}`; - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async flush_bogus(useCachedb: boolean = false): Promise { + public async flush_bogus(useCachedb: boolean = false): Promise { const command = useCachedb ? "flush_bogus +c" : "flush_bogus"; - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async flush_negative(useCachedb: boolean = false): Promise { + public async flush_negative(useCachedb: boolean = false): Promise { const command = useCachedb ? "flush_negative +c" : "flush_negative"; - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async flush_stats(): Promise { - return this.control.sendCommand("flush_stats"); + public async flush_stats(): Promise { + const raw = await this.control.sendCommand("flush_stats"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async flush_requestlist(): Promise { - return this.control.sendCommand("flush_requestlist"); + public async flush_requestlist(): Promise { + const raw = await this.control.sendCommand("flush_requestlist"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async dump_requestlist(): Promise { - return this.control.sendCommand("dump_requestlist"); + public async dump_requestlist(): Promise { + const raw = await this.control.sendCommand("dump_requestlist"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async flush_infra(address: string): Promise { + public async flush_infra(address: string): Promise { this.checkValidIp(address); - return this.control.sendCommand(`flush_infra ${address}`); + const raw = await this.control.sendCommand(`flush_infra ${address}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Show the contents of the infra cache. */ - public async dump_infra(): Promise { - return this.control.sendCommand("dump_infra"); + public async dump_infra(): Promise { + const raw = await this.control.sendCommand("dump_infra"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -477,8 +561,17 @@ export class UnboundControlClient { * @param option - The configuration option to set. Must be one of the predefined valid options. * @param value - The value to assign to the option. The type and range depend on the option. */ - public async set_option(option: ValidOption, value: string): Promise { - return this.control.sendCommand(`set_option ${option}: ${value}`); + public async set_option( + option: ValidOption, + value: string, + ): Promise { + const raw = await this.control.sendCommand( + `set_option ${option}: ${value}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -486,36 +579,68 @@ export class UnboundControlClient { * @param option * @returns */ - public async get_option(option: ValidOption): Promise { - return this.control.sendCommand(`get_option ${option}`); + public async get_option(option: ValidOption): Promise { + const raw = await this.control.sendCommand(`get_option ${option}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async list_stubs(): Promise { - return this.control.sendCommand("list_stubs"); + public async list_stubs(): Promise { + const raw = await this.control.sendCommand("list_stubs"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async list_forwards(): Promise { - return this.control.sendCommand("list_forwards"); + public async list_forwards(): Promise { + const raw = await this.control.sendCommand("list_forwards"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async list_insecure(): Promise { - return this.control.sendCommand("list_insecure"); + public async list_insecure(): Promise { + const raw = await this.control.sendCommand("list_insecure"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async list_local_zones(): Promise { - return this.control.sendCommand("list_local_zones"); + public async list_local_zones(): Promise { + const raw = await this.control.sendCommand("list_local_zones"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async list_local_data(): Promise { - return this.control.sendCommand("list_local_data"); + public async list_local_data(): Promise { + const raw = await this.control.sendCommand("list_local_data"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async insecure_add(zone: string): Promise { - return this.control.sendCommand(`insecure_add ${zone}`); + public async insecure_add(zone: string): Promise { + const raw = await this.control.sendCommand(`insecure_add ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async insecure_remove(zone: string): Promise { - return this.control.sendCommand(`insecure_remove ${zone}`); + public async insecure_remove(zone: string): Promise { + const raw = await this.control.sendCommand(`insecure_remove ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -533,7 +658,7 @@ export class UnboundControlClient { addresses: string[], insecure: boolean = false, useTLS: boolean = false, - ): Promise { + ): Promise { if (addresses.length === 0) { throw new ParseError("At least one address must be provided."); } @@ -547,15 +672,25 @@ export class UnboundControlClient { const command = `forward_add ${flags} ${zone} ${addresses.join(" ")}`.trim(); - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } public async forward_remove( zone: string, insecure: boolean = false, - ): Promise { + ): Promise { const flags = insecure ? "+i" : ""; - return this.control.sendCommand(`forward_remove ${flags} ${zone}`); + const raw = await this.control.sendCommand( + `forward_remove ${flags} ${zone}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -573,7 +708,7 @@ export class UnboundControlClient { insecure: boolean = false, prime: boolean = false, useTLS: boolean = false, - ): Promise { + ): Promise { if (addresses.length === 0) { throw new ParseError("At least one address must be provided."); } @@ -586,31 +721,49 @@ export class UnboundControlClient { const flags = `${insecure ? "+i" : ""}${prime ? "+p" : ""}${useTLS ? "+t" : ""}`; const command = `stub_add ${flags} ${zone} ${addresses.join(" ")}`.trim(); - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } public async stub_remove( zone: string, insecure: boolean = false, - ): Promise { + ): Promise { const flags = insecure ? "+i" : ""; - return this.control.sendCommand(`stub_remove ${flags} ${zone}`); + const raw = await this.control.sendCommand(`stub_remove ${flags} ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** * Setup forwarding mode. */ - public async forward(addresses: string | string[]): Promise { + public async forward(addresses: string | string[]): Promise { if (typeof addresses === "string") { this.checkValidIp(addresses); - return this.control.sendCommand(`forward ${addresses}`); + const raw = await this.control.sendCommand(`forward ${addresses}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } for (const address of addresses) { this.checkValidIp(address); } - return this.control.sendCommand(`forward ${addresses.join(" ")}`); + const raw = await this.control.sendCommand( + `forward ${addresses.join(" ")}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } /** @@ -618,97 +771,183 @@ export class UnboundControlClient { * * @param allDomains - Whether to include all domains (not just rate-limited ones). Defaults to `false`. */ - public async ratelimit_list(allDomains: boolean = false): Promise { + public async ratelimit_list(allDomains: boolean = false): Promise { const command = `ratelimit_list ${allDomains ? "+a" : ""}`.trim(); - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async ip_ratelimit_list(allDomains: boolean = false): Promise { + public async ip_ratelimit_list( + allDomains: boolean = false, + ): Promise { const command = `ip_ratelimit_list ${allDomains ? "+a" : ""}`.trim(); - return this.control.sendCommand(command); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async list_auth_zones(): Promise { - return this.control.sendCommand("list_auth_zones"); + public async list_auth_zones(): Promise { + const raw = await this.control.sendCommand("list_auth_zones"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async auth_zone_reload(zone: string): Promise { - return this.control.sendCommand(`auth_zone_reload ${zone}`); + public async auth_zone_reload(zone: string): Promise { + const raw = await this.control.sendCommand(`auth_zone_reload ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async auth_zone_transfer(zone: string): Promise { - return this.control.sendCommand(`auth_zone_transfer ${zone}`); + public async auth_zone_transfer(zone: string): Promise { + const raw = await this.control.sendCommand(`auth_zone_transfer ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async rpz_enable(zone: string): Promise { - return this.control.sendCommand(`rpz_enable ${zone}`); + public async rpz_enable(zone: string): Promise { + const raw = await this.control.sendCommand(`rpz_enable ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async rpz_disable(zone: string): Promise { - return this.control.sendCommand(`rpz_disable ${zone}`); + public async rpz_disable(zone: string): Promise { + const raw = await this.control.sendCommand(`rpz_disable ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async view_list_local_zones(view: string): Promise { - return this.control.sendCommand(`view_list_local_zones ${view}`); + public async view_list_local_zones(view: string): Promise { + const raw = await this.control.sendCommand(`view_list_local_zones ${view}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } public async view_local_zone( view: string, name: string, type: string, - ): Promise { - return this.control.sendCommand(`view_local_zone ${view} ${name} ${type}`); + ): Promise { + const raw = await this.control.sendCommand( + `view_local_zone ${view} ${name} ${type}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } public async view_local_zone_remove( view: string, name: string, - ): Promise { - return this.control.sendCommand(`view_local_zone_remove ${view} ${name}`); + ): Promise { + const raw = await this.control.sendCommand( + `view_local_zone_remove ${view} ${name}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async view_list_local_data(view: string): Promise { - return this.control.sendCommand(`view_list_local_data ${view}`); + public async view_list_local_data(view: string): Promise { + const raw = await this.control.sendCommand(`view_list_local_data ${view}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } public async view_local_data( view: string, rr: string, data: string[], - ): Promise { - return this.control.sendCommand( + ): Promise { + const raw = await this.control.sendCommand( `view_local_data ${view} ${rr} ${data.join(" ")}`.trim(), ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } public async view_local_data_remove( view: string, name: string, - ): Promise { - return this.control.sendCommand(`view_local_data_remove ${view} ${name}`); + ): Promise { + const raw = await this.control.sendCommand( + `view_local_data_remove ${view} ${name}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async view_local_datas_remove(view: string): Promise { - return this.control.sendCommand(`view_local_datas_remove ${view}`); + public async view_local_datas_remove(view: string): Promise { + const raw = await this.control.sendCommand( + `view_local_datas_remove ${view}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async view_local_datas(view: string): Promise { - return this.control.sendCommand(`view_local_datas ${view}`); + public async view_local_datas(view: string): Promise { + const raw = await this.control.sendCommand(`view_local_datas ${view}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async add_cookie_secret(secret: string): Promise { - return this.control.sendCommand(`add_cookie_secret ${secret}`); + public async add_cookie_secret(secret: string): Promise { + const raw = await this.control.sendCommand(`add_cookie_secret ${secret}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async drop_cookie_secret(): Promise { - return this.control.sendCommand("drop_cookie_secret"); + public async drop_cookie_secret(): Promise { + const raw = await this.control.sendCommand("drop_cookie_secret"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async activate_cookie_secret(): Promise { - return this.control.sendCommand("activate_cookie_secret"); + public async activate_cookie_secret(): Promise { + const raw = await this.control.sendCommand("activate_cookie_secret"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } - public async print_cookie_secrets(): Promise { - return this.control.sendCommand("print_cookie_secrets"); + public async print_cookie_secrets(): Promise { + const raw = await this.control.sendCommand("print_cookie_secrets"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; } } From ae43257dd316c78e424b5fb3ed9f11f1e1d6148f Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Mon, 25 Nov 2024 00:07:57 +0900 Subject: [PATCH 18/24] Refactor --- README.md | 26 ++++++- package.json | 1 + src/client.ts | 130 ++++++--------------------------- src/control.ts | 69 ++++++++--------- src/index.ts | 2 +- src/types.ts | 113 ++++++++++++++++++++++++++++ tests/control.it.test.ts | 8 +- tests/control.snapshot.test.ts | 8 +- tests/control.test.ts | 8 +- tests/mockServer.ts | 2 +- 10 files changed, 205 insertions(+), 162 deletions(-) create mode 100644 src/types.ts diff --git a/README.md b/README.md index 3aab85b..84085fc 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,31 @@ yarn add unbound-control-ts Here's a basic example to demonstrate how to use the library: +Use domain socket: ```ts -import { UnboundControlClient } from 'unbound-control-ts'; +import { UnixUnboundClient } from 'unbound-control-ts'; -const client = new UnboundControlClient('/path/to/unbound-control.sock'); +const client = new UnixUnboundClient('/path/to/unbound-control.sock'); + +(async () => { + try { + const response = await client.status(); + console.log(response); + } catch (error) { + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } + } +})(); +``` + +Use tcp socket: +```ts +import { TcpUnboundClient } from 'unbound-control-ts'; + +const client = new TcpUnboundClient('localhost', 8953); (async () => { try { @@ -142,7 +163,6 @@ Before you begin, ensure you have the following tools installed on your system: - **Node.js**: Version 16 or later. [Download Node.js](https://nodejs.org/) - **npm**: Comes with Node.js, or install it separately if needed. - **Unbound**: Ensure that `unbound-control` is installed and properly configured. Follow the [Unbound installation guide](https://nlnetlabs.nl/documentation/unbound/) for details. -- **TypeScript**: (Optional) For contributing to or extending the library, TypeScript must be installed globally or as a dev dependency. ### Develop Setup diff --git a/package.json b/package.json index 19d21b3..158ead8 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test": "jest --detectOpenHandles tests/control.test.ts", "test:it": "jest --detectOpenHandles tests/control.it.test.ts", "snapshot": "jest --detectOpenHandles tests/control.snapshot.test.ts", + "snapshot:update": "jest --detectOpenHandles --updateSnapshot tests/control.snapshot.test.ts", "sample:esm": "node examples/index.mjs", "sample:cjs": "node examples/index.cjs", "sample": "npm run test:esm:node && npm run test:cjs:node" diff --git a/src/client.ts b/src/client.ts index 11112fe..77106c2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,116 +1,21 @@ import { UnboundControl } from "./control"; import { ParseError } from "./error"; +import { Response, TlsConfig, NestedRecord, ValidOption } from "./types"; -/** - * A list of valid configuration options for the `set_option` command. - */ -export type ValidOption = - | "statistics-interval" - | "statistics-cumulative" - | "do-not-query-localhost" - | "harden-short-bufsize" - | "harden-large-queries" - | "harden-glue" - | "harden-dnssec-stripped" - | "harden-below-nxdomain" - | "harden-referral-path" - | "prefetch" - | "prefetch-key" - | "log-queries" - | "hide-identity" - | "hide-version" - | "identity" - | "version" - | "val-log-level" - | "val-log-squelch" - | "ignore-cd-flag" - | "add-holddown" - | "del-holddown" - | "keep-missing" - | "tcp-upstream" - | "ssl-upstream" - | "max-udp-size" - | "ratelimit" - | "ip-ratelimit" - | "cache-max-ttl" - | "cache-min-ttl" - | "cache-max-negative-ttl"; - -export interface Response { - raw: string; - json: any; // eslint-disable-line @typescript-eslint/no-explicit-any -} - -// export interface StatusResponse { -// version: string; -// verbosity: number; -// threads: number; -// modules: string[]; -// uptime: number; -// options: string[]; -// pid: number; -// status: string; -// } - -// export interface StasResponse { -// total: { -// num: { -// queries: number; -// queries_ip_ratelimited: number; -// queries_cookie_valid: number; -// queries_cookie_client: number; -// queries_cookie_invalid: number; -// cachehits: number; -// cachemiss: number; -// prefetch: number; -// queries_timed_out: number; -// expired: number; -// recursivereplies: number; -// }; -// query: { -// queue_time_us: { -// max: number; -// }; -// }; -// requestlist: { -// avg: number; -// max: number; -// overwritten: number; -// exceeded: number; -// current: { -// all: number; -// user: number; -// }; -// }; -// recursion: { -// time: { -// avg: number; -// median: number; -// }; -// }; -// tcpusage: number; -// }; -// time: { -// now: number; -// up: number; -// elapsed: number; -// }; -// } - -export interface NestedRecord { - [key: string]: string | number | string[] | NestedRecord; -} - -export class UnboundControlClient { +abstract class UnboundControlClient { private control: UnboundControl; constructor( - unixSocketName: string | null = null, - // host: string = "127.0.0.1", - // port: number = 8953, - // tlsConfig?: TLSConfig, + unixSocketName: string | null, + host?: string, + port?: number, + tlsConfig?: TlsConfig | null, ) { - this.control = new UnboundControl(unixSocketName); + if (unixSocketName) { + this.control = new UnboundControl(unixSocketName); + } else { + this.control = new UnboundControl(null, host, port, tlsConfig); + } } /** @@ -951,3 +856,16 @@ export class UnboundControlClient { }; } } + +export class UnixUnboundClient extends UnboundControlClient { + // eslint-disable-next-line @typescript-eslint/no-useless-constructor + constructor(unixSocketName: string) { + super(unixSocketName); + } +} + +export class TcpUnboundClient extends UnboundControlClient { + constructor(host: string, port: number, tlsConfig?: TlsConfig) { + super(null, host, port, tlsConfig); + } +} diff --git a/src/control.ts b/src/control.ts index e6002dc..22ad00d 100644 --- a/src/control.ts +++ b/src/control.ts @@ -1,44 +1,29 @@ import net from "net"; +import tls from "tls"; +import fs from "fs"; import { ConnectionError, CommandError } from "./error"; -// import tls from "tls"; - -/** - * Configuration for the TLS connection. - */ -// export interface TLSConfig { -// /** Certificate file. */ -// cert: string; - -// /** Key file. */ -// key: string; - -// /** CA certificate file. */ -// ca?: string; - -// /** Reject unauthorized connections. If set to false, the server certificate is not verified. */ -// rejectUnauthorized: boolean; -// } +import { TlsConfig } from "./types"; /** * A class to interact with an Unbound control interface via TCP or Unix socket. * Provides methods to establish connections and send commands to the Unbound DNS resolver. */ export class UnboundControl { + /** The path to the Unix domain socket (if applicable). */ + private readonly unixSocketName: string | null; + /** The host address for TCP connections. */ - // private readonly host: string; + private readonly host: string; /** The port number for TCP connections. */ - // private readonly port: number; + private readonly port: number; - /** The path to the Unix domain socket (if applicable). */ - private readonly unixSocketName: string | null; + /** Optional TLS configuration for secure connections. */ + private readonly tlsConfig: TlsConfig | null = null; /** The underlying network socket for communication. */ private socket: net.Socket | null = null; - /** Optional TLS configuration for secure connections. */ - // private readonly tlsConfig: TLSConfig | null = null; - /** * Creates a new instance of the UnboundControl class. * @@ -49,14 +34,14 @@ export class UnboundControl { */ constructor( unixSocketName: string | null = null, - // host: string = "127.0.0.1", - // port: number = 8953, - // tlsConfig?: TLSConfig, + host: string = "localhost", + port: number = 8953, + tlsConfig: TlsConfig | null = null, ) { this.unixSocketName = unixSocketName; - // this.host = host; - // this.port = port; - // this.tlsConfig = tlsConfig || null; + this.host = host; + this.port = port; + this.tlsConfig = tlsConfig; } /** @@ -79,15 +64,21 @@ export class UnboundControl { if (this.unixSocketName !== null) { socket = net.createConnection(this.unixSocketName); } else { - throw new Error("Not implemented"); + if (this.tlsConfig) { + socket = tls.connect({ + host: this.host, + port: this.port, + rejectUnauthorized: this.tlsConfig.ca ? true : false, + cert: fs.readFileSync(this.tlsConfig.cert), + key: fs.readFileSync(this.tlsConfig.key), + ca: this.tlsConfig.ca + ? fs.readFileSync(this.tlsConfig.ca) + : undefined, + }); + } else { + socket = net.createConnection(this.port, this.host); + } } - // else { - // socket = tls.createConnection({ - // host: this.host, - // port: this.port, - // ...this.tlsConfig, - // }); - // } socket.once("connect", () => { this.socket = socket; diff --git a/src/index.ts b/src/index.ts index 7d7a834..bf7c4b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ export { UnboundControl } from "./control"; -export { UnboundControlClient } from "./client"; +export { UnixUnboundClient, TcpUnboundClient } from "./client"; export { UnboundError, ConnectionError, diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..badaf66 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,113 @@ +/** + * Configuration for the TLS connection. + */ +export interface TlsConfig { + /** Certificate file. */ + cert: string; + + /** Key file. */ + key: string; + + /** CA certificate file. */ + ca?: string; +} + +export interface Response { + raw: string; + json: any; // eslint-disable-line @typescript-eslint/no-explicit-any +} + +// export interface StatusResponse { +// version: string; +// verbosity: number; +// threads: number; +// modules: string[]; +// uptime: number; +// options: string[]; +// pid: number; +// status: string; +// } + +// export interface StasResponse { +// total: { +// num: { +// queries: number; +// queries_ip_ratelimited: number; +// queries_cookie_valid: number; +// queries_cookie_client: number; +// queries_cookie_invalid: number; +// cachehits: number; +// cachemiss: number; +// prefetch: number; +// queries_timed_out: number; +// expired: number; +// recursivereplies: number; +// }; +// query: { +// queue_time_us: { +// max: number; +// }; +// }; +// requestlist: { +// avg: number; +// max: number; +// overwritten: number; +// exceeded: number; +// current: { +// all: number; +// user: number; +// }; +// }; +// recursion: { +// time: { +// avg: number; +// median: number; +// }; +// }; +// tcpusage: number; +// }; +// time: { +// now: number; +// up: number; +// elapsed: number; +// }; +// } + +export interface NestedRecord { + [key: string]: string | number | string[] | NestedRecord; +} + +/** + * A list of valid configuration options for the `set_option` command. + */ +export type ValidOption = + | "statistics-interval" + | "statistics-cumulative" + | "do-not-query-localhost" + | "harden-short-bufsize" + | "harden-large-queries" + | "harden-glue" + | "harden-dnssec-stripped" + | "harden-below-nxdomain" + | "harden-referral-path" + | "prefetch" + | "prefetch-key" + | "log-queries" + | "hide-identity" + | "hide-version" + | "identity" + | "version" + | "val-log-level" + | "val-log-squelch" + | "ignore-cd-flag" + | "add-holddown" + | "del-holddown" + | "keep-missing" + | "tcp-upstream" + | "ssl-upstream" + | "max-udp-size" + | "ratelimit" + | "ip-ratelimit" + | "cache-max-ttl" + | "cache-min-ttl" + | "cache-max-negative-ttl"; diff --git a/tests/control.it.test.ts b/tests/control.it.test.ts index f390d36..0946502 100644 --- a/tests/control.it.test.ts +++ b/tests/control.it.test.ts @@ -1,4 +1,4 @@ -import { UnboundControlClient } from "../src/index"; +import { UnixUnboundClient } from "../src/index"; import fs from "fs"; import path from "path"; import YAML from "yaml"; @@ -20,14 +20,14 @@ interface TestCase { } describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { - let client: UnboundControlClient; + let client: UnixUnboundClient; const unixSocketPath = path.join( baseDir, "../unbound-config/unix/socket/unbound.ctl", ); beforeAll(() => { - client = new UnboundControlClient(unixSocketPath); + client = new UnixUnboundClient(unixSocketPath); }); const files = fs @@ -43,7 +43,7 @@ describe(`Unix domain socket docker server tests. Unbound version: ${unboundVers it(`${command} test: ${title}`, async () => { console.log(`Running ${command} test: ${title}`); - const method = client[command as keyof UnboundControlClient].bind( + const method = client[command as keyof UnixUnboundClient].bind( client, ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any if (typeof method !== "function") { diff --git a/tests/control.snapshot.test.ts b/tests/control.snapshot.test.ts index dada18d..a87669b 100644 --- a/tests/control.snapshot.test.ts +++ b/tests/control.snapshot.test.ts @@ -1,4 +1,4 @@ -import { UnboundControlClient } from "../src/index"; +import { UnixUnboundClient } from "../src/index"; import fs from "fs"; import path from "path"; import YAML from "yaml"; @@ -20,14 +20,14 @@ interface TestCase { } describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { - let client: UnboundControlClient; + let client: UnixUnboundClient; const unixSocketPath = path.join( baseDir, "../unbound-config/unix/socket/unbound.ctl", ); beforeAll(() => { - client = new UnboundControlClient(unixSocketPath); + client = new UnixUnboundClient(unixSocketPath); }); const files = fs @@ -43,7 +43,7 @@ describe(`Unix domain socket docker server tests. Unbound version: ${unboundVers it(`${command} test: ${title}`, async () => { console.log(`Running ${command} test: ${title}`); - const method = client[command as keyof UnboundControlClient].bind( + const method = client[command as keyof UnixUnboundClient].bind( client, ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any if (typeof method !== "function") { diff --git a/tests/control.test.ts b/tests/control.test.ts index c26d6d5..c56ed5a 100644 --- a/tests/control.test.ts +++ b/tests/control.test.ts @@ -1,5 +1,5 @@ import { UnixMockServer, MockServer } from "./mockServer"; -import { UnboundControlClient } from "../src/index"; +import { UnixUnboundClient } from "../src/index"; import fs from "fs"; import path from "path"; import YAML from "yaml"; @@ -27,7 +27,7 @@ interface Response { describe(`Unix domain socket mock server tests. Unbound version: ${unboundVersion}`, () => { let server: MockServer; - let client: UnboundControlClient; + let client: UnixUnboundClient; const unixSocketPath = "/tmp/mock.sock"; beforeAll(() => { @@ -35,7 +35,7 @@ describe(`Unix domain socket mock server tests. Unbound version: ${unboundVersio fs.unlinkSync(unixSocketPath); } server = new UnixMockServer(unixSocketPath); - client = new UnboundControlClient(unixSocketPath); + client = new UnixUnboundClient(unixSocketPath); }); afterEach(async () => { @@ -55,7 +55,7 @@ describe(`Unix domain socket mock server tests. Unbound version: ${unboundVersio it(`${command} test: ${title}`, async () => { server.start(raw); - const method = client[command as keyof UnboundControlClient].bind( + const method = client[command as keyof UnixUnboundClient].bind( client, ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any if (typeof method !== "function") { diff --git a/tests/mockServer.ts b/tests/mockServer.ts index 65d9635..e8e5902 100644 --- a/tests/mockServer.ts +++ b/tests/mockServer.ts @@ -27,7 +27,7 @@ export class UnixMockServer implements MockServer { }); socket.on("end", () => { - socket.destroy(); // 接続を完全に破棄 + socket.destroy(); }); socket.on("error", () => { From a03a85c4a6ef1dea19f5467bf48115dec991943c Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Fri, 29 Nov 2024 00:03:30 +0900 Subject: [PATCH 19/24] Support tcp socket --- src/client.ts | 10 +++--- src/control.ts | 52 ++++++++++++++++++++---------- tests/control.test.ts | 73 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 111 insertions(+), 24 deletions(-) diff --git a/src/client.ts b/src/client.ts index 77106c2..f75766f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6,15 +6,15 @@ abstract class UnboundControlClient { private control: UnboundControl; constructor( - unixSocketName: string | null, + unixSocketName?: string, host?: string, port?: number, - tlsConfig?: TlsConfig | null, + tlsConfig?: TlsConfig, ) { if (unixSocketName) { this.control = new UnboundControl(unixSocketName); } else { - this.control = new UnboundControl(null, host, port, tlsConfig); + this.control = new UnboundControl(undefined, host, port, tlsConfig); } } @@ -865,7 +865,7 @@ export class UnixUnboundClient extends UnboundControlClient { } export class TcpUnboundClient extends UnboundControlClient { - constructor(host: string, port: number, tlsConfig?: TlsConfig) { - super(null, host, port, tlsConfig); + constructor(host: string, port: number, tlsConfig: TlsConfig) { + super(undefined, host, port, tlsConfig); } } diff --git a/src/control.ts b/src/control.ts index 22ad00d..5fda53b 100644 --- a/src/control.ts +++ b/src/control.ts @@ -10,7 +10,7 @@ import { TlsConfig } from "./types"; */ export class UnboundControl { /** The path to the Unix domain socket (if applicable). */ - private readonly unixSocketName: string | null; + private readonly unixSocketName?: string; /** The host address for TCP connections. */ private readonly host: string; @@ -19,10 +19,10 @@ export class UnboundControl { private readonly port: number; /** Optional TLS configuration for secure connections. */ - private readonly tlsConfig: TlsConfig | null = null; + private readonly tlsConfig?: TlsConfig; /** The underlying network socket for communication. */ - private socket: net.Socket | null = null; + private socket?: net.Socket | null; /** * Creates a new instance of the UnboundControl class. @@ -33,10 +33,10 @@ export class UnboundControl { * @param tlsConfig - Optional TLS configuration for secure connections. */ constructor( - unixSocketName: string | null = null, + unixSocketName?: string, host: string = "localhost", port: number = 8953, - tlsConfig: TlsConfig | null = null, + tlsConfig?: TlsConfig, ) { this.unixSocketName = unixSocketName; this.host = host; @@ -61,22 +61,40 @@ export class UnboundControl { return new Promise((resolve, reject) => { let socket: net.Socket; - if (this.unixSocketName !== null) { + if (this.unixSocketName) { socket = net.createConnection(this.unixSocketName); } else { if (this.tlsConfig) { - socket = tls.connect({ - host: this.host, - port: this.port, - rejectUnauthorized: this.tlsConfig.ca ? true : false, - cert: fs.readFileSync(this.tlsConfig.cert), - key: fs.readFileSync(this.tlsConfig.key), - ca: this.tlsConfig.ca - ? fs.readFileSync(this.tlsConfig.ca) - : undefined, - }); + // Connect via TLS + socket = tls.connect( + { + host: this.host, + port: this.port, + rejectUnauthorized: !!this.tlsConfig.ca, + cert: fs.readFileSync(this.tlsConfig.cert), + key: fs.readFileSync(this.tlsConfig.key), + ca: this.tlsConfig.ca + ? fs.readFileSync(this.tlsConfig.ca) + : undefined, + }, + () => { + const tlsSocket = socket as tls.TLSSocket; + if (tlsSocket.authorized || !this.tlsConfig?.ca) { + resolve(tlsSocket); + } else { + reject( + new ConnectionError( + `TLS authorization failed: ${tlsSocket.authorizationError}`, + ), + ); + } + }, + ); } else { - socket = net.createConnection(this.port, this.host); + // Connect via plain TCP + socket = net.createConnection(this.port, this.host, () => { + resolve(socket); + }); } } diff --git a/tests/control.test.ts b/tests/control.test.ts index c56ed5a..5ea00df 100644 --- a/tests/control.test.ts +++ b/tests/control.test.ts @@ -1,8 +1,9 @@ -import { UnixMockServer, MockServer } from "./mockServer"; -import { UnixUnboundClient } from "../src/index"; import fs from "fs"; import path from "path"; import YAML from "yaml"; +import { UnixMockServer, TcpTlsMockServer, MockServer } from "./mockServer"; +import { UnixUnboundClient, TcpUnboundClient } from "../src/index"; +import { TlsConfig } from "../src/types"; const baseDir = path.resolve(__dirname); const unboundVersion = process.env.UNBOUND_VERSION || "1.22.0"; @@ -84,3 +85,71 @@ describe(`Unix domain socket mock server tests. Unbound version: ${unboundVersio } } }); + +describe(`TCP socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let server: MockServer; + let client: TcpUnboundClient; + const keyPath = path.join(baseDir, "./key/unbound_control.key"); + const certPath = path.join(baseDir, "./key/unbound_control.pem"); + const tlsConfig: TlsConfig = { + cert: certPath, + key: keyPath, + }; + + const options = { + key: fs.readFileSync(path.join(baseDir, "./key/unbound_server.key")), + cert: fs.readFileSync(path.join(baseDir, "./key/unbound_server.pem")), + requestCert: false, + }; + + beforeAll(() => { + server = new TcpTlsMockServer("localhost", 8953, options); + client = new TcpUnboundClient("localhost", 8953, tlsConfig); + }); + + afterEach(async () => { + await server.stop(); + }); + + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".yaml")); + + for (const file of files) { + const command = file.replace(".yaml", ""); + const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); + const contents = YAML.parse(fileContent) as ParsedData; + + for (const { title, options, raw, expected, exception } of contents.data) { + it(`${command} test: ${title}`, async () => { + server.start(raw); + + const method = client[command as keyof UnixUnboundClient].bind( + client, + ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + if (typeof method !== "function") { + throw new Error(`Invalid command: ${command}`); + } + + const args = + options !== undefined + ? Array.isArray(options) + ? options + : [options] + : []; + + let result; + + if (exception) { + await expect(method.apply(client, args)).rejects.toThrow(exception); + } else { + result = await method.apply(client, args); + expect(result.raw).toEqual(raw); + expect(result.json).toEqual(expected); + } + + await client.disconnect(); + }); + } + } +}); From a93830d577e4e7c333bc2cfc58d99611fb46e0df Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Fri, 29 Nov 2024 00:12:56 +0900 Subject: [PATCH 20/24] Add workflow to generate mock certificates --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ .gitignore | 3 ++- tests/key/.gitkeep | 0 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/key/.gitkeep diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6abfac6..6bf8c3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,28 @@ jobs: run: npm ci - name: Lint with ESLint run: npm run lint + generate-certs: + name: Generate Certs + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install OpenSSL + run: sudo apt-get install -y openssl + - name: Generate server key + run: | + openssl genrsa -out tests/key/unbound_server.ky2048 + openssl req -new -key tests/key/unbound_server.ky-out unbound_server.csr -subj "/CN=server" + openssl x509 -req -in tests/key/unbound_server.csr -signkey unbound_server.ky-out unbound_server.pem -days 365 + + - name: Generate client key + run: | + openssl genrsa -out tests/key/unbound_control.key 2048 + openssl req -new -key tests/key/unbound_control.key -out unbound_control.csr -subj "/CN=client" + openssl x509 -req -in tests/key/unbound_control.csr -signkey unbound_control.key -out unbound_control.pem -days 365 + + - name: List generated files + run: ls -l test: name: Test runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 87d359b..0f3b5b2 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ unbound-config/*/* !unbound-config/tls/unbound.conf !unbound-config/unix/unbound.conf tests/__snapshots__ -tests/key \ No newline at end of file +tests/key/* +!.gitkeep \ No newline at end of file diff --git a/tests/key/.gitkeep b/tests/key/.gitkeep new file mode 100644 index 0000000..e69de29 From e31865edc18f349a4f738679491c66790dfc06d5 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Fri, 29 Nov 2024 00:14:32 +0900 Subject: [PATCH 21/24] Fix typo --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bf8c3d..f99e52a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,9 +34,9 @@ jobs: run: sudo apt-get install -y openssl - name: Generate server key run: | - openssl genrsa -out tests/key/unbound_server.ky2048 - openssl req -new -key tests/key/unbound_server.ky-out unbound_server.csr -subj "/CN=server" - openssl x509 -req -in tests/key/unbound_server.csr -signkey unbound_server.ky-out unbound_server.pem -days 365 + openssl genrsa -out tests/key/unbound_server.key 2048 + openssl req -new -key tests/key/unbound_server.key -out unbound_server.csr -subj "/CN=server" + openssl x509 -req -in tests/key/unbound_server.csr -signkey unbound_server.key -out unbound_server.pem -days 365 - name: Generate client key run: | From 5ff7e42318144e76ba0a224f0424af0a94e27411 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Fri, 29 Nov 2024 00:18:07 +0900 Subject: [PATCH 22/24] Fix paths for generated certificate files in CI workflow --- .github/workflows/ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f99e52a..0554325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,19 +35,20 @@ jobs: - name: Generate server key run: | openssl genrsa -out tests/key/unbound_server.key 2048 - openssl req -new -key tests/key/unbound_server.key -out unbound_server.csr -subj "/CN=server" - openssl x509 -req -in tests/key/unbound_server.csr -signkey unbound_server.key -out unbound_server.pem -days 365 + openssl req -new -key tests/key/unbound_server.key -out tests/key/unbound_server.csr -subj "/CN=server" + openssl x509 -req -in tests/key/unbound_server.csr -signkey tests/key/unbound_server.key -out tests/key/unbound_server.pem -days 365 - name: Generate client key run: | openssl genrsa -out tests/key/unbound_control.key 2048 - openssl req -new -key tests/key/unbound_control.key -out unbound_control.csr -subj "/CN=client" - openssl x509 -req -in tests/key/unbound_control.csr -signkey unbound_control.key -out unbound_control.pem -days 365 + openssl req -new -key tests/key/unbound_control.key -out tests/key/unbound_control.csr -subj "/CN=client" + openssl x509 -req -in tests/key/unbound_control.csr -signkey tests/key/unbound_control.key -out tests/key/unbound_control.pem -days 365 - name: List generated files run: ls -l test: name: Test + needs: generate-certs runs-on: ubuntu-latest strategy: matrix: From 87dea167f7849cca64d198c12514e279502d8b30 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Fri, 29 Nov 2024 00:22:18 +0900 Subject: [PATCH 23/24] CI workflow: Upload and download generated keys as artifacts --- .github/workflows/ci.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0554325..dcadef3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,15 +37,16 @@ jobs: openssl genrsa -out tests/key/unbound_server.key 2048 openssl req -new -key tests/key/unbound_server.key -out tests/key/unbound_server.csr -subj "/CN=server" openssl x509 -req -in tests/key/unbound_server.csr -signkey tests/key/unbound_server.key -out tests/key/unbound_server.pem -days 365 - - name: Generate client key run: | openssl genrsa -out tests/key/unbound_control.key 2048 openssl req -new -key tests/key/unbound_control.key -out tests/key/unbound_control.csr -subj "/CN=client" openssl x509 -req -in tests/key/unbound_control.csr -signkey tests/key/unbound_control.key -out tests/key/unbound_control.pem -days 365 - - - name: List generated files - run: ls -l + - name: Upload generated keys + uses: actions/upload-artifact@v4 + with: + name: test-keys + path: tests/key/ test: name: Test needs: generate-certs @@ -55,6 +56,11 @@ jobs: node-version: [16.x, 18.x, 20.x, 22.x] steps: - uses: actions/checkout@v4 + - name: Download generated keys + uses: actions/download-artifact@v4 + with: + name: test-keys + path: tests/key/ - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: From b97930208f0625d47fe1898d52df481e6d563257 Mon Sep 17 00:00:00 2001 From: tsutsu3 Date: Sun, 1 Dec 2024 14:07:42 +0900 Subject: [PATCH 24/24] Add tls test --- .env.example | 12 ++++++ .gitignore | 3 +- docker-compose.yml | 1 + tests/control.it.test.ts | 60 +++++++++++++++++++++++++++++- tests/control.snapshot.test.ts | 63 +++++++++++++++++++++++++++++++- unbound-config/unix/unbound.conf | 18 ++++----- 6 files changed, 144 insertions(+), 13 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3632bdd --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Unbound Configuration Path +# default: ./unbound-config/unix +# UNBOUND_CONF_PATH=./unbound-config/unix +# UNBOUND_CONF_PATH=./unbound-config/tls + +# Unbound Version +# default: latest +# UNBOUND_VERSION=latest + +# Unbound DNS, Control Port +# UNBOUND_DNS_PORT=53 +# UNBOUND_CONTROL_PORT=8953 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0f3b5b2..1ea7379 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ unbound-config/*/* !unbound-config/unix/unbound.conf tests/__snapshots__ tests/key/* -!.gitkeep \ No newline at end of file +!.gitkeep +.env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 106517b..9c727fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: unbound-control-setup && mkdir /opt/unbound/etc/unbound/key /opt/unbound/etc/unbound/socket && mv /opt/unbound/etc/unbound/unbound_* /opt/unbound/etc/unbound/key && + chown 1000 /opt/unbound/etc/unbound/key/* && echo 'Setup complete'; else echo 'Certificates already exist'; diff --git a/tests/control.it.test.ts b/tests/control.it.test.ts index 0946502..4ffe8c4 100644 --- a/tests/control.it.test.ts +++ b/tests/control.it.test.ts @@ -1,7 +1,8 @@ -import { UnixUnboundClient } from "../src/index"; +import { UnixUnboundClient, TcpUnboundClient } from "../src/index"; import fs from "fs"; import path from "path"; import YAML from "yaml"; +import { TlsConfig } from "../src/types"; const baseDir = path.resolve(__dirname); const unboundVersion = process.env.UNBOUND_VERSION || "1.22.0"; @@ -67,3 +68,60 @@ describe(`Unix domain socket docker server tests. Unbound version: ${unboundVers } } }); + +describe(`TCP socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let client: TcpUnboundClient; + const keyPath = path.join( + baseDir, + "../unbound-config/tls/key/unbound_control.key", + ); + const certPath = path.join( + baseDir, + "../unbound-config/tls/key/unbound_control.pem", + ); + const tlsConfig: TlsConfig = { + cert: certPath, + key: keyPath, + }; + + beforeAll(() => { + client = new TcpUnboundClient("localhost", 8953, tlsConfig); + }); + + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".yaml")); + + for (const file of files) { + const command = file.replace(".yaml", ""); + const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); + const contents = YAML.parse(fileContent) as ParsedData; + + for (const { title, options, exception } of contents.data) { + it(`${command} test: ${title}`, async () => { + console.log(`Running ${command} test: ${title}`); + + const method = client[command as keyof UnixUnboundClient].bind( + client, + ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + if (typeof method !== "function") { + throw new Error(`Invalid command: ${command}`); + } + + const args = + options !== undefined + ? Array.isArray(options) + ? options + : [options] + : []; + + if (exception) { + await expect(method.apply(client, args)).rejects.toThrow(exception); + } else { + await expect(method.apply(client, args)).resolves.not.toThrow(); + } + await client.disconnect(); + }); + } + } +}); diff --git a/tests/control.snapshot.test.ts b/tests/control.snapshot.test.ts index a87669b..f69e69a 100644 --- a/tests/control.snapshot.test.ts +++ b/tests/control.snapshot.test.ts @@ -1,7 +1,8 @@ -import { UnixUnboundClient } from "../src/index"; import fs from "fs"; import path from "path"; import YAML from "yaml"; +import { UnixUnboundClient, TcpUnboundClient } from "../src/index"; +import { TlsConfig } from "../src/types"; const baseDir = path.resolve(__dirname); const unboundVersion = process.env.UNBOUND_VERSION || "1.22.0"; @@ -58,7 +59,65 @@ describe(`Unix domain socket docker server tests. Unbound version: ${unboundVers : []; try { - // メソッド呼び出し + const result = await method.apply(client, args); // eslint-disable-line @typescript-eslint/no-unsafe-assignment + expect(result).toMatchSnapshot(); + } catch (error) { + expect((error as Error).message).toMatchSnapshot(); + } finally { + await client.disconnect(); + } + }); + } + } +}); + +describe(`TCP socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let client: TcpUnboundClient; + const keyPath = path.join( + baseDir, + "../unbound-config/tls/key/unbound_control.key", + ); + const certPath = path.join( + baseDir, + "../unbound-config/tls/key/unbound_control.pem", + ); + const tlsConfig: TlsConfig = { + cert: certPath, + key: keyPath, + }; + + beforeAll(() => { + client = new TcpUnboundClient("localhost", 8953, tlsConfig); + }); + + const files = fs + .readdirSync(dataDir) + .filter((file) => file.endsWith(".yaml")); + + for (const file of files) { + const command = file.replace(".yaml", ""); + const fileContent = fs.readFileSync(path.join(dataDir, file), "utf-8"); + const contents = YAML.parse(fileContent) as ParsedData; + + for (const { title, options } of contents.data) { + it(`${command} test: ${title}`, async () => { + console.log(`Running ${command} test: ${title}`); + + const method = client[command as keyof UnixUnboundClient].bind( + client, + ) as (...args: any[]) => Promise; // eslint-disable-line @typescript-eslint/no-explicit-any + if (typeof method !== "function") { + throw new Error(`Invalid command: ${command}`); + } + + const args = + options !== undefined + ? Array.isArray(options) + ? options + : [options] + : []; + + try { const result = await method.apply(client, args); // eslint-disable-line @typescript-eslint/no-unsafe-assignment expect(result).toMatchSnapshot(); } catch (error) { diff --git a/unbound-config/unix/unbound.conf b/unbound-config/unix/unbound.conf index 1dcaafd..1653fae 100644 --- a/unbound-config/unix/unbound.conf +++ b/unbound-config/unix/unbound.conf @@ -1,9 +1,9 @@ -server: - interface: 0.0.0.0 - logfile: "" - verbosity: 1 - # extended-statistics: yes - - remote-control: - control-enable: yes - control-interface: /opt/unbound/etc/unbound/socket/unbound.ctl +server: + interface: 0.0.0.0 + logfile: "" + verbosity: 1 + # extended-statistics: yes + + remote-control: + control-enable: yes + control-interface: /opt/unbound/etc/unbound/socket/unbound.ctl