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

378 lines
12 KiB
Go

package main
// Where a root command connects.
//
// A root command's output goes back to the host, and the host reads it. If the
// command fetched anything from outside the platform, that output may carry
// words someone else wrote, so the host must treat it as untrusted and end root
// for that turn. This file decides which connections count: the watch itself
// is per platform (rootexec_watch_linux.go records every outbound connection
// the command's process tree opens), and here each one is looked up in the
// machine's list of the platform's own hosts.
//
// The list is <config dir>/platform_hosts.json, written by the person who runs
// the machine:
//
// {"hosts": ["2001:db8::/32", "203.0.113.0/24", "git.example.org"]}
//
// Each entry is an address, a CIDR or a name (resolved when the command ends).
// It defaults to EMPTY: with no list, every connection that leaves this machine
// counts as outside. Loopback is this machine's own, with one exception: a
// local relay. A proxy listening on this machine (squid on 3128, say) fetches
// from outside for whoever connects to it, so a connection to one of its ports
// on a loopback or local address counts as outside. The ports are the list's
// "loopback_relays" (TCP), defaulting to the common proxy ports 1080 (SOCKS),
// 3128 (squid), 8118 (privoxy) and 9050 (tor) when the key is absent:
//
// {"hosts": [...], "loopback_relays": [3128]}
//
// The local DNS resolver (127.0.0.53:53) stays loopback: its answers are
// addresses, not content. A list that cannot be read counts as empty, and its
// relays as the default, so a broken file never makes anything trusted.
//
// This is a tripwire for a command that fetches from outside by accident, not
// a boundary: a root command can always leave its cgroup, delete the table or
// reach out through another program on purpose.
//
// The J frame carries the outside destinations as untrusted_connections, and
// leaves the field out when there are none, so a host that does not know the
// field reads the frame as before. A command whose connections could not be
// watched gets one entry with addr "*" and a note saying why: unwatched output
// is not known to be clean, so it is handled as if it had connected out.
import (
"context"
"encoding/json"
"fmt"
"net"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// rootExecConn is one destination a command's process tree opened connections
// to, and how many times it was seen opening one (a retried SYN counts again).
type rootExecConn struct {
Addr string `json:"addr"`
Proto string `json:"proto"`
Port int `json:"port,omitempty"`
Count int64 `json:"count"`
// Own is set in the audit row for a destination on the platform's list;
// the J frame lists only the others, so it never carries it.
Own bool `json:"own,omitempty"`
Note string `json:"note,omitempty"`
}
const (
// rootExecUntrustedMax bounds the list the host is given; the rest is a count.
rootExecUntrustedMax = 32
// rootExecAuditConnsMax bounds the list kept in the audit row.
rootExecAuditConnsMax = 256
// rootExecOwnHostsMax bounds the platform list read from disk.
rootExecOwnHostsMax = 1024
)
// rootExecWatch is one command's connection watch. Err says why it is not
// watching ("" = it is); every field but Err belongs to the platform code.
type rootExecWatch struct {
ID string
Err string
fd int // the command's cgroup, opened for CLONE_INTO_CGROUP; -1 when none
dir string // that cgroup's folder
table string // the nftables table this watch created
}
// CgroupFD is what the starter places the command into; -1 = nowhere.
func (w *rootExecWatch) CgroupFD() int {
if w == nil {
return -1
}
return w.fd
}
// rootExecWatchStart starts one command's watch. A var so tests can choose
// whether the kernel is touched.
var rootExecWatchStart = rootExecWatchStartKernel
// rootExecWatchStop ends a watch: what it saw, and why it saw nothing when it
// could not watch. Everything the watch created is removed.
var rootExecWatchStop = rootExecWatchStopKernel
// rootExecWatchKill ends every process still in the command's cgroup, however
// it left the command's process group (setsid, a new process group, a double
// fork): used when a wall stops holding mid-run, so nothing the command started
// keeps running as root after the grant is gone. A no-op where the command has
// no cgroup of its own. A var so tests can see it called without a kernel.
var rootExecWatchKill = rootExecWatchKillKernel
// ── the platform's own hosts ────────────────────────────────────────────────
func rootExecOwnHostsPath() (string, error) {
d, err := configDir()
if err != nil {
return "", err
}
return filepath.Join(d, "platform_hosts.json"), nil
}
type rootExecOwnHostsFile struct {
Hosts []string `json:"hosts"`
// LoopbackRelays: TCP ports on this machine that relay outside (a local
// proxy). nil (the key absent) = rootExecDefaultRelays.
LoopbackRelays *[]int `json:"loopback_relays"`
}
// rootExecDefaultRelays are the common local proxy ports: SOCKS, squid,
// privoxy, tor. A connection to one that does not listen still counts.
var rootExecDefaultRelays = []int{1080, 3128, 8118, 9050}
// rootExecLoopbackRelays is the list's loopback_relays, sorted, or the
// default when the file or the key is missing, the file cannot be read, or a
// port is out of range.
func rootExecLoopbackRelays() []int {
def := append([]int(nil), rootExecDefaultRelays...)
p, err := rootExecOwnHostsPath()
if err != nil {
return def
}
raw, err := os.ReadFile(p)
if err != nil {
return def
}
var f rootExecOwnHostsFile
if json.Unmarshal(raw, &f) != nil || f.LoopbackRelays == nil {
return def
}
seen := map[int]bool{}
out := []int{}
for _, port := range *f.LoopbackRelays {
if port < 1 || port > 65535 {
return def
}
if !seen[port] {
seen[port] = true
out = append(out, port)
}
}
sort.Ints(out)
return out
}
// rootExecLocalAddr says whether an address is this machine's own (one of its
// interfaces' addresses); a var so a test needs no interfaces.
var rootExecLocalAddr = func(a netip.Addr) bool {
addrs, err := net.InterfaceAddrs()
if err != nil {
return false
}
for _, ia := range addrs {
if n, ok := ia.(*net.IPNet); ok {
if b, ok := netip.AddrFromSlice(n.IP); ok && b.Unmap() == a {
return true
}
}
}
return false
}
// rootExecIsRelay says whether a connection went to a local relay: TCP to one
// of `relays` on a loopback address or one of this machine's own.
func rootExecIsRelay(c rootExecConn, relays []int) bool {
if c.Proto != "tcp" || len(relays) == 0 {
return false
}
hit := false
for _, p := range relays {
if p == c.Port {
hit = true
break
}
}
if !hit {
return false
}
a, err := netip.ParseAddr(c.Addr)
if err != nil {
return false
}
a = a.Unmap()
return a.IsLoopback() || rootExecLocalAddr(a)
}
// rootExecOwnHosts reads the list. A missing file is an empty list; a file
// that cannot be read or parsed is an empty list too, with the reason.
func rootExecOwnHosts() (prefixes []netip.Prefix, names []string, problem string) {
p, err := rootExecOwnHostsPath()
if err != nil {
return nil, nil, "no config folder: " + err.Error()
}
raw, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return nil, nil, ""
}
return nil, nil, p + ": " + err.Error()
}
var f rootExecOwnHostsFile
if err := json.Unmarshal(raw, &f); err != nil {
return nil, nil, p + ": " + err.Error()
}
if len(f.Hosts) > rootExecOwnHostsMax {
return nil, nil, fmt.Sprintf("%s: more than %d entries", p, rootExecOwnHostsMax)
}
for _, h := range f.Hosts {
h = strings.TrimSpace(h)
if h == "" {
continue
}
if pf, err := netip.ParsePrefix(h); err == nil {
prefixes = append(prefixes, pf.Masked())
continue
}
if a, err := netip.ParseAddr(strings.Trim(h, "[]")); err == nil {
a = a.Unmap()
prefixes = append(prefixes, netip.PrefixFrom(a, a.BitLen()))
continue
}
if strings.ContainsAny(h, " /\\:") {
return nil, nil, fmt.Sprintf("%s: %q is not an address, a CIDR or a name", p, h)
}
names = append(names, strings.ToLower(strings.TrimSuffix(h, ".")))
}
return prefixes, names, ""
}
// rootExecResolve is the resolver the platform names go through; a var so a
// test needs no DNS.
var rootExecResolve = func(ctx context.Context, name string) ([]netip.Addr, error) {
return net.DefaultResolver.LookupNetIP(ctx, "ip", name)
}
// rootExecOwnSet is the list made ready to look addresses up in: the names
// resolved now, when the command has ended. A name that does not resolve
// adds nothing, so a connection to it counts as outside.
func rootExecOwnSet() []netip.Prefix {
prefixes, names, _ := rootExecOwnHosts()
if len(names) == 0 {
return prefixes
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
for _, n := range names {
addrs, err := rootExecResolve(ctx, n)
if err != nil {
continue
}
for _, a := range addrs {
a = a.Unmap()
prefixes = append(prefixes, netip.PrefixFrom(a, a.BitLen()))
}
}
return prefixes
}
func rootExecIsOwn(addr string, own []netip.Prefix) bool {
a, err := netip.ParseAddr(addr)
if err != nil {
return false
}
a = a.Unmap()
if a.IsLoopback() {
return true
}
for _, p := range own {
if p.Contains(a) {
return true
}
}
return false
}
// rootExecClassify marks each connection own or not, and returns the outside
// ones for the host (capped, with how many more there were) and every one for
// the audit row (capped). A connection to a local relay (`relays`) is
// outside, with a note. A watch that could not watch gives one outside entry
// saying why.
func rootExecClassify(conns []rootExecConn, unwatched string, own []netip.Prefix, relays []int) (untrusted []rootExecConn, more int, all []rootExecConn) {
if unwatched != "" {
e := rootExecConn{Addr: "*", Proto: "*", Note: "connections not watched: " + unwatched}
return []rootExecConn{e}, 0, []rootExecConn{e}
}
for _, c := range conns {
c.Own = rootExecIsOwn(c.Addr, own)
if rootExecIsRelay(c, relays) {
c.Own = false
c.Note = "a local relay: this port fetches from outside (loopback_relays)"
}
all = append(all, c)
}
sort.Slice(all, func(i, j int) bool {
if all[i].Count != all[j].Count {
return all[i].Count > all[j].Count
}
if all[i].Addr != all[j].Addr {
return all[i].Addr < all[j].Addr
}
if all[i].Proto != all[j].Proto {
return all[i].Proto < all[j].Proto
}
return all[i].Port < all[j].Port
})
for _, c := range all {
if c.Own {
continue
}
if len(untrusted) == rootExecUntrustedMax {
more++
continue
}
c.Own = false
untrusted = append(untrusted, c)
}
if len(all) > rootExecAuditConnsMax {
all = all[:rootExecAuditConnsMax]
}
return untrusted, more, all
}
// rootExecConnsText is the transcript's line for the connections.
func rootExecConnsText(all []rootExecConn) string {
if len(all) == 0 {
return "none outside this machine"
}
parts := make([]string, 0, len(all))
for _, c := range all {
if c.Addr == "*" {
parts = append(parts, c.Note)
continue
}
where := "outside"
if c.Own {
where = "platform"
}
dest := c.Addr
if c.Port != 0 {
dest = net.JoinHostPort(c.Addr, fmt.Sprint(c.Port))
}
parts = append(parts, fmt.Sprintf("%s %s x%d (%s)", c.Proto, dest, c.Count, where))
}
return strings.Join(parts, ", ")
}
// rootExecOwnHostsLine is the console's line about the platform list.
func rootExecOwnHostsLine() string {
p, _ := rootExecOwnHostsPath()
prefixes, names, problem := rootExecOwnHosts()
relays := fmt.Sprintf("; local relays (TCP ports that count as outside): %v", rootExecLoopbackRelays())
if problem != "" {
return "platform hosts: unreadable, so every connection off this machine counts as outside (" + problem + ")" + relays
}
if len(prefixes)+len(names) == 0 {
return "platform hosts: none listed in " + p + ", so every connection off this machine counts as outside" + relays
}
return fmt.Sprintf("platform hosts (%s): %d addresses or ranges, %d names; any other connection a command makes counts as outside", p, len(prefixes), len(names)) + relays
}