go-dns/service_test.go
2026-04-03 21:30:08 +00:00

1781 lines
53 KiB
Go

package dns
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
dnsprotocol "github.com/miekg/dns"
)
func exchangeWithRetry(t *testing.T, client dnsprotocol.Client, request *dnsprotocol.Msg, address string) *dnsprotocol.Msg {
t.Helper()
for attempt := 0; attempt < 80; attempt++ {
response, _, err := client.Exchange(request, address)
if err == nil {
return response
}
if !strings.Contains(err.Error(), "connection refused") {
t.Fatalf("dns query failed: %v", err)
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("dns query failed after retrying due to startup timing")
return nil
}
func TestServiceResolveUsesExactNameBeforeWildcard(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"*.charon.lthn": {
A: []string{"10.69.69.165"},
},
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
result, ok := service.Resolve("gateway.charon.lthn")
if !ok {
t.Fatal("expected exact record to resolve")
}
if len(result.A) != 1 || result.A[0] != "10.10.10.10" {
t.Fatalf("unexpected resolve result: %#v", result.A)
}
}
func TestServiceOptionsAliasBuildsService(t *testing.T) {
service := NewService(Options{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
result, ok := service.ResolveAddress("gateway.charon.lthn")
if !ok {
t.Fatal("expected service constructed from Options alias to resolve")
}
if len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" {
t.Fatalf("unexpected resolve result from Options alias: %#v", result.Addresses)
}
}
func TestServiceResolveUsesMostSpecificWildcard(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"*.lthn": {
A: []string{"10.0.0.1"},
},
"*.charon.lthn": {
A: []string{"10.0.0.2"},
},
},
})
result, ok := service.Resolve("gateway.charon.lthn")
if !ok {
t.Fatal("expected wildcard record to resolve")
}
if len(result.A) != 1 || result.A[0] != "10.0.0.2" {
t.Fatalf("unexpected wildcard match: %#v", result.A)
}
}
func TestServiceResolveTXTUsesWildcard(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"*.gateway.charon.lthn": {
TXT: []string{"v=lthn1 type=gateway"},
},
},
})
result, ok := service.ResolveTXT("node1.gateway.charon.lthn.")
if !ok {
t.Fatal("expected wildcard TXT record")
}
if len(result) != 1 || result[0] != "v=lthn1 type=gateway" {
t.Fatalf("unexpected TXT record: %#v", result)
}
}
func TestServiceResolveTXTRecordsReturnsNamedField(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
TXT: []string{"v=lthn1 type=gateway"},
},
},
})
result, ok := service.ResolveTXTRecords("gateway.charon.lthn")
if !ok {
t.Fatal("expected named TXT result to resolve")
}
if len(result.TXT) != 1 || result.TXT[0] != "v=lthn1 type=gateway" {
t.Fatalf("unexpected ResolveTXTRecords output: %#v", result.TXT)
}
}
func TestServiceResolveAddressReturnsMergedRecords(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10", "10.10.10.10"},
AAAA: []string{"2600:1f1c:7f0:4f01:0000:0000:0000:0001"},
},
},
})
result, ok := service.ResolveAddress("gateway.charon.lthn")
if !ok {
t.Fatal("expected address record to resolve")
}
if len(result.Addresses) != 2 {
t.Fatalf("expected merged unique addresses, got %#v", result.Addresses)
}
if result.Addresses[0] != "10.10.10.10" || result.Addresses[1] != "2600:1f1c:7f0:4f01:0000:0000:0000:0001" {
t.Fatalf("unexpected address order or value: %#v", result.Addresses)
}
}
func TestServiceResolveAddressFallsBackToFalseWhenMissing(t *testing.T) {
service := NewService(ServiceOptions{})
if _, ok := service.ResolveAddress("missing.charon.lthn"); ok {
t.Fatal("expected missing record to return false")
}
}
func TestServiceResolveReverseUsesARecords(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
result, ok := service.ResolveReverse("10.10.10.10")
if !ok {
t.Fatal("expected reverse record to resolve")
}
if len(result) != 1 || result[0] != "gateway.charon.lthn" {
t.Fatalf("unexpected reverse result: %#v", result)
}
}
func TestServiceResolveReverseNamesReturnsNamedField(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
result, ok := service.ResolveReverseNames("10.10.10.10")
if !ok {
t.Fatal("expected named reverse result")
}
if len(result.Names) != 1 || result.Names[0] != "gateway.charon.lthn" {
t.Fatalf("unexpected reverse names result: %#v", result)
}
}
func TestServiceResolveReverseFallsBackToFalseWhenUnknown(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
if _, ok := service.ResolveReverse("10.10.10.11"); ok {
t.Fatal("expected no reverse match for unknown IP")
}
}
func TestServiceResolveReverseUsesSetAndRemove(t *testing.T) {
service := NewService(ServiceOptions{})
service.SetRecord("gateway.charon.lthn", NameRecords{
AAAA: []string{"2600:1f1c:7f0:4f01:0000:0000:0000:0001"},
})
result, ok := service.ResolveReverse("2600:1f1c:7f0:4f01::1")
if !ok || len(result) != 1 || result[0] != "gateway.charon.lthn" {
t.Fatalf("expected newly set reverse record, got %#v", result)
}
service.RemoveRecord("gateway.charon.lthn")
if _, ok := service.ResolveReverse("2600:1f1c:7f0:4f01::1"); ok {
t.Fatal("expected removed reverse record to disappear")
}
}
func TestServiceHealthUsesDeterministicTreeRootAndUpdatesOnMutations(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10", "10.0.0.1"},
NS: []string{"ns1.example.com"},
},
},
})
health := service.Health()
root, ok := health["tree_root"].(string)
if !ok || root == "" || root == "stubbed" {
t.Fatalf("expected computed tree root, got %#v", health["tree_root"])
}
healthRepeating := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
NS: []string{"ns1.example.com"},
A: []string{"10.0.0.1", "10.10.10.10"},
},
},
}).Health()["tree_root"].(string)
if healthRepeating != root {
t.Fatalf("expected deterministic tree root, got %s and %s", healthRepeating, root)
}
service.SetRecord("gateway.charon.lthn", NameRecords{
A: []string{"10.10.10.11"},
})
updatedRoot, ok := service.Health()["tree_root"].(string)
if !ok || updatedRoot == root {
t.Fatalf("expected updated tree root after SetRecord, got %s", updatedRoot)
}
service.RemoveRecord("gateway.charon.lthn")
removedRoot, ok := service.Health()["tree_root"].(string)
if !ok || removedRoot == updatedRoot {
t.Fatalf("expected updated tree root after RemoveRecord, got %s", removedRoot)
}
}
func TestServiceHealthUsesChainTreeRootAfterDiscovery(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "chain-root-1",
},
})
case "getnameresource":
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
return []string{"gateway.charon.lthn"}, nil
},
HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}),
})
if err := service.DiscoverAliases(context.Background()); err != nil {
t.Fatalf("expected discover to run for health chain-root assertions: %v", err)
}
health := service.Health()
root, ok := health["tree_root"].(string)
if !ok || root != "chain-root-1" {
t.Fatalf("expected health to expose chain tree_root, got %#v", health["tree_root"])
}
}
func TestServiceServeHTTPHealthReturnsJSON(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
httpServer, err := service.ServeHTTPHealth("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected health HTTP server to start: %v", err)
}
defer func() {
_ = httpServer.Close()
}()
response, err := http.Get("http://" + httpServer.Address() + "/health")
if err != nil {
t.Fatalf("expected health endpoint to respond: %v", err)
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected health status: %d", response.StatusCode)
}
var payload map[string]any
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("expected health payload: %v", err)
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("expected health JSON: %v", err)
}
if payload["status"] != "ready" {
t.Fatalf("expected ready health status, got %#v", payload["status"])
}
if payload["names_cached"] != float64(1) {
t.Fatalf("expected one cached name, got %#v", payload["names_cached"])
}
}
func TestServiceServeAllStartsDNSAndHTTPTogether(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
runtime, err := service.ServeAll("127.0.0.1", 0, 0)
if err != nil {
t.Fatalf("expected combined runtime to start: %v", err)
}
defer func() {
_ = runtime.Close()
}()
if runtime.DNSAddress() == "" {
t.Fatal("expected DNS address from combined runtime")
}
if runtime.HTTPAddress() == "" {
t.Fatal("expected HTTP address from combined runtime")
}
response, err := http.Get("http://" + runtime.HTTPAddress() + "/health")
if err != nil {
t.Fatalf("expected combined HTTP health endpoint to respond: %v", err)
}
defer func() {
_ = response.Body.Close()
}()
if response.StatusCode != http.StatusOK {
t.Fatalf("unexpected combined health status: %d", response.StatusCode)
}
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeA)
dnsResponse := exchangeWithRetry(t, client, request, runtime.DNSAddress())
if dnsResponse.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("unexpected combined DNS rcode: %d", dnsResponse.Rcode)
}
if len(dnsResponse.Answer) != 1 {
t.Fatalf("expected one DNS answer from combined runtime, got %d", len(dnsResponse.Answer))
}
}
func TestServiceDiscoverReplacesRecordsFromDiscoverer(t *testing.T) {
records := []map[string]NameRecords{
{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
"*.lthn": {
A: []string{"10.0.0.1"},
},
},
}
index := 0
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"legacy.charon.lthn": {
A: []string{"10.11.11.11"},
},
},
Discoverer: func() (map[string]NameRecords, error) {
next := records[index%len(records)]
index++
return next, nil
},
})
if _, ok := service.Resolve("legacy.charon.lthn"); !ok {
t.Fatal("expected baseline record before discovery")
}
if err := service.Discover(); err != nil {
t.Fatalf("unexpected discover error: %v", err)
}
result, ok := service.Resolve("gateway.charon.lthn")
if !ok {
t.Fatal("expected discovered exact record")
}
if len(result.A) != 1 || result.A[0] != "10.10.10.10" {
t.Fatalf("unexpected discovered resolve result: %#v", result.A)
}
if _, ok := service.Resolve("legacy.unknown"); ok {
t.Fatal("expected replaced cache not to resolve via old record")
}
}
func TestServiceDiscoverAliasesUsesConfiguredAliasDiscovery(t *testing.T) {
var treeRootCalls int32
var nameResourceCalls int32
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "alias-root-1",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
if len(payload.Params) != 1 || payload.Params[0] != "gateway.charon.lthn" {
t.Fatalf("unexpected alias lookup: %#v", payload.Params)
}
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
return []string{"gateway.charon.lthn"}, nil
},
HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}),
})
if err := service.DiscoverAliases(context.Background()); err != nil {
t.Fatalf("expected DiscoverAliases action to run: %v", err)
}
record, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(record.A) != 1 || record.A[0] != "10.10.10.10" {
t.Fatalf("expected discovered gateway record, got %#v (ok=%t)", record, ok)
}
if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 1 {
t.Fatalf("expected discovery to perform chain and name-resource calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls))
}
}
func TestServiceDiscoverAliasesUsesConfiguredActionCaller(t *testing.T) {
var treeRootCalls int32
var nameResourceCalls int32
actionCalled := false
discovererCalled := false
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "action-caller-root",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
switch payload.Params[0] {
case "gateway.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
case "node.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"aaaa": []string{"2600:1f1c:7f0:4f01::2"},
},
})
default:
t.Fatalf("unexpected alias lookup: %#v", payload.Params)
}
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasActionCaller: actionCallerFunc(func(ctx context.Context, name string, values map[string]any) (any, bool, error) {
actionCalled = true
if name != "blockchain.chain.aliases" {
t.Fatalf("unexpected action name: %s", name)
}
return map[string]any{
"aliases": []any{"gateway.charon.lthn", "node.charon.lthn"},
}, true, nil
}),
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
discovererCalled = true
return nil, errors.New("discoverer should not run when action caller succeeds")
},
HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}),
})
if err := service.DiscoverAliases(context.Background()); err != nil {
t.Fatalf("expected DiscoverAliases to complete through action caller: %v", err)
}
if !actionCalled {
t.Fatal("expected action caller to be invoked")
}
if discovererCalled {
t.Fatal("expected chain alias discoverer to be skipped after action caller success")
}
gateway, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" {
t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok)
}
node, ok := service.Resolve("node.charon.lthn")
if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" {
t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok)
}
if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 {
t.Fatalf("expected discovery to perform chain and name-resource calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls))
}
}
func TestServiceDiscoverAliasesUsesConfiguredChainAliasAction(t *testing.T) {
var treeRootCalls int32
var nameResourceCalls int32
actionCalled := false
discovererCalled := false
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "action-root-1",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
if len(payload.Params) != 1 || payload.Params[0] != "gateway.charon.lthn" {
t.Fatalf("unexpected alias lookup: %#v", payload.Params)
}
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasAction: func(_ context.Context) ([]string, error) {
actionCalled = true
return []string{"gateway.charon.lthn"}, nil
},
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
discovererCalled = true
return nil, errors.New("discoverer should not be used when action succeeds")
},
HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}),
})
if err := service.DiscoverAliases(context.Background()); err != nil {
t.Fatalf("expected DiscoverAliases to complete through chain alias action: %v", err)
}
if !actionCalled {
t.Fatal("expected chain alias action to be called")
}
if discovererCalled {
t.Fatal("expected chain alias discoverer to be skipped after action success")
}
record, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(record.A) != 1 || record.A[0] != "10.10.10.10" {
t.Fatalf("expected discovered gateway record, got %#v (ok=%t)", record, ok)
}
if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 1 {
t.Fatalf("expected one tree-root and one name-resource RPC call, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls))
}
}
func TestServiceDiscoverFallsBackWhenPrimaryDiscovererFails(t *testing.T) {
primaryCalled := false
fallbackCalled := false
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"legacy.charon.lthn": {
A: []string{"10.11.11.11"},
},
},
Discoverer: func() (map[string]NameRecords, error) {
primaryCalled = true
return nil, errors.New("chain service unavailable")
},
FallbackDiscoverer: func() (map[string]NameRecords, error) {
fallbackCalled = true
return map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
}, nil
},
})
if err := service.Discover(); err != nil {
t.Fatalf("expected fallback discovery to succeed: %v", err)
}
if !primaryCalled {
t.Fatal("expected primary discoverer to be attempted")
}
if !fallbackCalled {
t.Fatal("expected fallback discoverer to run after primary failure")
}
result, ok := service.Resolve("gateway.charon.lthn")
if !ok {
t.Fatal("expected fallback record to resolve")
}
if len(result.A) != 1 || result.A[0] != "10.10.10.10" {
t.Fatalf("unexpected fallback resolve result: %#v", result.A)
}
if _, ok := service.Resolve("legacy.charon.lthn"); ok {
t.Fatal("expected legacy record to be replaced by fallback discovery")
}
}
func TestServiceDiscoverUsesFallbackOnlyWhenPrimaryMissing(t *testing.T) {
fallbackCalled := false
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"legacy.charon.lthn": {
A: []string{"10.11.11.11"},
},
},
FallbackDiscoverer: func() (map[string]NameRecords, error) {
fallbackCalled = true
return map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.20"},
},
}, nil
},
})
if err := service.Discover(); err != nil {
t.Fatalf("expected fallback discovery to run: %v", err)
}
if !fallbackCalled {
t.Fatal("expected fallback discoverer to run when primary is missing")
}
if _, ok := service.Resolve("gateway.charon.lthn"); !ok {
t.Fatal("expected fallback record to resolve")
}
}
func TestServiceDiscoverFromChainAliasesUsesFallbackWhenPrimaryFails(t *testing.T) {
primaryCalled := false
fallbackCalled := false
var treeRootCalls int32
var nameResourceCalls int32
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "root-1",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
switch payload.Params[0] {
case "gateway.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
case "node.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"aaaa": []string{"2600:1f1c:7f0:4f01::2"},
},
})
default:
t.Fatalf("unexpected alias query: %#v", payload.Params)
}
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
primaryCalled = true
return nil, errors.New("blockchain service unavailable")
},
FallbackChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
fallbackCalled = true
return []string{"gateway.charon.lthn", "node.charon.lthn"}, nil
},
})
client := NewHSDClient(HSDClientOptions{
URL: server.URL,
})
if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil {
t.Fatalf("expected chain alias discovery to complete: %v", err)
}
if !primaryCalled {
t.Fatal("expected primary chain alias discoverer to be attempted")
}
if !fallbackCalled {
t.Fatal("expected fallback chain alias discoverer to run")
}
gateway, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" {
t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok)
}
node, ok := service.Resolve("node.charon.lthn")
if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" {
t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok)
}
if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 {
t.Fatalf("expected one tree-root and two name-resource RPC calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls))
}
}
func TestServiceDiscoverFromChainAliasesUsesConfiguredHSDClient(t *testing.T) {
var treeRootCalls int32
var nameResourceCalls int32
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "root-1",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
switch payload.Params[0] {
case "gateway.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
default:
t.Fatalf("unexpected alias lookup: %#v", payload.Params)
}
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
return []string{"gateway.charon.lthn"}, nil
},
HSDClient: NewHSDClient(HSDClientOptions{URL: server.URL}),
})
if err := service.DiscoverFromChainAliases(context.Background(), nil); err != nil {
t.Fatalf("expected chain alias discovery to complete: %v", err)
}
if atomic.LoadInt32(&treeRootCalls) != 1 {
t.Fatalf("expected one tree-root call, got %d", atomic.LoadInt32(&treeRootCalls))
}
if atomic.LoadInt32(&nameResourceCalls) != 1 {
t.Fatalf("expected one name-resource call, got %d", atomic.LoadInt32(&nameResourceCalls))
}
gateway, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" {
t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok)
}
}
func TestServiceDiscoverFromChainAliasesFallsBackToMainchainClientWhenDiscovererFails(t *testing.T) {
var chainAliasCalls int32
var treeRootCalls int32
var nameResourceCalls int32
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "get_all_alias_details":
atomic.AddInt32(&chainAliasCalls, 1)
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": []any{
map[string]any{
"hns": "gateway.charon.lthn",
},
map[string]any{
"hns": "node.charon.lthn",
},
},
})
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
responseWriter.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "chain-root-1",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
switch payload.Params[0] {
case "gateway.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
case "node.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"aaaa": []string{"2600:1f1c:7f0:4f01::2"},
},
})
default:
t.Fatalf("unexpected alias query: %#v", payload.Params)
}
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
return nil, errors.New("blockchain service unavailable")
},
MainchainAliasClient: NewMainchainAliasClient(MainchainClientOptions{
URL: server.URL,
}),
})
hsdClient := NewHSDClient(HSDClientOptions{
URL: server.URL,
})
if err := service.DiscoverFromChainAliases(context.Background(), hsdClient); err != nil {
t.Fatalf("expected chain alias discovery to complete: %v", err)
}
gateway, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(gateway.A) != 1 || gateway.A[0] != "10.10.10.10" {
t.Fatalf("expected gateway A record, got %#v (ok=%t)", gateway, ok)
}
node, ok := service.Resolve("node.charon.lthn")
if !ok || len(node.AAAA) != 1 || node.AAAA[0] != "2600:1f1c:7f0:4f01::2" {
t.Fatalf("expected node AAAA record, got %#v (ok=%t)", node, ok)
}
if atomic.LoadInt32(&chainAliasCalls) != 1 {
t.Fatalf("expected one chain alias call, got %d", atomic.LoadInt32(&chainAliasCalls))
}
if atomic.LoadInt32(&treeRootCalls) != 1 || atomic.LoadInt32(&nameResourceCalls) != 2 {
t.Fatalf("expected one tree-root and two name-resource calls, got treeRoot=%d nameResource=%d", atomic.LoadInt32(&treeRootCalls), atomic.LoadInt32(&nameResourceCalls))
}
}
func TestServiceDiscoverFromChainAliasesSkipsRefreshWhenTreeRootUnchanged(t *testing.T) {
var treeRootCalls int32
var nameResourceCalls int32
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
atomic.AddInt32(&treeRootCalls, 1)
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "same-root",
},
})
case "getnameresource":
atomic.AddInt32(&nameResourceCalls, 1)
switch payload.Params[0] {
case "gateway.charon.lthn":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
default:
t.Fatalf("unexpected alias query: %#v", payload.Params)
}
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
TreeRootCheckInterval: 5 * time.Second,
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
return []string{"gateway.charon.lthn"}, nil
},
})
client := NewHSDClient(HSDClientOptions{
URL: server.URL,
})
if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil {
t.Fatalf("expected first chain alias discovery to run: %v", err)
}
if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil {
t.Fatalf("expected second chain alias discovery to skip refresh: %v", err)
}
if atomic.LoadInt32(&treeRootCalls) != 1 {
t.Fatalf("expected one tree_root check in interval window, got %d", atomic.LoadInt32(&treeRootCalls))
}
if atomic.LoadInt32(&nameResourceCalls) != 1 {
t.Fatalf("expected one name-resource query while refreshing, got %d", atomic.LoadInt32(&nameResourceCalls))
}
}
func TestServiceDiscoverFromChainAliasesIgnoresMissingDiscoverers(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
t.Fatalf("expected no hsd requests when alias discoverers are missing")
}))
defer server.Close()
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
client := NewHSDClient(HSDClientOptions{
URL: server.URL,
})
if err := service.DiscoverFromChainAliases(context.Background(), client); err != nil {
t.Fatalf("expected no-op when no alias discoverer configured: %v", err)
}
result, ok := service.Resolve("gateway.charon.lthn")
if !ok || len(result.A) != 1 || result.A[0] != "10.10.10.10" {
t.Fatalf("expected baseline record to stay in cache, got %#v (ok=%t)", result, ok)
}
}
func TestServiceDiscoverReturnsNilWithoutDiscoverer(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
if err := service.Discover(); err != nil {
t.Fatalf("expected no error when discoverer is missing: %v", err)
}
}
func TestServiceServeResolvesAAndAAAARecords(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
AAAA: []string{"2600:1f1c:7f0:4f01::1"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
query := func(qtype uint16) *dnsprotocol.Msg {
request := new(dnsprotocol.Msg)
request.SetQuestion("gateway.charon.lthn.", qtype)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("unexpected rcode for qtype %d: %d", qtype, response.Rcode)
}
return response
}
aResponse := query(dnsprotocol.TypeA)
if len(aResponse.Answer) != 1 {
t.Fatalf("expected one A answer, got %d", len(aResponse.Answer))
}
if got, ok := aResponse.Answer[0].(*dnsprotocol.A); !ok || got.A.String() != "10.10.10.10" {
t.Fatalf("unexpected A answer: %#v", aResponse.Answer[0])
}
aaaaResponse := query(dnsprotocol.TypeAAAA)
if len(aaaaResponse.Answer) != 1 {
t.Fatalf("expected one AAAA answer, got %d", len(aaaaResponse.Answer))
}
if got, ok := aaaaResponse.Answer[0].(*dnsprotocol.AAAA); !ok || got.AAAA.String() != "2600:1f1c:7f0:4f01::1" {
t.Fatalf("unexpected AAAA answer: %#v", aaaaResponse.Answer[0])
}
}
func TestServiceServeAnswersANYWithAllRecordTypes(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
AAAA: []string{"2600:1f1c:7f0:4f01::1"},
TXT: []string{"v=lthn1 type=gateway"},
NS: []string{"ns.gateway.charon.lthn"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeANY)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("unexpected ANY rcode: %d", response.Rcode)
}
var sawA, sawAAAA, sawTXT, sawNS bool
for _, answer := range response.Answer {
switch rr := answer.(type) {
case *dnsprotocol.A:
sawA = rr.A.String() == "10.10.10.10"
case *dnsprotocol.AAAA:
sawAAAA = rr.AAAA.String() == "2600:1f1c:7f0:4f01::1"
case *dnsprotocol.TXT:
sawTXT = len(rr.Txt) == 1 && rr.Txt[0] == "v=lthn1 type=gateway"
case *dnsprotocol.NS:
sawNS = rr.Ns == "ns.gateway.charon.lthn."
}
}
if !sawA || !sawAAAA || !sawTXT || !sawNS {
t.Fatalf("expected ANY answer to include A, AAAA, TXT, and NS records, got %#v", response.Answer)
}
}
func TestServiceServeResolvesWildcardAndPTRRecords(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"*.charon.lthn": {
A: []string{"10.0.0.1"},
},
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("node1.charon.lthn.", dnsprotocol.TypeA)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("unexpected rcode: %d", response.Rcode)
}
if got, ok := response.Answer[0].(*dnsprotocol.A); !ok || got.A.String() != "10.0.0.1" {
t.Fatalf("unexpected wildcard A answer: %#v", response.Answer)
}
ptrName := "10.10.10.10.in-addr.arpa."
ptrRequest := new(dnsprotocol.Msg)
ptrRequest.SetQuestion(ptrName, dnsprotocol.TypePTR)
ptrResponse := exchangeWithRetry(t, client, ptrRequest, srv.Address())
if len(ptrResponse.Answer) == 0 {
t.Fatal("expected PTR answer")
}
if got, ok := ptrResponse.Answer[0].(*dnsprotocol.PTR); !ok || got.Ptr != "gateway.charon.lthn." {
t.Fatalf("unexpected PTR answer: %#v", ptrResponse.Answer)
}
}
func TestServiceServeAnswersSOAOnlyForZoneApex(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"charon.lthn": {
NS: []string{"ns1.charon.lthn"},
},
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
apexRequest := new(dnsprotocol.Msg)
apexRequest.SetQuestion("charon.lthn.", dnsprotocol.TypeSOA)
apexResponse := exchangeWithRetry(t, client, apexRequest, srv.Address())
if apexResponse.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("expected SOA query for apex to succeed, got %d", apexResponse.Rcode)
}
if len(apexResponse.Answer) != 1 {
t.Fatalf("expected one SOA answer for apex, got %d", len(apexResponse.Answer))
}
if _, ok := apexResponse.Answer[0].(*dnsprotocol.SOA); !ok {
t.Fatalf("expected SOA answer for apex, got %#v", apexResponse.Answer[0])
}
subdomainRequest := new(dnsprotocol.Msg)
subdomainRequest.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeSOA)
subdomainResponse := exchangeWithRetry(t, client, subdomainRequest, srv.Address())
if subdomainResponse.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("expected SOA query for non-apex existing name to succeed, got %d", subdomainResponse.Rcode)
}
if len(subdomainResponse.Answer) != 0 {
t.Fatalf("expected no SOA answer for non-apex name, got %#v", subdomainResponse.Answer)
}
}
func TestServiceServeAnswersSOAForDerivedZoneApexWithoutExactRecord(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
"node.charon.lthn": {
A: []string{"10.10.10.11"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("charon.lthn.", dnsprotocol.TypeSOA)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("expected SOA query for derived apex to succeed, got %d", response.Rcode)
}
if len(response.Answer) != 1 {
t.Fatalf("expected one SOA answer for derived apex, got %d", len(response.Answer))
}
if _, ok := response.Answer[0].(*dnsprotocol.SOA); !ok {
t.Fatalf("expected SOA answer for derived apex, got %#v", response.Answer[0])
}
}
func TestServiceServeAnswersNSForDerivedZoneApexWithoutExactRecord(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
"node.charon.lthn": {
A: []string{"10.10.10.11"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("charon.lthn.", dnsprotocol.TypeNS)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("expected NS query for derived apex to succeed, got %d", response.Rcode)
}
if len(response.Answer) != 1 {
t.Fatalf("expected one NS answer for derived apex, got %d", len(response.Answer))
}
ns, ok := response.Answer[0].(*dnsprotocol.NS)
if !ok {
t.Fatalf("expected NS answer for derived apex, got %#v", response.Answer[0])
}
if ns.Ns != "ns.charon.lthn." {
t.Fatalf("expected synthesized apex NS, got %q", ns.Ns)
}
}
func TestServiceResolveAllSynthesizesNSForDerivedZoneApex(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
"node.charon.lthn": {
AAAA: []string{"2600:1f1c:7f0:4f01::2"},
},
},
})
result, ok := service.ResolveAll("charon.lthn")
if !ok {
t.Fatal("expected derived zone apex to resolve")
}
if len(result.NS) != 1 || result.NS[0] != "ns.charon.lthn" {
t.Fatalf("expected synthesized apex NS, got %#v", result.NS)
}
}
func TestServiceResolveAllReturnsEmptyArraysForMissingRecordValues(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
"node.charon.lthn": {
AAAA: []string{"2600:1f1c:7f0:4f01::2"},
},
},
})
result, ok := service.ResolveAll("gateway.charon.lthn")
if !ok {
t.Fatal("expected record to resolve")
}
if result.AAAA == nil || len(result.AAAA) != 0 {
t.Fatalf("expected empty AAAA slice, got %#v", result.AAAA)
}
if result.TXT == nil || len(result.TXT) != 0 {
t.Fatalf("expected empty TXT slice, got %#v", result.TXT)
}
if result.NS == nil || len(result.NS) != 0 {
t.Fatalf("expected empty NS slice, got %#v", result.NS)
}
raw, err := json.Marshal(result)
if err != nil {
t.Fatalf("expected result to marshal: %v", err)
}
if string(raw) != `{"a":["10.10.10.10"],"aaaa":[],"txt":[],"ns":[]}` {
t.Fatalf("expected empty arrays in JSON, got %s", raw)
}
}
func TestServiceServeReturnsNXDOMAINWhenMissing(t *testing.T) {
service := NewService(ServiceOptions{})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("missing.charon.lthn.", dnsprotocol.TypeA)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeNameError {
t.Fatalf("expected NXDOMAIN, got %d", response.Rcode)
}
}
func TestServiceServeReturnsNoErrorWhenTypeIsMissing(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
srv, err := service.Serve("127.0.0.1", 0)
if err != nil {
t.Fatalf("expected server to start: %v", err)
}
defer func() { _ = srv.Close() }()
client := dnsprotocol.Client{}
request := new(dnsprotocol.Msg)
request.SetQuestion("gateway.charon.lthn.", dnsprotocol.TypeTXT)
response := exchangeWithRetry(t, client, request, srv.Address())
if response.Rcode != dnsprotocol.RcodeSuccess {
t.Fatalf("expected NOERROR for existing name without TXT records, got %d", response.Rcode)
}
if len(response.Answer) != 0 {
t.Fatalf("expected empty answer for missing TXT record, got %#v", response.Answer)
}
}
func TestServiceHandleActionResolveAndTXTAndAll(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
AAAA: []string{"2600:1f1c:7f0:4f01::1"},
TXT: []string{"v=lthn1 type=gateway"},
NS: []string{"ns.charon.lthn"},
},
},
})
addresses, ok, err := service.HandleAction(ActionResolve, map[string]any{
"name": "gateway.charon.lthn",
})
if err != nil {
t.Fatalf("unexpected resolve action error: %v", err)
}
if !ok {
t.Fatal("expected resolve action to return a record")
}
payload, ok := addresses.(ResolveAddressResult)
if !ok {
t.Fatalf("expected ResolveAddressResult payload, got %T", addresses)
}
if len(payload.Addresses) != 2 || payload.Addresses[0] != "10.10.10.10" || payload.Addresses[1] != "2600:1f1c:7f0:4f01::1" {
t.Fatalf("unexpected resolve result: %#v", payload.Addresses)
}
txtPayload, ok, err := service.HandleAction(ActionResolveTXT, map[string]any{
"name": "gateway.charon.lthn",
})
if err != nil {
t.Fatalf("unexpected txt action error: %v", err)
}
if !ok {
t.Fatal("expected txt action to return a record")
}
txts, ok := txtPayload.(ResolveTXTResult)
if !ok {
t.Fatalf("expected ResolveTXTResult payload, got %T", txtPayload)
}
if len(txts.TXT) != 1 || txts.TXT[0] != "v=lthn1 type=gateway" {
t.Fatalf("unexpected txt result: %#v", txts.TXT)
}
allPayload, ok, err := service.HandleAction(ActionResolveAll, map[string]any{
"name": "gateway.charon.lthn",
})
if err != nil {
t.Fatalf("unexpected resolve.all action error: %v", err)
}
if !ok {
t.Fatal("expected resolve.all action to return a record")
}
all, ok := allPayload.(ResolveAllResult)
if !ok {
t.Fatalf("expected ResolveAllResult payload, got %T", allPayload)
}
if len(all.NS) != 1 || all.NS[0] != "ns.charon.lthn" {
t.Fatalf("unexpected resolve.all result: %#v", all)
}
}
func TestServiceActionNamesExposeAllRFCActions(t *testing.T) {
service := NewService(ServiceOptions{})
names := service.ActionNames()
expected := []string{
ActionResolve,
ActionResolveTXT,
ActionResolveAll,
ActionReverse,
ActionServe,
ActionHealth,
ActionDiscover,
}
if len(names) != len(expected) {
t.Fatalf("expected %d action names, got %d: %#v", len(expected), len(names), names)
}
for i, name := range expected {
if names[i] != name {
t.Fatalf("unexpected action name at %d: got %q want %q", i, names[i], name)
}
}
}
func TestServiceRegisterActionsPublishesAllActionsInOrder(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
registrar := &actionRecorder{}
service.RegisterActions(registrar)
expected := service.ActionNames()
if len(registrar.names) != len(expected) {
t.Fatalf("expected %d registered actions, got %d: %#v", len(expected), len(registrar.names), registrar.names)
}
for index, name := range expected {
if registrar.names[index] != name {
t.Fatalf("unexpected registered action at %d: got %q want %q", index, registrar.names[index], name)
}
}
payload, ok, err := registrar.handlers[ActionResolve](map[string]any{"name": "gateway.charon.lthn"})
if err != nil {
t.Fatalf("unexpected registered handler error: %v", err)
}
if !ok {
t.Fatal("expected registered handler to resolve")
}
result, ok := payload.(ResolveAddressResult)
if !ok || len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" {
t.Fatalf("unexpected registered handler payload: %#v", payload)
}
}
func TestNewServiceWithRegistrarBuildsAndRegistersInOneStep(t *testing.T) {
registrar := &actionRecorder{}
service := NewServiceWithRegistrar(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
}, registrar)
if service == nil {
t.Fatal("expected service to be built")
}
if len(registrar.names) != len(service.ActionNames()) {
t.Fatalf("expected helper to register all actions, got %#v", registrar.names)
}
payload, ok, err := registrar.handlers[ActionResolve](map[string]any{"name": "gateway.charon.lthn"})
if err != nil {
t.Fatalf("unexpected registered handler error: %v", err)
}
if !ok {
t.Fatal("expected registered handler to resolve")
}
result, ok := payload.(ResolveAddressResult)
if !ok || len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" {
t.Fatalf("unexpected registered handler payload: %#v", payload)
}
}
func TestServiceActionDefinitionsHaveInvokers(t *testing.T) {
service := NewService(ServiceOptions{
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.10"},
},
},
})
definitions := service.ActionDefinitions()
if len(definitions) == 0 {
t.Fatal("expected action definitions")
}
for _, definition := range definitions {
if definition.Name == "" {
t.Fatal("expected action definition name")
}
if definition.Invoke == nil {
t.Fatalf("expected action invoke for %s", definition.Name)
}
}
resolveDefinition := definitions[0]
if resolveDefinition.Name != ActionResolve {
t.Fatalf("expected first action definition to be %s, got %s", ActionResolve, resolveDefinition.Name)
}
payload, ok, err := resolveDefinition.Invoke(map[string]any{
"name": "gateway.charon.lthn",
})
if err != nil {
t.Fatalf("unexpected action invoke error: %v", err)
}
if !ok {
t.Fatal("expected resolve action definition to return a record")
}
result, ok := payload.(ResolveAddressResult)
if !ok || len(result.Addresses) != 1 || result.Addresses[0] != "10.10.10.10" {
t.Fatalf("unexpected resolve payload: %#v", payload)
}
handlePayload, handleOK, handleErr := service.HandleAction(ActionResolve, map[string]any{
"name": "gateway.charon.lthn",
})
if handleErr != nil || !handleOK {
t.Fatalf("unexpected handle action result: ok=%t err=%v", handleOK, handleErr)
}
if handleResult, ok := handlePayload.(ResolveAddressResult); !ok || len(handleResult.Addresses) != 1 || handleResult.Addresses[0] != "10.10.10.10" {
t.Fatalf("unexpected handle action payload: %#v", handlePayload)
}
}
func TestServiceHandleActionReverseHealthServeAndDiscover(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
var payload struct {
Method string `json:"method"`
Params []any `json:"params"`
}
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatalf("unexpected request payload: %v", err)
}
switch payload.Method {
case "getblockchaininfo":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"tree_root": "discover-root",
},
})
case "getnameresource":
_ = json.NewEncoder(responseWriter).Encode(map[string]any{
"result": map[string]any{
"a": []string{"10.10.10.10"},
},
})
default:
t.Fatalf("unexpected method: %s", payload.Method)
}
}))
defer server.Close()
service := NewService(ServiceOptions{
ChainAliasDiscoverer: func(_ context.Context) ([]string, error) {
return []string{"gateway.charon.lthn"}, nil
},
HSDClient: NewHSDClient(HSDClientOptions{
URL: server.URL,
}),
Records: map[string]NameRecords{
"gateway.charon.lthn": {
A: []string{"10.10.10.20"},
},
},
})
reversePayload, ok, err := service.HandleAction(ActionReverse, map[string]any{
"ip": "10.10.10.20",
})
if err != nil {
t.Fatalf("unexpected reverse action error: %v", err)
}
if !ok {
t.Fatal("expected reverse action to return a record")
}
reverse, ok := reversePayload.(ReverseLookupResult)
if !ok {
t.Fatalf("expected ReverseLookupResult payload, got %T", reversePayload)
}
if len(reverse.Names) != 1 || reverse.Names[0] != "gateway.charon.lthn" {
t.Fatalf("unexpected reverse result: %#v", reverse.Names)
}
healthPayload, ok, err := service.HandleAction(ActionHealth, nil)
if err != nil {
t.Fatalf("unexpected health action error: %v", err)
}
if !ok {
t.Fatal("expected health action payload")
}
health, ok := healthPayload.(map[string]any)
if !ok {
t.Fatalf("expected health map payload, got %T", healthPayload)
}
if health["status"] != "ready" {
t.Fatalf("unexpected health payload: %#v", health)
}
srvPayload, ok, err := service.HandleAction(ActionServe, map[string]any{
"bind": "127.0.0.1",
"port": 0,
})
if err != nil {
t.Fatalf("unexpected serve action error: %v", err)
}
if !ok {
t.Fatal("expected serve action to start server")
}
dnsServer, ok := srvPayload.(*DNSServer)
if !ok {
t.Fatalf("expected DNSServer payload, got %T", srvPayload)
}
if dnsServer.Address() == "" {
t.Fatal("expected server address from serve action")
}
_ = dnsServer.Close()
discoverPayload, ok, err := service.HandleAction(ActionDiscover, nil)
if err != nil {
t.Fatalf("unexpected discover action error: %v", err)
}
if discoverPayload == nil || !ok {
t.Fatal("expected discover action payload")
}
if !ok {
t.Fatal("expected discover action to succeed")
}
discoverHealth, ok := discoverPayload.(map[string]any)
if !ok {
t.Fatalf("expected discover action payload map, got %T", discoverPayload)
}
if discoverHealth["tree_root"] != "discover-root" {
t.Fatalf("expected discover to refresh tree root, got %#v", discoverHealth["tree_root"])
}
}
type actionRecorder struct {
names []string
handlers map[string]func(map[string]any) (any, bool, error)
}
func (recorder *actionRecorder) RegisterAction(name string, invoke func(map[string]any) (any, bool, error)) {
if recorder.handlers == nil {
recorder.handlers = map[string]func(map[string]any) (any, bool, error){}
}
recorder.names = append(recorder.names, name)
recorder.handlers[name] = invoke
}
type actionCallerFunc func(context.Context, string, map[string]any) (any, bool, error)
func (caller actionCallerFunc) CallAction(ctx context.Context, name string, values map[string]any) (any, bool, error) {
return caller(ctx, name, values)
}