Browse code

WIP: Add implementation

Robert Cranston authored on 17/06/2023 18:12:27
Showing 13 changed files

... ...
@@ -6,6 +6,132 @@ Interact with [NearlyFreeSpeech.NET][]'s [API][] from the command line.
6 6
 [NearlyFreeSpeech.NET]: https://www.nearlyfreespeech.net
7 7
 [API]: https://en.wikipedia.org/wiki/API
8 8
 
9
+## Goals
10
+
11
+`nfsn-utils` has three main goals:
12
+
13
+1.  **Be portable** (even to things like [routers][]).
14
+
15
+    POSIX shell is used as glue for standard utilities. See
16
+    [dependencies](#dependencies).
17
+
18
+2.  **Be modular** where it makes sense.
19
+
20
+    [`nfsn-send`](#nfsn-send) is a general purpose [NearlyFreeSpeech.NET][]
21
+    [API][] wrapper. [`nfsn-dns-update`](#nfsn-dns-update) is a general purpose
22
+    [NearlyFreeSpeech.NET][] DNS update utility.
23
+
24
+3.  **Be opinionated with sane defaults** where it makes sense.
25
+
26
+    The smaller utilities assume things like that you want to use [standard
27
+    email addresses][].
28
+
29
+4.  **Be easily auditable**.
30
+
31
+    The scripts are well abstracted and no more than about 100 lines of code.
32
+
33
+[routers]: https://openwrt.org
34
+[standard email addresses]: https://www.ietf.org/rfc/rfc2142.txt
35
+
36
+## Prerequisites
37
+
38
+### API key
39
+
40
+As described in the [NearlyFreeSpeech.NET][] [documentation][], one needs to
41
+submit a [free assistance request][] to obtain an API key.
42
+
43
+Place the credentials in the environment variables `NFSN_LOGIN` and
44
+`NFSN_API_KEY` or in the file `./.nfsn-api` or `$HOME/.nfsn-api` (location
45
+overridable by the `NFSN_CREDENTIALS_PATH` environment variable). This file
46
+should be a JSON file consisting of an object with the keys `login` and
47
+`api-key`. (The file format and default location is compatible with
48
+[WebService::NFSN][] and [python-nfsn][].)
49
+
50
+[documentation]: https://members.nearlyfreespeech.net/wiki/API/Introduction
51
+[free assistance request]: https://members.nearlyfreespeech.net/support/assist?tag=apikey
52
+[WebService::NFSN]: https://metacpan.org/pod/WebService::NFSN#INTERFACE
53
+[python-nfsn]: https://github.com/ktdreyer/python-nfsn#authentication
54
+
55
+### Dependencies
56
+
57
+-  Unix-like environment (in particular, `/dev/urandom`).
58
+-  [POSIX utilities][] with `date` supporting `+%s` (such as GNU `date`).
59
+-  `sha1sum` (for instance, the one in `coreutils`).
60
+-  [curl][].
61
+-  [jq][].
62
+-  `certbot` (only needed for `nfsn-dns-certbot*`).
63
+
64
+[POSIX utilities]: http://pubs.opengroup.org/onlinepubs/9699919799/idx/utilities.html
65
+[curl]: https://curl.haxx.se
66
+[jq]: https://github.com/stedolan/jq
67
+
68
+## Included programs
69
+
70
+Dependency graph:
71
+
72
+![included programs](doc/included-programs.dot.png)
73
+
74
+### `nfsn-send`
75
+
76
+Wraps the Requests, Responses and Authentication described in the
77
+[NearlyFreeSpeech.NET][] [documentation][].
78
+
79
+### `nfsn-dns-update`
80
+
81
+Updates several DNS records and outputs what data was actually changed.
82
+
83
+### `nfsn-dns-a`
84
+
85
+Updates DNS [A][] records, used to map hostnames to an IPv4 address.
86
+
87
+[A]: https://en.wikipedia.org/wiki/List_of_DNS_record_types#A
88
+
89
+### `nfsn-dns-spf`
90
+
91
+Updates DNS [SPF][] records, used for email authorization (specifying who is
92
+allowed to send mail from a domain).
93
+
94
+[SPF]: https://en.wikipedia.org/wiki/Sender_Policy_Framework
95
+
96
+### `nfsn-dns-dkim`
97
+
98
+Updates DNS [DKIM][] records, used for email authentication (using digital
99
+signatures).
100
+
101
+[DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail
102
+
103
+### `nfsn-dns-dmarc`
104
+
105
+Updates DNS [DMARC][] records, extending [SPF][] and [DKIM][] by specifying
106
+failure policy and reporting.
107
+
108
+See also the [dmarc.org FAQ][].
109
+
110
+[DMARC]: https://en.wikipedia.org/wiki/DMARC
111
+[dmarc.org FAQ]: https://dmarc.org/wiki/FAQ#Sender_Questions
112
+
113
+### `nfsn-dns-certbot*`
114
+
115
+`nfsn-dns-certbot` calls [certbot][] (the [Electronic Frontier Foundation][]'s
116
+(EFF) [Let's Encrypt][] client, for getting HTTPS certificates) in [manual
117
+mode][] to make it use `nfsn-utils` to update DNS records in order to fullfill
118
+the [`dns-01` challenge][]. It does this by registering
119
+`nfsn-dns-certbot-{auth,cleanup}` as [hooks][].
120
+
121
+By default, the `auth` hook sleeps for 30 seconds to let the DNS records
122
+propagate. This can be overridden with the environment variable
123
+`NFSN_DNS_CERTBOT_AUTH_SLEEP`.
124
+
125
+Given that `nfsn-dns-certbot` has successfully run once, running `certbot
126
+renew` will suffice to renew the certificates.
127
+
128
+[certbot]: https://certbot.eff.org
129
+[Electronic Frontier Foundation]: https://www.eff.org
130
+[Let's Encrypt]: https://letsencrypt.org
131
+[manual mode]: https://certbot.eff.org/docs/using.html#manual
132
+[`dns-01` challenge]: https://tools.ietf.org/html/draft-ietf-acme-acme-03#section-7.4
133
+[hooks]: https://certbot.eff.org/docs/using.html#hooks
134
+
9 135
 ## License
10 136
 
11 137
 Licensed under the [ISC License][] unless otherwise noted, see the
12 138
new file mode 100644
... ...
@@ -0,0 +1,20 @@
1
+digraph "included programs" {
2
+    node [shape=box]
3
+
4
+    {
5
+        { "nfsn-dns-a" [URL="nfsn-dns-a"] }
6
+        { "nfsn-dns-mx" [URL="nfsn-dns-mx"] }
7
+        { "nfsn-dns-spf" [URL="nfsn-dns-spf"] }
8
+        { "nfsn-dns-dkim" [URL="nfsn-dns-dkim"] }
9
+        { "nfsn-dns-dmarc" [URL="nfsn-dns-dmarc"] }
10
+    }
11
+    -> { "nfsn-dns-update" [URL="nfsn-dns-update"] }
12
+    -> { "nfsn-send" [URL="nfsn-send"] }
13
+
14
+    { "nfsn-dns-certbot" [URL="nfsn-dns-certbot"] }
15
+    -> {
16
+        { "nfsn-dns-certbot-auth" [URL="nfsn-dns-certbot-auth"] }
17
+        { "nfsn-dns-certbot-cleanup" [URL="nfsn-dns-certbot-cleanup"] }
18
+    }
19
+    -> { "nfsn-send" [URL="nfsn-send"] }
20
+}
0 21
new file mode 100644
1 22
Binary files /dev/null and b/docs/included-programs.dot.png differ
2 23
new file mode 100755
... ...
@@ -0,0 +1,21 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-a DOMAIN NAME [IP]...
5
+
6
+# nfsn-dns-a "example.com" "git" \
7
+#     "$(curl -s "https://api.ipify.org")"
8
+# nfsn-dns-a "example.com" "" \
9
+#     "185.199.108.153" \
10
+#     "185.199.109.153" \
11
+#     "185.199.110.153" \
12
+#     "185.199.111.153"
13
+
14
+# Arguments.
15
+
16
+domain="$1" ; shift
17
+name="$1" ; shift
18
+
19
+# Update.
20
+
21
+nfsn-dns-update "$domain" "$name" "A" '' "$@"
0 22
new file mode 100755
... ...
@@ -0,0 +1,47 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-certbot DOMAIN NAME EMAIL_NAME INSTALLER [CERTBOT_ARG]...
5
+
6
+# nfsn-dns-certbot "example.com" "" "" ""
7
+# nfsn-dns-certbot "example.com" "git" "" "apache" --quiet
8
+
9
+# Arguments.
10
+
11
+domain="$1" ; shift
12
+name="$1" ; shift
13
+email_name="${1:-"hostmaster"}" ; shift
14
+installer="${1:-}" ; shift
15
+
16
+# Certbot.
17
+
18
+host="${name:+"$name."}$domain"
19
+dir="$(cd "$(dirname "$0")" ; pwd)"
20
+
21
+certbot certonly \
22
+    --non-interactive \
23
+    --email "$email_name@$host" \
24
+    --agree-tos \
25
+    --manual \
26
+    --manual-public-ip-logging-ok \
27
+    --manual-auth-hook "$dir/nfsn-dns-certbot-auth" \
28
+    --manual-cleanup-hook "$dir/nfsn-dns-certbot-cleanup" \
29
+    --preferred-challenges="dns" \
30
+    --domains "$host" \
31
+    "$@"
32
+
33
+if [ -n "$installer" ]
34
+then
35
+    certbot install \
36
+        --cert-name "$host" \
37
+        --installer "$installer" \
38
+
39
+    certbot enhance \
40
+        --non-interactive \
41
+        --cert-name "$host" \
42
+        --domain "$host" \
43
+        --installer "$installer" \
44
+        --redirect \
45
+        --hsts \
46
+        --uir
47
+fi
0 48
new file mode 100755
... ...
@@ -0,0 +1,20 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# This is a naive guess. A better implementation would use e.g. the Public
5
+# Suffix List.
6
+re='\(\(.*\)\.\)\?\([^.]\+\.[^.]\+\)'
7
+domain="$(echo "$CERTBOT_DOMAIN" | sed -n "s/$re/\3/p")"
8
+name="$(echo "$CERTBOT_DOMAIN" | sed -n "s/$re/\2/p")"
9
+
10
+name="_acme-challenge${name:+".$name"}"
11
+data="$CERTBOT_VALIDATION"
12
+
13
+dir="$(cd "$(dirname "$0")" ; pwd)"
14
+
15
+"$dir/nfsn-send" "POST" "/dns/$domain/addRR" \
16
+    "name" "$name" \
17
+    "type" "TXT" \
18
+    "data" "$data"
19
+
20
+sleep "${NFSN_DNS_CERTBOT_AUTH_SLEEP:-"30"}"
0 21
new file mode 100755
... ...
@@ -0,0 +1,18 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# This is a naive guess. A better implementation would use e.g. the Public
5
+# Suffix List.
6
+re='\(\(.*\)\.\)\?\([^.]\+\.[^.]\+\)'
7
+domain="$(echo "$CERTBOT_DOMAIN" | sed -n "s/$re/\3/p")"
8
+name="$(echo "$CERTBOT_DOMAIN" | sed -n "s/$re/\2/p")"
9
+
10
+name="_acme-challenge${name:+".$name"}"
11
+data="$CERTBOT_VALIDATION"
12
+
13
+dir="$(cd "$(dirname "$0")" ; pwd)"
14
+
15
+"$dir/nfsn-send" "POST" "/dns/$domain/removeRR" \
16
+    "name" "$name" \
17
+    "type" "TXT" \
18
+    "data" "$data"
0 19
new file mode 100755
... ...
@@ -0,0 +1,23 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-dkim DOMAIN NAME SELECTOR KEY_TYPE KEY_PUB
5
+
6
+# nfsn-dns-dkim "example.com" "" "k1" "rsa" "$key_pub"
7
+
8
+# Arguments.
9
+
10
+domain="$1" ; shift
11
+name="$1" ; shift
12
+selector="$1" ; shift
13
+key_type="$1" ; shift
14
+key_pub="$1" ; shift
15
+
16
+# Process.
17
+
18
+name="$selector._domainkey${name:+".$name"}"
19
+data="v=DKIM1; k=$key_type; p=$key_pub"
20
+
21
+# Update.
22
+
23
+nfsn-dns-update "$domain" "$name" "TXT" '^v=DKIM1' "$data"
0 24
new file mode 100755
... ...
@@ -0,0 +1,31 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-dmarc DOMAIN NAME EMAIL_NAME POLICY
5
+
6
+# nfsn-dns-dmarc "example.com" "" "" "none"
7
+# nfsn-dns-dmarc "example.com" "" "" "quarantine"
8
+# nfsn-dns-dmarc "example.com" "" "" "reject"
9
+
10
+# Arguments.
11
+
12
+domain="$1" ; shift
13
+name="$1" ; shift
14
+email_name="${1:-"postmaster"}" ; shift
15
+policy="$1" ; shift
16
+
17
+# Process.
18
+
19
+name="_dmarc${name:+".$name"}"
20
+data="$(
21
+    printf "%s" \
22
+        "v=DMARC1; " \
23
+        "p=$policy; " \
24
+        "sp=$policy; " \
25
+        "pct=100; " \
26
+        "rua=mailto:$email_name@$domain"
27
+)"
28
+
29
+# Update.
30
+
31
+nfsn-dns-update "$domain" "$name" "TXT" '' "$data"
0 32
new file mode 100755
... ...
@@ -0,0 +1,17 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-mx DOMAIN NAME [MX_DATA]...
5
+
6
+# nfsn-dns-mx "example.com" "" \
7
+#     "10 mxa.mailgun.org." \
8
+#     "10 mxb.mailgun.org."
9
+
10
+# Arguments.
11
+
12
+domain="$1" ; shift
13
+name="$1" ; shift
14
+
15
+# Update.
16
+
17
+nfsn-dns-update "$domain" "$name" "MX" '' "$@"
0 18
new file mode 100755
... ...
@@ -0,0 +1,27 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-spf DOMAIN NAME [INCLUDE]...
5
+
6
+# nfsn-dns-spf "example.com" "mailgun.org"
7
+
8
+# Arguments.
9
+
10
+domain="$1" ; shift
11
+name="$1" ; shift
12
+
13
+# Process.
14
+
15
+data="$(
16
+    printf -- "v=spf1"
17
+    for include in "$@"
18
+    do
19
+        printf -- " include:%s" "$include"
20
+    done
21
+    printf -- " -all"
22
+    printf -- "\n"
23
+)"
24
+
25
+# Update.
26
+
27
+nfsn-dns-update "$domain" "$name" "TXT" '^v=spf1' "$data"
0 28
new file mode 100755
... ...
@@ -0,0 +1,87 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-dns-update DOMAIN NAME TYPE FILTER_REGEX [DATA]...
5
+
6
+# nfsn-dns-update "example.com" "git" "A" '' \
7
+#     "$(curl -s "https://api.ipify.org")"
8
+
9
+# Arguments.
10
+
11
+domain="$1" ; shift
12
+name="$1" ; shift
13
+type="$1" ; shift
14
+filter_regex="$1" ; shift
15
+
16
+host="${name:+"$name."}$domain"
17
+
18
+data_new_list=""
19
+while [ "$#" -gt "0" ]
20
+do
21
+    data="$1" ; shift
22
+    data_new_list="$(
23
+        printf "%s${data_new_list:+"\n"}%s\n" \
24
+            "${data_new_list:-}" \
25
+            "$data"
26
+    )"
27
+done
28
+
29
+# Old data.
30
+
31
+data_old_response="$(
32
+    nfsn-send "POST" "/dns/$domain/listRRs" \
33
+        "name" "$name" \
34
+        "type" "$type"
35
+)"
36
+data_old_list="$(
37
+    printf "%s\n" "$data_old_response" \
38
+    | jq -r '
39
+        .[] |
40
+        if ."aux"
41
+        then
42
+            "\(."aux") "
43
+        else
44
+            ""
45
+        end
46
+        +
47
+        "\(."data"?)"
48
+    ' \
49
+    | grep "$filter_regex" \
50
+    || true
51
+)"
52
+
53
+# Update.
54
+
55
+if [ -n "$data_old_list" ]
56
+then
57
+    printf "%s\n" "$data_old_list" \
58
+    | while IFS= read -r data
59
+    do
60
+        if ! printf "%s\n" "$data_new_list" | grep -qFx "$data"
61
+        then
62
+            printf "Removing data: %s\n" "$data"
63
+            nfsn-send "POST" "/dns/$domain/removeRR" \
64
+                "name" "$name" \
65
+                "type" "$type" \
66
+                "data" "$data"
67
+        fi
68
+    done
69
+fi
70
+
71
+if [ -n "$data_new_list" ]
72
+then
73
+    printf "%s\n" "$data_new_list" \
74
+    | while IFS= read -r data
75
+    do
76
+        if ! printf "%s\n" "$data_old_list" | grep -qFx "$data"
77
+        then
78
+            printf "Adding data: %s\n" "$data"
79
+            nfsn-send "POST" "/dns/$domain/addRR" \
80
+                "name" "$name" \
81
+                "type" "$type" \
82
+                "data" "$data"
83
+        else
84
+            printf "Data already present: %s\n" "$data"
85
+        fi
86
+    done
87
+fi
0 88
new file mode 100755
... ...
@@ -0,0 +1,114 @@
1
+#!/bin/sh
2
+set -euC
3
+
4
+# nfsn-send METHOD REQUEST_URI [KEY VALUE]...
5
+
6
+# nfsn-send "GET" "member/$member/sites"
7
+# nfsn-send "POST" "dns/$domain/listRRs" \
8
+#     "name" "" \
9
+#     "type" "MX"
10
+
11
+# Arguments.
12
+
13
+method="$1" ; shift
14
+request_uri="$1" ; shift
15
+
16
+body=""
17
+while [ "$#" -gt "0" ]
18
+do
19
+    key="$1" ; shift
20
+    value="$1" ; shift
21
+    body="${body:-}${body:+"&"}$key=$(printf "%s" "$value" | jq -sRr '@uri')"
22
+done
23
+
24
+# Authentication.
25
+
26
+login=""
27
+api_key=""
28
+for auth in "${NFSN_CREDENTIALS_PATH:-}" ".nfsn-api" "$HOME/.nfsn-api"
29
+do
30
+    if [ -f "$auth" ]
31
+    then
32
+        login="$(jq -r '."login"' "$auth")"
33
+        api_key="$(jq -r '."api-key"' "$auth")"
34
+        break
35
+    fi
36
+done
37
+login="${NFSN_LOGIN:-"$login"}"
38
+api_key="${NFSN_API_KEY:-"$api_key"}"
39
+
40
+
41
+if [ -z "${login:-}" ] || [ -z "${api_key:-}" ]
42
+then
43
+    >&2 printf "%s: %s\n" \
44
+        "$0" \
45
+        "Could not find authentication credentials."
46
+    exit 1
47
+fi
48
+
49
+# URL.
50
+
51
+protocol="https"
52
+host="api.nearlyfreespeech.net"
53
+
54
+# protocol="http"
55
+# host="localhost:8080"
56
+# nc -l 8080 &
57
+
58
+url="$protocol://$host/$request_uri"
59
+
60
+# Data.
61
+
62
+body_hash="$(
63
+    printf "%s" "$body" \
64
+    | sha1sum -b \
65
+    | cut -d ' ' -f 1
66
+)"
67
+
68
+timestamp="$(
69
+    date "+%s"
70
+)"
71
+salt="$(
72
+    < "/dev/urandom" \
73
+    tr -dc "a-zA-Z0-9" \
74
+    | head -c 16
75
+)"
76
+
77
+hash="$(
78
+    printf "%s" \
79
+        "$login;$timestamp;$salt;$api_key;$request_uri;$body_hash" \
80
+    | sha1sum -b \
81
+    | cut -d ' ' -f 1
82
+)"
83
+
84
+header="X-NFSN-Authentication: $login;$timestamp;$salt;$hash"
85
+
86
+# Request.
87
+
88
+response="$(
89
+    curl \
90
+        --silent \
91
+        --request "$method" \
92
+        --header "$header" \
93
+        --data-raw "$body" \
94
+        "$url"
95
+)"
96
+
97
+error="$(printf "%s\n" "$response" | jq -r '."error"?')"
98
+debug="$(printf "%s\n" "$response" | jq -r '."debug"?')"
99
+
100
+if [ -n "$error" ]
101
+then
102
+    >&2 printf "%s" "$error"
103
+    if [ -n "$debug" ]
104
+    then
105
+        >&2 printf " %s" "$debug"
106
+    fi
107
+    >&2 printf "\n"
108
+    return 1
109
+fi
110
+
111
+if [ -n "$response" ]
112
+then
113
+    printf "%s\n" "$response"
114
+fi