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