bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+453
View File
@@ -0,0 +1,453 @@
//go:build linux
package main
// The connection watch on Linux: each root command starts inside a cgroup of
// its own (CLONE_INTO_CGROUP, so no child is ever outside it), and an
// nftables table this bastion creates for that command records every
// connection a socket of that cgroup opens: `socket cgroupv2` matches the
// cgroup the socket was made in, and a dynamic set with a counter keeps each
// destination once. The table's chain has policy accept and gives no verdict,
// so it changes nothing about what the machine allows: the firewall that is
// already here (ufw, its own nftables tables) is never edited, flushed or
// reordered, and a packet it drops is still dropped.
//
// When the command ends, the sets are read, any process the command left
// running is moved back to the bastion's own cgroup (where every command ran
// before this watch, so it lives on exactly as it did), and the cgroup and
// the table are removed. If the bastion dies first, systemd removes the
// cgroup with the unit, and the next start (and every new watch) deletes any
// table of this name it did not make itself.
//
// What it cannot see, plainly: connections another program makes on the
// command's behalf. `docker build` / `docker pull` ask dockerd, which pulls
// with its own sockets, and a container's processes live in docker's
// cgroups; `systemctl start`, `systemd-run` and snapd are the same. Those are
// not the command's process tree and are not attributed. A local proxy is
// seen at its door: a connection to one of the list's loopback_relays ports
// is recorded and counts as outside. A root command can also leave its cgroup
// or delete the table on purpose; a table that is gone when the command ends
// reads as "not watched". This is a tripwire for an accidental outside fetch,
// not a boundary: root can always defeat it.
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/unix"
)
const (
rootExecWatchTablePrefix = "privatae_rootexec_"
rootExecWatchCgroupPrefix = "privatae-rootexec-"
rootExecWatchSetSize = 65535
cgroupRoot = "/sys/fs/cgroup"
)
var rootExecWatching = struct {
sync.Mutex
ids map[string]bool
}{ids: map[string]bool{}}
func rootExecWatchActive(id string) bool {
rootExecWatching.Lock()
defer rootExecWatching.Unlock()
return rootExecWatching.ids[id]
}
func rootExecNft() (string, error) {
for _, p := range []string{"/usr/sbin/nft", "/sbin/nft"} {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, nil
}
}
return exec.LookPath("nft")
}
func rootExecRunNft(stdin string, args ...string) ([]byte, error) {
bin, err := rootExecNft()
if err != nil {
return nil, errors.New("nft is not installed")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, args...)
cmd.Env = []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"}
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if len(msg) > 300 {
msg = msg[:300]
}
return nil, fmt.Errorf("nft %s: %v %s", strings.Join(args, " "), err, msg)
}
return out.Bytes(), nil
}
// rootExecOwnCgroup is this process's cgroup v2 path, e.g.
// /system.slice/privatae-bastion.service.
func rootExecOwnCgroup() (string, error) {
raw, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return "", err
}
for _, line := range strings.Split(string(raw), "\n") {
if p, ok := strings.CutPrefix(line, "0::"); ok && strings.HasPrefix(p, "/") {
return p, nil
}
}
return "", errors.New("this machine does not run the unified cgroup hierarchy")
}
func rootExecWatchStartKernel(sessionID string) *rootExecWatch {
_ = sessionID
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return &rootExecWatch{fd: -1, Err: "no randomness for the watch's name"}
}
w := &rootExecWatch{ID: hex.EncodeToString(b[:]), fd: -1}
rootExecWatching.Lock()
rootExecWatching.ids[w.ID] = true
rootExecWatching.Unlock()
rootExecWatchSweep()
if err := rootExecWatchSetup(w); err != nil {
w.Err = err.Error()
rootExecWatchCleanup(w)
}
return w
}
func rootExecWatchSetup(w *rootExecWatch) error {
if _, err := rootExecNft(); err != nil {
return errors.New("nft is not installed")
}
own, err := rootExecOwnCgroup()
if err != nil {
return err
}
rel := strings.Trim(filepath.Join(own, rootExecWatchCgroupPrefix+w.ID), "/")
if strings.ContainsAny(rel, "\"\\\n") {
return fmt.Errorf("this bastion's cgroup path %q cannot be named in an nftables rule", own)
}
dir := filepath.Join(cgroupRoot, rel)
if err := os.Mkdir(dir, 0o755); err != nil {
return fmt.Errorf("cannot make the command's cgroup: %v", err)
}
w.dir = dir
level := strings.Count(rel, "/") + 1
table := rootExecWatchTablePrefix + w.ID
// Loopback is skipped, except TCP to a local relay's port (a proxy that
// fetches from outside, rootExecLoopbackRelays): that is recorded, and
// rootExecClassify counts it as outside.
lo := `oifname "lo" return`
if relays := rootExecLoopbackRelays(); len(relays) > 0 {
ports := make([]string, len(relays))
for i, p := range relays {
ports[i] = strconv.Itoa(p)
}
lo = `oifname "lo" meta l4proto != tcp return
oifname "lo" tcp dport != { ` + strings.Join(ports, ", ") + ` } return`
}
// ct state: one entry per connection opened, not per packet. untracked is
// there too, so a raw-table notrack rule elsewhere cannot hide a flow.
script := fmt.Sprintf(`table inet %[1]s {
set out4 {
type ipv4_addr . inet_proto . inet_service
flags dynamic
counter
size %[2]d
}
set out6 {
type ipv6_addr . inet_proto . inet_service
flags dynamic
counter
size %[2]d
}
set other4 {
type ipv4_addr . inet_proto
flags dynamic
counter
size %[2]d
}
set other6 {
type ipv6_addr . inet_proto
flags dynamic
counter
size %[2]d
}
chain seen {
meta nfproto ipv4 meta l4proto { tcp, udp } add @out4 { ip daddr . meta l4proto . th dport }
meta nfproto ipv6 meta l4proto { tcp, udp } add @out6 { ip6 daddr . meta l4proto . th dport }
meta nfproto ipv4 meta l4proto != { tcp, udp } add @other4 { ip daddr . meta l4proto }
meta nfproto ipv6 meta l4proto != { tcp, udp } add @other6 { ip6 daddr . meta l4proto }
}
chain out {
type filter hook output priority -190; policy accept;
%[5]s
socket cgroupv2 level %[3]d "%[4]s" ct state { new, untracked } jump seen
}
}
`, table, rootExecWatchSetSize, level, rel, lo)
if _, err := rootExecRunNft(script, "-f", "-"); err != nil {
return fmt.Errorf("cannot create the watch's nftables table: %v", err)
}
w.table = table
fd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
if err != nil {
return fmt.Errorf("cannot open the command's cgroup: %v", err)
}
w.fd = fd
return nil
}
// nftListTable is the part of `nft -j list table` the watch reads.
type nftListTable struct {
Nftables []struct {
Set *struct {
Name string `json:"name"`
Size int `json:"size"`
Elem []struct {
Elem struct {
Val struct {
Concat []any `json:"concat"`
} `json:"val"`
Counter struct {
Packets int64 `json:"packets"`
} `json:"counter"`
} `json:"elem"`
} `json:"elem"`
} `json:"set"`
Rule *struct {
Chain string `json:"chain"`
} `json:"rule"`
} `json:"nftables"`
}
func nftScalar(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
}
return fmt.Sprint(v)
}
// rootExecWatchRead reads what the table recorded. A table that is gone, or
// whose watching rule is gone, is reported as not watched.
func rootExecWatchRead(table string) ([]rootExecConn, error) {
out, err := rootExecRunNft("", "-j", "list", "table", "inet", table)
if err != nil {
return nil, errors.New("the watch's table was gone when the command ended")
}
var lt nftListTable
if err := json.Unmarshal(out, &lt); err != nil {
return nil, fmt.Errorf("cannot read the watch's table: %v", err)
}
watching, sets := false, 0
var conns []rootExecConn
for _, o := range lt.Nftables {
if o.Rule != nil && o.Rule.Chain == "out" {
watching = true
}
s := o.Set
if s == nil {
continue
}
sets++
if s.Size > 0 && len(s.Elem) >= s.Size {
return nil, fmt.Errorf("more than %d destinations", s.Size)
}
for _, e := range s.Elem {
c := e.Elem.Val.Concat
if len(c) < 2 {
continue
}
rc := rootExecConn{Addr: nftScalar(c[0]), Proto: nftScalar(c[1]), Count: e.Elem.Counter.Packets}
if len(c) >= 3 {
rc.Port, _ = strconv.Atoi(nftScalar(c[2]))
}
conns = append(conns, rc)
}
}
if !watching || sets != 4 {
return nil, errors.New("the watch's rule or sets were removed while the command ran")
}
return conns, nil
}
func rootExecWatchStopKernel(w *rootExecWatch) ([]rootExecConn, string) {
if w == nil {
return nil, "no watch was started"
}
defer func() {
rootExecWatchCleanup(w)
rootExecWatching.Lock()
delete(rootExecWatching.ids, w.ID)
rootExecWatching.Unlock()
}()
if w.Err != "" {
return nil, w.Err
}
conns, err := rootExecWatchRead(w.table)
if err != nil {
return nil, err.Error()
}
return conns, ""
}
// rootExecWatchCleanup removes what a watch created. Safe to call twice.
func rootExecWatchCleanup(w *rootExecWatch) {
if w.fd >= 0 {
_ = unix.Close(w.fd)
w.fd = -1
}
if w.dir != "" {
if rootExecCgroupRemove(w.dir) == nil {
w.dir = ""
}
}
if w.table != "" {
if _, err := rootExecRunNft("", "delete", "table", "inet", w.table); err == nil {
w.table = ""
}
}
}
// rootExecKillPid sends SIGKILL to one process of a command's cgroup. A var
// so a test can fence it; it never takes pid 0 or 1.
var rootExecKillPid = func(pid int) error {
if pid <= 1 {
return syscall.EINVAL
}
return syscall.Kill(pid, syscall.SIGKILL)
}
// rootExecWatchKillKernel kills every process in the command's cgroup:
// cgroup.kill (Linux 5.14+), which the kernel applies to the whole cgroup at
// once, so a process forking meanwhile cannot slip out; on an older kernel,
// SIGKILL to each pid cgroup.procs lists, read again until it is empty or a
// few rounds have passed. A process that moved itself to another cgroup is not
// here; the process-group kill that runs beside this one still reaches it if it
// kept the command's group.
func rootExecWatchKillKernel(w *rootExecWatch) {
if w == nil || w.dir == "" {
return
}
if f, err := os.OpenFile(filepath.Join(w.dir, "cgroup.kill"), os.O_WRONLY, 0); err == nil {
_, werr := f.Write([]byte("1"))
_ = f.Close()
if werr == nil {
return
}
}
for round := 0; round < 20; round++ {
raw, err := os.ReadFile(filepath.Join(w.dir, "cgroup.procs"))
if err != nil {
return
}
pids := strings.Fields(string(raw))
if len(pids) == 0 {
return
}
for _, f := range pids {
if pid, err := strconv.Atoi(f); err == nil && pid > 1 {
_ = rootExecKillPid(pid)
}
}
time.Sleep(10 * time.Millisecond)
}
}
// rootExecCgroupRemove moves whatever is left in a command's cgroup into its
// parent (the bastion's own cgroup, where a command's leftovers lived before
// the watch) and removes the cgroup.
func rootExecCgroupRemove(dir string) error {
parent := filepath.Join(filepath.Dir(dir), "cgroup.procs")
var err error
for i := 0; i < 50; i++ {
if err = syscall.Rmdir(dir); err == nil || errors.Is(err, syscall.ENOENT) {
return nil
}
raw, rerr := os.ReadFile(filepath.Join(dir, "cgroup.procs"))
if rerr != nil {
return rerr
}
for _, f := range strings.Fields(string(raw)) {
_ = os.WriteFile(parent, []byte(f), 0)
}
time.Sleep(10 * time.Millisecond)
}
return err
}
// rootExecWatchSweep removes every watch table and command cgroup that no
// watch of this process holds: what a bastion that died mid-command left.
func rootExecWatchSweep() {
if os.Geteuid() != 0 {
return
}
if out, err := rootExecRunNft("", "-j", "list", "tables", "inet"); err == nil {
var lt struct {
Nftables []struct {
Table *struct {
Family string `json:"family"`
Name string `json:"name"`
} `json:"table"`
} `json:"nftables"`
}
if json.Unmarshal(out, &lt) == nil {
for _, o := range lt.Nftables {
if o.Table == nil || o.Table.Family != "inet" {
continue
}
id, ok := strings.CutPrefix(o.Table.Name, rootExecWatchTablePrefix)
if !ok || rootExecWatchActive(id) {
continue
}
_, _ = rootExecRunNft("", "delete", "table", "inet", o.Table.Name)
}
}
}
own, err := rootExecOwnCgroup()
if err != nil {
return
}
ents, err := os.ReadDir(filepath.Join(cgroupRoot, own))
if err != nil {
return
}
for _, e := range ents {
id, ok := strings.CutPrefix(e.Name(), rootExecWatchCgroupPrefix)
if !e.IsDir() || !ok || rootExecWatchActive(id) {
continue
}
_ = rootExecCgroupRemove(filepath.Join(cgroupRoot, own, e.Name()))
}
}
// rootExecIntoCgroup starts the command inside the watch's cgroup, so the
// shell and every child it makes are in it from their first instruction.
func rootExecIntoCgroup(attr *syscall.SysProcAttr, fd int) {
if fd >= 0 {
attr.UseCgroupFD = true
attr.CgroupFD = fd
}
}