diff options
Diffstat (limited to 'main.go')
-rw-r--r-- | main.go | 429 |
1 files changed, 66 insertions, 363 deletions
@@ -1,395 +1,98 @@ | |||
1 | package main | 1 | package main |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "encoding/base64" | 4 | "fmt" |
5 | "encoding/json" | ||
6 | "io/ioutil" | ||
7 | "log" | 5 | "log" |
8 | "net" | 6 | "os" |
9 | "net/http" | 7 | "strconv" |
10 | "regexp" | ||
11 | "strings" | ||
12 | 8 | ||
13 | "code.crute.me/mcrute/go_ddns_manager/bind" | ||
14 | "code.crute.me/mcrute/go_ddns_manager/dns" | ||
15 | "github.com/gin-gonic/gin" | 9 | "github.com/gin-gonic/gin" |
16 | ) | 10 | "github.com/spf13/cobra" |
17 | |||
18 | const ( | ||
19 | ACME_AUTH_KEY = "ACMEAuthUserID" | ||
20 | DDNS_AUTH_KEY = "DDNSAuthZone" | ||
21 | CTX_SERVER_CONFIG = "ServerConfig" | ||
22 | ) | ||
23 | 11 | ||
24 | var ( | 12 | "code.crute.me/mcrute/go_ddns_manager/web" |
25 | ipRegexp = regexp.MustCompile(`(?:\[([0-9a-f:]+)\]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})):\d+`) | 13 | "code.crute.me/mcrute/go_ddns_manager/web/controllers" |
14 | "code.crute.me/mcrute/go_ddns_manager/web/middleware" | ||
26 | ) | 15 | ) |
27 | 16 | ||
28 | type ServerConfig struct { | 17 | // TODO: use from a common package |
29 | BindConfig *bind.BINDConfig | 18 | func MustGetString(c *cobra.Command, k string) string { |
30 | DNSClient *dns.DNSClient | 19 | f := c.Flags().Lookup(k) |
31 | ddnsSecrets map[string]string `json:"DDNS"` | 20 | if f == nil { |
32 | acmeSecrets map[string]map[string]int `json:"ACME"` | 21 | panic(fmt.Errorf("No flag named %s", k)) |
33 | } | ||
34 | |||
35 | func LoadServerConfig(zonesFile, secretsFile string) (*ServerConfig, error) { | ||
36 | scfg := &ServerConfig{ | ||
37 | // TODO: Remove | ||
38 | DNSClient: &dns.DNSClient{Server: "172.16.18.52:53"}, | ||
39 | } | ||
40 | |||
41 | cfg, err := bind.ParseBINDConfig(zonesFile) | ||
42 | if err != nil { | ||
43 | return nil, err | ||
44 | } | 22 | } |
45 | scfg.BindConfig = cfg | ||
46 | |||
47 | fd, err := ioutil.ReadFile(secretsFile) | ||
48 | if err != nil { | ||
49 | return nil, err | ||
50 | } | ||
51 | |||
52 | if err = json.Unmarshal(fd, scfg); err != nil { | ||
53 | return nil, err | ||
54 | } | ||
55 | |||
56 | return scfg, nil | ||
57 | } | ||
58 | 23 | ||
59 | func (s *ServerConfig) GetDDNZoneName(k string) string { | 24 | return f.Value.String() |
60 | v, _ := s.ddnsSecrets[k] | ||
61 | return v | ||
62 | } | 25 | } |
63 | 26 | ||
64 | func (s *ServerConfig) AcmeSecretExists(k string) bool { | 27 | // TODO: use from a common package |
65 | _, ok := s.acmeSecrets[k] | 28 | func MustGetBool(c *cobra.Command, k string) bool { |
66 | return ok | 29 | f := c.Flags().Lookup(k) |
67 | } | 30 | if f == nil { |
68 | 31 | panic(fmt.Errorf("No flag named %s", k)) | |
69 | func (s *ServerConfig) IsAcmeClientAllowed(key, zone string) bool { | ||
70 | u, ok := s.acmeSecrets[key] | ||
71 | if !ok { | ||
72 | return false | ||
73 | } | ||
74 | |||
75 | p, ok := u[zone] | ||
76 | if ok && p == 1 { | ||
77 | return true | ||
78 | } | 32 | } |
79 | 33 | ||
80 | p, ok = u[strings.TrimRight(zone, ".")] | 34 | t, err := strconv.ParseBool(f.Value.String()) |
81 | if ok && p == 1 { | ||
82 | return true | ||
83 | } | ||
84 | |||
85 | return false | ||
86 | } | ||
87 | |||
88 | type DDNSUpdateRequest struct { | ||
89 | Key string `form:"key" binding:"required"` | ||
90 | } | ||
91 | |||
92 | type AcmeChallenge struct { | ||
93 | Zone string `json:"zone" binding:"required"` | ||
94 | Challenge string `json:"challenge" binding:"required"` | ||
95 | } | ||
96 | |||
97 | type AcmeChallengeID struct { | ||
98 | Zone string | ||
99 | Prefix string | ||
100 | Challenge string | ||
101 | } | ||
102 | |||
103 | func joinDomainParts(parts ...string) string { | ||
104 | p := []string{} | ||
105 | for _, i := range parts { | ||
106 | if strings.TrimSpace(i) != "" { | ||
107 | p = append(p, i) | ||
108 | } | ||
109 | } | ||
110 | return strings.Join(p, ".") | ||
111 | } | ||
112 | |||
113 | func createAcmeChallenge(c *gin.Context) { | ||
114 | cfg := GetServerConfig(c) | ||
115 | |||
116 | var ch AcmeChallenge | ||
117 | if err := c.ShouldBindJSON(&ch); err != nil { | ||
118 | c.JSON(http.StatusBadRequest, gin.H{ | ||
119 | "error": err.Error(), | ||
120 | }) | ||
121 | return | ||
122 | } | ||
123 | |||
124 | zone, prefix := cfg.BindConfig.FindClosestZone(ch.Zone, "external") | ||
125 | if zone == nil { | ||
126 | c.JSON(http.StatusNotFound, gin.H{ | ||
127 | "error": "Zone not found", | ||
128 | }) | ||
129 | return | ||
130 | } | ||
131 | |||
132 | if v := c.GetString(ACME_AUTH_KEY); !cfg.IsAcmeClientAllowed(v, zone.Name) { | ||
133 | c.JSON(http.StatusForbidden, gin.H{ | ||
134 | "error": "Zone update not allowed", | ||
135 | }) | ||
136 | return | ||
137 | } | ||
138 | |||
139 | // Do this first, in-case it fails (even though it should never fail) | ||
140 | id, err := json.Marshal(AcmeChallengeID{ | ||
141 | Zone: zone.Name, | ||
142 | Prefix: prefix, | ||
143 | Challenge: ch.Challenge, | ||
144 | }) | ||
145 | if err != nil { | 35 | if err != nil { |
146 | log.Printf("error: %s", err) | 36 | panic(err) |
147 | c.JSON(http.StatusInternalServerError, gin.H{ | ||
148 | "error": "error encoding ID", | ||
149 | }) | ||
150 | return | ||
151 | } | ||
152 | |||
153 | url := makeURL(c.Request, "/acme/%s", base64.URLEncoding.EncodeToString(id)) | ||
154 | |||
155 | t := &dns.TXT{ | ||
156 | Name: joinDomainParts("_acme-challenge", prefix), | ||
157 | Ttl: 5, | ||
158 | Txt: []string{ch.Challenge}, | ||
159 | } | ||
160 | |||
161 | // Cleanup any old challenges before adding a new one | ||
162 | if err := cfg.DNSClient.RemoveAll(zone, t); err != nil { | ||
163 | log.Printf("error RemoveAll: %s", err) | ||
164 | c.JSON(http.StatusInternalServerError, gin.H{ | ||
165 | "error": err.Error(), | ||
166 | }) | ||
167 | return | ||
168 | } | ||
169 | |||
170 | if err := cfg.DNSClient.Insert(zone, t); err != nil { | ||
171 | log.Printf("error Insert: %s", err) | ||
172 | c.JSON(http.StatusInternalServerError, gin.H{ | ||
173 | "error": err.Error(), | ||
174 | }) | ||
175 | return | ||
176 | } | ||
177 | |||
178 | c.Writer.Header()["Location"] = []string{url.String()} | ||
179 | c.JSON(http.StatusCreated, gin.H{ | ||
180 | "created": url.String(), | ||
181 | }) | ||
182 | } | ||
183 | |||
184 | func deleteAcmeChallenge(c *gin.Context) { | ||
185 | cfg := GetServerConfig(c) | ||
186 | |||
187 | rid, err := base64.URLEncoding.DecodeString(c.Param("id")) | ||
188 | if err != nil { | ||
189 | c.JSON(http.StatusBadRequest, gin.H{ | ||
190 | "error": "unable to decode ID", | ||
191 | }) | ||
192 | return | ||
193 | } | ||
194 | |||
195 | var id AcmeChallengeID | ||
196 | if err = json.Unmarshal(rid, &id); err != nil { | ||
197 | c.JSON(http.StatusBadRequest, gin.H{ | ||
198 | "error": "unable to decode ID", | ||
199 | }) | ||
200 | return | ||
201 | } | ||
202 | |||
203 | zone := cfg.BindConfig.Zone("external", id.Zone) | ||
204 | if zone == nil { | ||
205 | c.JSON(http.StatusNotFound, gin.H{ | ||
206 | "error": "Zone not found", | ||
207 | }) | ||
208 | return | ||
209 | } | ||
210 | |||
211 | if v := c.GetString(ACME_AUTH_KEY); !cfg.IsAcmeClientAllowed(v, zone.Name) { | ||
212 | c.JSON(http.StatusForbidden, gin.H{ | ||
213 | "error": "Zone update not allowed", | ||
214 | }) | ||
215 | return | ||
216 | } | ||
217 | |||
218 | t := &dns.TXT{ | ||
219 | Name: joinDomainParts("_acme-challenge", id.Prefix), | ||
220 | Ttl: 5, | ||
221 | Txt: []string{id.Challenge}, | ||
222 | } | ||
223 | |||
224 | if err := cfg.DNSClient.Remove(zone, t); err != nil { | ||
225 | log.Printf("error Remove: %s", err) | ||
226 | c.JSON(http.StatusInternalServerError, gin.H{ | ||
227 | "error": err.Error(), | ||
228 | }) | ||
229 | return | ||
230 | } | ||
231 | |||
232 | c.JSON(http.StatusNoContent, gin.H{}) | ||
233 | } | ||
234 | |||
235 | func updateDynamicDNS(c *gin.Context) { | ||
236 | cfg := GetServerConfig(c) | ||
237 | |||
238 | res := c.GetString(DDNS_AUTH_KEY) | ||
239 | if res == "" { | ||
240 | log.Println("ddns: Unable to get auth key") | ||
241 | c.AbortWithStatus(http.StatusForbidden) | ||
242 | return | ||
243 | } | ||
244 | |||
245 | zone, part := cfg.BindConfig.FindClosestZone(res, "external") | ||
246 | if zone == nil { | ||
247 | log.Println("ddns: Unable to locate zone") | ||
248 | c.AbortWithStatus(http.StatusNotFound) | ||
249 | return | ||
250 | } | ||
251 | |||
252 | inip := net.ParseIP(strings.Split(c.Request.RemoteAddr, ":")[0]) | ||
253 | xff := c.Request.Header.Get("X-Forwarded-For") | ||
254 | if xff != "" { | ||
255 | inip = net.ParseIP(xff) | ||
256 | } | ||
257 | |||
258 | if inip == nil { | ||
259 | log.Println("ddns: Unable to parse IP") | ||
260 | c.AbortWithStatus(http.StatusInternalServerError) | ||
261 | return | ||
262 | } | ||
263 | |||
264 | t := &dns.A{ | ||
265 | Name: part, | ||
266 | Ttl: 60, | ||
267 | A: inip, | ||
268 | } | ||
269 | |||
270 | // Cleanup any old records before adding the new one | ||
271 | if err := cfg.DNSClient.RemoveAll(zone, t); err != nil { | ||
272 | log.Printf("ddns RemoveAll: %s", err) | ||
273 | c.AbortWithStatus(http.StatusInternalServerError) | ||
274 | return | ||
275 | } | ||
276 | |||
277 | if err := cfg.DNSClient.Insert(zone, t); err != nil { | ||
278 | log.Printf("ddns Insert: %s", err) | ||
279 | c.AbortWithStatus(http.StatusInternalServerError) | ||
280 | return | ||
281 | } | ||
282 | |||
283 | c.String(http.StatusAccepted, "") | ||
284 | } | ||
285 | |||
286 | func reflectIP(c *gin.Context) { | ||
287 | myIp := c.Request.RemoteAddr | ||
288 | xff := c.Request.Header.Get("X-Forwarded-For") | ||
289 | if xff != "" { | ||
290 | myIp = xff | ||
291 | } | ||
292 | |||
293 | ips := ipRegexp.FindStringSubmatch(myIp) | ||
294 | if ips == nil { | ||
295 | c.AbortWithStatus(http.StatusInternalServerError) | ||
296 | return | ||
297 | } | ||
298 | |||
299 | v6, v4 := ips[1], ips[2] | ||
300 | if v6 != "" { | ||
301 | c.String(http.StatusOK, v6) | ||
302 | } else if v4 != "" { | ||
303 | c.String(http.StatusOK, v4) | ||
304 | } else { | ||
305 | c.AbortWithStatus(http.StatusInternalServerError) | ||
306 | } | ||
307 | } | ||
308 | |||
309 | func acmeAuthMiddleware(c *gin.Context) { | ||
310 | cfg := GetServerConfig(c) | ||
311 | |||
312 | _, pwd, ok := c.Request.BasicAuth() | ||
313 | if !ok { | ||
314 | c.Request.Header.Set("WWW-Authenticate", `Basic realm="closed site"`) | ||
315 | c.AbortWithStatus(http.StatusUnauthorized) | ||
316 | return | ||
317 | } | ||
318 | |||
319 | if !cfg.AcmeSecretExists(pwd) { | ||
320 | c.AbortWithStatus(http.StatusForbidden) | ||
321 | return | ||
322 | } else { | ||
323 | c.Set(ACME_AUTH_KEY, pwd) | ||
324 | } | ||
325 | |||
326 | c.Next() | ||
327 | } | ||
328 | |||
329 | func ddnsAuthMiddleware(c *gin.Context) { | ||
330 | cfg := GetServerConfig(c) | ||
331 | |||
332 | var req DDNSUpdateRequest | ||
333 | if err := c.ShouldBind(&req); err != nil { | ||
334 | log.Println("ddnsAuthMiddleware: No key in request") | ||
335 | c.AbortWithStatus(http.StatusNotFound) | ||
336 | return | ||
337 | } | ||
338 | |||
339 | res := cfg.GetDDNZoneName(req.Key) | ||
340 | if res == "" { | ||
341 | log.Println("ddnsAuthMiddleware: Unknown secret") | ||
342 | c.AbortWithStatus(http.StatusNotFound) | ||
343 | return | ||
344 | } else { | ||
345 | c.Set(DDNS_AUTH_KEY, res) | ||
346 | } | ||
347 | |||
348 | c.Next() | ||
349 | } | ||
350 | |||
351 | func ConfigContextMiddleware(cfg *ServerConfig) func(*gin.Context) { | ||
352 | return func(c *gin.Context) { | ||
353 | c.Set(CTX_SERVER_CONFIG, cfg) | ||
354 | c.Next() | ||
355 | } | ||
356 | } | ||
357 | |||
358 | func GetServerConfig(c *gin.Context) *ServerConfig { | ||
359 | v, ok := c.Get(CTX_SERVER_CONFIG) | ||
360 | if !ok { | ||
361 | // This should never happen if the config context middlware is in place | ||
362 | panic("Unable to get config from request") | ||
363 | } | 37 | } |
364 | 38 | ||
365 | return v.(*ServerConfig) | 39 | return t |
366 | } | 40 | } |
367 | 41 | ||
368 | func main() { | 42 | func makeServer(cfg *web.ServerConfig) *gin.Engine { |
369 | gin.SetMode(gin.DebugMode) | ||
370 | |||
371 | cfg, err := LoadServerConfig("cfg/zones.conf", "cfg/secrets.json") | ||
372 | if err != nil { | ||
373 | panic(err) | ||
374 | } | ||
375 | |||
376 | router := gin.Default() | 43 | router := gin.Default() |
377 | router.Use(ConfigContextMiddleware(cfg)) | 44 | router.Use(middleware.ConfigContextMiddleware(cfg)) |
378 | 45 | ||
379 | router.GET("/reflect-ip", reflectIP) | 46 | router.GET("/reflect-ip", controllers.ReflectIP) |
380 | 47 | ||
381 | ddns := router.Group("/dynamic-dns") | 48 | ddns := router.Group("/dynamic-dns") |
382 | ddns.Use(ddnsAuthMiddleware) | 49 | ddns.Use(middleware.DDNSAuthMiddleware) |
383 | { | 50 | { |
384 | ddns.POST("", updateDynamicDNS) | 51 | ddns.POST("", controllers.UpdateDynamicDNS) |
385 | } | 52 | } |
386 | 53 | ||
387 | acme := router.Group("/acme") | 54 | acme := router.Group("/acme") |
388 | acme.Use(acmeAuthMiddleware) | 55 | acme.Use(middleware.AcmeAuthMiddleware) |
389 | { | 56 | { |
390 | acme.POST("", createAcmeChallenge) | 57 | acme.POST("", controllers.CreateAcmeChallenge) |
391 | acme.DELETE("/:id", deleteAcmeChallenge) | 58 | acme.DELETE("/:id", controllers.DeleteAcmeChallenge) |
392 | } | 59 | } |
393 | 60 | ||
394 | router.Run() | 61 | return router |
62 | } | ||
63 | |||
64 | func main() { | ||
65 | cmd := &cobra.Command{ | ||
66 | Use: "dns-manage-service [flags] dns-server-ip-port", | ||
67 | Short: "DNS manager service", | ||
68 | Args: cobra.MinimumNArgs(1), | ||
69 | Run: func(cmd *cobra.Command, args []string) { | ||
70 | cfg, err := web.LoadServerConfig( | ||
71 | MustGetString(cmd, "zones-config"), | ||
72 | MustGetString(cmd, "auth-config"), | ||
73 | MustGetString(cmd, "view-name"), | ||
74 | args[0], | ||
75 | ) | ||
76 | if err != nil { | ||
77 | log.Println(err) | ||
78 | os.Exit(1) | ||
79 | } | ||
80 | |||
81 | web.GinRun( | ||
82 | makeServer(cfg), | ||
83 | MustGetBool(cmd, "debug"), | ||
84 | MustGetString(cmd, "listen")) | ||
85 | }, | ||
86 | } | ||
87 | |||
88 | cmd.Flags().StringP("zones-config", "z", "cfg/zones.conf", "Bind key and zones config file") | ||
89 | cmd.Flags().StringP("auth-config", "a", "cfg/server_auth.json", "Server auth configuration file") | ||
90 | cmd.Flags().StringP("view-name", "n", "external", "Name of view to update for ACME and DDNS") | ||
91 | cmd.Flags().StringP("listen", "l", ":9090", "Listen address and port") | ||
92 | cmd.Flags().BoolP("debug", "d", false, "Run server in debug mode with debug logs") | ||
93 | |||
94 | if err := cmd.Execute(); err != nil { | ||
95 | log.Println(err) | ||
96 | os.Exit(1) | ||
97 | } | ||
395 | } | 98 | } |