summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorMike Crute <mike@crute.us>2020-01-04 03:59:53 +0000
committerMike Crute <mike@crute.us>2020-01-04 03:59:53 +0000
commit8ed9671c0b4f78711858448cf3b4ee9af0eba51e (patch)
tree2b6e9b9f1958101de8501a36fc60124b86a35c6f /web
parentb4214b63d7c73cb0fec55b4e678a98327a159d48 (diff)
downloadgo_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.go75
-rw-r--r--web/controllers/acme.go149
-rw-r--r--web/controllers/ddns.go51
-rw-r--r--web/controllers/reflect_ip.go19
-rw-r--r--web/middleware/acme.go33
-rw-r--r--web/middleware/config_context.go26
-rw-r--r--web/middleware/ddns.go40
-rw-r--r--web/utils.go117
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 @@
1package web
2
3import (
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
13type 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
22func 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
47func (s *ServerConfig) GetDDNSZoneName(k string) string {
48 log.Printf("%#v", s.DDNSSecrets)
49 v, _ := s.DDNSSecrets[k]
50 return v
51}
52
53func (s *ServerConfig) AcmeSecretExists(k string) bool {
54 _, ok := s.AcmeSecrets[k]
55 return ok
56}
57
58func (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 @@
1package controllers
2
3import (
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
17type AcmeChallenge struct {
18 Zone string `json:"zone" binding:"required"`
19 Challenge string `json:"challenge" binding:"required"`
20}
21
22type AcmeChallengeID struct {
23 Zone string
24 Prefix string
25 Challenge string
26}
27
28func 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
42func (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
51func 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
61func 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
74func 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
116func 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 @@
1package controllers
2
3import (
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
14func 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 @@
1package controllers
2
3import (
4 "net/http"
5
6 "github.com/gin-gonic/gin"
7
8 "code.crute.me/mcrute/go_ddns_manager/web"
9)
10
11func 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 @@
1package middleware
2
3import (
4 "net/http"
5
6 "github.com/gin-gonic/gin"
7)
8
9const acmeUserId = "ACMEAuthUserID"
10
11func 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
31func 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 @@
1package middleware
2
3import (
4 "github.com/gin-gonic/gin"
5
6 "code.crute.me/mcrute/go_ddns_manager/web"
7)
8
9const serverConfig = "ServerConfig"
10
11func ConfigContextMiddleware(cfg *web.ServerConfig) func(*gin.Context) {
12 return func(c *gin.Context) {
13 c.Set(serverConfig, cfg)
14 c.Next()
15 }
16}
17
18func 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 @@
1package middleware
2
3import (
4 "log"
5 "net/http"
6
7 "github.com/gin-gonic/gin"
8)
9
10const DDNS_AUTH_KEY = "DDNSAuthZone"
11
12type DDNSUpdateRequest struct {
13 Key string `form:"key" binding:"required"`
14}
15
16func 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
38func 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 @@
1package web
2
3import (
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.
20var 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
23func 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
37func 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
45func 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
66func 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
84func 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}