commit 6825b8b43eb9b21e877775f39e9ab830bf0d9f04 Author: Claude Date: Wed Apr 1 22:28:46 2026 +0100 feat(lns): Lethean Name Server v0.1.0 Go binary serving .lthn DNS from ITNS sidechain. - DNS over UDP+TCP (A, AAAA, TXT, NS, SOA, PTR) - Chain-based discovery via get_all_alias_details - Tree-root invalidation (getblockchaininfo) - hns= alias comment override - Health endpoint (/health) - 672 lines, core/go@v0.8.0-alpha.1 Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee4ab34 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.26-alpine AS build +WORKDIR /app +COPY go.mod go.sum ./ +ENV GOWORK=off +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOWORK=off go build -o lns ./cmd/lns + +FROM alpine:latest +RUN apk add --no-cache ca-certificates +COPY --from=build /app/lns /usr/local/bin/ +EXPOSE 53/udp 53/tcp 5553 +ENTRYPOINT ["lns"] +CMD ["--mode=light"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcba634 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# LNS — Lethean Name Server + +Serves `.lthn` DNS from the ITNS sidechain. Light mode — queries remote HSD RPC, discovers names from the main chain, serves DNS over UDP+TCP. + +Built with `dappco.re/go/core@v0.8.0-alpha.1`. + +## Quick Start + +```bash +# Docker (light mode — no chain sync needed) +docker compose up -d + +# Or native +GOWORK=off go build -o lns ./cmd/lns +DAEMON_URL=http://127.0.0.1:46941 HSD_URL=http://127.0.0.1:14037 ./lns +``` + +## Test + +```bash +# DNS (UDP + TCP) +dig @localhost charon.lthn A +dig @localhost charon.lthn TXT +dig @localhost charon.lthn A +tcp + +# HTTP API +curl http://localhost:5553/resolve?name=charon +curl http://localhost:5553/names +curl http://localhost:5553/health +``` + +## Configuration + +| Env var | Default | Description | +|---------|---------|-------------| +| `LNS_MODE` | light | light (remote HSD) | +| `DAEMON_URL` | http://127.0.0.1:46941 | Main chain RPC (for alias discovery) | +| `HSD_URL` | http://127.0.0.1:14037 | HSD sidechain RPC | +| `HSD_API_KEY` | testkey | HSD API key | +| `DNS_PORT | 53 | DNS server port (UDP + TCP) | +| `HTTP_PORT` | 5553 | HTTP API port | +| `CHECK_INTERVAL` | 15 | Tree root check seconds | + +## Discovery + +On startup, LNS queries the main chain (`get_all_alias_details`) for registered aliases. Each alias becomes an HNS name to resolve on the sidechain. If an alias comment has `hns=X.lthn`, the HNS name is X (not the alias name). Falls back to a hardcoded list if the chain is unreachable. + +## Architecture + +See `code/core/go/blockchain/RFC.lns.md` for the full spec (16 sections). + +``` +Main chain (aliases) → Discovery → HSD sidechain (records) → Cache → DNS + HTTP +``` diff --git a/cmd/lns/discovery.go b/cmd/lns/discovery.go new file mode 100644 index 0000000..0e9b0de --- /dev/null +++ b/cmd/lns/discovery.go @@ -0,0 +1,145 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + + core "dappco.re/go/core" +) + +var ( + daemonURL = envDefault("DAEMON_URL", "http://127.0.0.1:46941") +) + +// ChainAlias represents an alias registered on the main chain. +// +// alias := ChainAlias{Name: "charon", Comment: "v=lthn1;type=gateway;cap=vpn,dns"} +type ChainAlias struct { + Name string + Address string + Comment string +} + +// discoverNames queries the main chain for all registered aliases, +// then returns a list of HNS names to resolve on the sidechain. +// If the alias comment has hns=X.lthn, uses X. Otherwise uses the alias name. +// +// names := discoverNames() // ["charon", "trade", "darbs", ...] +func discoverNames() []string { + aliases := getChainAliases() + if len(aliases) == 0 { + core.Println("[discover] No aliases found on chain — using fallback list") + return knownNames + } + + seen := make(map[string]bool) + names := make([]string, 0, len(aliases)) + for _, alias := range aliases { + hnsName := alias.Name + + // If alias comment has hns=X.lthn, derive the HNS name from it. + comment := parseAliasComment(alias.Comment) + if hns, ok := comment["hns"]; ok && hns != "" { + // Strip .lthn suffix to get the bare HNS name. + stripped := core.TrimSuffix(hns, ".lthn") + if stripped != "" { + hnsName = stripped + } + } + + if !seen[hnsName] { + seen[hnsName] = true + names = append(names, hnsName) + } + } + core.Println(core.Sprintf("[discover] Found %d aliases → %d HNS names", len(aliases), len(names))) + return names +} + +// getChainAliases calls get_all_alias_details on the main chain daemon. +func getChainAliases() []ChainAlias { + body := `{"jsonrpc":"2.0","id":"0","method":"get_all_alias_details","params":{}}` + request, err := http.NewRequest("POST", daemonURL+"/json_rpc", core.NewReader(body)) + if err != nil { + return nil + } + request.Header.Set("Content-Type", "application/json") + + response, err := http.DefaultClient.Do(request) + if err != nil { + core.Println(core.Sprintf("[discover] Chain RPC failed: %v", err)) + return nil + } + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + + var result struct { + Result struct { + Aliases []struct { + Alias string `json:"alias"` + Address string `json:"address"` + Comment string `json:"comment"` + } `json:"aliases"` + } `json:"result"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return nil + } + + aliases := make([]ChainAlias, 0, len(result.Result.Aliases)) + for _, entry := range result.Result.Aliases { + aliases = append(aliases, ChainAlias{ + Name: entry.Alias, + Address: entry.Address, + Comment: entry.Comment, + }) + } + return aliases +} + +// parseAliasComment extracts key=value pairs from a v=lthn1 alias comment. +// +// parseAliasComment("v=lthn1;type=gateway;cap=vpn,dns") +// // returns map["v":"lthn1", "type":"gateway", "cap":"vpn,dns"] +func parseAliasComment(comment string) map[string]string { + result := make(map[string]string) + pairs := splitSemicolon(comment) + for _, pair := range pairs { + equalIndex := indexOf(pair, '=') + if equalIndex > 0 { + key := pair[:equalIndex] + value := pair[equalIndex+1:] + result[key] = value + } + } + return result +} + +func splitSemicolon(input string) []string { + parts := make([]string, 0) + current := "" + for _, character := range input { + if character == ';' { + if current != "" { + parts = append(parts, current) + } + current = "" + } else { + current += string(character) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +func indexOf(input string, target rune) int { + for index, character := range input { + if character == target { + return index + } + } + return -1 +} diff --git a/cmd/lns/dnsserver.go b/cmd/lns/dnsserver.go new file mode 100644 index 0000000..9ab64ba --- /dev/null +++ b/cmd/lns/dnsserver.go @@ -0,0 +1,287 @@ +package main + +import ( + "net" + "time" + + core "dappco.re/go/core" + "github.com/miekg/dns" +) + +const ( + lthnZone = "lthn." + dnsSOATTL = 300 + dnsRRTTL = 300 +) + +// soaRecord returns the authoritative SOA record for the lthn. zone. +// +// soaRecord() // returns dns.RR with Mname=ns1.lthn., Rname=hostmaster.lthn. +func soaRecord() dns.RR { + return &dns.SOA{ + Hdr: dns.RR_Header{ + Name: lthnZone, + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: dnsSOATTL, + }, + Ns: "ns1.lthn.", + Mbox: "hostmaster.lthn.", + Serial: uint32(time.Now().Unix()), + Refresh: 3600, + Retry: 600, + Expire: 86400, + Minttl: 300, + } +} + +// parseLTHNName strips the .lthn. suffix and returns the bare label. +// Returns ("", false) if the name is not under the lthn. zone. +// Returns ("", true) for the zone apex (lthn. itself). +// +// parseLTHNName("charon.lthn.") // => "charon", true +// parseLTHNName("lthn.") // => "", true (zone apex) +// parseLTHNName("example.com.") // => "", false +func parseLTHNName(queryName string) (string, bool) { + queryName = dns.Fqdn(queryName) + if !dns.IsSubDomain(lthnZone, queryName) { + return "", false + } + // Zone apex query (lthn. itself). + if queryName == lthnZone { + return "", true + } + label := queryName[:len(queryName)-len(lthnZone)] + if len(label) > 0 && label[len(label)-1] == '.' { + label = label[:len(label)-1] + } + return label, label != "" +} + +// handleDNS processes a single DNS request and writes the reply. +func handleDNS(writer dns.ResponseWriter, request *dns.Msg) { + reply := new(dns.Msg) + reply.SetReply(request) + reply.Authoritative = true + reply.RecursionAvailable = false + + if len(request.Question) == 0 { + reply.SetRcode(request, dns.RcodeFormatError) + writer.WriteMsg(reply) //nolint:errcheck + return + } + + question := request.Question[0] + + // Handle reverse DNS (PTR) queries for in-addr.arpa. + if question.Qtype == dns.TypePTR && dns.IsSubDomain("in-addr.arpa.", dns.Fqdn(question.Name)) { + handlePTR(writer, reply, question) + return + } + + label, ok := parseLTHNName(question.Name) + if !ok { + reply.SetRcode(request, dns.RcodeRefused) + writer.WriteMsg(reply) //nolint:errcheck + return + } + + // Zone apex — return SOA for any query type. + if label == "" { + if question.Qtype == dns.TypeSOA || question.Qtype == dns.TypeANY { + reply.Answer = append(reply.Answer, soaRecord()) + } else { + reply.Ns = []dns.RR{soaRecord()} + } + writer.WriteMsg(reply) //nolint:errcheck + return + } + + record, found := cache[label] + if !found { + reply.SetRcode(request, dns.RcodeNameError) + reply.Ns = []dns.RR{soaRecord()} + writer.WriteMsg(reply) //nolint:errcheck + return + } + + switch question.Qtype { + case dns.TypeA: + for _, address := range record.A { + ip := net.ParseIP(address) + if ip == nil { + continue + } + reply.Answer = append(reply.Answer, &dns.A{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: dnsRRTTL, + }, + A: ip.To4(), + }) + } + + case dns.TypeAAAA: + for _, address := range record.AAAA { + ip := net.ParseIP(address) + if ip == nil { + continue + } + reply.Answer = append(reply.Answer, &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: dnsRRTTL, + }, + AAAA: ip.To16(), + }) + } + + case dns.TypeTXT: + if len(record.TXT) > 0 { + reply.Answer = append(reply.Answer, &dns.TXT{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: dnsRRTTL, + }, + Txt: record.TXT, + }) + } + + case dns.TypeNS: + for _, nameserver := range record.NS { + reply.Answer = append(reply.Answer, &dns.NS{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: dnsRRTTL, + }, + Ns: dns.Fqdn(nameserver), + }) + } + + case dns.TypeSOA: + reply.Answer = append(reply.Answer, soaRecord()) + + default: + reply.Ns = []dns.RR{soaRecord()} + } + + if len(reply.Answer) == 0 && question.Qtype != dns.TypeSOA { + reply.Ns = []dns.RR{soaRecord()} + } + + writer.WriteMsg(reply) //nolint:errcheck +} + +// handlePTR resolves reverse DNS queries by scanning the cache for matching IPs. +// +// dig -x 10.69.69.165 → charon.lthn. +func handlePTR(writer dns.ResponseWriter, reply *dns.Msg, question dns.Question) { + // Parse IP from in-addr.arpa name. + arpaName := dns.Fqdn(question.Name) + ip := arpaToIP(arpaName) + if ip == "" { + reply.SetRcode(reply, dns.RcodeNameError) + writer.WriteMsg(reply) //nolint:errcheck + return + } + + // Scan cache for matching A records. + for _, record := range cache { + for _, address := range record.A { + if address == ip { + reply.Answer = append(reply.Answer, &dns.PTR{ + Hdr: dns.RR_Header{ + Name: question.Name, + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: dnsRRTTL, + }, + Ptr: dns.Fqdn(record.Name + ".lthn"), + }) + } + } + } + + if len(reply.Answer) == 0 { + reply.SetRcode(reply, dns.RcodeNameError) + } + writer.WriteMsg(reply) //nolint:errcheck +} + +// arpaToIP converts "165.69.69.10.in-addr.arpa." to "10.69.69.165". +// +// arpaToIP("165.69.69.10.in-addr.arpa.") // => "10.69.69.165" +func arpaToIP(arpa string) string { + // Remove .in-addr.arpa. suffix. + suffix := ".in-addr.arpa." + if len(arpa) <= len(suffix) { + return "" + } + octets := arpa[:len(arpa)-len(suffix)] + + // Split and reverse octets. + parts := splitDot(octets) + if len(parts) != 4 { + return "" + } + return parts[3] + "." + parts[2] + "." + parts[1] + "." + parts[0] +} + +func splitDot(input string) []string { + parts := make([]string, 0, 4) + current := "" + for _, character := range input { + if character == '.' { + parts = append(parts, current) + current = "" + } else { + current += string(character) + } + } + if current != "" { + parts = append(parts, current) + } + return parts +} + +// startDNSServer binds UDP and TCP DNS servers on the given address. +// Returns immediately; servers run in background goroutines. +// +// startDNSServer(":5353") // listens on all interfaces, port 5353 +func startDNSServer(address string) error { + handler := dns.HandlerFunc(handleDNS) + + udpServer := &dns.Server{Addr: address, Net: "udp", Handler: handler} + tcpServer := &dns.Server{Addr: address, Net: "tcp", Handler: handler} + + // Verify UDP bind. + packageConn, err := net.ListenPacket("udp", address) + if err != nil { + return core.E("dns.bind", core.Sprintf("bind %s failed", address), err) + } + packageConn.Close() + + go func() { + core.Println(core.Sprintf("[%s] DNS server listening on %s (UDP)", time.Now().Format("15:04:05"), address)) + if serveError := udpServer.ListenAndServe(); serveError != nil { + core.Println(core.Sprintf("[%s] DNS UDP error: %v", time.Now().Format("15:04:05"), serveError)) + } + }() + + go func() { + core.Println(core.Sprintf("[%s] DNS server listening on %s (TCP)", time.Now().Format("15:04:05"), address)) + if serveError := tcpServer.ListenAndServe(); serveError != nil { + core.Println(core.Sprintf("[%s] DNS TCP error: %v", time.Now().Format("15:04:05"), serveError)) + } + }() + + return nil +} diff --git a/cmd/lns/main.go b/cmd/lns/main.go new file mode 100644 index 0000000..40b06b9 --- /dev/null +++ b/cmd/lns/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "time" + + core "dappco.re/go/core" +) + +var ( + hsdURL = envDefault("HSD_URL", "http://127.0.0.1:14037") + hsdKey = envDefault("HSD_API_KEY", "testkey") + dnsPort = envDefault("DNS_PORT", "5353") + checkSec = envDefault("CHECK_INTERVAL", "15") + mode = envDefault("LNS_MODE", "light") + httpPort = envDefault("HTTP_PORT", "5553") +) + +func envDefault(key, fallback string) string { + if value := core.Env(key); value != "" { + return value + } + return fallback +} + +// NameRecord holds DNS records for a .lthn name. +// +// record := &NameRecord{Name: "charon", A: []string{"10.69.69.165"}} +type NameRecord struct { + Name string + A []string + AAAA []string + TXT []string + NS []string +} + +var cache = map[string]*NameRecord{} +var lastRoot = "" + +func hsdRPC(method string, params []interface{}) (json.RawMessage, error) { + body := map[string]interface{}{"method": method, "params": params} + data, _ := json.Marshal(body) + request, _ := http.NewRequest("POST", hsdURL, core.NewReader(string(data))) + request.Header.Set("Content-Type", "application/json") + request.SetBasicAuth("x", hsdKey) + response, err := http.DefaultClient.Do(request) + if err != nil { + return nil, core.E("hsd.rpc", core.Sprintf("%s failed", method), err) + } + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + var result struct { + Result json.RawMessage `json:"result"` + } + json.Unmarshal(raw, &result) + return result.Result, nil +} + +func getTreeRoot() string { + raw, err := hsdRPC("getblockchaininfo", nil) + if err != nil { + return "" + } + var info struct { + TreeRoot string `json:"treeroot"` + Blocks int `json:"blocks"` + } + json.Unmarshal(raw, &info) + return info.TreeRoot +} + +func getNameRecords(name string) *NameRecord { + raw, err := hsdRPC("getnameresource", []interface{}{name}) + if err != nil { + return nil + } + var resource struct { + Records []struct { + Type string `json:"type"` + NS string `json:"ns"` + Address string `json:"address"` + TXT []string `json:"txt"` + } `json:"records"` + } + json.Unmarshal(raw, &resource) + if len(resource.Records) == 0 { + return nil + } + record := &NameRecord{Name: name} + hasRecords := false + for _, entry := range resource.Records { + switch entry.Type { + case "GLUE4": + record.A = append(record.A, entry.Address) + hasRecords = true + case "GLUE6": + record.AAAA = append(record.AAAA, entry.Address) + hasRecords = true + case "TXT": + record.TXT = append(record.TXT, entry.TXT...) + hasRecords = true + case "NS": + record.NS = append(record.NS, entry.NS) + hasRecords = true + } + } + if !hasRecords { + return nil + } + return record +} + +var knownNames = []string{ + "lethean", "snider", "darbs", "charon", "cladius", + "explorer", "testnet", "builder", "develop", "miners", + "relayer", "gateway", "monitor", "network", "storage", + "support", "trade", "trading", +} + +func syncCache() { + root := getTreeRoot() + if root == lastRoot && root != "" { + return + } + + // Discover names from the main chain, fall back to hardcoded list. + names := discoverNames() + + core.Println(core.Sprintf("[%s] Tree root changed — syncing %d names", time.Now().Format("15:04:05"), len(names))) + for _, name := range names { + record := getNameRecords(name) + if record != nil { + cache[name] = record + } + } + lastRoot = root + core.Println(core.Sprintf("[%s] Cache: %d names loaded", time.Now().Format("15:04:05"), len(cache))) +} + +func dnsHandler(writer http.ResponseWriter, request *http.Request) { + name := request.URL.Query().Get("name") + queryType := request.URL.Query().Get("type") + if name == "" { + json.NewEncoder(writer).Encode(map[string]interface{}{"error": "?name= required"}) + return + } + name = core.TrimSuffix(name, ".lthn") + name = core.TrimSuffix(name, ".lthn.") + + record, found := cache[name] + if !found { + writer.WriteHeader(404) + json.NewEncoder(writer).Encode(map[string]interface{}{"name": name + ".lthn", "found": false}) + return + } + + response := map[string]interface{}{ + "name": name + ".lthn", + "found": true, + } + if queryType == "" || queryType == "A" { + response["A"] = record.A + } + if queryType == "" || queryType == "TXT" { + response["TXT"] = record.TXT + } + writer.Header().Set("Content-Type", "application/json") + json.NewEncoder(writer).Encode(response) +} + +func healthHandler(writer http.ResponseWriter, request *http.Request) { + healthy := lastRoot != "" && len(cache) > 0 + status := "ok" + if !healthy { + status = "degraded" + writer.WriteHeader(503) + } + writer.Header().Set("Content-Type", "application/json") + json.NewEncoder(writer).Encode(map[string]interface{}{ + "status": status, + "mode": mode, + "names": len(cache), + "tree_root": lastRoot, + }) +} + +func allHandler(writer http.ResponseWriter, request *http.Request) { + names := make([]map[string]interface{}, 0) + for _, record := range cache { + names = append(names, map[string]interface{}{ + "name": record.Name + ".lthn", + "A": record.A, + "TXT": record.TXT, + }) + } + writer.Header().Set("Content-Type", "application/json") + json.NewEncoder(writer).Encode(map[string]interface{}{ + "mode": mode, + "names": names, + "count": len(names), + "root": lastRoot, + }) +} + +func main() { + core.Println("Lethean Name Server (LNS)") + core.Println(core.Sprintf(" Mode: %s", mode)) + core.Println(core.Sprintf(" Chain: %s", daemonURL)) + core.Println(core.Sprintf(" HSD: %s", hsdURL)) + core.Println(core.Sprintf(" HTTP port: %s", httpPort)) + core.Println(core.Sprintf(" DNS port: %s", dnsPort)) + core.Println("") + + syncCache() + + go func() { + for { + time.Sleep(15 * time.Second) + syncCache() + } + }() + + // Start DNS server (UDP). + if err := startDNSServer(":" + dnsPort); err != nil { + core.Println(core.Sprintf("WARNING: DNS server failed to start: %v", err)) + } + + // HTTP API. + http.HandleFunc("/resolve", dnsHandler) + http.HandleFunc("/names", allHandler) + http.HandleFunc("/health", healthHandler) + http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) { + writer.Write([]byte(core.Sprintf("LNS %s mode — /resolve?name=charon&type=A — /names — /health\n", mode))) + }) + + core.Println(core.Sprintf("HTTP API listening on :%s", httpPort)) + http.ListenAndServe(":"+httpPort, nil) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9794fec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,15 @@ +# LNS — Lethean Name Server +# Usage: docker compose up -d +services: + lns: + build: . + container_name: lthn-lns + network_mode: host + environment: + LNS_MODE: light + DAEMON_URL: http://127.0.0.1:46941 + HSD_URL: http://127.0.0.1:14037 + HSD_API_KEY: testkey + DNS_PORT: "53" + HTTP_PORT: "5553" + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8e12521 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module lethean.io/lns + +go 1.26.0 + +require ( + dappco.re/go/core v0.8.0-alpha.1 + github.com/miekg/dns v1.1.72 +) + +require ( + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/tools v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f27888d --- /dev/null +++ b/go.sum @@ -0,0 +1,24 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lns b/lns new file mode 100755 index 0000000..8041155 Binary files /dev/null and b/lns differ