1use serde::Serialize;
4
5pub mod help;
6
7#[derive(Serialize, Clone)]
9pub struct Input {
10 pub name: String,
12 #[serde(rename = "type")]
14 pub ty: String,
15 pub required: bool,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub default: Option<String>,
20}
21
22#[derive(Serialize, Clone)]
24pub struct Descriptor {
25 pub id: String,
27 pub summary: String,
29 pub long: String,
31 pub privileged: bool,
33 pub output_kind: String,
35 pub inputs: Vec<Input>,
37 pub flags: Vec<String>,
39 pub examples: Vec<String>,
41}
42
43fn input(name: &str, required: bool) -> Input {
44 Input {
45 name: name.into(),
46 ty: "string".into(),
47 required,
48 default: None,
49 }
50}
51
52fn mutation(
53 id: &str,
54 summary: &str,
55 long: &str,
56 output_kind: &str,
57 extra_flags: &[&str],
58) -> Descriptor {
59 let mut flags = vec![
60 "--host".to_string(),
61 "--json".to_string(),
62 "--dry-run".to_string(),
63 "--force".to_string(),
64 ];
65 flags.extend(extra_flags.iter().map(|f| f.to_string()));
66 Descriptor {
67 id: id.into(),
68 summary: summary.into(),
69 long: long.into(),
70 privileged: true,
71 output_kind: output_kind.into(),
72 inputs: vec![input("unit", true)],
73 flags,
74 examples: vec![format!("fez {} --json", id.replace('.', " "))],
75 }
76}
77
78fn enablement(id: &str, summary: &str, long: &str) -> Descriptor {
79 let verb = id.rsplit('.').next().expect("capability id has a verb");
80 Descriptor {
81 id: id.into(),
82 summary: summary.into(),
83 long: long.into(),
84 privileged: true,
85 output_kind: "ServiceEnablement".into(),
86 inputs: vec![input("unit", true)],
87 flags: vec![
88 "--host".into(),
89 "--json".into(),
90 "--dry-run".into(),
91 "--force".into(),
92 "--now".into(),
93 ],
94 examples: vec![
95 format!("fez services {verb} chronyd.service --json"),
96 format!("fez services {verb} chronyd.service --now"),
97 ],
98 }
99}
100
101pub fn registry() -> Vec<Descriptor> {
103 vec![
104 Descriptor {
105 id: "services.list".into(),
106 summary: "List systemd units".into(),
107 long: "List systemd units on the target host. Use --state to filter by \
108active state (e.g. active, failed, inactive). Read-only; never mutates."
109 .into(),
110 privileged: false,
111 output_kind: "ServiceList".into(),
112 inputs: vec![input("state", false)],
113 flags: vec!["--host".into(), "--json".into(), "--state".into()],
114 examples: vec![
115 "fez services list --state failed --json".into(),
116 "fez --host web01 services list".into(),
117 ],
118 },
119 Descriptor {
120 id: "services.status".into(),
121 summary: "Show one unit's status".into(),
122 long: "Show the current status of a single systemd unit (active state, \
123sub-state, enablement). Read-only."
124 .into(),
125 privileged: false,
126 output_kind: "ServiceStatus".into(),
127 inputs: vec![input("unit", true)],
128 flags: vec!["--host".into(), "--json".into()],
129 examples: vec!["fez services status sshd.service --json".into()],
130 },
131 Descriptor {
132 id: "services.logs".into(),
133 summary: "Read a unit's journal".into(),
134 long: "Read journal entries for a unit. Filter with --since and --priority \
135(journalctl syntax), cap with --lines, or stream with --follow. Read-only."
136 .into(),
137 privileged: false,
138 output_kind: "LogEntries".into(),
139 inputs: vec![input("unit", true)],
140 flags: vec![
141 "--host".into(),
142 "--json".into(),
143 "--since".into(),
144 "--priority".into(),
145 "--lines".into(),
146 "--follow".into(),
147 ],
148 examples: vec![
149 "fez services logs sshd.service --lines 100 --json".into(),
150 "fez services logs nginx.service --since '1 hour ago' --priority err".into(),
151 ],
152 },
153 mutation(
154 "services.start",
155 "Start a unit",
156 "Start a systemd unit immediately. Privileged. Protected units are \
157refused unless --force is supplied. Exits 8 on a protected-unit refusal.",
158 "ServiceMutation",
159 &[],
160 ),
161 mutation(
162 "services.stop",
163 "Stop a unit",
164 "Stop a running systemd unit. Privileged. Protected units are refused \
165unless --force is supplied (exit 8).",
166 "ServiceMutation",
167 &[],
168 ),
169 mutation(
170 "services.restart",
171 "Restart a unit",
172 "Restart a systemd unit. Privileged. Protected units are refused unless \
173--force is supplied (exit 8).",
174 "ServiceMutation",
175 &[],
176 ),
177 mutation(
178 "services.reload",
179 "Reload a unit's configuration",
180 "Ask a unit to reload its configuration without a full restart. \
181Privileged. Protected units are refused unless --force is supplied (exit 8).",
182 "ServiceMutation",
183 &[],
184 ),
185 enablement(
186 "services.enable",
187 "Enable a unit",
188 "Enable a unit so it starts at boot. Add --now to also start it \
189immediately. Privileged. Protected units are refused unless --force is supplied (exit 8).",
190 ),
191 enablement(
192 "services.disable",
193 "Disable a unit",
194 "Disable a unit so it no longer starts at boot. Add --now to also \
195stop it immediately. Privileged. Protected units are refused unless --force is supplied (exit 8).",
196 ),
197 Descriptor {
198 id: "packages.list".into(),
199 summary: "List packages".into(),
200 long: "List installed (default) or available packages. Use --available to list \
201available packages, --repo to restrict by repository. Read-only."
202 .into(),
203 privileged: false,
204 output_kind: "PackageList".into(),
205 inputs: vec![],
206 flags: vec![
207 "--host".into(),
208 "--json".into(),
209 "--installed".into(),
210 "--available".into(),
211 "--repo".into(),
212 ],
213 examples: vec![
214 "fez packages list --json".into(),
215 "fez packages list --available --repo fedora".into(),
216 ],
217 },
218 Descriptor {
219 id: "packages.info".into(),
220 summary: "Show one package's attributes".into(),
221 long: "Show the full attributes of a single package (version, arch, repo, size, \
222summary). Read-only."
223 .into(),
224 privileged: false,
225 output_kind: "PackageInfo".into(),
226 inputs: vec![input("spec", true)],
227 flags: vec!["--host".into(), "--json".into()],
228 examples: vec!["fez packages info bash --json".into()],
229 },
230 Descriptor {
231 id: "packages.search".into(),
232 summary: "Search packages".into(),
233 long: "Search available packages by name, summary, or provides. Read-only.".into(),
234 privileged: false,
235 output_kind: "PackageSearch".into(),
236 inputs: vec![input("pattern", true)],
237 flags: vec!["--host".into(), "--json".into()],
238 examples: vec!["fez packages search nginx --json".into()],
239 },
240 Descriptor {
241 id: "packages.check-update".into(),
242 summary: "List available upgrades".into(),
243 long: "List packages with available upgrades. Read-only.".into(),
244 privileged: false,
245 output_kind: "PackageUpdates".into(),
246 inputs: vec![],
247 flags: vec!["--host".into(), "--json".into()],
248 examples: vec!["fez packages check-update --json".into()],
249 },
250 Descriptor {
251 id: "packages.repolist".into(),
252 summary: "List repositories".into(),
253 long: "List repositories and their enabled state. Use --enabled (default), \
254--disabled, or --all. Read-only."
255 .into(),
256 privileged: false,
257 output_kind: "RepoList".into(),
258 inputs: vec![],
259 flags: vec![
260 "--host".into(),
261 "--json".into(),
262 "--enabled".into(),
263 "--disabled".into(),
264 "--all".into(),
265 ],
266 examples: vec!["fez packages repolist --all --json".into()],
267 },
268 Descriptor {
269 id: "packages.install".into(),
270 summary: "Install packages".into(),
271 long: "Install one or more packages. Resolves the transaction first and surfaces \
272the plan; --dry-run stops after the plan. Privileged. Exits 9 if dnf5daemon is \
273missing, 10 if the resolved transaction is refused by removal guardrails (use \
274--force to override)."
275 .into(),
276 privileged: true,
277 output_kind: "PackageMutation".into(),
278 inputs: vec![input("specs", true)],
279 flags: vec![
280 "--host".into(),
281 "--json".into(),
282 "--dry-run".into(),
283 "--force".into(),
284 ],
285 examples: vec![
286 "fez packages install htop --json".into(),
287 "fez packages install nginx --dry-run".into(),
288 ],
289 },
290 Descriptor {
291 id: "packages.remove".into(),
292 summary: "Remove packages".into(),
293 long: "Remove one or more packages. Resolves first and applies removal guardrails: \
294a protected package or a cascade larger than the limit is refused unless --force \
295is supplied (exit 10). --dry-run surfaces the plan without removing. Privileged."
296 .into(),
297 privileged: true,
298 output_kind: "PackageMutation".into(),
299 inputs: vec![input("specs", true)],
300 flags: vec![
301 "--host".into(),
302 "--json".into(),
303 "--dry-run".into(),
304 "--force".into(),
305 ],
306 examples: vec![
307 "fez packages remove htop --json".into(),
308 "fez packages remove oldpkg --dry-run".into(),
309 ],
310 },
311 Descriptor {
312 id: "packages.upgrade".into(),
313 summary: "Upgrade packages".into(),
314 long: "Upgrade named packages, or all packages when no spec is given. Resolves \
315first and surfaces the plan; --dry-run stops after the plan. Privileged. Refusals \
316from removal guardrails (replaced/obsoleted packages) exit 10 unless --force is \
317supplied."
318 .into(),
319 privileged: true,
320 output_kind: "PackageMutation".into(),
321 inputs: vec![input("specs", false)],
322 flags: vec![
323 "--host".into(),
324 "--json".into(),
325 "--dry-run".into(),
326 "--force".into(),
327 ],
328 examples: vec![
329 "fez packages upgrade --json".into(),
330 "fez packages upgrade nginx --force".into(),
331 ],
332 },
333 Descriptor {
334 id: "network.list".into(),
335 summary: "List network devices".into(),
336 long: "List NetworkManager devices with their type, state, primary IPv4/IPv6 \
337address, and MAC. By default unmanaged virtual interfaces (container veth, etc.) are \
338hidden; use --all to show every device. Read-only."
339 .into(),
340 privileged: false,
341 output_kind: "NetworkDeviceList".into(),
342 inputs: vec![],
343 flags: vec!["--host".into(), "--json".into(), "--all".into()],
344 examples: vec![
345 "fez network list --json".into(),
346 "fez network list --all".into(),
347 ],
348 },
349 Descriptor {
350 id: "network.show".into(),
351 summary: "Show one device's network detail".into(),
352 long: "Show the full network detail for one device: addresses (IPv4 and IPv6), \
353gateway, DNS servers, search domains, routes, MAC, MTU, the active connection profile, \
354and DHCP lease. Read-only."
355 .into(),
356 privileged: false,
357 output_kind: "NetworkDeviceDetail".into(),
358 inputs: vec![input("device", true)],
359 flags: vec!["--host".into(), "--json".into()],
360 examples: vec!["fez network show enp1s0 --json".into()],
361 },
362 Descriptor {
363 id: "firewall.status".into(),
364 summary: "Show firewall status".into(),
365 long: "Show firewalld state, the default zone, the panic-mode flag, and any \
366uncommitted runtime-vs-permanent drift (pending_changes). Read-only."
367 .into(),
368 privileged: false,
369 output_kind: "FirewallStatus".into(),
370 inputs: vec![],
371 flags: vec!["--host".into(), "--json".into()],
372 examples: vec!["fez firewall status --json".into()],
373 },
374 Descriptor {
375 id: "firewall.list".into(),
376 summary: "List firewall zones".into(),
377 long: "List all firewalld zones with a per-zone summary (default flag, \
378services, ports, interfaces). Read-only."
379 .into(),
380 privileged: false,
381 output_kind: "FirewallZoneList".into(),
382 inputs: vec![],
383 flags: vec!["--host".into(), "--json".into()],
384 examples: vec!["fez firewall list --json".into()],
385 },
386 Descriptor {
387 id: "firewall.show".into(),
388 summary: "Show one zone's detail".into(),
389 long: "Show one zone's full firewall detail: services, ports, interfaces, \
390and sources. Read-only. Exits 4 for an unknown zone."
391 .into(),
392 privileged: false,
393 output_kind: "FirewallZone".into(),
394 inputs: vec![input("zone", true)],
395 flags: vec!["--host".into(), "--json".into()],
396 examples: vec!["fez firewall show public --json".into()],
397 },
398 Descriptor {
399 id: "firewall.services".into(),
400 summary: "List the firewall service catalog".into(),
401 long: "List the service names firewalld knows about (the valid arguments \
402to add-service). Read-only."
403 .into(),
404 privileged: false,
405 output_kind: "FirewallServiceCatalog".into(),
406 inputs: vec![],
407 flags: vec!["--host".into(), "--json".into()],
408 examples: vec!["fez firewall services --json".into()],
409 },
410 Descriptor {
411 id: "firewall.add-service".into(),
412 summary: "Add a service to a zone".into(),
413 long: "Add a service to a zone at runtime only. Use --zone to target a zone \
414(the default zone otherwise) and --timeout to auto-revert after N seconds. The change \
415is NOT permanent until `fez firewall confirm`. Privileged. An unknown service is \
416rejected by firewalld (exit 7). Protected ops elsewhere need --force."
417 .into(),
418 privileged: true,
419 output_kind: "FirewallChange".into(),
420 inputs: vec![input("service", true)],
421 flags: vec![
422 "--host".into(),
423 "--json".into(),
424 "--zone".into(),
425 "--timeout".into(),
426 "--force".into(),
427 ],
428 examples: vec![
429 "fez firewall add-service http --json".into(),
430 "fez firewall add-service http --zone public --timeout 60".into(),
431 ],
432 },
433 Descriptor {
434 id: "firewall.remove-service".into(),
435 summary: "Remove a service from a zone".into(),
436 long: "Remove a service from a zone at runtime only. Removing the ssh \
437service (which carries the active session) is refused unless --force is supplied \
438(exit 8). NOT permanent until `fez firewall confirm`. Privileged."
439 .into(),
440 privileged: true,
441 output_kind: "FirewallChange".into(),
442 inputs: vec![input("service", true)],
443 flags: vec![
444 "--host".into(),
445 "--json".into(),
446 "--zone".into(),
447 "--force".into(),
448 ],
449 examples: vec!["fez firewall remove-service http --json".into()],
450 },
451 Descriptor {
452 id: "firewall.add-port".into(),
453 summary: "Add a port to a zone".into(),
454 long: "Add a port (port/proto, e.g. 8080/tcp) to a zone at runtime only. \
455Use --zone and --timeout. NOT permanent until `fez firewall confirm`. Privileged. \
456Protected ops elsewhere need --force."
457 .into(),
458 privileged: true,
459 output_kind: "FirewallChange".into(),
460 inputs: vec![input("port", true)],
461 flags: vec![
462 "--host".into(),
463 "--json".into(),
464 "--zone".into(),
465 "--timeout".into(),
466 "--force".into(),
467 ],
468 examples: vec!["fez firewall add-port 8080/tcp --json".into()],
469 },
470 Descriptor {
471 id: "firewall.remove-port".into(),
472 summary: "Remove a port from a zone".into(),
473 long: "Remove a port (port/proto) from a zone at runtime only. Removing the \
474port that carries the active SSH session is refused unless --force is supplied \
475(exit 8). NOT permanent until `fez firewall confirm`. Privileged."
476 .into(),
477 privileged: true,
478 output_kind: "FirewallChange".into(),
479 inputs: vec![input("port", true)],
480 flags: vec![
481 "--host".into(),
482 "--json".into(),
483 "--zone".into(),
484 "--force".into(),
485 ],
486 examples: vec!["fez firewall remove-port 8080/tcp --json".into()],
487 },
488 Descriptor {
489 id: "firewall.set-default-zone".into(),
490 summary: "Set the default zone".into(),
491 long: "Set the default firewall zone. Every default-zone change is gated \
492and refused unless --force is supplied (exit 8), because a different default can \
493sever a connection that relied on the old zone. Runtime only until confirm. Privileged."
494 .into(),
495 privileged: true,
496 output_kind: "FirewallChange".into(),
497 inputs: vec![input("zone", true)],
498 flags: vec!["--host".into(), "--json".into(), "--force".into()],
499 examples: vec!["fez firewall set-default-zone internal --force --json".into()],
500 },
501 Descriptor {
502 id: "firewall.reload".into(),
503 summary: "Reload permanent config into runtime".into(),
504 long: "Reload the permanent config into runtime, discarding any uncommitted \
505runtime changes. With uncommitted drift present the reload is refused unless --force \
506is supplied (exit 8), since it would lose that work. With no drift it runs freely. \
507Privileged."
508 .into(),
509 privileged: true,
510 output_kind: "FirewallChange".into(),
511 inputs: vec![],
512 flags: vec!["--host".into(), "--json".into(), "--force".into()],
513 examples: vec!["fez firewall reload --json".into()],
514 },
515 Descriptor {
516 id: "firewall.confirm".into(),
517 summary: "Persist runtime config to permanent".into(),
518 long: "Commit the current runtime firewall config to permanent \
519(runtimeToPermanent). This is the only persistence path; mutations are runtime-only \
520until confirmed. Privileged. --force is not required for confirm itself."
521 .into(),
522 privileged: true,
523 output_kind: "FirewallConfirm".into(),
524 inputs: vec![],
525 flags: vec!["--host".into(), "--json".into(), "--force".into()],
526 examples: vec!["fez firewall confirm --json".into()],
527 },
528 Descriptor {
529 id: "firewall.panic".into(),
530 summary: "Toggle panic mode".into(),
531 long: "Toggle panic mode. `panic on` drops ALL traffic and is refused unless \
532--force is supplied (exit 8); `panic off` re-enables traffic. Runtime only. Privileged."
533 .into(),
534 privileged: true,
535 output_kind: "FirewallChange".into(),
536 inputs: vec![input("state", true)],
537 flags: vec!["--host".into(), "--json".into(), "--force".into()],
538 examples: vec![
539 "fez firewall panic off --json".into(),
540 "fez firewall panic on --force".into(),
541 ],
542 },
543 Descriptor {
544 id: "firewall.masquerade".into(),
545 summary: "Enable or disable masquerade (SNAT) for a zone".into(),
546 long: "Enable or disable masquerade (source NAT for forwarded traffic) on a \
547zone. Use --zone to target a zone (the default zone otherwise) and --timeout to \
548auto-revert after N seconds (ignored for `off`). Runtime only; NOT permanent until \
549`fez firewall confirm`. Enabling is unguarded; disabling is refused unless --force is \
550supplied (exit 8), because dropping SNAT can sever a gateway's forwarded clients. \
551Privileged."
552 .into(),
553 privileged: true,
554 output_kind: "FirewallChange".into(),
555 inputs: vec![input("state", true)],
556 flags: vec![
557 "--host".into(),
558 "--json".into(),
559 "--zone".into(),
560 "--timeout".into(),
561 "--force".into(),
562 ],
563 examples: vec![
564 "fez firewall masquerade on --json".into(),
565 "fez firewall masquerade off --zone public --force".into(),
566 ],
567 },
568 ]
569}
570
571pub fn find(id: &str) -> Option<Descriptor> {
573 registry().into_iter().find(|d| d.id == id)
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn every_descriptor_has_long_and_examples() {
582 for d in registry() {
583 assert!(!d.long.trim().is_empty(), "{} missing long", d.id);
584 assert!(!d.examples.is_empty(), "{} has no examples", d.id);
585 for ex in &d.examples {
586 assert!(ex.starts_with("fez "), "{}: bad example {:?}", d.id, ex);
587 }
588 }
589 }
590
591 #[test]
592 fn protected_capabilities_document_force() {
593 for d in registry() {
594 if d.privileged {
595 assert!(
596 d.long.contains("--force") || d.examples.iter().any(|e| e.contains("--force")),
597 "{}: privileged capability should mention --force",
598 d.id
599 );
600 }
601 }
602 }
603
604 #[test]
605 fn enable_disable_have_now_example() {
606 for id in ["services.enable", "services.disable"] {
607 let d = find(id).unwrap();
608 assert!(
609 d.examples.iter().any(|e| e.contains("--now")),
610 "{id}: needs --now example"
611 );
612 }
613 }
614}