feat: add notnet crate and YubikeySource, thread Tailscale into notstrap

- New crates/notnet: TailscaleOptions, ensure_connected(), auth key chain
  (TS_AUTHKEY env → YubiKey PIV 9d → interactive prompt), install via
  apt/brew/pacman or official install.sh, peer reachability verification
- notsecrets: YubikeySource implementing IdentitySource, reads age key from
  PIV slot 9c; gated behind optional `yubikey` feature
- notstrap: Tailscale step inserted between prerequisites and clone;
  BootstrapOptions.tailscale controls override/skip; NotstrapConfig gains
  optional [tailscale] section; dotfiles_repo is now Option<String> (Tailscale
  gitea_url takes precedence); identity chain adds YubikeySource before
  Bitwarden
- Updated workspace Cargo.toml: notnet added to members and workspace deps
- Integration tests: tailscale: Some(None) to skip Tailscale in all test opts
This commit is contained in:
Joseph O'Brien
2026-04-17 00:01:50 -04:00
parent dfdd9ca6d3
commit c7c074d48b
20 changed files with 797 additions and 6 deletions

View File

@@ -4,25 +4,31 @@ use notfiles::{LinkOptions, link};
use nothooks::{HookRunner, run_phase};
use notsecrets::identities::Identity;
use notsecrets::sources::IdentitySource;
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
use notsecrets::{BitwardenSource, FileSource, PromptSource, YubikeySource, resolve_identities};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub mod prereqs;
pub mod repo;
pub use notnet::TailscaleOptions;
type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>;
#[derive(Deserialize)]
pub struct NotstrapConfig {
pub bootstrap: BootstrapSection,
/// When present, notstrap joins the tailnet before cloning dotfiles.
pub tailscale: Option<TailscaleOptions>,
#[serde(default)]
pub hooks: Vec<notcore::HookSpec>,
}
#[derive(Deserialize)]
pub struct BootstrapSection {
pub dotfiles_repo: String,
/// Required when `[tailscale]` is absent. When `[tailscale]` is present,
/// `gitea_url` from that section is used as the clone URL instead.
pub dotfiles_repo: Option<String>,
pub dotfiles_dir: String,
#[serde(default = "default_bw_item")]
pub bw_age_item: String,
@@ -42,6 +48,12 @@ pub struct BootstrapOptions {
pub force: bool,
pub key_file: Option<PathBuf>,
pub dotfiles: Option<PathBuf>,
/// Override the Tailscale options from the config file. `None` here defers to the
/// config; use `Some(None)` to explicitly skip Tailscale even when the config has
/// a `[tailscale]` section (useful in tests and CI).
///
/// Encoding: `None` = use config, `Some(None)` = skip, `Some(Some(opts))` = override.
pub tailscale: Option<Option<TailscaleOptions>>,
/// None = skip prereq check (tests). Some(f) = run f().
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
/// None = skip env injection (tests). Some(f) = decrypt sops at path and inject.
@@ -76,8 +88,37 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
})?,
};
// 3. Clone dotfiles if missing
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) {
// Resolve which Tailscale options to use: CLI override > config > None.
let ts_opts: Option<TailscaleOptions> = match opts.tailscale {
Some(override_val) => override_val, // Some(None) = skip, Some(Some(o)) = use override
None => cfg.tailscale.clone(), // defer to config
};
// Resolve dotfiles clone URL: Tailscale gitea_url takes precedence over dotfiles_repo.
let clone_url: Option<String> = ts_opts
.as_ref()
.map(|ts| ts.gitea_url.clone())
.or_else(|| cfg.bootstrap.dotfiles_repo.clone());
// 3. Tailscale (inserted before clone, skipped when ts_opts is None)
if let Some(ref ts) = ts_opts {
match notnet::ensure_connected(ts) {
Ok(true) => report.add("tailscale", StepStatus::Ok),
Ok(false) => report.add("tailscale", StepStatus::Skipped),
Err(e) => {
report.add("tailscale", StepStatus::Failed(e.to_string()));
return Ok(report);
}
}
}
// 4. Clone dotfiles if missing
let clone_url = clone_url.ok_or_else(|| {
anyhow::anyhow!(
"no dotfiles clone URL: set [bootstrap].dotfiles_repo or add a [tailscale] section"
)
})?;
match repo::clone_if_missing(&clone_url, &dotfiles_dir) {
Ok(true) => {
report.add("clone dotfiles", StepStatus::Ok);
}
@@ -90,11 +131,12 @@ pub fn run(opts: BootstrapOptions) -> Result<Report> {
}
}
// 4. Resolve age identities
// 5. Resolve age identities
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
vec![Box::new(FileSource::new(kf))]
} else {
vec![
Box::new(YubikeySource),
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
Box::new(PromptSource),
]