package main import ( "errors" "net/http" "regexp" "slices" "strings" ) // DDNSForm is the dynamic DNS section of the settings (router.ddns). type DDNSForm struct { Enabled bool Provider string // a connection of router.dnsProviders 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), Provider: str(d, "provider"), 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.Interval == "" { f.Interval = "5min" } return f } // applyDDNS writes the form into the settings. func (s Settings) applyDDNS(f DDNSForm) error { errs := FormErrors{} f.Provider, f.Zone, f.Interval = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Zone), strings.TrimSpace(f.Interval) 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 f.Provider != "" { p, ok := s.dnsProviderMap()[f.Provider].(map[string]any) switch { case !ok: errs["provider"] = "choose one of the DNS providers" case !slices.Contains(ddnsTypes, str(p, "type")): errs["provider"] = "dynamic DNS does not know " + str(p, "type") + "; it is for certificates" case str(p, "secretFile") == "": errs["provider"] = "this account has no password or token (add it on the DNS providers page)" } } if f.Interval != "" && !intervalRe.MatchString(f.Interval) { errs["interval"] = "like 5min, 30s or 1h" } if f.Enabled { if f.Provider == "" { errs["provider"] = "choose a DNS provider (add one on the DNS providers page)" } if len(domains) == 0 { errs["domains"] = "name at least one host" } if !f.IPv4 && !f.IPv6 { errs["ipv4"] = "choose IPv4, IPv6 or both" } } if len(errs) > 0 { return errs } d := obj(obj(s, "router"), "ddns") d["enable"] = f.Enabled 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("provider", f.Provider) set("zone", f.Zone) if len(domains) > 0 { d["domains"] = domains } else { delete(d, "domains") } return 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 Providers []DNSProvider } 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(), Providers: a.dnsProviders(s)} 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", Provider: v("provider"), Zone: v("zone"), Domains: v("domains"), IPv4: v("ipv4") == "on", IPv6: v("ipv6") == "on", SSL: v("ssl") == "on", Interval: v("interval")} if err := s.applyDDNS(f); err != nil { var fe FormErrors errors.As(err, &fe) p.Error = "Please correct the marked fields." p.Data = ddnsData{Form: f, Errors: fe, Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)} w.WriteHeader(http.StatusUnprocessableEntity) 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.") }