diff --git a/alerts.go b/alerts.go index 4ffd625..04cfe65 100644 --- a/alerts.go +++ b/alerts.go @@ -28,14 +28,17 @@ const ( defaultOffline = 90 * time.Second ) -// AlertChannel is somewhere alerts are sent: an email address or a webhook. +// AlertChannel is somewhere alerts are sent: an email address, a webhook, or a +// Sandbar server (which pages whoever is on call and tracks the alert). type AlertChannel struct { ID string `json:"id"` OrgID string `json:"orgId"` // "" = a server-level channel (for edges) Name string `json:"name"` - Kind string `json:"kind"` // "email" or "webhook" - Target string `json:"target"` // address(es), or a webhook URL (secret: URLs often carry tokens) + Kind string `json:"kind"` // "email", "webhook" or "sandbar" + Target string `json:"target"` // address(es), a webhook URL, or Sandbar's address (secret: URLs often carry tokens) Format string `json:"format"` // webhook body: "json", "slack", "discord" or "ntfy" + Token string `json:"token"` // Sandbar: API key (secret, never sent back to the browser) + Policy string `json:"policy"` // Sandbar: escalation policy id; blank sends the alert without paging anyone AllowPrivate bool `json:"allowPrivate"` Enabled bool `json:"enabled"` } @@ -50,6 +53,7 @@ type alertState struct { type problem struct{ org, kind, name, msg string } type alertNote struct { + Key string `json:"key"` // stable per problem, so a recovery can find its alert Status string `json:"status"` // "firing", "resolved" or "test" Org string `json:"org"` Kind string `json:"kind"` @@ -251,7 +255,7 @@ func (s *Server) evaluateAlerts(now time.Time) { chans = append(chans, *c) } } - out = append(out, delivery{alertNote{Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans}) + out = append(out, delivery{alertNote{Key: st.Key, Status: status, Org: orgName(st.OrgID), Kind: st.Kind, Name: st.Name, Message: st.Message, Since: st.Since, Time: now}, chans}) s.db.addAlertEvent(now, st.OrgID, st.Key, status, st.Name, st.Message) } keys := make([]string, 0, len(probs)) @@ -362,6 +366,8 @@ func (s *Server) send(ch AlertChannel, n alertNote) error { } } return errors.Join(errs...) + case "sandbar": + return s.sendSandbar(ch, n) case "webhook": body, ctype, hdr := webhookBody(ch.Format, n) req, err := http.NewRequest("POST", ch.Target, bytes.NewReader(body)) @@ -385,6 +391,47 @@ func (s *Server) send(ch AlertChannel, n alertNote) error { return errors.New("unknown channel kind") } +// sendSandbar raises the alert on a Sandbar server, or resolves it there when the +// problem clears. Sandbar de-duplicates by key, so a reminder updates the alert +// that is already open instead of opening another. A test raises an info alert +// with no policy (nobody is paged) and resolves it straight away. +func (s *Server) sendSandbar(ch AlertChannel, n alertNote) error { + post := func(body map[string]string) error { + raw, _ := json.Marshal(body) + req, err := http.NewRequest("POST", strings.TrimRight(ch.Target, "/")+"/api/v1/alerts", bytes.NewReader(raw)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+ch.Token) + resp, err := netguard.Client(ch.AllowPrivate).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + switch { + case resp.StatusCode == 401 || resp.StatusCode == 403: + return errors.New("Sandbar rejected the API key") + case resp.StatusCode >= 300: + return fmt.Errorf("Sandbar answered %s", resp.Status) + } + return nil + } + key := "hammerhead:" + n.Key + switch n.Status { + case "resolved": + return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"}) + case "test": + key = "hammerhead:test:" + ch.ID + if err := post(map[string]string{"key": key, "source": "hammerhead", "summary": "Hammerhead test alert", "detail": n.text(), "severity": "info"}); err != nil { + return err + } + return post(map[string]string{"key": key, "status": "resolved", "source": "hammerhead"}) + } + return post(map[string]string{"key": key, "source": "hammerhead", "summary": n.Name + ": " + n.Message, + "detail": "Organization: " + n.Org + "\nSince: " + n.Since.Format(time.RFC1123), "severity": "critical", "policy": ch.Policy}) +} + func webhookBody(format string, n alertNote) (body []byte, ctype string, hdr map[string]string) { text := n.subject() + "\n" + n.text() switch format { @@ -415,12 +462,25 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error { return errors.New("enter one or more email addresses, separated by commas") } } - ch.Format, ch.AllowPrivate = "", false + ch.Format, ch.Token, ch.Policy, ch.AllowPrivate = "", "", "", false + case "sandbar": + u, err := url.Parse(ch.Target) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return errors.New("enter Sandbar's address, such as https://sandbar.example.com") + } + if ch.Token = strings.TrimSpace(ch.Token); ch.Token == "" { + return errors.New("enter an API key from Sandbar's Account page") + } + ch.Policy, ch.Format = strings.TrimSpace(ch.Policy), "" + if ch.AllowPrivate && !trusted { + return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization") + } case "webhook": u, err := url.Parse(ch.Target) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return errors.New("the webhook must be an http:// or https:// URL") } + ch.Token, ch.Policy = "", "" switch ch.Format { case "json", "slack", "discord", "ntfy": case "": @@ -432,7 +492,7 @@ func (s *Server) validateChannel(ch *AlertChannel, trusted bool) error { return errors.New("sending to private addresses (like a service on your home network) needs a server administrator to allow it for this organization") } default: - return errors.New("a channel is an email or a webhook") + return errors.New("a channel is an email, a webhook or a Sandbar server") } if ch.Name == "" { ch.Name = ch.Kind diff --git a/alerts_api.go b/alerts_api.go index 64f537f..656e319 100644 --- a/alerts_api.go +++ b/alerts_api.go @@ -23,7 +23,7 @@ type channelView struct { } func preview(ch AlertChannel) string { - if ch.Kind != "webhook" { + if ch.Kind == "email" { return ch.Target } if u, err := url.Parse(ch.Target); err == nil { @@ -47,7 +47,7 @@ func (s *Server) alertOverview(w http.ResponseWriter, sc alertScope) { for _, c := range s.st.Channels { if c.OrgID == sc.org { v := channelView{AlertChannel: *c, Preview: preview(*c)} - v.Target = "" // webhook URLs often contain tokens + v.Target, v.Token = "", "" // webhook URLs often contain tokens if c.Kind == "email" { v.Target = c.Target } @@ -95,6 +95,9 @@ func (s *Server) saveChannel(w http.ResponseWriter, r *http.Request, sc alertSco if in.Target == "" { in.Target = existing.Target // blank keeps the saved URL/address } + if id != "" && in.Token == "" && in.Kind == "sandbar" { + in.Token = existing.Token // blank keeps the saved API key + } } trusted := sc.trusted if err := s.validateChannel(&in, trusted); err != nil { diff --git a/alerts_test.go b/alerts_test.go index 94a8134..075ec28 100644 --- a/alerts_test.go +++ b/alerts_test.go @@ -10,8 +10,8 @@ import ( "testing" "time" - "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" + "git.veillax.com/Veillax/reef/secretbox" ) type hook struct { @@ -487,3 +487,57 @@ func TestPromEscape(t *testing.T) { t.Errorf("promEscape = %q", got) } } + +func TestSandbarChannelRaisesAndResolves(t *testing.T) { + f := newFx(t) + h := newHook(t) + f.s.mu.Lock() + f.s.st.Channels = []*AlertChannel{{ID: "c2", OrgID: f.org, Name: "pager", Kind: "sandbar", Target: strings.TrimSuffix(h.URL, "/hook/secret-token"), + Token: "sb_secret", Policy: "pol1", AllowPrivate: true, Enabled: true}} + f.s.mu.Unlock() + + f.eval(f.base) + f.setHealthy(false) + f.eval(f.base.Add(10 * time.Second)) + f.eval(f.base.Add(3 * time.Minute)) + var up map[string]string + if err := json.Unmarshal([]byte(h.next(2*time.Second)), &up); err != nil { + t.Fatal("no Sandbar request:", err) + } + if up["key"] != "hammerhead:target:m1:0" || up["policy"] != "pol1" || up["severity"] != "critical" || !strings.Contains(up["summary"], "Minecraft") { + t.Fatalf("unexpected alert sent to Sandbar: %v", up) + } + h.mu.Lock() + auth := h.hdrs[0].Get("Authorization") + h.mu.Unlock() + if auth != "Bearer sb_secret" { + t.Fatalf("API key not sent: %q", auth) + } + + f.setHealthy(true) + f.eval(f.base.Add(4 * time.Minute)) + var down map[string]string + if err := json.Unmarshal([]byte(h.next(2*time.Second)), &down); err != nil { + t.Fatal("no resolve request:", err) + } + if down["key"] != up["key"] || down["status"] != "resolved" { + t.Fatalf("recovery did not resolve the same alert: %v", down) + } +} + +func TestSandbarChannelValidation(t *testing.T) { + f := newFx(t) + for _, ch := range []AlertChannel{ + {Kind: "sandbar", Target: "not a url", Token: "k"}, + {Kind: "sandbar", Target: "https://sandbar.example.com"}, // no API key + } { + ch := ch + if err := f.s.validateChannel(&ch, true); err == nil { + t.Fatalf("accepted %+v", ch) + } + } + ok := AlertChannel{Kind: "sandbar", Target: "https://sandbar.example.com", Token: " k ", Policy: " p "} + if err := f.s.validateChannel(&ok, true); err != nil || ok.Token != "k" || ok.Policy != "p" { + t.Fatalf("%v %+v", err, ok) + } +} diff --git a/cmd/hammerhead/main.go b/cmd/hammerhead/main.go index d301c79..bd6aeef 100644 --- a/cmd/hammerhead/main.go +++ b/cmd/hammerhead/main.go @@ -13,8 +13,8 @@ import ( "time" "git.veillax.com/Veillax/hammerhead" - "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/dotenv" + "git.veillax.com/Veillax/reef/secretbox" "git.veillax.com/Veillax/reef/selfupdate" ) diff --git a/db.go b/db.go index 1ac4af5..ea65bfa 100644 --- a/db.go +++ b/db.go @@ -9,8 +9,8 @@ import ( "sync" "time" - "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" + "git.veillax.com/Veillax/reef/secretbox" _ "modernc.org/sqlite" // pure Go, so no C compiler is needed ) @@ -113,7 +113,7 @@ func (d *DB) sealed(v any) any { return &c case *AlertChannel: c := *x - c.Target = b.Seal(c.Target) + c.Target, c.Token = b.Seal(c.Target), b.Seal(c.Token) return &c case Settings: x.SMTP.Pass = b.Seal(x.SMTP.Pass) @@ -144,7 +144,7 @@ func (d *DB) unseal(v any) error { case *model.DKIMKey: return d.open1(&x.PrivateKey) case *AlertChannel: - return d.open1(&x.Target) + return errors.Join(d.open1(&x.Target), d.open1(&x.Token)) case *Settings: return d.open1(&x.SMTP.Pass) } diff --git a/db_test.go b/db_test.go index f31f375..d1cba5f 100644 --- a/db_test.go +++ b/db_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" + "git.veillax.com/Veillax/reef/secretbox" ) func apiClient(t *testing.T, s *Server) (do func(method, path string, in any) (int, []byte)) { diff --git a/go.mod b/go.mod index 0fd1c54..04a0fc6 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.3 require ( git.veillax.com/Veillax/bamboo v0.1.5 git.veillax.com/Veillax/blacktip v0.1.8 - git.veillax.com/Veillax/reef v0.1.7 + git.veillax.com/Veillax/reef v0.1.9 github.com/emersion/go-msgauth v0.7.0 golang.org/x/crypto v0.58.0 modernc.org/sqlite v1.61.0 diff --git a/go.sum b/go.sum index a25d86e..e31c67b 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,9 @@ -git.veillax.com/Veillax/bamboo v0.1.0 h1:16BG1P8982dIkHA2l0LPS0xYQc8d86PruVAGxc8BP84= -git.veillax.com/Veillax/bamboo v0.1.0/go.mod h1:i4l4uJgG6Ymfwbjnlz0xeTt3qW/+1W1L2JBToMw50n0= -git.veillax.com/Veillax/bamboo v0.1.3 h1:A66gz9GmmtUq1iIGUAL2ItIeC6iTy5pvqJOoIjRHjFg= -git.veillax.com/Veillax/bamboo v0.1.3/go.mod h1:UuHV6aNHmV9jpow0xeGlXX0QhMB9glPJUGzgIM7S1MM= -git.veillax.com/Veillax/bamboo v0.1.4 h1:pgiBJA46CBJx7T3bloCjPekb6wl6W5H8teokFAH5gj0= -git.veillax.com/Veillax/bamboo v0.1.4/go.mod h1:wzSpfql7U4a3b9mCVH01OPG9H9szrzwSFDufWw02e0g= git.veillax.com/Veillax/bamboo v0.1.5 h1:i81c75/7wjayP9tOQ3tSnJWTBhvbjAwNBEPhT+3P2Xk= git.veillax.com/Veillax/bamboo v0.1.5/go.mod h1:se4kkcP6c3uCGD4HQd+nDLoblS0lN8qa+eFWLo5Horw= -git.veillax.com/Veillax/blacktip v0.1.0 h1:L8WpW56gqtvZ1IAapE95Ynl6NKjqTA98WwmEH01kFFk= -git.veillax.com/Veillax/blacktip v0.1.0/go.mod h1:sj+mr8393rajzWbl0+RRZrstGyDtZ/GHsut6ebScVxg= -git.veillax.com/Veillax/blacktip v0.1.4 h1:o3uNoA0ucB/qcN9ccofysdFA6RTEU4jKdgeTWuE2Uw0= -git.veillax.com/Veillax/blacktip v0.1.4/go.mod h1:f7zXCYlQf/9ffDLjPrMHylKAyXX4Nb+aQ7kErmOOhqI= -git.veillax.com/Veillax/blacktip v0.1.6 h1:dUJzNGXgieWX05ftWjnMfh6norhaeKTqo1P13lJvjtk= -git.veillax.com/Veillax/blacktip v0.1.6/go.mod h1:ZnPRc2gPh9ElILfPH39G/x3B6KAiHQyeoDLycSDgVPM= -git.veillax.com/Veillax/blacktip v0.1.7 h1:PjxEkhE4fnGWht9Pj2/l6S8fOX2Iqjir3rc9vfpMFiQ= -git.veillax.com/Veillax/blacktip v0.1.7/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc= git.veillax.com/Veillax/blacktip v0.1.8 h1:TR4HfXlGwrNjCqOY+xUnJCJwESRTwFerb2wVhq/zbUk= git.veillax.com/Veillax/blacktip v0.1.8/go.mod h1:dVm4wC6wufPjFzK8pvHnqNJJlxroRYmnq8mdkCqOKGc= -git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROpCs= -git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= -git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI= -git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= -git.veillax.com/Veillax/reef v0.1.4 h1:T2+B8P1GAitEyTYRkwMT3UHbG9mN5zXb/inE2muXdQ4= -git.veillax.com/Veillax/reef v0.1.4/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= -git.veillax.com/Veillax/reef v0.1.6 h1:NgZ4u2Z3hJ+BYD/zX9lov8fox4s2i8txtYNOpIsikVo= -git.veillax.com/Veillax/reef v0.1.6/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= -git.veillax.com/Veillax/reef v0.1.7 h1:5C2udonlJac2xvDOE5e+zDxej+AbZ9ttrNSE4owmwuU= -git.veillax.com/Veillax/reef v0.1.7/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= +git.veillax.com/Veillax/reef v0.1.9 h1:dmC+oPmRQIOVfx2kgS95QvRDn2wudwpZw7H9p0m6W8Y= +git.veillax.com/Veillax/reef v0.1.9/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso= github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g= github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U= github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg= diff --git a/infra.go b/infra.go index ed40d96..254ae49 100644 --- a/infra.go +++ b/infra.go @@ -204,6 +204,43 @@ func (s *Server) addSite(w http.ResponseWriter, r *http.Request, c *orgCtx) { writeJSON(w, 200, st) } +// putSite renames a site and sets its tags. The built-in local site is shared between +// organizations, so it can't be edited here. +func (s *Server) putSite(w http.ResponseWriter, r *http.Request, c *orgCtx) { + var in struct { + Name string `json:"name"` + Tags []string `json:"tags"` + } + if !decode(w, r, &in) { + return + } + in.Name = strings.TrimSpace(in.Name) + if in.Name == "" { + fail(w, 400, errors.New("name is required")) + return + } + tags, err := normalizeTags(in.Tags) + if err != nil { + fail(w, 400, err) + return + } + s.mu.Lock() + defer s.mu.Unlock() + st := s.findSite(r.PathValue("id")) + if st == nil || st.OrgID != c.Org.ID { + fail(w, 404, errors.New("no such site")) + return + } + if st.Local { + fail(w, 400, errors.New("the built-in local site can't be edited")) + return + } + st.Name, st.Tags = in.Name, tags + s.bump() + s.audit(c.Org.ID, c.Actor, "site.update", st.Name) + ok(w) +} + func (s *Server) deleteSite(w http.ResponseWriter, r *http.Request, c *orgCtx) { id := r.PathValue("id") s.mu.Lock() diff --git a/internal/secretbox/secretbox.go b/internal/secretbox/secretbox.go deleted file mode 100644 index 159c7df..0000000 --- a/internal/secretbox/secretbox.go +++ /dev/null @@ -1,87 +0,0 @@ -// Package secretbox encrypts small secrets (tokens, passwords, private keys) -// before they are written to the database, using AES-256-GCM. -// -// Sealed values look like "enc1:". Opening a value that isn't sealed -// returns it unchanged, so data written before encryption was turned on is -// still readable and gets sealed the next time it is saved. -package secretbox - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "errors" - "strings" -) - -const prefix = "enc1:" - -type Box struct{ aead cipher.AEAD } - -// GenerateKey returns a new random key in the form New accepts. -func GenerateKey() string { - b := make([]byte, 32) - rand.Read(b) - return base64.StdEncoding.EncodeToString(b) -} - -// New builds a Box from a key given as base64 or hex of 32 bytes. Any other -// text is treated as a passphrase and stretched with SHA-256. -func New(key string) (*Box, error) { - key = strings.TrimSpace(key) - if key == "" { - return nil, errors.New("empty key") - } - raw := decodeKey(key) - block, err := aes.NewCipher(raw) - if err != nil { - return nil, err - } - aead, err := cipher.NewGCM(block) - if err != nil { - return nil, err - } - return &Box{aead: aead}, nil -} - -func decodeKey(key string) []byte { - if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 { - return b - } - if b, err := hex.DecodeString(key); err == nil && len(b) == 32 { - return b - } - sum := sha256.Sum256([]byte(key)) - return sum[:] -} - -// Seal encrypts s. The empty string stays empty so unset secrets stay visibly unset. -func (b *Box) Seal(s string) string { - if s == "" || strings.HasPrefix(s, prefix) { - return s - } - nonce := make([]byte, b.aead.NonceSize()) - rand.Read(nonce) - return prefix + base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, []byte(s), nil)) -} - -// Open decrypts a sealed value. A wrong key or damaged data is an error, so a -// bad key can never silently turn secrets into garbage. -func (b *Box) Open(s string) (string, error) { - if !strings.HasPrefix(s, prefix) { - return s, nil - } - raw, err := base64.StdEncoding.DecodeString(s[len(prefix):]) - if err != nil || len(raw) < b.aead.NonceSize() { - return "", errors.New("damaged secret") - } - n := b.aead.NonceSize() - plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil) - if err != nil { - return "", errors.New("can't decrypt a stored secret: is HAMMERHEAD_MASTER_KEY the key it was saved with?") - } - return string(plain), nil -} diff --git a/internal/secretbox/secretbox_test.go b/internal/secretbox/secretbox_test.go deleted file mode 100644 index c8f2822..0000000 --- a/internal/secretbox/secretbox_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package secretbox - -import ( - "strings" - "testing" -) - -func TestSealOpenRoundTripAndKeys(t *testing.T) { - key := GenerateKey() - b, err := New(key) - if err != nil { - t.Fatal(err) - } - sealed := b.Seal("hunter2") - if !strings.HasPrefix(sealed, "enc1:") || strings.Contains(sealed, "hunter2") { - t.Fatalf("not sealed: %q", sealed) - } - if again := b.Seal("hunter2"); again == sealed { - t.Error("two seals of the same value must differ (random nonce)") - } - if b.Seal(sealed) != sealed { - t.Error("sealing an already sealed value must not double-encrypt") - } - if got, err := b.Open(sealed); err != nil || got != "hunter2" { - t.Errorf("Open = %q, %v", got, err) - } - if got, _ := b.Open("legacy plaintext"); got != "legacy plaintext" { - t.Errorf("unsealed legacy data should pass through, got %q", got) - } - if b.Seal("") != "" { - t.Error("empty stays empty") - } - - other, _ := New(GenerateKey()) - if _, err := other.Open(sealed); err == nil { - t.Error("the wrong key must fail loudly, not return garbage") - } - if _, err := b.Open(sealed[:len(sealed)-4] + "AAAA"); err == nil { - t.Error("tampered data must fail") - } - - // a passphrase works as a key, and is stable - p1, _ := New("correct horse battery staple") - p2, _ := New("correct horse battery staple") - if got, err := p2.Open(p1.Seal("x")); err != nil || got != "x" { - t.Errorf("passphrase keys must be reproducible: %q %v", got, err) - } - if _, err := New(" "); err == nil { - t.Error("an empty key must be rejected") - } -} diff --git a/mappings.go b/mappings.go index 1270e49..635443f 100644 --- a/mappings.go +++ b/mappings.go @@ -53,9 +53,39 @@ func listFromCSV(in []string) []string { return out } +// normalizeTags trims tags, drops blanks and repeats (ignoring case; the first spelling wins) +// and keeps the list short enough to stay readable in the dashboard. +func normalizeTags(in []string) ([]string, error) { + var out []string + seen := map[string]bool{} + for _, t := range in { + t = strings.Join(strings.Fields(t), " ") + if t == "" || seen[strings.ToLower(t)] { + continue + } + if len([]rune(t)) > 32 { + return nil, errors.New("A tag can be at most 32 characters.") + } + if strings.ContainsAny(t, ",<>\"") { + return nil, errors.New("Tags can't contain commas, quotes or angle brackets.") + } + seen[strings.ToLower(t)] = true + out = append(out, t) + } + if len(out) > 10 { + return nil, errors.New("Use at most 10 tags.") + } + return out, nil +} + // validateMapping checks m against org c and normalises it. Caller holds s.mu. func (s *Server) validateMapping(c *orgCtx, m *model.Mapping) error { m.Name = strings.TrimSpace(m.Name) + tags, err := normalizeTags(m.Tags) + if err != nil { + return err + } + m.Tags = tags m.Hostname = strings.ToLower(strings.TrimSpace(m.Hostname)) m.OrgID = c.Org.ID switch m.Proto { diff --git a/selfedge_test.go b/selfedge_test.go index db6bf7d..bf5c9ed 100644 --- a/selfedge_test.go +++ b/selfedge_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" + "git.veillax.com/Veillax/reef/secretbox" ) func edgeConfigOf(t *testing.T, s *Server, token string) model.EdgeConfig { diff --git a/server.go b/server.go index 661c99f..2b60aae 100644 --- a/server.go +++ b/server.go @@ -127,6 +127,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/orgs/{org}/apikeys", s.withOrg(true, s.createKey)) mux.HandleFunc("DELETE /api/orgs/{org}/apikeys/{id}", s.withOrg(true, s.deleteKey)) mux.HandleFunc("POST /api/orgs/{org}/sites", s.withOrg(true, s.addSite)) + mux.HandleFunc("PUT /api/orgs/{org}/sites/{id}", s.withOrg(true, s.putSite)) mux.HandleFunc("DELETE /api/orgs/{org}/sites/{id}", s.withOrg(true, s.deleteSite)) mux.HandleFunc("GET /api/orgs/{org}/sites/{id}/containers", s.withOrg(true, s.siteContainers)) mux.HandleFunc("POST /api/orgs/{org}/domains", s.withOrg(true, s.addDomain)) diff --git a/store.go b/store.go index 0878b93..6a99b1c 100644 --- a/store.go +++ b/store.go @@ -16,8 +16,8 @@ import ( "sync" "time" - "git.veillax.com/Veillax/hammerhead/internal/secretbox" "git.veillax.com/Veillax/reef/model" + "git.veillax.com/Veillax/reef/secretbox" "unicode" "unicode/utf8" ) diff --git a/tags_test.go b/tags_test.go new file mode 100644 index 0000000..aa280bc --- /dev/null +++ b/tags_test.go @@ -0,0 +1,122 @@ +package hammerhead + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" +) + +func TestNormalizeTags(t *testing.T) { + got, err := normalizeTags([]string{" Minecraft ", "minecraft", "", "prod east", "PROD east"}) + if err != nil || !reflect.DeepEqual(got, []string{"Minecraft", "prod east"}) { + t.Fatalf("got %v, %v", got, err) + } + if got, err := normalizeTags(nil); err != nil || got != nil { + t.Fatalf("nil in should give nil out: %v, %v", got, err) + } + tooMany := make([]string, 11) + for i := range tooMany { + tooMany[i] = "t" + strings.Repeat("x", i) + } + for _, bad := range [][]string{{strings.Repeat("a", 33)}, {"a,b"}, {""}, tooMany} { + if _, err := normalizeTags(bad); err == nil { + t.Errorf("accepted %v", bad) + } + } +} + +func TestSiteTagsAndRenameThroughTheAPI(t *testing.T) { + s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + do := apiClient(t, s) + _, b := do("GET", "/api/me", nil) + var me struct{ Orgs []struct{ ID string } } + json.Unmarshal(b, &me) + org := me.Orgs[0].ID + + code, raw := do("POST", "/api/orgs/"+org+"/sites", map[string]any{"name": "Home"}) + if code != 200 { + t.Fatalf("add site: %d %s", code, raw) + } + var site struct{ ID string } + json.Unmarshal(raw, &site) + + if code, raw := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, map[string]any{"name": " Home lab ", "tags": []string{"Lab", "lab", "nas"}}); code != 200 { + t.Fatalf("edit site: %d %s", code, raw) + } + _, raw = do("GET", "/api/orgs/"+org+"/state", nil) + var st struct { + Sites []struct { + ID, Name string + Tags []string + Local bool + } + } + json.Unmarshal(raw, &st) + found := false + for _, x := range st.Sites { + if x.ID == site.ID { + found = true + if x.Name != "Home lab" || !reflect.DeepEqual(x.Tags, []string{"Lab", "nas"}) { + t.Fatalf("site after edit: %+v", x) + } + } + } + if !found { + t.Fatalf("site missing from state: %s", raw) + } + + for name, body := range map[string]map[string]any{"blank name": {"name": " "}, "bad tag": {"name": "x", "tags": []string{"a,b"}}} { + if code, _ := do("PUT", "/api/orgs/"+org+"/sites/"+site.ID, body); code != 400 { + t.Errorf("%s: expected 400, got %d", name, code) + } + } + if code, _ := do("PUT", "/api/orgs/"+org+"/sites/nope", map[string]any{"name": "x"}); code != 404 { + t.Errorf("unknown site: expected 404, got %d", code) + } +} + +func TestSandbarKeyIsNeverSentBackAndBlankKeepsIt(t *testing.T) { + s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", AlertInterval: time.Hour}) + if err != nil { + t.Fatal(err) + } + defer s.Close() + do := apiClient(t, s) + _, b := do("GET", "/api/me", nil) + var me struct{ Orgs []struct{ ID string } } + json.Unmarshal(b, &me) + org := me.Orgs[0].ID + + code, raw := do("POST", "/api/orgs/"+org+"/alerts/channels", map[string]any{"name": "pager", "kind": "sandbar", "target": "https://sandbar.example.com", "token": "sb_topsecret", "policy": "p1", "enabled": true}) + if code != 200 { + t.Fatalf("create channel: %d %s", code, raw) + } + _, raw = do("GET", "/api/orgs/"+org+"/alerts", nil) + if strings.Contains(string(raw), "sb_topsecret") || strings.Contains(string(raw), "sandbar.example.com/api") { + t.Fatalf("the overview leaks Sandbar details: %s", raw) + } + var ov struct { + Channels []struct{ ID, Kind, Policy string } + } + json.Unmarshal(raw, &ov) + if len(ov.Channels) != 1 || ov.Channels[0].Kind != "sandbar" || ov.Channels[0].Policy != "p1" { + t.Fatalf("overview: %s", raw) + } + // editing without re-entering the address or key keeps both + if code, raw := do("PUT", "/api/orgs/"+org+"/alerts/channels/"+ov.Channels[0].ID, map[string]any{"name": "pager 2", "kind": "sandbar", "policy": "p2", "enabled": true}); code != 200 { + t.Fatalf("edit channel: %d %s", code, raw) + } + s.mu.Lock() + ch := s.st.Channels[0] + kept := ch.Token == "sb_topsecret" && ch.Target == "https://sandbar.example.com" && ch.Policy == "p2" + s.mu.Unlock() + if !kept { + t.Fatalf("an edit lost the saved key or address: %+v", ch) + } +} diff --git a/web/index.html b/web/index.html index 606bfcd..fa0b9f5 100644 --- a/web/index.html +++ b/web/index.html @@ -159,6 +159,10 @@ .badge-warning { background:var(--warning-bg); color:var(--warning); } .badge-danger { background:var(--danger-bg); color:var(--danger); } .badge-accent { background:var(--accent-soft); color:var(--accent-text); } + .tags { display:flex; flex-wrap:wrap; gap:var(--s1); margin-top:var(--s1); } + .tagbar { display:flex; flex-wrap:wrap; gap:var(--s2); margin-bottom:var(--s3); } + .chip { min-height:44px; padding:0 var(--s3); border:var(--border-w) solid var(--border); border-radius:var(--radius); background:var(--surface); color:var(--text); font:inherit; cursor:pointer; } + .chip.on { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); } .tag { display:inline-block; padding:1px var(--s2); border:var(--border-w) solid var(--border); border-radius:var(--radius); font-size:var(--text-xs); color:var(--text-muted); } pre { background:var(--surface-2); border:var(--border-w) solid var(--border); border-radius:var(--radius); padding:var(--s3); overflow:auto; margin:var(--s2) 0 0; user-select:all; white-space:pre-wrap; word-break:break-all; } @@ -271,6 +275,25 @@ const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:' const byId = (list, id) => (list || []).find(x => x.id === id); const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null); const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); }; +// ----- tags ----- +// Tags are free-form labels on resources and sites. The filter choice is remembered per list until the page reloads. +const TAGSEL = {ports:'', sites:''}; +const allTags = (...lists) => [...new Set(lists.flat().flatMap(x => x.tags || []))].sort((a, b) => a.localeCompare(b)); +const hasTag = (x, t) => !t || (x.tags || []).some(v => v.toLowerCase() === t.toLowerCase()); +const tagList = x => (x.tags || []).length ? h('div', {class:'tags'}, x.tags.map(t => h('span', {class:'tag'}, t))) : null; +function tagInput(tags, known) { + const id = 'tags-' + Math.random().toString(36).slice(2, 8); + const i = h('input', {value:(tags || []).join(', '), placeholder:'e.g. minecraft, production', list:id}); + const el = field('Tags', h('div', {}, i, h('datalist', {id}, known.map(t => h('option', {value:t})))), 'Optional. Separate with commas. Use them to filter this list.'); + return {el, get:() => i.value.split(',').map(t => t.trim()).filter(Boolean)}; +} +// chips that narrow a list to one tag; pressing the active chip, or "All", clears it +function tagBar(key, tags) { + if (!tags.length) return null; + const chip = (label, val) => h('button', {class:'chip' + (TAGSEL[key] === val ? ' on' : ''), type:'button', 'aria-pressed':String(TAGSEL[key] === val), + onclick:() => { TAGSEL[key] = TAGSEL[key] === val ? '' : val; refresh(); }}, label); + return h('div', {class:'tagbar', role:'group', 'aria-label':'Filter by tag'}, chip('All', ''), tags.map(t => chip(t, t))); +} // ----- versions and updates ----- // A progress bar and live log for an update. load() returns {running, stage, pct, lines}; the panel polls it // until the update is over, and keeps going through the gap while a program restarts into its new version. @@ -600,7 +623,9 @@ views.ports = async () => { S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null); const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email)); const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({})); - const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port); + const tags = allTags(st.mappings); + if (TAGSEL.ports && !tags.some(t => t.toLowerCase() === TAGSEL.ports.toLowerCase())) TAGSEL.ports = ''; // the last resource with that tag is gone + const rows = st.mappings.filter(m => hasTag(m, TAGSEL.ports)).sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port); const isWebRow = m => m.proto === 'http' || m.proto === 'https' || m.proto === 'tls'; const section = (title, list) => list.length ? h('div', {class:'card flush'}, h('div', {class:'sect-h'}, title + ' (' + list.length + ')'), h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', title === 'Websites' ? 'Address' : 'Public address', 'Targets', 'Traffic (24 hours)', ''].map(t => h('th', {}, t)))), @@ -609,7 +634,7 @@ views.ports = async () => { S.headerAction = ['Add resource', () => portForm()]; const q = h('input', {type:'search', placeholder:'Search resources…', oninput:() => { const t = q.value.toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { tr.style.display = tr.textContent.toLowerCase().includes(t) ? '' : 'none'; }); }}); return h('div', {}, unsetDash ? h('div', {class:'notice'}, 'Some ports have a login in front of them, but the dashboard URL isn\'t set. Visitors are sent back to this address\'s login page, so set it under Server → Settings if people reach Hammerhead by a different address.') : null, - rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table); + tagBar('ports', tags), rows.length > 5 ? h('div', {class:'toolbar'}, q) : null, table); }; function spark(series) { @@ -642,6 +667,7 @@ function mappingRow(m, st, series) { return h('tr', {}, h('td', {title:why}, badge(label, tone)), h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null), + tagList(m), s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null, s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null), h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))), @@ -665,7 +691,8 @@ function portForm(existing) { // the public address is picked from the edge's own addresses, each shown with the domains pointed at it const ipIn = h('select', {}), ipList = document.createTextNode(''); const protoSel = h('select', {disabled:!!existing}, PROTOS.filter(p => !existing || p[0] !== 'both').map(([v, t]) => h('option', {value:v, selected:v === m.proto}, t))); - const name = h('input', {value:m.name || '', placeholder:'optional'}), port = h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'}); + const tagsIn = tagInput(m.tags, allTags(st.mappings)); + const name = h('input', {value:m.name || '', placeholder:'optional'}), port =h('input', {value:m.port ? portText(m) : '', placeholder:'25565 or 2456-2458'}); const host = h('input', {value:m.hostname || '', list:'doms', placeholder:'app.example.com or *.example.com'}); const domList = h('datalist', {id:'doms'}, st.domains.map(d => h('option', {value:d.name}))); const hostBox = field('Hostname', host, st.domains.length ? 'Must be under one of your domains' : 'Add a domain on the Domains tab first'); @@ -762,7 +789,7 @@ function portForm(existing) { if (!pr) throw new Error('Enter a port like 25565, or a range like 2456-2458'); const roles = roleChecks.filter(x => x.c.box.checked).map(x => x.r.id); const base = { - name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets, + name:name.value, edgeId:edgeSel.value, bindIp:ipIn.value || '0.0.0.0', port:+pr[1], portEnd:pr[2] ? +pr[2] : 0, hostname:host.value, targets, tags:tagsIn.get(), enabled:m.enabled !== false, rules:rules.filter(r => r.value.trim()), auth:{sso:ssoC.box.checked, password:pwC.box.checked, pin:pinC.box.checked, email:mailC.box.checked, allowedRoles:roles, allowedUsers:a.allowedUsers || [], allowedMails:mails.value.split(',').map(s => s.trim()).filter(Boolean), sessionHours:+hours.value || 0}, @@ -777,7 +804,7 @@ function portForm(existing) { close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh(); }); const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'}, - h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')), + h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn, 'Domains pointed at an address are shown next to it'), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP'), tagsIn.el), ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox, h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal(); @@ -869,7 +896,7 @@ function portForm(existing) { } else if (cur === 'access') { kids.push(accessFs); } else { - kids.push(field('Name', name, 'Optional. Shown in the list.'), h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()), + kids.push(field('Name', name, 'Optional. Shown in the list.'), tagsIn.el, h('div', {class:'card', style:'background:var(--surface-2); margin:0'}, summary()), h('details', {}, h('summary', {class:'small dim', style:'cursor:pointer'}, 'More options (health checks' + (isWeb() ? ', rules, redirects' : '') + ')'), h('div', {class:'stack', style:'margin-top:10px'}, adv, ...(isWeb() ? [rulesFs, advFs] : [])))); } @@ -910,10 +937,19 @@ views.sites = async () => { h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name), h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }]; const latest = await latestVersions(); + const tags = allTags(st.sites); + if (TAGSEL.sites && !tags.some(t => t.toLowerCase() === TAGSEL.sites.toLowerCase())) TAGSEL.sites = ''; + const editSite = s => { // rename a site and set its tags + const nm = h('input', {value:s.name}), tg = tagInput(s.tags, tags); + const close = modal('Edit ' + s.name, h('div', {class:'stack'}, field('Name', nm), tg.el, + h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), + h('button', {class:'btn', onclick: act(async () => { await api('PUT', O('/sites/' + s.id), {name:nm.value, tags:tg.get()}); close(); toast('Saved ' + nm.value.trim()); refresh(); })}, 'Save changes')))); + }; return h('div', {}, + tagBar('sites', tags), card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))), - h('tbody', {}, st.sites.map(s => h('tr', {}, - h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name), + h('tbody', {}, st.sites.filter(s => hasTag(s, TAGSEL.sites)).map(s => h('tr', {}, + h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name, tagList(s)), h('td', {}, s.local ? 'This machine' : 'Bamboo agent'), h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')), s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)), @@ -922,6 +958,7 @@ views.sites = async () => { modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) : h('p', {class:'dim'}, 'None reported. Start the agent with --docker (or BLACKTIP_DOCKER=1 / BAMBOO_DOCKER=1) on a machine where Docker is running.')); })}, 'View')), h('td', {}, s.local ? null : h('div', {class:'actions'}, + h('button', {class:'ghost', onclick:() => editSite(s)}, 'Edit'), h('button', {class:'ghost', onclick:() => showCmd(s)}, 'Show command'), h('button', {class:'ghost danger', onclick: ask({title:`Delete ${s.name}?`, body:'Every port that uses only this site is deleted with it, and its agent loses its connection. This cannot be undone.', confirm:'Delete site'}, async () => { await api('DELETE', O('/sites/' + s.id)); refresh(); })}, 'Delete'))))))))); }; @@ -1056,26 +1093,32 @@ const FORMATS = [['json', 'JSON'], ['slack', 'Slack'], ['discord', 'Discord'], [ function channelForm(base, existing, trusted) { const c = existing || {kind:'email', format:'json', enabled:true}; const name = h('input', {value:c.name || '', placeholder:'e.g. On-call'}); - const kind = h('select', {}, [['email', 'Email'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t))); + const kind = h('select', {}, [['email', 'Email'], ['sandbar', 'Sandbar'], ['webhook', 'Webhook']].map(([v, t]) => h('option', {value:v, selected:v === c.kind}, t))); const target = h('input', {value:c.kind === 'email' ? c.target || '' : '', placeholder:c.kind === 'webhook' && existing ? 'unchanged' : ''}); + const token = h('input', {type:'password', autocomplete:'off', placeholder:existing && c.kind === 'sandbar' ? 'unchanged' : 'sb_…'}); + const policy = h('input', {value:c.policy || '', placeholder:'Optional'}); + const sandbarBox = h('div', {class:'grid'}, field('API key', token, 'Create one on Sandbar\'s Account page.'), field('Escalation policy ID', policy, 'Who gets paged. Leave blank to send alerts without paging anyone.')); const fmt = h('select', {}, FORMATS.map(([v, t]) => h('option', {value:v, selected:v === (c.format || 'json')}, t))); const priv = chk('This address is on a private network (like a home server)', c.allowPrivate); const en = chk('Enabled', c.enabled !== false); const fmtBox = field('Webhook format', fmt, 'Slack and Discord incoming-webhook URLs work as they are. ntfy sends a push notification.'); const targetBox = field('Where', target); const redraw = () => { - const w = kind.value === 'webhook'; + const w = kind.value === 'webhook', sb = kind.value === 'sandbar'; fmtBox.style.display = w ? '' : 'none'; - priv.style.display = w && trusted ? '' : 'none'; - target.placeholder = w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com'; + sandbarBox.style.display = sb ? '' : 'none'; + priv.style.display = (w || sb) && trusted ? '' : 'none'; + targetBox.firstChild.textContent =sb ? 'Sandbar address' : 'Where'; + target.placeholder = sb ? (existing && c.kind === 'sandbar' ? 'unchanged' : 'https://sandbar.example.com') : + w ? (existing && c.kind === 'webhook' ? 'unchanged (it may contain a secret token)' : 'https://hooks.example.com/…') : 'you@example.com, oncall@example.com'; }; kind.onchange = redraw; redraw(); const save = act(async () => { - await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked}); + await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', token:token.value, policy:policy.value, allowPrivate:trusted && priv.box.checked, enabled:en.box.checked}); close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh(); }); const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'}, - h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en, + h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, sandbarBox, fmtBox, priv, en, h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true}); } @@ -1083,7 +1126,7 @@ async function alertsView(base, scoped, trusted) { const d = await api('GET', base); const channels = h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Type', 'Sends to', ''].map(x => h('th', {}, x)))), h('tbody', {}, d.channels.map(c => h('tr', {}, h('td', {}, c.name, c.enabled ? null : h('span', {class:'tag', style:'margin-left:6px'}, 'off')), - h('td', {}, c.kind === 'email' ? 'Email' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview), + h('td', {}, c.kind === 'email' ? 'Email' : c.kind === 'sandbar' ? 'Sandbar' : 'Webhook (' + c.format + ')'), h('td', {class:'mono small'}, c.preview), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', base + '/channels/' + c.id + '/test'); toast('Test sent'); })}, 'Send test'), h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'), @@ -1091,11 +1134,11 @@ async function alertsView(base, scoped, trusted) { const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours}); S.headerAction = ['Add channel', () => channelForm(base, null, trusted)]; return h('div', {}, - !d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null, + !d.emailConfigured ? h('div', {class:'notice'}, 'Email alerts need an email server. Set one up under Server → Settings, or use Sandbar or a webhook.') : null, card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, a.fired ? badge('Firing', 'danger') : badge('Waiting', 'warning')), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) : emptyState('check', 'Nothing is wrong', 'Problems that are firing or about to fire appear here.')), card('Where alerts go', - d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')), + d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address, a Sandbar server or a webhook to be told when something stops working.')), scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'), h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'), h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,