Files
terdut-server/internal/prom/allow.go
T
Niklas Ye f344bb707b Graph the metric behind each alert on the incident page
Each alert carries a generatorURL pointing at the Prometheus rule that fired
it. The server now parses that link, queries the same Prometheus for the
rule's expression and draws it under the alert, with the current value
beside it. The browser never talks to Prometheus, so the CSP stays at
connect-src 'self'.

The URL comes out of a webhook payload, so whoever holds an integration key
chooses it. Querying it is therefore opt-in and bounded by an allowlist,
TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as
several, IPs, CIDRs). The check runs on the address the dialer is about to
use, a name that resolves to loopback or link-local is refused even when it
matches, and redirects and proxy variables are ignored.

A worker takes a snapshot when an incident opens and another when it
closes, so the graph outlives Prometheus' retention. It finds its work in
the database rather than being triggered from the webhook, so ingestion is
untouched and a restart loses nothing. While an incident is open the
metrics endpoint also queries live, cached for 15 seconds.

The rule expression keeps its threshold, so Prometheus returns data only
while the alert condition holds; "no data" for the current value means the
condition cleared. Stripping the threshold needs a PromQL parser and is left
out.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 14:10:34 +02:00

269 lines
8.0 KiB
Go

// Package prom fetches metrics from the Prometheus an alert came from.
//
// The address to query is the alert's generatorURL, which arrives in a webhook
// payload and is therefore chosen by whoever holds an integration key. Left
// unchecked, that would let the key holder make this server issue requests to
// any host it can reach. Everything here is built around the Allowlist so that
// cannot happen: a host is queried only if the operator listed it.
package prom
import (
"fmt"
"net"
"net/netip"
"net/url"
"path"
"strings"
)
// Allowlist is the set of Prometheus servers this install may query. The zero
// value, and an empty list, permit nothing, which is what keeps the feature off
// until an operator opts in.
//
// An entry is one of:
//
// - a host name, optionally with a port: prometheus.monitoring.svc, prom:9090
// - a wildcard host name: "*" stands for exactly one DNS label (or part of
// one), so *.example.com matches a.example.com but neither example.com nor
// a.b.example.com, and prometheus-*.monitoring.svc matches prometheus-0.
// A leading "**" label matches one or more labels, so **.example.com also
// matches a.b.example.com.
// - an IP address or a CIDR range, optionally with a port: 10.0.0.0/8,
// [fd00::]:9090
//
// Without a port an entry matches any port. Matching is case-insensitive.
type Allowlist struct {
hosts []hostPattern
nets []netEntry
}
type hostPattern struct {
labels []string // lower-cased; a leading "**" label is kept as is
port string // empty matches any port
}
type netEntry struct {
prefix netip.Prefix
port string
}
// ParseAllowlist validates and compiles the configured entries. It refuses an
// entry that would admit every host (a bare "*", "*.*"): a typo there would
// quietly turn the guard off.
func ParseAllowlist(entries []string) (*Allowlist, error) {
a := &Allowlist{}
for _, raw := range entries {
e := strings.ToLower(strings.TrimSpace(raw))
if e == "" {
continue
}
if err := a.add(e); err != nil {
return nil, fmt.Errorf("allowed host %q: %w", raw, err)
}
}
return a, nil
}
func (a *Allowlist) add(e string) error {
// CIDR first: its colons (IPv6) and slash would confuse the host:port split.
if strings.Contains(e, "/") {
host, port := e, ""
if strings.HasPrefix(e, "[") {
end := strings.Index(e, "]")
if end < 0 {
return fmt.Errorf("unterminated [")
}
host = e[1:end]
rest := e[end+1:]
if strings.HasPrefix(rest, ":") {
port = rest[1:]
}
}
p, err := netip.ParsePrefix(host)
if err != nil {
return fmt.Errorf("not a CIDR range: %w", err)
}
if err := checkPort(port); err != nil {
return err
}
a.nets = append(a.nets, netEntry{prefix: p.Masked(), port: port})
return nil
}
// A bare IP address, IPv6 included.
if ip, err := netip.ParseAddr(e); err == nil {
a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen())})
return nil
}
host, port := e, ""
if strings.HasPrefix(e, "[") || strings.Count(e, ":") == 1 {
h, p, err := net.SplitHostPort(e)
if err != nil {
return fmt.Errorf("bad host:port: %w", err)
}
host, port = h, p
if ip, err := netip.ParseAddr(host); err == nil {
if err := checkPort(port); err != nil {
return err
}
a.nets = append(a.nets, netEntry{prefix: netip.PrefixFrom(ip.Unmap(), ip.Unmap().BitLen()), port: port})
return nil
}
}
if err := checkPort(port); err != nil {
return err
}
host = strings.TrimSuffix(host, ".")
if host == "" {
return fmt.Errorf("empty host")
}
for _, r := range host {
ok := r == '.' || r == '-' || r == '_' || r == '*' || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
if !ok {
return fmt.Errorf("unexpected character %q", r)
}
}
labels := strings.Split(host, ".")
for i, l := range labels {
if l == "" {
return fmt.Errorf("empty label")
}
if l == "**" && i != 0 {
return fmt.Errorf("** is only allowed as the first label")
}
if strings.Contains(l, "**") && l != "**" {
return fmt.Errorf("** must be a whole label")
}
}
// The last label has to say something, or the entry is allow-all.
if strings.Trim(labels[len(labels)-1], "*") == "" {
return fmt.Errorf("would match every host: the last label cannot be a wildcard alone")
}
a.hosts = append(a.hosts, hostPattern{labels: labels, port: port})
return nil
}
func checkPort(p string) error {
if p == "" {
return nil
}
for _, r := range p {
if r < '0' || r > '9' {
return fmt.Errorf("bad port %q", p)
}
}
return nil
}
// Empty reports whether nothing is allowed, i.e. the feature is off.
func (a *Allowlist) Empty() bool {
return a == nil || (len(a.hosts) == 0 && len(a.nets) == 0)
}
// Port returns the effective port of u: the explicit one, else the scheme's.
func Port(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
// permits decides whether host:port may be contacted at ip. host is what the
// URL said (a name or an IP literal), ip is where it actually resolved to; the
// check runs on the resolved address at dial time so that a name that resolves
// somewhere else than it did when it was listed (DNS rebinding) is caught.
//
// - An address inside a listed CIDR range or equal to a listed IP is allowed.
// - A host name that matches a listed pattern is allowed, unless it resolved
// to an address that is never a Prometheus (loopback, link-local, which
// includes the cloud metadata address, unspecified, multicast). A wildcard
// over a domain somebody else controls must not be a way to reach internal
// services. To allow such an address deliberately, list it as a CIDR.
func (a *Allowlist) permits(host, port string, ip netip.Addr) bool {
if a == nil {
return false
}
ip = ip.Unmap()
for _, n := range a.nets {
if n.prefix.Contains(ip) && (n.port == "" || n.port == port) {
return true
}
}
if forbiddenAddr(ip) {
return false
}
if _, isIP := parseHostIP(host); isIP {
return false // an IP literal is allowed only through a CIDR entry
}
return a.matchesName(host, port)
}
func parseHostIP(host string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
return ip, err == nil
}
// MatchesHost reports whether the URL's host is worth dialing at all: a listed
// name, or an IP that falls in a listed range. It is the cheap early answer;
// the dial-time check in permits is the one that is authoritative for names.
func (a *Allowlist) MatchesHost(u *url.URL) bool {
if a.Empty() {
return false
}
host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
port := Port(u)
if ip, ok := parseHostIP(host); ok {
return a.permits(host, port, ip)
}
if a.matchesName(host, port) {
return true
}
// A name outside the patterns can still be admitted by a CIDR entry once it
// resolves; leave that to the dialer.
return len(a.nets) > 0
}
func (a *Allowlist) matchesName(host, port string) bool {
labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".")
for _, p := range a.hosts {
if (p.port == "" || p.port == port) && matchLabels(p.labels, labels) {
return true
}
}
return false
}
func matchLabels(pattern, host []string) bool {
if len(pattern) > 0 && pattern[0] == "**" {
rest := pattern[1:]
if len(host) <= len(rest) {
return false
}
return matchLabels(rest, host[len(host)-len(rest):])
}
if len(pattern) != len(host) {
return false
}
for i := range pattern {
// The pattern was restricted to [a-z0-9_.-*] when parsed, so path.Match
// sees nothing but literals and "*" here.
if ok, _ := path.Match(pattern[i], host[i]); !ok {
return false
}
}
return true
}
// forbiddenAddr is the set of addresses a Prometheus is never reached at and
// that are dangerous to reach by mistake. Private ranges are deliberately not
// in it: a Prometheus inside the cluster lives there.
func forbiddenAddr(ip netip.Addr) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified()
}