package main import ( "encoding/base64" "encoding/json" "fmt" "io/ioutil" "log" "net" "net/http" "net/url" "regexp" "strings" "code.crute.me/mcrute/go_ddns_manager/bind" "code.crute.me/mcrute/go_ddns_manager/dns" "github.com/gin-gonic/gin" ) const ( ACME_AUTH_KEY = "ACMEAuthContext" DDNS_AUTH_KEY = "DDNSAuthZone" ) var ( cfg *bind.BINDConfig secrets *Secrets ipRegexp = regexp.MustCompile(`(?:\[([0-9a-f:]+)\]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})):\d+`) ) func init() { var err error cfg, err = bind.ParseBINDConfig("zones.conf") if err != nil { panic(err) } fd, err := ioutil.ReadFile("secrets.json") if err != nil { panic(err) } secrets = &Secrets{} if err = json.Unmarshal(fd, secrets); err != nil { panic(err) } } type Secrets struct { DDNS map[string]string ACME map[string]map[string]int } type DDNSUpdateRequest struct { Key string `form:"key" binding:"required"` } type ACMEChallenge struct { Zone string `json:"zone" binding:"required"` Challenge string `json:"challenge" binding:"required"` } type ACMEChallengeID struct { Zone string Prefix string Challenge string } func joinDomainParts(parts ...string) string { p := []string{} for _, i := range parts { if strings.TrimSpace(i) != "" { p = append(p, i) } } return strings.Join(p, ".") } // Find the closest zone that we manage by striping dotted components off the // front of the domain until one matches. If there is a match return the zone // that matched and any prefix components, if any, as a dotted string. If none // match then return nil. func findClosestZone(cfg *bind.BINDConfig, zoneIn, view string) (*bind.Zone, string) { suffix := "" prefix := []string{} zc := strings.Split(zoneIn, ".") for i := 0; i <= len(zc)-2; i++ { prefix, suffix = zc[:i], strings.Join(zc[i:], ".") if zone := cfg.Zone(view, suffix); zone != nil { return zone, strings.Join(prefix, ".") } } return nil, "" } func makeURL(r *http.Request, path string, subs ...interface{}) *url.URL { scheme := "https" if r.TLS == nil { scheme = "http" } // Always defer to whatever the proxy told us it was doing because this // could be a mullet-VIP in either direction. if fwProto := r.Header.Get("X-Forwarded-Proto"); fwProto != "" { scheme = fwProto } return &url.URL{ Scheme: scheme, Host: r.Host, Path: fmt.Sprintf(path, subs...), } } func createAcmeChallenge(c *gin.Context) { dc := dns.DNSClient{Server: "172.16.18.52:53"} var ch ACMEChallenge if err := c.ShouldBindJSON(&ch); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return } zone, prefix := findClosestZone(cfg, ch.Zone, "external") if zone == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "Zone not found", }) return } if v, ok := c.Get(ACME_AUTH_KEY); !ok || v.(map[string]int)[zone.Name] != 1 { c.JSON(http.StatusForbidden, gin.H{ "error": "Zone update not allowed", }) return } // Do this first, in-case it fails (even though it should never fail) id, err := json.Marshal(ACMEChallengeID{ Zone: zone.Name, Prefix: prefix, Challenge: ch.Challenge, }) if err != nil { log.Printf("error: %s", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "error encoding ID", }) return } url := makeURL(c.Request, "/acme/%s", base64.URLEncoding.EncodeToString(id)) t := &dns.TXT{ Name: joinDomainParts("_acme-challenge", prefix), Ttl: 5, Txt: []string{ch.Challenge}, } log.Printf("%+v %+v '%s'", zone, t, t.Name) // Cleanup any old challenges before adding a new one if err := dc.RemoveAll(zone, t); err != nil { log.Printf("error RemoveAll: %s", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } if err := dc.Insert(zone, t); err != nil { log.Printf("error Insert: %s", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } c.Writer.Header()["Location"] = []string{url.String()} c.JSON(http.StatusCreated, gin.H{ "created": url.String(), }) } func deleteAcmeChallenge(c *gin.Context) { dc := dns.DNSClient{Server: "172.16.18.52:53"} rid, err := base64.URLEncoding.DecodeString(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "unable to decode ID", }) return } var id ACMEChallengeID if err = json.Unmarshal(rid, &id); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "unable to decode ID", }) return } zone := cfg.Zone("external", id.Zone) if zone == nil { c.JSON(http.StatusNotFound, gin.H{ "error": "Zone not found", }) return } if v, ok := c.Get(ACME_AUTH_KEY); !ok || v.(map[string]int)[zone.Name] != 1 { c.JSON(http.StatusForbidden, gin.H{ "error": "Zone update not allowed", }) return } t := &dns.TXT{ Name: joinDomainParts("_acme-challenge", id.Prefix), Ttl: 5, Txt: []string{id.Challenge}, } if err := dc.Remove(zone, t); err != nil { log.Printf("error Remove: %s", err) c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } c.JSON(http.StatusNoContent, gin.H{}) } func updateDynamicDNS(c *gin.Context) { dc := dns.DNSClient{Server: "172.16.18.52:53"} res, ok := c.GetString(DDNS_AUTH_KEY) if !ok { log.Println("ddns: Unable to get auth key") c.AbortWithStatus(http.StatusForbidden) return } zone, part := findClosestZone(cfg, res, "external") if zone == nil { log.Println("ddns: Unable to locate zone") c.AbortWithStatus(http.StatusNotFound) return } inip := net.ParseIP(strings.Split(c.Request.RemoteAddr, ":")[0]) xff := c.Request.Header.Get("X-Forwarded-For") if xff != "" { inip = net.ParseIP(xff) } if inip == nil { log.Println("ddns: Unable to parse IP") c.AbortWithStatus(http.StatusInternalServerError) return } t := &dns.A{ Name: part, Ttl: 60, A: inip, } // Cleanup any old records before adding the new one if err := dc.RemoveAll(zone, t); err != nil { log.Printf("ddns RemoveAll: %s", err) c.AbortWithStatus(http.StatusInternalServerError) return } if err := dc.Insert(zone, t); err != nil { log.Printf("ddns Insert: %s", err) c.AbortWithStatus(http.StatusInternalServerError) return } c.String(http.StatusAccepted, "") } func reflectIP(c *gin.Context) { myIp := c.Request.RemoteAddr xff := c.Request.Header.Get("X-Forwarded-For") if xff != "" { myIp = xff } ips := ipRegexp.FindStringSubmatch(myIp) if ips == nil { c.AbortWithStatus(http.StatusInternalServerError) return } v6, v4 := ips[1], ips[2] if v6 != "" { c.String(http.StatusOK, v6) } else if v4 != "" { c.String(http.StatusOK, v4) } else { c.AbortWithStatus(http.StatusInternalServerError) } } func acmeAuth(c *gin.Context) { _, pwd, ok := c.Request.BasicAuth() if !ok { c.Request.Header["WWW-Authenticate"] = []string{`Basic realm="closed site"`} c.AbortWithStatus(http.StatusUnauthorized) return } allowed, ok := secrets.ACME[pwd] if !ok { c.AbortWithStatus(http.StatusForbidden) return } else { c.Set(ACME_AUTH_KEY, allowed) } c.Next() } func ddnsAuth(c *gin.Context) { var req DDNSUpdateRequest if err := c.ShouldBind(&req); err != nil { log.Println("ddnsAuth: No key in request") c.AbortWithStatus(http.StatusNotFound) return } res, ok := secrets.DDNS[req.Key] if !ok { log.Println("ddnsAuth: Unknown secret") c.AbortWithStatus(http.StatusNotFound) return } else { c.Set(DDNS_AUTH_KEY, res) } c.Next() } func main() { router := gin.Default() router.GET("/reflect-ip", reflectIP) ddns := router.Group("/dynamic-dns") ddns.Use(ddnsAuth) { ddns.POST("", updateDynamicDNS) } acme := router.Group("/acme") acme.Use(acmeAuth) { acme.POST("", createAcmeChallenge) acme.DELETE("/:id", deleteAcmeChallenge) } router.Run() }