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:
17
crates/notnet/Cargo.toml
Normal file
17
crates/notnet/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "notnet"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
description = "Tailscale network presence management for notstrap"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rpassword = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
yubikey = { version = "0.8", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
yubikey = ["dep:yubikey"]
|
||||
49
crates/notnet/src/auth.rs
Normal file
49
crates/notnet/src/auth.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
24
crates/notnet/src/error.rs
Normal file
24
crates/notnet/src/error.rs
Normal 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),
|
||||
}
|
||||
92
crates/notnet/src/installer.rs
Normal file
92
crates/notnet/src/installer.rs
Normal 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
80
crates/notnet/src/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
13
crates/notnet/src/ports.rs
Normal file
13
crates/notnet/src/ports.rs
Normal 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,
|
||||
}
|
||||
@@ -23,6 +23,11 @@ hmac = "0.12"
|
||||
rand = "0.8"
|
||||
zeroize = "1"
|
||||
curve25519-dalek = "4"
|
||||
yubikey = { version = "0.8", optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
yubikey = ["dep:yubikey"]
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -18,7 +18,7 @@ pub use ports::IdentitySource;
|
||||
pub use recipients::{
|
||||
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
||||
};
|
||||
pub use sources::{BitwardenSource, FileSource, PromptSource};
|
||||
pub use sources::{BitwardenSource, FileSource, PromptSource, YubikeySource};
|
||||
|
||||
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
||||
///
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub mod bitwarden;
|
||||
pub mod file;
|
||||
pub mod prompt;
|
||||
pub mod yubikey;
|
||||
|
||||
pub use crate::ports::IdentitySource;
|
||||
pub use bitwarden::BitwardenSource;
|
||||
pub use file::FileSource;
|
||||
pub use prompt::PromptSource;
|
||||
pub use yubikey::YubikeySource;
|
||||
|
||||
72
crates/notsecrets/src/sources/yubikey.rs
Normal file
72
crates/notsecrets/src/sources/yubikey.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::Identity;
|
||||
use crate::ports::IdentitySource;
|
||||
|
||||
/// Reads an age private key from YubiKey PIV slot 9c (Digital Signature slot).
|
||||
///
|
||||
/// Requires the `yubikey` feature to be enabled. When the feature is absent this
|
||||
/// source is compiled out entirely — the struct still exists for type-level use but
|
||||
/// `load()` always returns an error.
|
||||
pub struct YubikeySource;
|
||||
|
||||
impl IdentitySource for YubikeySource {
|
||||
fn name(&self) -> &str {
|
||||
"yubikey-piv-9c"
|
||||
}
|
||||
|
||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||
load_impl()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "yubikey")]
|
||||
fn load_impl() -> Result<Box<dyn Identity>, AgeError> {
|
||||
use crate::identities::x25519::X25519Identity;
|
||||
use yubikey::{YubiKey, piv};
|
||||
|
||||
let mut yk = YubiKey::open().map_err(|e| AgeError::SourceError {
|
||||
name: "yubikey-piv-9c".to_string(),
|
||||
source: anyhow::anyhow!("cannot open YubiKey: {e}"),
|
||||
})?;
|
||||
|
||||
// Slot 9c — Digital Signature slot, used here to store the age private key.
|
||||
let data =
|
||||
piv::read_object(&mut yk, piv::ObjectId::from(piv::SlotId::Signature)).map_err(|e| {
|
||||
AgeError::SourceError {
|
||||
name: "yubikey-piv-9c".to_string(),
|
||||
source: anyhow::anyhow!("cannot read PIV slot 9c: {e}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
let key = std::str::from_utf8(&data)
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: "yubikey-piv-9c".to_string(),
|
||||
source: anyhow::anyhow!("PIV slot 9c data is not valid UTF-8: {e}"),
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if key.is_empty() {
|
||||
return Err(AgeError::SourceError {
|
||||
name: "yubikey-piv-9c".to_string(),
|
||||
source: anyhow::anyhow!("PIV slot 9c is empty"),
|
||||
});
|
||||
}
|
||||
|
||||
let identity = X25519Identity::from_bech32(&key).map_err(|e| AgeError::SourceError {
|
||||
name: "yubikey-piv-9c".to_string(),
|
||||
source: anyhow::anyhow!("slot 9c content is not a valid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(identity))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "yubikey"))]
|
||||
fn load_impl() -> Result<Box<dyn Identity>, AgeError> {
|
||||
Err(AgeError::SourceError {
|
||||
name: "yubikey-piv-9c".to_string(),
|
||||
source: anyhow::anyhow!(
|
||||
"notsecrets was compiled without the `yubikey` feature; \
|
||||
YubikeySource is unavailable"
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ notcore = { workspace = true }
|
||||
notfiles = { workspace = true }
|
||||
notsecrets = { workspace = true }
|
||||
nothooks = { workspace = true }
|
||||
notnet = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
which = { workspace = true }
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -44,6 +44,7 @@ fn main() -> Result<()> {
|
||||
force,
|
||||
key_file,
|
||||
dotfiles,
|
||||
tailscale: None, // defer to [tailscale] section in config (if present)
|
||||
check_prereqs: Some(Box::new(prereqs::check_prerequisites)),
|
||||
env_injector: None,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user