Files
privatae-bastion/client-go/observe_endpoints.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

217 lines
8.5 KiB
Go

package main
// observe-endpoints.json — the neighbourhood the box is allowed to READ.
//
// observe:alerts, observe:metrics and observe:logs are HTTP reads of services
// that run beside this box: an Alertmanager, a Prometheus, a Loki. Nothing is
// exec'd, so the sudoers discipline the rest of the family lives by shows up
// here as a different wall: the HOST never names a URL. The admin places one
// file at install and the box will talk to those three addresses and nowhere
// else; the host's only argument is a NAME out of a fixed table.
//
// This follows observeKubeconfig (observe_ops.go:457) exactly: a file the
// installer places, never this code, and an op that says so plainly when it is
// absent rather than inventing a value. observe:k8s answers
// {"supported":false,"reason":...} with exit 0 (readK8s, observe_ops_linux.go:302-306)
// because "this box cannot say" is a true answer and 126 means "not permitted";
// the three ops here answer in the same shape, with the same exit code.
import (
"context"
"encoding/json"
"errors"
"net"
"net/netip"
"net/url"
"os"
"strings"
)
// observeEndpointsPath is placed by the admin at install, beside the read-only
// kubeconfig observe:k8s reads. A var, not a const, only so the tests can point
// it at a temp file; nothing in the product ever assigns it.
var observeEndpointsPath = "/etc/privatae-bastion/observe-endpoints.json"
// observeEndpoints is the whole file: three optional keys, each a base URL.
//
// {"alertmanager":"http://192.0.2.10:9093",
// "prometheus":"http://192.0.2.11:9090",
// "loki":"http://192.0.2.12:3100"}
//
// IP literals are the common case, because a host-network process on many
// platforms cannot resolve cluster-internal service names — a name is
// accepted too, but it has to resolve here and every address it resolves to
// has to pass the check below.
type observeEndpoints struct {
Alertmanager string `json:"alertmanager"`
Prometheus string `json:"prometheus"`
Loki string `json:"loki"`
}
func (e observeEndpoints) get(key string) string {
switch key {
case observeKeyAlertmanager:
return e.Alertmanager
case observeKeyPrometheus:
return e.Prometheus
case observeKeyLoki:
return e.Loki
}
return ""
}
const (
observeKeyAlertmanager = "alertmanager"
observeKeyPrometheus = "prometheus"
observeKeyLoki = "loki"
)
// errEndpointsAbsent is the "the admin has not placed the file" case, which is
// NOT an error the host should see as a refusal — it is the unconfigured
// answer. Kept as a sentinel so the handlers can tell it from a malformed file,
// which IS a refusal.
var errEndpointsAbsent = errors.New("absent")
// THE REFUSAL LIST IS INVERTED HERE, ON PURPOSE.
//
// observe:net refuses every non-public address (nonPublicRanges,
// observe_ops.go:617-624): its job is an outside-in probe of a NAMED PUBLIC
// DOOR, and a probe of a mesh or LAN address from a fleet box is a different
// tool. These three ops are that different tool. Their whole point is reading
// the box's own neighbourhood — a ClusterIP, a loopback port, a mesh address —
// so loopback, RFC1918, CGNAT, link-local and ULA are exactly what is ALLOWED,
// and a PUBLIC address is what is refused.
//
// The refusal is not symmetry for its own sake. Without it this file is a way
// to make the box fetch the internet on the host's behalf: place one URL and
// every `observe:alerts` becomes an outbound GET to somebody else's server,
// from inside the perimeter, with the box's own identity. The endpoints file is
// an admin's declaration of what is NEXT TO this machine, and nothing else.
func observeEndpointAllowed(a netip.Addr) bool { return !isPublicAddr(a) }
// validateObserveEndpoint checks one key's URL and returns the normalized base.
// Every failure names the key, because the admin reading the message has three
// of them in one file.
func validateObserveEndpoint(ctx context.Context, key, raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return "", errors.New(key + ": not a URL: " + err.Error())
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", errors.New(key + `: scheme must be http or https, got "` + u.Scheme + `"`)
}
if u.User != nil {
return "", errors.New(key + ": credentials in the URL are not accepted")
}
if u.RawQuery != "" || u.Fragment != "" {
return "", errors.New(key + ": the endpoint is a base URL — no query, no fragment")
}
if p := strings.Trim(u.Path, "/"); p != "" {
return "", errors.New(key + ": the endpoint is a base URL — no path, the op owns the path")
}
host := u.Hostname()
if host == "" {
return "", errors.New(key + ": no host")
}
var addrs []netip.Addr
if a, err := netip.ParseAddr(host); err == nil {
addrs = []netip.Addr{a}
} else {
// A hostname is allowed, and it is resolved HERE so the check below has
// something to judge. Same all-must-pass rule as publicTarget
// (observe_ops.go:646-667), inverted: one public record among private
// ones is enough to turn the op outward, so all must be private.
resolved, err := netResolve(ctx, host)
if err != nil {
return "", errors.New(key + ": resolve " + host + ": " + err.Error())
}
if len(resolved) == 0 {
return "", errors.New(key + ": resolve " + host + ": no addresses")
}
addrs = resolved
}
for _, a := range addrs {
if !observeEndpointAllowed(a) {
return "", errors.New(key + ": " + a.Unmap().String() +
" is a public address — these ops read this box's own neighbourhood only")
}
}
port := u.Port()
if port != "" {
if _, err := net.LookupPort("tcp", port); err != nil {
return "", errors.New(key + ": bad port " + port)
}
}
// THE HOSTNAME IS RETURNED UNRESOLVED, AND THAT IS A TOCTOU BY CONSTRUCTION.
// The addresses checked just above came from a resolution that happened
// HERE; the transport resolves again when it dials, and between the two the
// answer can change — DNS rebinding, a short TTL, a split-horizon resolver
// that answers differently per query. Returning the resolved literal instead
// would not fix it: it would pin an address and break every endpoint behind
// a name that legitimately moves, and it would still be one resolution
// earlier than the socket.
//
// The gap is closed one layer down, where it cannot be raced:
// observeDialControl (observe_pull.go) re-checks observeEndpointAllowed
// against the address the socket is ABOUT TO CONNECT TO, after the
// transport's own resolution and before connect. This check stays because it
// gives the admin a clear, named error at the point of a typo; the dial
// control is what actually holds the wall.
return u.Scheme + "://" + u.Host, nil
}
// loadObserveEndpoints reads and validates the file. errEndpointsAbsent when it
// is not there; a named error when it is there and wrong. Read per call rather
// than cached: the admin edits the file, and an op that answered from a cache
// would keep reporting an address the admin has already removed.
func loadObserveEndpoints(ctx context.Context) (observeEndpoints, error) {
var e observeEndpoints
raw, err := os.ReadFile(observeEndpointsPath)
if err != nil {
if os.IsNotExist(err) {
return e, errEndpointsAbsent
}
return e, errors.New("observe-endpoints.json: " + err.Error())
}
if err := json.Unmarshal(raw, &e); err != nil {
return e, errors.New("observe-endpoints.json: " + err.Error())
}
for _, key := range []string{observeKeyAlertmanager, observeKeyPrometheus, observeKeyLoki} {
v := e.get(key)
if strings.TrimSpace(v) == "" {
continue
}
norm, err := validateObserveEndpoint(ctx, key, v)
if err != nil {
return observeEndpoints{}, errors.New("observe-endpoints.json: " + err.Error())
}
switch key {
case observeKeyAlertmanager:
e.Alertmanager = norm
case observeKeyPrometheus:
e.Prometheus = norm
case observeKeyLoki:
e.Loki = norm
}
}
return e, nil
}
// observeEndpointFor resolves ONE key to a base URL, with the three answers the
// handlers need kept apart: configured, unconfigured (absent file or absent
// key — reported, not refused), refused (a file that is there and wrong).
func observeEndpointFor(ctx context.Context, key string) (base string, unconfigured string, err error) {
e, err := loadObserveEndpoints(ctx)
if errors.Is(err, errEndpointsAbsent) {
return "", "unconfigured: no endpoints file at " + observeEndpointsPath, nil
}
if err != nil {
return "", "", err
}
base = e.get(key)
if base == "" {
return "", "unconfigured: " + observeEndpointsPath + ` names no "` + key + `"`, nil
}
return base, "", nil
}