ryra 0.9.0

A tool to test and deploy self-hosted services on a Linux server using rootless Podman and systemd. Built-in VM testing gives AI agents fast feedback loops for building infrastructure and deploying apps.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
pub mod add;
pub mod apply;
pub mod backup;
pub mod configure;
pub mod configure_global;
pub mod diff;
pub mod doctor;
pub mod init;
pub mod lifecycle;
pub mod linger;
pub mod list;
pub mod prompts;
pub mod registry_cmd;
pub mod remove;
pub mod reset;
pub mod revert;
pub mod search;
pub mod status;
pub mod style;
pub mod sysctl_low_ports;
pub mod tailscale_sudoers;
pub mod test;
pub mod upgrade;

use std::io::IsTerminal;
use std::net::TcpListener;

use ryra_core::Step;

/// Whether we can safely run interactive dialoguer prompts.
///
/// Both stdin AND stdout must be TTYs: stdin because we need to read the
/// user's response, stdout because dialoguer writes the prompt there and
/// errors with "not a terminal" if it isn't one. Checking only stdin
/// misses the `ryra add | tee` / test-runner case where stdout is
/// captured but stdin happens to be inherited from the parent shell.
pub fn is_interactive() -> bool {
    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
}

/// Check if a port is already bound on the host.
///
/// A port is considered in use if binding IPv4 fails. IPv6 is only checked
/// when the system has a working IPv6 loopback — otherwise `bind(::1)` can
/// fail even on a free port, making every port look occupied.
///
/// Lives in the CLI (not core) so that `ryra-core` planning stays free of
/// real system-state probes: callers pass this as `&dyn Fn(u16) -> bool`.
pub fn is_port_in_use(port: u16) -> bool {
    if TcpListener::bind(("127.0.0.1", port)).is_err() {
        return true;
    }
    if TcpListener::bind(("::1", 0u16)).is_ok() {
        return TcpListener::bind(("::1", port)).is_err();
    }
    false
}

/// A system CA certificate to install or remove, with the distro-specific
/// trust store path and update command.
struct CaCertTarget {
    cert_path: &'static str,
    update_cmd: &'static str,
}

const CA_TARGETS: &[CaCertTarget] = &[
    // Fedora / RHEL
    CaCertTarget {
        cert_path: "/etc/pki/ca-trust/source/anchors/services-caddy-ca.crt",
        update_cmd: "update-ca-trust",
    },
    // Arch Linux
    CaCertTarget {
        cert_path: "/etc/ca-certificates/trust-source/anchors/services-caddy-ca.crt",
        update_cmd: "update-ca-trust",
    },
    // Debian / Ubuntu
    CaCertTarget {
        cert_path: "/usr/local/share/ca-certificates/services-caddy-ca.crt",
        update_cmd: "update-ca-certificates",
    },
];

/// The nickname used for ryra's Caddy CA in every NSS database (user NSS for
/// Chromium and every Firefox profile). Keeping it symmetric on install and
/// uninstall means `remove` / `reset` can always locate and drop the cert.
pub const CADDY_CA_NICKNAME: &str = "services-caddy-ca";

/// `~/.pki/nssdb` — the Chromium-family (Chrome, Edge, Brave, Opera, Vivaldi)
/// per-user cert store. Returns `None` if `$HOME` is unset.
pub fn nssdb_dir() -> Option<std::path::PathBuf> {
    std::env::var("HOME")
        .ok()
        .map(|h| std::path::PathBuf::from(h).join(".pki/nssdb"))
}

/// Every Firefox profile directory on the host that has a `cert9.db`. Covers
/// the native install, Flatpak, and Snap. Older `cert8.db`-only profiles
/// (Firefox <58) are skipped.
pub fn firefox_profile_dirs() -> Vec<std::path::PathBuf> {
    let Ok(home) = std::env::var("HOME") else {
        return Vec::new();
    };
    let home = std::path::PathBuf::from(home);
    let bases = [
        home.join(".mozilla/firefox"),
        home.join(".var/app/org.mozilla.firefox/.mozilla/firefox"),
        home.join("snap/firefox/common/.mozilla/firefox"),
    ];
    let mut profiles = Vec::new();
    for base in bases {
        let Ok(entries) = std::fs::read_dir(&base) else {
            continue;
        };
        for entry in entries.flatten() {
            let dir = entry.path();
            if dir.join("cert9.db").is_file() {
                profiles.push(dir);
            }
        }
    }
    profiles
}

