From 1c24090da6b95ea304677d36f7cb6458034c08b9 Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sun, 30 Jan 2022 12:24:26 -0800 Subject: 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. --- dns/client.go | 70 +++++++++++++++++++++++++++++++++++- main.go | 17 ++++++++- web/controllers/acmev2.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 web/controllers/acmev2.go 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 @@ package dns import ( + "context" "fmt" + "time" "github.com/miekg/dns" @@ -9,7 +11,9 @@ import ( ) type DNSClient struct { - Server string + Server string + RecursiveResolvers []string + PollTimeout time.Duration } type DNSTransaction struct { @@ -142,3 +146,67 @@ func (c *DNSClient) SendQuery(t *DNSTransaction) ([]dns.RR, error) { return in.Answer, nil } + +// TODO: Copied from the letsencrypt service, merge this into existing functions +func (c *DNSClient) sendReadQuery(ctx context.Context, fqdn string, rtype uint16, nameserver string) (*dns.Msg, error) { + udp := &dns.Client{Net: "udp"} + tcp := &dns.Client{Net: "tcp"} + + m := &dns.Msg{} + m.SetQuestion(fqdn, rtype) + m.SetEdns0(4096, false) + m.RecursionDesired = true + + in, _, err := udp.ExchangeContext(ctx, m, nameserver) + if in != nil && in.Truncated { + // If the TCP request succeeds, the err will reset to nil + in, _, err = tcp.ExchangeContext(ctx, m, nameserver) + } + + if err != nil { + return nil, err + } + + return in, err +} + +func (c *DNSClient) WaitForDNSPropagation(ctx context.Context, fqdn, value string) error { + if c.RecursiveResolvers == nil { + return fmt.Errorf("DNSClient.WaitForDNSPropagation: RecursiveResolvers not set") + } + + pt := c.PollTimeout + if pt == 0 { + pt = 3 * time.Second + } + + timer := time.NewTicker(pt) + defer timer.Stop() + + for { + // Give the server the initial timout to satisfy the request + select { + case <-ctx.Done(): + return fmt.Errorf("DNSClient.WaitForDNSPropagation: context has expired, polling terminated") + case <-timer.C: + } + + ok_count := 0 + for _, rs := range c.RecursiveResolvers { + r, err := c.sendReadQuery(ctx, fqdn, dns.TypeTXT, rs) + if err != nil { + return err + } + + if len(r.Answer) > 0 { + if r.Answer[0].(*dns.TXT).Txt[0] == value { + ok_count++ + } + } + } + + if ok_count == len(c.RecursiveResolvers) { + return nil + } + } +} diff --git a/main.go b/main.go index 351633b..8b6ba10 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "encoding/json" "io/ioutil" + "time" "github.com/gin-gonic/gin" @@ -21,7 +22,14 @@ func loadConfig(app *frame.WebApp, args []string) (interface{}, error) { view := app.GetStringArgument("view_name") scfg := &web.ServerConfig{ - DNSClient: &dns.DNSClient{Server: server}, + DNSClient: &dns.DNSClient{ + Server: server, + RecursiveResolvers: []string{ + "google-public-dns-a.google.com:53", + "google-public-dns-b.google.com:53", + }, + PollTimeout: 3 * time.Second, + }, AcmeView: view, DynamicDnsView: view, } @@ -64,6 +72,13 @@ func prepareServer(c interface{}, router *gin.Engine) error { acme.DELETE("/:id", controllers.DeleteAcmeChallenge) } + acme2 := router.Group("/acmev2") + acme2.Use(middleware.AcmeAuthMiddleware) + { + acme2.POST("/:domain/:challenge", controllers.CreateAcmeChallengeV2) + acme2.DELETE("/:domain/:challenge", controllers.DeleteAcmeChallengeV2) + } + manage := router.Group("/manage") manage.Use(middleware.ApiAuthMiddleware) { 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 @@ +package controllers + +import ( + "fmt" + "log" + "net/http" + + "code.crute.me/mcrute/go_ddns_manager/dns" + "code.crute.me/mcrute/go_ddns_manager/web/middleware" + "github.com/gin-gonic/gin" +) + +func CreateAcmeChallengeV2(c *gin.Context) { + cfg := middleware.GetServerConfig(c) + + var ch AcmeChallenge + if err := c.ShouldBindJSON(&ch); err != nil { + jsonError(c, http.StatusBadRequest, err) + return + } + + zone, prefix := cfg.BindConfig.FindClosestZone(ch.Zone, cfg.AcmeView) + if zone == nil { + jsonError(c, http.StatusNotFound, "Zone not found") + return + } + + if !cfg.IsAcmeClientAllowed(middleware.GetAcmeAuthContext(c), zone.Name) { + jsonError(c, http.StatusForbidden, "Zone update not allowed") + return + } + + txn := cfg.DNSClient.StartUpdate(zone).Upsert(&dns.TXT{ + Name: joinDomainParts("_acme-challenge", prefix), + Ttl: 5, + Txt: []string{ch.Challenge}, + }) + + if err := cfg.DNSClient.SendUpdate(txn); err != nil { + log.Printf("error Insert: %s", err) + jsonError(c, http.StatusInternalServerError, err) + return + } + + if err := cfg.DNSClient.WaitForDNSPropagation( + c.Request.Context(), + fmt.Sprintf("_acme-challenge.%s.", prefix), + ch.Challenge, + ); err != nil { + jsonError(c, http.StatusInternalServerError, fmt.Errorf("Error polling for DNS propagation: %w", err)) + return + } + + c.JSON(http.StatusCreated, "") +} + +func DeleteAcmeChallengeV2(c *gin.Context) { + cfg := middleware.GetServerConfig(c) + + var ch AcmeChallenge + if err := c.ShouldBindJSON(&ch); err != nil { + jsonError(c, http.StatusBadRequest, err) + return + } + + zone, prefix := cfg.BindConfig.FindClosestZone(ch.Zone, cfg.AcmeView) + if zone == nil { + jsonError(c, http.StatusNotFound, "Zone not found") + return + } + + if !cfg.IsAcmeClientAllowed(middleware.GetAcmeAuthContext(c), zone.Name) { + jsonError(c, http.StatusForbidden, "Zone update not allowed") + return + } + + txn := cfg.DNSClient.StartUpdate(zone).Remove(&dns.TXT{ + Name: joinDomainParts("_acme-challenge", prefix), + Ttl: 5, + Txt: []string{ch.Challenge}, + }) + + if err := cfg.DNSClient.SendUpdate(txn); err != nil { + log.Printf("error Remove: %s", err) + jsonError(c, http.StatusInternalServerError, err) + return + } + + c.String(http.StatusNoContent, "") +} -- cgit v1.2.3