diff --git a/.env b/.env index 9ef2dea..3632bdd 100644 --- a/.env +++ b/.env @@ -1,6 +1,12 @@ # 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 + +# Unbound DNS, Control Port +# UNBOUND_DNS_PORT=53 +# UNBOUND_CONTROL_PORT=8953 \ No newline at end of file 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6abfac6..dcadef3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,14 +24,43 @@ 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.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: Upload generated keys + uses: actions/upload-artifact@v4 + with: + name: test-keys + path: tests/key/ test: name: Test + needs: generate-certs runs-on: ubuntu-latest strategy: matrix: 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: diff --git a/.github/workflows/it.yml b/.github/workflows/it.yml new file mode 100644 index 0000000..1fddac0 --- /dev/null +++ b/.github/workflows/it.yml @@ -0,0 +1,92 @@ +# TODO: Fix Unix domain socket permissions + +# name: Test Unbound with Multiple Versions + +# on: +# push: +# branches: +# - main +# pull_request: +# branches: +# - main + +# jobs: +# test-unbound: +# runs-on: ubuntu-latest + +# 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 + +# 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 }} \ +# 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: 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: Fix permissions inside container +# run: | +# docker exec unbound-${{ matrix.unbound-version }} chmod 777 /opt/unbound/etc/unbound/socket/unbound.ctl + +# - name: Set permissions for Unix socket +# run: ls -Rl "$GITHUB_WORKSPACE/unbound-config" + +# - 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 }} diff --git a/.gitignore b/.gitignore index 0539f8b..1ea7379 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,10 @@ dist-ssr coverage/ -unbound-config/* -!unbound-config/unbound.conf +unbound-config/*/* +!unbound-config/tls/unbound.conf +!unbound-config/unix/unbound.conf +tests/__snapshots__ +tests/key/* +!.gitkeep +.env \ 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/README.md b/README.md index 7fddb3b..84085fc 100644 --- a/README.md +++ b/README.md @@ -89,23 +89,69 @@ yarn add unbound-control-ts Here's a basic example to demonstrate how to use the library: +Use domain socket: ```ts -import { UnboundControl } from 'unbound-control-ts'; +import { UnixUnboundClient } 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 UnixUnboundClient('/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); + } } -} +})(); +``` + +Use tcp socket: +```ts +import { TcpUnboundClient } from 'unbound-control-ts'; + +const client = new TcpUnboundClient('localhost', 8953); -getStats(); +(async () => { + try { + const response = await client.status(); + console.log(response); + } catch (error) { + if (error instanceof UnboundError) { + console.error(error.message); + } else { + console.error(error); + } + } +})(); +``` + +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 @@ -117,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/docker-compose.yml b/docker-compose.yml index e8e3954..9c727fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1,32 @@ -services: - unbound-setup: - image: mvance/unbound:latest - volumes: - - ${UNBOUND_CONF_PATH}:/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 && - mv /opt/unbound/etc/unbound/unbound_* /opt/unbound/etc/unbound/key && - echo 'Setup complete'; - else - echo 'Certificates already exist'; - fi - " - unbound: - image: mvance/unbound:latest - container_name: unbound - ports: - - "53:53/tcp" - - "53:53/udp" - - "8953:8953" - volumes: - - ${UNBOUND_CONF_PATH}:/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 && + chown 1000 /opt/unbound/etc/unbound/key/* && + echo 'Setup complete'; + else + echo 'Certificates already exist'; + fi + " + unbound: + image: mvance/unbound:${UNBOUND_VERSION:-latest} + container_name: unbound-${UNBOUND_VERSION:-latest} + ports: + - "${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 + depends_on: + - unbound-setup diff --git a/examples/index.cjs b/examples/index.cjs index 2a96d20..a56dbbd 100644 --- a/examples/index.cjs +++ b/examples/index.cjs @@ -1,4 +1,39 @@ -const { hello } = require("../../dist/index.cjs"); +const path = require("path"); +const { UnboundControlClient, UnboundError } = require("../dist/index.cjs"); -const result = hello(); -console.log(`CJS Result: ${result}`); +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 f513219..250a881 100644 --- a/examples/index.mjs +++ b/examples/index.mjs @@ -1,4 +1,43 @@ -import { hello } from "../../dist/index.mjs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { UnboundControlClient, UnboundError } from "../dist/index.mjs"; -const result = hello(); -console.log(`ESM Result: ${result}`); +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/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", 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..158ead8 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,12 @@ "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 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" }, "devDependencies": { @@ -53,10 +56,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..f75766f --- /dev/null +++ b/src/client.ts @@ -0,0 +1,871 @@ +import { UnboundControl } from "./control"; +import { ParseError } from "./error"; +import { Response, TlsConfig, NestedRecord, ValidOption } from "./types"; + +abstract class UnboundControlClient { + private control: UnboundControl; + + constructor( + unixSocketName?: string, + host?: string, + port?: number, + tlsConfig?: TlsConfig, + ) { + if (unixSocketName) { + this.control = new UnboundControl(unixSocketName); + } else { + this.control = new UnboundControl(undefined, host, port, tlsConfig); + } + } + + /** + * 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 = {}; + + for (const line of lines) { + const [key, value] = line.split("=").map((str) => str.trim()); + + if (!key) { + 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 = 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 ==================== + + /** + * Start the server. + */ + 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 { + 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 { + 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 { + const raw = await this.control.sendCommand("reload_keep_cache"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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 { + const raw = await this.control.sendCommand("log_reopen"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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 = {}; + + 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 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 { + 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 { + 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 { + const raw = await this.control.sendCommand("local_zones_remove"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + const raw = await this.control.sendCommand("load_cache"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async lookup(name: string): Promise { + const raw = await this.control.sendCommand(`lookup ${name}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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}`; + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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}`; + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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}`; + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async flush_bogus(useCachedb: boolean = false): Promise { + const command = useCachedb ? "flush_bogus +c" : "flush_bogus"; + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async flush_negative(useCachedb: boolean = false): Promise { + const command = useCachedb ? "flush_negative +c" : "flush_negative"; + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + const raw = await this.control.sendCommand("flush_requestlist"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + this.checkValidIp(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 { + const raw = await this.control.sendCommand("dump_infra"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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 { + const raw = await this.control.sendCommand( + `set_option ${option}: ${value}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * Get the value of the option. + * @param option + * @returns + */ + 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 { + const raw = await this.control.sendCommand("list_stubs"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + const raw = await this.control.sendCommand("list_insecure"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + const raw = await this.control.sendCommand("list_local_data"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + const raw = await this.control.sendCommand(`insecure_remove ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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(); + + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async forward_remove( + zone: string, + insecure: boolean = false, + ): Promise { + const flags = insecure ? "+i" : ""; + const raw = await this.control.sendCommand( + `forward_remove ${flags} ${zone}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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(); + + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async stub_remove( + zone: string, + insecure: boolean = false, + ): Promise { + const flags = insecure ? "+i" : ""; + 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 { + if (typeof addresses === "string") { + this.checkValidIp(addresses); + const raw = await this.control.sendCommand(`forward ${addresses}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + for (const address of addresses) { + this.checkValidIp(address); + } + + const raw = await this.control.sendCommand( + `forward ${addresses.join(" ")}`, + ); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + /** + * 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(); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async ip_ratelimit_list( + allDomains: boolean = false, + ): Promise { + const command = `ip_ratelimit_list ${allDomains ? "+a" : ""}`.trim(); + const raw = await this.control.sendCommand(command); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + 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 { + const raw = await this.control.sendCommand(`auth_zone_transfer ${zone}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const raw = await this.control.sendCommand(`add_cookie_secret ${secret}`); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + 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 { + const raw = await this.control.sendCommand("activate_cookie_secret"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } + + public async print_cookie_secrets(): Promise { + const raw = await this.control.sendCommand("print_cookie_secrets"); + return { + raw: raw, + json: { todo: "Not implemented" }, + }; + } +} + +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(undefined, host, port, tlsConfig); + } +} diff --git a/src/control.ts b/src/control.ts new file mode 100644 index 0000000..5fda53b --- /dev/null +++ b/src/control.ts @@ -0,0 +1,170 @@ +import net from "net"; +import tls from "tls"; +import fs from "fs"; +import { ConnectionError, CommandError } from "./error"; +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; + + /** The host address for TCP connections. */ + private readonly host: string; + + /** The port number for TCP connections. */ + private readonly port: number; + + /** Optional TLS configuration for secure connections. */ + private readonly tlsConfig?: TlsConfig; + + /** The underlying network socket for communication. */ + private socket?: net.Socket | 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, + host: string = "localhost", + port: number = 8953, + tlsConfig?: TlsConfig, + ) { + this.unixSocketName = unixSocketName; + this.host = host; + this.port = port; + this.tlsConfig = tlsConfig; + } + + /** + * 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) { + socket = net.createConnection(this.unixSocketName); + } else { + if (this.tlsConfig) { + // 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 { + // Connect via plain TCP + socket = net.createConnection(this.port, this.host, () => { + resolve(socket); + }); + } + } + + 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..bf7c4b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,9 @@ -export function hello() { - return "Hello, World!"; -} +export { UnboundControl } from "./control"; +export { UnixUnboundClient, TcpUnboundClient } from "./client"; +export { + UnboundError, + ConnectionError, + CommandError, + ParseError, + UnsupportedCommandError, +} from "./error"; 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 new file mode 100644 index 0000000..4ffe8c4 --- /dev/null +++ b/tests/control.it.test.ts @@ -0,0 +1,127 @@ +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"; +const dataDir = path.join(baseDir, "data", unboundVersion); + +interface ParsedData { + data: TestCase[]; +} + +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; +} + +describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let client: UnixUnboundClient; + const unixSocketPath = path.join( + baseDir, + "../unbound-config/unix/socket/unbound.ctl", + ); + + beforeAll(() => { + client = new UnixUnboundClient(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, 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(); + }); + } + } +}); + +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 new file mode 100644 index 0000000..f69e69a --- /dev/null +++ b/tests/control.snapshot.test.ts @@ -0,0 +1,131 @@ +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"; +const dataDir = path.join(baseDir, "data", unboundVersion); + +interface ParsedData { + data: TestCase[]; +} + +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; +} + +describe(`Unix domain socket docker server tests. Unbound version: ${unboundVersion}`, () => { + let client: UnixUnboundClient; + const unixSocketPath = path.join( + baseDir, + "../unbound-config/unix/socket/unbound.ctl", + ); + + beforeAll(() => { + client = new UnixUnboundClient(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, 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) { + 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) { + expect((error as Error).message).toMatchSnapshot(); + } finally { + await client.disconnect(); + } + }); + } + } +}); diff --git a/tests/control.test.ts b/tests/control.test.ts new file mode 100644 index 0000000..5ea00df --- /dev/null +++ b/tests/control.test.ts @@ -0,0 +1,155 @@ +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"; +const dataDir = path.join(baseDir, "data", unboundVersion); + +interface ParsedData { + data: TestCase[]; +} + +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 Response { + 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: UnixUnboundClient; + const unixSocketPath = "/tmp/mock.sock"; + + beforeAll(() => { + if (fs.existsSync(unixSocketPath)) { + fs.unlinkSync(unixSocketPath); + } + server = new UnixMockServer(unixSocketPath); + client = new UnixUnboundClient(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, 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(); + }); + } + } +}); + +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(); + }); + } + } +}); diff --git a/tests/data/1.22.0/stats.yaml b/tests/data/1.22.0/stats.yaml new file mode 100644 index 0000000..baf8c18 --- /dev/null +++ b/tests/data/1.22.0/stats.yaml @@ -0,0 +1,425 @@ +data: + - 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 + 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=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 + 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: 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/status.yaml b/tests/data/1.22.0/status.yaml new file mode 100644 index 0000000..504bcc7 --- /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/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 new file mode 100644 index 0000000..46315dd --- /dev/null +++ b/tests/data/template.yaml @@ -0,0 +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/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/key/.gitkeep b/tests/key/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/mockServer.ts b/tests/mockServer.ts new file mode 100644 index 0000000..e8e5902 --- /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/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..1653fae --- /dev/null +++ b/unbound-config/unix/unbound.conf @@ -0,0 +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 diff --git a/vite.config.ts b/vite.config.ts index 22d228e..16faab7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ }, }, rollupOptions: { + external: ["net", "fs", "path"], output: { dir: "dist", }, @@ -23,6 +24,8 @@ export default defineConfig({ plugins: [ dts({ tsconfigPath: "./tsconfig.json", + rollupTypes: true, + outDir: "dist", }), ], });