carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package instancectl
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "log"
10 "net"
11 "os"
12 "os/exec"
13 "os/user"
14 "strconv"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19
20 "golang.org/x/sys/unix"
21)
22
23// peerCred reads SO_PEERCRED from a connected unix socket.
24func peerCred(conn net.Conn) (*unix.Ucred, error) {
25 uc, ok := conn.(*net.UnixConn)
26 if !ok {
27 return nil, errors.New("not a unix connection")
28 }
29 raw, err := uc.SyscallConn()
30 if err != nil {
31 return nil, err
32 }
33 var cred *unix.Ucred
34 var cerr error
35 if err := raw.Control(func(fd uintptr) {
36 cred, cerr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
37 }); err != nil {
38 return nil, err
39 }
40 return cred, cerr
41}
42
43// productionPeerOK requires the peer to be uid carlos AND the main process
44// of carlos-edge.service as systemd reports it (design §5.4): a flat tenant
45// is a child of that pid, never the pid itself, and a nested tenant cannot
46// reach the socket at all. MainPID is asked of systemctl and cached for one
47// second, because the agent restarts and the pid changes.
48func productionPeerOK() func(net.Conn) error {
49 // A missing carlos user or an unparsable uid is fatal at startup, not a
50 // permanent refusal cached into the returned closure: socket activation
51 // restarts this service on the very next connection attempt, so a fix
52 // to the box (the user provisioned, say) takes effect on the next
53 // accept instead of requiring every already-running process to be
54 // found and killed by hand (final review, plan 2, item 6).
55 u, err := user.Lookup("carlos")
56 if err != nil {
57 log.Fatalf("instance-control: no carlos user: %v", err)
58 }
59 carlosUID, err := strconv.Atoi(u.Uid)
60 if err != nil {
61 log.Fatalf("instance-control: carlos uid %q: %v", u.Uid, err)
62 }
63 var mu sync.Mutex
64 var mainPID int
65 var lastErr error
66 var at time.Time
67 edgePID := func() (int, error) {
68 mu.Lock()
69 defer mu.Unlock()
70 // Cache both a success and a failure for one second: while the
71 // edge has no MainPID yet (a restart window), every refused
72 // connect would otherwise re-exec systemctl under this mutex —
73 // the negative case needs the same cache the positive one always
74 // had (codex review fix round 2 item 5).
75 if time.Since(at) < time.Second {
76 if mainPID > 0 {
77 return mainPID, nil
78 }
79 if lastErr != nil {
80 return 0, lastErr
81 }
82 }
83 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
84 defer cancel()
85 out, err := exec.CommandContext(ctx, "systemctl", "show", "-p", "MainPID", "--value", edgeUnit).Output()
86 if err != nil {
87 mainPID, lastErr, at = 0, err, time.Now()
88 return 0, err
89 }
90 pid, err := strconv.Atoi(strings.TrimSpace(string(out)))
91 if err != nil || pid <= 0 {
92 err = fmt.Errorf("carlos-edge.service has no MainPID")
93 mainPID, lastErr, at = 0, err, time.Now()
94 return 0, err
95 }
96 mainPID, lastErr, at = pid, nil, time.Now()
97 return pid, nil
98 }
99 return func(conn net.Conn) error {
100 cred, err := peerCred(conn)
101 if err != nil {
102 return err
103 }
104 if int(cred.Uid) != carlosUID {
105 return fmt.Errorf("peer uid %d is not carlos", cred.Uid)
106 }
107 pid, err := edgePID()
108 if err != nil {
109 return err
110 }
111 if int(cred.Pid) != pid {
112 return fmt.Errorf("peer pid %d is not the edge's main pid %d", cred.Pid, pid)
113 }
114 return nil
115 }
116}
117
118// activatedListener returns the socket-activated listener when systemd
119// passed one (LISTEN_PID is us, LISTEN_FDS is 1, the fd is 3).
120func activatedListener() (net.Listener, bool, error) {
121 if os.Getenv("LISTEN_PID") != strconv.Itoa(os.Getpid()) {
122 return nil, false, nil
123 }
124 if os.Getenv("LISTEN_FDS") != "1" {
125 return nil, false, fmt.Errorf("LISTEN_FDS=%q, want 1", os.Getenv("LISTEN_FDS"))
126 }
127 f := os.NewFile(3, "control.sock")
128 defer f.Close()
129 ln, err := net.FileListener(f)
130 if err != nil {
131 return nil, false, err
132 }
133 return ln, true, nil
134}
135
136// bindControlSocket is the hand-run fallback: bind the path with the
137// ownership the socket unit would give it (carlos, 0600).
138func bindControlSocket(path string) (net.Listener, error) {
139 _ = os.Remove(path)
140 ln, err := net.Listen("unix", path)
141 if err != nil {
142 return nil, err
143 }
144 u, err := user.Lookup("carlos")
145 if err != nil {
146 ln.Close()
147 return nil, err
148 }
149 uid, _ := strconv.Atoi(u.Uid)
150 gid, _ := strconv.Atoi(u.Gid)
151 if err := os.Chown(path, uid, gid); err != nil {
152 ln.Close()
153 return nil, err
154 }
155 if err := os.Chmod(path, 0o600); err != nil {
156 ln.Close()
157 return nil, err
158 }
159 return ln, nil
160}
161
162// probeFixturesOK checks the two probe fixtures tmpfiles stages: the
163// secret the probe must not read, and the host /dev/shm file it must not
164// see. Either one missing would make its check pass vacuously.
165func probeFixturesOK() error {
166 shm, err := os.Lstat("/dev/shm/carlos-probe")
167 if err != nil {
168 return fmt.Errorf("probe shm fixture absent: %v", err)
169 }
170 if sst, ok := shm.Sys().(*syscall.Stat_t); !ok || sst.Uid != 0 || !shm.Mode().IsRegular() {
171 return errors.New("probe shm fixture: not a root-owned regular file")
172 }
173 fi, err := os.Stat("/etc/carlos/probe-secret")
174 if err != nil {
175 return fmt.Errorf("probe fixture absent: %v", err)
176 }
177 st, ok := fi.Sys().(*syscall.Stat_t)
178 if !ok {
179 return errors.New("probe fixture: no stat")
180 }
181 g, err := user.LookupGroup("carlos")
182 if err != nil {
183 return err
184 }
185 gid, _ := strconv.Atoi(g.Gid)
186 if st.Uid != 0 || int(st.Gid) != gid || fi.Mode().Perm() != 0o640 {
187 return fmt.Errorf("probe fixture is uid %d gid %d mode %o, want root:carlos 0640", st.Uid, st.Gid, fi.Mode().Perm())
188 }
189 return nil
190}
191
192// statfsBytes is the free space an unprivileged writer can use (f_bavail,
193// so root's reserved blocks do not count) and the filesystem's size.
194func statfsBytes(path string) (avail, total uint64, err error) {
195 var st unix.Statfs_t
196 if err := unix.Statfs(path, &st); err != nil {
197 return 0, 0, err
198 }
199 bs := uint64(st.Bsize)
200 return st.Bavail * bs, st.Blocks * bs, nil
201}
202