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:
commit
6825b8b43e
9 changed files with 795 additions and 0 deletions
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
54
README.md
Normal 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
145
cmd/lns/discovery.go
Normal 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
287
cmd/lns/dnsserver.go
Normal 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
240
cmd/lns/main.go
Normal 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
15
docker-compose.yml
Normal 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
16
go.mod
Normal 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
24
go.sum
Normal 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
BIN
lns
Executable file
Binary file not shown.
Loading…
Add table
Reference in a new issue