From 2136501f07e2221a4dfe2bfb710beac803a773e7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 20:01:14 +0000 Subject: [PATCH] feat(dns): add mainchain alias discovery via rpc Co-Authored-By: Virgil --- mainchain.go | 244 ++++++++++++++++++++++++++++++++++++++++++++++ mainchain_test.go | 142 +++++++++++++++++++++++++++ service.go | 22 +++++ 3 files changed, 408 insertions(+) create mode 100644 mainchain.go create mode 100644 mainchain_test.go diff --git a/mainchain.go b/mainchain.go new file mode 100644 index 0000000..cb64b9d --- /dev/null +++ b/mainchain.go @@ -0,0 +1,244 @@ +package dns + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +type MainchainClientOptions struct { + URL string + Username string + Password string + HTTPClient *http.Client +} + +type MainchainAliasClient struct { + baseURL string + username string + password string + httpClient *http.Client +} + +type MainchainRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []any `json:"params"` + ID int `json:"id"` +} + +type MainchainRPCResponse struct { + Result json.RawMessage `json:"result"` + Error *HSDRPCError `json:"error"` +} + +func NewMainchainAliasClient(options MainchainClientOptions) *MainchainAliasClient { + client := options.HTTPClient + if client == nil { + client = &http.Client{} + } + + baseURL := strings.TrimSpace(options.URL) + if baseURL == "" { + baseURL = "http://127.0.0.1:14037" + } + + return &MainchainAliasClient{ + baseURL: baseURL, + username: options.Username, + password: options.Password, + httpClient: client, + } +} + +// GetAllAliasDetails returns alias names mapped to HNS DNS names. +// +// client := dns.NewMainchainAliasClient(dns.MainchainClientOptions{ +// URL: "http://127.0.0.1:14037", +// Username: "user", +// Password: "pass", +// }) +// aliases, err := client.GetAllAliasDetails(context.Background()) +func (client *MainchainAliasClient) GetAllAliasDetails(ctx context.Context) ([]string, error) { + request := MainchainRPCRequest{ + JSONRPC: defaultHSDJSONRPCVersion, + Method: "get_all_alias_details", + Params: []any{}, + ID: 1, + } + + raw, err := client.call(ctx, request) + if err != nil { + return nil, err + } + + aliases, err := parseMainchainAliases(raw) + if err != nil { + return nil, err + } + return aliases, nil +} + +func (client *MainchainAliasClient) call(ctx context.Context, request MainchainRPCRequest) (json.RawMessage, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, err + } + + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL, io.NopCloser(io.Reader(strings.NewReader(string(body))))) + if err != nil { + return nil, err + } + httpRequest.Header.Set("Content-Type", "application/json") + + if client.username != "" || client.password != "" { + httpRequest.Header.Set("Authorization", "Basic "+basicAuthToken(client.username, client.password)) + } + + response, err := client.httpClient.Do(httpRequest) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("mainchain rpc request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(responseBody))) + } + + var decoded MainchainRPCResponse + if err := json.Unmarshal(responseBody, &decoded); err != nil { + return nil, err + } + + if decoded.Error != nil { + return nil, decoded.Error + } + + return decoded.Result, nil +} + +func parseMainchainAliases(raw json.RawMessage) ([]string, error) { + var result []string + if err := json.Unmarshal(raw, &result); err == nil { + return normalizeAliasList(result), nil + } + + var wrappedResult map[string]json.RawMessage + if err := json.Unmarshal(raw, &wrappedResult); err == nil { + if aliasesRaw, ok := wrappedResult["aliases"]; ok { + return parseMainchainAliases(aliasesRaw) + } + if resultRaw, ok := wrappedResult["result"]; ok { + return parseMainchainAliases(resultRaw) + } + } + + var rawRecords []json.RawMessage + if err := json.Unmarshal(raw, &rawRecords); err != nil { + return nil, errors.New("unable to parse get_all_alias_details result") + } + + parsed := make([]string, 0, len(rawRecords)) + for _, rawRecord := range rawRecords { + next, err := parseMainchainAliasRecord(rawRecord) + if err != nil { + return nil, err + } + if next != "" { + parsed = append(parsed, next) + } + } + return normalizeAliasList(parsed), nil +} + +func parseMainchainAliasRecord(raw json.RawMessage) (string, error) { + var name string + if err := json.Unmarshal(raw, &name); err == nil { + return normalizeName(name), nil + } + + var record map[string]json.RawMessage + if err := json.Unmarshal(raw, &record); err != nil { + return "", err + } + + var candidate string + if value, ok := record["hns"]; ok { + if hns, ok := decodeString(value); ok { + candidate = normalizeName(hns) + } + } + if candidate == "" && record["comment"] != nil { + if comment, ok := decodeString(record["comment"]); ok { + candidate = extractAliasFromComment(comment) + } + } + if candidate == "" && record["alias"] != nil { + if alias, ok := decodeString(record["alias"]); ok { + candidate = normalizeName(alias) + } + } + if candidate == "" && record["name"] != nil { + if alias, ok := decodeString(record["name"]); ok { + candidate = normalizeName(alias) + } + } + return candidate, nil +} + +func decodeString(raw json.RawMessage) (string, bool) { + var value string + if len(raw) == 0 { + return "", false + } + if err := json.Unmarshal(raw, &value); err != nil { + return "", false + } + value = strings.TrimSpace(value) + return value, value != "" +} + +func extractAliasFromComment(comment string) string { + for _, token := range strings.Fields(comment) { + if strings.HasPrefix(token, "hns=") { + return normalizeName(strings.TrimSuffix(strings.TrimPrefix(token, "hns="), ";")) + } + } + + if marker := strings.Index(comment, "hns="); marker >= 0 { + alias := comment[marker+4:] + if trim := strings.IndexAny(alias, " ;,"); trim >= 0 { + alias = alias[:trim] + } + alias = strings.TrimSpace(alias) + alias = strings.TrimSuffix(alias, ";") + return normalizeName(alias) + } + + return "" +} + +func normalizeAliasList(raw []string) []string { + seen := map[string]bool{} + normalized := make([]string, 0, len(raw)) + for _, name := range raw { + next := normalizeName(name) + if next == "" { + continue + } + if seen[next] { + continue + } + seen[next] = true + normalized = append(normalized, next) + } + return normalized +} diff --git a/mainchain_test.go b/mainchain_test.go new file mode 100644 index 0000000..d6e39e3 --- /dev/null +++ b/mainchain_test.go @@ -0,0 +1,142 @@ +package dns + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestMainchainClientGetsAliasDetailsAndParsesHNSComments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { + var payload struct { + Method string `json:"method"` + } + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + t.Fatalf("unexpected request payload: %v", err) + } + if payload.Method != "get_all_alias_details" { + t.Fatalf("expected method get_all_alias_details, got %s", payload.Method) + } + + responseWriter.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(responseWriter).Encode(map[string]any{ + "result": []any{ + map[string]any{ + "name": "gateway", + "comment": "gateway alias hns=gateway.charon.lthn", + }, + map[string]any{ + "hns": "node.charon.lthn", + "name": "node", + }, + }, + }) + })) + defer server.Close() + + client := NewMainchainAliasClient(MainchainClientOptions{ + URL: server.URL, + }) + + aliases, err := client.GetAllAliasDetails(context.Background()) + if err != nil { + t.Fatalf("unexpected get_all_alias_details error: %v", err) + } + if len(aliases) != 2 { + t.Fatalf("expected 2 aliases, got %d", len(aliases)) + } + if aliases[0] != "gateway.charon.lthn" || aliases[1] != "node.charon.lthn" { + t.Fatalf("unexpected aliases: %#v", aliases) + } +} + +func TestServiceDiscoverFromMainchainAliasesUsesMainchainThenHSD(t *testing.T) { + var chainCalls int32 + var hsdTreeRootCalls int32 + var hsdAliasCalls 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(&chainCalls, 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(&hsdTreeRootCalls, 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(&hsdAliasCalls, 1) + responseWriter.Header().Set("Content-Type", "application/json") + 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{}) + chainClient := NewMainchainAliasClient(MainchainClientOptions{ + URL: server.URL, + }) + hsdClient := NewHSDClient(HSDClientOptions{ + URL: server.URL, + }) + + if err := service.DiscoverFromMainchainAliases(context.Background(), chainClient, hsdClient); err != nil { + t.Fatalf("expected discover from mainchain aliases: %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 chainCalls != 1 || hsdTreeRootCalls != 1 || hsdAliasCalls != 2 { + t.Fatalf("expected chain=1 tree-root=1 name-resource=2, got %d %d %d", chainCalls, hsdTreeRootCalls, hsdAliasCalls) + } +} + diff --git a/service.go b/service.go index 8130ba3..c54e991 100644 --- a/service.go +++ b/service.go @@ -93,6 +93,28 @@ func (service *Service) DiscoverFromChainAliases(ctx context.Context, client *HS return service.discoverFromChainAliasesUsingTreeRoot(ctx, aliases, client) } +// DiscoverFromMainchainAliases updates records from main-chain aliases resolved through HSD. +// +// service.DiscoverFromMainchainAliases(context.Background(), dns.NewMainchainAliasClient(dns.MainchainClientOptions{ +// URL: "http://127.0.0.1:14037", +// }), dns.NewHSDClient(dns.HSDClientOptions{ +// URL: "http://127.0.0.1:14037", +// })) +func (service *Service) DiscoverFromMainchainAliases(ctx context.Context, chainClient *MainchainAliasClient, hsdClient *HSDClient) error { + if chainClient == nil { + return fmt.Errorf("mainchain alias client is required") + } + + aliases, err := chainClient.GetAllAliasDetails(ctx) + if err != nil { + return err + } + if len(aliases) == 0 { + return nil + } + return service.discoverFromChainAliasesUsingTreeRoot(ctx, aliases, hsdClient) +} + func (service *Service) discoverFromChainAliasesUsingTreeRoot(ctx context.Context, aliases []string, client *HSDClient) error { if len(aliases) == 0 { return nil -- 2.45.3