src/server/setup.rs
Ref: Size: 40.9 KiB History
//! `git-collab-server setup`: create `settings.git` and put governance in
//! force, the way `gitolite setup` creates `gitolite-admin`.
//!
//! Governance and the exposure model both shipped with nothing able to create
//! the repository that turns them on, so a deployment could run the code, host
//! repositories, and have no `settings.git` anywhere — governance present and
//! entirely inert. This is the missing piece.
//!
//! # Why this is a command and never a startup step
//!
//! Creating `settings.git` is precisely what puts the inverted default in
//! force: under governance a repository is unlisted and unreadable unless a
//! rule says otherwise. A server that seeded one on boot would, at the moment
//! it upgraded, make every repository invisible until somebody wrote rules —
//! the protection undone by the feature meant to make governance usable. So
//! the operator runs this, once, deliberately.
//!
//! # Why the seed reproduces the policy already in force
//!
//! The same reason, from the other end. If enabling governance changed what
//! the server exposes, no operator would dare enable it on anything live, and
//! a bootstrap nobody runs is not a bootstrap. So the seed reads each
//! repository's `server.toml` and writes the rule that means what it means
//! today, including `R = @anonymous` and `option listed = yes` where the
//! repository is public. Enabling governance is then behaviourally a no-op,
//! and `tests/server_setup_test.rs` asserts exactly that against a live
//! server.
//!
//! The claim is also checked here, at run time, rather than only in the test
//! suite: the generated rules are parsed and every repository's decision is
//! compared against the decision `server.toml` gives today. A mismatch is a
//! bug in this file, and it aborts rather than governing a server with rules
//! that mean something other than what it was told they mean.
//!
//! # Why the seed enrols the keys that already have access
//!
//! The same principle again, on the axis where it is easiest to get wrong.
//! `keydir/` supersedes `authorized_keys` the instant `settings.git` exists —
//! `auth_publickey` reads one roster or the other, never both — so a bootstrap
//! that enrolled only the administrator would revoke SSH for everybody else at
//! the moment it ran. That is not a smaller change than hiding every
//! repository; it is a larger one, and it is the change that locks the
//! operator out of the server they are configuring.
//!
//! So every well-formed entry in `authorized_keys` is enrolled, and the rules
//! translate the `server.toml` rosters that named those keys by fingerprint.
//! `--no-enrol-existing` asks for the clean single-administrator bootstrap
//! instead, and says how many keys that costs. The default is the safe one
//! because the two mistakes are not symmetric: an over-broad roster is a
//! `git rm` and a push, and a lockout may need the filesystem.
//!
//! # The one thing governance cannot say
//!
//! `server.toml` has two anonymous switches — `[ui] anonymous` and
//! `[http] anonymous_clone` — and governance has one grant, `R = @anonymous`,
//! covering both. A repository that has them set differently therefore has no
//! faithful translation. Setup writes the closed answer and says so, loudly
//! and by name, in the report and in a comment in the file: a bootstrap may
//! narrow exposure where it must, and must never widen it by guessing.
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use russh::keys::PublicKey;
use crate::governance::{
self,
conf::{Access, AccessConf, Subject},
keydir, SETTINGS_REPO,
};
use crate::repos::{self, RepoPolicy};
/// The branch the seed lands on. `governance::load` follows HEAD, so this is
/// a convention rather than a requirement — but a convention the operator
/// clones, so it should be the modern one.
const SEED_BRANCH: &str = "main";
/// Where the identity in `keydir/` came from. Reported, because the basename
/// *is* the principal: a wrong one locks the operator out of the repository
/// setup just created, and the failure would not show up until the first push.
enum NameSource {
Explicit,
Filename,
}
/// Run the command. Returns the process exit code.
pub fn run(
repos_dir: &Path,
authorized_keys: &Path,
admin_key: &str,
admin_name: Option<&str>,
enrol_existing: bool,
dry_run: bool,
) -> i32 {
match try_run(
repos_dir,
authorized_keys,
admin_key,
admin_name,
enrol_existing,
dry_run,
) {
Ok(()) => 0,
Err(reason) => {
eprintln!("error: {reason}");
1
}
}
}
fn try_run(
repos_dir: &Path,
authorized_keys: &Path,
admin_key: &str,
admin_name: Option<&str>,
enrol_existing: bool,
dry_run: bool,
) -> Result<(), String> {
if !repos_dir.is_dir() {
return Err(format!(
"repos_dir {} does not exist; nothing to govern",
repos_dir.display()
));
}
let settings_path = repos_dir.join(format!("{SETTINGS_REPO}.git"));
if settings_path.exists() {
return Err(format!(
"{} already exists; this server is already set up.\n \
Setup will not overwrite rules an operator wrote. Clone it and edit it, \
or move it aside if you meant to start again.",
settings_path.display()
));
}
let (key_text, key, name, name_source) = read_admin_key(admin_key, admin_name)?;
let admin_principal = crate::ssh::session::ssh_key_principal(&key);
// Read `authorized_keys` whether or not we are enrolling from it: the
// count is what makes declining an informed choice rather than a silent
// one.
let enrolment = Enrolment::plan(authorized_keys, &admin_principal, &name, enrol_existing);
let roster = enrolment.roster(&admin_principal, &name);
let entries = repos::discover(repos_dir)
.map_err(|e| format!("cannot read {}: {e}", repos_dir.display()))?;
// A directory literally named `settings` is the same name as the
// governance repository, and would collide with the block written for it.
if let Some(entry) = entries.iter().find(|entry| entry.name == SETTINGS_REPO) {
return Err(format!(
"{} is already a repository named {SETTINGS_REPO}, which is the name governance \
reserves for itself. Rename it before setting up.",
entry.path.display()
));
}
let plan = Plan::build(repos_dir, &entries, &name, &roster);
let conf_text = plan.render();
// Machine-check the safety claim before anything is written: the rules
// just generated must give every repository the decision it already has,
// to an anonymous request and to each key being enrolled.
verify(&conf_text, &plan, &roster)?;
if dry_run {
print!("{conf_text}");
println!();
report(&plan, &name, &name_source, &enrolment, &settings_path, true);
return Ok(());
}
seed(&settings_path, &conf_text, &name, &key_text, &enrolment)?;
report(
&plan,
&name,
&name_source,
&enrolment,
&settings_path,
false,
);
Ok(())
}
// ---------------------------------------------------------------------------
// The admin key
// ---------------------------------------------------------------------------
/// Read the admin key from a file or stdin, and work out the identity it
/// belongs to.
///
/// The name is the `keydir/` basename, which follows `gitolite setup -pk`:
/// the file you hand it names the principal. `-` has no filename to read, so
/// it needs `--admin-name` and says so rather than inventing something from
/// the key comment — a comment is free text an operator never chose as an
/// identity, and it is the kind of guess that is wrong exactly once.
fn read_admin_key(
admin_key: &str,
admin_name: Option<&str>,
) -> Result<(String, PublicKey, String, NameSource), String> {
let (text, from_filename) = if admin_key == "-" {
let mut buffer = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer)
.map_err(|e| format!("cannot read the admin key from stdin: {e}"))?;
(buffer, None)
} else {
let path = PathBuf::from(admin_key);
let text = std::fs::read_to_string(&path)
.map_err(|e| format!("cannot read the admin key from {}: {e}", path.display()))?;
let stem = path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.strip_suffix(".pub").unwrap_or(n).to_string());
(text, stem)
};
let text = text.trim().to_string();
if text.is_empty() {
return Err(format!(
"the admin key is empty; expected one OpenSSH public key \
(`ssh-ed25519 AAAA… you@host`) from {}",
if admin_key == "-" { "stdin" } else { admin_key }
));
}
let key = PublicKey::from_openssh(&text).map_err(|e| {
format!(
"the admin key is not a well-formed OpenSSH public key: {e}\n \
This is the contents of a `.pub` file, not a private key."
)
})?;
let (name, source) = match (admin_name, from_filename) {
(Some(name), _) => (name.to_string(), NameSource::Explicit),
(None, Some(stem)) => (stem, NameSource::Filename),
(None, None) => {
return Err(
"a key read from stdin has no filename to take an identity from; \
pass --admin-name <name> to say who it belongs to"
.to_string(),
)
}
};
// The basename is written straight into `conf/access.conf` as a
// principal, so it has to be a name that file can hold.
keydir::validate_name(&name).map_err(|reason| {
format!(
"{name:?} cannot name a principal: {reason}\n \
The identity is the key file's basename without `.pub`. \
Pass --admin-name <name> to choose it directly."
)
})?;
Ok((text, key, name, source))
}
// ---------------------------------------------------------------------------
// The keys that already have access
// ---------------------------------------------------------------------------
/// How an enrolled key got the name it got.
///
/// Reported for every key, because the basename *is* the principal: it goes
/// into `conf/access.conf`, and a name that is merely plausible is worse than
/// one that is obviously provisional. `key3` invites a rename; `alex` does not.
enum Naming {
/// The entry's trailing comment, used verbatim.
Comment,
/// A placeholder. The string says why the comment could not be used.
Placeholder(&'static str),
}
/// One `authorized_keys` entry, on its way into `keydir/`.
struct EnrolledKey {
name: String,
/// The OpenSSH line, written as `keydir/<name>.pub`. Kept verbatim so the
/// file in git is recognisably the line the operator had.
text: String,
fingerprint: String,
/// Which line of `authorized_keys` it came from, 1-based.
line: usize,
naming: Naming,
}
/// What `authorized_keys` yielded.
struct Enrolment {
path: PathBuf,
keys: Vec<EnrolledKey>,
/// Entries that were read but not enrolled, each with the reason. Never
/// fatal: one unusable line must not cost everybody else their access.
notes: Vec<String>,
/// Well-formed entries left out because `--no-enrol-existing` was given.
/// This is the size of the lockout the operator asked for.
declined: usize,
}
/// A candidate entry, before names are settled.
struct Candidate {
line: usize,
text: String,
fingerprint: String,
/// The trailing comment, if the entry has one that could be a name.
wanted: Option<String>,
/// Why it could not be, if it could not.
refusal: Option<&'static str>,
}
impl Enrolment {
/// Read `authorized_keys` and decide who is enrolled under what name.
///
/// Nothing here is fatal. A file that does not exist, a line that is not a
/// key, an entry carrying `command=` restrictions — each is reported and
/// skipped, because the purpose of this pass is to *keep* access, and
/// aborting over one bad line would keep none of it.
fn plan(path: &Path, admin_fingerprint: &str, admin_name: &str, enrol: bool) -> Enrolment {
let mut enrolment = Enrolment {
path: path.to_path_buf(),
keys: Vec::new(),
notes: Vec::new(),
declined: 0,
};
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Not an error: a server that has never had one has nobody to
// lock out.
return enrolment;
}
Err(e) => {
enrolment.notes.push(format!(
"{} could not be read ({e}), so no existing key was enrolled. \
Every key it holds will be refused SSH once governance is on.",
path.display()
));
return enrolment;
}
};
let candidates = enrolment.read_entries(&text, admin_fingerprint);
if !enrol {
enrolment.declined = candidates.len();
return enrolment;
}
enrolment.keys = assign_names(candidates, admin_name);
enrolment
}
/// Parse the file into candidates, recording every entry it had to drop.
fn read_entries(&mut self, text: &str, admin_fingerprint: &str) -> Vec<Candidate> {
let mut candidates: Vec<Candidate> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for (index, raw) in text.lines().enumerate() {
let line = index + 1;
let entry = raw.trim();
if entry.is_empty() || entry.starts_with('#') {
continue;
}
// An entry with options (`command="…",no-pty ssh-ed25519 …`) does
// not parse here — and does not authenticate today either, because
// `is_authorized` compares the first field against the key type.
// Skipping it is therefore faithful, not a narrowing.
let key = match PublicKey::from_openssh(entry) {
Ok(key) => key,
Err(e) => {
self.notes.push(format!(
"{}:{line} is not a well-formed OpenSSH public key ({e}), so it was not \
enrolled. An entry carrying `command=` or other options looks like this \
too, and does not authenticate today either.",
self.path.display()
));
continue;
}
};
let fingerprint = crate::ssh::session::ssh_key_principal(&key);
if fingerprint == admin_fingerprint {
self.notes.push(format!(
"{}:{line} is the administrator's own key, already enrolled under the name \
--admin-key gave it. One key cannot be two principals, so it was not \
enrolled a second time.",
self.path.display()
));
continue;
}
if !seen.insert(fingerprint.clone()) {
self.notes.push(format!(
"{}:{line} repeats a key that appears earlier in the file; enrolled once.",
self.path.display()
));
continue;
}
let comment = comment_of(entry).to_string();
let (wanted, refusal) = if comment.is_empty() {
(None, Some("the entry has no comment to take a name from"))
} else if keydir::validate_name(&comment).is_err() {
(
None,
Some("its comment cannot be written as a principal name"),
)
} else {
(Some(comment), None)
};
candidates.push(Candidate {
line,
text: entry.to_string(),
fingerprint,
wanted,
refusal,
});
}
candidates
}
/// Fingerprint-to-name for every identity these rules may mention: the
/// administrator, and each key being enrolled.
///
/// This is exactly the set of `server.toml` roster entries that can be
/// translated at all — a fingerprint outside it has no name to write.
fn roster(&self, admin_fingerprint: &str, admin_name: &str) -> Roster {
let mut roster = vec![(admin_fingerprint.to_string(), admin_name.to_string())];
for key in &self.keys {
roster.push((key.fingerprint.clone(), key.name.clone()));
}
Roster(roster)
}
}
/// The trailing comment of an `authorized_keys` entry: everything after the
/// key type and the key data.
///
/// Taken from the line rather than from the parsed key, so that "the comment"
/// means here what it means in `ssh::auth`, which splits the same way. A
/// comment is free text and may hold spaces; whether it can be a name is a
/// separate question, asked next.
fn comment_of(entry: &str) -> &str {
let after_type = match entry.trim().split_once(char::is_whitespace) {
Some((_, rest)) => rest.trim_start(),
None => return "",
};
match after_type.split_once(char::is_whitespace) {
Some((_, comment)) => comment.trim(),
None => "",
}
}
/// Settle a name on every candidate.
///
/// The comment is used **verbatim** when it can be a principal name, and never
/// cut down to a first component. Turning `alex@laptop` into `alex` would be a
/// guess, and the way that guess fails is the expensive way: two entries the
/// operator listed separately — `alex@laptop`, `alex@desktop` — collapse into
/// one principal, and a rule written for one silently covers the other. A
/// bootstrap may narrow access where it must; it must never widen it by
/// inference.
///
/// For the same reason a name two entries both claim is given to neither. One
/// of them winning by file order would put a plausible name on the wrong key,
/// which is the mistake that survives review; `key1` and `key2` do not.
fn assign_names(candidates: Vec<Candidate>, admin_name: &str) -> Vec<EnrolledKey> {
let mut claimed: HashSet<&str> = HashSet::new();
let mut contested: HashSet<&str> = HashSet::new();
for candidate in &candidates {
if let Some(name) = candidate.wanted.as_deref() {
if !claimed.insert(name) {
contested.insert(name);
}
}
}
let mut taken: HashSet<String> = HashSet::new();
taken.insert(admin_name.to_string());
let mut next_placeholder = 1usize;
let mut enrolled = Vec::new();
for candidate in &candidates {
let refusal = match candidate.wanted.as_deref() {
Some(name) if contested.contains(name) => {
Some("the same comment appears on more than one entry")
}
Some(name) if name == admin_name => {
Some("its comment is the name the administrator already holds")
}
Some(_) => None,
None => candidate.refusal,
};
let (name, naming) = match (candidate.wanted.as_deref(), refusal) {
(Some(name), None) => (name.to_string(), Naming::Comment),
(_, reason) => {
let mut placeholder = format!("key{next_placeholder}");
while taken.contains(&placeholder) {
next_placeholder += 1;
placeholder = format!("key{next_placeholder}");
}
next_placeholder += 1;
(
placeholder,
Naming::Placeholder(reason.unwrap_or("its comment could not name a principal")),
)
}
};
taken.insert(name.clone());
enrolled.push(EnrolledKey {
name,
text: candidate.text.clone(),
fingerprint: candidate.fingerprint.clone(),
line: candidate.line,
naming,
});
}
enrolled
}
/// Fingerprint-to-name for the identities this seed enrols.
struct Roster(Vec<(String, String)>);
impl Roster {
fn name_for(&self, fingerprint: &str) -> Option<&str> {
self.0
.iter()
.find(|(f, _)| f == fingerprint)
.map(|(_, name)| name.as_str())
}
fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.0.iter().map(|(f, n)| (f.as_str(), n.as_str()))
}
}
// ---------------------------------------------------------------------------
// Translating the policy already in force
// ---------------------------------------------------------------------------
/// What one repository's `server.toml` says, and the rules that mean the same.
struct RepoPlan {
/// The key rules are matched against: the path under `repos_dir` with one
/// `.git` removed. The same string `repo_key` gives the request path.
key: String,
/// Rules, already rendered as `(lhs, rhs)` pairs.
rules: Vec<(String, String)>,
/// `option listed = yes`.
listed: bool,
/// What an anonymous request may do today, and so must still do.
anonymous_read: bool,
/// Notes to write into the file above the block, and to report.
notes: Vec<String>,
/// The policy these rules were translated from, kept so `verify` can ask
/// it the same questions it asks the rule engine.
policy: RepoPolicy,
}
struct Plan {
admin: String,
repos: Vec<RepoPlan>,
/// Repositories no rule could be written for at all, and why.
skipped: Vec<String>,
/// Every place the seed could not say what `server.toml` says, and so
/// said less. Empty is the good answer, and the usual one; anything here
/// is a decision the operator now has to make deliberately.
narrowed: Vec<String>,
}
impl Plan {
fn build(repos_dir: &Path, entries: &[repos::RepoEntry], admin: &str, roster: &Roster) -> Plan {
let mut plan = Plan {
admin: admin.to_string(),
repos: Vec::new(),
skipped: Vec::new(),
narrowed: Vec::new(),
};
for entry in entries {
let Some(key) = governance::repo_key(repos_dir, &entry.path) else {
plan.skipped.push(format!(
"{}: not a name that can appear in the rules",
entry.path.display()
));
continue;
};
// A name carrying regex metacharacters would compile to a wild
// pattern and quietly govern repositories other than this one.
if !AccessConf::is_literal_repo_name(&key) {
// Left out, so governance closes it. Say plainly whether that
// is a change anyone will notice: this is the one place the
// seed cannot keep its no-op promise, and burying it in a
// sentence about rule syntax would be the wrong emphasis.
let visible = if entry.policy.allows_anonymous_ui() {
"It is public today, so it will disappear from the repository list \
until you do."
} else {
"It is not on the anonymous surface today, so nothing visible changes."
};
plan.skipped.push(format!(
"{key}: this name cannot be written as a literal rule (it would compile \
to a pattern matching other repositories), so no block was generated \
for it and governance closes it to everyone.\n{visible}"
));
continue;
}
plan.repos
.push(RepoPlan::build(&key, &entry.policy, roster));
}
for repo in &plan.repos {
for note in &repo.notes {
plan.narrowed.push(format!("{}: {note}", repo.key));
}
}
plan
}
/// The whole `conf/access.conf`.
fn render(&self) -> String {
let mut out = String::new();
out.push_str(
"# conf/access.conf — the rules that govern this server.\n\
#\n\
# Generated by `git-collab-server setup` from the policy that was already in\n\
# force: every block below reproduces what the repository's server.toml grants\n\
# today, so turning governance on changed nothing. From here on this file is\n\
# the authority on access, and server.toml's [access], visibility, [ui] and\n\
# [http] keys are no longer consulted.\n\
#\n\
# Push to this repository to change it. The push is validated first, and one\n\
# that would lock everyone out is refused.\n\n",
);
out.push_str(&format!("repo {SETTINGS_REPO}\n"));
out.push_str(
" # Whoever holds this may rewrite the rules, so it is deliberately one\n\
\x20 # name rather than a group. No anonymous grant: the key roster lives\n\
\x20 # here, and governance is unlisted and unreadable until a rule says\n\
\x20 # otherwise.\n",
);
out.push_str(&rule("RW+", &self.admin));
for repo in &self.repos {
out.push('\n');
out.push_str(&format!("repo {}\n", repo.key));
for note in &repo.notes {
for line in note.lines() {
out.push_str(&format!(" # {line}\n"));
}
}
for (lhs, rhs) in &repo.rules {
out.push_str(&rule(lhs, rhs));
}
if repo.listed {
out.push_str(&rule("option listed", "yes"));
}
}
out
}
}
/// One rule line, in the column layout the design's examples use.
fn rule(lhs: &str, rhs: &str) -> String {
format!(" {lhs:<21} = {rhs}\n")
}
impl RepoPlan {
fn build(key: &str, policy: &RepoPolicy, roster: &Roster) -> RepoPlan {
let mut rules = Vec::new();
let mut notes = Vec::new();
// --- The authenticated axis -------------------------------------
//
// `*` in server.toml means "any principal that authenticated at all",
// and `@all` means "every key enrolled in keydir/", which is the same
// sentence in the new language *because* every key that could
// authenticate is being enrolled. A force-push was never separately
// gated before governance, so a writer maps to RW+.
let read_all = policy.access.read.iter().any(|entry| entry == "*");
let write_all = policy.access.write.iter().any(|entry| entry == "*");
// An explicit roster names fingerprints, and rules name keydir
// identities. The ones this command enrols can be translated; a
// fingerprint belonging to no enrolled key has no name to write, and
// is recorded so the operator can enrol and grant it.
let mut untranslatable: Vec<&str> = policy
.access
.read
.iter()
.chain(policy.access.write.iter())
.map(String::as_str)
.filter(|entry| *entry != "*" && roster.name_for(entry).is_none())
.collect();
untranslatable.sort();
untranslatable.dedup();
// A fingerprint cannot appear in a rule, so each roster entry named by
// `server.toml` is written under the name it is being enrolled with.
// Nothing is emitted where `@all` below already says the same thing.
for (fingerprint, name) in roster.iter() {
if !write_all && policy.allows_write(fingerprint) {
rules.push(("RW+".to_string(), name.to_string()));
} else if !read_all && policy.allows_read(fingerprint) {
rules.push(("R".to_string(), name.to_string()));
}
}
if write_all {
rules.push(("RW+".to_string(), "@all".to_string()));
} else if read_all {
rules.push(("R".to_string(), "@all".to_string()));
}
if !untranslatable.is_empty() {
notes.push(format!(
"server.toml granted access to {} by key fingerprint, and rules name\n\
keydir identities instead. Enrol each key as keydir/<name>.pub and add\n\
the grant here; until then those keys cannot reach this repository:\n {}",
if untranslatable.len() == 1 {
"a key".to_string()
} else {
format!("{} keys", untranslatable.len())
},
untranslatable.join("\n ")
));
}
// --- The anonymous axis -----------------------------------------
//
// Two switches upstream, one grant here. Where they agree the
// translation is exact; where they disagree there is no rule that
// means what server.toml means, so the closed answer is written and
// named. Narrowing is a thing a bootstrap may do; widening is not.
let ui = policy.allows_anonymous_ui();
let http = policy.allows_anonymous_http();
let anonymous_read = ui && http;
if ui != http {
notes.push(format!(
"server.toml sets [ui] anonymous = {ui} but [http] anonymous_clone = {http},\n\
and governance has one anonymous read grant covering both. No rule can\n\
mean that, so none was written and this repository is now closed to\n\
anonymous requests. Add `R = @anonymous` (and `option listed = yes`) to\n\
publish it deliberately."
));
}
if anonymous_read {
rules.push(("R".to_string(), "@anonymous".to_string()));
}
RepoPlan {
key: key.to_string(),
rules,
listed: anonymous_read,
anonymous_read,
notes,
policy: policy.clone(),
}
}
}
// ---------------------------------------------------------------------------
// The self-check
// ---------------------------------------------------------------------------
/// Parse what was generated and confirm it decides every repository the way
/// the plan says it should — for an anonymous request, and for every key being
/// enrolled.
///
/// This is the safety claim, checked against the real evaluator rather than
/// against a second copy of the intent. If it ever fails, this file has a bug
/// and the right thing to do is refuse to govern the server with it.
fn verify(conf_text: &str, plan: &Plan, roster: &Roster) -> Result<(), String> {
let conf = AccessConf::parse(conf_text).map_err(|e| {
format!(
"the generated rules do not parse ({e}); refusing to write them. \
This is a bug in `setup`."
)
})?;
for repo in &plan.repos {
let anonymous_read = conf.anonymous_may_read(&repo.key);
let listed = conf.is_listed(&repo.key);
if anonymous_read != repo.anonymous_read || listed != repo.listed {
return Err(format!(
"the generated rules would expose {} differently from its server.toml \
(anonymous read {} vs {}, listed {} vs {}); refusing to write them. \
This is a bug in `setup`.",
repo.key, anonymous_read, repo.anonymous_read, listed, repo.listed
));
}
}
// The other half of the same claim: every key that could authenticate
// before must reach exactly the repositories it reached before. Asked of
// the rule engine, against the `server.toml` decision it is replacing.
//
// Rewind is compared against the write decision deliberately. Nothing
// before governance gated a force-push separately from a push, so a
// writer maps to RW+, and this is where that equivalence is checked
// rather than assumed.
for repo in &plan.repos {
for (fingerprint, name) in roster.iter() {
let subject = Subject::new(name);
for (access, expected) in [
(Access::Read, repo.policy.allows_read(fingerprint)),
(Access::Write, repo.policy.allows_write(fingerprint)),
(Access::Rewind, repo.policy.allows_write(fingerprint)),
] {
let granted = conf.allows_repo(&repo.key, &subject, access);
if granted != expected {
return Err(format!(
"the generated rules would give {name} {access:?} on {} where its \
server.toml gives {expected} (rules say {granted}); refusing to write \
them. This is a bug in `setup`.",
repo.key
));
}
}
}
}
// The settings repository must stay off the anonymous surface: the whole
// key roster is in it.
if conf.anonymous_may_read(SETTINGS_REPO) || conf.is_listed(SETTINGS_REPO) {
return Err(format!(
"the generated rules would publish {SETTINGS_REPO}, which holds the key \
roster; refusing to write them. This is a bug in `setup`."
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Writing it
// ---------------------------------------------------------------------------
/// Create `settings.git` and land both files in a single commit.
///
/// One commit, not two: the push-time guard refuses a roster with no rules,
/// because that state governs nothing while publishing who has access to the
/// server. Setup must not create by hand the very thing that guard exists to
/// prevent — so the tree is assembled whole, put through the same validation a
/// push would face, and only then committed.
fn seed(
settings_path: &Path,
conf_text: &str,
admin_name: &str,
key_text: &str,
enrolment: &Enrolment,
) -> Result<(), String> {
let mut options = git2::RepositoryInitOptions::new();
options.bare(true).initial_head(SEED_BRANCH);
let repo = git2::Repository::init_opts(settings_path, &options)
.map_err(|e| format!("cannot create {}: {e}", settings_path.display()))?;
// From here on a failure leaves a half-made repository, which would make
// the server think it is governed by nothing. Clean it up on every exit.
match build_and_commit(&repo, conf_text, admin_name, key_text, enrolment) {
Ok(()) => Ok(()),
Err(reason) => {
drop(repo);
let _ = std::fs::remove_dir_all(settings_path);
Err(reason)
}
}
}
fn build_and_commit(
repo: &git2::Repository,
conf_text: &str,
admin_name: &str,
key_text: &str,
enrolment: &Enrolment,
) -> Result<(), String> {
let git = |e: git2::Error| format!("building the seed commit: {e}");
let conf_blob = repo.blob(conf_text.as_bytes()).map_err(git)?;
let mut conf_dir = repo.treebuilder(None).map_err(git)?;
conf_dir
.insert("access.conf", conf_blob, 0o100644)
.map_err(git)?;
let conf_tree = conf_dir.write().map_err(git)?;
let mut keydir_tree = repo.treebuilder(None).map_err(git)?;
for (name, text) in std::iter::once((admin_name, key_text)).chain(
enrolment
.keys
.iter()
.map(|key| (key.name.as_str(), key.text.as_str())),
) {
let blob = repo
.blob(format!("{}\n", text.trim()).as_bytes())
.map_err(git)?;
keydir_tree
.insert(format!("{name}.pub"), blob, 0o100644)
.map_err(git)?;
}
let keydir_tree = keydir_tree.write().map_err(git)?;
let mut root = repo.treebuilder(None).map_err(git)?;
root.insert("conf", conf_tree, 0o040000).map_err(git)?;
root.insert("keydir", keydir_tree, 0o040000).map_err(git)?;
let tree = repo.find_tree(root.write().map_err(git)?).map_err(git)?;
// Exactly the checks a push to this repository would face: the rules
// parse, somebody is enrolled, and somebody still holds RW+ on settings.
// Passing them is what makes the seed a configuration the server would
// have accepted from an operator.
let landing_ref = format!("refs/heads/{SEED_BRANCH}");
governance::check_roster_has_rules(&tree)?;
governance::validate_settings_tree(repo, &tree, &landing_ref)?;
let who = git2::Signature::now("git-collab-server setup", "setup@git-collab").map_err(git)?;
repo.commit(
Some(&landing_ref),
&who,
&who,
"Seed governance from the policy already in force\n\n\
Generated by `git-collab-server setup`. Every block reproduces what the\n\
repository's server.toml granted at this moment, so enabling governance\n\
changed nothing about what this server exposes.\n",
&tree,
&[],
)
.map_err(git)?;
Ok(())
}
// ---------------------------------------------------------------------------
// The report
// ---------------------------------------------------------------------------
fn report(
plan: &Plan,
name: &str,
source: &NameSource,
enrolment: &Enrolment,
settings_path: &Path,
dry_run: bool,
) {
let derived = match source {
NameSource::Explicit => "from --admin-name",
NameSource::Filename => "from the key file's name",
};
if dry_run {
println!("Dry run: nothing was written.");
println!("Would create {}", settings_path.display());
} else {
println!("Created {}", settings_path.display());
}
println!("Admin identity: {name} ({derived}, enrolled as keydir/{name}.pub)");
println!(
" Rules are written against this name, and it holds RW+ on `{SETTINGS_REPO}`. \
If it is wrong, nobody can push the rules."
);
println!(
"Reproduced the policy of {} {}.",
plan.repos.len(),
if plan.repos.len() == 1 {
"repository"
} else {
"repositories"
}
);
report_enrolment(enrolment, dry_run);
for note in plan.narrowed.iter().chain(plan.skipped.iter()) {
let mut lines = note.lines();
if let Some(first) = lines.next() {
eprintln!("warning: {first}");
}
for line in lines {
eprintln!(" {line}");
}
}
if !dry_run {
println!();
println!("Governance is now in force. `authorized_keys` was not touched, and is");
println!("no longer consulted: only keys in keydir/ authenticate. Add anyone else by");
println!(
"cloning {}, adding keydir/<name>.pub",
settings_path.display()
);
println!("and a grant in conf/access.conf, and pushing both together.");
}
}
/// Say what happened to `authorized_keys`, by name.
///
/// Every derived name is printed, not just the surprising ones. The operator
/// is being handed a set of principals they did not choose, at the one moment
/// renaming them is free — a file rename and a push — and before anything in
/// `conf/access.conf` has come to depend on them.
fn report_enrolment(enrolment: &Enrolment, dry_run: bool) {
let verb = if dry_run { "Would enrol" } else { "Enrolled" };
if enrolment.declined > 0 {
println!();
println!(
"--no-enrol-existing: {} {} in {} {} NOT enrolled, and will be refused SSH",
enrolment.declined,
if enrolment.declined == 1 {
"key"
} else {
"keys"
},
enrolment.path.display(),
if enrolment.declined == 1 {
"was"
} else {
"were"
},
);
println!("as soon as governance is in force. Only the admin identity above can connect.");
} else if !enrolment.keys.is_empty() {
println!();
println!(
"{verb} {} {} from {}, so nobody loses access:",
enrolment.keys.len(),
if enrolment.keys.len() == 1 {
"key"
} else {
"keys"
},
enrolment.path.display()
);
for key in &enrolment.keys {
match key.naming {
Naming::Comment => println!(
" keydir/{}.pub (line {}, named after the entry's comment)",
key.name, key.line
),
Naming::Placeholder(reason) => {
println!(
" keydir/{}.pub (line {}, PROVISIONAL: {reason})",
key.name, key.line
);
println!(" {}", key.fingerprint);
}
}
}
if enrolment
.keys
.iter()
.any(|key| matches!(key.naming, Naming::Placeholder(_)))
{
println!(
" A provisional name is a working principal, not a right one. Rename the file \
and the"
);
println!(
" grants that use it before anyone comes to rely on it; match the key by the \
fingerprint above."
);
}
}
for note in &enrolment.notes {
eprintln!("warning: {note}");
}
}