From c7c074d48bf33a775217776a2ebdf34bc92474a4 Mon Sep 17 00:00:00 2001 From: Joseph O'Brien <98370624+89jobrien@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:01:50 -0400 Subject: [PATCH] feat: add notnet crate and YubikeySource, thread Tailscale into notstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (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 --- .../content/architecture.html | 71 ++++ .../72758-1776397235/state/server-info | 1 + .../72758-1776397235/state/server.log | 2 + .../72758-1776397235/state/server.pid | 1 + Cargo.lock | 314 ++++++++++++++++++ Cargo.toml | 2 + crates/notnet/Cargo.toml | 17 + crates/notnet/src/auth.rs | 49 +++ crates/notnet/src/error.rs | 24 ++ crates/notnet/src/installer.rs | 92 +++++ crates/notnet/src/lib.rs | 80 +++++ crates/notnet/src/ports.rs | 13 + crates/notsecrets/Cargo.toml | 5 + crates/notsecrets/src/lib.rs | 2 +- crates/notsecrets/src/sources/mod.rs | 2 + crates/notsecrets/src/sources/yubikey.rs | 72 ++++ crates/notstrap/Cargo.toml | 1 + crates/notstrap/src/lib.rs | 52 ++- crates/notstrap/src/main.rs | 1 + tests/integration/tests/bootstrap.rs | 2 + 20 files changed, 797 insertions(+), 6 deletions(-) create mode 100644 .superpowers/brainstorm/72758-1776397235/content/architecture.html create mode 100644 .superpowers/brainstorm/72758-1776397235/state/server-info create mode 100644 .superpowers/brainstorm/72758-1776397235/state/server.log create mode 100644 .superpowers/brainstorm/72758-1776397235/state/server.pid create mode 100644 crates/notnet/Cargo.toml create mode 100644 crates/notnet/src/auth.rs create mode 100644 crates/notnet/src/error.rs create mode 100644 crates/notnet/src/installer.rs create mode 100644 crates/notnet/src/lib.rs create mode 100644 crates/notnet/src/ports.rs create mode 100644 crates/notsecrets/src/sources/yubikey.rs diff --git a/.superpowers/brainstorm/72758-1776397235/content/architecture.html b/.superpowers/brainstorm/72758-1776397235/content/architecture.html new file mode 100644 index 0000000..097794b --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/content/architecture.html @@ -0,0 +1,71 @@ +

notfiles + Tailscale — Bootstrap Architecture

+

How the crates fit together for ephemeral machine setup

