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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user