package main import ( "errors" "net/http" "regexp" "strings" ) // DDNSForm is the dynamic DNS section of the settings (router.ddns). type DDNSForm struct { Enabled bool Protocol string Server string Username string Secret string // write-only: empty keeps the stored one SecretSet bool Zone string Domains string // one per line or comma separated IPv4 bool IPv6 bool SSL bool Interval string } var ( protocolRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`) ddnsHostRe = regexp.MustCompile(`^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`) intervalRe = regexp.MustCompile(`^[0-9]+ ?(s|sec|m|min|h)$`) serverRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?(/[A-Za-z0-9._/-]*)?$`) ) func (s Settings) ddns() DDNSForm { router, _ := s["router"].(map[string]any) d, _ := router["ddns"].(map[string]any) flag := func(k string, def bool) bool { if v, ok := d[k].(bool); ok { return v } return def } f := DDNSForm{ Enabled: flag("enable", false), Protocol: str(d, "protocol"), Server: str(d, "server"), Username: str(d, "username"), SecretSet: str(d, "passwordFile") != "", Zone: str(d, "zone"), Domains: strings.Join(stringList(d, "domains"), "\n"), IPv4: flag("ipv4", true), IPv6: flag("ipv6", false), SSL: flag("ssl", true), Interval: str(d, "interval"), } if f.Protocol == "" { f.Protocol = "cloudflare" } if f.Username == "" && f.Protocol == "cloudflare" { f.Username = "token" } if f.Interval == "" { f.Interval = "5min" } return f } // applyDDNS writes the form into the settings. It returns the secret to store, // if one was entered. func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) { errs := FormErrors{} f.Protocol, f.Server = strings.ToLower(strings.TrimSpace(f.Protocol)), strings.TrimSpace(f.Server) f.Username, f.Zone, f.Interval = strings.TrimSpace(f.Username), strings.TrimSpace(f.Zone), strings.TrimSpace(f.Interval) f.Secret = strings.TrimSpace(f.Secret) var domains []any for _, d := range splitList(f.Domains) { if !ddnsHostRe.MatchString(d) { errs["domains"] = d + " is not a host name" break } domains = append(domains, strings.ToLower(d)) } if !protocolRe.MatchString(f.Protocol) { errs["protocol"] = "the provider's ddclient protocol, like cloudflare or dyndns2" } if f.Server != "" && !serverRe.MatchString(f.Server) { errs["server"] = "a host name, without http://" } if f.Interval != "" && !intervalRe.MatchString(f.Interval) { errs["interval"] = "like 5min, 30s or 1h" } if strings.ContainsAny(f.Username, "\n\r") { errs["username"] = "one line" } if f.Enabled { if len(domains) == 0 { errs["domains"] = "name at least one host" } if !f.IPv4 && !f.IPv6 { errs["ipv4"] = "choose IPv4, IPv6 or both" } if f.Secret == "" && !s.ddns().SecretSet { errs["secret"] = "the provider's password or API token" } } if len(errs) > 0 { return "", errs } d := obj(obj(s, "router"), "ddns") d["enable"] = f.Enabled d["protocol"] = f.Protocol d["username"] = f.Username d["ipv4"], d["ipv6"], d["ssl"] = f.IPv4, f.IPv6, f.SSL if f.Interval != "" { d["interval"] = f.Interval } set := func(k, v string) { if v == "" { delete(d, k) } else { d[k] = v } } set("server", f.Server) set("zone", f.Zone) if len(domains) > 0 { d["domains"] = domains } else { delete(d, "domains") } if f.Secret != "" { d["passwordFile"] = secretFile } return f.Secret, nil } // DDNSStatus is what ddclient last did. type DDNSStatus struct { State string // "inactive", "active", ... of ddclient.service Last string // when it last ran Log string } func (s System) ddnsStatus() DDNSStatus { st := DDNSStatus{State: unitProperty("ddclient.service", "ActiveState"), Last: unitProperty("ddclient.service", "ExecMainExitTimestamp")} st.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "20", "-u", "ddclient.service") return st } type ddnsData struct { Form DDNSForm Errors FormErrors Status DDNSStatus } func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "Dynamic DNS", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "ddns", p) return } p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus()} a.render(w, "ddns", p) } func (a *app) ddnsSave(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "Dynamic DNS", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "ddns", p) return } v := r.PostFormValue f := DDNSForm{Enabled: v("enabled") == "on", Protocol: v("protocol"), Server: v("server"), Username: v("username"), Secret: v("secret"), Zone: v("zone"), Domains: v("domains"), IPv4: v("ipv4") == "on", IPv6: v("ipv6") == "on", SSL: v("ssl") == "on", Interval: v("interval")} secret, err := s.applyDDNS(f, a.sys.ddnsSecretPath()) if err != nil { var fe FormErrors errors.As(err, &fe) f.Secret, f.SecretSet = "", s.ddns().SecretSet p.Error = "Please correct the marked fields." p.Data = ddnsData{Form: f, Errors: fe, Status: a.sys.ddnsStatus()} w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "ddns", p) return } if secret != "" { if err := writeFileAtomic(a.sys.ddnsSecretPath(), []byte(secret), 0o600); err != nil { p.Error = "Storing the secret: " + err.Error() a.render(w, "ddns", p) return } } if err := s.save(a.sys.settingsPath()); err != nil { p.Error = "Saving: " + err.Error() a.render(w, "ddns", p) return } back(w, r, "Saved. Apply to make the changes take effect.") }