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:
@@ -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.")
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ buildGo127Module {
|
||||
fileset = lib.fileset.unions [
|
||||
./go.mod
|
||||
(lib.fileset.fileFilter (f: f.hasExt "go") ./.)
|
||||
./providers.json
|
||||
./templates
|
||||
./static
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user