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
+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