diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ee026..a89f67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ publish a tag that has no section here. `rollcall controllers` and the web UI. - Addresses on secondary subnets with a gateway get their own routing table and a source rule in `network-config`, so replies from a storage or tenant VLAN leave through that VLAN's gateway. +- With `admin_http` other than `allow`, browsers that open the web UI over HTTP are redirected to + HTTPS (nodes keep HTTP for images and the agent). - `rollcall version`; CD-built binary carries `git describe`. - Release notes from `CHANGELOG.md`. diff --git a/cmd/rollcall/ca_test.go b/cmd/rollcall/ca_test.go index 7b4a100..44f12b5 100644 --- a/cmd/rollcall/ca_test.go +++ b/cmd/rollcall/ca_test.go @@ -165,3 +165,33 @@ func TestIntermediate(t *testing.T) { t.Error("path-length-0 root signed an intermediate") } } + +func TestUIRedirectsToHTTPS(t *testing.T) { + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + try := func(admin, listen, remote, host string) (int, string) { + a := &App{cfg: &Config{AdminHTTP: admin, TLSListen: listen}} + req := httptest.NewRequest("GET", "/index.html?x=1", nil) + req.RemoteAddr, req.Host = remote, host + rec := httptest.NewRecorder() + a.uiOverHTTPS(ok).ServeHTTP(rec, req) + return rec.Code, rec.Header().Get("Location") + } + if c, l := try("loopback", ":443", "10.0.0.5:5555", "10.0.0.234"); c != 308 || l != "https://10.0.0.234/index.html?x=1" { + t.Errorf("remote over http: %d %s", c, l) + } + if c, l := try("deny", ":8443", "10.0.0.5:5555", "pxe.example:80"); c != 308 || l != "https://pxe.example:8443/index.html?x=1" { + t.Errorf("other port: %d %s", c, l) + } + if c, l := try("loopback", ":443", "[fd00::5]:5555", "[fd00::1]"); c != 308 || l != "https://[fd00::1]/index.html?x=1" { + t.Errorf("ipv6: %d %s", c, l) + } + if c, _ := try("loopback", ":443", "127.0.0.1:5555", "localhost"); c != 200 { + t.Errorf("loopback client redirected: %d", c) + } + if c, _ := try("allow", ":443", "10.0.0.5:5555", "10.0.0.234"); c != 200 { + t.Errorf("allow redirected: %d", c) + } + if c, _ := try("loopback", "", "10.0.0.5:5555", "10.0.0.234"); c != 200 { + t.Errorf("no TLS listener but redirected: %d", c) + } +} diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index 8b15861..f482120 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -84,6 +84,36 @@ func (a *App) adminOverHTTP(r *http.Request) error { return fmt.Errorf("the admin API is HTTPS-only here: https://%s (trust the CA from /ca.pem)", net.JoinHostPort(strings.Trim(h, "[]"), port)) } +// httpsURL is the region's HTTPS address for the host the client used. +func (a *App) httpsURL(r *http.Request) string { + _, port, _ := net.SplitHostPort(a.cfg.TLSListen) + h := r.Host + if hh, _, err := net.SplitHostPort(h); err == nil { + h = hh + } + h = strings.Trim(h, "[]") + if port == "" || port == "443" { + if strings.Contains(h, ":") { + return "https://[" + h + "]" + } + return "https://" + h + } + return "https://" + net.JoinHostPort(h, port) +} + +// uiOverHTTPS sends browsers that reach the web UI over plain HTTP to HTTPS when +// the admin API is not allowed over HTTP anyway (the page could not work). +// Only the UI is redirected: nodes keep using HTTP for images and the agent. +func (a *App) uiOverHTTPS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil && a.cfg.AdminHTTP != "" && a.cfg.AdminHTTP != "allow" && a.cfg.TLSListen != "" && a.adminOverHTTP(r) != nil { + http.Redirect(w, r, a.httpsURL(r)+r.URL.RequestURI(), http.StatusPermanentRedirect) + return + } + next.ServeHTTP(w, r) + }) +} + func (a *App) adminAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := a.adminOverHTTP(r); err != nil { @@ -236,7 +266,7 @@ func (a *App) routes() http.Handler { mux.Handle("GET /os/", http.StripPrefix("/os/", http.FileServer(http.Dir(a.cfg.OSDir)))) // embedded web UI sub, _ := fs.Sub(webFS, "web") - mux.Handle("GET /", http.FileServer(http.FS(sub))) + mux.Handle("GET /", a.uiOverHTTPS(http.FileServer(http.FS(sub)))) return logging(mux) }