Skip to content
File

Blob: cmd/dns/dns.go

go318 lines
1package dns
2 
3import (
4 "context"
5 "fmt"
6 "net"
7 "net/http"
8 "net/url"
9 "os"
10 "os/signal"
11 "runtime"
12 "strings"
13 "syscall"
14 "time"
15 
16 "go.miragespace.co/specter/acme"
17 cmdlisten "go.miragespace.co/specter/cmd/internal/listen"
18 acmeSpec "go.miragespace.co/specter/spec/acme"
19 "go.miragespace.co/specter/spec/chord"
20 "go.miragespace.co/specter/spec/protocol"
21 "go.miragespace.co/specter/util"
22 "go.miragespace.co/specter/util/reuse"
23 
24 "github.com/miekg/dns"
25 "github.com/urfave/cli/v3"
26 "go.uber.org/zap"
27)
28 
29func Generate() *cli.Command {
30 ip := util.GetOutboundIP()
31 return &cli.Command{
32 Name: "dns",
33 Usage: "start acme dns on the edge",
34 Description: `Handle ACME DNS challenge`,
35 ArgsUsage: " ",
36 Flags: []cli.Flag{
37 &cli.StringSliceFlag{
38 Name: "listen-addr",
39 Aliases: []string{"listen"},
40 Value: []string{fmt.Sprintf("%s:53", ip.String())},
41 Usage: `Repeatable address:port to listen for incoming acme dns queries. Each entry serves both TCP and UDP unless overridden.`,
42 Sources: cli.EnvVars("LISTEN_ADDR"),
43 },
44 &cli.StringSliceFlag{
45 Name: "listen-tcp",
46 DefaultText: "same as listen-addr",
47 Usage: "Override the listen address and port for TCP (repeatable)",
48 Sources: cli.EnvVars("LISTEN_TCP"),
49 },
50 &cli.StringSliceFlag{
51 Name: "listen-udp",
52 DefaultText: "same as listen-addr",
53 Usage: "Override the listen address and port for UDP (repeatable). Required if environment needs a specific address, such as on fly.io",
54 Sources: cli.EnvVars("LISTEN_UDP"),
55 },
56 &cli.StringFlag{
57 Name: "rpc",
58 Value: "tcp://127.0.0.1:11180",
59 Required: true,
60 Usage: `Specter server's listen-rpc endpoint. This is required to lookup acme challenge on the chord network.`,
61 },
62 &cli.StringFlag{
63 Name: "acme",
64 DefaultText: "acme://{ACME_EMAIL}:@acmehostedzone.com",
65 Required: true,
66 Sources: cli.EnvVars("ACME_URI"),
67 Usage: `To enable acme dns, provide an email for the issuer, and the delegated zone for hosting challenges.
68 Alternatively, you can set the URI via the environment variable ACME_URI.`,
69 },
70 &cli.StringSliceFlag{
71 Name: "acme-ns",
72 Sources: cli.EnvVars("ACME_NS"),
73 Required: true,
74 Usage: `If acme dns is enabled, specify the delegated zone's A/AAAA records. For example, ns1.acmehostedzone.com/93.184.216.34.
75 This is needed to delegate acme dns challenges to specter.
76 Multiple records can be separated with a comma.`,
77 },
78 
79 // used for acme setup internally
80 &cli.StringFlag{
81 Name: "acme_email",
82 Hidden: true,
83 },
84 &cli.StringFlag{
85 Name: "acme_zone",
86 Hidden: true,
87 },
88 },
89 Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
90 email, zone, err := acmeSpec.ParseAcmeURI(cmd.String("acme"))
91 if err != nil {
92 return ctx, err
93 }
94 
95 ns := make(map[string][]string)
96 records := cmd.StringSlice("acme-ns")
97 for _, r := range records {
98 parts := strings.Split(r, "/")
99 if len(parts) != 2 {
100 return ctx, fmt.Errorf("unable to parse record: %s", r)
101 }
102 domain := parts[0]
103 _, ok := ns[domain]
104 if !ok {
105 ns[domain] = make([]string, 0)
106 }
107 ns[domain] = append(ns[domain], parts[1])
108 }
109 cmd.Root().Metadata["ns"] = ns
110 
111 cmd.Set("acme_email", email)
112 cmd.Set("acme_zone", zone)
113 
114 return ctx, nil
115 },
116 Action: cmdDNS,
117 }
118}
119 
120func cmdDNS(ctx context.Context, cmd *cli.Command) error {
121 logger, ok := cmd.Root().Metadata["logger"].(*zap.Logger)
122 if !ok || logger == nil {
123 return fmt.Errorf("unable to obtain logger from app context")
124 }
125 
126 listenBase := cmd.StringSlice("listen-addr")
127 tcpAddrs, err := cmdlisten.ParseAddresses("tcp",
128 listenBase,
129 cmd.StringSlice("listen-tcp"),
130 )
131 if err != nil {
132 return fmt.Errorf("error parsing tcp listen address: %w", err)
133 }
134 
135 udpAddrs, err := cmdlisten.ParseAddresses("udp",
136 listenBase,
137 cmd.StringSlice("listen-udp"),
138 )
139 if err != nil {
140 return fmt.Errorf("error parsing udp listen address: %w", err)
141 }
142 
143 if len(listenBase) == 0 {
144 return fmt.Errorf("at least one listen-addr must be provided")
145 }
146 
147 addrStrings := func(addrs []cmdlisten.Address) []string {
148 out := make([]string, 0, len(addrs))
149 for _, a := range addrs {
150 out = append(out, a.Address)
151 }
152 return out
153 }
154 
155 logger.Info("acme dns listener configuration",
156 zap.Strings("tcp", addrStrings(tcpAddrs)),
157 zap.Strings("udp", addrStrings(udpAddrs)),
158 )
159 
160 var (
161 dialNetwork string
162 dialAddress string
163 )
164 parsedRpc, err := url.Parse(cmd.String("rpc"))
165 if err != nil {
166 return fmt.Errorf("error parsing rpc address: %w", err)
167 }
168 switch parsedRpc.Scheme {
169 case "unix":
170 dialNetwork = "unix"
171 dialAddress = parsedRpc.Path
172 case "tcp":
173 dialNetwork = "tcp"
174 dialAddress = parsedRpc.Host
175 default:
176 return fmt.Errorf("unknown scheme for rpc address: %s", parsedRpc.Scheme)
177 }
178 
179 listenCfg := &net.ListenConfig{
180 Control: reuse.Control,
181 }
182 
183 dialer := &net.Dialer{}
184 t := http.DefaultTransport.(*http.Transport).Clone()
185 t.MaxConnsPerHost = 100
186 t.MaxIdleConnsPerHost = 5
187 t.DisableCompression = true
188 t.IdleConnTimeout = time.Minute
189 t.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
190 return dialer.DialContext(ctx, dialNetwork, dialAddress)
191 }
192 client := protocol.NewKVServiceProtobufClient("http://rpc", &http.Client{
193 Transport: t,
194 })
195 kv := &RemoteKV{
196 Client: client,
197 }
198 
199 acmeDomain := cmd.String("acme_zone")
200 acmeDNS := acme.NewDNS(ctx, logger.With(zap.String("component", "acme_dns")),
201 kv,
202 cmd.String("acme_email"),
203 acmeDomain, cmd.Root().Metadata["ns"].(map[string][]string),
204 )
205 
206 dnsMux := dns.NewServeMux()
207 dnsMux.Handle(acmeDomain, acmeDNS)
208 dnsMux.Handle(".", dns.HandlerFunc(Chaos(cmd.Root().Version)))
209 
210 var tcpServers []*dns.Server
211 for _, addr := range tcpAddrs {
212 ln, err := listenCfg.Listen(ctx, addr.Network, addr.Address)
213 if err != nil {
214 return fmt.Errorf("error setting up dns tcp listener on %s: %w", addr.Address, err)
215 }
216 listenAddr := addr.Address
217 server := &dns.Server{
218 Listener: ln,
219 Handler: dnsMux,
220 NotifyStartedFunc: func() {
221 logger.Info("ACME DNS started", zap.String("proto", "tcp"), zap.String("listen", listenAddr))
222 },
223 }
224 tcpServers = append(tcpServers, server)
225 go server.ActivateAndServe()
226 }
227 
228 var udpServers []*dns.Server
229 for _, addr := range udpAddrs {
230 pconn, err := listenCfg.ListenPacket(ctx, addr.Network, addr.Address)
231 if err != nil {
232 return fmt.Errorf("error setting up dns udp listener on %s: %w", addr.Address, err)
233 }
234 listenAddr := addr.Address
235 var packetConn net.PacketConn = pconn
236 if runtime.GOOS == "illumos" {
237 // needed to force net.PacketConn path instead of *net.UDPConn path
238 // because of dual stack not working on illumos
239 packetConn = &squashed{PacketConn: pconn}
240 }
241 server := &dns.Server{
242 PacketConn: packetConn,
243 Handler: dnsMux,
244 NotifyStartedFunc: func() {
245 logger.Info("ACME DNS started", zap.String("proto", "udp"), zap.String("listen", listenAddr))
246 },
247 }
248 udpServers = append(udpServers, server)
249 go server.ActivateAndServe()
250 }
251 
252 defer func() {
253 for _, srv := range tcpServers {
254 srv.Shutdown()
255 }
256 for _, srv := range udpServers {
257 srv.Shutdown()
258 }
259 }()
260 
261 sigs := make(chan os.Signal, 1)
262 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
263 
264 select {
265 case sig := <-sigs:
266 logger.Info("received signal to stop", zap.String("signal", sig.String()))
267 case <-ctx.Done():
268 logger.Info("context done", zap.Error(ctx.Err()))
269 }
270 
271 return nil
272}
273 
274type RemoteKV struct {
275 chord.KV
276 Client protocol.KVService
277}
278 
279func (r *RemoteKV) PrefixList(ctx context.Context, prefix []byte) (children [][]byte, err error) {
280 resp, err := r.Client.List(ctx, &protocol.PrefixRequest{
281 Prefix: prefix,
282 })
283 if err != nil {
284 return nil, err
285 }
286 return resp.GetChildren(), nil
287}
288 
289func Chaos(version string) dns.HandlerFunc {
290 return func(w dns.ResponseWriter, r *dns.Msg) {
291 m := new(dns.Msg)
292 m.SetReply(r)
293 
294 q := r.Question[0]
295 if q.Name == "version.bind." && q.Qtype == dns.TypeTXT && q.Qclass == dns.ClassCHAOS {
296 m.Answer = []dns.RR{
297 &dns.TXT{
298 Hdr: dns.RR_Header{
299 Name: q.Name,
300 Rrtype: dns.TypeTXT,
301 Class: dns.ClassCHAOS,
302 Ttl: 3600,
303 },
304 Txt: []string{version},
305 },
306 }
307 } else {
308 m.MsgHdr.Rcode = dns.RcodeRefused
309 }
310 
311 w.WriteMsg(m)
312 }
313}
314 
315type squashed struct {
316 net.PacketConn
317}