├── contrib └── lseed.service ├── .gitignore ├── glide.yaml ├── seed ├── dns_test.go ├── network.go └── dns.go ├── LICENSE ├── README.md └── lseed.go /contrib/lseed.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Lightning Network Seeder 3 | After=lightningd.service 4 | 5 | [Service] 6 | ExecStart=/home/bitcoin/go/bin/lseed --listen : --root-domain 7 | User=bitcoin 8 | Group=bitcoin 9 | Type=simple 10 | KillMode=process 11 | TimeoutSec=60 12 | Restart=on-failure 13 | RestartSec=60 14 | 15 | [Install] 16 | WantedBy=multi-user.target 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | 26 | glide.lock 27 | vendor 28 | lseed 29 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/cdecker/lseed 2 | import: 3 | - package: github.com/miekg/dns 4 | - package: github.com/prometheus/client_golang 5 | version: ^0.8.0 6 | - package: github.com/elazarl/go-bindata-assetfs 7 | subpackages: 8 | - '...' 9 | - package: github.com/powerman/rpc-codec 10 | subpackages: 11 | - jsonrpc2 12 | - package: github.com/adiabat/bech32 13 | - package: github.com/thoj/go-ircevent 14 | version: ~0.1.0 15 | -------------------------------------------------------------------------------- /seed/dns_test.go: -------------------------------------------------------------------------------- 1 | package seed 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/davecgh/go-spew/spew" 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | type parseInput struct { 12 | name string 13 | qtype uint16 14 | } 15 | 16 | var parserstestsA = []struct { 17 | in parseInput 18 | out *DnsRequest 19 | }{ 20 | {parseInput{"r0.root.", dns.TypeA}, &DnsRequest{ 21 | subdomain: "r0.", 22 | atypes: 6, 23 | realm: 0, 24 | }}, 25 | {parseInput{"r0.root.", dns.TypeSRV}, &DnsRequest{ 26 | subdomain: "r0.", 27 | atypes: 6, 28 | realm: 0, 29 | }}, 30 | {parseInput{"a4.r0.root.", dns.TypeSRV}, &DnsRequest{ 31 | subdomain: "a4.r0.", 32 | atypes: 4, 33 | realm: 0, 34 | }}, 35 | {parseInput{"s.o.m.t.h.i.n.g.", dns.TypeSRV}, nil}, 36 | {parseInput{"0.root.", dns.TypeCNAME}, nil}, 37 | {parseInput{"root.", dns.TypeA}, &DnsRequest{ 38 | subdomain: "", 39 | atypes: 6, 40 | realm: 0, 41 | }}, 42 | } 43 | 44 | func TestParseRequest(t *testing.T) { 45 | ds := &DnsServer{ 46 | rootDomain: "root", 47 | } 48 | for _, tt := range parserstestsA { 49 | 50 | // Clone some details we are copying anyway 51 | if tt.out != nil { 52 | tt.out.qtype = tt.in.qtype 53 | } 54 | 55 | req, err := ds.parseRequest(tt.in.name, tt.in.qtype) 56 | 57 | if err != nil && tt.out != nil { 58 | t.Errorf("unexpected error %q => %q, want %q, %v", tt.in, req, tt.out, err) 59 | } else if !reflect.DeepEqual(req, tt.out) { 60 | spew.Dump(req) 61 | spew.Dump(tt.out) 62 | t.Errorf("parser error %q => %#v, want %#v", tt.in, req, tt.out) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016, Christian Decker 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /seed/network.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Christian Decker. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package seed 6 | 7 | import ( 8 | "net" 9 | "sync" 10 | "time" 11 | 12 | "github.com/niftynei/glightning/glightning" 13 | ) 14 | 15 | const ( 16 | // Default port for lightning nodes. A and AAAA queries only 17 | // return nodes that listen to this port, SRV queries can 18 | // actually specify a port, so they return all nodes. 19 | defaultPort = 9735 20 | ) 21 | 22 | // A bitfield in which bit 0 indicates whether it is an IPv6 if set, 23 | // and bit 1 indicates whether it uses the default port if set. 24 | type NodeType uint8 25 | 26 | type Address struct { 27 | IP net.IP 28 | Port uint16 29 | } 30 | 31 | // Local model of a node, 32 | type Node struct { 33 | Id string 34 | LastSeen time.Time 35 | Type NodeType 36 | Addresses []Address 37 | } 38 | 39 | // The local view of the network 40 | type NetworkView struct { 41 | nodesMut sync.Mutex 42 | nodes map[string]Node 43 | } 44 | 45 | // Return a random sample matching the NodeType, or just any node if 46 | // query is set to `0xFF`. Relies on random map-iteration ordering 47 | // internally. 48 | func (nv *NetworkView) RandomSample(query NodeType, count int) []Node { 49 | var result []Node 50 | for _, n := range nv.nodes { 51 | if n.Type&query != 0 || query == 255 { 52 | result = append(result, n) 53 | } 54 | if len(result) == count { 55 | break 56 | } 57 | } 58 | return result 59 | } 60 | 61 | // Insert nodes into the map of known nodes. Existing nodes with the 62 | // same Id are overwritten. 63 | func (nv *NetworkView) AddNode(node *glightning.Node) Node { 64 | n := Node{ 65 | Id: node.Id, 66 | LastSeen: time.Now(), 67 | } 68 | 69 | for _, a := range node.Addresses { 70 | 71 | if a.Type != "ipv4" && a.Type != "ipv6" { 72 | continue 73 | } 74 | 75 | address := Address{ 76 | IP: net.ParseIP(a.Addr), 77 | Port: uint16(a.Port), 78 | } 79 | 80 | if address.IP.To4() == nil { 81 | n.Type |= 1 82 | } else { 83 | n.Type |= 1 << 2 84 | } 85 | 86 | if address.Port == defaultPort { 87 | n.Type |= 1 << 1 88 | } 89 | n.Addresses = append(n.Addresses, address) 90 | } 91 | if len(n.Addresses) == 0 { 92 | return n 93 | } 94 | 95 | nv.nodesMut.Lock() 96 | defer nv.nodesMut.Unlock() 97 | nv.nodes[n.Id] = n 98 | 99 | return n 100 | } 101 | 102 | func NewNetworkView() *NetworkView { 103 | return &NetworkView{ 104 | nodesMut: sync.Mutex{}, 105 | nodes: make(map[string]Node), 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lseed -- A Lightning DNS Seed 2 | 3 | Upon first joining the Lightning Network, a node must open a few connections to existing nodes in the network. 4 | However, it can only learn of nodes that are present in the network through its peers. 5 | This Seed helps bootstrapping new nodes by indexing nodes that are present in the network and returning a random sample when queried. 6 | 7 | In addition the seed provides a way to query for specific nodes, e.g., allowing nodes to quickly find their peers even though the switched IP or port. 8 | 9 | ## Supported Queries 10 | 11 | Generally this implementation supports both IPv4 and IPv6 queries, i.e., `A` and `AAAA` queries. 12 | In addition it supports `SRV` queries that return a mix of IPv4 nodes and IPv6 nodes, and their associated `A` and `AAAA` answers. 13 | 14 | ### A & AAAA Queries 15 | 16 | The seed answers incoming `A` and `AAAA` queries with up to 25 known nodes in the network. 17 | The nodes are filtered by their listening port, and only nodes that listen on the default Lightning port, 9735, are returned. 18 | This is necessary since it is not possible to specify the port in `A` and `AAAA` answers. 19 | 20 | ### SRV Queries 21 | 22 | Upon receiving an `SRV` query the seed will sample up to 25 nodes from the known nodes, regardless of their listening port, and return them, specifying an alias and the port. 23 | The `SRV` query attempts to return a balanced set of IPv4 and IPv6 nodes. 24 | 25 | In addition to the alias and port, the seed will also attach the matching `A` and `AAAA` records, such that a single query return both IP and port, and nodes may initiate connections without further queries. 26 | 27 | ## Node Queries (A & AAAA) 28 | 29 | Given the alias from the `SRV` queries, a client can also directly query for a specific node. 30 | If the node's ID is `03edd9462482dbe1f1ea75db38a345c2b1d8a325c5c86f72aa9ea191a94be8b664` then the corresponding alias will be: 31 | 32 | 3edd9462482dbe1f1ea75db38a345c2b1d8a325c5c86f72aa9ea191a94be8b6.64.lseed.bitcoinstats.com 33 | 34 | The leading `0` character is removed from the pubkey, and the pubkey is split into two chunks. The first chunk is 63 characters long, while the second chunk is 2 characters long. 35 | The two chunks are then dot-separated and prefixed to the seed's domain, `lseed.bitcoinstats.com` in this case. 36 | 37 | The answer contains the record matching the query, or the record of the other IP version type in the additional section if IP versions do not match. 38 | 39 | ## Information Source 40 | 41 | Currently the seed will poll a local Lightning node periodically and update its local view accordingly. 42 | In future I'd like to introduce a number of different information sources and add further tests, such as testing for reachability before returning nodes. 43 | -------------------------------------------------------------------------------- /lseed.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "./seed" 12 | 13 | "github.com/niftynei/glightning/glightning" 14 | log "github.com/Sirupsen/logrus" 15 | ) 16 | 17 | var ( 18 | lightningRpc *glightning.Lightning 19 | 20 | listenAddr = flag.String("listen", "0.0.0.0:53", "Listen address for incoming requests.") 21 | rootDomain = flag.String("root-domain", "lseed.bitcoinstats.com", "Root DNS seed domain.") 22 | pollInterval = flag.Int("poll-interval", 10, "Time between polls to lightningd for updates") 23 | lightningDir = flag.String("lightning-dir", filepath.Join(os.Getenv("HOME"),".lightning"), "The lightning directory.") 24 | network = flag.String("network", "bitcoin", "The network to run the seeder on. Used to guess the RPC socket path.") 25 | lightningSock = flag.String("lightning-sock", "lightning-rpc", "Name of the lightning RPC socket") 26 | debug = flag.Bool("debug", false, "Be very verbose") 27 | numResults = flag.Int("results", 25, "How many results shall we return to a query?") 28 | ) 29 | 30 | // Expand variables in paths such as $HOME 31 | func expandVariables() error { 32 | user, err := user.Current() 33 | if err != nil { 34 | return err 35 | } 36 | *lightningSock = strings.Replace(*lightningSock, "$HOME", user.HomeDir, -1) 37 | return nil 38 | } 39 | 40 | // Regularly polls the lightningd node and updates the local NetworkView. 41 | func poller(lrpc *glightning.Lightning, nview *seed.NetworkView) { 42 | scrapeGraph := func() { 43 | nodes, err := lrpc.ListNodes() 44 | 45 | if err != nil { 46 | log.Errorf("Error trying to get update from lightningd: %v", err) 47 | } else { 48 | log.Debugf("Got %d nodes from lightningd", len(nodes)) 49 | for _, n := range nodes { 50 | if len(n.Addresses) == 0 { 51 | continue 52 | } 53 | nview.AddNode(n) 54 | } 55 | } 56 | } 57 | 58 | scrapeGraph() 59 | 60 | ticker := time.NewTicker(time.Second * time.Duration(*pollInterval)) 61 | for range ticker.C { 62 | scrapeGraph() 63 | } 64 | } 65 | 66 | // Parse flags and configure subsystems according to flags 67 | func configure() { 68 | flag.Parse() 69 | expandVariables() 70 | if *debug { 71 | log.SetLevel(log.DebugLevel) 72 | } else { 73 | log.SetLevel(log.InfoLevel) 74 | } 75 | } 76 | 77 | // Main entry point for the lightning-seed 78 | func main() { 79 | configure() 80 | lightningRpc = glightning.NewLightning() 81 | lightningRpc.StartUp(*lightningSock, filepath.Join(*lightningDir, *network)) 82 | 83 | // We currently only support mainnet. 84 | realm := 0 85 | 86 | nview := seed.NewNetworkView() 87 | dnsServer := seed.NewDnsServer(nview, *listenAddr, *rootDomain, realm) 88 | 89 | go poller(lightningRpc, nview) 90 | dnsServer.Serve() 91 | } 92 | -------------------------------------------------------------------------------- /seed/dns.go: -------------------------------------------------------------------------------- 1 | // Copyright 2016 Christian Decker. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package seed 6 | 7 | // Various utilities to help building and serializing DNS answers. Big 8 | // shoutout to miekg for his dns library :-) 9 | 10 | import ( 11 | "encoding/hex" 12 | "fmt" 13 | "strconv" 14 | "strings" 15 | 16 | log "github.com/Sirupsen/logrus" 17 | "github.com/adiabat/bech32" 18 | "github.com/btcsuite/btcd/btcec" 19 | "github.com/miekg/dns" 20 | ) 21 | 22 | type DnsServer struct { 23 | netview *NetworkView 24 | listenAddr string 25 | rootDomain string 26 | realm int 27 | } 28 | 29 | func NewDnsServer(netview *NetworkView, listenAddr, rootDomain string, realm int) *DnsServer { 30 | return &DnsServer{ 31 | netview: netview, 32 | listenAddr: listenAddr, 33 | rootDomain: rootDomain, 34 | realm: realm, 35 | } 36 | } 37 | 38 | func addAResponse(n Node, name string, responses *[]dns.RR) { 39 | header := dns.RR_Header{ 40 | Rrtype: dns.TypeA, 41 | Class: dns.ClassINET, 42 | Ttl: 60, 43 | Name: name, 44 | } 45 | 46 | for _, a := range n.Addresses { 47 | 48 | if a.IP.To4() == nil { 49 | continue 50 | } 51 | 52 | rr := &dns.A{ 53 | Hdr: header, 54 | A: a.IP.To4(), 55 | } 56 | *responses = append(*responses, rr) 57 | } 58 | 59 | } 60 | 61 | func addAAAAResponse(n Node, name string, responses *[]dns.RR) { 62 | header := dns.RR_Header{ 63 | Rrtype: dns.TypeAAAA, 64 | Class: dns.ClassINET, 65 | Ttl: 60, 66 | Name: name, 67 | } 68 | for _, a := range n.Addresses { 69 | 70 | if a.IP.To4() != nil { 71 | continue 72 | } 73 | 74 | rr := &dns.AAAA{ 75 | Hdr: header, 76 | AAAA: a.IP.To16(), 77 | } 78 | *responses = append(*responses, rr) 79 | } 80 | } 81 | 82 | func (ds *DnsServer) handleAAAAQuery(request *dns.Msg, response *dns.Msg) { 83 | nodes := ds.netview.RandomSample(3, 25) 84 | for _, n := range nodes { 85 | addAAAAResponse(n, request.Question[0].Name, &response.Answer) 86 | } 87 | } 88 | 89 | func (ds *DnsServer) handleAQuery(request *dns.Msg, response *dns.Msg) { 90 | nodes := ds.netview.RandomSample(2, 25) 91 | 92 | for _, n := range nodes { 93 | addAResponse(n, request.Question[0].Name, &response.Answer) 94 | } 95 | } 96 | 97 | // Handle incoming SRV requests. 98 | // 99 | // Unlike the A and AAAA requests these are a bit ambiguous, since the 100 | // client may either be IPv4 or IPv6, so just return a mix and let the 101 | // client figure it out. 102 | func (ds *DnsServer) handleSRVQuery(request *dns.Msg, response *dns.Msg) { 103 | nodes := ds.netview.RandomSample(255, 25) 104 | 105 | header := dns.RR_Header{ 106 | Name: request.Question[0].Name, 107 | Rrtype: dns.TypeSRV, 108 | Class: dns.ClassINET, 109 | Ttl: 60, 110 | } 111 | 112 | for _, n := range nodes { 113 | rawId, err := hex.DecodeString(n.Id) 114 | if err != nil { 115 | continue 116 | } 117 | 118 | encodedId := bech32.Encode("ln", rawId) 119 | nodeName := fmt.Sprintf("%s.%s.", encodedId, ds.rootDomain) 120 | rr := &dns.SRV{ 121 | Hdr: header, 122 | Priority: 10, 123 | Weight: 10, 124 | Target: nodeName, 125 | Port: n.Addresses[0].Port, 126 | } 127 | response.Answer = append(response.Answer, rr) 128 | //if n.Type&1 == 1 { 129 | // addAAAAResponse(n, nodeName, &response.Extra) 130 | //} else { 131 | // addAResponse(n, nodeName, &response.Extra) 132 | //} 133 | } 134 | 135 | } 136 | 137 | type DnsRequest struct { 138 | subdomain string 139 | qtype uint16 140 | atypes int 141 | realm int 142 | node_id string 143 | } 144 | 145 | func (ds *DnsServer) parseRequest(name string, qtype uint16) (*DnsRequest, error) { 146 | // Check that this is actually intended for us and not just some other domain 147 | if !strings.HasSuffix(strings.ToLower(name), fmt.Sprintf("%s.", ds.rootDomain)) { 148 | return nil, fmt.Errorf("malformed request: %s", name) 149 | } 150 | 151 | // Check that we actually like the request 152 | if qtype != dns.TypeA && qtype != dns.TypeAAAA && qtype != dns.TypeSRV { 153 | return nil, fmt.Errorf("refusing to handle query type %d (%s)", qtype, dns.TypeToString[qtype]) 154 | } 155 | 156 | req := &DnsRequest{ 157 | subdomain: name[:len(name)-len(ds.rootDomain)-1], 158 | qtype: qtype, 159 | atypes: 6, 160 | } 161 | parts := strings.Split(req.subdomain, ".") 162 | 163 | for _, cond := range parts { 164 | if len(cond) == 0 { 165 | continue 166 | } 167 | k, v := cond[0], cond[1:] 168 | 169 | if k == 'r' { 170 | req.realm, _ = strconv.Atoi(v) 171 | if req.realm != ds.realm { 172 | return nil, fmt.Errorf("unsupported network") 173 | } 174 | } else if k == 'a' && qtype == dns.TypeSRV { 175 | req.atypes, _ = strconv.Atoi(v) 176 | } else if k == 'l' { 177 | _, bin, err := bech32.Decode(cond) 178 | if err != nil { 179 | return nil, fmt.Errorf("malformed bech32 pubkey") 180 | } 181 | 182 | p, err := btcec.ParsePubKey(bin, btcec.S256()) 183 | if err != nil { 184 | return nil, fmt.Errorf("not a valid pubkey") 185 | } 186 | req.node_id = fmt.Sprintf("%x", p.SerializeCompressed()) 187 | } 188 | } 189 | 190 | return req, nil 191 | } 192 | 193 | func (ds *DnsServer) handleLightningDns(w dns.ResponseWriter, r *dns.Msg) { 194 | 195 | if len(r.Question) < 1 { 196 | log.Errorf("empty request") 197 | return 198 | } 199 | 200 | req, err := ds.parseRequest(r.Question[0].Name, r.Question[0].Qtype) 201 | 202 | if err != nil { 203 | log.Errorf("error parsing request: %v", err) 204 | return 205 | } 206 | 207 | log.WithFields(log.Fields{ 208 | "subdomain": req.subdomain, 209 | "type": dns.TypeToString[req.qtype], 210 | }).Debugf("Incoming request") 211 | 212 | m := new(dns.Msg) 213 | m.SetReply(r) 214 | 215 | // Is this a wildcard query? 216 | if req.node_id == "" { 217 | switch req.qtype { 218 | case dns.TypeAAAA: 219 | ds.handleAAAAQuery(r, m) 220 | break 221 | case dns.TypeA: 222 | log.Debugf("Wildcard query") 223 | ds.handleAQuery(r, m) 224 | break 225 | case dns.TypeSRV: 226 | ds.handleSRVQuery(r, m) 227 | } 228 | } else { 229 | n, ok := ds.netview.nodes[req.node_id] 230 | if !ok { 231 | log.Debugf("Unable to find node with ID %s", req.node_id) 232 | } 233 | 234 | // Reply with the correct type 235 | if req.qtype == dns.TypeAAAA { 236 | addAAAAResponse(n, r.Question[0].Name, &m.Answer) 237 | } else if req.qtype == dns.TypeA { 238 | addAResponse(n, r.Question[0].Name, &m.Answer) 239 | } 240 | } 241 | 242 | w.WriteMsg(m) 243 | log.WithField("replies", len(m.Answer)).Debugf( 244 | "Replying with %d answers and %d extras.", len(m.Answer), len(m.Extra)) 245 | } 246 | 247 | func (ds *DnsServer) Serve() { 248 | dns.HandleFunc(ds.rootDomain, ds.handleLightningDns) 249 | server := &dns.Server{Addr: ds.listenAddr, Net: "udp"} 250 | if err := server.ListenAndServe(); err != nil { 251 | log.Errorf("Failed to setup the udp server: %s\n", err.Error()) 252 | } 253 | } 254 | --------------------------------------------------------------------------------