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
This commit is contained in:
2026-10-30 19:27:49 +01:00
co-authored by Claude Sonnet 5.5
parent a32bf42777
commit caf2c6b26d
33 changed files with 1985 additions and 224 deletions
+262
View File
@@ -0,0 +1,262 @@
package main
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"errors"
"log"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
)
// CertInfo describes the certificate the web UI serves.
type CertInfo struct {
Subject, Issuer string
NotAfter time.Time
SelfSigned bool
Fingerprint string // SHA-256, colon separated
}
func readCertInfo(path string) (CertInfo, bool) {
data, err := os.ReadFile(path)
if err != nil {
return CertInfo{}, false
}
block, _ := pem.Decode(data)
if block == nil {
return CertInfo{}, false
}
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return CertInfo{}, false
}
sum := sha256.Sum256(c.Raw)
hexSum := strings.ToUpper(hex.EncodeToString(sum[:]))
var pairs []string
for i := 0; i < len(hexSum); i += 2 {
pairs = append(pairs, hexSum[i:i+2])
}
return CertInfo{Subject: c.Subject.String(), Issuer: c.Issuer.String(), NotAfter: c.NotAfter,
SelfSigned: c.Subject.String() == c.Issuer.String(), Fingerprint: strings.Join(pairs, ":")}, true
}
// certSource serves the certificate an ACME client keeps in dir while it is
// there and valid, and the self-signed one otherwise. Renewed files are picked
// up on the next connection.
type certSource struct {
fallback tls.Certificate
dir string
mu sync.Mutex
loaded tls.Certificate
mod time.Time
ok bool
}
func (c *certSource) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
if c.dir == "" {
return &c.fallback, nil
}
chain, key := filepath.Join(c.dir, "fullchain.pem"), filepath.Join(c.dir, "key.pem")
a, errA := os.Stat(chain)
b, errB := os.Stat(key)
c.mu.Lock()
defer c.mu.Unlock()
if errA != nil || errB != nil {
c.ok = false
return &c.fallback, nil
}
mod := a.ModTime()
if b.ModTime().After(mod) {
mod = b.ModTime()
}
if !c.ok || !mod.Equal(c.mod) {
cert, err := tls.LoadX509KeyPair(chain, key)
c.mod = mod // do not retry a broken pair on every connection
c.ok = err == nil
if err != nil {
log.Printf("certificate in %s: %v", c.dir, err)
} else {
c.loaded = cert
}
}
if !c.ok {
return &c.fallback, nil
}
return &c.loaded, nil
}
// ACMEForm is the certificate section of the settings (router.acme).
type ACMEForm struct {
Enabled bool
Provider string // a connection of router.dnsProviders
Domains string // one per line or comma separated
Email string
Staging bool
Agree bool
}
func (s Settings) acme() ACMEForm {
router, _ := s["router"].(map[string]any)
c, _ := router["acme"].(map[string]any)
flag := func(k string, def bool) bool {
if v, ok := c[k].(bool); ok {
return v
}
return def
}
return ACMEForm{Enabled: flag("enable", false), Provider: str(c, "provider"), Domains: strings.Join(stringList(c, "domains"), "\n"),
Email: str(c, "email"), Staging: flag("staging", true), Agree: flag("acceptTerms", false)}
}
func (s Settings) applyACME(f ACMEForm) error {
errs := FormErrors{}
f.Provider, f.Email = strings.TrimSpace(f.Provider), strings.TrimSpace(f.Email)
var domains []any
for _, d := range splitList(f.Domains) {
if !ddnsHostRe.MatchString(d) || !strings.Contains(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(acmeTypes, str(p, "type")):
errs["provider"] = "lego has no DNS provider " + str(p, "type") + "; it is for dynamic DNS"
case acmeTokens[str(p, "type")] != "" && str(p, "secretFile") == "":
errs["provider"] = "this account has no token (add it on the DNS providers page)"
case acmeTokens[str(p, "type")] == "" && str(p, "environmentFile") == "":
errs["provider"] = "this account has no certificate variables (add them on the DNS providers page)"
}
}
if f.Email != "" && !strings.Contains(f.Email, "@") {
errs["email"] = "an e-mail address"
}
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.Email == "" {
errs["email"] = "an address Let's Encrypt can write to about expiry"
}
if !f.Agree {
errs["agree"] = "accept the Let's Encrypt subscriber agreement"
}
}
if len(errs) > 0 {
return errs
}
c := obj(obj(s, "router"), "acme")
c["enable"], c["staging"], c["acceptTerms"] = f.Enabled, f.Staging, f.Agree
set := func(k, v string) {
if v == "" {
delete(c, k)
} else {
c[k] = v
}
}
set("provider", f.Provider)
set("email", f.Email)
if len(domains) > 0 {
c["domains"] = domains
} else {
delete(c, "domains")
}
return nil
}
type certificateData struct {
Cert CertInfo
Has bool
Serving string // "acme" or "self-signed"
Form ACMEForm
Errors FormErrors
Providers []DNSProvider
Status string // the order's last result
Log string
}
// servedCert is what the web UI hands out: the ACME certificate if it is there.
func (a *app) servedCert() (CertInfo, bool, string) {
if a.sys.ACMEDir != "" {
if c, ok := readCertInfo(filepath.Join(a.sys.ACMEDir, "fullchain.pem")); ok {
return c, true, "acme"
}
}
c, ok := readCertInfo(filepath.Join(a.sys.StateDir, "webui", "cert.pem"))
return c, ok, "self-signed"
}
func (a *app) certificateData(s Settings, f ACMEForm, errs FormErrors) certificateData {
var providers []DNSProvider
for _, p := range a.dnsProviders(s) {
if p.ACME {
providers = append(providers, p)
}
}
d := certificateData{Form: f, Errors: errs, Providers: providers}
d.Cert, d.Has, d.Serving = a.servedCert()
if first := splitList(f.Domains); len(first) > 0 && s.acme().Enabled {
unit := "acme-order-renew-" + strings.ToLower(first[0]) + ".service"
if st := unitProperty(unit, "ActiveState"); st != "" {
d.Status = st + ", result " + unitProperty(unit, "Result")
d.Log, _ = command("journalctl", "--no-pager", "-o", "cat", "-n", "15", "-u", unit)
}
}
return d
}
func (a *app) certificatePage(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true}
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
p.Error = err.Error()
a.render(w, "certificate", p)
return
}
p.Data = a.certificateData(s, s.acme(), nil)
a.render(w, "certificate", p)
}
func (a *app) certificateSave(w http.ResponseWriter, r *http.Request, sess session) {
p := page{Title: "Certificate", CSRF: sess.csrf, LoggedIn: true}
s, err := loadSettings(a.sys.settingsPath())
if err != nil {
p.Error = err.Error()
a.render(w, "certificate", p)
return
}
v := r.PostFormValue
f := ACMEForm{Enabled: v("enabled") == "on", Provider: v("provider"), Domains: v("domains"), Email: v("email"),
Staging: v("staging") == "on", Agree: v("agree") == "on"}
if err := s.applyACME(f); err != nil {
var fe FormErrors
errors.As(err, &fe)
p.Error = "Please correct the marked fields."
p.Data = a.certificateData(s, f, fe)
w.WriteHeader(http.StatusUnprocessableEntity)
a.render(w, "certificate", p)
return
}
if err := s.save(a.sys.settingsPath()); err != nil {
p.Error = "Saving: " + err.Error()
a.render(w, "certificate", p)
return
}
back(w, r, "Saved. Apply to order the certificate; this page shows how it went.")
}
+6 -1
View File
@@ -297,7 +297,12 @@ var kernelDirRe = regexp.MustCompile(`-linux-([0-9][^/]*)/`)
// (a path like /nix/store/…-linux-6.12.5/bzImage) if it is not the running one.
func pendingKernel(running, image string) string {
m := kernelDirRe.FindStringSubmatch(image)
if m == nil || running == "" || m[1] == running || strings.HasPrefix(running, m[1]+"-") {
if m == nil || running == "" {
return ""
}
// The store name says 7.3, the kernel 7.3.0 (or 7.3.0-suffix).
base, _, _ := strings.Cut(running, "-")
if base == m[1] || base == m[1]+".0" {
return ""
}
return m[1]
+38 -63
View File
@@ -4,23 +4,20 @@ import (
"errors"
"net/http"
"regexp"
"slices"
"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
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 (
@@ -40,30 +37,20 @@ func (s Settings) ddns() DDNSForm {
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"),
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.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) {
// applyDDNS writes the form into the settings.
func (s Settings) applyDDNS(f DDNSForm) 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)
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) {
@@ -72,36 +59,36 @@ func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) {
}
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.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 strings.ContainsAny(f.Username, "\n\r") {
errs["username"] = "one line"
}
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 f.Secret == "" && !s.ddns().SecretSet {
errs["secret"] = "the provider's password or API token"
}
}
if len(errs) > 0 {
return "", errs
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
@@ -113,17 +100,14 @@ func (s Settings) applyDDNS(f DDNSForm, secretFile string) (string, error) {
d[k] = v
}
}
set("server", f.Server)
set("provider", f.Provider)
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
return nil
}
// DDNSStatus is what ddclient last did.
@@ -141,9 +125,10 @@ func (s System) ddnsStatus() DDNSStatus {
}
type ddnsData struct {
Form DDNSForm
Errors FormErrors
Status DDNSStatus
Form DDNSForm
Errors FormErrors
Status DDNSStatus
Providers []DNSProvider
}
func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) {
@@ -154,7 +139,7 @@ func (a *app) ddnsPage(w http.ResponseWriter, r *http.Request, sess session) {
a.render(w, "ddns", p)
return
}
p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus()}
p.Data = ddnsData{Form: s.ddns(), Status: a.sys.ddnsStatus(), Providers: a.dnsProviders(s)}
a.render(w, "ddns", p)
}
@@ -167,27 +152,17 @@ func (a *app) ddnsSave(w http.ResponseWriter, r *http.Request, sess session) {
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 {
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)
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()}
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 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)
+146 -41
View File
@@ -4,103 +4,208 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// withProvider adds a connection to the settings.
func withProvider(s Settings, name, typ string) Settings {
obj(obj(s, "router"), "dnsProviders")[name] = map[string]any{"type": typ, "secretFile": "/var/lib/router/dns-secrets/" + name}
return s
}
func TestDDNSRoundTrip(t *testing.T) {
s := mustParse(t)
if f := s.ddns(); f.Enabled || f.Protocol != "cloudflare" || f.Username != "token" || f.Interval != "5min" || !f.IPv4 || f.IPv6 {
s := withProvider(mustParse(t), "cf", "cloudflare")
if f := s.ddns(); f.Enabled || f.Provider != "" || f.Interval != "5min" || !f.IPv4 || f.IPv6 {
t.Fatalf("defaults = %+v", f)
}
f := DDNSForm{Enabled: true, Protocol: "Cloudflare", Username: "token", Secret: " s3cret\n", Zone: "example.org",
f := DDNSForm{Enabled: true, Provider: "cf", Zone: "example.org",
Domains: "Home.example.org, *.k8s.example.org\nvpn.example.org", IPv4: true, IPv6: true, SSL: true, Interval: "10min"}
secret, err := s.applyDDNS(f, "/var/lib/router/ddns-secret")
if err != nil || secret != "s3cret" {
t.Fatalf("secret=%q err=%v", secret, err)
if err := s.applyDDNS(f); err != nil {
t.Fatal(err)
}
out := string(mustMarshal(t, s))
for _, want := range []string{`"passwordFile": "/var/lib/router/ddns-secret"`, `"home.example.org"`, `"*.k8s.example.org"`, `"ipv6": true`, `"zone": "example.org"`} {
for _, want := range []string{`"provider": "cf"`, `"home.example.org"`, `"*.k8s.example.org"`, `"ipv6": true`, `"zone": "example.org"`} {
if !strings.Contains(out, want) {
t.Errorf("%s missing:\n%s", want, out)
}
}
if strings.Contains(out, "s3cret") {
t.Error("the secret is in the settings")
}
got := s.ddns()
if !got.SecretSet || got.Domains != "home.example.org\n*.k8s.example.org\nvpn.example.org" || got.Protocol != "cloudflare" {
if got.Provider != "cf" || got.Domains != "home.example.org\n*.k8s.example.org\nvpn.example.org" {
t.Errorf("read back = %+v", got)
}
// Saving again without a secret keeps the stored one; switching off keeps the rest.
f.Secret, f.Enabled = "", false
if secret, err := s.applyDDNS(f, "/x"); err != nil || secret != "" {
t.Fatalf("secret=%q err=%v", secret, err)
f.Enabled = false // switching off keeps the rest
if err := s.applyDDNS(f); err != nil {
t.Fatal(err)
}
if got := s.ddns(); got.Enabled || !got.SecretSet || got.Zone != "example.org" {
if got := s.ddns(); got.Enabled || got.Zone != "example.org" || got.Provider != "cf" {
t.Errorf("after switching off = %+v", got)
}
}
func TestDDNSIsChecked(t *testing.T) {
good := DDNSForm{Enabled: true, Protocol: "dyndns2", Server: "dynupdate.no-ip.com", Username: "u", Secret: "p",
Domains: "a.example.org", IPv4: true, SSL: true, Interval: "5min"}
good := DDNSForm{Enabled: true, Provider: "cf", Domains: "a.example.org", IPv4: true, SSL: true, Interval: "5min"}
for name, mod := range map[string]func(*DDNSForm){
"domains": func(f *DDNSForm) { f.Domains = "not a host!" },
"protocol": func(f *DDNSForm) { f.Protocol = "no way" },
"server": func(f *DDNSForm) { f.Server = "https://x.example" },
"provider": func(f *DDNSForm) { f.Provider = "nothing" },
"interval": func(f *DDNSForm) { f.Interval = "soon" },
"ipv4": func(f *DDNSForm) { f.IPv4 = false },
"secret": func(f *DDNSForm) { f.Secret = "" },
} {
f := good
mod(&f)
if _, err := mustParse(t).applyDDNS(f, "/x"); err == nil || err.(FormErrors)[name] == "" {
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil || err.(FormErrors)[name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
f := good
f.Provider = ""
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil || err.(FormErrors)["provider"] == "" {
t.Errorf("enabled without an account accepted: %v", err)
}
f = good
f.Domains = ""
if _, err := mustParse(t).applyDDNS(f, "/x"); err == nil {
if err := withProvider(mustParse(t), "cf", "cloudflare").applyDDNS(f); err == nil {
t.Error("enabled without names accepted")
}
f.Enabled, f.Secret = false, ""
if _, err := mustParse(t).applyDDNS(f, "/x"); err != nil {
t.Errorf("disabled needs neither names nor a secret: %v", err)
f.Enabled, f.Provider = false, ""
if err := mustParse(t).applyDDNS(f); err != nil {
t.Errorf("disabled needs neither names nor an account: %v", err)
}
}
func TestDDNSPage(t *testing.T) {
func TestDNSProvidersRoundTrip(t *testing.T) {
s := mustParse(t)
dir := t.TempDir()
rows := []DNSProvider{
{Name: "CF", Type: "Cloudflare", Username: "token", Secret: " s3cret\n", New: true},
{Name: "dyn", Type: "dyndns2", Server: "dynupdate.no-ip.com", Username: "u", Secret: "p", New: true},
{New: true, Type: "cloudflare"}, // the empty add row
}
writes, deletes, err := s.applyDNSProviders(rows, dir)
if err != nil || len(writes) != 2 || len(deletes) != 0 {
t.Fatalf("writes=%v deletes=%v err=%v", writes, deletes, err)
}
if writes[0].Path != filepath.Join(dir, "cf") || writes[0].Secret != "s3cret" {
t.Errorf("writes = %+v", writes)
}
out := string(mustMarshal(t, s))
if strings.Contains(out, "s3cret") || !strings.Contains(out, `"secretFile": "`+dir+`/cf"`) || !strings.Contains(out, `"server": "dynupdate.no-ip.com"`) {
t.Errorf("settings:\n%s", out)
}
// Edited without a new secret: the file stays. A used connection cannot go.
obj(obj(s, "router"), "ddns")["provider"] = "cf"
rows = []DNSProvider{{Name: "cf", Type: "cloudflare", Username: "token"}, {Name: "dyn", Type: "dyndns2", Remove: true}}
if writes, deletes, err = s.applyDNSProviders(rows, dir); err != nil || len(writes) != 0 || len(deletes) != 1 {
t.Fatalf("writes=%v deletes=%v err=%v", writes, deletes, err)
}
rows = []DNSProvider{{Name: "cf", Type: "cloudflare", Remove: true}}
if _, _, err = s.applyDNSProviders(rows, dir); err == nil || !strings.Contains(err.(FormErrors)["prov[0].name"], "Dynamic DNS") {
t.Errorf("a connection in use was removed: %v", err)
}
}
func TestDNSProvidersAreChecked(t *testing.T) {
good := DNSProvider{Name: "cf", Type: "cloudflare", Secret: "x", New: true}
for name, mod := range map[string]func(*DNSProvider){
"name": func(r *DNSProvider) { r.Name = "Not A Name!" },
"type": func(r *DNSProvider) { r.Type = "no way" },
"server": func(r *DNSProvider) { r.Server = "https://x.example" },
"secret": func(r *DNSProvider) { r.Secret = "" },
} {
r := good
mod(&r)
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{r}, "/x"); err == nil || err.(FormErrors)["prov[0]."+name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
s := withProvider(mustParse(t), "cf", "cloudflare")
if _, _, err := s.applyDNSProviders([]DNSProvider{good}, "/x"); err == nil {
t.Error("a new connection with a taken name accepted")
}
}
func TestDNSProvidersAndDDNSPages(t *testing.T) {
a, sys := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/ddns", nil, cookie).Body.String()
if !strings.Contains(body, `name="domains"`) || !strings.Contains(body, "Dynamic DNS") {
body := do(h, "GET", "/dnsproviders", nil, cookie).Body.String()
if !strings.Contains(body, `name="providers" value="1"`) {
t.Fatalf("page:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "enabled": {"on"}, "protocol": {"cloudflare"}, "username": {"token"},
"zone": {"example.org"}, "domains": {"home.example.org"}, "ipv4": {"on"}, "ssl": {"on"}, "interval": {"5min"}}
if rec := do(h, "POST", "/ddns", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "API token") {
form := url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.new": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.username": {"token"}}
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "API token") {
t.Fatalf("no secret: %d\n%s", rec.Code, rec.Body)
}
form.Set("secret", "tok3n")
if rec := do(h, "POST", "/ddns", form, cookie); rec.Code != http.StatusSeeOther {
form.Set("prov.0.secret", "tok3n")
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
data, err := os.ReadFile(sys.ddnsSecretPath())
path := filepath.Join(sys.StateDir, "dns-secrets", "cf")
data, err := os.ReadFile(path)
if err != nil || string(data) != "tok3n" {
t.Fatalf("secret file: %q %v", data, err)
}
if fi, _ := os.Stat(sys.ddnsSecretPath()); fi.Mode().Perm() != 0o600 {
if fi, _ := os.Stat(path); fi.Mode().Perm() != 0o600 {
t.Errorf("mode %v", fi.Mode())
}
s, _ := loadSettings(sys.settingsPath())
if f := s.ddns(); !f.Enabled || f.Domains != "home.example.org" || !f.SecretSet {
t.Fatalf("not saved: %+v", f)
}
if body := do(h, "GET", "/ddns", nil, cookie).Body.String(); strings.Contains(body, "tok3n") || !strings.Contains(body, "leave empty to keep") {
if body := do(h, "GET", "/dnsproviders", nil, cookie).Body.String(); strings.Contains(body, "tok3n") || !strings.Contains(body, "leave empty to keep") {
t.Errorf("the page shows the secret or lacks the hint:\n%s", body)
}
// Dynamic DNS picks the connection.
body = do(h, "GET", "/ddns", nil, cookie).Body.String()
if !strings.Contains(body, `<option value="cf"`) {
t.Fatalf("the account is not offered:\n%s", body)
}
token = regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
dd := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "zone": {"example.org"}, "domains": {"home.example.org"},
"ipv4": {"on"}, "ssl": {"on"}, "interval": {"5min"}}
if rec := do(h, "POST", "/ddns", dd, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ := loadSettings(sys.settingsPath())
if f := s.ddns(); !f.Enabled || f.Provider != "cf" || f.Domains != "home.example.org" {
t.Fatalf("not saved: %+v", f)
}
// ... and then it cannot be removed.
form = url.Values{"csrf": {token}, "providers": {"1"}, "prov.0.name": {"cf"}, "prov.0.type": {"cloudflare"}, "prov.0.remove": {"on"}}
if rec := do(h, "POST", "/dnsproviders", form, cookie); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "used by Dynamic DNS") {
t.Fatalf("remove: %d\n%s", rec.Code, rec.Body)
}
}
func TestEnvironmentProviders(t *testing.T) {
s := mustParse(t)
dir := t.TempDir()
rows := []DNSProvider{{Name: "aws", Type: "route53", Env: "AWS_ACCESS_KEY_ID=a\r\nAWS_SECRET_ACCESS_KEY=b\n", New: true}}
writes, _, err := s.applyDNSProviders(rows, dir)
if err != nil || len(writes) != 1 || writes[0].Path != filepath.Join(dir, "aws.env") || writes[0].Secret != "AWS_ACCESS_KEY_ID=a\nAWS_SECRET_ACCESS_KEY=b\n" {
t.Fatalf("writes=%+v err=%v", writes, err)
}
out := string(mustMarshal(t, s))
if !strings.Contains(out, `"environmentFile"`) || strings.Contains(out, "secretFile") || strings.Contains(out, "AWS_SECRET") {
t.Errorf("settings:\n%s", out)
}
// Certificates accept it; dynamic DNS does not know route53.
good := ACMEForm{Enabled: true, Provider: "aws", Domains: "r.example.org", Email: "a@b.c", Agree: true}
if err := s.applyACME(good); err != nil {
t.Errorf("certificate through route53: %v", err)
}
if err := s.applyDDNS(DDNSForm{Enabled: true, Provider: "aws", Domains: "a.example.org", IPv4: true}); err == nil || err.(FormErrors)["provider"] == "" {
t.Errorf("dynamic DNS through route53 accepted: %v", err)
}
// A single-token provider needs the token, not variables.
cf := withProvider(mustParse(t), "cf", "cloudflare")
delete(cf.dnsProviderMap()["cf"].(map[string]any), "secretFile")
if err := cf.applyACME(ACMEForm{Enabled: true, Provider: "cf", Domains: "r.example.org", Email: "a@b.c", Agree: true}); err == nil {
t.Error("cloudflare without a token accepted")
}
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "route53", Env: "not variables", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].env"] == "" {
t.Errorf("bad variables accepted: %v", err)
}
if _, _, err := mustParse(t).applyDNSProviders([]DNSProvider{{Name: "x", Type: "nonesuch", Secret: "s", New: true}}, dir); err == nil || err.(FormErrors)["prov[0].type"] == "" {
t.Errorf("an unknown provider accepted: %v", err)
}
}
+331
View File
@@ -0,0 +1,331 @@
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.")
}
+85 -3
View File
@@ -78,8 +78,10 @@ func main() {
return
}
listen := flag.String("listen", ":443", "address to serve HTTPS on")
httpListen := flag.String("http", "", "address that redirects HTTP to HTTPS (empty: none)")
configDir := flag.String("config", "/var/lib/router/config", "configuration directory (settings.json)")
stateDir := flag.String("state", "/var/lib/router", "state directory (secrets, UI state)")
acmeDir := flag.String("acme-dir", "", "directory of a certificate from an ACME client (fullchain.pem, key.pem), served when present")
leases := flag.String("leases", "/var/lib/kea/dhcp4.leases", "Kea's DHCPv4 lease file")
dev := flag.String("assets", "", "serve templates/ and static/ from this directory, re-read on every request (development)")
flag.Parse()
@@ -87,7 +89,7 @@ func main() {
assets = os.DirFS(*dev)
}
sys := System{ConfigDir: *configDir, StateDir: *stateDir}
sys := System{ConfigDir: *configDir, StateDir: *stateDir, ACMEDir: *acmeDir}
// What was installed is confirmed; later router-confirm keeps this copy.
if _, err := os.Stat(sys.confirmedPath()); errors.Is(err, fs.ErrNotExist) {
if data, err := os.ReadFile(sys.settingsPath()); err == nil {
@@ -105,7 +107,7 @@ func main() {
srv := &http.Server{
Addr: *listen,
Handler: newApp(sys, metrics, *dev != "").routes(),
TLSConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
TLSConfig: &tls.Config{GetCertificate: (&certSource{fallback: cert, dir: *acmeDir}).get, MinVersion: tls.VersionTLS12},
ReadHeaderTimeout: 10 * time.Second,
}
@@ -123,6 +125,18 @@ func main() {
defer cancel()
srv.Shutdown(shutdown)
}()
if *httpListen != "" {
plain := &http.Server{Addr: *httpListen, Handler: redirectToHTTPS(*listen), ReadHeaderTimeout: 10 * time.Second}
go func() {
if err := plain.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Printf("http redirect: %v", err)
}
}()
go func() {
<-ctx.Done()
plain.Close()
}()
}
log.Printf("listening on %s", *listen)
if err := srv.ListenAndServeTLS("", ""); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
@@ -243,7 +257,7 @@ func newApp(sys System, metrics *Collector, dev bool) *app {
func (a *app) parseTemplates() (map[string]*template.Template, error) {
files := map[string][]string{"overview": {"templates/dashboard.html"}}
pages := map[string]*template.Template{}
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "advanced", "update"} {
for _, page := range []string{"login", "overview", "network", "dhcp", "dns", "wireguard", "firewall", "ddns", "dnsproviders", "certificate", "advanced", "update"} {
t, err := template.New("layout.html").Funcs(a.funcs).
ParseFS(assets, append([]string{"templates/layout.html", "templates/" + page + ".html"}, files[page]...)...)
if err != nil {
@@ -280,6 +294,8 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /apply", a.auth(a.apply))
mux.HandleFunc("POST /confirm", a.auth(a.confirm))
mux.HandleFunc("POST /discard", a.auth(a.discard))
mux.HandleFunc("POST /reboot", a.auth(a.reboot))
mux.HandleFunc("POST /password", a.auth(a.password))
mux.HandleFunc("GET /network", a.auth(a.networkPage))
mux.HandleFunc("POST /network", a.auth(a.network))
mux.HandleFunc("GET /advanced", a.auth(a.advancedPage))
@@ -294,6 +310,10 @@ func (a *app) routes() http.Handler {
mux.HandleFunc("POST /wireguard/import", a.auth(a.wireguardImport))
mux.HandleFunc("GET /ddns", a.auth(a.ddnsPage))
mux.HandleFunc("POST /ddns", a.auth(a.ddnsSave))
mux.HandleFunc("GET /dnsproviders", a.auth(a.providersPage))
mux.HandleFunc("POST /dnsproviders", a.auth(a.providersSave))
mux.HandleFunc("GET /certificate", a.auth(a.certificatePage))
mux.HandleFunc("POST /certificate", a.auth(a.certificateSave))
mux.HandleFunc("GET /firewall", a.auth(a.firewallPage))
mux.HandleFunc("POST /firewall", a.auth(a.firewall))
mux.HandleFunc("GET /dhcp", a.auth(a.dhcpPage))
@@ -370,6 +390,25 @@ func (a *app) auth(h func(http.ResponseWriter, *http.Request, session)) http.Han
// form posts), then Origin; requests with neither (curl) pass.
var crossOrigin = http.NewCrossOriginProtection()
// redirectToHTTPS sends plain HTTP requests to the same host and path over
// HTTPS; httpsAddr is where the HTTPS server listens.
func redirectToHTTPS(httpsAddr string) http.Handler {
_, port, _ := net.SplitHostPort(httpsAddr)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if strings.Contains(host, ":") { // an IPv6 literal
host = "[" + strings.Trim(host, "[]") + "]"
}
if port != "" && port != "443" {
host += ":" + port
}
http.Redirect(w, r, "https://"+host+r.URL.RequestURI(), http.StatusPermanentRedirect)
})
}
func sameOrigin(r *http.Request) bool {
return crossOrigin.Check(r) == nil
}
@@ -465,6 +504,49 @@ func (a *app) discard(w http.ResponseWriter, r *http.Request, _ session) {
back(w, r, "Changes discarded; the settings match the confirmed system again.")
}
func (a *app) reboot(w http.ResponseWriter, r *http.Request, _ session) {
if err := a.sys.Reboot(); err != nil {
back(w, r, "Could not reboot: "+err.Error())
return
}
back(w, r, "Rebooting in a few seconds. This page works again when the router is back.")
}
// password changes the web UI's password and root's, which are one.
func (a *app) password(w http.ResponseWriter, r *http.Request, _ session) {
a.loginMu.Lock()
defer a.loginMu.Unlock()
stored, err := os.ReadFile(a.sys.passwordPath())
ok := false
if err == nil {
ok, _ = checkPassword(string(stored), r.PostFormValue("current"))
}
next := r.PostFormValue("new")
switch {
case !ok:
time.Sleep(time.Second)
back(w, r, "The current password is wrong.")
case len(next) < 8:
back(w, r, "The new password must have at least 8 characters.")
case next != r.PostFormValue("confirm"):
back(w, r, "The new password and its confirmation differ.")
default:
if err := setRootPassword(a.sys, next); err != nil {
back(w, r, "Could not change the password: "+err.Error())
return
}
hash, err := hashPassword(next)
if err == nil {
err = writeFileAtomic(a.sys.passwordPath(), []byte(hash+"\n"), 0o600)
}
if err != nil {
back(w, r, "Root's password changed, the web UI's did not: "+err.Error())
return
}
back(w, r, "Password changed for the web UI and for root.")
}
}
type networkData struct {
Form Form
Errors FormErrors
+143
View File
@@ -9,6 +9,7 @@ import (
"regexp"
"strings"
"testing"
"time"
)
func newTestApp(t *testing.T) (*app, System) {
@@ -109,3 +110,145 @@ func TestLoginAndSave(t *testing.T) {
t.Fatalf("cross-origin POST: %d", rec.Code)
}
}
func TestAdvancedPageHasRebootButton(t *testing.T) {
a, _ := newTestApp(t)
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
if body := do(h, "GET", "/advanced", nil, cookie).Body.String(); !strings.Contains(body, `action="/reboot"`) {
t.Fatalf("no reboot button:\n%s", body)
}
}
func TestPasswordChange(t *testing.T) {
a, sys := newTestApp(t)
var root string
setRootPassword = func(_ System, pw string) error { root = pw; return nil }
t.Cleanup(func() { setRootPassword = (func(System, string) error)(nil) })
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/advanced", nil, cookie).Body.String()
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
try := func(cur, next, confirm string) string {
rec := do(h, "POST", "/password", url.Values{"csrf": {token}, "current": {cur}, "new": {next}, "confirm": {confirm}}, cookie)
return rec.Header().Get("Location")
}
for name, loc := range map[string]string{
"wrong": try("nope", "newpassword1", "newpassword1"),
"short": try("hunter2hunter2", "short", "short"),
"mismatch": try("hunter2hunter2", "newpassword1", "newpassword2"),
} {
if root != "" || !strings.Contains(loc, "msg=") {
t.Fatalf("%s: root=%q location=%q", name, root, loc)
}
}
if stored, _ := os.ReadFile(sys.passwordPath()); func() bool { ok, _ := checkPassword(string(stored), "hunter2hunter2"); return !ok }() {
t.Fatal("a refused change altered the password")
}
if loc := try("hunter2hunter2", "newpassword1", "newpassword1"); !strings.Contains(loc, "Password+changed") {
t.Fatalf("location %q", loc)
}
stored, _ := os.ReadFile(sys.passwordPath())
if ok, _ := checkPassword(string(stored), "newpassword1"); !ok || root != "newpassword1" {
t.Errorf("web UI password not changed or root=%q", root)
}
}
func TestRedirectToHTTPS(t *testing.T) {
for _, c := range []struct{ addr, host, want string }{
{":443", "192.168.10.1", "https://192.168.10.1/login?x=1"},
{":443", "router.home:80", "https://router.home/login?x=1"},
{":8443", "router.home", "https://router.home:8443/login?x=1"},
{":443", "[fd00::1]:80", "https://[fd00::1]/login?x=1"},
} {
req := httptest.NewRequest("GET", "http://"+c.host+"/login?x=1", nil)
rec := httptest.NewRecorder()
redirectToHTTPS(c.addr).ServeHTTP(rec, req)
if rec.Code != http.StatusPermanentRedirect || rec.Header().Get("Location") != c.want {
t.Errorf("%s %s: %d %q, want %q", c.addr, c.host, rec.Code, rec.Header().Get("Location"), c.want)
}
}
}
func TestACMEIsChecked(t *testing.T) {
good := ACMEForm{Enabled: true, Provider: "cf", Domains: "router.example.org\n*.home.example.org", Email: "me@example.org", Staging: true, Agree: true}
mk := func() Settings {
return withProvider(withProvider(mustParse(t), "cf", "cloudflare"), "dyn", "dyndns2")
}
if err := mk().applyACME(good); err != nil {
t.Fatal(err)
}
for name, mod := range map[string]func(*ACMEForm){
"provider": func(f *ACMEForm) { f.Provider = "dyn" }, // cannot do DNS-01
"domains": func(f *ACMEForm) { f.Domains = "" },
"email": func(f *ACMEForm) { f.Email = "" },
"agree": func(f *ACMEForm) { f.Agree = false },
} {
f := good
mod(&f)
if err := mk().applyACME(f); err == nil || err.(FormErrors)[name] == "" {
t.Errorf("%s accepted: %v", name, err)
}
}
if err := mk().applyACME(ACMEForm{}); err != nil {
t.Errorf("disabled and empty: %v", err)
}
}
func TestCertificatePage(t *testing.T) {
a, sys := newTestApp(t)
s, _ := loadSettings(sys.settingsPath())
withProvider(s, "cf", "cloudflare")
if err := s.save(sys.settingsPath()); err != nil {
t.Fatal(err)
}
h := a.routes()
cookie := do(h, "POST", "/login", url.Values{"password": {"hunter2hunter2"}}, nil).Result().Cookies()[0]
body := do(h, "GET", "/certificate", nil, cookie).Body.String()
if !strings.Contains(body, `<option value="cf"`) {
t.Fatalf("the account is not offered:\n%s", body)
}
token := regexp.MustCompile(`name="csrf" value="([^"]+)"`).FindStringSubmatch(body)[1]
form := url.Values{"csrf": {token}, "enabled": {"on"}, "provider": {"cf"}, "domains": {"router.example.org"}, "email": {"a@example.org"}}
if rec := do(h, "POST", "/certificate", form, cookie); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("without the agreement: %d", rec.Code)
}
form.Set("agree", "on")
if rec := do(h, "POST", "/certificate", form, cookie); rec.Code != http.StatusSeeOther {
t.Fatalf("save: %d\n%s", rec.Code, rec.Body)
}
s, _ = loadSettings(sys.settingsPath())
if f := s.acme(); !f.Enabled || f.Provider != "cf" || !f.Agree || f.Staging {
t.Fatalf("not saved: %+v", f)
}
}
func TestCertSource(t *testing.T) {
self, err := loadOrCreateCert(t.TempDir())
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
c := &certSource{fallback: self, dir: dir}
got, _ := c.get(nil)
if got != &c.fallback {
t.Fatal("served something else without files")
}
other := t.TempDir()
if _, err := loadOrCreateCert(other); err != nil {
t.Fatal(err)
}
for from, to := range map[string]string{"cert.pem": "fullchain.pem", "key.pem": "key.pem"} {
data, _ := os.ReadFile(filepath.Join(other, from))
os.WriteFile(filepath.Join(dir, to), data, 0o600)
}
if got, _ = c.get(nil); got == &c.fallback || string(got.Certificate[0]) == string(self.Certificate[0]) {
t.Fatal("did not pick up the certificate")
}
os.WriteFile(filepath.Join(dir, "key.pem"), []byte("junk"), 0o600)
future := time.Now().Add(time.Hour)
os.Chtimes(filepath.Join(dir, "key.pem"), future, future)
if got, _ = c.get(nil); got != &c.fallback {
t.Fatal("served a broken pair")
}
}
+1
View File
@@ -7,6 +7,7 @@ buildGo127Module {
fileset = lib.fileset.unions [
./go.mod
(lib.fileset.fileFilter (f: f.hasExt "go") ./.)
./providers.json
./templates
./static
];
+272
View File
@@ -0,0 +1,272 @@
{
"ddns": [
"1984",
"changeip",
"cloudflare",
"cloudns",
"ddns.fm",
"digitalocean",
"dinahosting",
"directnic",
"dnsexit2",
"dnsmadeeasy",
"domeneshop",
"dondominio",
"dslreports1",
"duckdns",
"dyndns1",
"dyndns2",
"easydns",
"enom",
"freedns",
"freemyip",
"gandi",
"godaddy",
"he.net",
"hetzner",
"infomaniak",
"inwx",
"keysystems",
"mythicdyn",
"namecheap",
"nfsn",
"njalla",
"noip",
"ovh",
"porkbun",
"regfishde",
"sitelutions",
"yandex",
"zoneedit1"
],
"acme": [
"abion",
"acmedns",
"active24",
"alidns",
"aliesa",
"allinkl",
"alwaysdata",
"anexia",
"artfiles",
"arvancloud",
"auroradns",
"autodns",
"axelname",
"azion",
"azuredns",
"baiducloud",
"beget",
"binarylane",
"bindman",
"bluecat",
"bluecatv2",
"bookmyname",
"bunny",
"checkdomain",
"civo",
"clouddns",
"cloudflare",
"cloudns",
"cloudru",
"com35",
"connbyte",
"conoha",
"conohav3",
"constellix",
"corenetworks",
"cpanel",
"curanet",
"czechia",
"dandomain",
"ddnss",
"derak",
"desec",
"designate",
"digitalocean",
"dinahosting",
"directadmin",
"dns51",
"dnscale",
"dnsexit",
"dnshomede",
"dnsimple",
"dnsla",
"dnsmadeeasy",
"dnsmint",
"dnsservices",
"dnsupdate",
"dode",
"domeneshop",
"dreamhost",
"duckdns",
"dyn",
"dynadot",
"dyndnsfree",
"dynu",
"easydns",
"edgecenter",
"edgedns",
"edgeone",
"efficientip",
"epik",
"eurodns",
"euserv",
"excedo",
"exoscale",
"f5xc",
"feno",
"fornex",
"freemyip",
"gandi",
"gandiv5",
"gcloud",
"gcore",
"gehirn",
"gigahostno",
"glesys",
"gname",
"godaddy",
"gravity",
"hetzner",
"hostingde",
"hostinger",
"hostingnl",
"hosttech",
"hostup",
"httpnet",
"httpreq",
"huaweicloud",
"hurricane",
"hyperone",
"ibmcloud",
"iijdpf",
"infoblox",
"infomaniak",
"internetbs",
"inwx",
"ionos",
"ionoscloud",
"ipv64",
"ispconfig",
"ispconfigddns",
"jdcloud",
"joker",
"katapult",
"keyhelp",
"leaseweb",
"liara",
"lightsail",
"limacity",
"linode",
"liquidweb",
"loopia",
"luadns",
"mailinabox",
"manageengine",
"metaname",
"metaregistrar",
"mijnhost",
"mittwald",
"myaddr",
"mydnsjp",
"myra",
"mythicbeasts",
"namecheap",
"namedotcom",
"namesilo",
"namesurfer",
"nearlyfreespeech",
"nederhost",
"neodigit",
"netcup",
"netlify",
"netnod",
"nexdns",
"ngenix",
"nicmanager",
"nicru",
"nifcloud",
"njalla",
"nodion",
"ns1",
"octenium",
"omglol",
"onecloudru",
"onlinenet",
"openprovider",
"opusdns",
"oraclecloud",
"otc",
"ovh",
"pdns",
"plesk",
"pointdns",
"porkbun",
"poweradmin",
"rackspace",
"rage4",
"rainyun",
"rcodezero",
"regfish",
"regru",
"rimuhosting",
"route53",
"safedns",
"sakuracloud",
"scaleway",
"scannet",
"selectel",
"selectelv2",
"selfhostde",
"servercow",
"shellrent",
"simply",
"sonic",
"spaceship",
"stackpath",
"syse",
"technitium",
"tele3",
"tencentcloud",
"timewebcloud",
"todaynic",
"transip",
"ucloud",
"ultradns",
"uniteddomains",
"variomedia",
"veesp",
"vegadns",
"vercel",
"versio",
"vinyldns",
"virtualname",
"vkcloud",
"volcengine",
"vscale",
"vultr",
"wannafind",
"webglobe",
"webnamesca",
"webnamesru",
"websupport",
"wedos",
"westcn",
"xinnet",
"yandex",
"yandex360",
"yandexcloud",
"zilore",
"zoneedit",
"zoneee",
"zonomi"
],
"acmeToken": {
"cloudflare": "CF_DNS_API_TOKEN_FILE",
"duckdns": "DUCKDNS_TOKEN_FILE",
"hetzner": "HETZNER_API_KEY_FILE",
"digitalocean": "DO_AUTH_TOKEN_FILE",
"desec": "DESEC_TOKEN_FILE"
}
}
+4 -1
View File
@@ -55,11 +55,14 @@ func TestReadSensors(t *testing.T) {
func TestPendingKernel(t *testing.T) {
img := "/nix/store/abc123-linux-6.12.5/bzImage"
for running, want := range map[string]string{"6.12.5": "", "6.12.5-zen1": "", "6.12.4": "6.12.5", "": ""} {
for running, want := range map[string]string{"6.12.5": "", "6.12.5-zen1": "", "6.12.5.0": "", "6.12.4": "6.12.5", "": ""} {
if got := pendingKernel(running, img); got != want {
t.Errorf("running %q: %q, want %q", running, got, want)
}
}
if got := pendingKernel("7.3.0", "/nix/store/abc-linux-7.3/bzImage"); got != "" {
t.Errorf("7.3.0 is 7.3: %q", got)
}
if got := pendingKernel("6.12.4", "/boot/vmlinuz"); got != "" {
t.Errorf("an unknown path gave %q", got)
}
+13
View File
@@ -0,0 +1,13 @@
// Menus close on a click elsewhere; forms with data-confirm ask first.
// Close an open menu when the click is somewhere else.
document.addEventListener("click", function (e) {
document.querySelectorAll("nav details[open]").forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute("open");
});
});
// Ask before a form marked data-confirm is sent.
document.addEventListener("submit", function (e) {
var q = e.target.getAttribute("data-confirm");
if (q && !confirm(q)) e.preventDefault();
});
+6
View File
@@ -7,6 +7,12 @@ body { margin: 0; font: 15px/1.5 system-ui, sans-serif; color: var(--fg); backgr
header { display: flex; gap: 1.5rem; align-items: center; padding: .75rem 1rem; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
nav { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
nav form { display: inline; }
nav details { position: relative; }
nav summary { cursor: pointer; color: var(--accent); list-style: none; }
nav summary::-webkit-details-marker { display: none; }
nav summary::after { content: " \25BE"; }
nav details > div { position: absolute; z-index: 10; top: 1.6rem; left: -.75rem; min-width: 11rem; display: flex; flex-direction: column; gap: .35rem;
padding: .6rem .75rem; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, .15); }
a { color: var(--accent); }
main { max-width: 72rem; margin: 0 auto; padding: 1rem; }
h1 { font-size: 1.4rem; margin: .5rem 0 1rem; }
+35 -5
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -22,6 +23,7 @@ type System struct {
ReleaseFile string
// LeasesDNSDir is where leases-dns leaves its status; /run/router-leases-dns if empty.
LeasesDNSDir string
ACMEDir string // where the ACME client keeps the web UI's certificate (fullchain.pem, key.pem), if any
}
func (s System) leasesDNSDir() string {
@@ -31,11 +33,10 @@ func (s System) leasesDNSDir() string {
return "/run/router-leases-dns"
}
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") }
func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") }
func (s System) ddnsSecretPath() string { return filepath.Join(s.StateDir, "ddns-secret") }
func (s System) settingsPath() string { return filepath.Join(s.ConfigDir, "settings.json") }
func (s System) confirmedPath() string { return filepath.Join(s.StateDir, "settings.confirmed.json") }
func (s System) passwordPath() string { return filepath.Join(s.StateDir, "webui-password") }
func (s System) pppoePath() string { return filepath.Join(s.StateDir, "pppoe-password") }
func command(name string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -122,6 +123,35 @@ func (s System) Apply() error {
return err
}
// Reboot restarts the router a few seconds from now, so the page that asked
// for it is still answered.
func (s System) Reboot() error {
_, err := command("systemd-run", "--quiet", "--on-active=3s", "systemctl", "reboot")
return err
}
func (s System) rootPasswordPath() string { return filepath.Join(s.StateDir, "root-password") }
// setRootPassword is replaced in tests.
var setRootPassword = func(s System, password string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "mkpasswd", "-s")
cmd.Stdin = strings.NewReader(password + "\n")
hash, err := cmd.Output()
if err != nil || !strings.HasPrefix(string(hash), "$") {
return fmt.Errorf("hashing the password for root: %v", err)
}
if err := writeFileAtomic(s.rootPasswordPath(), hash, 0o600); err != nil {
return err
}
// A unit does the part that needs more than the web UI may touch: /etc/shadow.
if out, err := command("systemctl", "start", "router-root-password.service"); err != nil {
return fmt.Errorf("setting root's password: %v %s", err, out)
}
return nil
}
func (s System) Confirm() error {
_, err := command("systemctl", "start", "--no-block", "router-confirm.service")
return err
+18
View File
@@ -21,6 +21,24 @@
</form>
</section>
<section>
<h2>Password</h2>
<p>One password for this web UI and for root on the console and over SSH.</p>
<form method="post" action="/password" class="narrow">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Current password <input type="password" name="current" autocomplete="current-password" required></label>
<label>New password (at least 8 characters) <input type="password" name="new" autocomplete="new-password" minlength="8" required></label>
<label>New password again <input type="password" name="confirm" autocomplete="new-password" minlength="8" required></label>
<button>Change password</button>
</form>
</section>
<section>
<h2>Reboot</h2>
<p>Restarts the router; routing stops for a minute or two. A new kernel from an update only runs after this.</p>
<form method="post" action="/reboot" data-confirm="Reboot the router now?"><input type="hidden" name="csrf" value="{{.CSRF}}"><button>Reboot</button></form>
</section>
<section>
<h2>Backup</h2>
<p>Everything on <code>/var/lib/router</code>: settings, PPPoE password, WireGuard keys, passwords, the web UI's
@@ -0,0 +1,45 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}{{$f := .Form}}
<section>
<h2>Serving now</h2>
{{if .Has}}
<dl>
<dt>Certificate for</dt><dd>{{.Cert.Subject}}</dd>
<dt>Issued by</dt><dd>{{.Cert.Issuer}}{{if .Cert.SelfSigned}} <span class="bad">self-signed: browsers warn</span>{{end}}</dd>
<dt>Valid until</dt><dd>{{.Cert.NotAfter.Format "2006-01-02"}}</dd>
<dt>SHA-256</dt><dd><code>{{.Cert.Fingerprint}}</code></dd>
</dl>
{{else}}<p>No certificate yet; the web UI creates a self-signed one when it starts.</p>{{end}}
</section>
<form method="post" action="/certificate">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset>
<legend>Let's Encrypt</legend>
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Get a certificate for the web UI and renew it</label>
<div>
<label>DNS account <select name="provider"><option value=""></option>
{{range .Providers}}<option value="{{.Name}}" {{if eq .Name $f.Provider}}selected{{end}}>{{.Name}} ({{.Type}})</option>{{end}}
</select></label>
<label>Contact e-mail <input name="email" value="{{$f.Email}}" size="26" spellcheck="false" placeholder="you@example.org"></label>
</div>
{{range $k := list "provider" "email"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
<p class="hint">The account must be able to change the DNS records of the names: Let's Encrypt is shown it by a TXT record, so no port
needs to be open to the internet. Accounts are kept on the <a href="/dnsproviders">DNS providers</a> page (cloudflare, desec,
digitalocean, duckdns and hetzner work here).{{if not .Providers}} <strong>There is none yet.</strong>{{end}}</p>
<label>Names, one per line; the first is the certificate's name <textarea name="domains" rows="4" cols="40" spellcheck="false">{{$f.Domains}}</textarea></label>
{{with index $e "domains"}}<p class="field-error">{{.}}</p>{{end}}
<p class="hint">A wildcard like <code>*.home.example.org</code> works. The names should lead to the router (see DNS) so the browser reaches it by name.</p>
<label class="inline"><input type="checkbox" name="staging" {{if $f.Staging}}checked{{end}}> Test first with the staging service (its certificates are not trusted by browsers); uncheck for the real one</label>
<label class="inline"><input type="checkbox" name="agree" {{if $f.Agree}}checked{{end}}> I accept the <a href="https://letsencrypt.org/repository/">Let's Encrypt subscriber agreement</a></label>
{{with index $e "agree"}}<p class="field-error">{{.}}</p>{{end}}
</fieldset>
<button>Save</button>
</form>
<section>
<h2>Ordering</h2>
{{if .Status}}<p class="muted">{{.Status}}</p>{{if .Log}}<pre class="log">{{.Log}}</pre>{{end}}
{{else}}<p class="muted">Nothing yet. After you save and apply, the router orders the certificate (a minute or two); renewals follow 30 days before it expires and need no restart.</p>{{end}}
</section>
{{end}}
{{end}}
+1 -1
View File
@@ -33,7 +33,7 @@
<h2>System</h2>
<dl>
<dt>Uptime</dt><dd>{{age $sys.Uptime}}</dd>
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span>{{end}}</dd>{{end}}
{{if $sys.Kernel}}<dt>Kernel</dt><dd>{{$sys.Kernel}}{{if $sys.KernelNext}} <span class="bad">reboot to run {{$sys.KernelNext}}</span> <a href="/advanced">Reboot</a>{{end}}</dd>{{end}}
<dt>Load</dt><dd>{{$sys.Load}}</dd>
<dt>CPU</dt><dd>{{value $sys.CPU "%"}}</dd>
{{if $sys.MemTotal}}<dt>Memory</dt><dd>{{bytes $sys.MemUsed}} of {{bytes $sys.MemTotal}}</dd>{{end}}
+6 -12
View File
@@ -10,20 +10,14 @@
<legend>Provider</legend>
<label class="inline"><input type="checkbox" name="enabled" {{if $f.Enabled}}checked{{end}}> Keep these names up to date</label>
<div>
<label>Provider (protocol) <input name="protocol" value="{{$f.Protocol}}" size="16" list="protocols" spellcheck="false"></label>
<datalist id="protocols"><option value="cloudflare"><option value="duckdns"><option value="dyndns2"><option value="namecheap"><option value="porkbun"><option value="hetzner"><option value="godaddy"><option value="digitalocean"></datalist>
<label>Server <input name="server" value="{{$f.Server}}" size="26" spellcheck="false" placeholder="only for dyndns2"></label>
<label>Account <select name="provider"><option value=""></option>
{{range .Providers}}<option value="{{.Name}}" {{if eq .Name $f.Provider}}selected{{end}}>{{.Name}} ({{.Type}})</option>{{end}}
</select></label>
<label>Zone <input name="zone" value="{{$f.Zone}}" size="22" spellcheck="false" placeholder="example.org (Cloudflare)"></label>
</div>
{{range $k := list "protocol" "server" "zone"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
<div>
<label>Login <input name="username" value="{{$f.Username}}" size="18" spellcheck="false" autocomplete="off"></label>
<label>Password or API token <input type="password" name="secret" size="30" autocomplete="new-password"
placeholder="{{if $f.SecretSet}}stored; leave empty to keep{{else}}required{{end}}"></label>
</div>
{{with index $e "secret"}}<p class="field-error">{{.}}</p>{{end}}
<p class="hint">Login is <code>token</code> for a Cloudflare API token (permission Zone → DNS → Edit). The secret is kept on the router
in a private file, not in the settings.</p>
{{range $k := list "provider" "zone"}}{{with index $e $k}}<p class="field-error">{{$k}}: {{.}}</p>{{end}}{{end}}
<p class="hint">The account (login and token) is kept on the <a href="/dnsproviders">DNS providers</a> page, where certificates use it too.
{{if not .Providers}}<strong>There is none yet.</strong>{{end}}</p>
</fieldset>
<fieldset>
<legend>Names</legend>
@@ -0,0 +1,36 @@
{{define "content"}}
{{with .Data}}
{{$e := .Errors}}
<p class="hint">Accounts at DNS providers, kept once and used by <a href="/ddns">Dynamic DNS</a> (the providers ddclient knows) and by the
<a href="/certificate">certificate</a> (the providers lego knows). Secrets stay on the router in private files, not in the settings,
and are never shown again.<br>
<strong>Password or API token</strong>: for dynamic DNS, and for certificates through cloudflare, desec, digitalocean, duckdns and hetzner.<br>
<strong>Certificate variables</strong>: for certificates through any other provider, the variables lego asks for, one
<code>VARIABLE=value</code> per line (see <a href="https://go-acme.github.io/lego/dns/">the list of providers</a>), for example
<code>AWS_ACCESS_KEY_ID=…</code> and <code>AWS_SECRET_ACCESS_KEY=…</code> for route53.</p>
<form method="post" action="/dnsproviders">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="providers" value="{{len .Rows}}">
<div class="scroll">
<table>
<tr><th>Name</th><th>Provider</th><th>Login</th><th>Server</th><th>Password or API token</th><th>Certificate variables</th><th>Remove</th></tr>
{{range $i, $r := .Rows}}
<tr>
<td>{{if $r.New}}<input name="prov.{{$i}}.name" size="14" spellcheck="false" placeholder="cloudflare"><input type="hidden" name="prov.{{$i}}.new" value="1">
{{else}}{{$r.Name}}<input type="hidden" name="prov.{{$i}}.name" value="{{$r.Name}}">{{end}}</td>
<td><select name="prov.{{$i}}.type">{{range $r.Options}}<option value="{{.Name}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>{{end}}</select></td>
<td><input name="prov.{{$i}}.username" value="{{$r.Username}}" size="12" spellcheck="false" autocomplete="off"></td>
<td><input name="prov.{{$i}}.server" value="{{$r.Server}}" size="18" spellcheck="false" placeholder="only dyndns2"></td>
<td><input type="password" name="prov.{{$i}}.secret" size="24" autocomplete="new-password" placeholder="{{if $r.SecretSet}}stored; leave empty to keep{{else if $r.New}}required{{else}}missing{{end}}"></td>
<td><textarea name="prov.{{$i}}.env" rows="2" cols="26" spellcheck="false" autocomplete="off" placeholder="{{if $r.EnvSet}}stored; leave empty to keep{{else}}VARIABLE=value, one per line{{end}}"></textarea></td>
<td>{{if not $r.New}}<input type="checkbox" name="prov.{{$i}}.remove">{{end}}</td>
</tr>
{{range $f := list "name" "type" "username" "server" "secret" "env"}}{{with index $e (printf "prov[%d].%s" $i $f)}}<tr><td colspan="7" class="field-error">{{$f}}: {{.}}</td></tr>{{end}}{{end}}
{{end}}
</table>
</div>
<p class="hint">Login is <code>token</code> for a Cloudflare API token (permission Zone → DNS → Edit). Fill in the last row to add an account.</p>
<button>Save</button>
</form>
{{end}}
{{end}}
+17 -8
View File
@@ -13,14 +13,22 @@
{{if .LoggedIn}}
<nav>
<a href="/">Overview</a>
<a href="/network">Network</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
<a href="/ddns">Dynamic DNS</a>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
<details name="menu"><summary>Network</summary><div>
<a href="/network">Interfaces</a>
<a href="/dhcp">DHCP</a>
<a href="/dns">DNS</a>
<a href="/wireguard">WireGuard</a>
<a href="/firewall">Firewall</a>
</div></details>
<details name="menu"><summary>Services</summary><div>
<a href="/ddns">Dynamic DNS</a>
<a href="/dnsproviders">DNS providers</a>
<a href="/certificate">Certificate</a>
</div></details>
<details name="menu"><summary>System</summary><div>
<a href="/update">Update</a>
<a href="/advanced">Advanced</a>
</div></details>
<form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="link">Log out</button></form>
</nav>
{{end}}
@@ -31,5 +39,6 @@
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{template "content" .}}
</main>
<script src="/static/menu.js"></script>
</body>
</html>