diff --git a/Magefile.go b/Magefile.go index da87d16..94e2a10 100644 --- a/Magefile.go +++ b/Magefile.go @@ -4,6 +4,7 @@ package main import ( + "bytes" "fmt" "os" "os/exec" @@ -13,7 +14,7 @@ import ( const Name = "dnsmasq-web" -var Default = Binaries +var Default = All // Get the build info from git and add a datetime stamp func buildInfo() (string, error) { @@ -55,12 +56,56 @@ func runBuild(name string, envVars ...string) error { return cmd.Run() } -// Build the dnsmasq-web for the current architecture -func Binary() error { - return runBuild(Name) +// Build the client POSIX shell environment +func createClientEnv(full, minify bool) error { + scripts := []string{"use.sh", "token.sh", "curl.sh"} + + if full { + scripts = append(scripts, "curl_jq.sh", "jq_commands.sh", "reservations.sh") + } + + var script bytes.Buffer + + script.WriteString("# Dnsmasq Web Client environment") + for _, scriptName := range scripts { + content, err := os.ReadFile("cli/" + scriptName) + if err != nil { + return fmt.Errorf("failed to read %s: %v", scriptName, err) + } + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "#!") { + script.WriteString(line + "\n") + } + } + } + + cmdArgs := []string{"shfmt", "-p"} + if minify { + cmdArgs = append(cmdArgs, "-mn") + } + cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + cmd.Stdin = &script + output, err := cmd.Output() + if err != nil { + return fmt.Errorf("failed to minify script: %v", err) + } + + return os.WriteFile(fmt.Sprintf("%s.env", Name), output, 0755) } -// Build binaries for amd64 and arm64 architectures +// Build the client environment +func ClientEnv() error { + return createClientEnv(false, true) +} + +// Build the full client environment +func FullClientEnv() error { + return createClientEnv(true, false) +} + +// Build binaries for pre-selected architectures func Binaries() error { for _, combo := range []struct { CC string @@ -83,11 +128,15 @@ func Binaries() error { return nil } -// Clean the project -func Clean() error { - if err := os.RemoveAll("dnsmasq-web"); err != nil { +// Build the client environment and the binaries +func All() error { + if err := ClientEnv(); err != nil { return err } + return Binaries() +} + +func CleanBinaries() error { for _, output := range []string{ "dnsmasq-web-amd64", "dnsmasq-web-arm64", @@ -98,3 +147,17 @@ func Clean() error { } return nil } + +func CleanClientEnv() error { + return os.RemoveAll(fmt.Sprintf("%s.env", Name)) +} + + +func Clean() { + if err := CleanClientEnv(); err != nil { + fmt.Println(err) + } + if err := CleanBinaries(); err != nil { + fmt.Println(err) + } +} diff --git a/README.md b/README.md index 77c5ffa..68184ff 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,12 @@ [![Release](https://github.com/amigus/dnsmasq-web/actions/workflows/release.yml/badge.svg)](https://github.com/amigus/dnsmasq-web/actions/workflows/release.yml) [![Run Tests](https://github.com/amigus/dnsmasq-web/actions/workflows/test.yml/badge.svg)](https://github.com/amigus/dnsmasq-web/actions/workflows/test.yml) -A JSON/HTTP interface for Dnsmasq. -It makes it easy to maintain DHCP reservations and query client, lease and request information easily over HTTP. -It stores the client, lease and request information in an +A JSON/HTTP interface for Dnsmasq and client interface written in POSIX shell. +It extends Dnsmasq using the `dhcp-script` and `dhcp-hostsdir` configuration parameters. +The script maintains client, lease and request information in an [SQLite](https://www.sqlite.org/index.html) [database](https://gist.github.com/amigus/6a9e4151d175d04bf05337b815f2213e). - -It leverages the `dhcp-script` and `dhcp-hostsdir` configuration options of Dnsmasq. -The `dhcp-script` maintains the database it queries. -It also maintains reservations files in the `dhcp-hostsdir` directory. +The DHCP reservation data is stored in files under the host directory. ## Installation @@ -19,6 +16,27 @@ It also maintains reservations files in the `dhcp-hostsdir` directory. 1. Download the appropriate binary from the [releases](https://github.com/amigus/dnsmasq-web/releases/latest) page to the DHCP server. 1. Run it, e.g., `dnsmasq-web` or as a daemon with `sudo dnsmasq-web -d -l :80 -T 0`. +## Client + +The [cli](cli) directory contains a client interface written in POSIX shell. + +The scripts define functions when sourced into the environment, e.g.: + +```sh +. /path/to/dnsmasq-web.env +dnsmasq_web_curl addresses/bc:32:b2:3b:13:d4 | jq +[ + { + "ipv4": "192.168.1.9", + "first_seen": "2024-09-03 10:07:21", + "last_seen": "2024-09-03 12:37:22", + "requested_options": "", + "hostname": "Adam-s-Phone", + "vendor_class": "android-dhcp-14" + } +] +``` + ## Endpoints | Endpoint | Method | Query Parameter | Required | Description | diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..11d4239 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,64 @@ +# Dnsmasq Web UNIX/Linux shell client + +A composable collection of POSIX shell scripts that constitute a client of Dnsmasq Web. + +## use.sh + +Contains the `dnsmasq_web_use` command. +It checks whether the argument(s) are available as a shell command. +It prevents the definition of functions that have unmet prerequisites. +It must be "dotted" into the shell first. +It will set DNSMASQ_WEB_SERVER to `[::1]`, i.e., _localhost_, and, +DNSMASQ_WEB_SERVER_USER to _root_ unless they are already set. + +```sh +. /path/to/cli/use.sh +``` + +## token.sh + +`dnsmasq_web_token` gets a token from the UNIX domain socket. +When `$DNSMASQ_WEB_SERVER` is set to something other than the default, +"[::1]", i.e., the server is remote, +it will use `ssh -ntq` to run the command on `$DNSMASQ_WEB_SERVER`. +Consider configuring SSH to connect to it without prompting for a password/passphrase. + +## curl.sh + +`dnsmasq_web_curl` uses cURL to access Dnsmasq Web. +It does two things: + +1. It uses `$DNSMASQ_WEB_SERVER` instead of requiring the full URL as an argument +1. It uses `$DNSMASQ_WEB_TOKEN` to add the `X-Token` header to each request + +There are also two commands to manage _reservations_: + +1. dnsmasq_web_reservations_add +1. dnsmasq_web_reservations_delete + +## curl_jq.sh + +`dnsmasq_web_curl_jq` processes the output of `dnsmasq_web_curl` using `jq`. +If has hooks to inject pre-built `jq` logic for formatting, processing and sorting. + +## jq_commands.sh + +Contains some pre-built `jq` arguments for _clients_, _leases_, and _reservations_. +It uses `eval` to define shell functions for: + +1. dnsmasq_web_curl_clients +1. dnsmasq_web_clients +1. dnsmasq_web_curl_leases +1. dnsmasq_web_leases +1. dnsmasq_web_curl_reservations +1. dnsmasq_web_reservations + +The shortened command presents the data as a table. + +## reservations.sh + +Adds commands to manage _reservations_ that also use `jq`: + +1. dnsmasq_web_reservations_add +1. dnsmasq_web_reservations_change +1. dnsmasq_web_reservations_delete diff --git a/cli/curl.sh b/cli/curl.sh new file mode 100644 index 0000000..acea26d --- /dev/null +++ b/cli/curl.sh @@ -0,0 +1,36 @@ +#!/bin/sh +dnsmasq_web_use curl || { + echo no dnsmasq_web_curl without curl && return +} + +dnsmasq_web_requires_token() { + test -n "$DNSMASQ_WEB_TOKEN" -o -n "$DNSMASQ_WEB_TOKEN_CMD" +} && ! dnsmasq_web_requires_token || dnsmasq_web_use dnsmasq_web_token || { + echo dnsmasq_web_curl requires dnsmasq_web_token && return +} + +dnsmasq_web_try_curl() { + url="http://$DNSMASQ_WEB_SERVER/${1%%/}" + shift + args='--fail-with-body --silent' + dnsmasq_web_requires_token && args="$args --header 'X-Token: $( + if test -n "$DNSMASQ_WEB_TOKEN"; then + echo "$DNSMASQ_WEB_TOKEN" + else + dnsmasq_web_token + fi + )'" + eval curl "$args" "$url" "$*" +} + +dnsmasq_web_curl() { + output=$(dnsmasq_web_try_curl "$@") + test $? -ne 22 && echo "$output" && return + if test "$output" = '{"error":"Unauthorized token"}'; then + printf '%s expired; renewing' "$DNSMASQ_WEB_TOKEN" >&2 + else + echo "$output" && return + fi + DNSMASQ_WEB_TOKEN=$(dnsmasq_web_token) && + dnsmasq_web_try_curl "$@" +} diff --git a/cli/curl_jq.sh b/cli/curl_jq.sh new file mode 100644 index 0000000..4715cce --- /dev/null +++ b/cli/curl_jq.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +dnsmasq_web_use dnsmasq_web_curl jq || { + echo no dnsmasq_web_curl_jq without jq && + return +} +dnsmasq_web_curl_jq() { + noun="$(echo "$1" | sed -e 's|[/?].*||')" + sort="$(eval echo "\$dnsmasq_web_${noun}_jq_sort")" + process="$(eval echo "\$dnsmasq_web_${noun}_jq")" + expression="." + test -n "$sort" && expression="$sort | .[]" + test -n "$process" && expression="$expression | $process" + dnsmasq_web_curl "$@" | jq "$expression" +} + +dnsmasq_web_use dnsmasq_web_curl column || { + echo using cat in place of column for dnsmasq_web_tabulate >&2 + column() { cat; } +} +dnsmasq_web_tabulate() { + jq -r '@tsv' | column -t +} diff --git a/cli/jq_commands.sh b/cli/jq_commands.sh new file mode 100644 index 0000000..658a9b1 --- /dev/null +++ b/cli/jq_commands.sh @@ -0,0 +1,44 @@ +#!/bin/sh + +# shellcheck disable=SC2034 +dnsmasq_web_clients_jq_sort='sort_by(.requests)' +# shellcheck disable=SC2034 +dnsmasq_web_clients_jq=' + .ipv4s |= (.|join(","))[0:30] | + .vendor_class |= (.|gsub("[\\s]+"; " "))[0:15] | + if .hostname == "" then + .hostname="" + end | + if .vendor_class == "" then + .vendor_class="" + end | + if (.ipv4s|length) > 28 then + .ipv4s |= .[0:27] + "..." + end | + if (.vendor_class|length) > 13 then + .vendor_class |= .[0:12] + "..." + end | + [.requests,.mac,.hostname,.ipv4s,.vendor_class]' +dnsmasq_sort_by_ipv4='sort_by(.ipv4|split("\\."; null)[3]|tonumber)' +# shellcheck disable=SC2034 +dnsmasq_web_leases_jq_sort="$dnsmasq_sort_by_ipv4" +# shellcheck disable=SC2034 +dnsmasq_web_leases_jq=' + if .hostname == "" then + .hostname="" + end | + if .renewed == "" then + .renewed=.added + end | + [.mac,.ipv4,.hostname,.added,.renewed,.age]' +# shellcheck disable=SC2034 +dnsmasq_web_reservations_jq_sort="$dnsmasq_sort_by_ipv4" +# shellcheck disable=SC2034 +dnsmasq_web_reservations_jq=' + [.mac,.ipv4,.hostname,(.tags|join(","))]' + +dnsmasq_web_use dnsmasq_web_curl_jq || return +for noun in clients leases reservations; do + eval "dnsmasq_web_curl_$noun() { dnsmasq_web_curl_jq $noun \"\$@\"; }; + dnsmasq_web_$noun() { dnsmasq_web_curl_$noun | dnsmasq_web_tabulate; }" +done diff --git a/cli/reservations.sh b/cli/reservations.sh new file mode 100644 index 0000000..47ae369 --- /dev/null +++ b/cli/reservations.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +dnsmasq_web_use dnsmasq_web_curl || return +dnsmasq_web_reservation_add() { + { + echo '{"mac":"'"$1"'","ipv4":"'"$2"'","hostname":"'"$3"'"' + test -n "$4" && echo ',"tags":["'"$4"'"]' + test -n "$5" && echo ',"lease_time":"'"$5"'"' + echo '}' + } | dnsmasq_web_curl reservations -X POST -d @- +} + +dnsmasq_web_reservation_delete() { + dnsmasq_web_curl "reservations/$1" -X DELETE +} + +dnsmasq_web_use jq || return +dnsmasq_web_reservation_change() { + mac="$1" + shift + dnsmasq_web_curl "reservations/$mac" | + jq ".$1 = $2" | + dnsmasq_web_curl "reservations/$mac" -X PUT -d @- +} diff --git a/cli/run.sh b/cli/run.sh new file mode 100644 index 0000000..c08cf03 --- /dev/null +++ b/cli/run.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +myname=$(basename "$0") +if command -v "$myname" >/dev/null 2>&1 && [ "$(type "$myname")" = "function" ]; then + "$myname" "$@" +else + echo "Function '$myname' is not defined." + return 1 +fi diff --git a/cli/token.sh b/cli/token.sh new file mode 100644 index 0000000..12fb82c --- /dev/null +++ b/cli/token.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +test -f /run/dnsmasq-web.sock -o -n "$DNSMASQ_WEB_SERVER" && + test -z "$DNSMASQ_WEB_TOKEN_SOCKET" && DNSMASQ_WEB_TOKEN_SOCKET=/run/dnsmasq-web.sock +test -n "$DNSMASQ_WEB_TOKEN_SOCKET" && { + # Use cURL to get the token from the (local) UNIX domain socket: + DNSMASQ_WEB_TOKEN_CMD="curl --unix-socket $DNSMASQ_WEB_TOKEN_SOCKET -s ." + # Using ncat is noticeably slower but can be done like this: + # echo -e 'GET / HTTP/1.1\r\nHost: .\r\n' | ncat -U $DNSMASQ_WEB_TOKEN_SOCKET | tail -1 + # Derive the SSH server from the web server: + DNSMASQ_WEB_SSH_SERVER=$(echo "$DNSMASQ_WEB_SERVER" | sed -e 's/^\[\(.*\)\]:.*$/\1/' -e 's/^\[\(.*\)\]$/\1/') + test -n "$DNSMASQ_WEB_SSH_SERVER" || + DNSMASQ_WEB_SSH_SERVER=$(expr "$DNSMASQ_WEB_SERVER" : '\([0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\)') + test -n "$DNSMASQ_WEB_SSH_SERVER" || + DNSMASQ_WEB_SSH_SERVER=$DNSMASQ_WEB_SERVER + + dnsmasq_web_server_is_local() { + test "$1" = "::1" || test "$1" = "127.0.0.1" + } && dnsmasq_web_server_is_local "$server" || dnsmasq_web_use ssh || { + printf 'dnsmasq_web_token requires ssh to access %s' "$DNSMASQ_WEB_SERVER" && return + } + + dnsmasq_web_token() { + if dnsmasq_web_server_is_local "$DNSMASQ_WEB_SSH_SERVER"; then + eval "$DNSMASQ_WEB_TOKEN_CMD" + else + server=$DNSMASQ_WEB_SSH_SERVER + test -n "$DNSMASQ_WEB_SSH_USER" && server="${DNSMASQ_WEB_SSH_USER}@${server}" + ssh -nqt "$server" "$DNSMASQ_WEB_TOKEN_CMD" + fi + } +} diff --git a/cli/use.sh b/cli/use.sh new file mode 100644 index 0000000..5d490bf --- /dev/null +++ b/cli/use.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +dnsmasq_web_use() { + for c in "$@"; do + command -v "$c" >/dev/null + test $? -eq 0 && continue + echo "$c is not present" >&2 + return 1 + done +} + +DNSMASQ_WEB_SERVER="${DNSMASQ_WEB_SERVER:-[::1]}" +DNSMASQ_WEB_SSH_USER="${DNSMASQ_WEB_SSH_USER:-root}" +export DNSMASQ_WEB_SERVER DNSMASQ_WEB_SSH_USER