diff --git a/tooling-cli/dns/.env.example b/tooling-cli/dns/.env.example new file mode 100644 index 0000000..151d32e --- /dev/null +++ b/tooling-cli/dns/.env.example @@ -0,0 +1,5 @@ +# Technitium DNS connection details +# Copy this file to .env and fill in real values. The .env file is gitignored. +TECHNITIUM_URL=http://pfv-netinfra-01:5380 +TECHNITIUM_DNS_TOKEN=your-dns-api-token-here +TECHNITIUM_DNS_ZONE=knel.net diff --git a/tooling-cli/dns/.gitignore b/tooling-cli/dns/.gitignore new file mode 100644 index 0000000..78099e7 --- /dev/null +++ b/tooling-cli/dns/.gitignore @@ -0,0 +1,8 @@ +# Secrets - never commit +.env + +# OS / editor cruft +.DS_Store +*.swp +*.swo +*~ diff --git a/tooling-cli/dns/AGENTS.md b/tooling-cli/dns/AGENTS.md new file mode 100644 index 0000000..b720e68 --- /dev/null +++ b/tooling-cli/dns/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md — DNS CLI tooling + +This directory holds the `dns-cli` Docker container — a bash CLI over the +Technitium DNS Server REST API. Every Crush session that works on or with +this tool should read this file first. + +## Connection & invocation + +Invoke the real container with `docker run`. Credentials come from the +centralized credential store (see `tooling-cli/KNELCredsManager`): + +```bash +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest +``` + +There is intentionally **no `bin/` wrapper** — invoke the real container, as +the project-wide rules require. For readability in an interactive session you +may define a throwaway shell alias (do not commit one): + +```bash +alias dns='docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest' +``` + +The examples below write `dns ` for brevity; expand it to the full +`docker run` line (or set the alias) before running. + +| Item | Value | +|------|-------| +| Instance | `http://pfv-netinfra-01.knel.net:5380` | +| Default zone | `knel.net` | +| Credentials | `~/.creds/technitium.env` (`TECHNITIUM_URL`, `TECHNITIUM_DNS_TOKEN`, `TECHNITIUM_DNS_ZONE`) | +| CLI image | `git.knownelement.com/reachableceo/dns-cli:latest` | +| Source | `src/dns_cli.sh` in this directory | + +## Quick start + +```bash +# List all zones: +dns zones + +# List records in the default zone: +dns list + +# Search for a record: +dns search ns1 + +# Resolve a name: +dns get pfv-netinfra-01 +``` + +## Command reference + +| Command | Description | +|---------|-------------| +| `zones` | List all zones. | +| `list [zone]` | List records in a zone (default: `TECHNITIUM_DNS_ZONE`). | +| `get ` | Resolve a record via `dig` (A/AAAA). | +| `add [ttl]` | Add an A record with auto-PTR (default TTL 3600). | +| `delete ` | Delete an A record. | +| `flush` | Flush the DNS cache. | +| `search ` | Search records by name pattern (case-insensitive). | + +## Patterns + +### Pattern: add a new DNS record +1. `dns search ` — check if the name already exists. +2. `dns add ` — creates the A record with auto-PTR. +3. `dns get ` — verify resolution. +4. `dns flush` — flush cache so the new record is immediately resolvable. + +### Pattern: before you act +- Always `dns search ` or `dns list` before adding/deleting — + confirm current state so you don't create duplicates or delete the wrong + record. +- **Never delete a record** unless the user explicitly asks. +- PTR records are auto-created on `add` and auto-deleted on `delete`. + +## Git workflow + +### Atomic commits +Each commit is **one logical change**. Stage precisely (`git add `, +not `git add -A`). + +### Conventional commit messages +``` +(): + + +``` + +Types: `feat`, `fix`, `docs`, `refactor`, `chore`, `style`. + +- Subject line under 72 chars, lowercase, imperative mood. +- No period at end of subject. +- Body wrapped at 72 chars, explains **why** the change exists. diff --git a/tooling-cli/dns/Dockerfile b/tooling-cli/dns/Dockerfile new file mode 100644 index 0000000..34361f2 --- /dev/null +++ b/tooling-cli/dns/Dockerfile @@ -0,0 +1,9 @@ +FROM alpine:3.20 + +RUN apk add --no-cache bash curl python3 bind-tools + +COPY src/dns_cli.sh /usr/local/bin/dns-cli +RUN chmod +x /usr/local/bin/dns-cli + +ENTRYPOINT ["dns-cli"] +CMD ["--help"] diff --git a/tooling-cli/dns/README.md b/tooling-cli/dns/README.md new file mode 100644 index 0000000..9d1c38d --- /dev/null +++ b/tooling-cli/dns/README.md @@ -0,0 +1,82 @@ +# dns-cli + +A Docker container that lets an AI agent (or a human) manage DNS records on a +[Technitium DNS Server](https://technitium.com/dns/) instance through its REST +API. A small bash CLI built on `curl` + `python3` + `dig`. + +## Requirements + +- Docker on the host. +- A Technitium DNS Server instance with the REST API enabled. +- A DNS API token (Technitium → Settings → API Token). + +## Configuration + +Credentials live in the **centralized credential store** at +`~/.creds/technitium.env` (see `tooling-cli/KNELCredsManager`). It holds: + +``` +TECHNITIUM_URL=http://pfv-netinfra-01:5380 +TECHNITIUM_DNS_TOKEN=your-api-token +TECHNITIUM_DNS_ZONE=knel.net +``` + +Use `.env.example` in this directory as a template if you need to create one. +Permissions: directory `700`, the env file `600` (owner read/write only). + +## Build + +```bash +docker build -t git.knownelement.com/reachableceo/dns-cli:latest . +``` + +The image is also available in the Gitea registry: + +``` +git.knownelement.com/reachableceo/dns-cli:latest +``` + +## Usage + +Invoke the container directly with `docker run`. Pass credentials from the +centralized store via `--env-file`: + +```bash +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest zones + +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest list + +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest search ns1 + +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest add myhost 192.168.1.50 + +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest delete myhost 192.168.1.50 + +docker run --rm --env-file ~/.creds/technitium.env \ + git.knownelement.com/reachableceo/dns-cli:latest flush +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `zones` | List all zones. | +| `list [zone]` | List records in a zone (default: `TECHNITIUM_DNS_ZONE`). | +| `get ` | Resolve a record via `dig` (A/AAAA). | +| `add [ttl]` | Add an A record with auto-PTR (default TTL 3600). | +| `delete ` | Delete an A record. | +| `flush` | Flush the DNS cache. | +| `search ` | Search records by name pattern (case-insensitive). | + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TECHNITIUM_URL` | yes | Base URL of the Technitium instance. | +| `TECHNITIUM_DNS_TOKEN` | yes | API token for the DNS REST API. | +| `TECHNITIUM_DNS_ZONE` | yes | Default zone for add/delete/search operations. | diff --git a/tooling-cli/dns/src/dns_cli.sh b/tooling-cli/dns/src/dns_cli.sh new file mode 100755 index 0000000..c5f4a11 --- /dev/null +++ b/tooling-cli/dns/src/dns_cli.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# dns-cli - CLI wrapper for Technitium DNS Server API +# +# Usage: +# dns-cli zones List all zones +# dns-cli list [zone] List records in a zone (default: $TECHNITIUM_DNS_ZONE) +# dns-cli get Resolve a record (A/AAAA) +# dns-cli add [ttl] Add A record (+ auto PTR) +# dns-cli delete Delete A record +# dns-cli flush Flush DNS cache +# dns-cli search Search records by name pattern +# +# Env (provided via --env-file ~/.creds/technitium.env): +# TECHNITIUM_URL Technitium base URL (required) +# TECHNITIUM_DNS_TOKEN API token (required) +# TECHNITIUM_DNS_ZONE Default zone (required) + +set -euo pipefail + +URL="${TECHNITIUM_URL:?TECHNITIUM_URL is required}" +TOKEN="${TECHNITIUM_DNS_TOKEN:?TECHNITIUM_DNS_TOKEN is required}" +ZONE="${TECHNITIUM_DNS_ZONE:?TECHNITIUM_DNS_ZONE is required}" + +# API helper — adds Bearer auth, returns JSON +api() { + local method="$1" endpoint="$2"; shift 2 + local args=() + for kv in "$@"; do + args+=(-d "$kv") + done + curl -s -H "Authorization: Bearer $TOKEN" -X "$method" "${args[@]}" \ + "$URL/api/$endpoint" 2>/dev/null +} + +# Subcommands +cmd_zones() { + api GET "zones/list" | python3 -c " +import json,sys +d=json.load(sys.stdin) +for z in d.get('response',{}).get('zones',[]): + print(z['name']) +" +} + +cmd_list() { + local zone="${1:-$ZONE}" + api GET "zones/records/get?domain=$zone&zone=$zone&listZone=true" | python3 -c " +import json,sys +d=json.load(sys.stdin) +recs = d.get('response',{}).get('records',[]) +for r in sorted(recs, key=lambda x: (x['name'], x['type'])): + rd = r.get('rData',{}) + ip = rd.get('ipAddress','') + val = ip or rd.get('nameServer','') or rd.get('primaryNameServer','') or str(rd) + print(f\"{r['name']:50s} {r['type']:6s} {r.get('ttl',''):6} {val}\") +" +} + +cmd_get() { + local name="$1" + dig +short "$name" 2>/dev/null || true +} + +cmd_add() { + local name="$1" ip="$2" ttl="${3:-3600}" + local fqdn="$name" + [[ "$fqdn" != *.* ]] && fqdn="$name.$ZONE" + api POST "zones/records/add" \ + "zone=$ZONE" "domain=$fqdn" "type=A" "ttl=$ttl" "ipAddress=$ip" "ptr=true" "overwrite=true" | python3 -c " +import json,sys +d=json.load(sys.stdin) +print(d.get('status','?'), d.get('errorMessage','')) +" +} + +cmd_delete() { + local name="$1" ip="$2" + local fqdn="$name" + [[ "$fqdn" != *.* ]] && fqdn="$name.$ZONE" + api POST "zones/records/delete" \ + "zone=$ZONE" "domain=$fqdn" "type=A" "ipAddress=$ip" | python3 -c " +import json,sys +d=json.load(sys.stdin) +print(d.get('status','?'), d.get('errorMessage','')) +" +} + +cmd_flush() { + api POST "cache/flush" | python3 -c " +import json,sys +d=json.load(sys.stdin) +print(d.get('status','?')) +" +} + +cmd_search() { + local pattern="$1" + api GET "zones/records/get?domain=$ZONE&zone=$ZONE&listZone=true" | python3 -c " +import json,sys +d=json.load(sys.stdin) +recs = d.get('response',{}).get('records',[]) +for r in sorted(recs, key=lambda x: x['name']): + if '$pattern' in r['name'].lower(): + rd = r.get('rData',{}) + ip = rd.get('ipAddress','') + val = ip or rd.get('nameServer','') or str(rd) + print(f\"{r['name']:50s} {r['type']:6s} {val}\") +" +} + +# Main +case "${1:-}" in + zones) cmd_zones ;; + list) shift; cmd_list "${1:-}" ;; + get) shift; cmd_get "$1" ;; + add) shift; cmd_add "$@" ;; + delete) shift; cmd_delete "$@" ;; + flush) cmd_flush ;; + search) shift; cmd_search "$1" ;; + ""|-h|--help|help) + sed -n '2,18p' "$0" >&2 + exit 0 + ;; + *) echo "Unknown command: $1" >&2; exit 1 ;; +esac diff --git a/tooling-cli/dns/validate.sh b/tooling-cli/dns/validate.sh new file mode 100755 index 0000000..88b1137 --- /dev/null +++ b/tooling-cli/dns/validate.sh @@ -0,0 +1,81 @@ +#!/bin/sh +# dns-cli validation script. +# Runs a live read-only cycle against the configured Technitium instance. +# +# Usage: ./validate.sh +# Exit codes: 0 = all checks passed, 1 = one or more checks failed +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +ENV_FILE="${TECHNITIUM_ENV_FILE:-$HOME/.creds/technitium.env}" + +if [ ! -f "$ENV_FILE" ]; then + echo "FAIL: env file not found: $ENV_FILE" + echo " Set TECHNITIUM_ENV_FILE or create it in ~/.creds/technitium.env" + exit 1 +fi + +IMAGE="${DNS_CLI_IMAGE:-git.knownelement.com/reachableceo/dns-cli:latest}" +PASS=0 +FAIL=0 +TOTAL=0 + +ok() { TOTAL=$((TOTAL+1)); echo "PASS: $1"; PASS=$((PASS+1)); } +fail() { TOTAL=$((TOTAL+1)); echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +echo "============================================" +echo " dns-cli validation" +echo " image: ${IMAGE}" +echo "============================================" +echo "" + +# ---------------------------------------------------------------- # +# 1. zones (live read) +# ---------------------------------------------------------------- # +echo "--- [1] zones ---" +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" zones 2>&1) || true +if echo "$OUT" | grep -qE '\.'; then + ok "zones returned data" + echo "$OUT" | head -5 +else + fail "zones did not return expected output" + echo " $OUT" | head -5 +fi +echo "" + +# ---------------------------------------------------------------- # +# 2. list (live read) +# ---------------------------------------------------------------- # +echo "--- [2] list ---" +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" list 2>&1) || true +if echo "$OUT" | grep -qE 'A\s'; then + ok "list returned records" + echo "$OUT" | head -5 +else + fail "list did not return expected output" + echo " $OUT" | head -5 +fi +echo "" + +# ---------------------------------------------------------------- # +# 3. search (live read) +# ---------------------------------------------------------------- # +echo "--- [3] search ---" +OUT=$(docker run --rm --env-file "$ENV_FILE" "${IMAGE}" search "ns" 2>&1) || true +if [ -n "$OUT" ]; then + ok "search returned results" + echo "$OUT" | head -3 +else + fail "search returned no results" +fi +echo "" + +# ---------------------------------------------------------------- # +# Results +# ---------------------------------------------------------------- # +echo "============================================" +echo " RESULTS: ${PASS}/${TOTAL} passed, ${FAIL} failed" +echo "============================================" +[ "$FAIL" -eq 0 ]