Files
privatae-bastion/client-go/observe_pull.go
T
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

959 lines
39 KiB
Go

package main
// observe:alerts / observe:metrics / observe:logs — the PULL half of the
// observe family.
//
// Three read-only ops that read the monitoring services sitting beside this
// box: an Alertmanager, a Prometheus, a Loki, each named in
// observe-endpoints.json by the admin at install (observe_endpoints.go). They
// are HTTP, so the exec seam is not used at all and there is no Linux driver:
// the pattern is observe:net, which execs nothing either.
//
// The discipline the rest of the family keeps with a fixed argv, these keep
// with a fixed TABLE. The host never sends PromQL, never sends LogQL, and never
// sends a URL. Its argument is a NAME; the box owns the query that name maps
// to. An unknown name is refused with the known names listed, so the host can
// see the whole surface without guessing at it.
//
// Cross-platform, no build tag: nothing here touches /proc, systemd or nft, so
// observe_ops_other.go has nothing to stub. These three answer the same on
// Linux, macOS and Windows — which is the point on a customer's bastion, where
// the box next to their Alertmanager may not be the box we would have picked.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
)
// observeBodyCap bounds ONE response body. Measured against a real estate:
// a few dozen active Alertmanager alerts came to 41 KB of v2 JSON and the
// largest of the six instant vectors (`up`, 29 series) was 8 KB. 1 MiB is room
// for an estate a couple of orders of magnitude larger without letting a job
// body grow without bound.
const observeBodyCap = 1 << 20
// errObserveDialPublic is the socket-level refusal. A named constant so a test
// pins the REASON and not merely the failure.
const errObserveDialPublic = "is a public address — refused at the socket"
// observeDialControl is the LAST wall, and the only one that cannot be raced.
//
// Validating the endpoints file proves what the admin WROTE was private. It
// cannot prove what the box will CONNECT to, and two things open that gap:
//
// - A REDIRECT. Nothing in the file's URL constrains where a 30x points, so a
// compromised or merely misconfigured Alertmanager could hand the box a
// Location anywhere. That is closed in observeHTTPClient by never following
// one; this is the backstop if that ever changes.
// - DNS. A hostname endpoint is resolved once by validateObserveEndpoint and
// again by the transport, and between those two resolutions the answer can
// change — the rebinding shape. Re-resolving more carefully cannot close
// that, because there is always a LAST resolution and the check has to come
// after it.
//
// net.Dialer.Control runs on the ADDRESS THE SOCKET IS ABOUT TO CONNECT TO,
// after resolution and before connect, so no window is left. Every path to the
// network in this file goes through it. It is the socket-level statement of the
// rule the file header sets out: this family reads the box's own neighbourhood
// and may never be turned into a way to make the box fetch the internet.
var observeDialControl = func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return errors.New("refused: unreadable socket address " + address)
}
a, err := netip.ParseAddr(host)
if err != nil {
// Control is handed a resolved literal. Anything else means the
// resolution path changed underneath this, and the safe answer is no.
return errors.New("refused: " + host + " is not an IP literal at connect time")
}
if !observeEndpointAllowed(a) {
return errors.New("refused: " + a.Unmap().String() + " " + errObserveDialPublic)
}
return nil
}
// observeHTTPClient is the client all three ops use. Built per call because it
// is cheap — one connection, no keep-alives — and because a shared client would
// let one op's dead connection be handed to another.
func observeHTTPClient() *http.Client {
return &http.Client{
// A REDIRECT IS A RESULT, NEVER A HOP. Go follows up to ten by default,
// and the endpoints-file wall checks only the address the ADMIN
// configured — so a 302 from the configured Alertmanager would walk the
// box straight out of its own neighbourhood, carrying the query string,
// on the unattended 60 s refresh loop. ErrUseLastResponse hands the 30x
// back as the response and observePull reports it as the non-2xx it is.
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
Transport: &http.Transport{
// DisableKeepAlives for the same reason probeNet sets it: one read,
// then the connection is done.
DisableKeepAlives: true,
DialContext: (&net.Dialer{
Timeout: netProbeTimeout,
Control: observeDialControl,
}).DialContext,
},
}
}
// observeHTTPGet is the ONE seam through which these three ops reach the
// network, the same way observeExec is the one seam for the exec ops. A
// variable so the tests can point it at an httptest server or record the URLs
// and prove no caller value ever reaches one.
var observeHTTPGet = func(ctx context.Context, rawURL string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
// The ctx carries the shared op budget, so a server that accepts and never
// answers is cut at the same deadline every other op is cut at — the HTTP
// analogue of the WaitDelay on the exec seam, where the transport rather
// than a grandchild is what would otherwise hold the read open past the
// kill.
resp, err := observeHTTPClient().Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
// LimitReader, never a bare ReadAll: an endpoint that streams for ever costs
// one megabyte and the op budget, never the process.
//
// cap+1, not cap. Reading exactly the cap cannot tell a body that happens to
// be 1 MiB from one that was CUT at 1 MiB, so an oversized response came back
// as a clean 1 MiB of valid-looking bytes and surfaced three frames later as
// "unexpected end of JSON input" — a parser error blaming the endpoint for
// something this function did. One extra byte makes the overflow decidable
// here, where it can be named.
body, err := io.ReadAll(io.LimitReader(resp.Body, observeBodyCap+1))
if err != nil {
return nil, resp.StatusCode, err
}
if len(body) > observeBodyCap {
return nil, resp.StatusCode, errors.New("response too large (> 1 MiB)")
}
return body, resp.StatusCode, nil
}
// opBudgetFrom is opBudget (observe_ops.go:74) with a PARENT. The background
// alerts refresh runs under the heartbeat goroutine's lifecycle context, so a
// shutdown cancels an in-flight fetch instead of leaving it to run out its own
// 20 s against a box that is going away.
func opBudgetFrom(parent context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(parent, observeOpBudget)
}
// observePull does the shared work of all three ops: resolve the endpoint,
// fetch, check the status, hand back the body. The unconfigured string is the
// observe:k8s answer — reported, exit 0 — and err is a refusal.
func observePull(ctx context.Context, key, path string) (body []byte, unconfigured string, err error) {
base, unconfigured, err := observeEndpointFor(ctx, key)
if unconfigured != "" || err != nil {
return nil, unconfigured, err
}
raw, code, err := observeHTTPGet(ctx, base+path)
if err != nil {
if ctx.Err() != nil {
return nil, "", errors.New(key + ": timeout")
}
return nil, "", errors.New(key + ": " + err.Error())
}
if code < 200 || code > 299 {
return nil, "", errors.New(key + ": HTTP " + strconv.Itoa(code))
}
return raw, "", nil
}
// writeObservePull is the one answer path for the three ops: an unconfigured
// box reports the observe:k8s shape at exit 0, a refusal says why at exit 2.
func writeObservePull(ws *websocket.Conn, unconfigured string, err error, v any) {
if err != nil {
sendMgmt(ws, 'R', []byte("refused: "+err.Error()+"\n"))
sendMgmtExit(ws, 2)
return
}
if unconfigured != "" {
writeObserve(ws, map[string]any{"supported": false, "reason": unconfigured})
return
}
writeObserve(ws, v)
}
// ── observe:alerts ───────────────────────────────────────────────────────────
// alertmanagerAlertsPath is Alertmanager's v2 API.
//
// ALL FOUR FILTER FLAGS ARE NAMED, not just active. In v2 each of active,
// silenced, inhibited and unprocessed defaults to TRUE, so `?active=true` alone
// narrows nothing — it re-states one default and leaves the other three on. An
// alert somebody deliberately silenced would then ride the heartbeat as firing,
// which is the one thing a silence exists to prevent.
//
// Measured against a real Alertmanager: `?active=true` returned the same
// count as the bare `/api/v2/alerts`, confirming the flag was doing nothing;
// `?active=false&silenced=true&inhibited=true` returned 0 against that same
// instance, so the bug could not be shown live there. It is closed here and
// proven by fixture instead (TestObserveAlertsExcludesSilencedAndInhibited).
const alertmanagerAlertsPath = "/api/v2/alerts?active=true&silenced=false&inhibited=false&unprocessed=false"
// observeAlertCap and observeLabelValueCap bound one answer. Measured against
// a real estate: a few dozen active alerts, so 200 is an order of magnitude
// of headroom — re-measure before lowering it. The cut is reported rather
// than hidden. 512 bytes is past every label an ordinary estate sets and
// short of an annotation-sized value pasted into one.
const (
observeAlertCap = 200
observeLabelValueCap = 512
)
// amAlert is the slice of Alertmanager's v2 alert this box reads. Annotations
// are deliberately not carried: they are prose written for a human reading
// Grafana, they are the largest field on the wire, and nothing in the host's
// lane keys on them.
type amAlert struct {
Fingerprint string `json:"fingerprint"`
Labels map[string]string `json:"labels"`
StartsAt string `json:"startsAt"`
Status struct {
State string `json:"state"`
} `json:"status"`
}
type observedAlert struct {
Fingerprint string `json:"fingerprint"`
Alertname string `json:"alertname"`
Severity string `json:"severity"`
State string `json:"state"`
StartsAt string `json:"starts_at"`
Labels map[string]string `json:"labels"`
// The labels whose VALUE was over observeLabelValueCap and was therefore
// dropped. Named rather than silently missing: a host that sees no
// `description` label cannot otherwise tell "not set" from "too big".
DroppedLabels []string `json:"dropped_labels,omitempty"`
}
type alertsReport struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
Source string `json:"source"`
// Count is how many rows are in Alerts — always len(Alerts), so a reader
// never has to reconcile the two. ActiveTotal is how many there ACTUALLY
// are; the two differ exactly when Truncated is set, which is what makes a
// truncation actionable rather than merely admitted.
Count int `json:"count"`
ActiveTotal int `json:"active_total"`
Alerts []observedAlert `json:"alerts"`
Truncated bool `json:"truncated,omitempty"`
// all is every ACTIVE alert, uncapped, and is deliberately not serialized —
// it exists so the heartbeat's counts can be true while the wire stays
// bounded. See summarizeAlerts.
all []observedAlert
}
// parseAlerts reduces an Alertmanager v2 body to the typed set.
//
// ORDER MATTERS HERE, and getting it wrong was a real bug: the row cap used to
// be applied to the RAW list, before the state filter, so 250 alerts were cut to
// 200 and only then examined. Everything downstream — including the heartbeat's
// `firing` — therefore saturated silently at 200 and an estate with 400 alerts
// firing looked exactly like one with 200. Filter first, then count, then cap.
//
// Suppressed alerts are dropped from the ROWS too, not just the counts. The
// request already asks for active only (alertmanagerAlertsPath); a silenced
// alert that arrives anyway is one this box was told not to report, and
// returning it as a row while excluding it from every count would be the worst
// of both.
func parseAlerts(raw []byte) (alertsReport, error) {
var list []amAlert
if err := json.Unmarshal(raw, &list); err != nil {
return alertsReport{}, err
}
rep := alertsReport{Supported: true, Source: observeKeyAlertmanager, Alerts: []observedAlert{}}
for _, a := range list {
// The state filter, BEFORE the cap. An empty state is treated as active:
// an Alertmanager that did not say is not evidence of a silence.
if a.Status.State != "" && a.Status.State != "active" {
continue
}
o := observedAlert{Fingerprint: a.Fingerprint, State: a.Status.State, StartsAt: a.StartsAt,
Labels: map[string]string{}}
for k, v := range a.Labels {
if len(v) > observeLabelValueCap {
o.DroppedLabels = append(o.DroppedLabels, k)
rep.Truncated = true
continue
}
o.Labels[k] = v
}
sort.Strings(o.DroppedLabels)
o.Alertname = o.Labels["alertname"]
o.Severity = o.Labels["severity"]
rep.all = append(rep.all, o)
}
rep.ActiveTotal = len(rep.all)
rep.Alerts = rep.all
if len(rep.Alerts) > observeAlertCap {
// The ROWS are capped; the counts above are not. Copied rather than
// resliced so the wire slice cannot alias `all` and be mutated through it.
rep.Alerts = append([]observedAlert(nil), rep.all[:observeAlertCap]...)
rep.Truncated = true
}
if rep.Alerts == nil {
rep.Alerts = []observedAlert{}
}
rep.Count = len(rep.Alerts)
return rep, nil
}
// handleObserveAlerts takes no argument: the op is the whole active set, the
// way observe:host is the whole posture. Args are ignored exactly as they are
// there, so the only arg-taking ops in the family stay observe:net,
// observe:metrics and observe:logs.
func handleObserveAlerts(ws *websocket.Conn, _ string, _ []string) {
ctx, cancel := opBudget()
defer cancel()
raw, unconfigured, err := observePull(ctx, observeKeyAlertmanager, alertmanagerAlertsPath)
if unconfigured != "" || err != nil {
writeObservePull(ws, unconfigured, err, nil)
return
}
rep, perr := parseAlerts(raw)
if perr != nil {
writeObservePull(ws, "", errors.New("alertmanager: "+perr.Error()), nil)
return
}
writeObservePull(ws, "", nil, rep)
}
// ── observe:metrics ──────────────────────────────────────────────────────────
// observeMetricQueries is the whole PromQL surface of this box. The host sends
// a NAME; the box owns the query. Adding a question means shipping a bastion,
// which is the trade this table exists to make: no free query language reaches
// a production Prometheus through a pairing grant.
//
// The six are the ones the plan names (§2a): is anything scraping, and the four
// node/pod conditions a finding rule is written against.
var observeMetricQueries = map[string]string{
"up": `up`,
"node_cpu": `1 - avg by (instance)(rate(node_cpu_seconds_total{mode="idle"}[5m]))`,
"node_mem": `1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes`,
"node_disk": `1 - node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}`,
// increase() over 1h rather than a bare counter: the counter's VALUE is
// lifetime restarts, which never goes down and so can never clear.
"pod_restarts": `sum by (namespace,pod)(increase(kube_pod_container_status_restarts_total[1h])) > 0`,
"pod_oom": `sum by (namespace,pod)(kube_pod_container_status_last_terminated_reason{reason="OOMKilled"}) > 0`,
}
// observeSeriesCap bounds one answer.
//
// Every one of the six, measured against a real Prometheus: `up` was the
// largest instant vector by a wide margin, the rest single digits or zero. So
// 500 is roughly an order of magnitude of headroom over the largest vector
// measured. Re-measure before lowering it: `up` grows with every scrape
// target added, and the cap is the one number here that would silently drop
// rows a finding rule is counting on.
const observeSeriesCap = 500
type observedSeries struct {
Labels map[string]string `json:"labels"`
Value string `json:"value"`
TS float64 `json:"ts"`
}
type metricsReport struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
Source string `json:"source"`
Name string `json:"name"`
Query string `json:"query"`
Count int `json:"count"`
Series []observedSeries `json:"series"`
Truncated bool `json:"truncated,omitempty"`
}
// metricNames lists the table, sorted, for the refusal message.
func metricNames() []string {
names := make([]string, 0, len(observeMetricQueries))
for k := range observeMetricQueries {
names = append(names, k)
}
sort.Strings(names)
return names
}
// parseInstantVector reduces a Prometheus /api/v1/query answer to the series
// list. A non-"success" status or a non-vector result is an error rather than
// an empty list: "no series" and "the query was rejected" must never read the
// same to a finding rule.
func parseInstantVector(raw []byte) ([]observedSeries, bool, error) {
var res struct {
Status string `json:"status"`
Error string `json:"error"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Metric map[string]string `json:"metric"`
Value []json.RawMessage `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return nil, false, err
}
if res.Status != "success" {
if res.Error != "" {
return nil, false, errors.New(res.Error)
}
return nil, false, errors.New("prometheus status " + res.Status)
}
if res.Data.ResultType != "vector" {
return nil, false, errors.New("expected an instant vector, got " + res.Data.ResultType)
}
truncated := false
rows := res.Data.Result
if len(rows) > observeSeriesCap {
rows = rows[:observeSeriesCap]
truncated = true
}
out := make([]observedSeries, 0, len(rows))
for _, r := range rows {
s := observedSeries{Labels: r.Metric}
if s.Labels == nil {
s.Labels = map[string]string{}
}
// [ <unix_float>, "<value as string>" ] — Prometheus sends the sample
// value as a STRING so NaN and Inf survive JSON; it is passed through
// as one rather than parsed into a float that cannot hold them.
if len(r.Value) == 2 {
_ = json.Unmarshal(r.Value[0], &s.TS)
_ = json.Unmarshal(r.Value[1], &s.Value)
}
out = append(out, s)
}
return out, truncated, nil
}
func handleObserveMetrics(ws *websocket.Conn, _ string, args []string) {
if len(args) != 1 {
sendMgmt(ws, 'R', []byte("observe:metrics takes exactly one argument: a metric name ("+
strings.Join(metricNames(), ", ")+")\n"))
sendMgmtExit(ws, 2)
return
}
name := args[0]
query, ok := observeMetricQueries[name]
if !ok {
sendMgmt(ws, 'R', []byte("refused: unknown metric name; this box answers only "+
strings.Join(metricNames(), ", ")+"\n"))
sendMgmtExit(ws, 2)
return
}
ctx, cancel := opBudget()
defer cancel()
// The name is a table KEY, so what goes on the wire is the box's own PromQL
// and never the caller's bytes. It is escaped anyway: the value being
// encoded is a constant from this file, and an encoder round a constant is
// what keeps it true if the table ever gains a query with a '&' in it.
raw, unconfigured, err := observePull(ctx, observeKeyPrometheus,
"/api/v1/query?query="+url.QueryEscape(query))
if unconfigured != "" || err != nil {
writeObservePull(ws, unconfigured, err, nil)
return
}
series, truncated, perr := parseInstantVector(raw)
if perr != nil {
writeObservePull(ws, "", errors.New("prometheus: "+perr.Error()), nil)
return
}
writeObservePull(ws, "", nil, metricsReport{Supported: true, Source: observeKeyPrometheus,
Name: name, Query: query, Count: len(series), Series: series, Truncated: truncated})
}
// ── observe:logs ─────────────────────────────────────────────────────────────
// observeLogSelectors is the whole LogQL surface, keyed by name, for the same
// reason the PromQL table exists.
//
// The label vocabulary is NOT guessed: it is what the collector actually
// sets, read from its own configuration and then confirmed against a live
// Loki.
//
// - The collector's journal relabel maps __journal__systemd_unit -> `unit`
// and sets a job label identifying journal streams. So journal streams
// carry that job label and a `unit`.
// - There is NO unit called "kernel". The live label values for `unit` are
// all .service/.scope unit names — because kernel and audit lines arrive
// over the kernel transport and have no __journal__systemd_unit at all, so
// the relabel leaves `unit` empty. The honest selector for them is
// therefore the journal with an EMPTY unit, confirmed live: an empty-unit
// journal selector returns the kernel/apparmor/audit lines.
// - The journal relabel also maps __journal__hostname -> `node`, and the
// live `node` label values are exactly the node hostnames. That is what
// scopes the three journal selectors to THIS box — see
// observeLogSelector.
// - Pod logs come from the collector's Kubernetes source. Its config
// DECLARES a node relabel for them, but it does not survive: measured
// live, every kube-system stream carries {cluster, container, instance,
// job, namespace, pod, service_name} and NO `node` at all, and scoping a
// kube-system query to one node's hostname returns zero lines while the
// unscoped query returns plenty. So k8s-system CANNOT be scoped to this
// box and is cluster-wide; the report says which it is, rather than
// letting a reader assume the scope from the op's name.
//
// SCOPE. The stated purpose of this family is the box's own neighbourhood, and
// an unscoped journal selector is not that — sshd unscoped returned four nodes'
// streams. The three journal names are therefore pinned to this box's hostname
// by default. There is no `all` variant: a host that wants another box's logs
// asks that box, which is the whole shape of the bastion lane.
var observeLogSelectorTemplates = map[string]string{
"kernel": `{job="systemd-journal", unit=""%s}`,
"sshd": `{job="systemd-journal", unit="ssh.service"%s}`,
"bastion": `{job="systemd-journal", unit="privatae-bastion.service"%s}`,
"k8s-system": `{namespace="kube-system"}`,
}
// observeLogScope is the value the `scope` field carries, so the host never has
// to infer from the op's name how wide the answer was.
const (
observeScopeNode = "node" // this box only
observeScopeCluster = "cluster" // every node shipping to this Loki
)
// observeHostname is the resolver seam for the node scope (a var so a test can
// pin it). os.Hostname is what the journal's __journal__hostname is, by
// definition of the journal.
var observeHostname = os.Hostname
// observeLogSelector builds the selector for a name, scoped to this box where
// the label exists to do it with. A box whose hostname cannot be read falls
// back to the unscoped journal rather than answering nothing — and SAYS so
// through the returned scope, because a cluster-wide answer presented as this
// box's own is the failure this whole field exists to prevent.
func observeLogSelector(name string) (selector, scope string, ok bool) {
tmpl, ok := observeLogSelectorTemplates[name]
if !ok {
return "", "", false
}
if !strings.Contains(tmpl, "%s") {
return tmpl, observeScopeCluster, true
}
host, err := observeHostname()
if err != nil || host == "" {
return fmt.Sprintf(tmpl, ""), observeScopeCluster, true
}
return fmt.Sprintf(tmpl, `, node=`+strconv.Quote(host)), observeScopeNode, true
}
const (
observeLogMinutesDefault = 15
observeLogMinutesMax = 60
// observeLogLineLimit is what Loki is ASKED for. The byte cap below is what
// actually bounds the answer; this keeps the server from building a far
// larger page than 200 KB could ever carry.
observeLogLineLimit = 5000
)
type logsReport struct {
Supported bool `json:"supported"`
Reason string `json:"reason,omitempty"`
Source string `json:"source"`
Name string `json:"name"`
Selector string `json:"selector"`
// Scope is "node" (this box) or "cluster" (every node shipping to this
// Loki). Stated rather than implied: three of the four names are scoped to
// this box and k8s-system cannot be, and a reader must not have to infer
// which from the op's name.
Scope string `json:"scope"`
Minutes int `json:"minutes"`
Lines int `json:"lines"`
Truncated bool `json:"truncated"`
// Note is set when the answer is EMPTY. "Nothing was logged" and "nothing
// came back" look identical in an empty body, and the difference is the
// whole question an operator is asking. Measured on a real box: the
// bastion's own unit (deploy/install.sh) can go many hours writing zero
// journal lines, so an empty `bastion` answer is the honest and expected
// one, and it has to say so instead of reading as a broken selector.
Note string `json:"note,omitempty"`
Log string `json:"log"`
}
func logNames() []string {
names := make([]string, 0, len(observeLogSelectorTemplates))
for k := range observeLogSelectorTemplates {
names = append(names, k)
}
sort.Strings(names)
return names
}
type lokiLine struct {
ns int64
text string
}
// parseLokiStreams flattens a query_range answer into one time-ordered list,
// OLDEST FIRST — Loki is asked backward so the newest lines are the ones that
// survive a server-side limit, and they are reversed here so a reader ends on
// the most recent line, the way `dmesg -T` does.
func parseLokiStreams(raw []byte) ([]lokiLine, error) {
var res struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Values [][2]string `json:"values"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return nil, err
}
if res.Status != "success" {
return nil, errors.New("loki status " + res.Status)
}
var out []lokiLine
for _, st := range res.Data.Result {
for _, v := range st.Values {
ns, err := strconv.ParseInt(v[0], 10, 64)
if err != nil {
continue
}
out = append(out, lokiLine{ns: ns, text: v[1]})
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].ns < out[j].ns })
return out, nil
}
// parseLogMinutes reads the optional second argument. Out of range is REFUSED
// rather than clamped: a host that asked for 600 minutes and silently got 60
// would draw a conclusion about ten hours from a quarter of one.
func parseLogMinutes(args []string) (int, error) {
if len(args) < 2 {
return observeLogMinutesDefault, nil
}
n, err := strconv.Atoi(strings.TrimSpace(args[1]))
if err != nil {
return 0, errors.New("minutes must be a whole number")
}
if n < 1 || n > observeLogMinutesMax {
return 0, errors.New("minutes must be between 1 and " + strconv.Itoa(observeLogMinutesMax))
}
return n, nil
}
func handleObserveLogs(ws *websocket.Conn, _ string, args []string) {
if len(args) < 1 || len(args) > 2 {
sendMgmt(ws, 'R', []byte("observe:logs takes a name ("+strings.Join(logNames(), ", ")+
") and optionally minutes (1-"+strconv.Itoa(observeLogMinutesMax)+")\n"))
sendMgmtExit(ws, 2)
return
}
name := args[0]
selector, scope, ok := observeLogSelector(name)
if !ok {
sendMgmt(ws, 'R', []byte("refused: unknown log name; this box answers only "+
strings.Join(logNames(), ", ")+"\n"))
sendMgmtExit(ws, 2)
return
}
minutes, err := parseLogMinutes(args)
if err != nil {
sendMgmt(ws, 'R', []byte("refused: "+err.Error()+"\n"))
sendMgmtExit(ws, 2)
return
}
ctx, cancel := opBudget()
defer cancel()
now := time.Now()
q := url.Values{}
q.Set("query", selector)
q.Set("start", strconv.FormatInt(now.Add(-time.Duration(minutes)*time.Minute).UnixNano(), 10))
q.Set("end", strconv.FormatInt(now.UnixNano(), 10))
q.Set("limit", strconv.Itoa(observeLogLineLimit))
q.Set("direction", "backward")
raw, unconfigured, err := observePull(ctx, observeKeyLoki, "/loki/api/v1/query_range?"+q.Encode())
if unconfigured != "" || err != nil {
writeObservePull(ws, unconfigured, err, nil)
return
}
lines, perr := parseLokiStreams(raw)
if perr != nil {
writeObservePull(ws, "", errors.New("loki: "+perr.Error()), nil)
return
}
var b strings.Builder
for _, l := range lines {
b.WriteString(time.Unix(0, l.ns).UTC().Format(time.RFC3339))
b.WriteString(" ")
b.WriteString(l.text)
b.WriteString("\n")
}
// capLines keeps the LAST lines and the LAST bytes (observe_ops.go:933), so
// a cut drops the OLDEST — which is the right end to lose when the answer
// is ordered newest last. Same 200 KB cap `ufw status verbose` is held to.
text, cut := capLines(b.String(), observeLogLineLimit, observeTextCap)
n := 0
if strings.TrimSpace(text) != "" {
n = len(strings.Split(strings.TrimRight(text, "\n"), "\n"))
}
rep := logsReport{Supported: true, Source: observeKeyLoki, Name: name, Selector: selector,
Scope: scope, Minutes: minutes, Lines: n, Truncated: cut, Log: text}
if n == 0 {
// An empty body is the most misreadable answer this op can give: it
// looks exactly like "nothing happened" when it may equally be "that
// selector matches nothing on this box". Say which query, over which
// window, came back with nothing, and let the reader judge.
rep.Note = "no lines from " + selector + " in the last " +
strconv.Itoa(minutes) + " minutes"
}
writeObservePull(ws, "", nil, rep)
}
// ── the heartbeat's alerts summary ───────────────────────────────────────────
//
// THE CONTRACT, which the host side is being built against:
//
// "alerts": {"firing": n, "critical": n, "warning": n,
// "since": "<RFC3339 of the oldest active startsAt>" | null,
// "at": "<RFC3339 of this refresh>",
// "fingerprints": ["...", ...]} // at most 20, critical first
//
// The key is ABSENT — not zeroed — when no endpoints file names an
// alertmanager. That is the same nil-vs-empty rule peers and pairings keep in
// agents.go (the note at agents.go:622-631): absence means "this box did not
// look", an empty/zero summary means "this box looked and there is nothing".
// A zero `firing` from a box with no Alertmanager would read as an all-clear
// from a machine nobody is watching, which is the worse of the two lies.
//
// A refresh that FAILS keeps the last good value rather than publishing zeros,
// for the same reason; its `at` stops moving, which is how the host sees the
// summary has gone stale. Before the first successful refresh there is no key
// at all — the way posture is absent until its first pass (observe_ops.go:795).
type alertsSummary struct {
Firing int `json:"firing"`
Critical int `json:"critical"`
Warning int `json:"warning"`
Since *string `json:"since"`
At string `json:"at"`
Fingerprints []string `json:"fingerprints"`
}
// alertsFingerprintCap is what rides the beat. The rows are pulled on demand
// with observe:alerts; this is the count plus enough identity for the host to
// tell a new firing alert from the same one still firing.
const alertsFingerprintCap = 20
// alertsRefreshEvery is the background interval. One minute, matching the
// heartbeat's own tick (run.go:601), because an alert is the one signal in this
// family whose value is in arriving promptly; the fetch is a single HTTP GET
// against a service on this box's own network, not seven execs, so it costs
// nothing like the posture refresh does.
const alertsRefreshEvery = 60 * time.Second
var (
alertsMu sync.Mutex
// alertsRunning is the in-flight guard refreshDelta has and this did not
// (deltarun.go:467-478 for the shape). run.go starts ONE refresher PER
// PAIRING, so a box serving several hosts runs several of these in phase:
// without the guard a slow goroutine holding an older summary can take
// storeMu after a newer one and journal an alert as `firing` that the newer
// pass just journalled as `cleared`. No entry is lost — every
// read-modify-write of store-alerts.json is wholly inside storeMu — but the
// journal's ORDER inverts, and a record whose order cannot be trusted is not
// much of a record.
alertsRunning bool
alertsLast *alertsSummary
)
func alertsPayload() *alertsSummary {
alertsMu.Lock()
defer alertsMu.Unlock()
return alertsLast
}
// severityRank orders the fingerprints: critical first, then warning, then
// everything else. `severity` is the label Prometheus alert rules set; a value
// outside {critical, warning} counts in `firing` only, because inventing a
// bucket for it would put an unknown word in a count a human reads as a
// priority.
func severityRank(sev string) int {
switch sev {
case "critical":
return 0
case "warning":
return 1
}
return 2
}
// summarizeAlerts folds the typed set into the beat's summary. Deterministic
// by construction — sorted by severity, then oldest first, then fingerprint —
// so two beats over an unchanged Alertmanager carry an identical list and the
// host's change detection sees no movement where there was none.
// THE COUNTS READ `all`, NOT `Alerts`. Alerts is capped at 200 rows for the
// wire; counting from it made `firing` saturate silently, so 200 alerts and 400
// alerts produced an identical beat — the number an operator reads as severity,
// stuck. Counting is three increments per alert, so there is nothing to save by
// capping it. Only the fingerprint LIST stays bounded, at 20, because that is
// the part with a size on the wire. rep.all is empty for a report built by hand
// in a test, so it falls back to Alerts and the old call sites still mean what
// they said.
func summarizeAlerts(rep alertsReport, now time.Time) alertsSummary {
s := alertsSummary{At: now.UTC().Format(time.RFC3339), Fingerprints: []string{}}
from := rep.all
if from == nil {
from = rep.Alerts
}
ordered := append([]observedAlert(nil), from...)
sort.SliceStable(ordered, func(i, j int) bool {
ri, rj := severityRank(ordered[i].Severity), severityRank(ordered[j].Severity)
if ri != rj {
return ri < rj
}
if ordered[i].StartsAt != ordered[j].StartsAt {
return ordered[i].StartsAt < ordered[j].StartsAt
}
return ordered[i].Fingerprint < ordered[j].Fingerprint
})
var oldest time.Time
for _, a := range ordered {
// BELT AND BRACES WITH THE QUERY STRING, and deliberately so. The path
// asks for active only, but a server-side filter is a request and this
// is the box's own arithmetic: a silenced or inhibited alert that
// arrives anyway — an older Alertmanager, a proxy that drops the query,
// a future default change — must not be counted as firing. A silence is
// an operator saying "I know, stop telling me", and the count that
// reaches a human is the last place to honour it.
if a.State != "" && a.State != "active" {
continue
}
s.Firing++
switch a.Severity {
case "critical":
s.Critical++
case "warning":
s.Warning++
}
if t, err := time.Parse(time.RFC3339, a.StartsAt); err == nil {
if oldest.IsZero() || t.Before(oldest) {
oldest = t
}
}
if len(s.Fingerprints) < alertsFingerprintCap && a.Fingerprint != "" {
s.Fingerprints = append(s.Fingerprints, a.Fingerprint)
}
}
if !oldest.IsZero() {
since := oldest.UTC().Format(time.RFC3339)
s.Since = &since
}
return s
}
// refreshAlerts pulls the active set once and publishes the summary. Silent on
// every failure: an unreachable Alertmanager is the operator's problem to see
// through the op, not something a bastion should log on a loop every minute.
// The ctx is the heartbeat goroutine's LIFECYCLE context, not
// context.Background(): a shutdown cancels an in-flight fetch instead of
// leaving it to run out its own 20 s budget against a box that is going away.
//
// UNCONFIGURED AND FAILED ARE NOT THE SAME THING, and the cache has to tell
// them apart or it lies in one of two directions:
//
// - A transient FAILURE keeps the last good summary. The alerts are still
// firing; this box just cannot reach the Alertmanager this minute. Publishing
// zeros would be an all-clear nobody can stand behind, and `at` going stale
// is how the host sees the difference.
// - UNCONFIGURED CLEARS IT. The admin removed the alertmanager key, so this
// box is no longer watching anything and must stop claiming to. Keeping the
// last value here froze a summary onto every beat until the process
// restarted — the host would have gone on reading counts from an
// Alertmanager this box had been told to forget. Clearing makes the key
// absent again, which is the honest "did not look" state.
func refreshAlerts(parent context.Context) {
alertsMu.Lock()
if alertsRunning {
alertsMu.Unlock()
return
}
alertsRunning = true
alertsMu.Unlock()
defer func() {
alertsMu.Lock()
alertsRunning = false
alertsMu.Unlock()
}()
ctx, cancel := opBudgetFrom(parent)
defer cancel()
raw, unconfigured, err := observePull(ctx, observeKeyAlertmanager, alertmanagerAlertsPath)
if unconfigured != "" {
alertsMu.Lock()
alertsLast = nil
alertsMu.Unlock()
return
}
if err != nil {
return
}
rep, err := parseAlerts(raw)
if err != nil {
return
}
s := summarizeAlerts(rep, time.Now())
alertsMu.Lock()
alertsLast = &s
alertsMu.Unlock()
// The alerts shelf of the machine store (machinestore.go). Written here and
// not on the beat, because THIS is where a refresh actually happened: the
// beat republishes the last value every time it fires, so journalling there
// would record the beat's cadence rather than the alert set's changes.
//
// The failure is swallowed on purpose. An alerts refresh that cannot write
// its journal must still publish its counts — the counts are what the host
// reads to page somebody, and losing them to a full disk would turn a
// storage problem into a blind fleet. The findings pass reports its own
// store failures as a note on the beat and that is the channel that says
// the store is unwell.
_, _ = storeRecordAlerts(&s, time.Now())
}
// runAlertsRefresh is started beside runPostureRefresh by the heartbeat
// goroutine (run.go) and lives as long as it does. The beat NEVER waits on
// this: a late beat is what flips the box stale on the pod (ONLINE_WINDOW
// 180 s), so a beat serves the last published value and nothing else.
func runAlertsRefresh(ctx context.Context) {
refreshAlerts(ctx)
t := time.NewTicker(alertsRefreshEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
refreshAlerts(ctx)
}
}
}