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:
@@ -0,0 +1,71 @@
|
|||||||
|
<h2>notfiles + Tailscale — Bootstrap Architecture</h2>
|
||||||
|
<p class="subtitle">How the crates fit together for ephemeral machine setup</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div style="background:#1a1a2e;border-radius:12px;padding:24px;font-family:monospace;font-size:13px;line-height:2;color:#e0e0e0;">
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;align-items:flex-start;flex-wrap:wrap;">
|
||||||
|
|
||||||
|
<!-- Fresh machine -->
|
||||||
|
<div style="border:2px solid #4a9eff;border-radius:8px;padding:16px;min-width:160px;background:#0d1b2a;">
|
||||||
|
<div style="color:#4a9eff;font-weight:bold;margin-bottom:8px;">New Machine</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">TS_AUTHKEY env var</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">YubiKey (PIV slot 9d)</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">→ interactive prompt</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="color:#888;font-size:20px;margin-top:28px;">→</div>
|
||||||
|
|
||||||
|
<!-- notstrap -->
|
||||||
|
<div style="border:2px solid #f0c040;border-radius:8px;padding:16px;min-width:160px;background:#1a1500;">
|
||||||
|
<div style="color:#f0c040;font-weight:bold;margin-bottom:8px;">notstrap</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">1. prereqs check</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">2. → <span style="color:#ff7eb3">notnet</span></div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">3. clone Gitea</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">4. → <span style="color:#7effa0">notsecrets</span></div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">5. link dotfiles</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">6. run hooks</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;margin-top:0;">
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<div style="color:#888;font-size:20px;">→</div>
|
||||||
|
<!-- notnet -->
|
||||||
|
<div style="border:2px solid #ff7eb3;border-radius:8px;padding:16px;min-width:160px;background:#1a0a14;">
|
||||||
|
<div style="color:#ff7eb3;font-weight:bold;margin-bottom:8px;">notnet <span style="color:#666;font-size:10px;">NEW</span></div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">detect existing Tailscale</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">install if missing</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">resolve auth key</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">join tailnet</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">verify peer reachable</div>
|
||||||
|
</div>
|
||||||
|
<div style="color:#888;font-size:20px;">→</div>
|
||||||
|
<div style="border:2px solid #7effa0;border-radius:8px;padding:12px;min-width:120px;background:#001a08;">
|
||||||
|
<div style="color:#7effa0;font-weight:bold;margin-bottom:6px;">Tailnet</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">minibox (VPS)</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">Gitea repo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<div style="color:#888;font-size:20px;">→</div>
|
||||||
|
<!-- notsecrets -->
|
||||||
|
<div style="border:2px solid #7effa0;border-radius:8px;padding:16px;min-width:160px;background:#001a08;">
|
||||||
|
<div style="color:#7effa0;font-weight:bold;margin-bottom:8px;">notsecrets <span style="color:#666;font-size:10px;">+YubiKey</span></div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">YubikeySource (PIV 9c)</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">BitwardenSource</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">FileSource</div>
|
||||||
|
<div style="color:#aaa;font-size:12px;">PromptSource</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #333;color:#888;font-size:11px;">
|
||||||
|
Already on Tailnet? notstrap detects active Tailscale → skips notnet entirely
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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"}
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
72766
|
||||||
314
Cargo.lock
generated
314
Cargo.lock
generated
@@ -107,6 +107,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -342,6 +348,18 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
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]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -387,10 +405,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid",
|
||||||
|
"der_derive",
|
||||||
|
"flagset",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "difflib"
|
name = "difflib"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -436,6 +476,20 @@ version = "0.3.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9"
|
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]]
|
[[package]]
|
||||||
name = "ed25519"
|
name = "ed25519"
|
||||||
version = "2.2.3"
|
version = "2.2.3"
|
||||||
@@ -466,6 +520,27 @@ version = "1.15.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
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]]
|
[[package]]
|
||||||
name = "env_home"
|
name = "env_home"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -494,6 +569,16 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
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]]
|
[[package]]
|
||||||
name = "fiat-crypto"
|
name = "fiat-crypto"
|
||||||
version = "0.2.9"
|
version = "0.2.9"
|
||||||
@@ -506,6 +591,12 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flagset"
|
||||||
|
version = "0.4.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -520,6 +611,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -570,6 +662,17 @@ dependencies = [
|
|||||||
"walkdir",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -766,6 +869,22 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
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]]
|
[[package]]
|
||||||
name = "notcore"
|
name = "notcore"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -819,6 +938,17 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "notnet"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"rpassword",
|
||||||
|
"serde",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"yubikey",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notsecrets"
|
name = "notsecrets"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -841,6 +971,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
|
"yubikey",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -853,6 +984,7 @@ dependencies = [
|
|||||||
"notcore",
|
"notcore",
|
||||||
"notfiles",
|
"notfiles",
|
||||||
"nothooks",
|
"nothooks",
|
||||||
|
"notnet",
|
||||||
"notsecrets",
|
"notsecrets",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml",
|
||||||
@@ -871,6 +1003,7 @@ dependencies = [
|
|||||||
"num-iter",
|
"num-iter",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"rand",
|
"rand",
|
||||||
|
"serde",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -929,6 +1062,30 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
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]]
|
[[package]]
|
||||||
name = "password-hash"
|
name = "password-hash"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -950,6 +1107,25 @@ dependencies = [
|
|||||||
"hmac",
|
"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]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -980,6 +1156,12 @@ dependencies = [
|
|||||||
"spki",
|
"spki",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkg-config"
|
||||||
|
version = "0.3.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "poly1305"
|
name = "poly1305"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -1037,6 +1219,15 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primeorder"
|
||||||
|
version = "0.13.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||||
|
dependencies = [
|
||||||
|
"elliptic-curve",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -1119,6 +1310,16 @@ version = "0.8.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
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]]
|
[[package]]
|
||||||
name = "rpassword"
|
name = "rpassword"
|
||||||
version = "7.4.0"
|
version = "7.4.0"
|
||||||
@@ -1219,6 +1420,29 @@ dependencies = [
|
|||||||
"sha2",
|
"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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -1281,6 +1505,17 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -1412,6 +1647,27 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
@@ -1487,6 +1743,17 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
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]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -1892,6 +2159,53 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.48"
|
version = "0.8.48"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ members = [
|
|||||||
"crates/notfiles",
|
"crates/notfiles",
|
||||||
"crates/notsecrets",
|
"crates/notsecrets",
|
||||||
"crates/nothooks",
|
"crates/nothooks",
|
||||||
|
"crates/notnet",
|
||||||
"crates/notstrap",
|
"crates/notstrap",
|
||||||
"crates/notgraph",
|
"crates/notgraph",
|
||||||
"tests/integration",
|
"tests/integration",
|
||||||
@@ -30,6 +31,7 @@ notcore = { path = "crates/notcore" }
|
|||||||
notfiles = { path = "crates/notfiles" }
|
notfiles = { path = "crates/notfiles" }
|
||||||
notsecrets = { path = "crates/notsecrets" }
|
notsecrets = { path = "crates/notsecrets" }
|
||||||
nothooks = { path = "crates/nothooks" }
|
nothooks = { path = "crates/nothooks" }
|
||||||
|
notnet = { path = "crates/notnet" }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
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"
|
rand = "0.8"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
curve25519-dalek = "4"
|
curve25519-dalek = "4"
|
||||||
|
yubikey = { version = "0.8", optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
yubikey = ["dep:yubikey"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub use ports::IdentitySource;
|
|||||||
pub use recipients::{
|
pub use recipients::{
|
||||||
Recipient, ScryptRecipient, SshEd25519Recipient, SshRsaRecipient, X25519Recipient,
|
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.
|
/// Try each `IdentitySource` in order; collect all identities that load successfully.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
pub mod bitwarden;
|
pub mod bitwarden;
|
||||||
pub mod file;
|
pub mod file;
|
||||||
pub mod prompt;
|
pub mod prompt;
|
||||||
|
pub mod yubikey;
|
||||||
|
|
||||||
pub use crate::ports::IdentitySource;
|
pub use crate::ports::IdentitySource;
|
||||||
pub use bitwarden::BitwardenSource;
|
pub use bitwarden::BitwardenSource;
|
||||||
pub use file::FileSource;
|
pub use file::FileSource;
|
||||||
pub use prompt::PromptSource;
|
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 }
|
notfiles = { workspace = true }
|
||||||
notsecrets = { workspace = true }
|
notsecrets = { workspace = true }
|
||||||
nothooks = { workspace = true }
|
nothooks = { workspace = true }
|
||||||
|
notnet = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
which = { workspace = true }
|
which = { workspace = true }
|
||||||
|
|||||||
@@ -4,25 +4,31 @@ use notfiles::{LinkOptions, link};
|
|||||||
use nothooks::{HookRunner, run_phase};
|
use nothooks::{HookRunner, run_phase};
|
||||||
use notsecrets::identities::Identity;
|
use notsecrets::identities::Identity;
|
||||||
use notsecrets::sources::IdentitySource;
|
use notsecrets::sources::IdentitySource;
|
||||||
use notsecrets::{BitwardenSource, FileSource, PromptSource, resolve_identities};
|
use notsecrets::{BitwardenSource, FileSource, PromptSource, YubikeySource, resolve_identities};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub mod prereqs;
|
pub mod prereqs;
|
||||||
pub mod repo;
|
pub mod repo;
|
||||||
|
|
||||||
|
pub use notnet::TailscaleOptions;
|
||||||
|
|
||||||
type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>;
|
type EnvInjector = Box<dyn Fn(&Path, Vec<Box<dyn Identity>>) -> Result<String>>;
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct NotstrapConfig {
|
pub struct NotstrapConfig {
|
||||||
pub bootstrap: BootstrapSection,
|
pub bootstrap: BootstrapSection,
|
||||||
|
/// When present, notstrap joins the tailnet before cloning dotfiles.
|
||||||
|
pub tailscale: Option<TailscaleOptions>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hooks: Vec<notcore::HookSpec>,
|
pub hooks: Vec<notcore::HookSpec>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct BootstrapSection {
|
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,
|
pub dotfiles_dir: String,
|
||||||
#[serde(default = "default_bw_item")]
|
#[serde(default = "default_bw_item")]
|
||||||
pub bw_age_item: String,
|
pub bw_age_item: String,
|
||||||
@@ -42,6 +48,12 @@ pub struct BootstrapOptions {
|
|||||||
pub force: bool,
|
pub force: bool,
|
||||||
pub key_file: Option<PathBuf>,
|
pub key_file: Option<PathBuf>,
|
||||||
pub dotfiles: 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().
|
/// None = skip prereq check (tests). Some(f) = run f().
|
||||||
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
|
pub check_prereqs: Option<Box<dyn Fn() -> Result<()>>>,
|
||||||
/// None = skip env injection (tests). Some(f) = decrypt sops at path and inject.
|
/// 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
|
// Resolve which Tailscale options to use: CLI override > config > None.
|
||||||
match repo::clone_if_missing(&cfg.bootstrap.dotfiles_repo, &dotfiles_dir) {
|
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) => {
|
Ok(true) => {
|
||||||
report.add("clone dotfiles", StepStatus::Ok);
|
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 {
|
let sources: Vec<Box<dyn IdentitySource>> = if let Some(kf) = opts.key_file {
|
||||||
vec![Box::new(FileSource::new(kf))]
|
vec![Box::new(FileSource::new(kf))]
|
||||||
} else {
|
} else {
|
||||||
vec![
|
vec![
|
||||||
|
Box::new(YubikeySource),
|
||||||
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
|
Box::new(BitwardenSource::new(&cfg.bootstrap.bw_age_item)),
|
||||||
Box::new(PromptSource),
|
Box::new(PromptSource),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ fn main() -> Result<()> {
|
|||||||
force,
|
force,
|
||||||
key_file,
|
key_file,
|
||||||
dotfiles,
|
dotfiles,
|
||||||
|
tailscale: None, // defer to [tailscale] section in config (if present)
|
||||||
check_prereqs: Some(Box::new(prereqs::check_prerequisites)),
|
check_prereqs: Some(Box::new(prereqs::check_prerequisites)),
|
||||||
env_injector: None,
|
env_injector: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ fn make_opts(env: &TestEnv, force: bool) -> BootstrapOptions {
|
|||||||
force,
|
force,
|
||||||
key_file: Some(env.key_file.clone()),
|
key_file: Some(env.key_file.clone()),
|
||||||
dotfiles: Some(env.dotfiles.path().to_path_buf()),
|
dotfiles: Some(env.dotfiles.path().to_path_buf()),
|
||||||
|
tailscale: Some(None), // skip Tailscale in tests
|
||||||
check_prereqs: None,
|
check_prereqs: None,
|
||||||
env_injector: None,
|
env_injector: None,
|
||||||
}
|
}
|
||||||
@@ -216,6 +217,7 @@ fn test_bootstrap_fails_fast_on_bad_key() {
|
|||||||
force: false,
|
force: false,
|
||||||
key_file: Some(PathBuf::from("/nonexistent/no-such-key.age")),
|
key_file: Some(PathBuf::from("/nonexistent/no-such-key.age")),
|
||||||
dotfiles: Some(d.to_path_buf()),
|
dotfiles: Some(d.to_path_buf()),
|
||||||
|
tailscale: Some(None), // skip Tailscale in tests
|
||||||
check_prereqs: None,
|
check_prereqs: None,
|
||||||
env_injector: None,
|
env_injector: None,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user