diff options
author | Mike Crute <mike@crute.us> | 2020-01-04 03:59:53 +0000 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2020-01-04 03:59:53 +0000 |
commit | 8ed9671c0b4f78711858448cf3b4ee9af0eba51e (patch) | |
tree | 2b6e9b9f1958101de8501a36fc60124b86a35c6f /web | |
parent | b4214b63d7c73cb0fec55b4e678a98327a159d48 (diff) | |
download | go_ddns_manager-8ed9671c0b4f78711858448cf3b4ee9af0eba51e.tar.bz2 go_ddns_manager-8ed9671c0b4f78711858448cf3b4ee9af0eba51e.tar.xz go_ddns_manager-8ed9671c0b4f78711858448cf3b4ee9af0eba51e.zip |
Refactor into an application
Diffstat (limited to 'web')
-rw-r--r-- | web/config.go | 75 | ||||
-rw-r--r-- | web/controllers/acme.go | 149 | ||||
-rw-r--r-- | web/controllers/ddns.go | 51 | ||||
-rw-r--r-- | web/controllers/reflect_ip.go | 19 | ||||
-rw-r--r-- | web/middleware/acme.go | 33 | ||||
-rw-r--r-- | web/middleware/config_context.go | 26 | ||||
-rw-r--r-- | web/middleware/ddns.go | 40 | ||||
-rw-r--r-- | web/utils.go | 117 |
8 files changed, 510 insertions, 0 deletions
diff --git a/web/config.go b/web/config.go new file mode 100644 index 0000000..01b6f47 --- /dev/null +++ b/web/config.go | |||
@@ -0,0 +1,75 @@ | |||
1 | package web | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "io/ioutil" | ||
6 | "log" | ||
7 | "strings" | ||
8 | |||
9 | "code.crute.me/mcrute/go_ddns_manager/bind" | ||
10 | "code.crute.me/mcrute/go_ddns_manager/dns" | ||
11 | ) | ||
12 | |||
13 | type ServerConfig struct { | ||
14 | BindConfig *bind.BINDConfig | ||
15 | DNSClient *dns.DNSClient | ||
16 | AcmeView string | ||
17 | DynamicDnsView string | ||
18 | DDNSSecrets map[string]string `json:"DDNS"` | ||
19 | AcmeSecrets map[string]map[string]int `json:"ACME"` | ||
20 | } | ||
21 | |||
22 | func LoadServerConfig(zonesFile, secretsFile, server, view string) (*ServerConfig, error) { | ||
23 | scfg := &ServerConfig{ | ||
24 | DNSClient: &dns.DNSClient{Server: server}, | ||
25 | AcmeView: view, | ||
26 | DynamicDnsView: view, | ||
27 | } | ||
28 | |||
29 | cfg, err := bind.ParseBINDConfig(zonesFile) | ||
30 | if err != nil { | ||
31 | return nil, err | ||
32 | } | ||
33 | scfg.BindConfig = cfg | ||
34 | |||
35 | fd, err := ioutil.ReadFile(secretsFile) | ||
36 | if err != nil { | ||
37 | return nil, err | ||
38 | } | ||
39 | |||
40 | if err = json.Unmarshal(fd, scfg); err != nil { | ||
41 | return nil, err | ||
42 | } | ||
43 | |||
44 | return scfg, nil | ||
45 | } | ||
46 | |||
47 | func (s *ServerConfig) GetDDNSZoneName(k string) string { | ||
48 | log.Printf("%#v", s.DDNSSecrets) | ||
49 | v, _ := s.DDNSSecrets[k] | ||
50 | return v | ||
51 | } | ||
52 | |||
53 | func (s *ServerConfig) AcmeSecretExists(k string) bool { | ||
54 | _, ok := s.AcmeSecrets[k] | ||
55 | return ok | ||
56 | } | ||
57 | |||
58 | func (s *ServerConfig) IsAcmeClientAllowed(key, zone string) bool { | ||
59 | u, ok := s.AcmeSecrets[key] | ||
60 | if !ok { | ||
61 | return false | ||
62 | } | ||
63 | |||
64 | p, ok := u[zone] | ||
65 | if ok && p == 1 { | ||
66 | return true | ||
67 | } | ||
68 | |||
69 | p, ok = u[strings.TrimRight(zone, ".")] | ||
70 | if ok && p == 1 { | ||
71 | return true | ||
72 | } | ||
73 | |||
74 | return false | ||
75 | } | ||
diff --git a/web/controllers/acme.go b/web/controllers/acme.go new file mode 100644 index 0000000..f40b2ec --- /dev/null +++ b/web/controllers/acme.go | |||
@@ -0,0 +1,149 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "encoding/base64" | ||
5 | "encoding/json" | ||
6 | "log" | ||
7 | "net/http" | ||
8 | "strings" | ||
9 | |||
10 | "github.com/gin-gonic/gin" | ||
11 | |||
12 | "code.crute.me/mcrute/go_ddns_manager/dns" | ||
13 | "code.crute.me/mcrute/go_ddns_manager/web" | ||
14 | "code.crute.me/mcrute/go_ddns_manager/web/middleware" | ||
15 | ) | ||
16 | |||
17 | type AcmeChallenge struct { | ||
18 | Zone string `json:"zone" binding:"required"` | ||
19 | Challenge string `json:"challenge" binding:"required"` | ||
20 | } | ||
21 | |||
22 | type AcmeChallengeID struct { | ||
23 | Zone string | ||
24 | Prefix string | ||
25 | Challenge string | ||
26 | } | ||
27 | |||
28 | func DecodeAcmeChallengeID(v string) *AcmeChallengeID { | ||
29 | rid, err := base64.URLEncoding.DecodeString(v) | ||
30 | if err != nil { | ||
31 | return nil | ||
32 | } | ||
33 | |||
34 | var id AcmeChallengeID | ||
35 | if err = json.Unmarshal(rid, &id); err != nil { | ||
36 | return nil | ||
37 | } | ||
38 | |||
39 | return &id | ||
40 | } | ||
41 | |||
42 | func (i AcmeChallengeID) Encode() string { | ||
43 | id, err := json.Marshal(i) | ||
44 | if err != nil { | ||
45 | return "" | ||
46 | } | ||
47 | |||
48 | return base64.URLEncoding.EncodeToString(id) | ||
49 | } | ||
50 | |||
51 | func joinDomainParts(parts ...string) string { | ||
52 | p := []string{} | ||
53 | for _, i := range parts { | ||
54 | if strings.TrimSpace(i) != "" { | ||
55 | p = append(p, i) | ||
56 | } | ||
57 | } | ||
58 | return strings.Join(p, ".") | ||
59 | } | ||
60 | |||
61 | func jsonError(c *gin.Context, status int, msg interface{}) { | ||
62 | var o string | ||
63 | |||
64 | switch msg := msg.(type) { | ||
65 | case string: | ||
66 | o = msg | ||
67 | case error: | ||
68 | o = msg.Error() | ||
69 | } | ||
70 | |||
71 | c.JSON(status, gin.H{"error": o}) | ||
72 | } | ||
73 | |||
74 | func CreateAcmeChallenge(c *gin.Context) { | ||
75 | cfg := middleware.GetServerConfig(c) | ||
76 | |||
77 | var ch AcmeChallenge | ||
78 | if err := c.ShouldBindJSON(&ch); err != nil { | ||
79 | jsonError(c, http.StatusBadRequest, err) | ||
80 | return | ||
81 | } | ||
82 | |||
83 | zone, prefix := cfg.BindConfig.FindClosestZone(ch.Zone, cfg.AcmeView) | ||
84 | if zone == nil { | ||
85 | jsonError(c, http.StatusNotFound, "Zone not found") | ||
86 | return | ||
87 | } | ||
88 | |||
89 | if !cfg.IsAcmeClientAllowed(middleware.GetAcmeAuthContext(c), zone.Name) { | ||
90 | jsonError(c, http.StatusForbidden, "Zone update not allowed") | ||
91 | return | ||
92 | } | ||
93 | |||
94 | txn := cfg.DNSClient.StartUpdate(zone).Upsert(&dns.TXT{ | ||
95 | Name: joinDomainParts("_acme-challenge", prefix), | ||
96 | Ttl: 5, | ||
97 | Txt: []string{ch.Challenge}, | ||
98 | }) | ||
99 | |||
100 | if err := cfg.DNSClient.SendUpdate(txn); err != nil { | ||
101 | log.Printf("error Insert: %s", err) | ||
102 | jsonError(c, http.StatusInternalServerError, err) | ||
103 | return | ||
104 | } | ||
105 | |||
106 | url := web.MakeURL(c.Request, "/acme/%s", AcmeChallengeID{ | ||
107 | Zone: zone.Name, | ||
108 | Prefix: prefix, | ||
109 | Challenge: ch.Challenge, | ||
110 | }.Encode()).String() | ||
111 | |||
112 | c.Writer.Header().Set("Location", url) | ||
113 | c.JSON(http.StatusCreated, gin.H{"created": url}) | ||
114 | } | ||
115 | |||
116 | func DeleteAcmeChallenge(c *gin.Context) { | ||
117 | cfg := middleware.GetServerConfig(c) | ||
118 | |||
119 | id := DecodeAcmeChallengeID(c.Param("id")) | ||
120 | if id == nil { | ||
121 | jsonError(c, http.StatusBadRequest, "unable to decode ID") | ||
122 | return | ||
123 | } | ||
124 | |||
125 | zone := cfg.BindConfig.Zone(cfg.AcmeView, id.Zone) | ||
126 | if zone == nil { | ||
127 | jsonError(c, http.StatusNotFound, "Zone not found") | ||
128 | return | ||
129 | } | ||
130 | |||
131 | if !cfg.IsAcmeClientAllowed(middleware.GetAcmeAuthContext(c), zone.Name) { | ||
132 | jsonError(c, http.StatusForbidden, "Zone update not allowed") | ||
133 | return | ||
134 | } | ||
135 | |||
136 | txn := cfg.DNSClient.StartUpdate(zone).Remove(&dns.TXT{ | ||
137 | Name: joinDomainParts("_acme-challenge", id.Prefix), | ||
138 | Ttl: 5, | ||
139 | Txt: []string{id.Challenge}, | ||
140 | }) | ||
141 | |||
142 | if err := cfg.DNSClient.SendUpdate(txn); err != nil { | ||
143 | log.Printf("error Remove: %s", err) | ||
144 | jsonError(c, http.StatusInternalServerError, err) | ||
145 | return | ||
146 | } | ||
147 | |||
148 | c.String(http.StatusNoContent, "") | ||
149 | } | ||
diff --git a/web/controllers/ddns.go b/web/controllers/ddns.go new file mode 100644 index 0000000..7300989 --- /dev/null +++ b/web/controllers/ddns.go | |||
@@ -0,0 +1,51 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "log" | ||
5 | "net/http" | ||
6 | |||
7 | "github.com/gin-gonic/gin" | ||
8 | |||
9 | "code.crute.me/mcrute/go_ddns_manager/dns" | ||
10 | "code.crute.me/mcrute/go_ddns_manager/web" | ||
11 | "code.crute.me/mcrute/go_ddns_manager/web/middleware" | ||
12 | ) | ||
13 | |||
14 | func UpdateDynamicDNS(c *gin.Context) { | ||
15 | cfg := middleware.GetServerConfig(c) | ||
16 | |||
17 | res := middleware.GetDDNSAuthKey(c) | ||
18 | if res == "" { | ||
19 | log.Println("ddns: Unable to get auth key") | ||
20 | c.AbortWithStatus(http.StatusForbidden) | ||
21 | return | ||
22 | } | ||
23 | |||
24 | zone, part := cfg.BindConfig.FindClosestZone(res, cfg.DynamicDnsView) | ||
25 | if zone == nil { | ||
26 | log.Println("ddns: Unable to locate zone") | ||
27 | c.AbortWithStatus(http.StatusNotFound) | ||
28 | return | ||
29 | } | ||
30 | |||
31 | inip := web.GetRequestIP(c) | ||
32 | if inip == nil { | ||
33 | log.Println("ddns: Unable to parse IP") | ||
34 | c.AbortWithStatus(http.StatusInternalServerError) | ||
35 | return | ||
36 | } | ||
37 | |||
38 | txn := cfg.DNSClient.StartUpdate(zone).Upsert(&dns.A{ | ||
39 | Name: part, | ||
40 | Ttl: 60, | ||
41 | A: inip, | ||
42 | }) | ||
43 | |||
44 | if err := cfg.DNSClient.SendUpdate(txn); err != nil { | ||
45 | log.Printf("ddns: %s", err) | ||
46 | c.AbortWithStatus(http.StatusInternalServerError) | ||
47 | return | ||
48 | } | ||
49 | |||
50 | c.String(http.StatusAccepted, "") | ||
51 | } | ||
diff --git a/web/controllers/reflect_ip.go b/web/controllers/reflect_ip.go new file mode 100644 index 0000000..d04b98c --- /dev/null +++ b/web/controllers/reflect_ip.go | |||
@@ -0,0 +1,19 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
4 | "net/http" | ||
5 | |||
6 | "github.com/gin-gonic/gin" | ||
7 | |||
8 | "code.crute.me/mcrute/go_ddns_manager/web" | ||
9 | ) | ||
10 | |||
11 | func ReflectIP(c *gin.Context) { | ||
12 | ip := web.GetRequestIP(c) | ||
13 | if ip == nil { | ||
14 | c.String(http.StatusInternalServerError, "") | ||
15 | return | ||
16 | } | ||
17 | |||
18 | c.String(http.StatusOK, ip.String()) | ||
19 | } | ||
diff --git a/web/middleware/acme.go b/web/middleware/acme.go new file mode 100644 index 0000000..54a4dfc --- /dev/null +++ b/web/middleware/acme.go | |||
@@ -0,0 +1,33 @@ | |||
1 | package middleware | ||
2 | |||
3 | import ( | ||
4 | "net/http" | ||
5 | |||
6 | "github.com/gin-gonic/gin" | ||
7 | ) | ||
8 | |||
9 | const acmeUserId = "ACMEAuthUserID" | ||
10 | |||
11 | func AcmeAuthMiddleware(c *gin.Context) { | ||
12 | cfg := GetServerConfig(c) | ||
13 | |||
14 | _, pwd, ok := c.Request.BasicAuth() | ||
15 | if !ok { | ||
16 | c.Request.Header.Set("WWW-Authenticate", `Basic realm="closed site"`) | ||
17 | c.AbortWithStatus(http.StatusUnauthorized) | ||
18 | return | ||
19 | } | ||
20 | |||
21 | if !cfg.AcmeSecretExists(pwd) { | ||
22 | c.AbortWithStatus(http.StatusForbidden) | ||
23 | return | ||
24 | } else { | ||
25 | c.Set(acmeUserId, pwd) | ||
26 | } | ||
27 | |||
28 | c.Next() | ||
29 | } | ||
30 | |||
31 | func GetAcmeAuthContext(c *gin.Context) string { | ||
32 | return c.GetString(acmeUserId) | ||
33 | } | ||
diff --git a/web/middleware/config_context.go b/web/middleware/config_context.go new file mode 100644 index 0000000..d474022 --- /dev/null +++ b/web/middleware/config_context.go | |||
@@ -0,0 +1,26 @@ | |||
1 | package middleware | ||
2 | |||
3 | import ( | ||
4 | "github.com/gin-gonic/gin" | ||
5 | |||
6 | "code.crute.me/mcrute/go_ddns_manager/web" | ||
7 | ) | ||
8 | |||
9 | const serverConfig = "ServerConfig" | ||
10 | |||
11 | func ConfigContextMiddleware(cfg *web.ServerConfig) func(*gin.Context) { | ||
12 | return func(c *gin.Context) { | ||
13 | c.Set(serverConfig, cfg) | ||
14 | c.Next() | ||
15 | } | ||
16 | } | ||
17 | |||
18 | func GetServerConfig(c *gin.Context) *web.ServerConfig { | ||
19 | v, ok := c.Get(serverConfig) | ||
20 | if !ok { | ||
21 | // This should never happen if the config context middlware is in place | ||
22 | panic("Unable to get config from request") | ||
23 | } | ||
24 | |||
25 | return v.(*web.ServerConfig) | ||
26 | } | ||
diff --git a/web/middleware/ddns.go b/web/middleware/ddns.go new file mode 100644 index 0000000..b213926 --- /dev/null +++ b/web/middleware/ddns.go | |||
@@ -0,0 +1,40 @@ | |||
1 | package middleware | ||
2 | |||
3 | import ( | ||
4 | "log" | ||
5 | "net/http" | ||
6 | |||
7 | "github.com/gin-gonic/gin" | ||
8 | ) | ||
9 | |||
10 | const DDNS_AUTH_KEY = "DDNSAuthZone" | ||
11 | |||
12 | type DDNSUpdateRequest struct { | ||
13 | Key string `form:"key" binding:"required"` | ||
14 | } | ||
15 | |||
16 | func DDNSAuthMiddleware(c *gin.Context) { | ||
17 | cfg := GetServerConfig(c) | ||
18 | |||
19 | var req DDNSUpdateRequest | ||
20 | if err := c.ShouldBind(&req); err != nil { | ||
21 | log.Println("ddnsAuthMiddleware: No key in request") | ||
22 | c.AbortWithStatus(http.StatusNotFound) | ||
23 | return | ||
24 | } | ||
25 | |||
26 | res := cfg.GetDDNSZoneName(req.Key) | ||
27 | if res == "" { | ||
28 | log.Println("ddnsAuthMiddleware: Unknown secret") | ||
29 | c.AbortWithStatus(http.StatusNotFound) | ||
30 | return | ||
31 | } else { | ||
32 | c.Set(DDNS_AUTH_KEY, res) | ||
33 | } | ||
34 | |||
35 | c.Next() | ||
36 | } | ||
37 | |||
38 | func GetDDNSAuthKey(c *gin.Context) string { | ||
39 | return c.GetString(DDNS_AUTH_KEY) | ||
40 | } | ||
diff --git a/web/utils.go b/web/utils.go new file mode 100644 index 0000000..5467132 --- /dev/null +++ b/web/utils.go | |||
@@ -0,0 +1,117 @@ | |||
1 | package web | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "fmt" | ||
6 | "log" | ||
7 | "net" | ||
8 | "net/http" | ||
9 | "net/url" | ||
10 | "os" | ||
11 | "os/signal" | ||
12 | "regexp" | ||
13 | |||
14 | "github.com/gin-gonic/gin" | ||
15 | ) | ||
16 | |||
17 | // Parses an IPv4 or IPv6 address with an optional port on the end. Returns | ||
18 | // match groups for the addresses. The first match is the IPv6 address and the | ||
19 | // second the IPv4 address. | ||
20 | var ipRegexp = regexp.MustCompile(`(?:\[([0-9a-f:]+)\]|(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))(?::\d+)?`) | ||
21 | |||
22 | // TODO: use from a common package | ||
23 | func ParseIP(s string) net.IP { | ||
24 | ips := ipRegexp.FindStringSubmatch(s) | ||
25 | if ips == nil { | ||
26 | return nil | ||
27 | } | ||
28 | |||
29 | if v6, v4 := ips[1], ips[2]; v6 != "" { | ||
30 | return net.ParseIP(v6) | ||
31 | } else { | ||
32 | return net.ParseIP(v4) | ||
33 | } | ||
34 | } | ||
35 | |||
36 | // TODO: use from a common package | ||
37 | func GetRequestIP(c *gin.Context) net.IP { | ||
38 | if xff := c.Request.Header.Get("X-Forwarded-For"); xff != "" { | ||
39 | return ParseIP(xff) | ||
40 | } | ||
41 | return ParseIP(c.Request.RemoteAddr) | ||
42 | } | ||
43 | |||
44 | // TODO: use from a common package | ||
45 | func MakeURL(r *http.Request, path string, subs ...interface{}) *url.URL { | ||
46 | scheme := "https" | ||
47 | if r.TLS == nil { | ||
48 | scheme = "http" | ||
49 | } | ||
50 | |||
51 | // Always defer to whatever the proxy told us it was doing because this | ||
52 | // could be a mullet-VIP in either direction. | ||
53 | if fwProto := r.Header.Get("X-Forwarded-Proto"); fwProto != "" { | ||
54 | scheme = fwProto | ||
55 | } | ||
56 | |||
57 | return &url.URL{ | ||
58 | Scheme: scheme, | ||
59 | Host: r.Host, | ||
60 | Path: fmt.Sprintf(path, subs...), | ||
61 | } | ||
62 | } | ||
63 | |||
64 | // TODO: use from a common package | ||
65 | // Copied from: https://github.com/gin-gonic/gin/blob/59ab588bf597f9f41faee4f217b5659893c2e925/utils.go#L137 | ||
66 | func resolveAddress(addr []string) string { | ||
67 | switch len(addr) { | ||
68 | case 0: | ||
69 | if port := os.Getenv("PORT"); port != "" { | ||
70 | log.Printf("Environment variable PORT=\"%s\"", port) | ||
71 | return ":" + port | ||
72 | } | ||
73 | log.Printf("Environment variable PORT is undefined. Using port :8080 by default") | ||
74 | return ":8080" | ||
75 | case 1: | ||
76 | return addr[0] | ||
77 | default: | ||
78 | panic("too many parameters") | ||
79 | } | ||
80 | } | ||
81 | |||
82 | // TODO: use from a common package | ||
83 | // Runs a gin.Engine instance in a way that can be canceled by an SIGINT | ||
84 | func GinRun(e *gin.Engine, debug bool, a ...string) { | ||
85 | if debug { | ||
86 | gin.SetMode(gin.DebugMode) | ||
87 | } else { | ||
88 | gin.SetMode(gin.ReleaseMode) | ||
89 | } | ||
90 | |||
91 | srv := &http.Server{ | ||
92 | Addr: resolveAddress(a), | ||
93 | Handler: e, | ||
94 | } | ||
95 | |||
96 | idleConnsClosed := make(chan struct{}) | ||
97 | |||
98 | go func() { | ||
99 | sigint := make(chan os.Signal, 1) | ||
100 | signal.Notify(sigint, os.Interrupt) | ||
101 | <-sigint | ||
102 | |||
103 | log.Println("Caught SIGINT, shutting down") | ||
104 | if err := srv.Shutdown(context.Background()); err != nil { | ||
105 | log.Printf("HTTP server Shutdown: %v", err) | ||
106 | } | ||
107 | |||
108 | close(idleConnsClosed) | ||
109 | }() | ||
110 | |||
111 | log.Printf("Listening and serving HTTP on %s\n", srv.Addr) | ||
112 | if err := srv.ListenAndServe(); err != http.ErrServerClosed { | ||
113 | log.Fatalf("HTTP server ListenAndServe: %v", err) | ||
114 | } | ||
115 | |||
116 | <-idleConnsClosed | ||
117 | } | ||