8bbdad3e
Enrol the keys that already have access when governance is turned on
a73x 2026-08-14 06:27
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -428,9 +428,18 @@ The seed reproduces the policy already in force: it reads each repository's | |||
| 428 | `R = @anonymous` and `option listed = yes` for a repository that is public | 428 | `R = @anonymous` and `option listed = yes` for a repository that is public |
| 429 | today. So enabling governance changes nothing about what the server exposes, | 429 | today. So enabling governance changes nothing about what the server exposes, |
| 430 | which is what makes it safe to run on a live server — `--dry-run` prints the | 430 | which is what makes it safe to run on a live server — `--dry-run` prints the |
| 431 | rules first. Setup refuses if `settings.git` already exists, and leaves | 431 | rules first. Setup refuses if `settings.git` already exists. |
| 432 | `authorized_keys` alone (though once governance is on, only keys in `keydir/` | 432 | |
| 433 | authenticate; enrol the rest before anyone needs them). | 433 | It changes nothing about who can *connect*, either. Because `keydir/` |
| 434 | supersedes `authorized_keys` the moment `settings.git` exists, setup copies | ||
| 435 | every key in that file into `keydir/` — leaving the file itself untouched — and | ||
| 436 | translates the `server.toml` rosters that named those keys by fingerprint. An | ||
| 437 | entry's trailing comment names it where the comment can be a principal name, | ||
| 438 | used verbatim and never cut down to `alex@laptop`'s first component; otherwise | ||
| 439 | it gets an obviously provisional `key1`, `key2`, reported with the key's | ||
| 440 | fingerprint so you can rename it. Every derived name is printed, by `--dry-run` | ||
| 441 | too. Pass `--no-enrol-existing` for a clean single-administrator bootstrap, | ||
| 442 | which says how many keys that locks out. | ||
| 434 | 443 | ||
| 435 | ```text | 444 | ```text |
| 436 | settings.git | 445 | settings.git |
src/server/main.rs
| Old | New | ||
|---|---|---|---|
| @@ -73,8 +73,8 @@ enum Command { | |||
| 73 | /// running this changes nothing about what the server exposes. That is | 73 | /// running this changes nothing about what the server exposes. That is |
| 74 | /// what makes it safe on a live server; `--dry-run` shows the rules first. | 74 | /// what makes it safe on a live server; `--dry-run` shows the rules first. |
| 75 | Setup { | 75 | Setup { |
| 76 | /// The same config the server runs with; `repos_dir` is read, and | 76 | /// The same config the server runs with. `repos_dir` is read, and |
| 77 | /// `authorized_keys` is left alone. | 77 | /// `authorized_keys` is read but never written. |
| 78 | #[arg(short, long)] | 78 | #[arg(short, long)] |
| 79 | config: PathBuf, | 79 | config: PathBuf, |
| 80 | 80 | ||
| @@ -90,7 +90,17 @@ enum Command { | |||
| 90 | #[arg(long, value_name = "NAME")] | 90 | #[arg(long, value_name = "NAME")] |
| 91 | admin_name: Option<String>, | 91 | admin_name: Option<String>, |
| 92 | 92 | ||
| 93 | /// Print the `conf/access.conf` this would write, and write nothing. | 93 | /// Do not enrol the keys already in `authorized_keys`. |
| 94 | /// | ||
| 95 | /// By default every key in that file is copied into `keydir/`, because | ||
| 96 | /// `keydir/` supersedes it the moment governance is on and the keys | ||
| 97 | /// would otherwise be refused. Pass this for a clean single-admin | ||
| 98 | /// bootstrap, knowing it locks everybody else out. | ||
| 99 | #[arg(long)] | ||
| 100 | no_enrol_existing: bool, | ||
| 101 | |||
| 102 | /// Print the `conf/access.conf` this would write and the names it | ||
| 103 | /// would enrol, and write nothing. | ||
| 94 | #[arg(long)] | 104 | #[arg(long)] |
| 95 | dry_run: bool, | 105 | dry_run: bool, |
| 96 | }, | 106 | }, |
| @@ -136,11 +146,14 @@ async fn main() { | |||
| 136 | Command::Setup { | 146 | Command::Setup { |
| 137 | admin_key, | 147 | admin_key, |
| 138 | admin_name, | 148 | admin_name, |
| 149 | no_enrol_existing, | ||
| 139 | .. | 150 | .. |
| 140 | } => setup::run( | 151 | } => setup::run( |
| 141 | &config.repos_dir, | 152 | &config.repos_dir, |
| 153 | &config.authorized_keys, | ||
| 142 | &admin_key, | 154 | &admin_key, |
| 143 | admin_name.as_deref(), | 155 | admin_name.as_deref(), |
| 156 | !no_enrol_existing, | ||
| 144 | dry_run, | 157 | dry_run, |
| 145 | ), | 158 | ), |
| 146 | }); | 159 | }); |
src/server/setup.rs
| Old | New | ||
|---|---|---|---|
| @@ -32,6 +32,23 @@ | |||
| 32 | //! bug in this file, and it aborts rather than governing a server with rules | 32 | //! bug in this file, and it aborts rather than governing a server with rules |
| 33 | //! that mean something other than what it was told they mean. | 33 | //! that mean something other than what it was told they mean. |
| 34 | //! | 34 | //! |
| 35 | //! # Why the seed enrols the keys that already have access | ||
| 36 | //! | ||
| 37 | //! The same principle again, on the axis where it is easiest to get wrong. | ||
| 38 | //! `keydir/` supersedes `authorized_keys` the instant `settings.git` exists — | ||
| 39 | //! `auth_publickey` reads one roster or the other, never both — so a bootstrap | ||
| 40 | //! that enrolled only the administrator would revoke SSH for everybody else at | ||
| 41 | //! the moment it ran. That is not a smaller change than hiding every | ||
| 42 | //! repository; it is a larger one, and it is the change that locks the | ||
| 43 | //! operator out of the server they are configuring. | ||
| 44 | //! | ||
| 45 | //! So every well-formed entry in `authorized_keys` is enrolled, and the rules | ||
| 46 | //! translate the `server.toml` rosters that named those keys by fingerprint. | ||
| 47 | //! `--no-enrol-existing` asks for the clean single-administrator bootstrap | ||
| 48 | //! instead, and says how many keys that costs. The default is the safe one | ||
| 49 | //! because the two mistakes are not symmetric: an over-broad roster is a | ||
| 50 | //! `git rm` and a push, and a lockout may need the filesystem. | ||
| 51 | //! | ||
| 35 | //! # The one thing governance cannot say | 52 | //! # The one thing governance cannot say |
| 36 | //! | 53 | //! |
| 37 | //! `server.toml` has two anonymous switches — `[ui] anonymous` and | 54 | //! `server.toml` has two anonymous switches — `[ui] anonymous` and |
| @@ -41,11 +58,16 @@ | |||
| 41 | //! and by name, in the report and in a comment in the file: a bootstrap may | 58 | //! and by name, in the report and in a comment in the file: a bootstrap may |
| 42 | //! narrow exposure where it must, and must never widen it by guessing. | 59 | //! narrow exposure where it must, and must never widen it by guessing. |
| 43 | 60 | ||
| 61 | use std::collections::HashSet; | ||
| 44 | use std::path::{Path, PathBuf}; | 62 | use std::path::{Path, PathBuf}; |
| 45 | 63 | ||
| 46 | use russh::keys::PublicKey; | 64 | use russh::keys::PublicKey; |
| 47 | 65 | ||
| 48 | use crate::governance::{self, conf::AccessConf, keydir, SETTINGS_REPO}; | 66 | use crate::governance::{ |
| 67 | self, | ||
| 68 | conf::{Access, AccessConf, Subject}, | ||
| 69 | keydir, SETTINGS_REPO, | ||
| 70 | }; | ||
| 49 | use crate::repos::{self, RepoPolicy}; | 71 | use crate::repos::{self, RepoPolicy}; |
| 50 | 72 | ||
| 51 | /// The branch the seed lands on. `governance::load` follows HEAD, so this is | 73 | /// The branch the seed lands on. `governance::load` follows HEAD, so this is |
| @@ -62,8 +84,22 @@ enum NameSource { | |||
| 62 | } | 84 | } |
| 63 | 85 | ||
| 64 | /// Run the command. Returns the process exit code. | 86 | /// Run the command. Returns the process exit code. |
| 65 | pub fn run(repos_dir: &Path, admin_key: &str, admin_name: Option<&str>, dry_run: bool) -> i32 { | 87 | pub fn run( |
| 66 | match try_run(repos_dir, admin_key, admin_name, dry_run) { | 88 | repos_dir: &Path, |
| 89 | authorized_keys: &Path, | ||
| 90 | admin_key: &str, | ||
| 91 | admin_name: Option<&str>, | ||
| 92 | enrol_existing: bool, | ||
| 93 | dry_run: bool, | ||
| 94 | ) -> i32 { | ||
| 95 | match try_run( | ||
| 96 | repos_dir, | ||
| 97 | authorized_keys, | ||
| 98 | admin_key, | ||
| 99 | admin_name, | ||
| 100 | enrol_existing, | ||
| 101 | dry_run, | ||
| 102 | ) { | ||
| 67 | Ok(()) => 0, | 103 | Ok(()) => 0, |
| 68 | Err(reason) => { | 104 | Err(reason) => { |
| 69 | eprintln!("error: {reason}"); | 105 | eprintln!("error: {reason}"); |
| @@ -74,8 +110,10 @@ pub fn run(repos_dir: &Path, admin_key: &str, admin_name: Option<&str>, dry_run: | |||
| 74 | 110 | ||
| 75 | fn try_run( | 111 | fn try_run( |
| 76 | repos_dir: &Path, | 112 | repos_dir: &Path, |
| 113 | authorized_keys: &Path, | ||
| 77 | admin_key: &str, | 114 | admin_key: &str, |
| 78 | admin_name: Option<&str>, | 115 | admin_name: Option<&str>, |
| 116 | enrol_existing: bool, | ||
| 79 | dry_run: bool, | 117 | dry_run: bool, |
| 80 | ) -> Result<(), String> { | 118 | ) -> Result<(), String> { |
| 81 | if !repos_dir.is_dir() { | 119 | if !repos_dir.is_dir() { |
| @@ -98,6 +136,12 @@ fn try_run( | |||
| 98 | let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?; | 136 | let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?; |
| 99 | let admin_principal = crate::ssh::session::ssh_key_principal(&key); | 137 | let admin_principal = crate::ssh::session::ssh_key_principal(&key); |
| 100 | 138 | ||
| 139 | // Read `authorized_keys` whether or not we are enrolling from it: the | ||
| 140 | // count is what makes declining an informed choice rather than a silent | ||
| 141 | // one. | ||
| 142 | let enrolment = Enrolment::plan(authorized_keys, &admin_principal, &name, enrol_existing); | ||
| 143 | let roster = enrolment.roster(&admin_principal, &name); | ||
| 144 | |||
| 101 | let entries = repos::discover(repos_dir) | 145 | let entries = repos::discover(repos_dir) |
| 102 | .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?; | 146 | .map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?; |
| 103 | 147 | ||
| @@ -111,22 +155,30 @@ fn try_run( | |||
| 111 | )); | 155 | )); |
| 112 | } | 156 | } |
| 113 | 157 | ||
| 114 | let plan = Plan::build(repos_dir, &entries, &name, &admin_principal); | 158 | let plan = Plan::build(repos_dir, &entries, &name, &roster); |
| 115 | let conf_text = plan.render(); | 159 | let conf_text = plan.render(); |
| 116 | 160 | ||
| 117 | // Machine-check the safety claim before anything is written: the rules | 161 | // Machine-check the safety claim before anything is written: the rules |
| 118 | // just generated must give every repository the decision it already has. | 162 | // just generated must give every repository the decision it already has, |
| 119 | verify(&conf_text, &plan)?; | 163 | // to an anonymous request and to each key being enrolled. |
| 164 | verify(&conf_text, &plan, &roster)?; | ||
| 120 | 165 | ||
| 121 | if dry_run { | 166 | if dry_run { |
| 122 | print!("{conf_text}"); | 167 | print!("{conf_text}"); |
| 123 | println!(); | 168 | println!(); |
| 124 | report(&plan, &name, &name_source, &settings_path, true); | 169 | report(&plan, &name, &name_source, &enrolment, &settings_path, true); |
| 125 | return Ok(()); | 170 | return Ok(()); |
| 126 | } | 171 | } |
| 127 | 172 | ||
| 128 | seed(&settings_path, &conf_text, &name, &key_text)?; | 173 | seed(&settings_path, &conf_text, &name, &key_text, &enrolment)?; |
| 129 | report(&plan, &name, &name_source, &settings_path, false); | 174 | report( |
| 175 | &plan, | ||
| 176 | &name, | ||
| 177 | &name_source, | ||
| 178 | &enrolment, | ||
| 179 | &settings_path, | ||
| 180 | false, | ||
| 181 | ); | ||
| 130 | Ok(()) | 182 | Ok(()) |
| 131 | } | 183 | } |
| 132 | 184 | ||
| @@ -204,6 +256,284 @@ fn read_admin_key( | |||
| 204 | } | 256 | } |
| 205 | 257 | ||
| 206 | // --------------------------------------------------------------------------- | 258 | // --------------------------------------------------------------------------- |
| 259 | // The keys that already have access | ||
| 260 | // --------------------------------------------------------------------------- | ||
| 261 | |||
| 262 | /// How an enrolled key got the name it got. | ||
| 263 | /// | ||
| 264 | /// Reported for every key, because the basename *is* the principal: it goes | ||
| 265 | /// into `conf/access.conf`, and a name that is merely plausible is worse than | ||
| 266 | /// one that is obviously provisional. `key3` invites a rename; `alex` does not. | ||
| 267 | enum Naming { | ||
| 268 | /// The entry's trailing comment, used verbatim. | ||
| 269 | Comment, | ||
| 270 | /// A placeholder. The string says why the comment could not be used. | ||
| 271 | Placeholder(&'static str), | ||
| 272 | } | ||
| 273 | |||
| 274 | /// One `authorized_keys` entry, on its way into `keydir/`. | ||
| 275 | struct EnrolledKey { | ||
| 276 | name: String, | ||
| 277 | /// The OpenSSH line, written as `keydir/<name>.pub`. Kept verbatim so the | ||
| 278 | /// file in git is recognisably the line the operator had. | ||
| 279 | text: String, | ||
| 280 | fingerprint: String, | ||
| 281 | /// Which line of `authorized_keys` it came from, 1-based. | ||
| 282 | line: usize, | ||
| 283 | naming: Naming, | ||
| 284 | } | ||
| 285 | |||
| 286 | /// What `authorized_keys` yielded. | ||
| 287 | struct Enrolment { | ||
| 288 | path: PathBuf, | ||
| 289 | keys: Vec<EnrolledKey>, | ||
| 290 | /// Entries that were read but not enrolled, each with the reason. Never | ||
| 291 | /// fatal: one unusable line must not cost everybody else their access. | ||
| 292 | notes: Vec<String>, | ||
| 293 | /// Well-formed entries left out because `--no-enrol-existing` was given. | ||
| 294 | /// This is the size of the lockout the operator asked for. | ||
| 295 | declined: usize, | ||
| 296 | } | ||
| 297 | |||
| 298 | /// A candidate entry, before names are settled. | ||
| 299 | struct Candidate { | ||
| 300 | line: usize, | ||
| 301 | text: String, | ||
| 302 | fingerprint: String, | ||
| 303 | /// The trailing comment, if the entry has one that could be a name. | ||
| 304 | wanted: Option<String>, | ||
| 305 | /// Why it could not be, if it could not. | ||
| 306 | refusal: Option<&'static str>, | ||
| 307 | } | ||
| 308 | |||
| 309 | impl Enrolment { | ||
| 310 | /// Read `authorized_keys` and decide who is enrolled under what name. | ||
| 311 | /// | ||
| 312 | /// Nothing here is fatal. A file that does not exist, a line that is not a | ||
| 313 | /// key, an entry carrying `command=` restrictions — each is reported and | ||
| 314 | /// skipped, because the purpose of this pass is to *keep* access, and | ||
| 315 | /// aborting over one bad line would keep none of it. | ||
| 316 | fn plan(path: &Path, admin_fingerprint: &str, admin_name: &str, enrol: bool) -> Enrolment { | ||
| 317 | let mut enrolment = Enrolment { | ||
| 318 | path: path.to_path_buf(), | ||
| 319 | keys: Vec::new(), | ||
| 320 | notes: Vec::new(), | ||
| 321 | declined: 0, | ||
| 322 | }; | ||
| 323 | |||
| 324 | let text = match std::fs::read_to_string(path) { | ||
| 325 | Ok(text) => text, | ||
| 326 | Err(e) if e.kind() == std::io::ErrorKind::NotFound => { | ||
| 327 | // Not an error: a server that has never had one has nobody to | ||
| 328 | // lock out. | ||
| 329 | return enrolment; | ||
| 330 | } | ||
| 331 | Err(e) => { | ||
| 332 | enrolment.notes.push(format!( | ||
| 333 | "{} could not be read ({e}), so no existing key was enrolled. \ | ||
| 334 | Every key it holds will be refused SSH once governance is on.", | ||
| 335 | path.display() | ||
| 336 | )); | ||
| 337 | return enrolment; | ||
| 338 | } | ||
| 339 | }; | ||
| 340 | |||
| 341 | let candidates = enrolment.read_entries(&text, admin_fingerprint); | ||
| 342 | if !enrol { | ||
| 343 | enrolment.declined = candidates.len(); | ||
| 344 | return enrolment; | ||
| 345 | } | ||
| 346 | enrolment.keys = assign_names(candidates, admin_name); | ||
| 347 | enrolment | ||
| 348 | } | ||
| 349 | |||
| 350 | /// Parse the file into candidates, recording every entry it had to drop. | ||
| 351 | fn read_entries(&mut self, text: &str, admin_fingerprint: &str) -> Vec<Candidate> { | ||
| 352 | let mut candidates: Vec<Candidate> = Vec::new(); | ||
| 353 | let mut seen: HashSet<String> = HashSet::new(); | ||
| 354 | |||
| 355 | for (index, raw) in text.lines().enumerate() { | ||
| 356 | let line = index + 1; | ||
| 357 | let entry = raw.trim(); | ||
| 358 | if entry.is_empty() || entry.starts_with('#') { | ||
| 359 | continue; | ||
| 360 | } | ||
| 361 | |||
| 362 | // An entry with options (`command="…",no-pty ssh-ed25519 …`) does | ||
| 363 | // not parse here — and does not authenticate today either, because | ||
| 364 | // `is_authorized` compares the first field against the key type. | ||
| 365 | // Skipping it is therefore faithful, not a narrowing. | ||
| 366 | let key = match PublicKey::from_openssh(entry) { | ||
| 367 | Ok(key) => key, | ||
| 368 | Err(e) => { | ||
| 369 | self.notes.push(format!( | ||
| 370 | "{}:{line} is not a well-formed OpenSSH public key ({e}), so it was not \ | ||
| 371 | enrolled. An entry carrying `command=` or other options looks like this \ | ||
| 372 | too, and does not authenticate today either.", | ||
| 373 | self.path.display() | ||
| 374 | )); | ||
| 375 | continue; | ||
| 376 | } | ||
| 377 | }; | ||
| 378 | |||
| 379 | let fingerprint = crate::ssh::session::ssh_key_principal(&key); | ||
| 380 | if fingerprint == admin_fingerprint { | ||
| 381 | self.notes.push(format!( | ||
| 382 | "{}:{line} is the administrator's own key, already enrolled under the name \ | ||
| 383 | --admin-key gave it. One key cannot be two principals, so it was not \ | ||
| 384 | enrolled a second time.", | ||
| 385 | self.path.display() | ||
| 386 | )); | ||
| 387 | continue; | ||
| 388 | } | ||
| 389 | if !seen.insert(fingerprint.clone()) { | ||
| 390 | self.notes.push(format!( | ||
| 391 | "{}:{line} repeats a key that appears earlier in the file; enrolled once.", | ||
| 392 | self.path.display() | ||
| 393 | )); | ||
| 394 | continue; | ||
| 395 | } | ||
| 396 | |||
| 397 | let comment = comment_of(entry).to_string(); | ||
| 398 | let (wanted, refusal) = if comment.is_empty() { | ||
| 399 | (None, Some("the entry has no comment to take a name from")) | ||
| 400 | } else if keydir::validate_name(&comment).is_err() { | ||
| 401 | ( | ||
| 402 | None, | ||
| 403 | Some("its comment cannot be written as a principal name"), | ||
| 404 | ) | ||
| 405 | } else { | ||
| 406 | (Some(comment), None) | ||
| 407 | }; | ||
| 408 | |||
| 409 | candidates.push(Candidate { | ||
| 410 | line, | ||
| 411 | text: entry.to_string(), | ||
| 412 | fingerprint, | ||
| 413 | wanted, | ||
| 414 | refusal, | ||
| 415 | }); | ||
| 416 | } | ||
| 417 | candidates | ||
| 418 | } | ||
| 419 | |||
| 420 | /// Fingerprint-to-name for every identity these rules may mention: the | ||
| 421 | /// administrator, and each key being enrolled. | ||
| 422 | /// | ||
| 423 | /// This is exactly the set of `server.toml` roster entries that can be | ||
| 424 | /// translated at all — a fingerprint outside it has no name to write. | ||
| 425 | fn roster(&self, admin_fingerprint: &str, admin_name: &str) -> Roster { | ||
| 426 | let mut roster = vec![(admin_fingerprint.to_string(), admin_name.to_string())]; | ||
| 427 | for key in &self.keys { | ||
| 428 | roster.push((key.fingerprint.clone(), key.name.clone())); | ||
| 429 | } | ||
| 430 | Roster(roster) | ||
| 431 | } | ||
| 432 | } | ||
| 433 | |||
| 434 | /// The trailing comment of an `authorized_keys` entry: everything after the | ||
| 435 | /// key type and the key data. | ||
| 436 | /// | ||
| 437 | /// Taken from the line rather than from the parsed key, so that "the comment" | ||
| 438 | /// means here what it means in `ssh::auth`, which splits the same way. A | ||
| 439 | /// comment is free text and may hold spaces; whether it can be a name is a | ||
| 440 | /// separate question, asked next. | ||
| 441 | fn comment_of(entry: &str) -> &str { | ||
| 442 | let after_type = match entry.trim().split_once(char::is_whitespace) { | ||
| 443 | Some((_, rest)) => rest.trim_start(), | ||
| 444 | None => return "", | ||
| 445 | }; | ||
| 446 | match after_type.split_once(char::is_whitespace) { | ||
| 447 | Some((_, comment)) => comment.trim(), | ||
| 448 | None => "", | ||
| 449 | } | ||
| 450 | } | ||
| 451 | |||
| 452 | /// Settle a name on every candidate. | ||
| 453 | /// | ||
| 454 | /// The comment is used **verbatim** when it can be a principal name, and never | ||
| 455 | /// cut down to a first component. Turning `alex@laptop` into `alex` would be a | ||
| 456 | /// guess, and the way that guess fails is the expensive way: two entries the | ||
| 457 | /// operator listed separately — `alex@laptop`, `alex@desktop` — collapse into | ||
| 458 | /// one principal, and a rule written for one silently covers the other. A | ||
| 459 | /// bootstrap may narrow access where it must; it must never widen it by | ||
| 460 | /// inference. | ||
| 461 | /// | ||
| 462 | /// For the same reason a name two entries both claim is given to neither. One | ||
| 463 | /// of them winning by file order would put a plausible name on the wrong key, | ||
| 464 | /// which is the mistake that survives review; `key1` and `key2` do not. | ||
| 465 | fn assign_names(candidates: Vec<Candidate>, admin_name: &str) -> Vec<EnrolledKey> { | ||
| 466 | let mut claimed: HashSet<&str> = HashSet::new(); | ||
| 467 | let mut contested: HashSet<&str> = HashSet::new(); | ||
| 468 | for candidate in &candidates { | ||
| 469 | if let Some(name) = candidate.wanted.as_deref() { | ||
| 470 | if !claimed.insert(name) { | ||
| 471 | contested.insert(name); | ||
| 472 | } | ||
| 473 | } | ||
| 474 | } | ||
| 475 | |||
| 476 | let mut taken: HashSet<String> = HashSet::new(); | ||
| 477 | taken.insert(admin_name.to_string()); | ||
| 478 | let mut next_placeholder = 1usize; | ||
| 479 | |||
| 480 | let mut enrolled = Vec::new(); | ||
| 481 | for candidate in &candidates { | ||
| 482 | let refusal = match candidate.wanted.as_deref() { | ||
| 483 | Some(name) if contested.contains(name) => { | ||
| 484 | Some("the same comment appears on more than one entry") | ||
| 485 | } | ||
| 486 | Some(name) if name == admin_name => { | ||
| 487 | Some("its comment is the name the administrator already holds") | ||
| 488 | } | ||
| 489 | Some(_) => None, | ||
| 490 | None => candidate.refusal, | ||
| 491 | }; | ||
| 492 | |||
| 493 | let (name, naming) = match (candidate.wanted.as_deref(), refusal) { | ||
| 494 | (Some(name), None) => (name.to_string(), Naming::Comment), | ||
| 495 | (_, reason) => { | ||
| 496 | let mut placeholder = format!("key{next_placeholder}"); | ||
| 497 | while taken.contains(&placeholder) { | ||
| 498 | next_placeholder += 1; | ||
| 499 | placeholder = format!("key{next_placeholder}"); | ||
| 500 | } | ||
| 501 | next_placeholder += 1; | ||
| 502 | ( | ||
| 503 | placeholder, | ||
| 504 | Naming::Placeholder(reason.unwrap_or("its comment could not name a principal")), | ||
| 505 | ) | ||
| 506 | } | ||
| 507 | }; | ||
| 508 | taken.insert(name.clone()); | ||
| 509 | enrolled.push(EnrolledKey { | ||
| 510 | name, | ||
| 511 | text: candidate.text.clone(), | ||
| 512 | fingerprint: candidate.fingerprint.clone(), | ||
| 513 | line: candidate.line, | ||
| 514 | naming, | ||
| 515 | }); | ||
| 516 | } | ||
| 517 | enrolled | ||
| 518 | } | ||
| 519 | |||
| 520 | /// Fingerprint-to-name for the identities this seed enrols. | ||
| 521 | struct Roster(Vec<(String, String)>); | ||
| 522 | |||
| 523 | impl Roster { | ||
| 524 | fn name_for(&self, fingerprint: &str) -> Option<&str> { | ||
| 525 | self.0 | ||
| 526 | .iter() | ||
| 527 | .find(|(f, _)| f == fingerprint) | ||
| 528 | .map(|(_, name)| name.as_str()) | ||
| 529 | } | ||
| 530 | |||
| 531 | fn iter(&self) -> impl Iterator<Item = (&str, &str)> { | ||
| 532 | self.0.iter().map(|(f, n)| (f.as_str(), n.as_str())) | ||
| 533 | } | ||
| 534 | } | ||
| 535 | |||
| 536 | // --------------------------------------------------------------------------- | ||
| 207 | // Translating the policy already in force | 537 | // Translating the policy already in force |
| 208 | // --------------------------------------------------------------------------- | 538 | // --------------------------------------------------------------------------- |
| 209 | 539 | ||
| @@ -220,6 +550,9 @@ struct RepoPlan { | |||
| 220 | anonymous_read: bool, | 550 | anonymous_read: bool, |
| 221 | /// Notes to write into the file above the block, and to report. | 551 | /// Notes to write into the file above the block, and to report. |
| 222 | notes: Vec<String>, | 552 | notes: Vec<String>, |
| 553 | /// The policy these rules were translated from, kept so `verify` can ask | ||
| 554 | /// it the same questions it asks the rule engine. | ||
| 555 | policy: RepoPolicy, | ||
| 223 | } | 556 | } |
| 224 | 557 | ||
| 225 | struct Plan { | 558 | struct Plan { |
| @@ -234,12 +567,7 @@ struct Plan { | |||
| 234 | } | 567 | } |
| 235 | 568 | ||
| 236 | impl Plan { | 569 | impl Plan { |
| 237 | fn build( | 570 | fn build(repos_dir: &Path, entries: &[repos::RepoEntry], admin: &str, roster: &Roster) -> Plan { |
| 238 | repos_dir: &Path, | ||
| 239 | entries: &[repos::RepoEntry], | ||
| 240 | admin: &str, | ||
| 241 | admin_principal: &str, | ||
| 242 | ) -> Plan { | ||
| 243 | let mut plan = Plan { | 571 | let mut plan = Plan { |
| 244 | admin: admin.to_string(), | 572 | admin: admin.to_string(), |
| 245 | repos: Vec::new(), | 573 | repos: Vec::new(), |
| @@ -276,7 +604,7 @@ impl Plan { | |||
| 276 | continue; | 604 | continue; |
| 277 | } | 605 | } |
| 278 | plan.repos | 606 | plan.repos |
| 279 | .push(RepoPlan::build(&key, &entry.policy, admin, admin_principal)); | 607 | .push(RepoPlan::build(&key, &entry.policy, roster)); |
| 280 | } | 608 | } |
| 281 | 609 | ||
| 282 | for repo in &plan.repos { | 610 | for repo in &plan.repos { |
| @@ -337,7 +665,7 @@ fn rule(lhs: &str, rhs: &str) -> String { | |||
| 337 | } | 665 | } |
| 338 | 666 | ||
| 339 | impl RepoPlan { | 667 | impl RepoPlan { |
| 340 | fn build(key: &str, policy: &RepoPolicy, admin: &str, admin_principal: &str) -> RepoPlan { | 668 | fn build(key: &str, policy: &RepoPolicy, roster: &Roster) -> RepoPlan { |
| 341 | let mut rules = Vec::new(); | 669 | let mut rules = Vec::new(); |
| 342 | let mut notes = Vec::new(); | 670 | let mut notes = Vec::new(); |
| 343 | 671 | ||
| @@ -345,31 +673,36 @@ impl RepoPlan { | |||
| 345 | // | 673 | // |
| 346 | // `*` in server.toml means "any principal that authenticated at all", | 674 | // `*` in server.toml means "any principal that authenticated at all", |
| 347 | // and `@all` means "every key enrolled in keydir/", which is the same | 675 | // and `@all` means "every key enrolled in keydir/", which is the same |
| 348 | // sentence in the new language. A force-push was never separately | 676 | // sentence in the new language *because* every key that could |
| 677 | // authenticate is being enrolled. A force-push was never separately | ||
| 349 | // gated before governance, so a writer maps to RW+. | 678 | // gated before governance, so a writer maps to RW+. |
| 350 | let read_all = policy.access.read.iter().any(|entry| entry == "*"); | 679 | let read_all = policy.access.read.iter().any(|entry| entry == "*"); |
| 351 | let write_all = policy.access.write.iter().any(|entry| entry == "*"); | 680 | let write_all = policy.access.write.iter().any(|entry| entry == "*"); |
| 352 | 681 | ||
| 353 | // An explicit roster names fingerprints, and rules name keydir | 682 | // An explicit roster names fingerprints, and rules name keydir |
| 354 | // identities. Only the one key this command enrols can be translated; | 683 | // identities. The ones this command enrols can be translated; a |
| 355 | // the rest are recorded so the operator can enrol and grant them. | 684 | // fingerprint belonging to no enrolled key has no name to write, and |
| 685 | // is recorded so the operator can enrol and grant it. | ||
| 356 | let mut untranslatable: Vec<&str> = policy | 686 | let mut untranslatable: Vec<&str> = policy |
| 357 | .access | 687 | .access |
| 358 | .read | 688 | .read |
| 359 | .iter() | 689 | .iter() |
| 360 | .chain(policy.access.write.iter()) | 690 | .chain(policy.access.write.iter()) |
| 361 | .map(String::as_str) | 691 | .map(String::as_str) |
| 362 | .filter(|entry| *entry != "*" && *entry != admin_principal) | 692 | .filter(|entry| *entry != "*" && roster.name_for(entry).is_none()) |
| 363 | .collect(); | 693 | .collect(); |
| 364 | untranslatable.sort(); | 694 | untranslatable.sort(); |
| 365 | untranslatable.dedup(); | 695 | untranslatable.dedup(); |
| 366 | 696 | ||
| 367 | // A fingerprint cannot appear in a rule, so the one roster entry that | 697 | // A fingerprint cannot appear in a rule, so each roster entry named by |
| 368 | // can be translated is the admin's own key, under the admin's name. | 698 | // `server.toml` is written under the name it is being enrolled with. |
| 369 | if !write_all && policy.allows_write(admin_principal) { | 699 | // Nothing is emitted where `@all` below already says the same thing. |
| 370 | rules.push(("RW+".to_string(), admin.to_string())); | 700 | for (fingerprint, name) in roster.iter() { |
| 371 | } else if !read_all && policy.allows_read(admin_principal) { | 701 | if !write_all && policy.allows_write(fingerprint) { |
| 372 | rules.push(("R".to_string(), admin.to_string())); | 702 | rules.push(("RW+".to_string(), name.to_string())); |
| 703 | } else if !read_all && policy.allows_read(fingerprint) { | ||
| 704 | rules.push(("R".to_string(), name.to_string())); | ||
| 705 | } | ||
| 373 | } | 706 | } |
| 374 | if write_all { | 707 | if write_all { |
| 375 | rules.push(("RW+".to_string(), "@all".to_string())); | 708 | rules.push(("RW+".to_string(), "@all".to_string())); |
| @@ -419,6 +752,7 @@ impl RepoPlan { | |||
| 419 | listed: anonymous_read, | 752 | listed: anonymous_read, |
| 420 | anonymous_read, | 753 | anonymous_read, |
| 421 | notes, | 754 | notes, |
| 755 | policy: policy.clone(), | ||
| 422 | } | 756 | } |
| 423 | } | 757 | } |
| 424 | } | 758 | } |
| @@ -428,12 +762,13 @@ impl RepoPlan { | |||
| 428 | // --------------------------------------------------------------------------- | 762 | // --------------------------------------------------------------------------- |
| 429 | 763 | ||
| 430 | /// Parse what was generated and confirm it decides every repository the way | 764 | /// Parse what was generated and confirm it decides every repository the way |
| 431 | /// the plan says it should. | 765 | /// the plan says it should — for an anonymous request, and for every key being |
| 766 | /// enrolled. | ||
| 432 | /// | 767 | /// |
| 433 | /// This is the safety claim, checked against the real evaluator rather than | 768 | /// This is the safety claim, checked against the real evaluator rather than |
| 434 | /// against a second copy of the intent. If it ever fails, this file has a bug | 769 | /// against a second copy of the intent. If it ever fails, this file has a bug |
| 435 | /// and the right thing to do is refuse to govern the server with it. | 770 | /// and the right thing to do is refuse to govern the server with it. |
| 436 | fn verify(conf_text: &str, plan: &Plan) -> Result<(), String> { | 771 | fn verify(conf_text: &str, plan: &Plan, roster: &Roster) -> Result<(), String> { |
| 437 | let conf = AccessConf::parse(conf_text).map_err(|e| { | 772 | let conf = AccessConf::parse(conf_text).map_err(|e| { |
| 438 | format!( | 773 | format!( |
| 439 | "the generated rules do not parse ({e}); refusing to write them. \ | 774 | "the generated rules do not parse ({e}); refusing to write them. \ |
| @@ -454,6 +789,35 @@ fn verify(conf_text: &str, plan: &Plan) -> Result<(), String> { | |||
| 454 | } | 789 | } |
| 455 | } | 790 | } |
| 456 | 791 | ||
| 792 | // The other half of the same claim: every key that could authenticate | ||
| 793 | // before must reach exactly the repositories it reached before. Asked of | ||
| 794 | // the rule engine, against the `server.toml` decision it is replacing. | ||
| 795 | // | ||
| 796 | // Rewind is compared against the write decision deliberately. Nothing | ||
| 797 | // before governance gated a force-push separately from a push, so a | ||
| 798 | // writer maps to RW+, and this is where that equivalence is checked | ||
| 799 | // rather than assumed. | ||
| 800 | for repo in &plan.repos { | ||
| 801 | for (fingerprint, name) in roster.iter() { | ||
| 802 | let subject = Subject::new(name); | ||
| 803 | for (access, expected) in [ | ||
| 804 | (Access::Read, repo.policy.allows_read(fingerprint)), | ||
| 805 | (Access::Write, repo.policy.allows_write(fingerprint)), | ||
| 806 | (Access::Rewind, repo.policy.allows_write(fingerprint)), | ||
| 807 | ] { | ||
| 808 | let granted = conf.allows_repo(&repo.key, &subject, access); | ||
| 809 | if granted != expected { | ||
| 810 | return Err(format!( | ||
| 811 | "the generated rules would give {name} {access:?} on {} where its \ | ||
| 812 | server.toml gives {expected} (rules say {granted}); refusing to write \ | ||
| 813 | them. This is a bug in `setup`.", | ||
| 814 | repo.key | ||
| 815 | )); | ||
| 816 | } | ||
| 817 | } | ||
| 818 | } | ||
| 819 | } | ||
| 820 | |||
| 457 | // The settings repository must stay off the anonymous surface: the whole | 821 | // The settings repository must stay off the anonymous surface: the whole |
| 458 | // key roster is in it. | 822 | // key roster is in it. |
| 459 | if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) { | 823 | if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) { |
| @@ -481,6 +845,7 @@ fn seed( | |||
| 481 | conf_text: &str, | 845 | conf_text: &str, |
| 482 | admin_name: &str, | 846 | admin_name: &str, |
| 483 | key_text: &str, | 847 | key_text: &str, |
| 848 | enrolment: &Enrolment, | ||
| 484 | ) -> Result<(), String> { | 849 | ) -> Result<(), String> { |
| 485 | let mut options = git2::RepositoryInitOptions::new(); | 850 | let mut options = git2::RepositoryInitOptions::new(); |
| 486 | options.bare(true).initial_head(SEED_BRANCH); | 851 | options.bare(true).initial_head(SEED_BRANCH); |
| @@ -489,7 +854,7 @@ fn seed( | |||
| 489 | 854 | ||
| 490 | // From here on a failure leaves a half-made repository, which would make | 855 | // From here on a failure leaves a half-made repository, which would make |
| 491 | // the server think it is governed by nothing. Clean it up on every exit. | 856 | // the server think it is governed by nothing. Clean it up on every exit. |
| 492 | match build_and_commit(&repo, conf_text, admin_name, key_text) { | 857 | match build_and_commit(&repo, conf_text, admin_name, key_text, enrolment) { |
| 493 | Ok(()) => Ok(()), | 858 | Ok(()) => Ok(()), |
| 494 | Err(reason) => { | 859 | Err(reason) => { |
| 495 | drop(repo); | 860 | drop(repo); |
| @@ -504,13 +869,11 @@ fn build_and_commit( | |||
| 504 | conf_text: &str, | 869 | conf_text: &str, |
| 505 | admin_name: &str, | 870 | admin_name: &str, |
| 506 | key_text: &str, | 871 | key_text: &str, |
| 872 | enrolment: &Enrolment, | ||
| 507 | ) -> Result<(), String> { | 873 | ) -> Result<(), String> { |
| 508 | let git = |e: git2::Error| format!("building the seed commit: {e}"); | 874 | let git = |e: git2::Error| format!("building the seed commit: {e}"); |
| 509 | 875 | ||
| 510 | let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?; | 876 | let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?; |
| 511 | let key_blob = repo | ||
| 512 | .blob(format!("{}\n", key_text.trim()).as_bytes()) | ||
| 513 | .map_err(git)?; | ||
| 514 | 877 | ||
| 515 | let mut conf_dir = repo.treebuilder(None).map_err(git)?; | 878 | let mut conf_dir = repo.treebuilder(None).map_err(git)?; |
| 516 | conf_dir | 879 | conf_dir |
| @@ -519,9 +882,19 @@ fn build_and_commit( | |||
| 519 | let conf_tree = conf_dir.write().map_err(git)?; | 882 | let conf_tree = conf_dir.write().map_err(git)?; |
| 520 | 883 | ||
| 521 | let mut keydir_tree = repo.treebuilder(None).map_err(git)?; | 884 | let mut keydir_tree = repo.treebuilder(None).map_err(git)?; |
| 522 | keydir_tree | 885 | for (name, text) in std::iter::once((admin_name, key_text)).chain( |
| 523 | .insert(format!("{admin_name}.pub"), key_blob, 0o100644) | 886 | enrolment |
| 524 | .map_err(git)?; | 887 | .keys |
| 888 | .iter() | ||
| 889 | .map(|key| (key.name.as_str(), key.text.as_str())), | ||
| 890 | ) { | ||
| 891 | let blob = repo | ||
| 892 | .blob(format!("{}\n", text.trim()).as_bytes()) | ||
| 893 | .map_err(git)?; | ||
| 894 | keydir_tree | ||
| 895 | .insert(format!("{name}.pub"), blob, 0o100644) | ||
| 896 | .map_err(git)?; | ||
| 897 | } | ||
| 525 | let keydir_tree = keydir_tree.write().map_err(git)?; | 898 | let keydir_tree = keydir_tree.write().map_err(git)?; |
| 526 | 899 | ||
| 527 | let mut root = repo.treebuilder(None).map_err(git)?; | 900 | let mut root = repo.treebuilder(None).map_err(git)?; |
| @@ -557,7 +930,14 @@ fn build_and_commit( | |||
| 557 | // The report | 930 | // The report |
| 558 | // --------------------------------------------------------------------------- | 931 | // --------------------------------------------------------------------------- |
| 559 | 932 | ||
| 560 | fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dry_run: bool) { | 933 | fn report( |
| 934 | plan: &Plan, | ||
| 935 | name: &str, | ||
| 936 | source: &NameSource, | ||
| 937 | enrolment: &Enrolment, | ||
| 938 | settings_path: &Path, | ||
| 939 | dry_run: bool, | ||
| 940 | ) { | ||
| 561 | let derived = match source { | 941 | let derived = match source { |
| 562 | NameSource::Explicit => "from --admin-name", | 942 | NameSource::Explicit => "from --admin-name", |
| 563 | NameSource::Filename => "from the key file's name", | 943 | NameSource::Filename => "from the key file's name", |
| @@ -583,6 +963,8 @@ fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dr | |||
| 583 | } | 963 | } |
| 584 | ); | 964 | ); |
| 585 | 965 | ||
| 966 | report_enrolment(enrolment, dry_run); | ||
| 967 | |||
| 586 | for note in plan.narrowed.iter().chain(plan.skipped.iter()) { | 968 | for note in plan.narrowed.iter().chain(plan.skipped.iter()) { |
| 587 | let mut lines = note.lines(); | 969 | let mut lines = note.lines(); |
| 588 | if let Some(first) = lines.next() { | 970 | if let Some(first) = lines.next() { |
| @@ -595,12 +977,87 @@ fn report(plan: &Plan, name: &str, source: &NameSource, settings_path: &Path, dr | |||
| 595 | 977 | ||
| 596 | if !dry_run { | 978 | if !dry_run { |
| 597 | println!(); | 979 | println!(); |
| 598 | println!("Governance is now in force. `authorized_keys` was not touched, but it is"); | 980 | println!("Governance is now in force. `authorized_keys` was not touched, and is"); |
| 599 | println!("no longer consulted: only keys in keydir/ authenticate. Enrol the rest"); | 981 | println!("no longer consulted: only keys in keydir/ authenticate. Add anyone else by"); |
| 600 | println!( | 982 | println!( |
| 601 | "by cloning {}, adding keydir/<name>.pub", | 983 | "cloning {}, adding keydir/<name>.pub", |
| 602 | settings_path.display() | 984 | settings_path.display() |
| 603 | ); | 985 | ); |
| 604 | println!("and a grant in conf/access.conf, and pushing both together."); | 986 | println!("and a grant in conf/access.conf, and pushing both together."); |
| 605 | } | 987 | } |
| 606 | } | 988 | } |
| 989 | |||
| 990 | /// Say what happened to `authorized_keys`, by name. | ||
| 991 | /// | ||
| 992 | /// Every derived name is printed, not just the surprising ones. The operator | ||
| 993 | /// is being handed a set of principals they did not choose, at the one moment | ||
| 994 | /// renaming them is free — a file rename and a push — and before anything in | ||
| 995 | /// `conf/access.conf` has come to depend on them. | ||
| 996 | fn report_enrolment(enrolment: &Enrolment, dry_run: bool) { | ||
| 997 | let verb = if dry_run { "Would enrol" } else { "Enrolled" }; | ||
| 998 | |||
| 999 | if enrolment.declined > 0 { | ||
| 1000 | println!(); | ||
| 1001 | println!( | ||
| 1002 | "--no-enrol-existing: {} {} in {} {} NOT enrolled, and will be refused SSH", | ||
| 1003 | enrolment.declined, | ||
| 1004 | if enrolment.declined == 1 { | ||
| 1005 | "key" | ||
| 1006 | } else { | ||
| 1007 | "keys" | ||
| 1008 | }, | ||
| 1009 | enrolment.path.display(), | ||
| 1010 | if enrolment.declined == 1 { | ||
| 1011 | "was" | ||
| 1012 | } else { | ||
| 1013 | "were" | ||
| 1014 | }, | ||
| 1015 | ); | ||
| 1016 | println!("as soon as governance is in force. Only the admin identity above can connect."); | ||
| 1017 | } else if !enrolment.keys.is_empty() { | ||
| 1018 | println!(); | ||
| 1019 | println!( | ||
| 1020 | "{verb} {} {} from {}, so nobody loses access:", | ||
| 1021 | enrolment.keys.len(), | ||
| 1022 | if enrolment.keys.len() == 1 { | ||
| 1023 | "key" | ||
| 1024 | } else { | ||
| 1025 | "keys" | ||
| 1026 | }, | ||
| 1027 | enrolment.path.display() | ||
| 1028 | ); | ||
| 1029 | for key in &enrolment.keys { | ||
| 1030 | match key.naming { | ||
| 1031 | Naming::Comment => println!( | ||
| 1032 | " keydir/{}.pub (line {}, named after the entry's comment)", | ||
| 1033 | key.name, key.line | ||
| 1034 | ), | ||
| 1035 | Naming::Placeholder(reason) => { | ||
| 1036 | println!( | ||
| 1037 | " keydir/{}.pub (line {}, PROVISIONAL: {reason})", | ||
| 1038 | key.name, key.line | ||
| 1039 | ); | ||
| 1040 | println!(" {}", key.fingerprint); | ||
| 1041 | } | ||
| 1042 | } | ||
| 1043 | } | ||
| 1044 | if enrolment | ||
| 1045 | .keys | ||
| 1046 | .iter() | ||
| 1047 | .any(|key| matches!(key.naming, Naming::Placeholder(_))) | ||
| 1048 | { | ||
| 1049 | println!( | ||
| 1050 | " A provisional name is a working principal, not a right one. Rename the file \ | ||
| 1051 | and the" | ||
| 1052 | ); | ||
| 1053 | println!( | ||
| 1054 | " grants that use it before anyone comes to rely on it; match the key by the \ | ||
| 1055 | fingerprint above." | ||
| 1056 | ); | ||
| 1057 | } | ||
| 1058 | } | ||
| 1059 | |||
| 1060 | for note in &enrolment.notes { | ||
| 1061 | eprintln!("warning: {note}"); | ||
| 1062 | } | ||
| 1063 | } | ||
tests/common/mod.rs
| Old | New | ||
|---|---|---|---|
| @@ -1030,6 +1030,16 @@ pub struct ServerHarness { | |||
| 1030 | repo_name: String, | 1030 | repo_name: String, |
| 1031 | work_repo: TestRepo, | 1031 | work_repo: TestRepo, |
| 1032 | server: Child, | 1032 | server: Child, |
| 1033 | /// Everything the server has logged, drained continuously. | ||
| 1034 | /// | ||
| 1035 | /// Draining is not a convenience. The server's stdout and stderr are | ||
| 1036 | /// pipes, and a pipe holds ~64 KiB before a write to it blocks — so a | ||
| 1037 | /// server nobody reads stops answering partway through a test that makes | ||
| 1038 | /// enough requests, and does it at whatever operation happens to cross the | ||
| 1039 | /// line. That looks like the server hanging on *that* request, which is | ||
| 1040 | /// the wrong thing to go and debug. (Same failure as `Drain`'s pty case, | ||
| 1041 | /// at a different buffer size.) | ||
| 1042 | server_log: Arc<Mutex<Vec<u8>>>, | ||
| 1033 | http_addr: SocketAddr, | 1043 | http_addr: SocketAddr, |
| 1034 | ssh_addr: SocketAddr, | 1044 | ssh_addr: SocketAddr, |
| 1035 | } | 1045 | } |
| @@ -1087,13 +1097,21 @@ impl ServerHarness { | |||
| 1087 | .spawn() | 1097 | .spawn() |
| 1088 | .expect("failed to start git-collab-server"); | 1098 | .expect("failed to start git-collab-server"); |
| 1089 | 1099 | ||
| 1090 | match wait_until_ready(&mut server, http_addr, ssh_addr) { | 1100 | // Both streams, from the moment it starts: see `server_log`. The |
| 1101 | // `Drain` handles are dropped; each one's reader thread owns its | ||
| 1102 | // pipe and keeps draining until the server exits. | ||
| 1103 | Drain::start(server.stdout.take().expect("server stdout is piped")); | ||
| 1104 | let server_log = | ||
| 1105 | Drain::start(server.stderr.take().expect("server stderr is piped")).live(); | ||
| 1106 | |||
| 1107 | match wait_until_ready(&mut server, &server_log, http_addr, ssh_addr) { | ||
| 1091 | Ok(()) => { | 1108 | Ok(()) => { |
| 1092 | return Self { | 1109 | return Self { |
| 1093 | root, | 1110 | root, |
| 1094 | repo_name: repo_name.to_string(), | 1111 | repo_name: repo_name.to_string(), |
| 1095 | work_repo, | 1112 | work_repo, |
| 1096 | server, | 1113 | server, |
| 1114 | server_log, | ||
| 1097 | http_addr, | 1115 | http_addr, |
| 1098 | ssh_addr, | 1116 | ssh_addr, |
| 1099 | }; | 1117 | }; |
| @@ -1124,6 +1142,12 @@ impl ServerHarness { | |||
| 1124 | self.root.path().join("server.toml") | 1142 | self.root.path().join("server.toml") |
| 1125 | } | 1143 | } |
| 1126 | 1144 | ||
| 1145 | /// Everything the running server has logged to stderr so far, for a | ||
| 1146 | /// failure message that would otherwise say only "the push was refused". | ||
| 1147 | pub fn server_log(&self) -> String { | ||
| 1148 | String::from_utf8_lossy(&self.server_log.lock().unwrap()).to_string() | ||
| 1149 | } | ||
| 1150 | |||
| 1127 | /// A scratch directory under the harness root, for a test that needs | 1151 | /// A scratch directory under the harness root, for a test that needs |
| 1128 | /// somewhere to clone to or a file to feed a command. | 1152 | /// somewhere to clone to or a file to feed a command. |
| 1129 | pub fn scratch(&self, name: &str) -> PathBuf { | 1153 | pub fn scratch(&self, name: &str) -> PathBuf { |
| @@ -1195,6 +1219,26 @@ impl ServerHarness { | |||
| 1195 | key_path | 1219 | key_path |
| 1196 | } | 1220 | } |
| 1197 | 1221 | ||
| 1222 | /// The `authorized_keys` file the running server authenticates against | ||
| 1223 | /// while it is ungoverned, and that `setup` reads to decide who to enrol. | ||
| 1224 | pub fn authorized_keys_path(&self) -> PathBuf { | ||
| 1225 | self.root.path().join("authorized_keys") | ||
| 1226 | } | ||
| 1227 | |||
| 1228 | /// Put the given `named_key`s into `authorized_keys`, replacing whatever | ||
| 1229 | /// was there. Each entry keeps the key's own `ssh-keygen -C` comment, so a | ||
| 1230 | /// test sees the same trailing comment an operator's file would carry. | ||
| 1231 | pub fn authorize_named_keys(&self, names: &[&str]) { | ||
| 1232 | let mut content = String::new(); | ||
| 1233 | for name in names { | ||
| 1234 | let pubkey = | ||
| 1235 | std::fs::read_to_string(self.named_key(name).with_extension("pub")).unwrap(); | ||
| 1236 | content.push_str(pubkey.trim()); | ||
| 1237 | content.push('\n'); | ||
| 1238 | } | ||
| 1239 | std::fs::write(self.authorized_keys_path(), content).unwrap(); | ||
| 1240 | } | ||
| 1241 | |||
| 1198 | fn settings_bare(&self) -> PathBuf { | 1242 | fn settings_bare(&self) -> PathBuf { |
| 1199 | self.repos_dir().join("settings.git") | 1243 | self.repos_dir().join("settings.git") |
| 1200 | } | 1244 | } |
| @@ -1567,6 +1611,7 @@ impl ServerHarness { | |||
| 1567 | /// instead of just reporting an exit code. | 1611 | /// instead of just reporting an exit code. |
| 1568 | fn wait_until_ready( | 1612 | fn wait_until_ready( |
| 1569 | server: &mut Child, | 1613 | server: &mut Child, |
| 1614 | log: &Arc<Mutex<Vec<u8>>>, | ||
| 1570 | http_addr: SocketAddr, | 1615 | http_addr: SocketAddr, |
| 1571 | ssh_addr: SocketAddr, | 1616 | ssh_addr: SocketAddr, |
| 1572 | ) -> Result<(), String> { | 1617 | ) -> Result<(), String> { |
| @@ -1576,7 +1621,7 @@ fn wait_until_ready( | |||
| 1576 | return Err(format!( | 1621 | return Err(format!( |
| 1577 | "git-collab-server exited before becoming ready: exit status {:?}{}", | 1622 | "git-collab-server exited before becoming ready: exit status {:?}{}", |
| 1578 | status.code(), | 1623 | status.code(), |
| 1579 | server_stderr(server) | 1624 | server_stderr(log) |
| 1580 | )); | 1625 | )); |
| 1581 | } | 1626 | } |
| 1582 | 1627 | ||
| @@ -1591,7 +1636,7 @@ fn wait_until_ready( | |||
| 1591 | let _ = server.wait(); | 1636 | let _ = server.wait(); |
| 1592 | return Err(format!( | 1637 | return Err(format!( |
| 1593 | "timed out waiting for git-collab-server on http {http_addr} / ssh {ssh_addr}{}", | 1638 | "timed out waiting for git-collab-server on http {http_addr} / ssh {ssh_addr}{}", |
| 1594 | server_stderr(server) | 1639 | server_stderr(log) |
| 1595 | )); | 1640 | )); |
| 1596 | } | 1641 | } |
| 1597 | 1642 | ||
| @@ -1599,19 +1644,13 @@ fn wait_until_ready( | |||
| 1599 | } | 1644 | } |
| 1600 | } | 1645 | } |
| 1601 | 1646 | ||
| 1602 | /// Drain the (already exited or killed) server's stderr for error messages. | 1647 | /// What the server has logged to stderr so far, for an error message. |
| 1603 | fn server_stderr(server: &mut Child) -> String { | 1648 | fn server_stderr(log: &Arc<Mutex<Vec<u8>>>) -> String { |
| 1604 | match server.stderr.take() { | 1649 | let buf = String::from_utf8_lossy(&log.lock().unwrap()).to_string(); |
| 1605 | Some(mut stderr) => { | 1650 | if buf.trim().is_empty() { |
| 1606 | let mut buf = String::new(); | 1651 | "\nserver stderr: <empty>".to_string() |
| 1607 | let _ = stderr.read_to_string(&mut buf); | 1652 | } else { |
| 1608 | if buf.trim().is_empty() { | 1653 | format!("\nserver stderr:\n{buf}") |
| 1609 | "\nserver stderr: <empty>".to_string() | ||
| 1610 | } else { | ||
| 1611 | format!("\nserver stderr:\n{buf}") | ||
| 1612 | } | ||
| 1613 | } | ||
| 1614 | None => "\nserver stderr: <unavailable>".to_string(), | ||
| 1615 | } | 1654 | } |
| 1616 | } | 1655 | } |
| 1617 | 1656 | ||
tests/server_setup_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -593,6 +593,372 @@ fn a_key_on_stdin_with_no_name_is_refused() { | |||
| 593 | ); | 593 | ); |
| 594 | } | 594 | } |
| 595 | 595 | ||
| 596 | // --------------------------------------------------------------------------- | ||
| 597 | // Enrolling the keys that already have access | ||
| 598 | // --------------------------------------------------------------------------- | ||
| 599 | // | ||
| 600 | // `keydir/` supersedes `authorized_keys` the instant `settings.git` exists, so | ||
| 601 | // a one-key bootstrap would revoke SSH for everybody but the admin. Setup | ||
| 602 | // therefore enrols the file's entries, and these tests hold it to the same | ||
| 603 | // standard as the exposure test above: what each key could do before, it can | ||
| 604 | // do after. | ||
| 605 | |||
| 606 | /// The principal string the server derives from a public key, taken from | ||
| 607 | /// OpenSSH's own fingerprinting rather than from ours — an external oracle, | ||
| 608 | /// so a change to how we compute principals cannot quietly bless itself. | ||
| 609 | fn principal_of(pubkey: &Path) -> String { | ||
| 610 | let output = Command::new("ssh-keygen") | ||
| 611 | .args(["-lf", pubkey.to_str().unwrap()]) | ||
| 612 | .output() | ||
| 613 | .expect("failed to run ssh-keygen"); | ||
| 614 | assert!( | ||
| 615 | output.status.success(), | ||
| 616 | "ssh-keygen -lf failed: {}", | ||
| 617 | stderr(&output) | ||
| 618 | ); | ||
| 619 | let text = String::from_utf8(output.stdout).unwrap(); | ||
| 620 | let fingerprint = text | ||
| 621 | .split_whitespace() | ||
| 622 | .nth(1) | ||
| 623 | .expect("ssh-keygen -lf prints the fingerprint second"); | ||
| 624 | format!("key:{fingerprint}") | ||
| 625 | } | ||
| 626 | |||
| 627 | /// What one key can do to one repository, asked of the running server over | ||
| 628 | /// real SSH rather than of the code that decides it. | ||
| 629 | #[derive(Debug, PartialEq, Eq)] | ||
| 630 | struct Reach { | ||
| 631 | read: bool, | ||
| 632 | write: bool, | ||
| 633 | } | ||
| 634 | |||
| 635 | fn reach_of(harness: &ServerHarness, key_name: &str, repo: &str, phase: &str) -> Reach { | ||
| 636 | let key = harness.named_key(key_name); | ||
| 637 | let dir = harness.work_repo().dir.path(); | ||
| 638 | Reach { | ||
| 639 | read: harness.ssh_fetch(dir, &key, repo).status.success(), | ||
| 640 | write: harness | ||
| 641 | .ssh_push_from( | ||
| 642 | dir, | ||
| 643 | &key, | ||
| 644 | repo, | ||
| 645 | &format!("HEAD:refs/heads/probe-{phase}-{key_name}"), | ||
| 646 | ) | ||
| 647 | .status | ||
| 648 | .success(), | ||
| 649 | } | ||
| 650 | } | ||
| 651 | |||
| 652 | /// The whole who-can-do-what matrix, in a fixed order so before and after are | ||
| 653 | /// comparable as one value. | ||
| 654 | fn reach_matrix( | ||
| 655 | harness: &ServerHarness, | ||
| 656 | keys: &[&str], | ||
| 657 | repos: &[&str], | ||
| 658 | phase: &str, | ||
| 659 | ) -> Vec<(String, Reach)> { | ||
| 660 | let mut matrix = Vec::new(); | ||
| 661 | for key in keys { | ||
| 662 | for repo in repos { | ||
| 663 | matrix.push(( | ||
| 664 | format!("{key} -> {repo}"), | ||
| 665 | reach_of(harness, key, repo, phase), | ||
| 666 | )); | ||
| 667 | } | ||
| 668 | } | ||
| 669 | matrix | ||
| 670 | } | ||
| 671 | |||
| 672 | /// A server that is already in use: three keys in `authorized_keys`, and a | ||
| 673 | /// repository set that gives each of them a *different* reach. | ||
| 674 | /// | ||
| 675 | /// The differences are the point. A fixture where everyone could do everything | ||
| 676 | /// would be satisfied by a seed that granted everyone everything, which is the | ||
| 677 | /// widening the no-op principle forbids just as much as the lockout. | ||
| 678 | fn populated_server(keys: &[&str]) -> ServerHarness { | ||
| 679 | let harness = ServerHarness::new("hosted"); | ||
| 680 | let repos_dir = harness.repos_dir(); | ||
| 681 | |||
| 682 | // Generate the keys first: the rosters below name them by fingerprint, | ||
| 683 | // exactly as an operator's `server.toml` would. | ||
| 684 | let principals: Vec<String> = keys | ||
| 685 | .iter() | ||
| 686 | .map(|name| principal_of(&harness.named_key(name).with_extension("pub"))) | ||
| 687 | .collect(); | ||
| 688 | harness.authorize_named_keys(keys); | ||
| 689 | |||
| 690 | // Anyone who authenticates: the default, and the common case. | ||
| 691 | make_repo(&repos_dir, "open", ""); | ||
| 692 | // An explicit roster of one: only the second key, read and write. | ||
| 693 | make_repo( | ||
| 694 | &repos_dir, | ||
| 695 | "bob-only", | ||
| 696 | &format!( | ||
| 697 | "visibility = \"private\"\n[access]\nread = [{0:?}]\nwrite = [{0:?}]\n", | ||
| 698 | principals[1] | ||
| 699 | ), | ||
| 700 | ); | ||
| 701 | // Read for the third key, write for nobody at all. | ||
| 702 | make_repo( | ||
| 703 | &repos_dir, | ||
| 704 | "carol-reads", | ||
| 705 | &format!( | ||
| 706 | "visibility = \"private\"\n[access]\nread = [{:?}]\nwrite = []\n", | ||
| 707 | principals[2] | ||
| 708 | ), | ||
| 709 | ); | ||
| 710 | |||
| 711 | harness.work_repo().commit_file("a.txt", "one", "first"); | ||
| 712 | harness | ||
| 713 | } | ||
| 714 | |||
| 715 | /// **The test that matters.** Enabling governance does not change who can do | ||
| 716 | /// what over SSH, any more than it changes what is exposed over HTTP. | ||
| 717 | /// | ||
| 718 | /// Every key in `authorized_keys` is measured against every repository before | ||
| 719 | /// setup runs and again afterwards, through the real server. The two matrices | ||
| 720 | /// must be identical: no key loses access it had, and no key gains access it | ||
| 721 | /// did not. | ||
| 722 | #[test] | ||
| 723 | fn setup_preserves_exactly_what_every_authorized_key_could_already_do() { | ||
| 724 | let keys = ["alex", "bob", "carol"]; | ||
| 725 | let repos = ["open", "bob-only", "carol-reads"]; | ||
| 726 | let harness = populated_server(&keys); | ||
| 727 | |||
| 728 | let before = reach_matrix(&harness, &keys, &repos, "before"); | ||
| 729 | |||
| 730 | // The fixture is only worth anything if it is genuinely mixed: a matrix | ||
| 731 | // that was all-true or all-false would be preserved by a seed that got | ||
| 732 | // the whole question wrong. | ||
| 733 | assert!( | ||
| 734 | before.iter().any(|(_, r)| r.read) && before.iter().any(|(_, r)| !r.read), | ||
| 735 | "the fixture must contain both readable and unreadable pairs, got {before:?}" | ||
| 736 | ); | ||
| 737 | assert!( | ||
| 738 | before.iter().any(|(_, r)| r.write) && before.iter().any(|(_, r)| !r.write), | ||
| 739 | "the fixture must contain both writable and unwritable pairs, got {before:?}" | ||
| 740 | ); | ||
| 741 | |||
| 742 | let output = setup( | ||
| 743 | &harness.config_path(), | ||
| 744 | &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()], | ||
| 745 | ); | ||
| 746 | assert_ok(&output, "setup on a server with keys already in use"); | ||
| 747 | |||
| 748 | // Governance really is in force, or everything below is vacuous: an | ||
| 749 | // unenrolled key must now be refused outright. | ||
| 750 | assert!( | ||
| 751 | harness.repos_dir().join("settings.git").exists(), | ||
| 752 | "setup must create settings.git" | ||
| 753 | ); | ||
| 754 | let stranger = harness.named_key("stranger"); | ||
| 755 | assert!( | ||
| 756 | !harness | ||
| 757 | .ssh_fetch(harness.work_repo().dir.path(), &stranger, "open") | ||
| 758 | .status | ||
| 759 | .success(), | ||
| 760 | "a key that was never in authorized_keys must not authenticate under governance" | ||
| 761 | ); | ||
| 762 | |||
| 763 | let after = reach_matrix(&harness, &keys, &repos, "after"); | ||
| 764 | assert_eq!( | ||
| 765 | before, after, | ||
| 766 | "enabling governance changed what an already-authorized key can do" | ||
| 767 | ); | ||
| 768 | } | ||
| 769 | |||
| 770 | /// The admin's own key is normally already in `authorized_keys`, and one key | ||
| 771 | /// cannot be two principals: enrolling it again under a derived name would | ||
| 772 | /// make the roster ambiguous and abort the seed. | ||
| 773 | #[test] | ||
| 774 | fn the_admin_key_already_in_authorized_keys_is_enrolled_once_under_the_admin_name() { | ||
| 775 | let harness = populated_server(&["alex", "bob", "carol"]); | ||
| 776 | let output = setup( | ||
| 777 | &harness.config_path(), | ||
| 778 | &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()], | ||
| 779 | ); | ||
| 780 | assert_ok(&output, "setup"); | ||
| 781 | |||
| 782 | let files = git_in( | ||
| 783 | &harness.repos_dir().join("settings.git"), | ||
| 784 | &["show", "--name-only", "--format=", "HEAD"], | ||
| 785 | ); | ||
| 786 | let enrolled: Vec<&str> = files | ||
| 787 | .lines() | ||
| 788 | .filter(|line| line.starts_with("keydir/")) | ||
| 789 | .collect(); | ||
| 790 | assert!( | ||
| 791 | enrolled.contains(&"keydir/alex.pub"), | ||
| 792 | "the admin keeps the name it was given; got {enrolled:?}" | ||
| 793 | ); | ||
| 794 | assert_eq!( | ||
| 795 | enrolled.iter().filter(|path| path.contains("alex")).count(), | ||
| 796 | 1, | ||
| 797 | "the admin's key must be enrolled exactly once; got {enrolled:?}" | ||
| 798 | ); | ||
| 799 | assert_eq!( | ||
| 800 | enrolled.len(), | ||
| 801 | 3, | ||
| 802 | "one file per distinct key; got {enrolled:?}" | ||
| 803 | ); | ||
| 804 | } | ||
| 805 | |||
| 806 | /// An entry with no usable comment still gets in, and the operator is told | ||
| 807 | /// what it was called. A malformed entry is reported and skipped rather than | ||
| 808 | /// aborting a setup that would otherwise preserve everyone else's access. | ||
| 809 | #[test] | ||
| 810 | fn an_unnameable_or_malformed_entry_does_not_abort_the_setup_and_is_reported() { | ||
| 811 | let harness = ServerHarness::new("hosted"); | ||
| 812 | make_repo(&harness.repos_dir(), "open", ""); | ||
| 813 | |||
| 814 | // Built by hand, because these are the shapes a real file grows: a key | ||
| 815 | // with no comment at all, a comment that cannot be a principal name, and | ||
| 816 | // a line that is not a key. | ||
| 817 | let read = |name: &str| { | ||
| 818 | std::fs::read_to_string(harness.named_key(name).with_extension("pub")) | ||
| 819 | .unwrap() | ||
| 820 | .trim() | ||
| 821 | .to_string() | ||
| 822 | }; | ||
| 823 | let strip_comment = |line: &str| { | ||
| 824 | line.split_whitespace() | ||
| 825 | .take(2) | ||
| 826 | .collect::<Vec<_>>() | ||
| 827 | .join(" ") | ||
| 828 | }; | ||
| 829 | let bob = read("bob"); | ||
| 830 | let dave = strip_comment(&read("dave")); | ||
| 831 | let erin = format!("{} erin's spare laptop", strip_comment(&read("erin"))); | ||
| 832 | std::fs::write( | ||
| 833 | harness.authorized_keys_path(), | ||
| 834 | format!( | ||
| 835 | "# the operator's own notes\n\n{bob}\n{dave}\n{erin}\n\ | ||
| 836 | ssh-ed25519 not-actually-base64 broken@host\n" | ||
| 837 | ), | ||
| 838 | ) | ||
| 839 | .unwrap(); | ||
| 840 | |||
| 841 | let output = setup( | ||
| 842 | &harness.config_path(), | ||
| 843 | &["--admin-key", admin_key(&harness, "alex").to_str().unwrap()], | ||
| 844 | ); | ||
| 845 | assert_ok(&output, "setup with an unnameable and a malformed entry"); | ||
| 846 | |||
| 847 | let files = git_in( | ||
| 848 | &harness.repos_dir().join("settings.git"), | ||
| 849 | &["show", "--name-only", "--format=", "HEAD"], | ||
| 850 | ); | ||
| 851 | let enrolled: Vec<&str> = files | ||
| 852 | .lines() | ||
| 853 | .filter(|line| line.starts_with("keydir/")) | ||
| 854 | .collect(); | ||
| 855 | assert!( | ||
| 856 | enrolled.contains(&"keydir/bob@test.pub"), | ||
| 857 | "a comment that can name a principal should name it; got {enrolled:?}" | ||
| 858 | ); | ||
| 859 | assert_eq!( | ||
| 860 | enrolled.len(), | ||
| 861 | 4, | ||
| 862 | "the admin and the three well-formed entries; got {enrolled:?}" | ||
| 863 | ); | ||
| 864 | |||
| 865 | // The two that could not be named got provisional names, and the operator | ||
| 866 | // was told which key wears which. | ||
| 867 | let report = format!("{}{}", stdout(&output), stderr(&output)); | ||
| 868 | assert!( | ||
| 869 | enrolled.contains(&"keydir/key1.pub") && enrolled.contains(&"keydir/key2.pub"), | ||
| 870 | "an entry with no usable comment needs an obviously provisional name; got {enrolled:?}" | ||
| 871 | ); | ||
| 872 | for name in ["bob@test", "key1", "key2"] { | ||
| 873 | assert!( | ||
| 874 | report.contains(name), | ||
| 875 | "setup must report the name it derived for every enrolled key; {name} missing from:\n{report}" | ||
| 876 | ); | ||
| 877 | } | ||
| 878 | assert!( | ||
| 879 | report.contains(&principal_of( | ||
| 880 | &harness.named_key("dave").with_extension("pub") | ||
| 881 | )), | ||
| 882 | "a provisional name is only usable if the report says which key it is:\n{report}" | ||
| 883 | ); | ||
| 884 | assert!( | ||
| 885 | report.to_lowercase().contains("not a well-formed") | ||
| 886 | || report.to_lowercase().contains("malformed") | ||
| 887 | || report.to_lowercase().contains("could not be read"), | ||
| 888 | "the malformed entry must be reported, not silently dropped:\n{report}" | ||
| 889 | ); | ||
| 890 | |||
| 891 | // And the malformed line did not become a principal. | ||
| 892 | assert!( | ||
| 893 | !files.contains("broken"), | ||
| 894 | "a line that is not a key must not be enrolled; got {files}" | ||
| 895 | ); | ||
| 896 | } | ||
| 897 | |||
| 898 | /// The derived names are the thing an operator most wants to check before it | ||
| 899 | /// is too late to change them cheaply, so the dry run has to show them. | ||
| 900 | #[test] | ||
| 901 | fn the_dry_run_shows_the_derived_names_before_anything_is_written() { | ||
| 902 | let harness = populated_server(&["alex", "bob", "carol"]); | ||
| 903 | let output = setup( | ||
| 904 | &harness.config_path(), | ||
| 905 | &[ | ||
| 906 | "--admin-key", | ||
| 907 | admin_key(&harness, "alex").to_str().unwrap(), | ||
| 908 | "--dry-run", | ||
| 909 | ], | ||
| 910 | ); | ||
| 911 | assert_ok(&output, "a dry run"); | ||
| 912 | |||
| 913 | let report = format!("{}{}", stdout(&output), stderr(&output)); | ||
| 914 | for name in ["bob@test", "carol@test"] { | ||
| 915 | assert!( | ||
| 916 | report.contains(name), | ||
| 917 | "the dry run must show the name {name} it would enrol; got:\n{report}" | ||
| 918 | ); | ||
| 919 | } | ||
| 920 | assert!( | ||
| 921 | !harness.repos_dir().join("settings.git").exists(), | ||
| 922 | "a dry run must not create anything" | ||
| 923 | ); | ||
| 924 | } | ||
| 925 | |||
| 926 | /// The opt-out. Enrolment is the default because the safe answer should be, | ||
| 927 | /// but an operator who wants the clean single-admin bootstrap can say so. | ||
| 928 | #[test] | ||
| 929 | fn no_enrol_existing_gives_a_single_admin_bootstrap() { | ||
| 930 | let harness = populated_server(&["alex", "bob", "carol"]); | ||
| 931 | let output = setup( | ||
| 932 | &harness.config_path(), | ||
| 933 | &[ | ||
| 934 | "--admin-key", | ||
| 935 | admin_key(&harness, "alex").to_str().unwrap(), | ||
| 936 | "--no-enrol-existing", | ||
| 937 | ], | ||
| 938 | ); | ||
| 939 | assert_ok(&output, "setup with enrolment declined"); | ||
| 940 | |||
| 941 | let files = git_in( | ||
| 942 | &harness.repos_dir().join("settings.git"), | ||
| 943 | &["show", "--name-only", "--format=", "HEAD"], | ||
| 944 | ); | ||
| 945 | let mut paths: Vec<&str> = files.lines().filter(|l| !l.is_empty()).collect(); | ||
| 946 | paths.sort(); | ||
| 947 | assert_eq!( | ||
| 948 | paths, | ||
| 949 | vec!["conf/access.conf", "keydir/alex.pub"], | ||
| 950 | "declining enrolment must leave the admin alone in keydir/; got {files}" | ||
| 951 | ); | ||
| 952 | |||
| 953 | // And it says what that costs, because it is the lockout the default | ||
| 954 | // exists to avoid. | ||
| 955 | let report = format!("{}{}", stdout(&output), stderr(&output)); | ||
| 956 | assert!( | ||
| 957 | report.contains("2") && report.to_lowercase().contains("authorized_keys"), | ||
| 958 | "declining must say how many keys it just locked out; got:\n{report}" | ||
| 959 | ); | ||
| 960 | } | ||
| 961 | |||
| 596 | /// `setup` must never touch `authorized_keys`: leaving it alone is what lets | 962 | /// `setup` must never touch `authorized_keys`: leaving it alone is what lets |
| 597 | /// an operator turn governance back off by removing `settings.git` and find | 963 | /// an operator turn governance back off by removing `settings.git` and find |
| 598 | /// the server exactly as they left it. | 964 | /// the server exactly as they left it. |