jarl option set tls NAME (and key, remote-servers, primaries, http, dnssec-policy, key-store, server, acl, view, zone, parental-agents) matches the name after the keyword too: setting tls cloudflare no longer replaces tls quad9. jarl option unset tls NAME removes one by its name. docs/encrypted-dns.md has forwarding to Quad9 or Cloudflare over TLS: one changeset, the options explained, several providers, checking it works, and why not over HTTPS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
252 lines
8.8 KiB
Go
252 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go.yaml.in/yaml/v3"
|
|
)
|
|
|
|
func TestBatchFile(t *testing.T) {
|
|
src := `
|
|
description: test
|
|
changes:
|
|
- add: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true}
|
|
- delete: {zone: example.com, name: old, type: A}
|
|
- delete: {id: 12}
|
|
- set: {zone: example.com, name: api, type: A, data: 192.0.2.5}
|
|
- set: {zone: example.com, name: gone, type: TXT}
|
|
- update: {id: 13, data: 192.0.2.9}
|
|
- disable: {zone: example.com, name: www, type: A, data: 192.0.2.1, ptr: true}
|
|
- enable: {id: 14}
|
|
- op: zone.create
|
|
data: {name: lab.example.com}
|
|
`
|
|
var b batchFile
|
|
if err := yaml.Unmarshal([]byte(src), &b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reqs, err := b.requests()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []struct {
|
|
op string
|
|
id int64
|
|
data string
|
|
}{
|
|
{"record.create", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`},
|
|
{"record.delete", 0, `{"name":"old","type":"A","zone":"example.com"}`},
|
|
{"record.delete", 12, `{}`},
|
|
{"rrset.replace", 0, `{"data":["192.0.2.5"],"name":"api","type":"A","zone":"example.com"}`},
|
|
{"rrset.replace", 0, `{"data":[],"name":"gone","type":"TXT","zone":"example.com"}`},
|
|
{"record.update", 13, `{"data":"192.0.2.9"}`},
|
|
{"record.disable", 0, `{"data":"192.0.2.1","name":"www","ptr":true,"type":"A","zone":"example.com"}`},
|
|
{"record.enable", 14, `{}`},
|
|
{"zone.create", 0, `{"name":"lab.example.com"}`},
|
|
}
|
|
if len(reqs) != len(want) {
|
|
t.Fatalf("got %d requests, want %d", len(reqs), len(want))
|
|
}
|
|
for i, w := range want {
|
|
r := reqs[i]
|
|
// Compare data as parsed JSON so key order doesn't matter.
|
|
var got, exp any
|
|
json.Unmarshal(r.Data, &got)
|
|
json.Unmarshal([]byte(w.data), &exp)
|
|
gb, _ := json.Marshal(got)
|
|
eb, _ := json.Marshal(exp)
|
|
if r.Op != w.op || r.ID != w.id || string(gb) != string(eb) {
|
|
t.Errorf("request %d = %s %d %s, want %s %d %s", i, r.Op, r.ID, gb, w.op, w.id, eb)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBatchFileErrors(t *testing.T) {
|
|
for _, src := range []string{
|
|
"changes: [{add: {name: a}, delete: {name: b}}]", // two actions in one entry
|
|
"changes: [{}]", // no action
|
|
"changes: [{update: {data: x}}]", // update without id
|
|
} {
|
|
var b batchFile
|
|
if err := yaml.Unmarshal([]byte(src), &b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := b.requests(); err == nil {
|
|
t.Errorf("%s: expected an error", src)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChallengeName(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
domain string
|
|
exact bool
|
|
want string
|
|
}{
|
|
{"example.com", false, "_acme-challenge.example.com."},
|
|
{"*.example.com", false, "_acme-challenge.example.com."},
|
|
{"WWW.Example.com.", false, "_acme-challenge.www.example.com."},
|
|
{"_acme-challenge.www.example.com", false, "_acme-challenge.www.example.com."}, // acme.sh passes the full name
|
|
{"www.acme.example.com", true, "www.acme.example.com."},
|
|
} {
|
|
o := &acmeOptions{exact: tc.exact}
|
|
got, err := o.challengeName(tc.domain)
|
|
if err != nil || got != tc.want {
|
|
t.Errorf("challengeName(%q, exact=%v) = %q, %v; want %q", tc.domain, tc.exact, got, err, tc.want)
|
|
}
|
|
}
|
|
if _, err := (&acmeOptions{}).challengeName(" "); err == nil {
|
|
t.Error("empty domain accepted")
|
|
}
|
|
}
|
|
|
|
func TestListValue(t *testing.T) {
|
|
if got := listValue([]string{"192.0.2.1", " 192.0.2.2; ", "key xfer"}); got != "{ 192.0.2.1; 192.0.2.2; key xfer; }" {
|
|
t.Errorf("listValue = %q", got)
|
|
}
|
|
}
|
|
|
|
// TestListQueries runs list commands against a fake server and checks the
|
|
// filters they send: flags bound to variables shared between subcommands
|
|
// must not leak defaults (zone create's --type primary once hid every
|
|
// non-primary zone from 'zone list').
|
|
func TestDisableCommands(t *testing.T) {
|
|
var got string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.Method + " " + r.URL.Path + "?" + r.URL.RawQuery
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Write([]byte(`{"applied":true,"changeset_id":1}`))
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml"))
|
|
for _, tc := range []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"record", "disable", "example.com", "www", "A"}, "POST /api/v1/zones/example.com/records/disable?name=www&type=A"},
|
|
{[]string{"record", "disable", "example.com", "www", "A", "192.0.2.1", "--ptr", "--view", "internal"}, "POST /api/v1/zones/example.com/records/disable?data=192.0.2.1&name=www&ptr=1&type=A&view=internal"},
|
|
{[]string{"record", "enable", "--id", "12", "--ptr"}, "POST /api/v1/records/12/enable?ptr=1"},
|
|
{[]string{"record", "enable", "example.com", "old", "CNAME", "--missing-ok"}, "POST /api/v1/zones/example.com/records/enable?missing_ok=1&name=old&type=CNAME"},
|
|
} {
|
|
got = ""
|
|
root := rootCmd()
|
|
root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json"))
|
|
root.SetOut(io.Discard)
|
|
if err := root.Execute(); err != nil {
|
|
t.Errorf("%v: %v", tc.args, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("%v sent %q, want %q", tc.args, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListQueries(t *testing.T) {
|
|
var got string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
got = r.URL.Path + "?" + r.URL.RawQuery
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if strings.Contains(r.URL.Path, "/stats/") && !strings.HasSuffix(r.URL.Path, "/top") && !strings.HasSuffix(r.URL.Path, "/breakdown") {
|
|
w.Write([]byte("{}"))
|
|
return
|
|
}
|
|
w.Write([]byte("[]"))
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("JARL_CLI_CONFIG", filepath.Join(t.TempDir(), "cli.yaml"))
|
|
for _, tc := range []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"zone", "list"}, "/api/v1/zones?"},
|
|
{[]string{"zone", "list", "--type", "hint"}, "/api/v1/zones?type=hint"},
|
|
{[]string{"zone", "list", "--view", "internal"}, "/api/v1/zones?view=internal"},
|
|
{[]string{"changeset", "list"}, "/api/v1/changesets?status=open"},
|
|
{[]string{"option", "list", "--all"}, "/api/v1/options?"},
|
|
{[]string{"stats", "summary"}, "/api/v1/stats/summary?since=24h&top=10"},
|
|
{[]string{"stats", "heatmap"}, "/api/v1/stats/heatmap?key=queries&since=28d"},
|
|
{[]string{"stats", "breakdown", "rcode"}, "/api/v1/stats/breakdown?group=rcode&since=24h"},
|
|
{[]string{"stats", "top", "clients", "--since", "7d"}, "/api/v1/stats/top?kind=client&limit=20&since=7d"},
|
|
{[]string{"stats", "top", "nxdomain"}, "/api/v1/stats/top?kind=nxdomain&limit=20&since=24h"},
|
|
{[]string{"stats", "timeline", "--key", "queries", "--key", "rcode:*"}, "/api/v1/stats/timeline?key=queries&key=rcode%3A%2A&since=24h&step=auto"},
|
|
} {
|
|
got = ""
|
|
root := rootCmd()
|
|
root.SetArgs(append(tc.args, "--server", srv.URL, "--token", "jarl_test_token", "-o", "json"))
|
|
root.SetOut(io.Discard)
|
|
if err := root.Execute(); err != nil {
|
|
t.Errorf("%v: %v", tc.args, err)
|
|
continue
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("%v sent %q, want %q", tc.args, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOutputFields(t *testing.T) {
|
|
capture := func(f func() error) string {
|
|
t.Helper()
|
|
r, w, _ := os.Pipe()
|
|
old := os.Stdout
|
|
os.Stdout = w
|
|
err := f()
|
|
os.Stdout = old
|
|
w.Close()
|
|
b, _ := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
zones := []map[string]any{
|
|
{"id": 12, "name": "adbraun.lol.", "view": "external", "soa": map[string]any{"serial": 2026100901}},
|
|
{"id": 3, "name": "lab.adbraun.lol.", "soa": map[string]any{"serial": 7}},
|
|
}
|
|
defer func(o string, q bool) { flagOutput, flagQuiet = o, q }(flagOutput, flagQuiet)
|
|
for _, tc := range []struct {
|
|
format string
|
|
quiet bool
|
|
v any
|
|
want string
|
|
}{
|
|
{"id", false, zones, "12\n3\n"},
|
|
{"id", false, zones[0], "12\n"},
|
|
{"field=name,view,soa.serial", false, zones, "adbraun.lol.\texternal\t2026100901\nlab.adbraun.lol.\t\t7\n"},
|
|
{"field=soa", false, zones[1], "{\"serial\":7}\n"},
|
|
{"table", true, zones, "12\n3\n"},
|
|
{"table", true, map[string]any{"status": "ok"}, ""}, // -q without IDs: nothing
|
|
} {
|
|
flagOutput, flagQuiet = tc.format, tc.quiet
|
|
got := capture(func() error { return output(tc.v, func(io.Writer) { t.Error("table printed") }) })
|
|
if got != tc.want {
|
|
t.Errorf("-o %s (quiet %v) = %q, want %q", tc.format, tc.quiet, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOptionLabel(t *testing.T) {
|
|
for _, c := range []struct{ name, value, want string }{
|
|
{"tls", `quad9 { remote-hostname "dns.quad9.net"; }`, "quad9"},
|
|
{"tls", `Cloudflare{ remote-hostname "cloudflare-dns.com"; }`, "cloudflare"},
|
|
{"key", `"xfer key" { algorithm hmac-sha256; secret "x"; }`, "xfer key"},
|
|
{"server", "192.0.2.1 { keys { xfer; }; }", "192.0.2.1"},
|
|
{"remote-servers", "secondaries { 192.0.2.53; }", "secondaries"},
|
|
{"forwarders", "{ 9.9.9.9; }", ""},
|
|
{"logging", "{ channel x { stderr; }; }", ""},
|
|
{"tls", "", ""},
|
|
} {
|
|
if got := optionLabel(c.name, c.value); got != c.want {
|
|
t.Errorf("optionLabel(%q, %q) = %q, want %q", c.name, c.value, got, c.want)
|
|
}
|
|
}
|
|
}
|