bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user