diff options
author | Mike Crute <mike@crute.us> | 2022-01-30 12:24:26 -0800 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2022-01-30 12:24:26 -0800 |
commit | 1c24090da6b95ea304677d36f7cb6458034c08b9 (patch) | |
tree | b375186f692a1e850f2f35ad5a3295e90ab7906c | |
parent | 74c33667f4c317bed29a014306130b9dd8990538 (diff) | |
download | go_ddns_manager-1c24090da6b95ea304677d36f7cb6458034c08b9.tar.bz2 go_ddns_manager-1c24090da6b95ea304677d36f7cb6458034c08b9.tar.xz go_ddns_manager-1c24090da6b95ea304677d36f7cb6458034c08b9.zip |
Add ACMEv2 endpoints
The ACMEv2 endpoints are easier to use for clients running the Golang
acme.autocert.Manager. They require no tracking of state and also handle
DNS propagation checking so the client can remain simple.
Once the legacy REST client for the v1 endpoints is gone those old
endpoints can be removed.
-rw-r--r-- | dns/client.go | 70 | ||||
-rw-r--r-- | main.go | 17 | ||||
-rw-r--r-- | web/controllers/acmev2.go | 90 |
3 files changed, 175 insertions, 2 deletions
diff --git a/dns/client.go b/dns/client.go index 525444a..f39bb4b 100644 --- a/dns/client.go +++ b/dns/client.go | |||
@@ -1,7 +1,9 @@ | |||
1 | package dns | 1 | package dns |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "context" | ||
4 | "fmt" | 5 | "fmt" |
6 | "time" | ||
5 | 7 | ||
6 | "github.com/miekg/dns" | 8 | "github.com/miekg/dns" |
7 | 9 | ||
@@ -9,7 +11,9 @@ import ( | |||
9 | ) | 11 | ) |
10 | 12 | ||
11 | type DNSClient struct { | 13 | type DNSClient struct { |
12 | Server string | 14 | Server string |
15 | RecursiveResolvers []string | ||
16 | PollTimeout time.Duration | ||
13 | } | 17 | } |
14 | 18 | ||
15 | type DNSTransaction struct { | 19 | type DNSTransaction struct { |
@@ -142,3 +146,67 @@ func (c *DNSClient) SendQuery(t *DNSTransaction) ([]dns.RR, error) { | |||
142 | 146 | ||
143 | return in.Answer, nil | 147 | return in.Answer, nil |
144 | } | 148 | } |
149 | |||
150 | // TODO: Copied from the letsencrypt service, merge this into existing functions | ||
151 | func (c *DNSClient) sendReadQuery(ctx context.Context, fqdn string, rtype uint16, nameserver string) (*dns.Msg, error) { | ||
152 | udp := &dns.Client{Net: "udp"} | ||
153 | tcp := &dns.Client{Net: "tcp"} | ||
154 | |||
155 | m := &dns.Msg{} | ||
156 | m.SetQuestion(fqdn, rtype) | ||
157 | m.SetEdns0(4096, false) | ||
158 | m.RecursionDesired = true | ||
159 | |||
160 | in, _, err := udp.ExchangeContext(ctx, m, nameserver) | ||
161 | if in != nil && in.Truncated { | ||
162 | // If the TCP request succeeds, the err will reset to nil | ||
163 | in, _, err = tcp.ExchangeContext(ctx, m, nameserver) | ||
164 | } | ||
165 | |||
166 | if err != nil { | ||
167 | return nil, err | ||
168 | } | ||
169 | |||
170 | return in, err | ||
171 | } | ||
172 | |||
173 | func (c *DNSClient) WaitForDNSPropagation(ctx context.Context, fqdn, value string) error { | ||
174 | if c.RecursiveResolvers == nil { | ||
175 | return fmt.Errorf("DNSClient.WaitForDNSPropagation: RecursiveResolvers not set") | ||
176 | } | ||
177 | |||
178 | pt := c.PollTimeout | ||
179 | if pt == 0 { | ||
180 | pt = 3 * time.Second | ||
181 | } | ||
182 | |||
183 | timer := time.NewTicker(pt) | ||
184 | defer timer.Stop() | ||
185 | |||
186 | for { | ||
187 | // Give the server the initial timout to satisfy the request | ||
188 | select { | ||
189 | case <-ctx.Done(): | ||
190 | return fmt.Errorf("DNSClient.WaitForDNSPropagation: context has expired, polling terminated") | ||
191 | case <-timer.C: | ||
192 | } | ||
193 | |||
194 | ok_count := 0 | ||
195 | for _, rs := range c.RecursiveResolvers { | ||
196 | r, err := c.sendReadQuery(ctx, fqdn, dns.TypeTXT, rs) | ||
197 | if err != nil { | ||
198 | return err | ||
199 | } | ||
200 | |||
201 | if len(r.Answer) > 0 { | ||
202 | if r.Answer[0].(*dns.TXT).Txt[0] == value { | ||
203 | ok_count++ | ||
204 | } | ||
205 | } | ||
206 | } | ||
207 | |||
208 | if ok_count == len(c.RecursiveResolvers) { | ||
209 | return nil | ||
210 | } | ||
211 | } | ||
212 | } | ||
@@ -3,6 +3,7 @@ package main | |||
3 | import ( | 3 | import ( |
4 | "encoding/json" | 4 | "encoding/json" |
5 | "io/ioutil" | 5 | "io/ioutil" |
6 | "time" | ||
6 | 7 | ||
7 | "github.com/gin-gonic/gin" | 8 | "github.com/gin-gonic/gin" |
8 | 9 | ||
@@ -21,7 +22,14 @@ func loadConfig(app *frame.WebApp, args []string) (interface{}, error) { | |||
21 | view := app.GetStringArgument("view_name") | 22 | view := app.GetStringArgument("view_name") |
22 | 23 | ||
23 | scfg := &web.ServerConfig{ | 24 | scfg := &web.ServerConfig{ |
24 | DNSClient: &dns.DNSClient{Server: server}, | 25 | DNSClient: &dns.DNSClient{ |
26 | Server: server, | ||
27 | RecursiveResolvers: []string{ | ||
28 | "google-public-dns-a.google.com:53", | ||
29 | "google-public-dns-b.google.com:53", | ||
30 | }, | ||
31 | PollTimeout: 3 * time.Second, | ||
32 | }, | ||
25 | AcmeView: view, | 33 | AcmeView: view, |
26 | DynamicDnsView: view, | 34 | DynamicDnsView: view, |
27 | } | 35 | } |
@@ -64,6 +72,13 @@ func prepareServer(c interface{}, router *gin.Engine) error { | |||
64 | acme.DELETE("/:id", controllers.DeleteAcmeChallenge) | 72 | acme.DELETE("/:id", controllers.DeleteAcmeChallenge) |
65 | } | 73 | } |
66 | 74 | ||
75 | acme2 := router.Group("/acmev2") | ||
76 | acme2.Use(middleware.AcmeAuthMiddleware) | ||
77 | { | ||
78 | acme2.POST("/:domain/:challenge", controllers.CreateAcmeChallengeV2) | ||
79 | acme2.DELETE("/:domain/:challenge", controllers.DeleteAcmeChallengeV2) | ||
80 | } | ||
81 | |||
67 | manage := router.Group("/manage") | 82 | manage := router.Group("/manage") |
68 | manage.Use(middleware.ApiAuthMiddleware) | 83 | manage.Use(middleware.ApiAuthMiddleware) |
69 | { | 84 | { |
diff --git a/web/controllers/acmev2.go b/web/controllers/acmev2.go new file mode 100644 index 0000000..a2fadf5 --- /dev/null +++ b/web/controllers/acmev2.go | |||
@@ -0,0 +1,90 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "fmt" | ||
5 | "log" | ||
6 | "net/http" | ||
7 | |||
8 | "code.crute.me/mcrute/go_ddns_manager/dns" | ||
9 | "code.crute.me/mcrute/go_ddns_manager/web/middleware" | ||
10 | "github.com/gin-gonic/gin" | ||
11 | ) | ||
12 | |||
13 | func CreateAcmeChallengeV2(c *gin.Context) { | ||
14 | cfg := middleware.GetServerConfig(c) | ||
15 | |||
16 | var ch AcmeChallenge | ||
17 | if err := c.ShouldBindJSON(&ch); err != nil { | ||
18 | jsonError(c, http.StatusBadRequest, err) | ||
19 | return | ||
20 | } | ||
21 | |||
22 | zone, prefix := cfg.BindConfig.FindClosestZone(ch.Zone, cfg.AcmeView) | ||
23 | if zone == nil { | ||
24 | jsonError(c, http.StatusNotFound, "Zone not found") | ||
25 | return | ||
26 | } | ||
27 | |||
28 | if !cfg.IsAcmeClientAllowed(middleware.GetAcmeAuthContext(c), zone.Name) { | ||
29 | jsonError(c, http.StatusForbidden, "Zone update not allowed") | ||
30 | return | ||
31 | } | ||
32 | |||
33 | txn := cfg.DNSClient.StartUpdate(zone).Upsert(&dns.TXT{ | ||
34 | Name: joinDomainParts("_acme-challenge", prefix), | ||
35 | Ttl: 5, | ||
36 | Txt: []string{ch.Challenge}, | ||
37 | }) | ||
38 | |||
39 | if err := cfg.DNSClient.SendUpdate(txn); err != nil { | ||
40 | log.Printf("error Insert: %s", err) | ||
41 | jsonError(c, http.StatusInternalServerError, err) | ||
42 | return | ||
43 | } | ||
44 | |||
45 | if err := cfg.DNSClient.WaitForDNSPropagation( | ||
46 | c.Request.Context(), | ||
47 | fmt.Sprintf("_acme-challenge.%s.", prefix), | ||
48 | ch.Challenge, | ||
49 | ); err != nil { | ||
50 | jsonError(c, http.StatusInternalServerError, fmt.Errorf("Error polling for DNS propagation: %w", err)) | ||
51 | return | ||
52 | } | ||
53 | |||
54 | c.JSON(http.StatusCreated, "") | ||
55 | } | ||
56 | |||
57 | func DeleteAcmeChallengeV2(c *gin.Context) { | ||
58 | cfg := middleware.GetServerConfig(c) | ||
59 | |||
60 | var ch AcmeChallenge | ||
61 | if err := c.ShouldBindJSON(&ch); err != nil { | ||
62 | jsonError(c, http.StatusBadRequest, err) | ||
63 | return | ||
64 | } | ||
65 | |||
66 | zone, prefix := cfg.BindConfig.FindClosestZone(ch.Zone, cfg.AcmeView) | ||
67 | if zone == nil { | ||
68 | jsonError(c, http.StatusNotFound, "Zone not found") | ||
69 | return | ||
70 | } | ||
71 | |||
72 | if !cfg.IsAcmeClientAllowed(middleware.GetAcmeAuthContext(c), zone.Name) { | ||
73 | jsonError(c, http.StatusForbidden, "Zone update not allowed") | ||
74 | return | ||
75 | } | ||
76 | |||
77 | txn := cfg.DNSClient.StartUpdate(zone).Remove(&dns.TXT{ | ||
78 | Name: joinDomainParts("_acme-challenge", prefix), | ||
79 | Ttl: 5, | ||
80 | Txt: []string{ch.Challenge}, | ||
81 | }) | ||
82 | |||
83 | if err := cfg.DNSClient.SendUpdate(txn); err != nil { | ||
84 | log.Printf("error Remove: %s", err) | ||
85 | jsonError(c, http.StatusInternalServerError, err) | ||
86 | return | ||
87 | } | ||
88 | |||
89 | c.String(http.StatusNoContent, "") | ||
90 | } | ||