package main import ( _ "embed" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "regexp" "slices" "sort" "strings" ) // providers.json is shared with the Nix modules: the ddclient protocols // (ddns), the lego DNS providers (acme) and those of them with a single token // (acmeToken), whose secret is that token; the others take lego's variables. // //go:embed providers.json var providersJSON []byte var acmeTypes, ddnsTypes []string var acmeTokens map[string]string func init() { var p struct { DDNS []string `json:"ddns"` ACME []string `json:"acme"` ACMEToken map[string]string `json:"acmeToken"` } if err := json.Unmarshal(providersJSON, &p); err != nil { panic(err) } ddnsTypes, acmeTypes, acmeTokens = p.DDNS, p.ACME, p.ACMEToken } // TypeOption is an entry of the provider drop-down. type TypeOption struct { Name, Label string Selected bool } // providerTypeOptions lists every supported provider, with what each can do; // a type the settings already have but the list lacks stays selectable. func providerTypeOptions(current string) []TypeOption { names := append([]string{}, ddnsTypes...) for _, t := range acmeTypes { if !slices.Contains(names, t) { names = append(names, t) } } if current != "" && !slices.Contains(names, current) { names = append(names, current) } sort.Strings(names) var out []TypeOption for _, n := range names { var uses []string if slices.Contains(ddnsTypes, n) { uses = append(uses, "dynamic DNS") } if slices.Contains(acmeTypes, n) { uses = append(uses, "certificates") } label := n if len(uses) > 0 { label += " (" + strings.Join(uses, ", ") + ")" } out = append(out, TypeOption{Name: n, Label: label, Selected: n == current}) } return out } type providerRow struct { DNSProvider Options []TypeOption } func providerRows(rows []DNSProvider) []providerRow { out := make([]providerRow, len(rows)) for i, r := range rows { out[i] = providerRow{r, providerTypeOptions(r.Type)} } return out } var envLineRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=.*$`) var providerNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) // DNSProvider is a connection to a DNS provider (router.dnsProviders). type DNSProvider struct { Name string Type string Username string Server string Secret string // form only, write-only: empty keeps the stored one SecretSet bool Env string // form only, write-only: lego's VARIABLE=value lines EnvSet bool New bool // form only: the name may be chosen Remove bool ACME bool // can order certificates } func (s Settings) dnsProviderMap() map[string]any { router, _ := s["router"].(map[string]any) m, _ := router["dnsProviders"].(map[string]any) return m } func (a *app) providerFileSet(s Settings, name, key string) bool { p, _ := s.dnsProviderMap()[name].(map[string]any) f := str(p, key) if f == "" { return false } fi, err := os.Stat(f) return err == nil && fi.Size() > 0 } func (a *app) dnsProviders(s Settings) []DNSProvider { var out []DNSProvider for name, v := range s.dnsProviderMap() { p, _ := v.(map[string]any) if p == nil { continue } out = append(out, DNSProvider{Name: name, Type: str(p, "type"), Username: str(p, "username"), Server: str(p, "server"), SecretSet: a.providerFileSet(s, name, "secretFile"), EnvSet: a.providerFileSet(s, name, "environmentFile"), ACME: slices.Contains(acmeTypes, str(p, "type"))}) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // providerUsers lists what refers to the connection. func (s Settings) providerUsers(name string) []string { router, _ := s["router"].(map[string]any) var users []string if d, _ := router["ddns"].(map[string]any); str(d, "provider") == name { users = append(users, "Dynamic DNS") } if c, _ := router["acme"].(map[string]any); str(c, "provider") == name { users = append(users, "the certificate") } return users } // knownType is a provider of ddclient or lego. func knownType(t string) bool { return slices.Contains(ddnsTypes, t) || slices.Contains(acmeTypes, t) } // secretWrite is a secret to store; secretDelete a file to remove once the // settings are saved. type secretWrite struct{ Path, Secret string } // applyDNSProviders replaces the connections by the rows. New secrets go to // secretDir/NAME unless the connection has a file already. func (s Settings) applyDNSProviders(rows []DNSProvider, secretDir string) (writes []secretWrite, deletes []string, err error) { errs := FormErrors{} old := s.dnsProviderMap() next := map[string]any{} for name, v := range old { // connections not in the form stay next[name] = v } seen := map[string]bool{} for i, r := range rows { field := func(n string) string { return fmt.Sprintf("prov[%d].%s", i, n) } r.Name, r.Type = strings.ToLower(strings.TrimSpace(r.Name)), strings.ToLower(strings.TrimSpace(r.Type)) r.Username, r.Server, r.Secret = strings.TrimSpace(r.Username), strings.TrimSpace(r.Server), strings.TrimSpace(r.Secret) r.Env = strings.TrimSpace(strings.ReplaceAll(r.Env, "\r\n", "\n")) if r.Name == "" && r.Secret == "" && r.Env == "" && r.Server == "" { continue // the add row, left as it was offered (type and login are pre-filled) } oldRow, exists := old[r.Name].(map[string]any) if r.New && exists { errs[field("name")] = "there is a connection with this name" } if !providerNameRe.MatchString(r.Name) { errs[field("name")] = "lower case letters, digits and dashes" } if seen[r.Name] { errs[field("name")] = "the same name twice" } seen[r.Name] = true if r.Remove { if users := s.providerUsers(r.Name); len(users) > 0 { errs[field("name")] = "used by " + strings.Join(users, " and ") continue } for _, k := range []string{"secretFile", "environmentFile"} { if f := str(oldRow, k); f != "" { deletes = append(deletes, f) } } delete(next, r.Name) continue } if !protocolRe.MatchString(r.Type) || !(knownType(r.Type) || r.Type == str(oldRow, "type")) { errs[field("type")] = "choose a provider from the list" } for _, l := range strings.Split(r.Env, "\n") { if l = strings.TrimSpace(l); l != "" && !envLineRe.MatchString(l) { errs[field("env")] = l + " is not VARIABLE=value" break } } if r.Server != "" && !serverRe.MatchString(r.Server) { errs[field("server")] = "a host name, without http://" } if strings.ContainsAny(r.Username, "\n\r") { errs[field("username")] = "one line" } secretFile := str(oldRow, "secretFile") if secretFile == "" { secretFile = filepath.Join(secretDir, r.Name) } envFile := str(oldRow, "environmentFile") if envFile == "" { envFile = filepath.Join(secretDir, r.Name+".env") } hasSecret := r.Secret != "" || str(oldRow, "secretFile") != "" hasEnv := r.Env != "" || str(oldRow, "environmentFile") != "" if !hasSecret && !hasEnv { errs[field("secret")] = "the provider's password or API token, or its variables for certificates" } row := map[string]any{"type": r.Type} if hasSecret { row["secretFile"] = secretFile } if hasEnv { row["environmentFile"] = envFile } if r.Username != "" { row["username"] = r.Username } if r.Server != "" { row["server"] = r.Server } next[r.Name] = row if r.Secret != "" { writes = append(writes, secretWrite{secretFile, r.Secret}) } if r.Env != "" { writes = append(writes, secretWrite{envFile, r.Env + "\n"}) } } if len(errs) > 0 { return nil, nil, errs } if len(next) == 0 { delete(obj(s, "router"), "dnsProviders") } else { obj(s, "router")["dnsProviders"] = next } return writes, deletes, nil } type providersData struct { Rows []providerRow Errors FormErrors } func (a *app) providersPage(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "DNS providers", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "dnsproviders", p) return } rows := append(a.dnsProviders(s), DNSProvider{New: true, Type: "cloudflare", Username: "token"}) p.Data = providersData{Rows: providerRows(rows)} a.render(w, "dnsproviders", p) } func (a *app) providersSave(w http.ResponseWriter, r *http.Request, sess session) { p := page{Title: "DNS providers", CSRF: sess.csrf, LoggedIn: true} s, err := loadSettings(a.sys.settingsPath()) if err != nil { p.Error = err.Error() a.render(w, "dnsproviders", p) return } n := 0 fmt.Sscanf(r.PostFormValue("providers"), "%d", &n) n = min(max(n, 0), 50) var rows []DNSProvider for i := 0; i < n; i++ { v := func(k string) string { return r.PostFormValue(fmt.Sprintf("prov.%d.%s", i, k)) } rows = append(rows, DNSProvider{Name: v("name"), Type: v("type"), Username: v("username"), Server: v("server"), Secret: v("secret"), Env: v("env"), New: v("new") == "1", Remove: v("remove") == "on"}) } writes, deletes, err := s.applyDNSProviders(rows, filepath.Join(a.sys.StateDir, "dns-secrets")) if err != nil { var fe FormErrors errors.As(err, &fe) for i := range rows { rows[i].Secret, rows[i].Env = "", "" _, rows[i].SecretSet = s.dnsProviderMap()[strings.ToLower(strings.TrimSpace(rows[i].Name))].(map[string]any) } p.Error = "Please correct the marked fields." p.Data = providersData{Rows: providerRows(rows), Errors: fe} w.WriteHeader(http.StatusUnprocessableEntity) a.render(w, "dnsproviders", p) return } for _, wr := range writes { err := os.MkdirAll(filepath.Dir(wr.Path), 0o700) if err == nil { err = writeFileAtomic(wr.Path, []byte(wr.Secret), 0o600) } if err != nil { p.Error = "Storing the secret: " + err.Error() a.render(w, "dnsproviders", p) return } } if err := s.save(a.sys.settingsPath()); err != nil { p.Error = "Saving: " + err.Error() a.render(w, "dnsproviders", p) return } for _, f := range deletes { if strings.HasPrefix(f, a.sys.StateDir+"/") { os.Remove(f) } } back(w, r, "Saved. Apply to make the changes take effect.") }