carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package instanceexec
2
3import (
4 "errors"
5 "fmt"
6 "net"
7 "net/netip"
8 "os"
9 "path/filepath"
10 "strings"
11 "syscall"
12 "time"
13
14 "github.com/carlosframework/platform/internal/instancectl"
15)
16
17// What the probe unit's own resource section sets (the units test pins
18// the unit side), read back from its cgroup by the cpu-limits and
19// io-weight lines.
20const (
21 probeCPUMax = "50000 100000" // CPUQuota=50%
22 probeCPUWeight = "50"
23 probeIOWeight = "default 50"
24)
25
26// resolverProbePort is the non-DNS port the resolver-ports line dials on
27// each allowed resolver: ssh, the listener a box is most likely to have
28// bound to all addresses.
29const resolverProbePort = 22
30
31// Addresses the egress fence must refuse (#214 item 3), one per range,
32// none of them anything a box would bind. A send to each is answered by
33// the cgroup filter with EPERM before a packet leaves.
34var (
35 privateV4 = []string{"10.255.255.254", "172.31.255.254", "192.168.255.254", "100.127.255.254", "169.254.0.1", "198.18.0.1", "192.0.0.1"}
36 // v6 has no route on most boxes; ENETUNREACH there is unverified.
37 privateV6 = []string{"fd00::1", "fec0::1", "64:ff9b::a00:1"}
38)
39
40// hardeningChecks are the verdict lines for #214 items 1-3 and #178.
41func hardeningChecks(e probeEnv, add func(check, verdict, detail string), pass func(check, detail string, ok bool)) {
42 // Item 2: /opt/carlos holds the platform binary and this unit's own
43 // build, and no version store.
44 o1, ok1 := exactly(e.optRoot, "app", "carlos", "live")
45 o2, ok2 := exactly(filepath.Join(e.optRoot, "live"), instancectl.ProbeDirName)
46 pass("opt-own-build", o1+o2, ok1 && ok2)
47
48 // Item 3: the box's resolvers are reachable through the fence. This
49 // is also the live-stack control the EPERM lines below rest on.
50 resolvers := map[string]bool{}
51 var allowed []netip.Addr
52 var dnsFail []string
53 if b, err := os.ReadFile(e.resolvConf); err != nil {
54 dnsFail = append(dnsFail, err.Error())
55 } else if addrs := instancectl.ParseResolvers(b); len(addrs) == 0 {
56 dnsFail = append(dnsFail, "no nameserver in "+e.resolvConf)
57 } else {
58 for _, a := range addrs {
59 resolvers[a.String()] = true
60 if ok, why := instancectl.ResolverAllowed(a); !ok {
61 dnsFail = append(dnsFail, a.String()+": "+why)
62 continue
63 }
64 allowed = append(allowed, a)
65 network := "udp4"
66 if a.Is6() {
67 network = "udp6"
68 }
69 if err := e.sendUDP(network, netip.AddrPortFrom(a, 53).String()); err != nil {
70 dnsFail = append(dnsFail, fmt.Sprintf("%s: sendto = %v", a, err))
71 }
72 }
73 }
74 pass("dns", strings.Join(dnsFail, "; "), len(dnsFail) == 0)
75
76 // Item 3: an allowed resolver opens only a resolver. The allow is per
77 // address, both ways and every port, so a non-DNS port on it must not
78 // answer. That it is not an address of this box is the control
79 // service's check (resolver-local), made where the allow is written:
80 // listing the box's addresses takes netlink, which this unit's
81 // RestrictAddressFamilies= refuses, as it does every instance's
82 // (#126, the v0.25.0 roll).
83 var portFail []string
84 for _, a := range allowed {
85 if err := e.connectTCP(netip.AddrPortFrom(a, resolverProbePort).String(), 2*time.Second); err == nil {
86 portFail = append(portFail, fmt.Sprintf("%s: port %d answered", a, resolverProbePort))
87 }
88 }
89 pass("resolver-ports", strings.Join(portFail, "; "), len(portFail) == 0)
90
91 // Item 3: loopback is closed.
92 lo := e.sendUDP("udp4", "127.0.0.1:9")
93 pass("loopback-v4", fmt.Sprintf("sendto = %v", lo), errors.Is(lo, syscall.EPERM))
94 lo6 := e.sendUDP("udp6", "[::1]:9")
95 switch {
96 case errors.Is(lo6, syscall.EPERM):
97 add("loopback-v6", "ok", "")
98 case noV6(lo6):
99 add("loopback-v6", "unverified", fmt.Sprintf("no IPv6 on this box (%v); deny not exercised", lo6))
100 default:
101 add("loopback-v6", "fail", fmt.Sprintf("sendto = %v", lo6))
102 }
103
104 // Item 3: private and cloud-internal ranges are closed, except an
105 // address the box resolves through, which is open on purpose.
106 var v4Fail []string
107 for _, a := range privateV4 {
108 if resolvers[a] {
109 continue
110 }
111 if err := e.sendUDP("udp4", net.JoinHostPort(a, "9")); !errors.Is(err, syscall.EPERM) {
112 v4Fail = append(v4Fail, fmt.Sprintf("%s: sendto = %v", a, err))
113 }
114 }
115 pass("private-v4", strings.Join(v4Fail, "; "), len(v4Fail) == 0)
116 var v6Fail, v6Unrouted []string
117 for _, a := range privateV6 {
118 if resolvers[a] {
119 continue
120 }
121 err := e.sendUDP("udp6", net.JoinHostPort(a, "9"))
122 switch {
123 case errors.Is(err, syscall.EPERM):
124 case noV6(err):
125 v6Unrouted = append(v6Unrouted, a)
126 default:
127 v6Fail = append(v6Fail, fmt.Sprintf("%s: sendto = %v", a, err))
128 }
129 }
130 switch {
131 case len(v6Fail) > 0:
132 add("private-v6", "fail", strings.Join(v6Fail, "; "))
133 case len(v6Unrouted) > 0:
134 add("private-v6", "unverified", "no route to "+strings.Join(v6Unrouted, ", ")+"; deny not exercised")
135 default:
136 add("private-v6", "ok", "")
137 }
138
139 // #178: an IPC namespace of its own, and no host mqueue mount.
140 ino, ierr := e.ipcInode()
141 m, mok := exactly(e.devMqueue)
142 pass("ipc-private", fmt.Sprintf("ipc ns inode=%d err=%v %s", ino, ierr, m), ierr == nil && ino != initIPCInode && mok)
143
144 // #178: the trampoline's abstract socket scope holds on this kernel.
145 aerr := e.abstractScoped()
146 pass("abstract-unix", fmt.Sprint(aerr), aerr == nil)
147
148 // Item 1: CPU settings land on a unit under carlos-instances.slice.
149 cg, cerr := e.selfCgroup()
150 if cerr != nil {
151 add("cpu-limits", "fail", cerr.Error())
152 add("io-weight", "fail", cerr.Error())
153 return
154 }
155 cpuMax := readTrim(filepath.Join(cg, "cpu.max"))
156 cpuWeight := readTrim(filepath.Join(cg, "cpu.weight"))
157 pass("cpu-limits", fmt.Sprintf("cpu.max=%q cpu.weight=%q", cpuMax, cpuWeight), cpuMax == probeCPUMax && cpuWeight == probeCPUWeight)
158
159 // Item 1: the IO weight lands too, and says whether anything on this
160 // box enforces it.
161 io, ioErr := os.ReadFile(filepath.Join(cg, "io.weight"))
162 ioFirst, _, _ := strings.Cut(strings.TrimSpace(string(io)), "\n")
163 switch {
164 case ioErr != nil:
165 add("io-weight", "unverified", "no io.weight here ("+ioErr.Error()+"); IO is not weighted")
166 case ioFirst != probeIOWeight:
167 add("io-weight", "fail", fmt.Sprintf("io.weight=%q, want %q", ioFirst, probeIOWeight))
168 case !e.ioEnforced(cg):
169 add("io-weight", "unverified", "weights are set, but no disk here has io.cost or BFQ to enforce them")
170 default:
171 add("io-weight", "ok", "")
172 }
173}
174
175// noV6 is an answer that means the box has no IPv6 path for that
176// address, so the fence was never asked.
177func noV6(err error) bool {
178 return errors.Is(err, syscall.ENETUNREACH) || errors.Is(err, syscall.EADDRNOTAVAIL) || errors.Is(err, syscall.EAFNOSUPPORT)
179}
180
181func readTrim(path string) string {
182 b, err := os.ReadFile(path)
183 if err != nil {
184 return "<" + err.Error() + ">"
185 }
186 return strings.TrimSpace(string(b))
187}
188