1 import Base 2 import bend-kit-files@0.1.0.0/files.bend as Fs 3 import bend-net-json@0.3.0.0/json.bend as Json 4 import ../../effs/io.bend as X 5 import ../../plan/type.bend as P 6 import ./type.bend as ND 7 import ./ops.bend as NO 8 import ./service/type.bend as Sv 9 import ./service/ops.bend as SvO 10 11 # A nix-darwin Mac as it is, read-only: its launch agents and their processes, its listening sockets, what 12 # Tailscale serves, and whether it logs its user in by itself. What breaks a law becomes a Blocked step. 13 14 def dedupe(xs: List<&2, String>) -> List<&2, String>: 15 Set.to_list(Set.from_list(xs)) 16 17 # Lines of a program's output. Base's String.lines recurses once per character, which overflows the 18 # stack on `launchctl list` (about 20 KB); this walks the text in a loop, keeping each line reversed. 19 def unreverse(xs: List<&2, String>, acc: List<&2, String>) -> List<&2, String>: 20 match xs: 21 case []: 22 acc 23 case x <> rest: 24 unreverse(rest, String.reverse(x) <> acc) 25 26 def lines_go(s: String, +line: String, +acc: List<&2, String>) -> List<&2, String>: 27 match s: 28 case SNil{}: 29 unreverse(line <> acc, []) 30 case SCon{+c, t}: 31 +nl = Char.is_eq(c, '\n') 32 lines_go(t, Bool.pick(String, nl, "", SCon{c, line}), Bool.pick(List<&2, String>, nl, line <> acc, acc)) 33 34 def lines(s: String) -> List<&2, String>: 35 lines_go(s, "", []) 36 37 def last_of(+s: String, c: Char) -> String: 38 Maybe.default(&2, String, List.last(&2, String, String.split(s, c)), "") 39 40 # ---- the processes V runs: launchctl's pid for each job ---- 41 def pid_of(ls: List<&2, String>) -> String: 42 match ls: 43 case []: 44 "" 45 case l <> rest: 46 +t = String.trim(l) 47 Bool.pick(String, String.starts_with(t, "pid = "), String.drop(t, 6n), pid_of(rest)) 48 49 def pids(ts: List<&2, String>) -> IO(List<&2, String>): 50 match ts: 51 case []: 52 IO.pure(List<&2, String>, []) 53 case t <> rest: 54 do IO>: 55 out : String <- X.run("/bin/launchctl", ["print", t]) 56 more : List<&2, String> <- pids(rest) 57 +p : String = pid_of(lines(out)) 58 return X.keep_filled(Bool.not(String.is_empty(p)), p, more) 59 60 # A service's launchd target: the user's session at login, the system at boot. 61 def service_targets(xs: List<&2, Sv.Service>, +uid: String) -> List<&2, String>: 62 match xs: 63 case []: 64 [] 65 case +s <> rest: 66 (Bool.pick(String, SvO.at_login(SvO.start(s)), "gui/" ++ uid, "system") ++ "/" ++ SvO.label(s)) <> service_targets(rest, uid) 67 68 def agent_targets(xs: List<&2, String>, +uid: String) -> List<&2, String>: 69 match xs: 70 case []: 71 [] 72 case l <> rest: 73 ("gui/" ++ uid ++ "/" ++ l) <> agent_targets(rest, uid) 74 75 def plist_label(+f: String) -> String: 76 Bool.pick(String, String.ends_with(f, ".plist"), String.take(f, Nat.sub(String.length(f), 6n)), f) 77 78 # The launch agents nix-darwin's current generation installed. 79 def installed(fs: List<&2, String>) -> List<&2, String>: 80 match fs: 81 case []: 82 [] 83 case +f <> rest: 84 plist_label(f) <> installed(rest) 85 86 # ---- listening sockets, from `lsof -F pcn` (one field per line: p, c, n) ---- 87 # A socket is "pidcommandaddress". 88 def field(+l: String) -> String: 89 String.drop(l, 1n) 90 91 def sockets(ls: List<&2, String>, +pid: String, +cmd: String) -> List<&2, String>: 92 match ls: 93 case []: 94 [] 95 case +l <> rest: 96 X.keep_filled(String.starts_with(l, "n"), pid ++ "\t" ++ cmd ++ "\t" ++ field(l), 97 sockets(rest, Bool.pick(String, String.starts_with(l, "p"), field(l), pid), Bool.pick(String, String.starts_with(l, "c"), field(l), cmd))) 98 99 def part(+x: String, n: Nat) -> String: 100 Maybe.default(&2, String, List.get(&2, String, String.split(x, '\t'), n), "") 101 102 def loopback(+addr: String) -> Bool: 103 String.starts_with(addr, "127.") || String.starts_with(addr, "[::1]:") 104 105 # Who holds `port` besides V's processes and Tailscale's own. 106 def holders(xs: List<&2, String>, +port: String, +owned: List<&2, String>, +tailscale: String) -> List<&2, String>: 107 match xs: 108 case []: 109 [] 110 case +x <> rest: 111 +pid = part(x, 0n) 112 +cmd = part(x, 1n) 113 X.keep_filled(String.eq(last_of(part(x, 2n), ':'), port) && Bool.not(String.eq(cmd, tailscale)) && Bool.not(List.contains(~String, ~String.eq, owned, pid)), 114 cmd ++ " (pid " ++ pid ++ ")", holders(rest, port, owned, tailscale)) 115 116 # Ports that V's processes listen on. 117 def owned_ports(xs: List<&2, String>, +owned: List<&2, String>) -> List<&2, String>: 118 match xs: 119 case []: 120 [] 121 case +x <> rest: 122 X.keep_filled(List.contains(~String, ~String.eq, owned, part(x, 0n)), last_of(part(x, 2n), ':'), owned_ports(rest, owned)) 123 124 # "process port" for each socket reachable from other machines whose process is not allowed. 125 def undeclared_listeners(xs: List<&2, String>, +allowed: List<&2, String>) -> List<&2, String>: 126 match xs: 127 case []: 128 [] 129 case +x <> rest: 130 +cmd = part(x, 1n) 131 +addr = part(x, 2n) 132 X.keep_filled(Bool.not(loopback(addr)) && Bool.not(List.contains(~String, ~String.eq, allowed, cmd)), 133 cmd ++ " " ++ last_of(addr, ':'), undeclared_listeners(rest, allowed)) 134 135 # ---- launch agents: `launchctl list`'s third column; macOS's own are left out ---- 136 def foreign_agent(+a: String) -> Bool: 137 Bool.not(String.is_empty(a)) && Bool.not(String.starts_with(a, "com.apple.")) && Bool.not(String.starts_with(a, "application.")) && Bool.not(String.eq(a, "com.openssh.ssh-agent")) 138 139 def undeclared_agents(ls: List<&2, String>, +declared: List<&2, String>) -> List<&2, String>: 140 match ls: 141 case []: 142 [] 143 case l <> rest: 144 +a = Maybe.default(&2, String, List.get(&2, String, String.split(l, '\t'), 2n), "") 145 X.keep_filled(foreign_agent(a) && Bool.not(List.contains(~String, ~String.eq, declared, a)), a, undeclared_agents(rest, declared)) 146 147 # ---- Tailscale Serve, from `tailscale serve status --json`: 148 # {"Web": {":": {"Handlers": {"/": {"Proxy": "http://127.0.0.1:[/path]"}}}}} ---- 149 def or_null(m: Maybe<&2, Json.Val>) -> Json.Val: 150 match m: 151 case None{}: 152 Json.Null{} 153 case Some{v}: 154 v 155 156 def at(v: Json.Val, k: String) -> Json.Val: 157 or_null(Json.get(v, k)) 158 159 def text(v: Json.Val) -> String: 160 match v: 161 case Json.Str{s}: 162 s 163 case _: 164 "" 165 166 def keys(v: Json.Val) -> List<&2, String>: 167 match v: 168 case Json.Obj{m}: 169 Map.keys(&2, Json.Val, m) 170 case _: 171 [] 172 173 # The port of a "host:port" key or a proxy URL. 174 def url_port(+w: String) -> String: 175 Maybe.default(&2, String, List.head(&2, String, String.split(last_of(w, ':'), '/')), "") 176 177 def proxy(+web: Json.Val, k: String) -> String: 178 text(at(at(at(at(web, k), "Handlers"), "/"), "Proxy")) 179 180 # What Tailscale serves on an HTTPS port now, "" when nothing. 181 def proxy_on(+web: Json.Val, ks: List<&2, String>, +port: String) -> String: 182 match ks: 183 case []: 184 "" 185 case +k <> rest: 186 Bool.pick(String, String.eq(url_port(k), port), proxy(web, k), proxy_on(web, rest, port)) 187 188 # A serve is current, to be served, or blocked by Tailscale serving its port to something not V's. 189 def serve_step(s: ND.Serve, +web: Json.Val, +ours: List<&2, String>, +tailscale: String, +what: String) -> P.Step: 190 match s: 191 case ND.Serve{_, +h, p, path}: 192 +target = "http://127.0.0.1:" ++ U32.show(p) ++ path 193 +now = proxy_on(web, keys(web), U32.show(h)) 194 Bool.pick(P.Step, String.is_empty(now) || List.contains(~String, ~String.eq, ours, url_port(now)), 195 Bool.pick(P.Step, String.eq(now, target), P.Current{"serve :" ++ U32.show(h)}, P.ServePort{h, target, tailscale}), 196 P.Blocked{what, "tailnet port " ++ U32.show(h) ++ " is served to " ++ now ++ ", which is not V's"}) 197 198 def serve_steps(xs: List<&2, ND.Serve>, +web: Json.Val, +ours: List<&2, String>, +tailscale: String, +what: String) -> List<&2, P.Step>: 199 match xs: 200 case []: 201 [] 202 case s <> rest: 203 serve_step(s, web, ours, tailscale, what) <> serve_steps(rest, web, ours, tailscale, what) 204 205 # ---- the steps ---- 206 def ports(xs: List<&2, U32>) -> List<&2, String>: 207 match xs: 208 case []: 209 [] 210 case +p <> rest: 211 X.keep_filled(Bool.not(U32.is_eq(p, 0)), U32.show(p), ports(rest)) 212 213 def service_ports(xs: List<&2, Sv.Service>) -> List<&2, U32>: 214 match xs: 215 case []: 216 [] 217 case s <> rest: 218 SvO.port(s) <> service_ports(rest) 219 220 def serve_ports(xs: List<&2, ND.Serve>) -> List<&2, U32>: 221 match xs: 222 case []: 223 [] 224 case s <> rest: 225 NO.serve_port(s) <> serve_ports(rest) 226 227 def blocked_by(+xs: List<&2, String>, +what: String, +why: String) -> List<&2, P.Step>: 228 Bool.pick(List<&2, P.Step>, List.is_empty(&2, String, xs), [], [P.Blocked{what, why ++ String.join(xs, ", ")}]) 229 230 # Each service's local port is free of processes that are not V's. 231 def local_steps(ps: List<&2, String>, +socks: List<&2, String>, +owned: List<&2, String>, +tailscale: String, +what: String) -> List<&2, P.Step>: 232 match ps: 233 case []: 234 [] 235 case +p <> rest: 236 List.append(&2, P.Step, blocked_by(dedupe(holders(socks, p, owned, tailscale)), what, "local port " ++ p ++ " is held by "), 237 local_steps(rest, socks, owned, tailscale, what)) 238 239 def tailscale_process(on: Bool) -> String: 240 Bool.pick(String, on, "tailscaled", "IPNExtension") 241 242 def steps(+what: String, +nd: ND.NixDarwin, +installed: List<&2, String>, +owned: List<&2, String>, +socks: List<&2, String>, status: String, list: String, auto: String) -> List<&2, P.Step>: 243 +ts = tailscale_process(NO.tailscale(nd)) 244 +ours = List.append(&2, String, ports(List.append(&2, U32, service_ports(NO.services(nd)), serve_ports(NO.serves(nd)))), owned_ports(socks, owned)) 245 +declared = List.append(&2, String, SvO.labels(NO.services(nd)), List.append(&2, String, NO.kept(nd), installed)) 246 List.concat(&2, P.Step, [ 247 local_steps(ports(service_ports(NO.services(nd))), socks, owned, ts, what), 248 blocked_by(dedupe(undeclared_agents(List.drop(&2, String, lines(list), 1n), declared)), what, "undeclared launch agents: "), 249 blocked_by(dedupe(undeclared_listeners(socks, NO.listening_processes(NO.listeners(nd)))), what, "undeclared listeners: "), 250 Bool.pick(List<&2, P.Step>, NO.survives_reboot(nd, String.trim(auto)), [], 251 [P.Blocked{what, "services start at login, but the Mac does not log " ++ NO.user(nd) ++ " in by itself"}]), 252 serve_steps(NO.serves(nd), at(or_null(Json.parse(status)), "Web"), ours, NO.tailscale_command(nd), what)]) 253 254 def observe(+what: String, +nd: ND.NixDarwin, +uid: String, lsof: String) -> IO(List<&2, P.Step>): 255 do IO>: 256 r : Result<&1, &1, U32 & String, List<&2, String>> <- Fs.list_dir("/run/current-system/user/Library/LaunchAgents") 257 +inst : List<&2, String> = installed(Result.default(&1, &1, U32 & String, List<&2, String>, r, [])) 258 owned : List<&2, String> <- pids(List.append(&2, String, service_targets(NO.services(nd), uid), agent_targets(inst, uid))) 259 status : String <- X.run(NO.tailscale_command(nd), ["serve", "status", "--json"]) 260 list : String <- X.run("/bin/launchctl", ["list"]) 261 auto : String <- X.run("/usr/bin/defaults", ["read", "/Library/Preferences/com.apple.loginwindow", "autoLoginUser"]) 262 return steps(what, nd, inst, owned, sockets(lines(lsof), "", ""), status, list, auto) 263 264 def read_or(unread: Bool, +what: String, nd: ND.NixDarwin, uid: String, lsof: String) -> IO(List<&2, P.Step>): 265 match unread: 266 case True{}: 267 IO.pure(List<&2, P.Step>, [P.Blocked{what, "cannot read the Mac: `id -u` or `lsof` failed"}]) 268 case False{}: 269 observe(what, nd, uid, lsof) 270 271 # The laws that need the Mac as it is, and what Tailscale must serve: a node's checks before a deploy. 272 def checks(+node: String, nd: ND.NixDarwin) -> IO(List<&2, P.Step>): 273 do IO>: 274 +uid : String <- X.run("/usr/bin/id", ["-u"]) 275 +lsof : String <- X.run("/usr/sbin/lsof", ["+c", "0", "-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"]) 276 read_or(String.is_empty(String.trim(uid)) || String.is_empty(lsof), "nix-darwin " ++ node, nd, String.trim(uid), lsof)