Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
92efb8e270
|
|||
|
0b64d13bb6
|
|||
|
a1ab276bc5
|
@@ -0,0 +1,115 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/ssh"
|
||||||
|
gossh "golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kcMockStore implements only what keyCache and userInvalidatingStore touch;
|
||||||
|
// any other Store method panics via the embedded nil interface.
|
||||||
|
type kcMockStore struct {
|
||||||
|
store.Store
|
||||||
|
users []models.User
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *kcMockStore) GetAllUsers() ([]models.User, error) { return m.users, m.err }
|
||||||
|
func (m *kcMockStore) DeleteUser(int) error { return nil }
|
||||||
|
|
||||||
|
func testKey(t *testing.T) (string, ssh.PublicKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sk, err := gossh.NewPublicKey(pub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(gossh.MarshalAuthorizedKey(sk)), sk
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
|
||||||
|
authorized, known := testKey(t)
|
||||||
|
_, unknown := testKey(t)
|
||||||
|
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
|
||||||
|
|
||||||
|
if !kc.IsAllowed(known) {
|
||||||
|
t.Error("known key denied")
|
||||||
|
}
|
||||||
|
if kc.IsAllowed(unknown) {
|
||||||
|
t.Error("unknown key allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
|
||||||
|
authorized, known := testKey(t)
|
||||||
|
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||||
|
kc := newKeyCache(ms)
|
||||||
|
|
||||||
|
if !kc.IsAllowed(known) {
|
||||||
|
t.Fatal("known key denied on first refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB goes down and the cache goes stale: a transient error must not lock
|
||||||
|
// every admin out — the previous key set stays in effect.
|
||||||
|
ms.err = errors.New("db down")
|
||||||
|
kc.mu.Lock()
|
||||||
|
kc.updated = time.Now().Add(-time.Hour)
|
||||||
|
kc.mu.Unlock()
|
||||||
|
|
||||||
|
if !kc.IsAllowed(known) {
|
||||||
|
t.Error("transient refresh error locked out a previously valid key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
|
||||||
|
authorized, known := testKey(t)
|
||||||
|
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||||
|
kc := newKeyCache(ms)
|
||||||
|
|
||||||
|
if !kc.IsAllowed(known) {
|
||||||
|
t.Fatal("known key denied on first refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revocation happened (Invalidate) and the DB is unreachable for the
|
||||||
|
// re-read: the revoked key must NOT keep working off the stale cache.
|
||||||
|
ms.err = errors.New("db down")
|
||||||
|
kc.Invalidate()
|
||||||
|
|
||||||
|
if kc.IsAllowed(known) {
|
||||||
|
t.Error("revoked key still allowed while DB is down — fails open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
|
||||||
|
authorized, known := testKey(t)
|
||||||
|
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||||
|
kc := newKeyCache(ms)
|
||||||
|
s := &userInvalidatingStore{Store: ms, kc: kc}
|
||||||
|
|
||||||
|
if !kc.IsAllowed(known) {
|
||||||
|
t.Fatal("known key denied on first refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke the user; DB unreachable immediately after. The cached key must
|
||||||
|
// be gone the moment the delete returns.
|
||||||
|
if err := s.DeleteUser(1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ms.users = nil
|
||||||
|
ms.err = errors.New("db down")
|
||||||
|
|
||||||
|
if kc.IsAllowed(known) {
|
||||||
|
t.Error("deleted user's key still allowed from stale cache")
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-3
@@ -376,7 +376,8 @@ func runServe(args []string) {
|
|||||||
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
|
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
|
||||||
}
|
}
|
||||||
|
|
||||||
var s store.Store = ss
|
kc := newKeyCache(ss)
|
||||||
|
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
|
||||||
if err := s.Init(); err != nil {
|
if err := s.Init(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -430,6 +431,10 @@ func runServe(args []string) {
|
|||||||
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
||||||
tlsKey := os.Getenv("UPTOP_TLS_KEY")
|
tlsKey := os.Getenv("UPTOP_TLS_KEY")
|
||||||
|
|
||||||
|
// When the local TUI owns the terminal, per-request HTTP logs to stderr
|
||||||
|
// would scribble over the alt screen.
|
||||||
|
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
|
||||||
|
|
||||||
httpSrv := server.Start(server.ServerConfig{
|
httpSrv := server.Start(server.ServerConfig{
|
||||||
Port: httpPort,
|
Port: httpPort,
|
||||||
EnableStatus: enableStatus,
|
EnableStatus: enableStatus,
|
||||||
@@ -441,6 +446,7 @@ func runServe(args []string) {
|
|||||||
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
|
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
|
||||||
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
|
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
|
||||||
TrustedProxies: parseTrustedProxies(os.Getenv("UPTOP_TRUSTED_PROXIES")),
|
TrustedProxies: parseTrustedProxies(os.Getenv("UPTOP_TRUSTED_PROXIES")),
|
||||||
|
QuietHTTPLog: localTUI,
|
||||||
}, s, eng)
|
}, s, eng)
|
||||||
|
|
||||||
cluster.Start(ctx, cluster.Config{
|
cluster.Start(ctx, cluster.Config{
|
||||||
@@ -449,10 +455,9 @@ func runServe(args []string) {
|
|||||||
SharedKey: clusterKey,
|
SharedKey: clusterKey,
|
||||||
}, eng)
|
}, eng)
|
||||||
|
|
||||||
kc := newKeyCache(s)
|
|
||||||
sshSrv := startSSHServer(*port, s, eng, kc)
|
sshSrv := startSSHServer(*port, s, eng, kc)
|
||||||
|
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
if localTUI {
|
||||||
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
@@ -573,6 +578,10 @@ func newKeyCache(db store.Store) *keyCache {
|
|||||||
func (c *keyCache) refresh() {
|
func (c *keyCache) refresh() {
|
||||||
users, err := c.db.GetAllUsers()
|
users, err := c.db.GetAllUsers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Keep the previous key set: a transient DB error must not lock every
|
||||||
|
// admin out. Revocation still fails closed because Invalidate clears
|
||||||
|
// the set immediately.
|
||||||
|
log.Printf("SSH key cache refresh failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := make([]ssh.PublicKey, 0, len(users))
|
keys := make([]ssh.PublicKey, 0, len(users))
|
||||||
@@ -589,8 +598,13 @@ func (c *keyCache) refresh() {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate clears the cached key set, not just the timestamp. If the
|
||||||
|
// refresh that follows a user revocation fails, auth fails closed (everyone
|
||||||
|
// re-authenticates after the next successful refresh) instead of the revoked
|
||||||
|
// key silently continuing to work off the stale cache.
|
||||||
func (c *keyCache) Invalidate() {
|
func (c *keyCache) Invalidate() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
c.keys = nil
|
||||||
c.updated = time.Time{}
|
c.updated = time.Time{}
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
@@ -614,6 +628,39 @@ func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userInvalidatingStore drops the SSH key cache whenever the user table
|
||||||
|
// changes, so a revocation takes effect on the next connection attempt
|
||||||
|
// instead of after the cache TTL — and fails closed if the DB is unreachable
|
||||||
|
// when that next attempt re-reads the table.
|
||||||
|
type userInvalidatingStore struct {
|
||||||
|
store.Store
|
||||||
|
kc *keyCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userInvalidatingStore) AddUser(username, publicKey, role string) error {
|
||||||
|
err := s.Store.AddUser(username, publicKey, role)
|
||||||
|
s.kc.Invalidate()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userInvalidatingStore) UpdateUser(id int, username, publicKey, role string) error {
|
||||||
|
err := s.Store.UpdateUser(id, username, publicKey, role)
|
||||||
|
s.kc.Invalidate()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userInvalidatingStore) DeleteUser(id int) error {
|
||||||
|
err := s.Store.DeleteUser(id)
|
||||||
|
s.kc.Invalidate()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *userInvalidatingStore) ImportData(data models.Backup) error {
|
||||||
|
err := s.Store.ImportData(data)
|
||||||
|
s.kc.Invalidate()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func seedKeysFromEnv(s store.Store) {
|
func seedKeysFromEnv(s store.Store) {
|
||||||
var keys []string
|
var keys []string
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.33
|
github.com/mattn/go-sqlite3 v1.14.33
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/prometheus-community/pro-bing v0.8.0
|
github.com/prometheus-community/pro-bing v0.8.0
|
||||||
|
golang.org/x/crypto v0.52.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,7 +51,6 @@ require (
|
|||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.35.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
// safeAlertSettingKeys lists, per provider type, the alert settings that are
|
||||||
|
// NOT secret and may be shown or exported in the clear. Everything else is
|
||||||
|
// redacted. Providers absent from this map (discord, slack, webhook, pushover)
|
||||||
|
// carry their secret in a field a denylist would miss — the webhook URL, the
|
||||||
|
// pushover token/user — so all of their settings are redacted.
|
||||||
|
var safeAlertSettingKeys = map[string]map[string]bool{
|
||||||
|
"email": {"host": true, "port": true, "to": true, "from": true},
|
||||||
|
"ntfy": {"topic": true, "priority": true},
|
||||||
|
"telegram": {"chat_id": true},
|
||||||
|
"pagerduty": {"severity": true},
|
||||||
|
"gotify": {"priority": true},
|
||||||
|
"opsgenie": {"priority": true, "eu": true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedactAlertSettings keeps only the known-safe keys for the alert type and
|
||||||
|
// redacts everything else. An allowlist fails safe: an unknown or newly added
|
||||||
|
// setting is redacted by default instead of leaking. Shared by the backup
|
||||||
|
// export path and the TUI alert detail panel so both render through the same
|
||||||
|
// policy.
|
||||||
|
func RedactAlertSettings(alertType string, settings map[string]string) map[string]string {
|
||||||
|
safe := safeAlertSettingKeys[alertType]
|
||||||
|
redacted := make(map[string]string, len(settings))
|
||||||
|
for k, v := range settings {
|
||||||
|
switch {
|
||||||
|
case v == "":
|
||||||
|
redacted[k] = ""
|
||||||
|
case safe[k]:
|
||||||
|
redacted[k] = v
|
||||||
|
default:
|
||||||
|
redacted[k] = "***REDACTED***"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return redacted
|
||||||
|
}
|
||||||
+37
-40
@@ -33,40 +33,8 @@ func extractBearerToken(r *http.Request) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeSettingKeys lists, per provider type, the settings that are NOT secret
|
// Alert-settings redaction policy lives in models.RedactAlertSettings so the
|
||||||
// and may be exported in the clear. Everything else is redacted. Providers
|
// TUI detail panel and this export path share one allowlist.
|
||||||
// absent from this map (discord, slack, webhook, pushover) carry their secret
|
|
||||||
// in a field a denylist would miss — the webhook URL, the pushover token/user —
|
|
||||||
// so all of their settings are redacted.
|
|
||||||
var safeSettingKeys = map[string]map[string]bool{
|
|
||||||
"email": {"host": true, "port": true, "to": true, "from": true},
|
|
||||||
"ntfy": {"topic": true, "priority": true},
|
|
||||||
"telegram": {"chat_id": true},
|
|
||||||
"pagerduty": {"severity": true},
|
|
||||||
"gotify": {"priority": true},
|
|
||||||
"opsgenie": {"priority": true, "eu": true},
|
|
||||||
}
|
|
||||||
|
|
||||||
// redactByProvider keeps only the known-safe keys for the alert type and
|
|
||||||
// redacts everything else. An allowlist fails safe: an unknown or newly added
|
|
||||||
// setting is redacted by default instead of leaking. This closes the denylist
|
|
||||||
// gap where url (discord/slack/webhook/ntfy/gotify) and api_key (opsgenie) —
|
|
||||||
// the actual credentials — were exported in the clear.
|
|
||||||
func redactByProvider(alertType string, settings map[string]string) map[string]string {
|
|
||||||
safe := safeSettingKeys[alertType]
|
|
||||||
redacted := make(map[string]string, len(settings))
|
|
||||||
for k, v := range settings {
|
|
||||||
switch {
|
|
||||||
case v == "":
|
|
||||||
redacted[k] = ""
|
|
||||||
case safe[k]:
|
|
||||||
redacted[k] = v
|
|
||||||
default:
|
|
||||||
redacted[k] = "***REDACTED***"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusTpl = template.Must(template.New("status").Parse(`
|
var statusTpl = template.Must(template.New("status").Parse(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -211,6 +179,23 @@ type ServerConfig struct {
|
|||||||
MetricsPublic bool
|
MetricsPublic bool
|
||||||
CORSOrigin string
|
CORSOrigin string
|
||||||
TrustedProxies []*net.IPNet
|
TrustedProxies []*net.IPNet
|
||||||
|
// QuietHTTPLog disables per-request stderr logging. Set when the local
|
||||||
|
// TUI owns the terminal — request logs would scribble over the alt screen.
|
||||||
|
QuietHTTPLog bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// statusSite is the public DTO for /status/json. models.Site must never be
|
||||||
|
// serialized raw here: it carries internal fields (LastError, Hostname, Port,
|
||||||
|
// DNSServer, AlertID, Token, ...) and every field added to it would become
|
||||||
|
// public by default. Field names match what the status page JS reads.
|
||||||
|
type statusSite struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
URL string
|
||||||
|
Status string
|
||||||
|
Paused bool
|
||||||
|
LastCheck time.Time
|
||||||
|
Latency time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
||||||
@@ -278,7 +263,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
}
|
}
|
||||||
if r.URL.Query().Get("redact_secrets") != "false" {
|
if r.URL.Query().Get("redact_secrets") != "false" {
|
||||||
for i := range data.Alerts {
|
for i := range data.Alerts {
|
||||||
data.Alerts[i].Settings = redactByProvider(data.Alerts[i].Type, data.Alerts[i].Settings)
|
data.Alerts[i].Settings = models.RedactAlertSettings(data.Alerts[i].Type, data.Alerts[i].Settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
|
||||||
@@ -483,18 +468,27 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
maintSet[mw.MonitorID] = true
|
maintSet[mw.MonitorID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
public := make(map[int]statusSite, len(state))
|
||||||
for id, site := range state {
|
for id, site := range state {
|
||||||
site.Token = ""
|
status := site.Status
|
||||||
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
|
if allInMaint || maintSet[site.ID] || (site.ParentID > 0 && maintSet[site.ParentID]) {
|
||||||
site.Status = "MAINT"
|
status = "MAINT"
|
||||||
|
}
|
||||||
|
public[id] = statusSite{
|
||||||
|
Name: site.Name,
|
||||||
|
Type: site.Type,
|
||||||
|
URL: site.URL,
|
||||||
|
Status: status,
|
||||||
|
Paused: site.Paused,
|
||||||
|
LastCheck: site.LastCheck,
|
||||||
|
Latency: site.Latency,
|
||||||
}
|
}
|
||||||
state[id] = site
|
|
||||||
}
|
}
|
||||||
if cfg.CORSOrigin != "" {
|
if cfg.CORSOrigin != "" {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
|
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(state) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(public) //nolint:errcheck
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +496,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
|
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := loggingMiddleware(cfg.TrustedProxies, securityHeadersMiddleware(mux))
|
handler := securityHeadersMiddleware(mux)
|
||||||
|
if !cfg.QuietHTTPLog {
|
||||||
|
handler = loggingMiddleware(cfg.TrustedProxies, handler)
|
||||||
|
}
|
||||||
if cfg.TLSCert != "" {
|
if cfg.TLSCert != "" {
|
||||||
handler = hstsMiddleware(handler)
|
handler = hstsMiddleware(handler)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@@ -480,15 +481,32 @@ func TestStatusPage_Enabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStatusJSON_TokensStripped(t *testing.T) {
|
func TestStatusJSON_PublicDTOOnly(t *testing.T) {
|
||||||
ts := newTestServer(t, "secret", true)
|
ts := newTestServer(t, "secret", true)
|
||||||
|
|
||||||
// Inject a site with a token into engine state
|
// Seed a push monitor (no network IO) through the store and start the
|
||||||
ts.engine.UpdateSiteConfig(models.Site{ID: 1, Name: "test", Type: "push", Token: "secret-token", Status: "UP"})
|
// engine so its poll loop loads it into live state — the path real sites
|
||||||
// Need to inject directly since UpdateSiteConfig only updates existing
|
// take. The old version of this test injected via UpdateSiteConfig, which
|
||||||
func() {
|
// no-ops for unknown IDs, so it asserted over zero sites and passed
|
||||||
ts.engine.RecordHeartbeat("unused") // just to exercise, won't match
|
// against a server that leaked tokens.
|
||||||
}()
|
ts.store.sites = []models.Site{{
|
||||||
|
ID: 1, Name: "test", Type: "push", Token: "secret-token",
|
||||||
|
Hostname: "internal-host", LastError: "internal failure detail", AlertID: 3,
|
||||||
|
}}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ts.engine.Start(ctx)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancel()
|
||||||
|
ts.engine.Stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && len(ts.engine.GetLiveState()) == 0 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if len(ts.engine.GetLiveState()) == 0 {
|
||||||
|
t.Fatal("engine never loaded the seeded site")
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := http.Get(ts.baseURL + "/status/json")
|
resp, err := http.Get(ts.baseURL + "/status/json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -498,11 +516,23 @@ func TestStatusJSON_TokensStripped(t *testing.T) {
|
|||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
var state map[string]models.Site
|
|
||||||
json.NewDecoder(resp.Body).Decode(&state)
|
// Decode raw so absent struct fields can't mask leaked JSON keys.
|
||||||
|
var state map[string]map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&state); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(state) != 1 {
|
||||||
|
t.Fatalf("expected 1 site in status JSON, got %d", len(state))
|
||||||
|
}
|
||||||
for _, site := range state {
|
for _, site := range state {
|
||||||
if site.Token != "" {
|
if site["Name"] != "test" {
|
||||||
t.Error("expected token stripped from status JSON response")
|
t.Errorf("expected Name to be public, got %v", site["Name"])
|
||||||
|
}
|
||||||
|
for _, leaked := range []string{"Token", "LastError", "Hostname", "Port", "DNSServer", "AlertID", "AcceptedCodes", "Interval"} {
|
||||||
|
if _, ok := site[leaked]; ok {
|
||||||
|
t.Errorf("status JSON leaks internal field %q", leaked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -656,7 +686,7 @@ func TestRedactByProvider(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
out := redactByProvider(tt.typ, tt.in)
|
out := models.RedactAlertSettings(tt.typ, tt.in)
|
||||||
for _, k := range tt.redacted {
|
for _, k := range tt.redacted {
|
||||||
if out[k] != "***REDACTED***" {
|
if out[k] != "***REDACTED***" {
|
||||||
t.Errorf("key %q: expected redacted, got %q", k, out[k])
|
t.Errorf("key %q: expected redacted, got %q", k, out[k])
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
neturl "net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
@@ -100,15 +103,17 @@ func (m Model) fmtAlertConfig(alert struct {
|
|||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
case "pagerduty":
|
case "pagerduty":
|
||||||
if key := alert.Settings["routing_key"]; key != "" {
|
if key := alert.Settings["routing_key"]; key != "" {
|
||||||
return limitStr(key, 34)
|
return limitStr(maskSecret(key), 34)
|
||||||
}
|
}
|
||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
case "pushover":
|
case "pushover":
|
||||||
if user := alert.Settings["user"]; user != "" {
|
if user := alert.Settings["user"]; user != "" {
|
||||||
return limitStr(fmt.Sprintf("user:%s", user), 34)
|
return limitStr(fmt.Sprintf("user:%s", maskSecret(user)), 34)
|
||||||
}
|
}
|
||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
case "gotify":
|
case "gotify":
|
||||||
|
// The gotify server URL identifies the target; the token is the
|
||||||
|
// secret and is never shown here.
|
||||||
if url := alert.Settings["url"]; url != "" {
|
if url := alert.Settings["url"]; url != "" {
|
||||||
return limitStr(url, 34)
|
return limitStr(url, 34)
|
||||||
}
|
}
|
||||||
@@ -116,10 +121,7 @@ func (m Model) fmtAlertConfig(alert struct {
|
|||||||
case "opsgenie":
|
case "opsgenie":
|
||||||
key := alert.Settings["api_key"]
|
key := alert.Settings["api_key"]
|
||||||
if key != "" {
|
if key != "" {
|
||||||
masked := key
|
masked := maskSecret(key)
|
||||||
if len(masked) > 8 {
|
|
||||||
masked = masked[:4] + "…" + masked[len(masked)-4:]
|
|
||||||
}
|
|
||||||
if alert.Settings["eu"] == "true" {
|
if alert.Settings["eu"] == "true" {
|
||||||
return limitStr(fmt.Sprintf("EU %s", masked), 34)
|
return limitStr(fmt.Sprintf("EU %s", masked), 34)
|
||||||
}
|
}
|
||||||
@@ -127,13 +129,33 @@ func (m Model) fmtAlertConfig(alert struct {
|
|||||||
}
|
}
|
||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
default:
|
default:
|
||||||
if val, ok := alert.Settings["url"]; ok {
|
// discord/slack/webhook: the URL path IS the credential — show only
|
||||||
return limitStr(val, 34)
|
// enough to identify the target.
|
||||||
|
if val, ok := alert.Settings["url"]; ok && val != "" {
|
||||||
|
return limitStr(maskWebhookURL(val), 34)
|
||||||
}
|
}
|
||||||
return m.st.subtleStyle.Render("—")
|
return m.st.subtleStyle.Render("—")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maskSecret keeps just enough of a credential to identify it.
|
||||||
|
func maskSecret(s string) string {
|
||||||
|
if len(s) > 8 {
|
||||||
|
return s[:4] + "…" + s[len(s)-4:]
|
||||||
|
}
|
||||||
|
return "●●●●●●●●"
|
||||||
|
}
|
||||||
|
|
||||||
|
// maskWebhookURL shows scheme and host only. For discord, slack, and generic
|
||||||
|
// webhooks the URL path carries the token, so the path is never rendered.
|
||||||
|
func maskWebhookURL(raw string) string {
|
||||||
|
u, err := neturl.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return "●●●●●●●●"
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host + "/…"
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) fmtAlertHealth(h monitor.AlertHealth) string {
|
func (m Model) fmtAlertHealth(h monitor.AlertHealth) string {
|
||||||
if h.LastSendAt.IsZero() {
|
if h.LastSendAt.IsZero() {
|
||||||
return m.st.subtleStyle.Render("●")
|
return m.st.subtleStyle.Render("●")
|
||||||
@@ -229,7 +251,21 @@ func (m Model) viewAlertDetailPanel() string {
|
|||||||
|
|
||||||
b.WriteString(m.divider() + "\n")
|
b.WriteString(m.divider() + "\n")
|
||||||
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
|
b.WriteString(m.st.subtleStyle.Render(" CONFIGURATION") + "\n")
|
||||||
for k, v := range a.Settings {
|
// Render through the same allowlist the backup export uses — this panel
|
||||||
|
// ends up in screen shares and asciinema recordings. Keys are sorted so
|
||||||
|
// rows don't reshuffle every render.
|
||||||
|
redacted := models.RedactAlertSettings(a.Type, a.Settings)
|
||||||
|
keys := make([]string, 0, len(redacted))
|
||||||
|
for k := range redacted {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
v := redacted[k]
|
||||||
|
if v == "***REDACTED***" {
|
||||||
|
row(k, m.st.subtleStyle.Render("●●●●●●●●"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
row(k, v)
|
row(k, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertDetailPanel_MasksSecretsStableOrder(t *testing.T) {
|
||||||
|
m := newTestModel(&tuiMockStore{})
|
||||||
|
m.termWidth, m.termHeight = 120, 40
|
||||||
|
m.alerts = []models.AlertConfig{{
|
||||||
|
ID: 1, Name: "ops", Type: "email",
|
||||||
|
Settings: map[string]string{
|
||||||
|
"host": "smtp.example.com",
|
||||||
|
"port": "587",
|
||||||
|
"user": "oncall@example.com",
|
||||||
|
"pass": "hunter2-secret",
|
||||||
|
"to": "team@example.com",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
m.cursor = 0
|
||||||
|
|
||||||
|
out := m.viewAlertDetailPanel()
|
||||||
|
if strings.Contains(out, "hunter2-secret") {
|
||||||
|
t.Error("SMTP password rendered in alert detail panel")
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "oncall@example.com") {
|
||||||
|
t.Error("SMTP user (not on the allowlist) rendered in alert detail panel")
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "smtp.example.com") {
|
||||||
|
t.Error("allowlisted setting (host) missing from panel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map iteration must not reshuffle rows between renders.
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if m.viewAlertDetailPanel() != out {
|
||||||
|
t.Fatal("panel output unstable across renders — settings keys not sorted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFmtAlertConfig_MasksSecrets(t *testing.T) {
|
||||||
|
m := newTestModel(&tuiMockStore{})
|
||||||
|
|
||||||
|
webhook := m.fmtAlertConfig(struct {
|
||||||
|
Type string
|
||||||
|
Settings map[string]string
|
||||||
|
}{"discord", map[string]string{"url": "https://discord.com/api/webhooks/123456/SeCrEtToKeN"}})
|
||||||
|
if strings.Contains(webhook, "SeCrEtToKeN") || strings.Contains(webhook, "123456") {
|
||||||
|
t.Errorf("webhook URL path (the credential) rendered in table: %q", webhook)
|
||||||
|
}
|
||||||
|
if !strings.Contains(webhook, "discord.com") {
|
||||||
|
t.Errorf("webhook host missing from table config: %q", webhook)
|
||||||
|
}
|
||||||
|
|
||||||
|
pd := m.fmtAlertConfig(struct {
|
||||||
|
Type string
|
||||||
|
Settings map[string]string
|
||||||
|
}{"pagerduty", map[string]string{"routing_key": "R0123456789ABCDEFGHIJ"}})
|
||||||
|
if strings.Contains(pd, "R0123456789ABCDEFGHIJ") {
|
||||||
|
t.Errorf("pagerduty routing key rendered raw in table: %q", pd)
|
||||||
|
}
|
||||||
|
if !strings.Contains(pd, "R012") || !strings.Contains(pd, "GHIJ") {
|
||||||
|
t.Errorf("masked routing key should keep identifying ends: %q", pd)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user