package server import ( "bytes" "context" "fmt" "html/template" "net/http" "net/url" "strconv" "strings" "sync" "time" "armdash/internal/auth" "armdash/internal/config" "armdash/internal/links" "armdash/internal/promql" "armdash/internal/system" ) // ---- settings ------------------------------------------------------------- // // Generated from the schemas, like the page it replaces: the core keys, each // system's ConfigSchema and the links. Each box is its own form, saved on its // own. A save changes the store, builds a new tree from it and only then // writes the file, so a value that breaks the build never reaches the disk. // // A value from an env file or the environment wins over the page and renders // disabled, naming the file. Secrets are never sent back to the browser. type settingsField struct { Name, Label, Help, EnvName string Input string // text, url or password Value, Placeholder string Locked string Disabled bool Err string } type settingsToggle struct { Name, Label, Locked string On, Disabled bool } type settingsSection struct { ID, Title, Action string Toggle *settingsToggle Fields []settingsField Editable bool Saved bool Err string } type settingsLinkRow struct { Index int ID, Title, URL, Mode, Framing string New bool } type settingsLinks struct { Rows []settingsLinkRow Modes []string Editable bool Locked string Saved bool Err string LastIndex int } type settingsLogin struct { User, Locked string Off bool Editable bool Saved bool Err string } type infoRow struct { Label, EnvName, Value, Source, Help string Set bool } type settingsData struct { Writable bool StorePath string Theme string Welcome bool Login settingsLogin Core settingsSection Systems []settingsSection Links settingsLinks Server []infoRow } // saveState carries a failed form back into the page, so the owner keeps what // they typed and sees which field was wrong. type saveState struct { Section string Saved string Err string Posted url.Values Fields map[string]string } // fieldSpec is one editable key, from the core list or a system's schema. type fieldSpec struct { Key, Label, Help string Kind system.FieldKind Default string Secret bool } var coreFields = []fieldSpec{ {Key: prometheusURLKey, Label: "Prometheus URL", Kind: system.KindURL, Help: "Every chart on every page is read from here, usually http://127.0.0.1:9090."}, } func systemFields(sys system.System) []fieldSpec { var out []fieldSpec for _, f := range sys.ConfigSchema() { out = append(out, fieldSpec{Key: "system." + sys.ID() + "." + f.Key, Label: f.Label, Help: f.Help, Kind: f.Kind, Default: f.Default, Secret: f.Secret}) } return out } func (t *tree) input(f fieldSpec, st saveState, section string) settingsField { in := settingsField{ Name: f.Key, Label: f.Label, Help: f.Help, EnvName: config.EnvName(f.Key), Input: "text", Locked: t.cfg.Locked(f.Key), Disabled: !t.cfg.Writable(), } if in.Locked != "" { in.Disabled = true } switch f.Kind { case system.KindURL: in.Input = "url" case system.KindPassword: in.Input = "password" case system.KindBool: in.Input = "checkbox" } cur := t.cfg.Get(f.Key) switch { case f.Secret && cur != "": in.Placeholder = "set, leave empty to keep it" case f.Secret: in.Placeholder = "not set" default: in.Value = cur in.Placeholder = f.Default } if f.Kind == system.KindBool { in.Value = map[bool]string{true: "1", false: ""}[t.cfg.Bool(f.Key)] } if st.Section == section && st.Posted != nil && !f.Secret && in.Locked == "" { in.Value = st.Posted.Get(f.Key) } if st.Section == section { in.Err = st.Fields[f.Key] } return in } func (t *tree) section(id, title, action string, specs []fieldSpec, st saveState) settingsSection { sec := settingsSection{ID: id, Title: title, Action: action, Saved: st.Saved == id} if st.Section == id { sec.Err = st.Err } for _, f := range specs { in := t.input(f, st, id) if !in.Disabled { sec.Editable = true } sec.Fields = append(sec.Fields, in) } return sec } func (t *tree) settingsData(st saveState) settingsData { d := settingsData{ Writable: t.cfg.Writable(), StorePath: t.cfg.StorePath(), Welcome: st.Saved == "setup", } user, _ := t.loginPair() d.Login = settingsLogin{User: user, Off: t.loginOff, Saved: st.Saved == "login"} if l := t.cfg.Locked(authHashKey); l != "" { d.Login.Locked = l } else if l := t.cfg.Locked(authUserKey); l != "" { d.Login.Locked = l } d.Login.Editable = d.Writable && d.Login.Locked == "" && t.hasLogin() if st.Section == "login" { d.Login.Err = st.Err } d.Core = t.section("core", "Core", "/settings/core", coreFields, st) for _, sys := range t.systems { id := "system-" + sys.ID() sec := t.section(id, sys.Title(), "/settings/system/"+sys.ID(), systemFields(sys), st) key := "system." + sys.ID() + ".enabled" tg := &settingsToggle{Name: key, Label: "Shown in the navbar", On: t.cfg.Enabled(sys.ID()), Locked: t.cfg.Locked(key)} tg.Disabled = tg.Locked != "" || !d.Writable if !tg.Disabled { sec.Editable = true } sec.Toggle = tg d.Systems = append(d.Systems, sec) } d.Links = settingsLinks{ Modes: []string{string(links.ModeFrame), string(links.ModeProxy), string(links.ModeTab)}, Locked: t.linksLocked(), Saved: st.Saved == "links", } d.Links.Editable = d.Writable && d.Links.Locked == "" if st.Section == "links" { d.Links.Err = st.Err } for i, l := range t.links { d.Links.Rows = append(d.Links.Rows, settingsLinkRow{Index: i, ID: l.ID, Title: l.Title, URL: l.URL.String(), Mode: string(l.Mode), Framing: l.Framing}) } if st.Section == "links" && st.Posted != nil { d.Links.Rows = postedLinkRows(st.Posted) } if d.Links.Editable && (len(d.Links.Rows) == 0 || !d.Links.Rows[len(d.Links.Rows)-1].New) { d.Links.Rows = append(d.Links.Rows, settingsLinkRow{Index: len(d.Links.Rows), New: true, Mode: string(links.ModeFrame)}) } d.Links.LastIndex = len(d.Links.Rows) - 1 d.Server = []infoRow{ t.info("core.addr", "Listen address", "127.0.0.1:9494", "Where plain HTTP is served. The -addr flag overrides it."), t.info("core.tls_addr", "HTTPS address", "127.0.0.1:9495", "Used when both the certificate and the key are set."), t.info(tlsCertKey, "TLS certificate", "HTTPS is off", ""), t.info(tlsKeyKey, "TLS key", "HTTPS is off", ""), t.info(dataDirKey, "Data directory", t.dataDirUnset(), "Settings saved here, uploaded floor plans and device positions."), t.info(trustedProxiesKey, "Trusted proxies", "Off: forwarding headers are ignored", "Addresses whose X-Forwarded-For and X-Forwarded-Proto are believed, for a reverse proxy in front."), t.info(authModeKey, "Login", "login", "none turns the login off, for a proxy in front that already lets only the owner through."), {Label: "Env files", Value: strings.Join(t.files, ", "), Set: len(t.files) > 0, Help: "Read at startup, later ones win, the environment wins over all. A value set there locks its field here."}, } return d } func (t *tree) dataDirUnset() string { if dir := DataDir(t.cfg); dir != "" { return dir + " (systemd)" } return "none, settings cannot be saved" } func (t *tree) info(key, label, unset, help string) infoRow { v := t.cfg.Get(key) r := infoRow{Label: label, EnvName: config.EnvName(key), Value: v, Source: t.cfg.Source(key), Help: help, Set: v != ""} if !r.Set { r.Value = unset } return r } // linksLocked names the file that sets the link list or any link, which makes // the whole list read-only here: half a list from a file and half from the // page would be impossible to reason about. func (t *tree) linksLocked() string { if l := t.cfg.Locked(links.ListKey); l != "" { return l } prefix := config.EnvName("link") + "_" for _, k := range t.cfg.Keys() { if strings.HasPrefix(k, prefix) { if l := t.cfg.LockedVar(k); l != "" { return l } } } return "" } func (t *tree) handleSettings(w http.ResponseWriter, r *http.Request) { if t.setupMode() { t.renderSetup(w, r, http.StatusOK, "", "") return } if t.requireLogin(w, r) { return } t.renderSettings(w, r, http.StatusOK, saveState{Saved: r.URL.Query().Get("saved")}) } func (t *tree) renderSettings(w http.ResponseWriter, r *http.Request, code int, st saveState) { sd := t.settingsData(st) sd.Theme = theme(r) t.page(w, r, code, "settings", sd, "Settings") } func (t *tree) renderSetup(w http.ResponseWriter, r *http.Request, code int, user, errMsg string) { t.page(w, r, code, "setup", map[string]string{"User": user, "Err": errMsg}, "Set up") } func (t *tree) page(w http.ResponseWriter, r *http.Request, code int, name string, data any, title string) { var body bytes.Buffer if err := t.tmpl.ExecuteTemplate(&body, name, data); err != nil { t.log.Error("settings render failed", "err", err) http.Error(w, "template error", http.StatusInternalServerError) return } d := t.layout(r, nil, "") d.PageTitle = title d.SettingsActive = true d.Body = template.HTML(body.String()) t.renderCode(w, code, d) } // save applies changes to the store, builds a tree from them and writes the // file. A failure at either step puts the old values back. func (s *Server) save(changes map[string]string) error { s.saveMu.Lock() defer s.saveMu.Unlock() return s.saveLocked(changes) } func (s *Server) saveLocked(changes map[string]string) error { if len(changes) == 0 { return nil } undo, err := s.cfg.Set(changes) if err != nil { return err } if err := s.rebuild(); err != nil { _, _ = s.cfg.Set(undo) return err } if err := s.cfg.Save(); err != nil { _, _ = s.cfg.Set(undo) _ = s.rebuild() return fmt.Errorf("saving the settings: %w", err) } s.log.Info("settings saved", "keys", len(changes)) return nil } // saveScoped is Deps.Save: a system's own keys, under its prefix only. func (s *Server) saveScoped(prefix string) func(map[string]string) error { return func(changes map[string]string) error { full := make(map[string]string, len(changes)) for k, v := range changes { full[prefix+k] = v } return s.save(full) } } // editor refuses a settings form from anyone but the logged-in owner. func (t *tree) editor(w http.ResponseWriter, r *http.Request) bool { switch { case !t.loginOff && !t.hasLogin(): t.refuse(w, r, http.StatusForbidden, "Editing is off", "No login exists yet.") return false case !system.CanEdit(r): t.refuse(w, r, http.StatusUnauthorized, "Not logged in", "Log in to make changes.") return false } return true } func saved(w http.ResponseWriter, r *http.Request, section string) { http.Redirect(w, r, "/settings?saved="+section+"#"+section, http.StatusSeeOther) } // collect turns a posted form into store changes. An unchanged value is left // alone, so saving a form does not copy the committed defaults into the store; // an emptied one is removed. An empty secret keeps the stored one. func (t *tree) collect(specs []fieldSpec, form url.Values) (map[string]string, map[string]string) { changes, errs := map[string]string{}, map[string]string{} for _, f := range specs { if t.cfg.Locked(f.Key) != "" { continue } raw := strings.TrimSpace(form.Get(f.Key)) cur := t.cfg.Get(f.Key) // An unticked checkbox is absent from the form, so unticked is "", // which removes the stored value and falls back to off. if f.Kind == system.KindBool { if t.cfg.Bool(f.Key) != (raw == "1") { changes[f.Key] = raw } continue } if raw == cur || f.Secret && raw == "" { continue } if raw != "" { if err := validate(f.Kind, raw); err != "" { errs[f.Key] = err continue } } changes[f.Key] = raw } return changes, errs } func validate(kind system.FieldKind, v string) string { switch kind { case system.KindURL: u, err := url.Parse(v) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return "Needs an http:// or https:// address." } if u.User != nil { return "Leave the credentials out of the address." } case system.KindDuration: if d, err := time.ParseDuration(v); err != nil || d <= 0 { return "Needs a duration such as 60s or 5m." } } return "" } func (t *tree) failed(w http.ResponseWriter, r *http.Request, section string, err string, fields map[string]string) { t.renderSettings(w, r, http.StatusUnprocessableEntity, saveState{Section: section, Err: err, Posted: r.PostForm, Fields: fields}) } func (t *tree) handleSaveCore(w http.ResponseWriter, r *http.Request) { if !t.editor(w, r) { return } _ = r.ParseForm() changes, errs := t.collect(coreFields, r.PostForm) if len(errs) > 0 { t.failed(w, r, "core", "Nothing was saved, see the field below.", errs) return } if err := t.save(changes); err != nil { t.failed(w, r, "core", err.Error(), nil) return } saved(w, r, "core") } func (t *tree) handleSaveSystem(w http.ResponseWriter, r *http.Request) { if !t.editor(w, r) { return } var sys system.System for _, s := range t.systems { if s.ID() == r.PathValue("id") { sys = s } } if sys == nil { http.NotFound(w, r) return } _ = r.ParseForm() section := "system-" + sys.ID() specs := systemFields(sys) changes, errs := t.collect(specs, r.PostForm) if len(errs) > 0 { t.failed(w, r, section, "Nothing was saved, see the fields below.", errs) return } // A stored secret belongs to the address it was entered for. Pointing // the address elsewhere drops it, unless a new one comes with the change, // so a session in the wrong hands cannot send the password to a host of // its choosing. for _, f := range specs { if _, moved := changes[f.Key]; !moved || f.Kind != system.KindURL { continue } for _, sec := range specs { _, replaced := changes[sec.Key] if sec.Secret && !replaced && t.cfg.Source(sec.Key) == config.SourceSettings { changes[sec.Key] = "" } } } key := "system." + sys.ID() + ".enabled" if t.cfg.Locked(key) == "" { on := r.PostForm.Get(key) == "1" if on != t.cfg.Enabled(sys.ID()) { changes[key] = map[bool]string{true: "1", false: "0"}[on] } } if err := t.save(changes); err != nil { t.failed(w, r, section, err.Error(), nil) return } saved(w, r, section) } func postedLinkRows(form url.Values) []settingsLinkRow { n, _ := strconv.Atoi(form.Get("rows")) var rows []settingsLinkRow for i := 0; i < n && i < 100; i++ { get := func(f string) string { return strings.TrimSpace(form.Get(f + "." + strconv.Itoa(i))) } rows = append(rows, settingsLinkRow{Index: i, ID: strings.ToLower(get("id")), Title: get("title"), URL: get("url"), Mode: get("mode"), Framing: get("framing"), New: get("new") == "1"}) } return rows } // linkID makes an id from a title for a new link: its letters and digits. func linkID(title string) string { var b strings.Builder for _, c := range strings.ToLower(title) { if c >= 'a' && c <= 'z' || c >= '0' && c <= '9' { b.WriteRune(c) } } return b.String() } func (t *tree) handleSaveLinks(w http.ResponseWriter, r *http.Request) { if !t.editor(w, r) { return } if l := t.linksLocked(); l != "" { t.refuse(w, r, http.StatusConflict, "Links are set in "+l, "Edit them there.") return } _ = r.ParseForm() rows := postedLinkRows(r.PostForm) // The arrows and the remove button are submit buttons: one press moves or // drops one row and saves. if i, dir, ok := strings.Cut(r.PostForm.Get("move"), ":"); ok { if n, err := strconv.Atoi(i); err == nil && n >= 0 && n < len(rows) { m := n - 1 if dir == "down" { m = n + 1 } if m >= 0 && m < len(rows) && !rows[m].New && !rows[n].New { rows[n], rows[m] = rows[m], rows[n] } } } var ids []string seen := map[string]bool{} changes := map[string]string{} for _, row := range rows { if r.PostForm.Get("remove") == strconv.Itoa(row.Index) { continue } if row.New && row.ID == "" && row.Title == "" && row.URL == "" { continue } id := row.ID if id == "" { id = linkID(row.Title) } name := row.Title if name == "" { name = id } switch { case !links.ValidID(id): t.failed(w, r, "links", fmt.Sprintf("%q needs an id of letters a-z and digits only.", name), nil) return case seen[id]: t.failed(w, r, "links", fmt.Sprintf("Two links share the id %q.", id), nil) return case row.URL == "": t.failed(w, r, "links", fmt.Sprintf("%q needs a URL.", name), nil) return } seen[id] = true ids = append(ids, id) title, mode := row.Title, row.Mode if title == id { title = "" } if mode == string(links.ModeFrame) { mode = "" } changes[links.Key(id, "url")] = row.URL changes[links.Key(id, "title")] = title changes[links.Key(id, "mode")] = mode changes[links.Key(id, "framing")] = row.Framing } for _, l := range t.links { if !seen[l.ID] { for _, f := range links.Fields() { changes[links.Key(l.ID, f)] = "" } } } changes[links.ListKey] = strings.Join(ids, ",") if err := t.save(changes); err != nil { t.failed(w, r, "links", err.Error(), nil) return } saved(w, r, "links") } // handleSetup creates the first login, on a fresh install only, and logs the // new owner straight in. func (t *tree) handleSetup(w http.ResponseWriter, r *http.Request) { t.saveMu.Lock() defer t.saveMu.Unlock() if !t.setupMode() { t.refuse(w, r, http.StatusForbidden, "Already set up", "A login exists, log in with it.") return } user := strings.TrimSpace(r.PostFormValue("user")) pass, confirm := r.PostFormValue("password"), r.PostFormValue("confirm") msg := "" switch { case user == "": msg = "Choose a user name." case pass != confirm: msg = "The two passwords differ." } var hash string if msg == "" { var err error if hash, err = auth.Hash(pass); err != nil { msg = "The password needs at least " + strconv.Itoa(auth.MinPasswordLen) + " characters." } } if msg == "" { if err := t.saveLocked(map[string]string{authUserKey: user, authHashKey: hash}); err != nil { msg = err.Error() } } if msg != "" { t.renderSetup(w, r, http.StatusUnprocessableEntity, user, msg) return } id, err := t.sessions.Create() if err != nil { http.Error(w, "could not create a session", http.StatusInternalServerError) return } t.setSession(w, r, id, int(auth.SessionTTL.Seconds())) t.log.Info("login created", "user", user, "addr", t.clientAddr(r)) http.Redirect(w, r, "/settings?saved=setup", http.StatusSeeOther) } // handleChangeLogin changes the stored user name or password. Either needs the // current password, and a new password ends every other session. func (t *tree) handleChangeLogin(w http.ResponseWriter, r *http.Request) { if !t.editor(w, r) { return } if t.cfg.Locked(authHashKey) != "" || t.cfg.Locked(authUserKey) != "" { t.refuse(w, r, http.StatusConflict, "The login is set in an env file", "Change it there with armdash passwd.") return } _ = r.ParseForm() addr := t.clientAddr(r) if ok, _ := t.limiter.Allowed(addr); !ok { t.failed(w, r, "login", "Too many wrong passwords. Try again later.", nil) return } curUser, hash := t.loginPair() if !auth.Verify(r.PostForm.Get("current"), hash) { t.limiter.Fail(addr) t.failed(w, r, "login", "The current password is wrong.", nil) return } t.limiter.Reset(addr) changes := map[string]string{} if u := strings.TrimSpace(r.PostForm.Get("user")); u != "" && u != curUser { changes[authUserKey] = u } pass := r.PostForm.Get("password") if pass != "" { if pass != r.PostForm.Get("confirm") { t.failed(w, r, "login", "The two new passwords differ.", nil) return } h, err := auth.Hash(pass) if err != nil { t.failed(w, r, "login", "The new password needs at least "+strconv.Itoa(auth.MinPasswordLen)+" characters.", nil) return } changes[authHashKey] = h } if err := t.save(changes); err != nil { t.failed(w, r, "login", err.Error(), nil) return } if pass != "" { if c, err := r.Cookie(sessionCookie); err == nil { t.sessions.DeleteOthers(c.Value) } } saved(w, r, "login") } // ---- status --------------------------------------------------------------- type statusGroup struct { Title string Checks []system.Check } // handleStatus is the box beside the settings: what works and what is still // to do. Loaded after the page, because it asks Prometheus and the FRITZ!Box, // and one slow answer should not hold the form back. func (t *tree) handleStatus(w http.ResponseWriter, r *http.Request) { if t.setupMode() || t.hasLogin() && !system.CanEdit(r) { http.Error(w, "log in first", http.StatusUnauthorized) return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() groups := []statusGroup{{Title: "Core", Checks: t.coreStatus(ctx)}} var wg sync.WaitGroup var mu sync.Mutex perSystem := map[string][]system.Check{} for _, sys := range t.enabled() { c, ok := sys.(system.Checker) if !ok { continue } wg.Add(1) go func() { defer wg.Done() checks := c.Status(ctx) mu.Lock() perSystem[sys.ID()] = checks mu.Unlock() }() } wg.Wait() for _, sys := range t.enabled() { if checks := perSystem[sys.ID()]; len(checks) > 0 { groups = append(groups, statusGroup{Title: sys.Title(), Checks: checks}) } } var buf bytes.Buffer if err := t.tmpl.ExecuteTemplate(&buf, "settings-status", groups); err != nil { t.log.Error("status render failed", "err", err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = buf.WriteTo(w) } func (t *tree) coreStatus(ctx context.Context) []system.Check { var out []system.Check user, _ := t.loginPair() switch { case t.loginOff: out = append(out, system.Check{Title: "Login", Level: "ok", Detail: "Off, set by " + config.EnvName(authModeKey) + ". Anyone who reaches this page can change settings."}) case user == "": out = append(out, system.Check{Title: "Login", Level: "warning", Detail: "None, and no data directory to save one in. Anyone can read this page."}) case t.cfg.Locked(authUserKey) != "": out = append(out, system.Check{Title: "Login", Level: "ok", Detail: user + ", set in " + t.cfg.Locked(authUserKey)}) default: out = append(out, system.Check{Title: "Login", Level: "ok", Detail: user}) } if p := t.cfg.StorePath(); p != "" { out = append(out, system.Check{Title: "Settings", Level: "ok", Detail: "Saved in " + p}) } else { out = append(out, system.Check{Title: "Settings", Level: "warning", Detail: "No data directory, so nothing here can be saved. Set " + config.EnvName(dataDirKey) + "."}) } if t.PromURL() == "" { return append(out, system.Check{Title: "Prometheus", Level: "danger", Detail: "Not set. Enter its URL under Core, every chart reads from it."}) } prom := promql.New(t.PromURL) n, ok, err := prom.QueryOne(ctx, "count(up == 1)") if err != nil { return append(out, system.Check{Title: "Prometheus", Level: "danger", Detail: "Unreachable: " + err.Error()}) } if !ok { n = 0 } out = append(out, system.Check{Title: "Prometheus", Level: "ok", Detail: fmt.Sprintf("Reachable, %.0f targets up", n)}) if _, ok, err := prom.QueryOne(ctx, "count(armdash_collector_up)"); err == nil && !ok { out = append(out, system.Check{Title: "Scraping armdash", Level: "warning", Detail: "Prometheus has no samples from armdash's /metrics yet. Add its scrape job, see docs/install.md."}) } return out }