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

49
crates/notnet/src/auth.rs Normal file
View File

@@ -0,0 +1,49 @@
/// Auth key resolution chain for Tailscale:
///
/// 1. `TS_AUTHKEY` environment variable
/// 2. YubiKey PIV slot 9d (when compiled with the `yubikey` feature)
/// 3. Interactive prompt
///
/// Returns `Err(NotnetError::NoAuthKey)` if all sources are exhausted without a key.
use crate::error::NotnetError;
pub fn resolve_auth_key() -> Result<String, NotnetError> {
// 1. Environment variable
if let Ok(key) = std::env::var("TS_AUTHKEY")
&& !key.is_empty()
{
return Ok(key);
}
// 2. YubiKey PIV slot 9d
#[cfg(feature = "yubikey")]
if let Some(key) = read_yubikey_slot_9d() {
return Ok(key);
}
// 3. Interactive prompt
prompt_auth_key()
}
#[cfg(feature = "yubikey")]
fn read_yubikey_slot_9d() -> Option<String> {
use yubikey::{YubiKey, piv};
let mut yk = YubiKey::open().ok()?;
// Slot 9d — key management slot, repurposed here for the Tailscale auth key.
let slot = piv::SlotId::KeyManagement; // 0x9d
let data = piv::read_object(&mut yk, piv::ObjectId::from(slot)).ok()?;
let key = String::from_utf8(data.to_vec()).ok()?;
let key = key.trim().to_string();
if key.is_empty() { None } else { Some(key) }
}
fn prompt_auth_key() -> Result<String, NotnetError> {
let key = rpassword::prompt_password("Tailscale auth key: ").map_err(NotnetError::Io)?;
let key = key.trim().to_string();
if key.is_empty() {
Err(NotnetError::NoAuthKey)
} else {
Ok(key)
}
}

View File

@@ -0,0 +1,24 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum NotnetError {
#[error("Tailscale is not installed; set install = true in [tailscale] to auto-install")]
NotInstalled,
#[error("Tailscale install failed: {0}")]
InstallFailed(String),
#[error("command `{cmd}` failed: {detail}")]
CommandFailed { cmd: String, detail: String },
#[error("peer `{0}` is unreachable over Tailscale")]
PeerUnreachable(String),
#[error(
"auth key resolution failed: no TS_AUTHKEY env var, no YubiKey on slot 9d, and user declined prompt"
)]
NoAuthKey,
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}

View File

@@ -0,0 +1,92 @@
use crate::error::NotnetError;
/// Returns true if the `tailscale` binary is on PATH.
pub fn is_installed() -> bool {
std::process::Command::new("tailscale")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Install Tailscale using the platform package manager, falling back to the official
/// install script.
///
/// Supported package managers (tried in order): `apt-get`, `brew`, `pacman`.
/// Fallback: `curl -fsSL https://tailscale.com/install.sh | sh`.
pub fn install() -> Result<(), NotnetError> {
if try_apt().is_ok() {
return Ok(());
}
if try_brew().is_ok() {
return Ok(());
}
if try_pacman().is_ok() {
return Ok(());
}
install_via_script()
}
fn try_apt() -> Result<(), NotnetError> {
// apt-get install tailscale requires the tailscale repo to be configured already.
// We only attempt this if apt-get is present — the user is expected to have added
// the Tailscale apt repo as part of their base image or preseed.
if !cmd_exists("apt-get") {
return Err(not_available("apt-get"));
}
run_cmd("apt-get", &["install", "-y", "tailscale"])
}
fn try_brew() -> Result<(), NotnetError> {
if !cmd_exists("brew") {
return Err(not_available("brew"));
}
run_cmd("brew", &["install", "tailscale"])
}
fn try_pacman() -> Result<(), NotnetError> {
if !cmd_exists("pacman") {
return Err(not_available("pacman"));
}
run_cmd("pacman", &["-S", "--noconfirm", "tailscale"])
}
fn install_via_script() -> Result<(), NotnetError> {
// Pipe the official install script through sh.
// `curl | sh` is the documented method on tailscale.com/download.
let output = std::process::Command::new("sh")
.args(["-c", "curl -fsSL https://tailscale.com/install.sh | sh"])
.output()
.map_err(|e| NotnetError::InstallFailed(e.to_string()))?;
if !output.status.success() {
return Err(NotnetError::InstallFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(())
}
fn cmd_exists(cmd: &str) -> bool {
std::process::Command::new("sh")
.args(["-c", &format!("command -v {cmd}")])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn run_cmd(program: &str, args: &[&str]) -> Result<(), NotnetError> {
let output = std::process::Command::new(program)
.args(args)
.output()
.map_err(|e| NotnetError::InstallFailed(e.to_string()))?;
if !output.status.success() {
return Err(NotnetError::InstallFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
Ok(())
}
fn not_available(name: &str) -> NotnetError {
NotnetError::InstallFailed(format!("{name} not found"))
}

80
crates/notnet/src/lib.rs Normal file
View File

@@ -0,0 +1,80 @@
pub mod auth;
pub mod error;
pub mod installer;
pub mod ports;
pub use error::NotnetError;
pub use ports::TailscaleOptions;
use anyhow::Result;
/// Ensure this machine is connected to the tailnet and the target peer is reachable.
///
/// Returns immediately (skipped) if `tailscale status` reports an active connection.
/// Installs Tailscale if `opts.install` is true and the binary is missing.
pub fn ensure_connected(opts: &TailscaleOptions) -> Result<bool, NotnetError> {
// Already connected — fast path.
if status::is_connected() {
return Ok(false); // false = skipped (already on tailnet)
}
// Install if missing and opts.install allows it.
if !installer::is_installed() {
if !opts.install {
return Err(NotnetError::NotInstalled);
}
installer::install()?;
}
// Resolve auth key.
let auth_key = auth::resolve_auth_key()?;
// Join the tailnet.
join(&auth_key)?;
// Verify target peer is reachable.
verify_peer(&opts.peer_hostname)?;
Ok(true) // true = connected now
}
fn join(auth_key: &str) -> Result<(), NotnetError> {
let output = std::process::Command::new("tailscale")
.args(["up", &format!("--authkey={auth_key}")])
.output()
.map_err(|e| NotnetError::CommandFailed {
cmd: "tailscale up".to_string(),
detail: e.to_string(),
})?;
if !output.status.success() {
return Err(NotnetError::CommandFailed {
cmd: "tailscale up".to_string(),
detail: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(())
}
fn verify_peer(hostname: &str) -> Result<(), NotnetError> {
let output = std::process::Command::new("tailscale")
.args(["ping", "--c", "1", hostname])
.output()
.map_err(|e| NotnetError::CommandFailed {
cmd: format!("tailscale ping {hostname}"),
detail: e.to_string(),
})?;
if !output.status.success() {
return Err(NotnetError::PeerUnreachable(hostname.to_string()));
}
Ok(())
}
mod status {
pub fn is_connected() -> bool {
std::process::Command::new("tailscale")
.args(["status", "--peers=false"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}

View File

@@ -0,0 +1,13 @@
use serde::Deserialize;
/// Configuration for Tailscale network setup.
#[derive(Debug, Clone, Deserialize)]
pub struct TailscaleOptions {
/// Tailscale hostname of the peer to verify after joining (e.g. "minibox").
pub peer_hostname: String,
/// Gitea clone URL reachable over the tailnet (e.g. "http://minibox:3000/joe/dotfiles.git").
pub gitea_url: String,
/// If true, install Tailscale automatically when the binary is missing.
#[serde(default)]
pub install: bool,
}