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) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-04-01 22:28:46 +01:00
commit 6825b8b43e
No known key found for this signature in database
GPG key ID: AF404715446AEB41
9 changed files with 795 additions and 0 deletions

14
Dockerfile Normal file
View file

@ -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"]

54
README.md Normal file
View file

@ -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
```

145
cmd/lns/discovery.go Normal file
View file

@ -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
}

287
cmd/lns/dnsserver.go Normal file
View file

@ -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
}

240
cmd/lns/main.go Normal file
View file

@ -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)
}

15
docker-compose.yml Normal file
View file

@ -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

16
go.mod Normal file
View file

@ -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
)

24
go.sum Normal file
View file

@ -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=

BIN
lns Executable file

Binary file not shown.