Files
nixos-router/pkgs/router-webui/dnsproviders.go
nilsandClaude Sonnet 5.5 caf2c6b26d Add DNS accounts, Let's Encrypt, password change, reboot, HTTP redirect
- router.dnsProviders: named accounts at DNS providers, used by dynamic
  DNS and certificates (ddclient and lego provider lists in
  pkgs/router-webui/providers.json). Schema 2 migrates router.ddns.
- router.acme: a Let's Encrypt certificate for the web UI via
  security.acme (DNS-01, resolver 1.1.1.1), served and reloaded by the
  web UI; Certificate page.
- Web UI: password change (web UI and root), reboot button, HTTP to HTTPS
  redirect (router.webui.httpPort), grouped menu.
- Latest kernel (linuxPackages_latest); 7.3 and 7.3.0 are the same kernel
  for the reboot hint.
- SPECS.md: wanted later (REST API, GraphQL, MCP).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeFqV9dZrK6nipHkPfyM4F
2026-10-30 19:27:49 +01:00

332 lines
9.9 KiB
Go

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.")
}