/// Remove `/etc/hosts` entries this service added on install. Only lines
/// whose trailing comment matches `# Service-Source: registry/<service>`
/// are removed — handwritten entries (no marker) are left alone, so a
/// pre-existing `127.0.0.1 seafile` from before ryra never got clobbered.
///
/// Best-effort with sudo: tries `sudo -n` first (passwordless / cached),
/// escalates to interactive prompt if stderr is a TTY, prints a warning
/// otherwise. Failure to remove is non-fatal — the entry is harmless;
/// we just can't clean it up automatically.
pub fn remove_hosts_entries(service: &str) {
    use std::io::Write;
    let marker = format!("# Service-Source: registry/{service}");
    let Ok(content) = std::fs::read_to_string("/etc/hosts") else {
        return;
    };
    let lines: Vec<&str> = content.lines().collect();
    let kept: Vec<&str> = lines
        .iter()
        .filter(|l| !l.contains(&marker))
        .copied()
        .collect();
    if kept.len() == lines.len() {
        return;
    }
    let removed_count = lines.len() - kept.len();
    let mut new_content = kept.join("\n");
    if content.ends_with('\n') && !new_content.ends_with('\n') {
        new_content.push('\n');
    }

    let try_write = |interactive: bool| -> bool {
        let mut cmd = std::process::Command::new("sudo");
        if !interactive {
            cmd.arg("-n");
        }
        cmd.args(["sh", "-c", "cat > /etc/hosts"]);
        cmd.stdin(std::process::Stdio::piped());
        let Ok(mut child) = cmd.spawn() else {
            return false;
        };
        if let Some(mut stdin) = child.stdin.take() {
            let _ = stdin.write_all(new_content.as_bytes());
        }
        child.wait().map(|s| s.success()).unwrap_or(false)
    };

    if try_write(false) {
        println!("  Removed {removed_count} stale /etc/hosts entry(ies) (via sudo).");
    } else if std::io::stderr().is_terminal() {
        eprintln!("  Removing {removed_count} stale /etc/hosts entry(ies) (sudo required):");
        if try_write(true) {
            println!("  Removed.");
        } else {
            eprintln!(
                "  WARN: failed to remove /etc/hosts entries for {service}. They are harmless but persist."
            );
        }
    } else {
        eprintln!(
            "  WARN: {removed_count} stale /etc/hosts entry(ies) for {service} not removed (sudo required)."
        );
    }
}

/// Remove Caddy's CA certificate from every rootless trust store (user NSS
/// DB, Firefox profiles). For the system trust store — which ryra never
/// installed itself, only ever printed a hint for — we print the matching
/// removal hint if something is still there.
pub fn remove_caddy_ca() {
    // No certutil means the matching install path never ran either — skip
    // silently so reset/remove don't spew warnings on hosts without nss-tools.
    let have_certutil = std::process::Command::new("certutil")
        .arg("-V")
        .output()
        .is_ok();

    if have_certutil {
        // Rootless: user NSS DB (Chromium family)
        if let Some(nssdb_path) = nssdb_dir().filter(|p| p.exists()) {
            let nss_arg = format!("sql:{}", nssdb_path.display());
            delete_ca_from_nssdb(&nss_arg);
        }

        // Rootless: every Firefox profile we can find
        for profile in firefox_profile_dirs() {
            let nss_arg = format!("sql:{}", profile.display());
            delete_ca_from_nssdb(&nss_arg);
        }
    }

    // System trust (anything at /etc/pki/...) — ryra didn't install it, so
    // ryra doesn't remove it. Point the user at the one-liner instead.
    let installed: Vec<&CaCertTarget> = CA_TARGETS
        .iter()
        .filter(|t| std::path::Path::new(t.cert_path).exists())
        .collect();
    if !installed.is_empty() {
        println!();
        println!("  Caddy CA is still in the system trust store. To remove it:");
        for target in &installed {
            println!(
                "    sudo rm -f {} && sudo {}",
                target.cert_path, target.update_cmd
            );
        }
    }
}

/// Best-effort `certutil -D` against a NSS DB directory. Silent when the
/// cert isn't in the DB (common case after manual cleanup); warns on real
/// failures so a busted `certutil` doesn't swallow state we wanted gone.
fn delete_ca_from_nssdb(nss_arg: &str) {
    let present = std::process::Command::new("certutil")
        .args(["-d", nss_arg, "-L", "-n", CADDY_CA_NICKNAME])
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !present {
        return;
    }
    match std::process::Command::new("certutil")
        .args(["-d", nss_arg, "-D", "-n", CADDY_CA_NICKNAME])
        .status()
    {
        Ok(s) if s.success() => {}
        Ok(s) => eprintln!("  Warning: certutil -D exited with {s} for {nss_arg}"),
        Err(e) => eprintln!("  Warning: could not run certutil for {nss_arg}: {e}"),
    }
}

/// Collapse a `$HOME` prefix to `~/...` for friendlier paths.
fn tildify(path: &std::path::Path) -> String {
    if let Ok(home) = std::env::var("HOME")
        && let Ok(stripped) = path.strip_prefix(&home)
    {
        return format!("~/{}", stripped.display());
    }
    path.display().to_string()
}

