diff options
author | Mike Crute <mike@crute.us> | 2020-01-02 03:05:21 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2020-01-02 03:05:21 +0000 |
commit | 473ec1c19bb9d8cad259481f5cd2096a47dfb40f (patch) | |
tree | b319656f708795e34fdecce45cb6aed28e7b4972 /main.go | |
parent | b2e062c5de3fb233d34bf0c67c7e43cfd9969706 (diff) | |
download | go_ddns_manager-473ec1c19bb9d8cad259481f5cd2096a47dfb40f.tar.bz2 go_ddns_manager-473ec1c19bb9d8cad259481f5cd2096a47dfb40f.tar.xz go_ddns_manager-473ec1c19bb9d8cad259481f5cd2096a47dfb40f.zip |
Create updated service
Diffstat (limited to 'main.go')
-rw-r--r-- | main.go | 466 |
1 files changed, 302 insertions, 164 deletions
@@ -1,236 +1,374 @@ | |||
1 | package main | 1 | package main |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "bytes" | 4 | "encoding/base64" |
5 | "encoding/json" | 5 | "encoding/json" |
6 | "errors" | ||
7 | "fmt" | 6 | "fmt" |
8 | "github.com/miekg/dns" | ||
9 | "io/ioutil" | 7 | "io/ioutil" |
8 | "log" | ||
9 | "net" | ||
10 | "net/http" | 10 | "net/http" |
11 | "net/url" | ||
12 | "regexp" | ||
11 | "strings" | 13 | "strings" |
12 | "text/template" | 14 | |
13 | "time" | 15 | "code.crute.me/mcrute/go_ddns_manager/bind" |
16 | "code.crute.me/mcrute/go_ddns_manager/dns" | ||
17 | "github.com/gin-gonic/gin" | ||
18 | ) | ||
19 | |||
20 | const ( | ||
21 | ACME_AUTH_KEY = "ACMEAuthContext" | ||
22 | DDNS_AUTH_KEY = "DDNSAuthZone" | ||
14 | ) | 23 | ) |
15 | 24 | ||
16 | type Zone struct { | 25 | var ( |
17 | Name string | 26 | cfg *bind.BINDConfig |
18 | View string | 27 | secrets *Secrets |
28 | ipRegexp = regexp.MustCompile(`(?:\[([0-9a-f:]+)\]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})):\d+`) | ||
29 | ) | ||
30 | |||
31 | func init() { | ||
32 | var err error | ||
33 | cfg, err = bind.ParseBINDConfig("zones.conf") | ||
34 | if err != nil { | ||
35 | panic(err) | ||
36 | } | ||
37 | |||
38 | fd, err := ioutil.ReadFile("secrets.json") | ||
39 | if err != nil { | ||
40 | panic(err) | ||
41 | } | ||
42 | |||
43 | secrets = &Secrets{} | ||
44 | if err = json.Unmarshal(fd, secrets); err != nil { | ||
45 | panic(err) | ||
46 | } | ||
47 | } | ||
48 | |||
49 | type Secrets struct { | ||
50 | DDNS map[string]string | ||
51 | ACME map[string]map[string]int | ||
52 | } | ||
53 | |||
54 | type DDNSUpdateRequest struct { | ||
55 | Key string `form:"key" binding:"required"` | ||
19 | } | 56 | } |
20 | 57 | ||
21 | type TSIGSecrets struct { | 58 | type ACMEChallenge struct { |
22 | secrets map[string]map[string]TSIGSecret | 59 | Zone string `json:"zone" binding:"required"` |
23 | viewzones map[string][]string | 60 | Challenge string `json:"challenge" binding:"required"` |
24 | } | 61 | } |
25 | 62 | ||
26 | func NewTSIGSecrets() *TSIGSecrets { | 63 | type ACMEChallengeID struct { |
27 | return &TSIGSecrets{ | 64 | Zone string |
28 | secrets: make(map[string]map[string]TSIGSecret), | 65 | Prefix string |
29 | viewzones: make(map[string][]string), | 66 | Challenge string |
67 | } | ||
68 | |||
69 | func joinDomainParts(parts ...string) string { | ||
70 | p := []string{} | ||
71 | for _, i := range parts { | ||
72 | if strings.TrimSpace(i) != "" { | ||
73 | p = append(p, i) | ||
74 | } | ||
30 | } | 75 | } |
76 | return strings.Join(p, ".") | ||
31 | } | 77 | } |
32 | 78 | ||
33 | func (t *TSIGSecrets) GetViews() []string { | 79 | // Find the closest zone that we manage by striping dotted components off the |
34 | r := make([]string, 0, len(t.viewzones)) | 80 | // front of the domain until one matches. If there is a match return the zone |
35 | for k := range t.viewzones { | 81 | // that matched and any prefix components, if any, as a dotted string. If none |
36 | r = append(r, k) | 82 | // match then return nil. |
83 | func findClosestZone(cfg *bind.BINDConfig, zoneIn, view string) (*bind.Zone, string) { | ||
84 | suffix := "" | ||
85 | prefix := []string{} | ||
86 | |||
87 | zc := strings.Split(zoneIn, ".") | ||
88 | for i := 0; i <= len(zc)-2; i++ { | ||
89 | prefix, suffix = zc[:i], strings.Join(zc[i:], ".") | ||
90 | if zone := cfg.Zone(view, suffix); zone != nil { | ||
91 | return zone, strings.Join(prefix, ".") | ||
92 | } | ||
37 | } | 93 | } |
38 | return r | 94 | |
95 | return nil, "" | ||
39 | } | 96 | } |
40 | 97 | ||
41 | func (t *TSIGSecrets) GetViewZones() map[string][]string { | 98 | func makeURL(r *http.Request, path string, subs ...interface{}) *url.URL { |
42 | return t.viewzones | 99 | scheme := "https" |
100 | |||
101 | if r.TLS == nil { | ||
102 | scheme = "http" | ||
103 | } | ||
104 | |||
105 | // Always defer to whatever the proxy told us it was doing because this | ||
106 | // could be a mullet-VIP in either direction. | ||
107 | if fwProto := r.Header.Get("X-Forwarded-Proto"); fwProto != "" { | ||
108 | scheme = fwProto | ||
109 | } | ||
110 | |||
111 | return &url.URL{ | ||
112 | Scheme: scheme, | ||
113 | Host: r.Host, | ||
114 | Path: fmt.Sprintf(path, subs...), | ||
115 | } | ||
43 | } | 116 | } |
44 | 117 | ||
45 | func (t *TSIGSecrets) UnmarshalJSON(d []byte) error { | 118 | func createAcmeChallenge(c *gin.Context) { |
46 | v := make(map[string]map[string]string) | 119 | dc := dns.DNSClient{Server: "172.16.18.52:53"} |
47 | 120 | ||
48 | if err := json.Unmarshal(d, &v); err != nil { | 121 | var ch ACMEChallenge |
49 | return err | 122 | if err := c.ShouldBindJSON(&ch); err != nil { |
123 | c.JSON(http.StatusBadRequest, gin.H{ | ||
124 | "error": err.Error(), | ||
125 | }) | ||
126 | return | ||
50 | } | 127 | } |
51 | 128 | ||
52 | for k, v := range v { | 129 | zone, prefix := findClosestZone(cfg, ch.Zone, "external") |
53 | o := strings.Split(k, "-") | 130 | if zone == nil { |
54 | view := o[len(o)-1] | 131 | c.JSON(http.StatusNotFound, gin.H{ |
132 | "error": "Zone not found", | ||
133 | }) | ||
134 | return | ||
135 | } | ||
55 | 136 | ||
56 | d := make([]string, len(o)) | 137 | if v, ok := c.Get(ACME_AUTH_KEY); !ok || v.(map[string]int)[zone.Name] != 1 { |
57 | copy(d, o[:len(o)-1]) | 138 | c.JSON(http.StatusForbidden, gin.H{ |
58 | dn := strings.Join(d, ".") | 139 | "error": "Zone update not allowed", |
140 | }) | ||
141 | return | ||
142 | } | ||
59 | 143 | ||
60 | a, ok := v["algorithm"] | 144 | // Do this first, in-case it fails (even though it should never fail) |
61 | if !ok { | 145 | id, err := json.Marshal(ACMEChallengeID{ |
62 | a = "hmac-sha256." | 146 | Zone: zone.Name, |
63 | } | 147 | Prefix: prefix, |
148 | Challenge: ch.Challenge, | ||
149 | }) | ||
150 | if err != nil { | ||
151 | log.Printf("error: %s", err) | ||
152 | c.JSON(http.StatusInternalServerError, gin.H{ | ||
153 | "error": "error encoding ID", | ||
154 | }) | ||
155 | return | ||
156 | } | ||
64 | 157 | ||
65 | if !strings.HasSuffix(a, ".") { | 158 | url := makeURL(c.Request, "/acme/%s", base64.URLEncoding.EncodeToString(id)) |
66 | a = fmt.Sprintf("%s.", a) | ||
67 | } | ||
68 | 159 | ||
69 | if _, ok := t.viewzones[view]; !ok { | 160 | t := &dns.TXT{ |
70 | t.viewzones[view] = make([]string, 1) | 161 | Name: joinDomainParts("_acme-challenge", prefix), |
71 | t.viewzones[view] = append(t.viewzones[view], dn) | 162 | Ttl: 5, |
72 | } else { | 163 | Txt: []string{ch.Challenge}, |
73 | t.viewzones[view] = append(t.viewzones[view], dn) | 164 | } |
74 | } | ||
75 | 165 | ||
76 | if _, ok := t.secrets[view]; !ok { | 166 | log.Printf("%+v %+v '%s'", zone, t, t.Name) |
77 | t.secrets[view] = make(map[string]TSIGSecret) | ||
78 | } | ||
79 | 167 | ||
80 | if !strings.HasSuffix(k, ".") { | 168 | // Cleanup any old challenges before adding a new one |
81 | k = fmt.Sprintf("%s.", k) | 169 | if err := dc.RemoveAll(zone, t); err != nil { |
82 | } | 170 | log.Printf("error RemoveAll: %s", err) |
171 | c.JSON(http.StatusInternalServerError, gin.H{ | ||
172 | "error": err.Error(), | ||
173 | }) | ||
174 | return | ||
175 | } | ||
83 | 176 | ||
84 | t.secrets[view][dn] = TSIGSecret{ | 177 | if err := dc.Insert(zone, t); err != nil { |
85 | KeyName: k, | 178 | log.Printf("error Insert: %s", err) |
86 | Algorithm: a, | 179 | c.JSON(http.StatusInternalServerError, gin.H{ |
87 | Secret: v["secret"], | 180 | "error": err.Error(), |
88 | } | 181 | }) |
182 | return | ||
89 | } | 183 | } |
90 | 184 | ||
91 | return nil | 185 | c.Writer.Header()["Location"] = []string{url.String()} |
186 | c.JSON(http.StatusCreated, gin.H{ | ||
187 | "created": url.String(), | ||
188 | }) | ||
92 | } | 189 | } |
93 | 190 | ||
94 | func (t *TSIGSecrets) GetSecret(zone, view string) (*TSIGSecret, error) { | 191 | func deleteAcmeChallenge(c *gin.Context) { |
95 | if !strings.HasSuffix(zone, ".") { | 192 | dc := dns.DNSClient{Server: "172.16.18.52:53"} |
96 | zone = fmt.Sprintf("%s.", zone) | 193 | |
194 | rid, err := base64.URLEncoding.DecodeString(c.Param("id")) | ||
195 | if err != nil { | ||
196 | c.JSON(http.StatusBadRequest, gin.H{ | ||
197 | "error": "unable to decode ID", | ||
198 | }) | ||
199 | return | ||
97 | } | 200 | } |
98 | 201 | ||
99 | if _, ok := t.secrets[view]; !ok { | 202 | var id ACMEChallengeID |
100 | return nil, errors.New("No keys for requested zone") | 203 | if err = json.Unmarshal(rid, &id); err != nil { |
204 | c.JSON(http.StatusBadRequest, gin.H{ | ||
205 | "error": "unable to decode ID", | ||
206 | }) | ||
207 | return | ||
101 | } | 208 | } |
102 | 209 | ||
103 | key, ok := t.secrets[view][zone] | 210 | zone := cfg.Zone("external", id.Zone) |
104 | if !ok { | 211 | if zone == nil { |
105 | return nil, errors.New("No keys for requested view of zone") | 212 | c.JSON(http.StatusNotFound, gin.H{ |
213 | "error": "Zone not found", | ||
214 | }) | ||
215 | return | ||
106 | } | 216 | } |
107 | 217 | ||
108 | return &key, nil | 218 | if v, ok := c.Get(ACME_AUTH_KEY); !ok || v.(map[string]int)[zone.Name] != 1 { |
109 | } | 219 | c.JSON(http.StatusForbidden, gin.H{ |
220 | "error": "Zone update not allowed", | ||
221 | }) | ||
222 | return | ||
223 | } | ||
110 | 224 | ||
111 | type Signable interface { | 225 | t := &dns.TXT{ |
112 | SetTsig(string, string, uint16, int64) *dns.Msg | 226 | Name: joinDomainParts("_acme-challenge", id.Prefix), |
113 | } | 227 | Ttl: 5, |
228 | Txt: []string{id.Challenge}, | ||
229 | } | ||
114 | 230 | ||
115 | // TODO: Name and Algorithm end with dot (.) | 231 | if err := dc.Remove(zone, t); err != nil { |
116 | type TSIGSecret struct { | 232 | log.Printf("error Remove: %s", err) |
117 | KeyName string | 233 | c.JSON(http.StatusInternalServerError, gin.H{ |
118 | Algorithm string `json:"algorithm"` | 234 | "error": err.Error(), |
119 | Secret string `json:"secret"` | 235 | }) |
120 | } | 236 | return |
237 | } | ||
121 | 238 | ||
122 | func (t *TSIGSecret) Sign(r Signable) { | 239 | c.JSON(http.StatusNoContent, gin.H{}) |
123 | r.SetTsig(t.KeyName, t.Algorithm, 300, time.Now().Unix()) | ||
124 | } | 240 | } |
125 | 241 | ||
126 | func (t *TSIGSecret) AsMap() map[string]string { | 242 | func updateDynamicDNS(c *gin.Context) { |
127 | return map[string]string{ | 243 | dc := dns.DNSClient{Server: "172.16.18.52:53"} |
128 | t.KeyName: t.Secret, | 244 | |
245 | res, ok := c.GetString(DDNS_AUTH_KEY) | ||
246 | if !ok { | ||
247 | log.Println("ddns: Unable to get auth key") | ||
248 | c.AbortWithStatus(http.StatusForbidden) | ||
249 | return | ||
129 | } | 250 | } |
130 | } | ||
131 | 251 | ||
132 | func getValue(v interface{}) string { | 252 | zone, part := findClosestZone(cfg, res, "external") |
133 | switch i := v.(type) { | 253 | if zone == nil { |
134 | case *dns.SOA: | 254 | log.Println("ddns: Unable to locate zone") |
135 | return fmt.Sprintf("%s %s %d %d %d %d %d", i.Ns, i.Mbox, i.Serial, i.Refresh, i.Retry, i.Expire, i.Minttl) | 255 | c.AbortWithStatus(http.StatusNotFound) |
136 | case *dns.A: | 256 | return |
137 | return fmt.Sprintf("%s", i.A) | 257 | } |
138 | case *dns.CNAME: | 258 | |
139 | return fmt.Sprintf("%s", i.Target) | 259 | inip := net.ParseIP(strings.Split(c.Request.RemoteAddr, ":")[0]) |
140 | case *dns.AAAA: | 260 | xff := c.Request.Header.Get("X-Forwarded-For") |
141 | return fmt.Sprintf("%s", i.AAAA) | 261 | if xff != "" { |
142 | case *dns.MX: | 262 | inip = net.ParseIP(xff) |
143 | return fmt.Sprintf("%d %s", i.Preference, i.Mx) | ||
144 | case *dns.TXT: | ||
145 | b := &bytes.Buffer{} | ||
146 | for _, t := range i.Txt { | ||
147 | fmt.Fprintf(b, "\"%s\"", t) // [] | ||
148 | } | ||
149 | return b.String() | ||
150 | case *dns.PTR: | ||
151 | return fmt.Sprintf("%s", i.Ptr) | ||
152 | case *dns.NS: | ||
153 | return fmt.Sprintf("%s", i.Ns) | ||
154 | case *dns.SRV: | ||
155 | return fmt.Sprintf("%d %d %d %s", i.Priority, i.Weight, i.Port, i.Target) | ||
156 | case *dns.SPF: | ||
157 | b := &bytes.Buffer{} | ||
158 | for _, t := range i.Txt { | ||
159 | fmt.Fprintf(b, "\"%s\"", t) // [] | ||
160 | } | ||
161 | return b.String() | ||
162 | default: | ||
163 | return "UNKNOWN" | ||
164 | } | 263 | } |
165 | } | ||
166 | 264 | ||
167 | func getDns(sm *TSIGSecrets) chan *dns.Envelope { | 265 | if inip == nil { |
168 | s, _ := sm.GetSecret("crute.us", "external") | 266 | log.Println("ddns: Unable to parse IP") |
267 | c.AbortWithStatus(http.StatusInternalServerError) | ||
268 | return | ||
269 | } | ||
169 | 270 | ||
170 | c := &dns.Transfer{} | 271 | t := &dns.A{ |
171 | c.TsigSecret = s.AsMap() | 272 | Name: part, |
273 | Ttl: 60, | ||
274 | A: inip, | ||
275 | } | ||
172 | 276 | ||
173 | m := &dns.Msg{} | 277 | // Cleanup any old records before adding the new one |
174 | m.SetAxfr("crute.us.") | 278 | if err := dc.RemoveAll(zone, t); err != nil { |
175 | s.Sign(m) | 279 | log.Printf("ddns RemoveAll: %s", err) |
280 | c.AbortWithStatus(http.StatusInternalServerError) | ||
281 | return | ||
282 | } | ||
176 | 283 | ||
177 | in, err := c.In(m, "172.16.18.52:53") | 284 | if err := dc.Insert(zone, t); err != nil { |
178 | if err != nil { | 285 | log.Printf("ddns Insert: %s", err) |
179 | fmt.Printf("Error: %s\n", err.Error()) | 286 | c.AbortWithStatus(http.StatusInternalServerError) |
180 | return nil | 287 | return |
181 | } | 288 | } |
182 | 289 | ||
183 | return in | 290 | c.String(http.StatusAccepted, "") |
184 | } | 291 | } |
185 | 292 | ||
186 | func dnsClass(rrh *dns.RR_Header) string { | 293 | func reflectIP(c *gin.Context) { |
187 | return dns.Class(rrh.Class).String() | 294 | myIp := c.Request.RemoteAddr |
188 | } | 295 | xff := c.Request.Header.Get("X-Forwarded-For") |
296 | if xff != "" { | ||
297 | myIp = xff | ||
298 | } | ||
299 | |||
300 | ips := ipRegexp.FindStringSubmatch(myIp) | ||
301 | if ips == nil { | ||
302 | c.AbortWithStatus(http.StatusInternalServerError) | ||
303 | return | ||
304 | } | ||
189 | 305 | ||
190 | func dnsType(rrh *dns.RR_Header) string { | 306 | v6, v4 := ips[1], ips[2] |
191 | return dns.Type(rrh.Rrtype).String() | 307 | if v6 != "" { |
308 | c.String(http.StatusOK, v6) | ||
309 | } else if v4 != "" { | ||
310 | c.String(http.StatusOK, v4) | ||
311 | } else { | ||
312 | c.AbortWithStatus(http.StatusInternalServerError) | ||
313 | } | ||
192 | } | 314 | } |
193 | 315 | ||
194 | func dnsTTL(rrh *dns.RR_Header) string { | 316 | func acmeAuth(c *gin.Context) { |
195 | t := rrh.Ttl | 317 | _, pwd, ok := c.Request.BasicAuth() |
318 | if !ok { | ||
319 | c.Request.Header["WWW-Authenticate"] = []string{`Basic realm="closed site"`} | ||
320 | c.AbortWithStatus(http.StatusUnauthorized) | ||
321 | return | ||
322 | } | ||
196 | 323 | ||
197 | if t/86400 > 1 { | 324 | allowed, ok := secrets.ACME[pwd] |
198 | return fmt.Sprintf("%d days", t/86400) | 325 | if !ok { |
199 | } else if t/3600 > 1 { | 326 | c.AbortWithStatus(http.StatusForbidden) |
200 | return fmt.Sprintf("%d hours", t/3600) | 327 | return |
201 | } else if t/60 > 1 { | ||
202 | return fmt.Sprintf("%d minutes", t/60) | ||
203 | } else { | 328 | } else { |
204 | return fmt.Sprintf("%d seconds", t) | 329 | c.Set(ACME_AUTH_KEY, allowed) |
205 | } | 330 | } |
331 | |||
332 | c.Next() | ||
206 | } | 333 | } |
207 | 334 | ||
208 | func handler(w http.ResponseWriter, r *http.Request) { | 335 | func ddnsAuth(c *gin.Context) { |
209 | fm := template.FuncMap{ | 336 | var req DDNSUpdateRequest |
210 | "getValue": getValue, | 337 | if err := c.ShouldBind(&req); err != nil { |
211 | "dnsClass": dnsClass, | 338 | log.Println("ddnsAuth: No key in request") |
212 | "dnsType": dnsType, | 339 | c.AbortWithStatus(http.StatusNotFound) |
213 | "dnsTTL": dnsTTL, | 340 | return |
341 | } | ||
342 | |||
343 | res, ok := secrets.DDNS[req.Key] | ||
344 | if !ok { | ||
345 | log.Println("ddnsAuth: Unknown secret") | ||
346 | c.AbortWithStatus(http.StatusNotFound) | ||
347 | return | ||
348 | } else { | ||
349 | c.Set(DDNS_AUTH_KEY, res) | ||
214 | } | 350 | } |
215 | t, _ := template.New("").Funcs(fm).ParseFiles("dns.html") | 351 | |
216 | t.ExecuteTemplate(w, "dns.html", getDns(nil)) | 352 | c.Next() |
217 | } | 353 | } |
218 | 354 | ||
219 | func main() { | 355 | func main() { |
220 | /* | 356 | router := gin.Default() |
221 | http.HandleFunc("/", handler) | 357 | |
222 | http.ListenAndServe(":8080", nil) | 358 | router.GET("/reflect-ip", reflectIP) |
223 | */ | 359 | |
224 | 360 | ddns := router.Group("/dynamic-dns") | |
225 | c, _ := ioutil.ReadFile("secrets.json") | 361 | ddns.Use(ddnsAuth) |
226 | k := NewTSIGSecrets() | 362 | { |
227 | json.Unmarshal(c, k) | 363 | ddns.POST("", updateDynamicDNS) |
228 | |||
229 | //fmt.Printf("%+v\n", k) | ||
230 | for r := range getDns(k) { | ||
231 | for _, rr := range r.RR { | ||
232 | /* hlen := len(rr.Header().String()) | ||
233 | fmt.Printf("%+v\n", rr.String()[hlen:]) */ | ||
234 | } | ||
235 | } | 364 | } |
365 | |||
366 | acme := router.Group("/acme") | ||
367 | acme.Use(acmeAuth) | ||
368 | { | ||
369 | acme.POST("", createAcmeChallenge) | ||
370 | acme.DELETE("/:id", deleteAcmeChallenge) | ||
371 | } | ||
372 | |||
373 | router.Run() | ||
236 | } | 374 | } |