Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
11 kB · 299 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300//! ssh push/fetch entry point: ForceCommand wrapper for the git user.//!//! the static serving path never accepts writes; this is the write path, and//! it is admin-adjacent by design: provisioning is an explicit action that//! happens to be triggered by a push (sorcery-style, see//! char.lt/blog/2026/09/sorcery-repo-viewer). the public deployment is as//! secure as its sshd.//!//! reads SSH_ORIGINAL_COMMAND (set by sshd for `command=` / ForceCommand//! sessions), understands the three git transport verbs, resolves the repo//! through the meta.git registry, auto-provisions unknown repos on//! receive-pack, and chains generate + activate after a successful push so//! records are republished and mirrors resync with no operator in the loop.
use std::process::Command;use std::sync::Arc;
use anyhow::Context as _;use knot_types::{OwnerDid, RepoDid, RepoRkey};use knot_index::Resolved;
use crate::config::ValidatedConfig;
struct GitCommand { verb: Verb, /// repo key as pushed: last path component, `.git` suffix stripped. rkey: String,}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]enum Verb { UploadPack, UploadArchive, ReceivePack,}
impl Verb { fn git_subcommand(self) -> &'static str { match self { Verb::UploadPack => "upload-pack", Verb::UploadArchive => "upload-archive", Verb::ReceivePack => "receive-pack", } }
fn is_write(self) -> bool { self == Verb::ReceivePack }}
/// parse `SSH_ORIGINAL_COMMAND` — e.g. `git-upload-pack '/ptr.pet/ark.git'`./// anything that is not exactly one of the three git transport verbs is/// refused; this program must never exec attacker-chosen commands.fn parse_original_command() -> anyhow::Result<GitCommand> { parse_command(&std::env::var("SSH_ORIGINAL_COMMAND").unwrap_or_default())}
fn parse_command(raw: &str) -> anyhow::Result<GitCommand> { let (head, rest) = raw .split_once(' ') .context("no git command in SSH_ORIGINAL_COMMAND (shell access is not provided)")?; // accept both sshd wire forms: `git-upload-pack '/rkey'` and `git upload-pack '/rkey'` let (verb_str, quoted_raw) = match head { "git" => rest .split_once(' ') .context("incomplete git command in SSH_ORIGINAL_COMMAND")?, verb => (verb, rest), }; // the space form spells the verb without the `git-` prefix let verb = match verb_str.trim_start_matches("git-") { "upload-pack" => Verb::UploadPack, "upload-archive" => Verb::UploadArchive, "receive-pack" => Verb::ReceivePack, other => anyhow::bail!("unsupported command {other:?}: only git fetch/push is served"), }; let quoted = quoted_raw.trim(); let repo_path = quoted .strip_prefix('\'') .and_then(|s| s.strip_suffix('\'')) .unwrap_or(quoted); let mut key = repo_path.trim_start_matches('/').trim_end_matches('/'); key = key.strip_suffix(".git").unwrap_or(key); let rkey = key.rsplit('/').next().context("empty repo path")?; if rkey.is_empty() { anyhow::bail!("empty repo path"); } Ok(GitCommand { verb, rkey: rkey.to_string(), })}
/// resolve (owner, rkey) through the registry index; the knot is/// single-owner, so the owner component of the push path (if any) is/// decorative and the configured owner is authoritative.async fn resolve_or_provision( config: &Arc<ValidatedConfig>, git_cmd: &GitCommand,) -> anyhow::Result<(RepoDid, knot_types::AccountDid)> { let layout = config.layout()?; let meta_path = layout.meta_path(&config.knot_did)?; let index = knot_index::Index::new(&meta_path, layout); index.rebuild().context("rebuild registry index")?;
// search all hosted repos in the index for a matching rkey for did in index.hosted_repos() { if let Resolved::Ready(Some(ownership)) = index.ownership_of(&did) { if ownership.rkey.as_str() == git_cmd.rkey { let owner_account = knot_types::AccountDid::new(ownership.owner.to_string()) .context("invalid owner DID in index")?; return Ok((did, owner_account)); } } }
anyhow::ensure!( git_cmd.verb.is_write(), "repo {} is not hosted on this knot (fetch of unknown repo)", git_cmd.rkey );
// first push: run the full provisioning transaction (mint repo DID, // registration + pending genesis journal, genesis PLC submission) before // receive-pack creates any refs. eprintln!("repo {} is new: provisioning (minting repo DID, submitting genesis)...", git_cmd.rkey); let did = crate::provision::create_for_push( config.clone(), git_cmd.rkey.clone(), None, ) .await?; Ok((did, config.owner.clone()))}
pub async fn run(config: Arc<ValidatedConfig>) -> anyhow::Result<()> { let git_cmd = parse_original_command()?;
// provisioning (write path only) happens before the transport runs so the // bare repo exists when receive-pack starts. let (repo_did, repo_owner) = resolve_or_provision(&config, &git_cmd).await?;
let layout = config.layout()?; let repo_path = layout .guarded_path(&repo_did) .with_context(|| format!("resolve guarded path for {repo_did}"))?; anyhow::ensure!( repo_path.exists(), "hosted repo {} missing on disk at {}", repo_did, repo_path.display() );
if git_cmd.verb.is_write() { // pre-hook: verify or run OAuth authorization for this repo's owner if let Err(e) = crate::oauth::ensure_auth_for_owner(&config, &repo_owner, true).await { eprintln!("knot-static: oauth pre-hook notice: {e:#}"); eprintln!("knot-static: push will continue, but sh.tangled.repo cannot be published automatically."); } }
let status = Command::new("git") .arg(git_cmd.verb.git_subcommand()) .arg(&repo_path) .status() .context("spawn git transport process")?;
if git_cmd.verb.is_write() && status.success() { // refs moved: compile a new generation and activate it. the activate // step republishes changed repos' owner records (the only resync // signal — the static knot emits no firehose) and pings configured // mirrors. output::lock serializes concurrent pushes. eprintln!("push accepted: compiling generation..."); match crate::generate::run(config.clone(), Some(repo_did.as_str())).await { Ok(generation) => { match crate::activate::activate(config.clone(), &generation).await { Ok(result) => { eprintln!("generation {} activated", result.generation); if !result.changed_repos.is_empty() { if let Err(e) = detach_post_activate(&config, &result.changed_repos) { eprintln!("warning: failed to detach post-activation tasks: {e:#}"); } } } Err(e) => { eprintln!("warning: activation failed after push: {e:#}"); } } } Err(e) => { eprintln!("warning: generation failed after push: {e:#}"); } } }
anyhow::ensure!(status.success(), "git {} exited with {status}", git_cmd.verb.git_subcommand()); Ok(())}
fn open_log_sink(config: &ValidatedConfig) -> std::fs::File { use std::path::PathBuf; let candidates = [ PathBuf::from("/var/log/knot-static/post-activate.log"), PathBuf::from("/var/log/knot-static.log"), config.output_root.join("logs/post-activate.log"), std::env::temp_dir().join("knot-static-post-activate.log"), ];
for path in &candidates { if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } if let Ok(file) = std::fs::OpenOptions::new() .create(true) .append(true) .open(path) { return file; } }
std::fs::OpenOptions::new() .write(true) .open("/dev/null") .expect("open /dev/null fallback")}
fn detach_post_activate(config: &ValidatedConfig, changed_repos: &[String]) -> anyhow::Result<()> { let exe = std::env::current_exe().context("resolve current executable")?;
// Check if systemd-cat is available and systemd journal is running let use_systemd_cat = std::path::Path::new("/run/systemd/journal/socket").exists() && Command::new("systemd-cat") .arg("--version") .output() .map(|o| o.status.success()) .unwrap_or(false);
let mut cmd = if use_systemd_cat { let mut c = Command::new("systemd-cat"); c.arg("-t").arg("knot-static"); c.arg(&exe); c } else { Command::new(&exe) };
cmd.arg("--config").arg(&config.config_path); cmd.arg("post-activate"); for repo in changed_repos { cmd.arg("--repo").arg(repo); }
cmd.stdin(std::process::Stdio::null());
if use_systemd_cat { cmd.stdout(std::process::Stdio::null()); cmd.stderr(std::process::Stdio::null()); } else { let sink = open_log_sink(config); let sink_out = sink.try_clone().context("clone log sink for stdout")?; cmd.stdout(sink_out); cmd.stderr(sink); }
#[cfg(unix)] { use std::os::unix::process::CommandExt; cmd.process_group(0); }
cmd.spawn().context("spawn background post-activate process")?; Ok(())}
#[cfg(test)]mod tests { use super::*;
#[test] fn parses_transport_verbs() { for (cmd, verb, rkey) in [ ("git-upload-pack '/ptr.pet/ark.git'", Verb::UploadPack, "ark"), ("git-upload-pack '/ark'", Verb::UploadPack, "ark"), ("git receive-pack '/knot-static.git'", Verb::ReceivePack, "knot-static"), ("git-upload-archive '/x/y/z.git'", Verb::UploadArchive, "z"), ] { let parsed = parse_command(cmd).unwrap_or_else(|e| panic!("{cmd}: {e}")); assert_eq!(parsed.verb, verb, "{cmd}"); assert_eq!(parsed.rkey, rkey, "{cmd}"); } }
#[test] fn rejects_non_git_commands() { for cmd in ["ls -la", "rm -rf /", "git push origin main", "echo hi", ""] { assert!(parse_command(cmd).is_err(), "{cmd} must be refused"); } }}