// Package server is the shell: routing, layout, settings. // // It knows nothing about any particular system. Navigation is assembled from // what the systems declare, and the settings form is generated from their // config schemas, so adding a system requires no change in this package. package server import ( "bytes" "context" "fmt" "html/template" "log/slog" "net/http" "os" "path/filepath" "strings" "sync" "sync/atomic" "time" "armdash/internal/auth" "armdash/internal/config" "armdash/internal/forwarded" "armdash/internal/links" "armdash/internal/system" "armdash/internal/version" "armdash/web" ) const ( prometheusURLKey = "core.prometheus_url" dataDirKey = "core.data_dir" trustedProxiesKey = "core.trusted_proxies" ) // themeCookie holds a light/dark override, or is absent to follow the browser. // // A cookie rather than localStorage because the page is server rendered: the // theme arrives as an attribute in the HTML itself, so there is no moment where // a dark page has already painted white and waits for a script to correct it. // localStorage is invisible to the server and would need a blocking script in // the head to avoid exactly that flash. const themeCookie = "armdash_theme" // theme returns "light", "dark", or "" to follow the browser. // // The value is whitelisted rather than passed through: it ends up in an // attribute, and a cookie is client-controlled input like any other. func theme(r *http.Request) string { c, err := r.Cookie(themeCookie) if err != nil { return "" } switch c.Value { case "light", "dark": return c.Value } return "" } type Server struct { cfg *config.Config log *slog.Logger tmpl *template.Template files []string sessions *auth.Sessions limiter *auth.Limiter xorigin *http.CrossOriginProtection trusted forwarded.Trusted // loginOff is AD_CORE_AUTH_MODE=none: whoever reaches the page is the // owner, because something in front of armdash already checked. loginOff bool // saveMu serialises saving, so two forms submitted at once cannot each // build a tree from half of the other's values. saveMu sync.Mutex cur atomic.Pointer[tree] } // tree is everything built from the current settings: the routes, the links // and a fresh instance of every system. A save builds a new one and swaps it // in; requests already running finish on the old one. Sessions, the login // limiter and the templates live on the Server, outside it, so a save logs // nobody out. type tree struct { *Server mux *http.ServeMux links []links.Link systems []system.System } func New(cfg *config.Config, log *slog.Logger, files []string) (*Server, error) { tmpl, err := template.New("").Funcs(system.FuncMap()). ParseFS(web.Templates, "templates/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } loginOff, err := authMode(cfg) if err != nil { return nil, err } if err := checkLogin(cfg, loginOff); err != nil { return nil, err } trusted, err := forwarded.Parse(cfg.Get(trustedProxiesKey)) if err != nil { return nil, fmt.Errorf("%s: %w", config.EnvName(trustedProxiesKey), err) } s := &Server{cfg: cfg, log: log, tmpl: tmpl, files: files, sessions: auth.NewSessions(auth.SessionTTL), limiter: auth.NewLimiter(maxFailures, failWindow, lockFor), xorigin: http.NewCrossOriginProtection(), trusted: trusted, loginOff: loginOff} if err := s.rebuild(); err != nil { return nil, err } switch { case loginOff: case s.setupMode(): log.Info("no login yet, open /settings to create one") case !s.hasLogin(): log.Info("no login and no data directory, editing is off") } return s, nil } // rebuild swaps in a tree built from the current settings, or keeps the old // one and reports why the new one could not be built. func (s *Server) rebuild() error { ls, err := links.Parse(s.cfg) if err != nil { return err } t := &tree{Server: s, mux: http.NewServeMux(), links: ls, systems: system.New()} t.routes() s.cur.Store(t) return nil } func (s *Server) tree() *tree { return s.cur.Load() } // setupExempt is what a visitor may still reach before the first login // exists: the setup form itself, its assets and the Prometheus scrape. func setupExempt(path string) bool { return path == "/settings" || path == "/metrics" || strings.HasPrefix(path, "/settings/") || strings.HasPrefix(path, "/static/") } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { if safeMethod(r.Method) && !setupExempt(r.URL.Path) && s.setupMode() { http.Redirect(w, r, "/settings", http.StatusFound) return } s.tree().mux.ServeHTTP(w, s.withSession(r)) } // PromURL is handed to systems so they always read the current value rather // than a copy taken at startup. func (s *Server) PromURL() string { return s.cfg.Get(prometheusURLKey) } func (t *tree) routes() { s := t.Server t.mux.Handle("GET /static/", http.FileServerFS(web.Static)) t.mux.HandleFunc("GET /{$}", t.handleRoot) t.mux.HandleFunc("GET /settings", t.handleSettings) t.mux.HandleFunc("GET /settings/status", t.handleStatus) type route struct { path string h http.HandlerFunc } login := []route{ {"POST /settings/setup", t.handleSetup}, {"POST /settings/login", t.handleChangeLogin}, {"POST /login", t.handleLogin}, {"POST /logout", t.handleLogout}, } // Registered as 404 rather than left out, which would answer 405 because // GET /login exists. if s.loginOff { for i := range login { login[i].h = http.NotFound } } for _, route := range append(login, route{"POST /settings/core", t.handleSaveCore}, route{"POST /settings/system/{id}", t.handleSaveSystem}, route{"POST /settings/links", t.handleSaveLinks}, ) { t.mux.Handle(route.path, s.xorigin.Handler(route.h)) } t.mux.HandleFunc("GET /login", t.handleLoginPage) t.mux.HandleFunc("GET /metrics", t.handleMetrics) t.mux.HandleFunc("GET /s/{system}/{$}", t.handleSystemDefault) t.mux.HandleFunc("GET /s/{system}/{slug}", t.handleSystemPage) t.mux.HandleFunc("GET /l/{link}/{rest...}", t.handleLink) // Every method, not only GET: saving a wiki page is a POST. for _, l := range t.links { if l.Mode == links.ModeProxy { t.mux.Handle(l.Prefix()+"/", l.Handler(s.log.With("link", l.ID), s.trusted)) } } // Each system gets its own subtree for fragments and JSON. dataDir := DataDir(s.cfg) for _, sys := range t.systems { prefix := "/s/" + sys.ID() + "/api/" deps := system.Deps{ Config: s.cfg.Scoped(sys.ID()), Log: s.log.With("system", sys.ID()), PromURL: s.PromURL, Save: s.saveScoped("system." + sys.ID() + "."), } if dataDir != "" { deps.DataDir = filepath.Join(dataDir, sys.ID()) } api := http.NewServeMux() sys.Register(api, prefix, deps) t.mux.Handle(prefix, s.api(sys.ID(), api)) } } // api guards a system's own endpoints. The checks sit here rather than in each // system, so a new write endpoint is covered without anyone having to remember // it: a write needs a session, and it has to come from this site. A request // with neither Sec-Fetch-Site nor Origin, curl for instance, is not a browser // being tricked and passes the second check, never the first. With the login // off only the second is left, so it stays outside the edit mark. func (s *Server) api(id string, h http.Handler) http.Handler { protected := s.xorigin.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case safeMethod(r.Method): case !s.loginOff && !s.hasLogin(): s.refuse(w, r, http.StatusForbidden, "Editing is off", "No login exists yet, create one on the settings page.") return case !system.CanEdit(r): s.refuse(w, r, http.StatusUnauthorized, "Not logged in", "Log in to make changes.") return } h.ServeHTTP(w, r) })) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.cfg.Enabled(id) { http.NotFound(w, r) return } protected.ServeHTTP(w, r) }) } // DataDir is where systems keep what people change through a page. systemd // passes StateDirectory= in $STATE_DIRECTORY, which can list several // colon-separated paths; the first is ours. func DataDir(cfg *config.Config) string { if d := cfg.Get(dataDirKey); d != "" { return d } d, _, _ := strings.Cut(os.Getenv("STATE_DIRECTORY"), ":") return d } // enabled returns the systems that should be visible right now. func (t *tree) enabled() []system.System { var out []system.System for _, sys := range t.systems { if t.cfg.Enabled(sys.ID()) { out = append(out, sys) } } return out } func (t *tree) find(id string) system.System { for _, sys := range t.enabled() { if sys.ID() == id { return sys } } return nil } func (t *tree) handleRoot(w http.ResponseWriter, r *http.Request) { if en := t.enabled(); len(en) > 0 { http.Redirect(w, r, "/s/"+en[0].ID()+"/", http.StatusFound) return } for _, l := range t.links { if l.Mode != links.ModeTab { http.Redirect(w, r, l.Page(), http.StatusFound) return } } // Nothing to show: send the user somewhere useful rather than 404. http.Redirect(w, r, "/settings", http.StatusFound) } func (t *tree) findLink(id string) *links.Link { for i := range t.links { if t.links[i].ID == id { return &t.links[i] } } return nil } func (t *tree) handleLink(w http.ResponseWriter, r *http.Request) { l := t.findLink(r.PathValue("link")) if l == nil || l.Mode == links.ModeTab { http.NotFound(w, r) return } // The escaped path, not the path value, so an encoded ? or / in a wiki page // name survives the trip into the iframe source. rest := strings.TrimPrefix(r.URL.EscapedPath(), l.Page()) if rest != "" && l.Mode != links.ModeProxy { http.NotFound(w, r) return } d := t.layout(r, nil, "") for i := range d.Top { d.Top[i].Active = d.Top[i].Href == l.Page() } d.PageTitle = l.Title d.Frame = l.Src(rest, r.URL.RawQuery) if l.Mode == links.ModeProxy { d.FrameFrom, d.FrameTo = l.Prefix()+"/", l.Page() } t.render(w, d) } func (t *tree) handleSystemDefault(w http.ResponseWriter, r *http.Request) { sys := t.find(r.PathValue("system")) if sys == nil { http.NotFound(w, r) return } nav := sys.Nav() if len(nav) == 0 { http.Error(w, "system declares no pages", http.StatusInternalServerError) return } http.Redirect(w, r, "/s/"+sys.ID()+"/"+nav[0].Slug, http.StatusFound) } func (t *tree) handleSystemPage(w http.ResponseWriter, r *http.Request) { sys := t.find(r.PathValue("system")) if sys == nil { http.NotFound(w, r) return } slug := r.PathValue("slug") var known bool for _, n := range sys.Nav() { if n.Slug == slug { known = true break } } if !known { http.NotFound(w, r) return } data := t.layout(r, sys, slug) body, err := sys.Render(slug, r) if err != nil { // Render the shell anyway: a broken page should not cost the user // their navigation, and the error belongs on screen, not only in logs. t.log.Error("system render failed", "system", sys.ID(), "slug", slug, "err", err) data.Error = err.Error() } data.Body = body t.render(w, data) } type navLink struct { Href, Title string Active bool } // topLink is one navbar entry, a system or a link. type topLink struct { Href, Title string Active, NewTab bool } type layoutData struct { Version string Theme string PageTitle string ActiveTitle string Top []topLink Nav []navLink SettingsActive bool Body template.HTML Error string // Login is whether one is configured at all, LoggedIn whether this visitor // is, and Here where the Log in link returns to. Login, LoggedIn, LoginActive bool Here string // Frame replaces the whole content area with an iframe. FrameFrom and // FrameTo are set for a same-origin frame, whose location is mirrored into // the address bar. Frame, FrameFrom, FrameTo string } func (t *tree) layout(r *http.Request, active system.System, slug string) layoutData { d := layoutData{ Version: version.Version, Theme: theme(r), Login: t.hasLogin(), LoggedIn: t.hasLogin() && system.CanEdit(r), Here: r.URL.RequestURI(), } for _, sys := range t.enabled() { isActive := active != nil && sys.ID() == active.ID() d.Top = append(d.Top, topLink{Href: "/s/" + sys.ID() + "/", Title: sys.Title(), Active: isActive}) } for _, l := range t.links { d.Top = append(d.Top, topLink{Href: l.Href(), Title: l.Title, NewTab: l.Mode == links.ModeTab}) } if active == nil { return d } d.ActiveTitle = active.Title() d.PageTitle = active.Title() for _, n := range active.Nav() { if n.Slug == slug { d.PageTitle = n.Title + " ยท " + active.Title() } d.Nav = append(d.Nav, navLink{ Href: "/s/" + active.ID() + "/" + n.Slug, Title: n.Title, Active: n.Slug == slug, }) } return d } func (s *Server) render(w http.ResponseWriter, d layoutData) { s.renderCode(w, http.StatusOK, d) } func (s *Server) renderCode(w http.ResponseWriter, code int, d layoutData) { var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, "layout", d); err != nil { s.log.Error("layout render failed", "err", err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) _, _ = buf.WriteTo(w) } // contextWithTimeout keeps the metrics handler readable. func contextWithTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) { return context.WithTimeout(r.Context(), d) }