domain/architecture/system/deployment/nix_darwin/effects.bend

near-v-framework@0.3.0.0 · 276 lines · 0 definitions

1import Base
2import bend-kit-files@0.1.0.0/files.bend as Fs
3import bend-net-json@0.3.0.0/json.bend as Json
4import near-v-framework@0.3.0.0/domain/architecture/system/effs/io.bend as X
5import near-v-framework@0.3.0.0/domain/architecture/system/plan/type.bend as P
6import near-v-framework@0.3.0.0/domain/architecture/system/deployment/nix_darwin/type.bend as ND
7import near-v-framework@0.3.0.0/domain/architecture/system/deployment/nix_darwin/ops.bend as NO
8import near-v-framework@0.3.0.0/domain/architecture/system/deployment/nix_darwin/service/type.bend as Sv
9import near-v-framework@0.3.0.0/domain/architecture/system/deployment/nix_darwin/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
14def 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.
19def 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
26def 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
34def lines(s: String) -> List<&2, String>:
35  lines_go(s, "", [])
36
37def 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 ----
41def 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
49def 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<List<&2, String>>:
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.
61def 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
68def 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
75def 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.
79def 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<pid>, c<command>, n<address:port>) ----
87# A socket is "pid<tab>command<tab>address".
88def field(+l: String) -> String:
89  String.drop(l, 1n)
90
91def 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
99def part(+x: String, n: Nat) -> String:
100  Maybe.default(&2, String, List.get(&2, String, String.split(x, '\t'), n), "")
101
102def 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.
106def 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.
117def 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.
125def 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 ----
136def 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
139def 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": {"<host>:<port>": {"Handlers": {"/": {"Proxy": "http://127.0.0.1:<local>[/path]"}}}}} ----
149def 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
156def at(v: Json.Val, k: String) -> Json.Val:
157  or_null(Json.get(v, k))
158
159def text(v: Json.Val) -> String:
160  match v:
161    case Json.Str{s}:
162      s
163    case _:
164      ""
165
166def 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.
174def url_port(+w: String) -> String:
175  Maybe.default(&2, String, List.head(&2, String, String.split(last_of(w, ':'), '/')), "")
176
177def 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.
181def 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.
189def 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
198def 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 ----
206def 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
213def 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
220def 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
227def 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.
231def 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
239def tailscale_process(on: Bool) -> String:
240  Bool.pick(String, on, "tailscaled", "IPNExtension")
241
242def 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
254def observe(+what: String, +nd: ND.NixDarwin, +uid: String, lsof: String) -> IO(List<&2, P.Step>):
255  do IO<List<&2, P.Step>>:
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
264def 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.
272def checks(+node: String, nd: ND.NixDarwin) -> IO(List<&2, P.Step>):
273  do IO<List<&2, P.Step>>:
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)