Skip to content

Commit

Permalink
Merge pull request #7 from amigus/client
Browse files Browse the repository at this point in the history
Add a simple CLI based on POSIX shell, cURL and jq.
  • Loading branch information
amigus authored Oct 10, 2024
2 parents 52c9989 + 03cf790 commit ce68119
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 15 deletions.
79 changes: 71 additions & 8 deletions Magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package main

import (
"bytes"
"fmt"
"os"
"os/exec"
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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)
}
}
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,40 @@
[![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

1. Add the [database](https://gist.github.com/amigus/6a9e4151d175d04bf05337b815f2213e) to the DHCP server.
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 |
Expand Down
64 changes: 64 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions cli/curl.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
}
23 changes: 23 additions & 0 deletions cli/curl_jq.sh
Original file line number Diff line number Diff line change
@@ -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
}
44 changes: 44 additions & 0 deletions cli/jq_commands.sh
Original file line number Diff line number Diff line change
@@ -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="<empty>"
end |
if .vendor_class == "" then
.vendor_class="<empty>"
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="<empty>"
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
24 changes: 24 additions & 0 deletions cli/reservations.sh
Original file line number Diff line number Diff line change
@@ -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 @-
}
9 changes: 9 additions & 0 deletions cli/run.sh
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit ce68119

Please sign in to comment.