/// Compact preamble for `ryra add`: three arrows — pulls, writes, starts.
/// Executed steps follow beneath; verbose adds detail to the same flow.
pub fn print_plan_header(steps: &[Step], service: &str, primary_url: Option<&str>) {
    use std::collections::BTreeSet;

    // Deduplicated image pulls — multi-container services need every image
    // listed so the user knows what's coming down the wire.
    let images: BTreeSet<&str> = steps
        .iter()
        .filter_map(|s| match s {
            Step::PullImage { image } => Some(image.as_str()),
            _ => None,
        })
        .collect();

    // Primary quadlet: `<service>.container`. Sidecars/env/configs are
    // implied — listing them defeats the point of a compact header.
    let quadlet_name = format!("{service}.container");
    let primary_quadlet = steps.iter().find_map(|s| match s {
        Step::WriteFile(f) => {
            let name = f.path.file_name().and_then(|n| n.to_str())?;
            (name == quadlet_name).then_some(f.path.as_path())
        }
        _ => None,
    });

    let arrow = style::arrow();
    for image in &images {
        println!("{arrow} pulls {image}");
    }
    if let Some(p) = primary_quadlet {
        println!("{arrow} writes {}", tildify(p));
    }

    // Narrate automatic metrics wiring — integrations that happen because
    // a provider is present must be loud, not silent. Scrape targets are
    // `<store>/targets/<svc>.json`; datasources are
    // `<dash>/provisioning-datasources/ryra-<store>.yml`.
    for step in steps {
        let Step::WriteFile(f) = step else { continue };
        let Some(name) = f.path.file_name().and_then(|n| n.to_str()) else {
            continue;
        };
        let dir = f
            .path
            .parent()
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str());
        let owner = f
            .path
            .parent()
            .and_then(|p| p.parent())
            .and_then(|p| p.file_name())
            .and_then(|n| n.to_str());
        match dir {
            Some("targets") if name.ends_with(".json") => {
                let scraped = name.trim_end_matches(".json");
                println!(
                    "{arrow} wires {scraped} into {} (scrape target)",
                    owner.unwrap_or("the metrics store")
                );
            }
            Some("provisioning-datasources") => {
                let store = name.trim_start_matches("ryra-").trim_end_matches(".yml");
                println!(
                    "{arrow} provisions {store} datasource in {}",
                    owner.unwrap_or("the dashboard")
                );
            }
            _ => {}
        }
    }
    // Restarts of units that don't belong to the service being added —
    // retroactive wiring (network joins, datasource reloads) on peers.
    let peer_restarts: BTreeSet<&str> = steps
        .iter()
        .filter_map(|s| match s {
            Step::RestartService { unit }
                if unit != service && !unit.starts_with(&format!("{service}-")) =>
            {
                Some(unit.as_str())
            }
            _ => None,
        })
        .collect();
    if !peer_restarts.is_empty() {
        let list: Vec<&str> = peer_restarts.into_iter().collect();
        println!(
            "{arrow} restarts {} (picks up the new wiring)",
            list.join(", ")
        );
    }

    match primary_url {
        Some(url) => println!("{arrow} starts {service} on {url}"),
        None => println!("{arrow} starts {service}"),
    }
    println!();
}

/// Print a dry-run summary: files to write, then commands to run.
pub fn print_dry_run(steps: &[Step]) {
    enum FileEntry<'a> {
        Write(&'a ryra_core::generate::GeneratedFile),
        Copy {
            src: &'a std::path::Path,
            dst: &'a std::path::Path,
        },
    }

    let file_steps: Vec<FileEntry> = steps
        .iter()
        .filter_map(|s| match s {
            Step::WriteFile(f) => Some(FileEntry::Write(f)),
            Step::CopyFile { src, dst } => Some(FileEntry::Copy { src, dst }),
            _ => None,
        })
        .collect();

    let commands: Vec<_> = steps
        .iter()
        .filter(|s| !matches!(s, Step::WriteFile(_) | Step::CopyFile { .. }))
        .collect();

    if !file_steps.is_empty() {
        println!("Files to write:\n");
        for entry in &file_steps {
            match entry {
                FileEntry::Write(file) => {
                    println!("  {}", file.path.display());
                }
                FileEntry::Copy { src, dst } => {
                    println!("  {} (<- {})", dst.display(), src.display());
                }
            }
        }
        println!();
    }

    if !commands.is_empty() {
        println!("Commands to run:\n");
        for step in &commands {
            println!("  {}", step.to_command());
        }
        println!();
    }

    println!("Dry run — no changes made. Remove --dry-run to apply.\n");
}