+ +
+
+ +
+ + +
+
New Machine
+
TS_AUTHKEY env var
+
YubiKey (PIV slot 9d)
+
→ interactive prompt
+
+ +
+ + +
+
notstrap
+
1. prereqs check
+
2. → notnet
+
3. clone Gitea
+
4. → notsecrets
+
5. link dotfiles
+
6. run hooks
+
+ +
+ +
+
+ +
+
notnet NEW
+
detect existing Tailscale
+
install if missing
+
resolve auth key
+
join tailnet
+
verify peer reachable
+
+
+
+
Tailnet
+
minibox (VPS)
+
Gitea repo
+
+
+ +
+
+ +
+
notsecrets +YubiKey
+
YubikeySource (PIV 9c)
+
BitwardenSource
+
FileSource
+
PromptSource
+
+
+ +
+ +
+ +
+ Already on Tailnet? notstrap detects active Tailscale → skips notnet entirely +
+
+
diff --git a/.superpowers/brainstorm/72758-1776397235/state/server-info b/.superpowers/brainstorm/72758-1776397235/state/server-info new file mode 100644 index 0000000..613d21c --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/state/server-info @@ -0,0 +1 @@ +{"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"} diff --git a/.superpowers/brainstorm/72758-1776397235/state/server.log b/.superpowers/brainstorm/72758-1776397235/state/server.log new file mode 100644 index 0000000..31e0e26 --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/state/server.log @@ -0,0 +1,2 @@ +{"type":"server-started","port":63245,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63245","screen_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content","state_dir":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/state"} +{"type":"screen-added","file":"/Users/joe/dev/notfiles/.superpowers/brainstorm/72758-1776397235/content/architecture.html"} diff --git a/.superpowers/brainstorm/72758-1776397235/state/server.pid b/.superpowers/brainstorm/72758-1776397235/state/server.pid new file mode 100644 index 0000000..436a082 --- /dev/null +++ b/.superpowers/brainstorm/72758-1776397235/state/server.pid @@ -0,0 +1 @@ +72766 diff --git a/Cargo.lock b/Cargo.lock index 1899373..a14ba29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,6 +107,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -342,6 +348,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -387,10 +405,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher", +] + [[package]] name = "difflib" version = "0.4.0" @@ -436,6 +476,20 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -466,6 +520,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "env_home" version = "0.1.0" @@ -494,6 +569,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -506,6 +591,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "foldhash" version = "0.1.5" @@ -520,6 +611,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -570,6 +662,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -766,6 +869,22 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notcore" version = "0.1.0" @@ -819,6 +938,17 @@ dependencies = [ "toml", ] +[[package]] +name = "notnet" +version = "0.1.0" +dependencies = [ + "anyhow", + "rpassword", + "serde", + "thiserror 2.0.18", + "yubikey", +] + [[package]] name = "notsecrets" version = "0.1.0" @@ -841,6 +971,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "x25519-dalek", + "yubikey", "zeroize", ] @@ -853,6 +984,7 @@ dependencies = [ "notcore", "notfiles", "nothooks", + "notnet", "notsecrets", "serde", "toml", @@ -871,6 +1003,7 @@ dependencies = [ "num-iter", "num-traits", "rand", + "serde", "smallvec", "zeroize", ] @@ -929,6 +1062,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -950,6 +1107,25 @@ dependencies = [ "hmac", ] +[[package]] +name = "pcsc" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd833ecf8967e65934c49d3521a175929839bf6d0e497f3bd0d3a2ca08943da" +dependencies = [ + "bitflags", + "pcsc-sys", +] + +[[package]] +name = "pcsc-sys" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ef017e15d2e5592a9e39a346c1dbaea5120bab7ed7106b210ef58ebd97003" +dependencies = [ + "pkg-config", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -980,6 +1156,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "poly1305" version = "0.8.0" @@ -1037,6 +1219,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1119,6 +1310,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rpassword" version = "7.4.0" @@ -1219,6 +1420,29 @@ dependencies = [ "sha2", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.27" @@ -1281,6 +1505,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1412,6 +1647,27 @@ dependencies = [ "syn", ] +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.23" @@ -1487,6 +1743,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1892,6 +2159,53 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "sha1", + "signature", + "spki", + "tls_codec", +] + +[[package]] +name = "yubikey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1efb43c1e3edd4cf871c8dc500d900abfa083c1f2bab10b781ea8ffcadedcb" +dependencies = [ + "base16ct", + "der", + "des", + "ecdsa", + "elliptic-curve", + "hmac", + "log", + "nom", + "num-bigint-dig", + "num-integer", + "num-traits", + "p256", + "p384", + "pbkdf2", + "pcsc", + "rand_core", + "rsa", + "secrecy", + "sha1", + "sha2", + "signature", + "subtle", + "uuid", + "x509-cert", + "zeroize", +] + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 3b476b9..4afe364 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/notfiles", "crates/notsecrets", "crates/nothooks", + "crates/notnet", "crates/notstrap", "crates/notgraph", "tests/integration", @@ -30,6 +31,7 @@ notcore = { path = "crates/notcore" } notfiles = { path = "crates/notfiles" } notsecrets = { path = "crates/notsecrets" } nothooks = { path = "crates/nothooks" } +notnet = { path = "crates/notnet" } [profile.release] opt-level = 3 diff --git a/crates/notnet/Cargo.toml b/crates/notnet/Cargo.toml new file mode 100644 index 0000000..3c9dc93 --- /dev/null +++ b/crates/notnet/Cargo.toml @@ -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"] diff --git a/crates/notnet/src/auth.rs b/crates/notnet/src/auth.rs new file mode 100644 index 0000000..571fc60 --- /dev/null +++ b/crates/notnet/src/auth.rs @@ -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 { + // 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 { + 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 { + 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) + } +} diff --git a/crates/notnet/src/error.rs b/crates/notnet/src/error.rs new file mode 100644 index 0000000..d37a7c2 --- /dev/null +++ b/crates/notnet/src/error.rs @@ -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), +} diff --git a/crates/notnet/src/installer.rs b/crates/notnet/src/installer.rs new file mode 100644 index 0000000..b80c97d --- /dev/null +++ b/crates/notnet/src/installer.rs @@ -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")) +} diff --git a/crates/notnet/src/lib.rs b/crates/notnet/src/lib.rs new file mode 100644 index 0000000..875ab8f --- /dev/null +++ b/crates/notnet/src/lib.rs @@ -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 { + // 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) + } +} diff --git a/crates/notnet/src/ports.rs b/crates/notnet/src/ports.rs new file mode 100644 index 0000000..35213c1 --- /dev/null +++ b/crates/notnet/src/ports.rs @@ -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, +} diff --git a/crates/notsecrets/Cargo.toml b/crates/notsecrets/Cargo.toml index a9a1adc..32befa6 100644 --- a/crates/notsecrets/Cargo.toml +++ b/crates/notsecrets/Cargo.toml @@ -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 } diff --git a/crates/notsecrets/src/lib.rs b/crates/notsecrets/src/lib.rs index 46f6a24..c31630c 100644 --- a/crates/notsecrets/src/lib.rs +++ b/crates/notsecrets/src/lib.rs @@ -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. /// diff --git a/crates/notsecrets/src/sources/mod.rs b/crates/notsecrets/src/sources/mod.rs index 3353ac6..721a4f9 100644 --- a/crates/notsecrets/src/sources/mod.rs +++ b/crates/notsecrets/src/sources/mod.rs @@ -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; diff --git a/crates/notsecrets/src/sources/yubikey.rs b/crates/notsecrets/src/sources/yubikey.rs new file mode 100644 index 0000000..0bb9bd0 --- /dev/null +++ b/crates/notsecrets/src/sources/yubikey.rs @@ -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, AgeError> { + load_impl() + } +} + +#[cfg(feature = "yubikey")] +fn load_impl() -> Result, 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, AgeError> { + Err(AgeError::SourceError { + name: "yubikey-piv-9c".to_string(), + source: anyhow::anyhow!( + "notsecrets was compiled without the `yubikey` feature; \ + YubikeySource is unavailable" + ), + }) +} diff --git a/crates/notstrap/Cargo.toml b/crates/notstrap/Cargo.toml index a31b702..0a2f3bf 100644 --- a/crates/notstrap/Cargo.toml +++ b/crates/notstrap/Cargo.toml @@ -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 } diff --git a/crates/notstrap/src/lib.rs b/crates/notstrap/src/lib.rs index f404a83..0af973a 100644 --- a/crates/notstrap/src/lib.rs +++ b/crates/notstrap/src/lib.rs @@ -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>) -> Result>; #[derive(Deserialize)] pub struct NotstrapConfig { pub bootstrap: BootstrapSection, + /// When present, notstrap joins the tailnet before cloning dotfiles. + pub tailscale: Option, #[serde(default)] pub hooks: Vec, } #[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, 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, pub dotfiles: Option, + /// 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>, /// None = skip prereq check (tests). Some(f) = run f(). pub check_prereqs: Option Result<()>>>, /// None = skip env injection (tests). Some(f) = decrypt sops at path and inject. @@ -76,8 +88,37 @@ pub fn run(opts: BootstrapOptions) -> Result { })?, }; - // 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 = 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 = 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 { } } - // 4. Resolve age identities + // 5. Resolve age identities let sources: Vec> = 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), ] diff --git a/crates/notstrap/src/main.rs b/crates/notstrap/src/main.rs index 3e87b5d..179a636 100644 --- a/crates/notstrap/src/main.rs +++ b/crates/notstrap/src/main.rs @@ -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, }; diff --git a/tests/integration/tests/bootstrap.rs b/tests/integration/tests/bootstrap.rs index 7a6acf2..b856a9d 100644 --- a/tests/integration/tests/bootstrap.rs +++ b/tests/integration/tests/bootstrap.rs @@ -69,6 +69,7 @@ fn make_opts(env: &TestEnv, force: bool) -> BootstrapOptions { force, key_file: Some(env.key_file.clone()), dotfiles: Some(env.dotfiles.path().to_path_buf()), + tailscale: Some(None), // skip Tailscale in tests check_prereqs: None, env_injector: None, } @@ -216,6 +217,7 @@ fn test_bootstrap_fails_fast_on_bad_key() { force: false, key_file: Some(PathBuf::from("/nonexistent/no-such-key.age")), dotfiles: Some(d.to_path_buf()), + tailscale: Some(None), // skip Tailscale in tests check_prereqs: None, env_injector: None, };