don't
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(knot): hooks!

Signed-off-by: tjh <did:plc:65gha4t3avpfpzmvpbwovss7>

+865 -15
+6 -2
.gitignore
··· 1 1 /target 2 - did*/ 3 - jetstream.json 2 + /hooks 3 + /allowed_signers 4 + /did*/ 5 + /deleted/** 6 + /jetstream.json 7 + /git_config
+3
Cargo.lock
··· 2712 2712 "async-trait", 2713 2713 "atproto", 2714 2714 "auth", 2715 + "aws-lc-rs", 2715 2716 "axum", 2716 2717 "axum-extra", 2717 2718 "bytes", 2718 2719 "clap", 2719 2720 "data-encoding", 2721 + "data-encoding-macro", 2720 2722 "futures-util", 2721 2723 "gix", 2722 2724 "http-body-util", ··· 2726 2724 "identity", 2727 2725 "jetstream", 2728 2726 "lexicon", 2727 + "rand 0.9.2", 2729 2728 "rayon", 2730 2729 "reqwest", 2731 2730 "rustc-hash",
+3
crates/knot/Cargo.toml
··· 27 27 url.workspace = true 28 28 29 29 async-trait = "0.1.89" 30 + aws-lc-rs = { version = "1.14.1", default-features = false, features = ["alloc", "aws-lc-sys"] } 30 31 axum = { workspace = true, features = ["ws"] } 31 32 axum-extra = { version = "0.12.1", features = ["async-read-body"] } 32 33 bytes = "1.10.1" 33 34 clap = { version = "4.5.47", features = ["derive", "env", "string"] } 34 35 data-encoding.workspace = true 36 + data-encoding-macro = "0.1.18" 35 37 futures-util = "0.3.31" 36 38 hyper-util = { version = "0.1.17", features = ["client"] } 39 + rand = "0.9.2" 37 40 rayon = "1.11.0" 38 41 rustc-hash = "2.1.1" 39 42 sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-native-tls", "postgres", "time", "json", "macros", "derive"] }
+133
crates/knot/src/hooks.rs
··· 1 + use core::fmt; 2 + use std::{ 3 + collections::HashMap, 4 + env, fs, 5 + io::{self, Write}, 6 + path::Path, 7 + }; 8 + 9 + use atproto::Did; 10 + use axum::http::{HeaderMap, HeaderName, header::InvalidHeaderName}; 11 + use bytes::Bytes; 12 + use knot::private::{self, Hook}; 13 + use url::Url; 14 + 15 + /// Setup the global hooks directory at `path`. 16 + /// 17 + /// Currently, this just creates symlinks to the currently running executable for the 18 + /// hook names returned by [`Hook::iter_names()`]. 19 + /// 20 + pub fn setup_global_hooks<P: AsRef<Path>>(path: P) -> io::Result<()> { 21 + let current_exe = env::current_exe()?; 22 + let _ = fs::create_dir_all(&path); 23 + for hook_name in Hook::iter_names() { 24 + let hook_path = path.as_ref().join(hook_name); 25 + update_symlink(hook_path, &current_exe)?; 26 + } 27 + Ok(()) 28 + } 29 + 30 + /// Creates or updates a symlink pointing to the specified target. 31 + /// 32 + /// # Errors 33 + /// 34 + /// Will return an error in the following situations, but is not limited to just these 35 + /// cases: 36 + /// 37 + /// * If the path `symlink` already exists, but is not a symlink. 38 + /// 39 + fn update_symlink<P: AsRef<Path> + fmt::Debug, Q: AsRef<Path>>( 40 + symlink: P, 41 + target: Q, 42 + ) -> io::Result<fs::Metadata> { 43 + match fs::symlink_metadata(&symlink) { 44 + Ok(attr) if attr.is_symlink() => { 45 + fs::remove_file(&symlink)?; 46 + } 47 + Ok(_) => Err(io::Error::new( 48 + io::ErrorKind::AlreadyExists, 49 + format!("{symlink:?} already exists and is not a symlink"), 50 + ))?, 51 + Err(error) if matches!(error.kind(), io::ErrorKind::NotFound) => {} 52 + Err(error) => Err(error)?, 53 + } 54 + 55 + std::os::unix::fs::symlink(target, &symlink)?; 56 + fs::metadata(symlink) 57 + } 58 + 59 + #[tracing::instrument] 60 + pub async fn post_hook(hook: &Hook) -> anyhow::Result<()> { 61 + let mut environment_vars: HashMap<_, _> = env::vars().collect(); 62 + 63 + // Take the environment variables we need to post the hook to the internal API. 64 + let endpoints = take_var(&mut environment_vars, private::ENV_PRIVATE_ENDPOINTS)?; 65 + let repo_did: Box<Did> = take_var(&mut environment_vars, private::ENV_REPO_DID)?.parse()?; 66 + let repo_rkey = take_var(&mut environment_vars, private::ENV_REPO_RKEY)?; 67 + 68 + // Build a header map with the remaining environment variables. 69 + let mut headers = HeaderMap::with_capacity(environment_vars.len()); 70 + for (key, value) in environment_vars { 71 + let header_name = variable_to_header_name(&key)?; 72 + headers.insert(header_name, value.try_into()?); 73 + } 74 + 75 + let stdin = Bytes::from(io::read_to_string(io::stdin())?); 76 + 77 + let client = reqwest::Client::new(); 78 + let url_path = format!("/hook/{repo_did}/{repo_rkey}/{hook}"); 79 + for endpoint in endpoints.split_whitespace() { 80 + let mut hook_url = match Url::parse(endpoint) { 81 + Ok(hook_url) => hook_url, 82 + Err(error) => { 83 + tracing::error!(?error, ?endpoint, "failed to parse internal endpoint"); 84 + continue; 85 + } 86 + }; 87 + 88 + hook_url.set_path(&url_path); 89 + let response = client 90 + .post(hook_url) 91 + .headers(headers.clone()) 92 + .body(stdin.clone()) 93 + .send() 94 + .await; 95 + 96 + match response { 97 + Ok(response) if response.status().is_success() => { 98 + let body = response.bytes().await?; 99 + io::stdout().write_all(&body)?; 100 + return Ok(()); 101 + } 102 + Ok(response) => { 103 + let status = response.status(); 104 + let body = response.bytes().await?; 105 + io::stdout().write_all(&body)?; 106 + return Err(anyhow::anyhow!("Knot returned error status {status}")); 107 + } 108 + Err(error) => { 109 + tracing::error!(?error, "failed to post hook to internal API"); 110 + continue; 111 + } 112 + } 113 + } 114 + 115 + Err(anyhow::anyhow!("Failed to find a valid internal endpoint")) 116 + } 117 + 118 + fn take_var(vars: &mut HashMap<String, String>, name: &str) -> anyhow::Result<String> { 119 + vars.remove(name).ok_or(anyhow::anyhow!( 120 + "Expected environment variable {name:?} to be set", 121 + )) 122 + } 123 + 124 + fn variable_to_header_name(name: &str) -> Result<HeaderName, InvalidHeaderName> { 125 + format!( 126 + "{}-{}", 127 + private::ENV_HEADER_PREFIX, 128 + name.trim_start_matches("GORDIAN_") 129 + ) 130 + .replace('_', "-") 131 + .to_lowercase() 132 + .try_into() 133 + }
+6
crates/knot/src/lib.rs
··· 1 1 pub mod model; 2 + pub mod private; 2 3 pub mod public; 3 4 pub mod services; 4 5 pub mod types; 6 + 7 + /// Encoding used for [Timestamp Identitiers](https://atproto.com/specs/tid). 8 + pub const BASE32_SORTABLE: data_encoding::Encoding = data_encoding_macro::new_encoding! { 9 + symbols: "234567abcdefghijklmnopqrstuvwxyz", 10 + };
+88 -9
crates/knot/src/main.rs
··· 1 1 mod cli; 2 + mod hooks; 2 3 3 4 use anyhow::Context; 4 5 use axum::{ ··· 14 13 use reqwest::ClientBuilder; 15 14 use sqlx::postgres::PgPoolOptions; 16 15 use std::{ 16 + env, 17 + ffi::OsString, 17 18 net::{SocketAddr, ToSocketAddrs}, 19 + path::PathBuf, 20 + process, 18 21 sync::{Arc, atomic::AtomicU64}, 19 22 time::Duration, 20 23 }; ··· 35 30 use url::Url; 36 31 37 32 fn main() { 33 + use knot::private::Hook; 34 + 38 35 let stderr_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr); 39 36 tracing_subscriber::registry() 40 37 .with(EnvFilter::from_default_env()) ··· 44 37 .try_init() 45 38 .unwrap(); 46 39 47 - let arguments = cli::parse(); 48 - tracing::debug!(?arguments); 49 - 50 - Builder::new_current_thread() 40 + let runtime = Builder::new_current_thread() 51 41 .enable_all() 52 42 .build() 53 - .expect("Failed to build runtime") 54 - .block_on(run(arguments)) 55 - .unwrap(); 43 + .expect("Failed to build runtime"); 44 + 45 + let name = executable_name().expect("Executable should have a name"); 46 + match Hook::try_from(name.as_os_str()) { 47 + Ok(hook) => { 48 + runtime.block_on(hooks::post_hook(&hook)).unwrap(); 49 + } 50 + Err(_) => { 51 + let arguments = cli::parse(); 52 + tracing::debug!(?arguments); 53 + runtime.block_on(run(arguments)).unwrap(); 54 + } 55 + } 56 + } 57 + 58 + fn executable_name() -> anyhow::Result<OsString> { 59 + Ok(env::args_os() 60 + .next() 61 + .map(PathBuf::from) 62 + .ok_or(anyhow::anyhow!("Expected at least one argument"))? 63 + .file_name() 64 + .ok_or(anyhow::anyhow!( 65 + "Failed to extract file name from executable" 66 + ))? 67 + .to_os_string()) 56 68 } 57 69 58 70 const X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id"); ··· 107 81 const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 108 82 109 83 pub async fn run(arguments: cli::Arguments) -> anyhow::Result<()> { 84 + let git_config_path = env::current_dir().unwrap().join("git_config"); 85 + unsafe { 86 + env::set_var("GIT_CONFIG_GLOBAL", &git_config_path); 87 + } 88 + 89 + // Setup git hooks 90 + let hooks_path = env::current_dir()?.join("hooks"); 91 + hooks::setup_global_hooks(&hooks_path)?; 92 + 93 + assert!( 94 + process::Command::new("/usr/bin/git") 95 + .args(["config", "set", "--global", "core.hooksPath"]) 96 + .arg(&hooks_path) 97 + .spawn()? 98 + .wait()? 99 + .success() 100 + ); 101 + 102 + assert!( 103 + process::Command::new("/usr/bin/git") 104 + .args([ 105 + "config", 106 + "set", 107 + "--global", 108 + "receive.advertisePushOptions", 109 + "true" 110 + ]) 111 + .spawn()? 112 + .wait()? 113 + .success() 114 + ); 115 + 110 116 let pool = PgPoolOptions::new() 111 117 .max_connections(5) 112 118 .connect(arguments.db.as_str()) ··· 196 138 .cursor(jetstream_cursor.map(|(_, ts)| ts)) 197 139 .build(Url::parse(jetstream::PUBLIC_JETSTREAM_INSTANCES[0])?); 198 140 141 + let mut service = JoinSet::new(); 142 + let mut private_sockets = Vec::new(); 143 + for socket in "localhost:0".to_socket_addrs()? { 144 + let bound_socket = TcpListener::bind(socket).await?; 145 + private_sockets.push(bound_socket); 146 + } 147 + 148 + let private_addrs: Vec<_> = private_sockets 149 + .iter() 150 + .map(|listener| listener.local_addr().unwrap()) 151 + .collect(); 152 + 199 153 let config = KnotConfiguration::builder() 200 154 .instance_name(&arguments.name) 201 155 .owner_did(&resolved_owner) 202 156 .repo_path(&arguments.repos) 157 + .hook_path(&hooks_path) 158 + .git_config_path(&git_config_path) 159 + .private_sockets(&private_addrs) 203 160 .build()?; 204 161 205 162 let knot: Knot = KnotState::new(config, resolver, public_http, jetstream, store).into(); 163 + 206 164 let router = router 207 165 .layer(SetRequestIdLayer::new( 208 166 X_REQUEST_ID, ··· 245 171 tracing::trace!(?latency, status = ?response.status()); 246 172 }), 247 173 ) 248 - .with_state(knot); 174 + .with_state(knot.clone()); 175 + 176 + let internal = knot::private::router().with_state(knot); 177 + for socket in private_sockets { 178 + let router = internal.clone(); 179 + service.spawn(async move { axum::serve(socket, router).await }); 180 + } 249 181 250 182 let mut sockets = Vec::with_capacity(arguments.addr.len()); 251 183 for addr in &arguments.addr { ··· 260 180 } 261 181 } 262 182 263 - let mut service = JoinSet::new(); 264 183 for socket in sockets { 265 184 serve(&mut service, socket, router.clone()).await; 266 185 }
+85 -4
crates/knot/src/model.rs
··· 6 6 pub mod repository; 7 7 8 8 use core::ops; 9 - use std::sync::Arc; 9 + use std::{ffi::OsString, sync::Arc}; 10 10 11 11 use axum::{ 12 12 extract::{FromRef, FromRequestParts}, ··· 14 14 }; 15 15 use identity::Resolver; 16 16 use serve_git::state::GitServiceState; 17 + use tokio::process::Command; 17 18 18 19 use crate::{ 20 + private, 19 21 public::git::{Error, GitAuthorization, NotFound}, 20 22 services::authorization::AuthorizationClaimsStore, 21 23 types::repository_path::RepositoryPath, ··· 74 72 ) -> Result<tokio::process::Command, Self::Rejection> { 75 73 use axum::extract::Path; 76 74 let Path(repopath) = Path::<RepositoryPath>::from_request_parts(parts, &()).await?; 75 + let resolved = self.resolve_repo_path(&repopath).await.map_err(NotFound)?; 77 76 let repo = self.open_repository(repopath).await.map_err(NotFound)?; 78 - let command = serve_git::commands::upload_pack_info_refs("/usr/bin/git", repo.path()); 77 + let path = repo.path(); 78 + 79 + let mut command = Command::new("/usr/bin/git"); 80 + command 81 + .env_clear() 82 + .env(private::ENV_PRIVATE_ENDPOINTS, self.private_endpoints()) 83 + .env(private::ENV_REPO_DID, resolved.owner.as_str()) 84 + .env(private::ENV_REPO_RKEY, resolved.rkey.as_ref()) 85 + .env("GIT_CONFIG_GLOBAL", self.git_config_path()) 86 + .current_dir(path) 87 + .args([ 88 + "upload-pack", 89 + "--http-backend-info-refs", 90 + "--stateless-rpc", 91 + "--strict", 92 + "--timeout=10", 93 + ]) 94 + .arg(path.as_os_str()); 95 + 79 96 Ok(command) 80 97 } 81 98 ··· 104 83 ) -> Result<tokio::process::Command, Self::Rejection> { 105 84 use axum::extract::Path; 106 85 let Path(repopath) = Path::<RepositoryPath>::from_request_parts(parts, &()).await?; 86 + let resolved = self.resolve_repo_path(&repopath).await.map_err(NotFound)?; 107 87 let repo = self.open_repository(repopath).await.map_err(NotFound)?; 88 + let path = repo.path(); 89 + 90 + let mut command = Command::new("/usr/bin/git"); 91 + command 92 + .env_clear() 93 + .env(private::ENV_PRIVATE_ENDPOINTS, self.private_endpoints()) 94 + .env(private::ENV_REPO_DID, resolved.owner.as_str()) 95 + .env(private::ENV_REPO_RKEY, resolved.rkey.as_ref()) 96 + .env("GIT_CONFIG_GLOBAL", self.git_config_path()) 97 + .current_dir(path) 98 + .args(["upload-pack", "--strict", "--stateless-rpc"]) 99 + .arg(path.as_os_str()); 100 + 108 101 let command = serve_git::commands::upload_pack("/usr/bin/git", repo.path()); 109 102 Ok(command) 110 103 } ··· 145 110 } 146 111 147 112 let repo = self.open_repository(repopath).await.map_err(NotFound)?; 148 - let command = serve_git::commands::receive_pack_info_refs("/usr/bin/git", repo.path()); 113 + let path = repo.path(); 114 + 115 + let nonce_seed = self.generate_push_seed(&resolved); 116 + let mut command = Command::new("/usr/bin/git"); 117 + command 118 + .env_clear() 119 + .env(private::ENV_PRIVATE_ENDPOINTS, self.private_endpoints()) 120 + .env(private::ENV_REPO_DID, resolved.owner.as_str()) 121 + .env(private::ENV_REPO_RKEY, resolved.rkey.as_ref()) 122 + .env(private::ENV_USER_DID, auth.iss.as_str()) 123 + .env("GIT_CONFIG_GLOBAL", self.git_config_path()) 124 + .current_dir(path) 125 + .args([ 126 + "-c", 127 + &nonce_seed, 128 + "receive-pack", 129 + "--http-backend-info-refs", 130 + "--stateless-rpc", 131 + ]) 132 + .arg(path.as_os_str()); 149 133 Ok(command) 150 134 } 151 135 ··· 190 136 } 191 137 192 138 let repo = self.open_repository(repopath).await.map_err(NotFound)?; 193 - let command = serve_git::commands::receive_pack("/usr/bin/git", repo.path()); 139 + let path = repo.path(); 140 + 141 + let allowed_signers_path = std::env::current_dir() 142 + .unwrap() 143 + .join("allowed_signers") 144 + .join(auth.iss.as_str()); 145 + 146 + let mut allowed_signers_option = OsString::with_capacity( 147 + "gpg.ssh.allowedSignersFile=".len() + allowed_signers_path.as_os_str().len(), 148 + ); 149 + allowed_signers_option.push("gpg.ssh.allowedSignersFile="); 150 + allowed_signers_option.push(&allowed_signers_path); 151 + 152 + let nonce_seed = self.generate_push_seed(&resolved); 153 + let mut command = Command::new("/usr/bin/git"); 154 + command 155 + .env_clear() 156 + .env(private::ENV_PRIVATE_ENDPOINTS, self.private_endpoints()) 157 + .env(private::ENV_REPO_DID, resolved.owner.as_str()) 158 + .env(private::ENV_REPO_RKEY, resolved.rkey.as_ref()) 159 + .env(private::ENV_USER_DID, auth.iss.as_str()) 160 + .env("GIT_CONFIG_GLOBAL", self.git_config_path()) 161 + .current_dir(path) 162 + .args(["-c", &nonce_seed, "-c"]) 163 + .arg(&allowed_signers_option) 164 + .args(["receive-pack", "--stateless-rpc"]) 165 + .arg(path.as_os_str()); 166 + 194 167 Ok(command) 195 168 } 196 169 }
+54
crates/knot/src/model/config.rs
··· 3 3 use gix::bstr::BString; 4 4 use std::{ 5 5 collections::HashSet, 6 + net, 6 7 path::{Path, PathBuf}, 7 8 }; 8 9 ··· 31 30 instance_audience: Box<Did>, 32 31 owner_did: Box<Did>, 33 32 repo_path: PathBuf, 33 + hook_path: PathBuf, 34 + git_config: PathBuf, 35 + private_sockets: String, 34 36 readmes: HashSet<BString>, 35 37 } 36 38 ··· 65 61 self.repo_path.as_path() 66 62 } 67 63 64 + /// Path to the knot-server's global git hooks directory. 65 + #[inline] 66 + pub fn hook_path(&self) -> &Path { 67 + self.hook_path.as_path() 68 + } 69 + 70 + #[inline] 71 + pub fn git_config_path(&self) -> &Path { 72 + self.git_config.as_path() 73 + } 74 + 75 + #[inline] 76 + pub fn private_endpoints(&self) -> &str { 77 + &self.private_sockets 78 + } 79 + 68 80 pub fn readmes(&self) -> &HashSet<BString> { 69 81 &self.readmes 70 82 } ··· 91 71 instance_name: Option<&'a str>, 92 72 owner_did: Option<&'a Did>, 93 73 repo_path: Option<&'a Path>, 74 + hook_path: Option<&'a Path>, 75 + git_config: Option<&'a Path>, 76 + private_addrs: Vec<net::SocketAddr>, 94 77 } 95 78 96 79 impl<'a> KnotConfigurationBuilder<'a> { ··· 112 89 self 113 90 } 114 91 92 + pub fn hook_path(&mut self, hook_path: &'a Path) -> &mut Self { 93 + self.hook_path.replace(hook_path); 94 + self 95 + } 96 + 97 + pub fn git_config_path(&mut self, git_config_path: &'a Path) -> &mut Self { 98 + self.git_config.replace(git_config_path); 99 + self 100 + } 101 + 102 + pub fn private_sockets(&mut self, addrs: &[net::SocketAddr]) -> &mut Self { 103 + self.private_addrs.extend_from_slice(addrs); 104 + self 105 + } 106 + 115 107 pub fn build(&self) -> Result<KnotConfiguration, Error> { 116 108 let instance_name = self.instance_name.ok_or(Error::InstanceName)?.into(); 117 109 let owner_did = self.owner_did.ok_or(Error::OwnerDid)?.into(); 118 110 let repo_path = self.repo_path.ok_or(Error::MissingRepositoryPath)?.into(); 111 + let hook_path = self.hook_path.ok_or(Error::MissingHooksPath)?.into(); 112 + let git_config = self.git_config.ok_or(Error::MissingGitConfigPath)?.into(); 119 113 120 114 // Check the base repository path exists 121 115 let metadata = std::fs::metadata(&repo_path)?; ··· 143 103 ))); 144 104 } 145 105 106 + let private_sockets = self 107 + .private_addrs 108 + .iter() 109 + .map(|addr| format!("http://{addr}/")) 110 + .collect::<Vec<_>>() 111 + .join(" "); 112 + 146 113 let instance_audience = format!("did:web:{instance_name}").try_into()?; 147 114 Ok(KnotConfiguration { 148 115 instance_name, 149 116 instance_audience, 150 117 owner_did, 151 118 repo_path, 119 + hook_path, 120 + git_config, 121 + private_sockets, 152 122 readmes: DEFAULT_READMES 153 123 .iter() 154 124 .map(|v| BString::new(v.to_vec())) ··· 177 127 OwnerDid, 178 128 #[error("Missing repositories path")] 179 129 MissingRepositoryPath, 130 + #[error("Missing git hooks path")] 131 + MissingHooksPath, 132 + #[error("Missing git config path")] 133 + MissingGitConfigPath, 180 134 #[error("Invalid repositories path: {0}")] 181 135 InvalidRepositoryPath(#[from] std::io::Error), 182 136 }
+27
crates/knot/src/model/knot_state.rs
··· 56 56 /// Maps (handle, name), (handle, rkey), or (did, name) to (did, rkey). 57 57 repo_cache: RwLock<HashMap<RepositoryPath, RepositoryKey>>, 58 58 59 + push_seed: Mutex<HashMap<RepositoryKey, Box<str>>>, 60 + 59 61 #[cfg(feature = "repository-cache")] 60 62 repo_handle_cache: Mutex<HashMap<RepositoryKey, gix::ThreadSafeRepository>>, 61 63 } ··· 83 81 pool, 84 82 jwt_claims: Default::default(), 85 83 repo_cache: Default::default(), 84 + push_seed: Default::default(), 86 85 #[cfg(feature = "repository-cache")] 87 86 repo_handle_cache: Default::default(), 88 87 }); ··· 404 401 poisoned.into_inner() 405 402 }) 406 403 .remove(repo_path); 404 + } 405 + 406 + /// Get or generate a new nonce seed for signed pushes to the specified repository. 407 + pub fn generate_push_seed(&self, repository: &RepositoryKey) -> Box<str> { 408 + const PUSH_SEED_NONCE_LEN: usize = 16; 409 + 410 + let mut guard = self.push_seed.lock().unwrap_or_else(|mut poisoned| { 411 + self.push_seed.clear_poison(); 412 + poisoned.get_mut().clear(); 413 + poisoned.into_inner() 414 + }); 415 + 416 + if let Some(seed) = guard.get(repository).cloned() { 417 + return seed; 418 + } 419 + 420 + let mut option = "receive.certNonceSeed=".to_owned(); 421 + 422 + let raw_seed: [u8; PUSH_SEED_NONCE_LEN] = rand::random(); 423 + crate::BASE32_SORTABLE.encode_append(&raw_seed, &mut option); 424 + 425 + let encoded: Box<str> = option.into(); 426 + guard.insert(repository.clone(), encoded.clone()); 427 + encoded 407 428 } 408 429 } 409 430
+285
crates/knot/src/private.rs
··· 1 + use core::fmt; 2 + use std::borrow::Cow; 3 + 4 + use atproto::Did; 5 + use axum::{ 6 + extract::{FromRequestParts, Path, State}, 7 + http::{HeaderMap, StatusCode, request::Parts}, 8 + response::IntoResponse, 9 + }; 10 + use serde::{Deserialize, Serialize}; 11 + 12 + /// Environment variable containing one or more whitespace separated URLs for the internal API. 13 + /// 14 + /// By default, knot will serve the internal API on all the addresses resolved from `localhost` 15 + /// bound to a OS assigned port. 16 + /// 17 + /// # Example 18 + /// 19 + /// `"http://[::1]:44269/ http://127.0.0.1:36413/"` 20 + /// 21 + pub const ENV_PRIVATE_ENDPOINTS: &str = "GORDIAN_PRIVATE_ENDPOINTS"; 22 + 23 + /// Environment variable containing the DID of the account that triggered the hook. 24 + pub const ENV_USER_DID: &str = "GORDIAN_USER_DID"; 25 + 26 + /// Environment variable containing the DID that owns the repository the hook has be triggered on. 27 + pub const ENV_REPO_DID: &str = "GORDIAN_REPO_DID"; 28 + 29 + /// Environment variable containing the rkey of the repository the hook has be triggered on. 30 + pub const ENV_REPO_RKEY: &str = "GORDIAN_REPO_RKEY"; 31 + 32 + /// Prefix to add when converting an environment variable from the hook to a HTTP header. 33 + pub const ENV_HEADER_PREFIX: &str = "X-Gordian"; 34 + 35 + use crate::{ 36 + model::Knot, 37 + types::{push_certificate::PushCertificate, repository_key::RepositoryKey}, 38 + }; 39 + 40 + /// Build a new router for the internal API. 41 + #[rustfmt::skip] 42 + pub fn router() -> axum::Router<Knot> { 43 + use axum::routing::post; 44 + axum::Router::new() 45 + .without_v07_checks() 46 + .route("/hook/{owner}/{rkey}/pre-receive", post(hook_pre_receive)) 47 + .route("/hook/{owner}/{rkey}/reference-transaction", post(hook_reference_transaction)) 48 + .route("/hook/{owner}/{rkey}/post-receive", post(hook_post_receive)) 49 + .route("/hook/{owner}/{rkey}/post-update", post(hook_post_update)) 50 + } 51 + 52 + /// Hooks handled by knot. 53 + #[derive(Debug, PartialEq, Eq, Deserialize, Serialize)] 54 + #[serde(rename_all = "kebab-case")] 55 + pub enum Hook { 56 + PostReceive, 57 + PostUpdate, 58 + PreReceive, 59 + ReferenceTransaction, 60 + } 61 + 62 + impl Hook { 63 + /// Get the hook name as a `'static str`. 64 + /// 65 + /// # Example 66 + /// 67 + /// ```rust 68 + /// # use knot::private::Hook; 69 + /// assert_eq!(Hook::PostReceive.as_str(), "post-receive"); 70 + /// assert_eq!(Hook::PostUpdate.as_str(), "post-update"); 71 + /// assert_eq!(Hook::PreReceive.as_str(), "pre-receive"); 72 + /// assert_eq!(Hook::ReferenceTransaction.as_str(), "reference-transaction"); 73 + /// ``` 74 + /// 75 + pub fn as_str(&self) -> &'static str { 76 + match self { 77 + Self::PostReceive => "post-receive", 78 + Self::PostUpdate => "post-update", 79 + Self::PreReceive => "pre-receive", 80 + Self::ReferenceTransaction => "reference-transaction", 81 + } 82 + } 83 + 84 + /// Iterate over all the supported hooks. 85 + /// 86 + /// # Example 87 + /// 88 + /// ```rust 89 + /// # use knot::private::Hook; 90 + /// let mut hooks = Hook::iter(); 91 + /// assert_eq!(hooks.next(), Some(Hook::PostReceive)); 92 + /// assert_eq!(hooks.next(), Some(Hook::PostUpdate)); 93 + /// assert_eq!(hooks.next(), Some(Hook::PreReceive)); 94 + /// assert_eq!(hooks.next(), Some(Hook::ReferenceTransaction)); 95 + /// assert_eq!(hooks.next(), None); 96 + /// ``` 97 + /// 98 + pub fn iter() -> impl Iterator<Item = Self> { 99 + [ 100 + Self::PostReceive, 101 + Self::PostUpdate, 102 + Self::PreReceive, 103 + Self::ReferenceTransaction, 104 + ] 105 + .into_iter() 106 + } 107 + 108 + /// Iterate over all the supported hook names. 109 + /// 110 + /// # Example 111 + /// 112 + /// ```rust 113 + /// # use knot::private::Hook; 114 + /// let mut names = Hook::iter_names(); 115 + /// assert_eq!(names.next(), Some("post-receive")); 116 + /// assert_eq!(names.next(), Some("post-update")); 117 + /// assert_eq!(names.next(), Some("pre-receive")); 118 + /// assert_eq!(names.next(), Some("reference-transaction")); 119 + /// assert_eq!(names.next(), None); 120 + /// ``` 121 + /// 122 + pub fn iter_names() -> impl Iterator<Item = &'static str> { 123 + Self::iter().map(|hook| hook.as_str()) 124 + } 125 + } 126 + 127 + impl fmt::Display for Hook { 128 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 129 + write!(f, "{}", self.as_str()) 130 + } 131 + } 132 + 133 + #[derive(Debug, thiserror::Error)] 134 + #[error("Hook name not recognised")] 135 + pub struct UnknownHook; 136 + 137 + impl std::str::FromStr for Hook { 138 + type Err = UnknownHook; 139 + fn from_str(s: &str) -> Result<Self, Self::Err> { 140 + match s { 141 + "post-receive" => Ok(Self::PostReceive), 142 + "post-update" => Ok(Self::PostUpdate), 143 + "pre-receive" => Ok(Self::PreReceive), 144 + "reference-transaction" => Ok(Self::ReferenceTransaction), 145 + _ => Err(UnknownHook), 146 + } 147 + } 148 + } 149 + 150 + impl TryFrom<&std::ffi::OsStr> for Hook { 151 + type Error = UnknownHook; 152 + fn try_from(value: &std::ffi::OsStr) -> Result<Self, Self::Error> { 153 + match value.as_encoded_bytes() { 154 + b"post-receive" => Ok(Self::PostReceive), 155 + b"post-update" => Ok(Self::PostUpdate), 156 + b"pre-receive" => Ok(Self::PreReceive), 157 + b"reference-transaction" => Ok(Self::ReferenceTransaction), 158 + _ => Err(UnknownHook), 159 + } 160 + } 161 + } 162 + 163 + /// Extracts the 'X-Gordian-User-Did' header from a request. 164 + pub struct GordianUserDid(pub Box<Did>); 165 + 166 + impl<S: Sync> FromRequestParts<S> for GordianUserDid { 167 + type Rejection = (StatusCode, Cow<'static, str>); 168 + 169 + async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { 170 + macro_rules! hdr { 171 + () => { 172 + "X-Gordian-User-Did" 173 + }; 174 + } 175 + 176 + const HEADER: &str = hdr!(); 177 + 178 + let did = parts 179 + .headers 180 + .get(HEADER) 181 + .ok_or(( 182 + StatusCode::BAD_REQUEST, 183 + concat!("'", hdr!(), "' header required").into(), 184 + ))? 185 + .to_str() 186 + .map_err(|_| { 187 + ( 188 + StatusCode::BAD_REQUEST, 189 + concat!("'", hdr!(), "' contains invalid ASCII").into(), 190 + ) 191 + })? 192 + .parse() 193 + .map_err(|_| { 194 + ( 195 + StatusCode::BAD_REQUEST, 196 + concat!("'", hdr!(), "' contains invalid DID").into(), 197 + ) 198 + })?; 199 + 200 + Ok(Self(did)) 201 + } 202 + } 203 + 204 + #[tracing::instrument(skip(state, _body))] 205 + async fn hook_pre_receive( 206 + State(state): State<Knot>, 207 + Path(repo): Path<RepositoryKey>, 208 + GordianUserDid(user_did): GordianUserDid, 209 + headers: HeaderMap, 210 + _body: String, 211 + ) -> Result<impl IntoResponse, impl IntoResponse> { 212 + use data_encoding::BASE64_NOPAD as Encoding; 213 + 214 + let push_certificate = PushCertificate::try_from(&headers) 215 + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; 216 + 217 + // @TODO Make acceptable slop configurable. 218 + if !push_certificate.is_good(Some(5)) { 219 + tracing::error!("push certificate rejected"); 220 + return Err(( 221 + StatusCode::FORBIDDEN, 222 + "Push certificate rejected".to_owned(), 223 + )); 224 + } 225 + 226 + let certificate_key_digest = push_certificate 227 + .signing_key_digest() 228 + .map_err(|error| (StatusCode::BAD_REQUEST, error.to_string()))?; 229 + 230 + for public_key in state 231 + .store() 232 + .public_keys_for_did(&user_did) 233 + .await 234 + .map_err(|error| (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))? 235 + { 236 + let mut key_parts = public_key.key.split_whitespace(); 237 + let key_data = match (key_parts.next(), key_parts.next()) { 238 + (Some(_), Some(key_data)) => key_data, 239 + _ => continue, 240 + }; 241 + 242 + let Ok(decoded) = Encoding.decode(key_data.as_bytes()) else { 243 + continue; 244 + }; 245 + 246 + let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, &decoded); 247 + if digest.as_ref() == certificate_key_digest { 248 + let output = format!("Good signature for '{user_did}'"); 249 + return Ok((StatusCode::OK, output)); 250 + } 251 + } 252 + 253 + let output = format!("Failed to find matching key for '{user_did}'"); 254 + Err((StatusCode::FORBIDDEN, output)) 255 + } 256 + 257 + #[tracing::instrument(skip(body))] 258 + async fn hook_reference_transaction( 259 + Path(repo): Path<RepositoryKey>, 260 + headers: HeaderMap, 261 + body: String, 262 + ) -> impl IntoResponse { 263 + tracing::debug!("{body}"); 264 + StatusCode::NO_CONTENT 265 + } 266 + 267 + #[tracing::instrument(skip(body))] 268 + async fn hook_post_receive( 269 + Path(repo): Path<RepositoryKey>, 270 + headers: HeaderMap, 271 + body: String, 272 + ) -> impl IntoResponse { 273 + tracing::debug!("{body}"); 274 + StatusCode::NO_CONTENT 275 + } 276 + 277 + #[tracing::instrument(skip(body))] 278 + async fn hook_post_update( 279 + Path(repo): Path<RepositoryKey>, 280 + headers: HeaderMap, 281 + body: String, 282 + ) -> impl IntoResponse { 283 + tracing::debug!("{body}"); 284 + StatusCode::NO_CONTENT 285 + }
+1
crates/knot/src/types.rs
··· 1 + pub mod push_certificate; 1 2 pub mod repository_key; 2 3 pub mod repository_path; 3 4 pub mod sh;
+174
crates/knot/src/types/push_certificate.rs
··· 1 + use axum::http::HeaderMap; 2 + 3 + /// Deserialized from 'GIT_PUSH_CERT_STATUS` 4 + /// 5 + /// See `man 1 git-receive-pack` 6 + #[derive(Debug)] 7 + pub enum PushCertificateStatus { 8 + Good, 9 + Bad, 10 + GoodUnknownValidity, 11 + GoodExpiredSignature, 12 + GoodExpiredKey, 13 + GoodRevokedKey, 14 + MissingKey, 15 + NoSignature, 16 + } 17 + 18 + impl PushCertificateStatus { 19 + pub const fn is_good(&self) -> bool { 20 + matches!(self, Self::Good) 21 + } 22 + } 23 + 24 + impl std::str::FromStr for PushCertificateStatus { 25 + type Err = PushCertificateError; 26 + fn from_str(s: &str) -> Result<Self, Self::Err> { 27 + match s { 28 + "G" => Ok(Self::Good), 29 + "B" => Ok(Self::Bad), 30 + "U" => Ok(Self::GoodUnknownValidity), 31 + "X" => Ok(Self::GoodExpiredSignature), 32 + "Y" => Ok(Self::GoodExpiredKey), 33 + "R" => Ok(Self::GoodRevokedKey), 34 + "E" => Ok(Self::MissingKey), 35 + "N" => Ok(Self::NoSignature), 36 + other => Err(PushCertificateError::CertificateStatus(other.to_string())), 37 + } 38 + } 39 + } 40 + 41 + /// Deserialized from 'GIT_PUSH_CERT_NONCE_STATUS` 42 + /// 43 + /// See `man 1 git-receive-pack` 44 + #[derive(Debug)] 45 + pub enum PushCertificateNonceStatus { 46 + /// `git push --signed` sent a nonce when we did not ask it to send one 47 + Unsolicited, 48 + /// `git push --signed` did not send any nonce header 49 + Missing, 50 + /// `git push --signed` sent a bogus nonce 51 + Bad, 52 + /// `git push --signed` sent the nonce we asked it to send 53 + Ok, 54 + /// `git push --signed` send a nonce different from what we asked it to send now, but 55 + /// in a previous session. 56 + Slop, 57 + } 58 + 59 + impl std::str::FromStr for PushCertificateNonceStatus { 60 + type Err = PushCertificateError; 61 + fn from_str(s: &str) -> Result<Self, Self::Err> { 62 + match s { 63 + "UNSOLICITED" => Ok(Self::Unsolicited), 64 + "MISSING" => Ok(Self::Missing), 65 + "BAD" => Ok(Self::Bad), 66 + "OK" => Ok(Self::Ok), 67 + "SLOP" => Ok(Self::Slop), 68 + other => Err(PushCertificateError::NonceStatus(other.to_string())), 69 + } 70 + } 71 + } 72 + 73 + /// Push certificate assembled from environment variables passed to a git pre-receive hook. 74 + /// 75 + /// See: `man 1 git-receive-pack` 76 + #[derive(Debug)] 77 + pub struct PushCertificate<'a> { 78 + /// Object ID of the push certificate. 79 + /// 80 + /// The certificate may be read from the git repository as a blob using this ID. 81 + pub cert: &'a str, 82 + pub key: &'a str, 83 + pub signer: &'a str, 84 + pub status: PushCertificateStatus, 85 + pub nonce: &'a str, 86 + pub nonce_status: PushCertificateNonceStatus, 87 + pub nonce_slop: u32, 88 + } 89 + 90 + impl PushCertificate<'_> { 91 + pub fn is_good(&self, allowed_slop: Option<u32>) -> bool { 92 + self.status.is_good() 93 + && matches!(&self.nonce_status, PushCertificateNonceStatus::Ok 94 + | PushCertificateNonceStatus::Slop if allowed_slop.is_some_and(|value| self.nonce_slop <= value)) 95 + } 96 + 97 + /// Decode the SHA256 digest of the key that signed the push certificate. 98 + pub fn signing_key_digest(&self) -> Result<Vec<u8>, CertificateKeyError> { 99 + use data_encoding::BASE64_NOPAD as Encoding; 100 + 101 + let key_digest = self 102 + .key 103 + .strip_prefix("SHA256:") 104 + .ok_or(CertificateKeyError::Format)?; 105 + 106 + let key_digest = Encoding.decode(key_digest.as_bytes())?; 107 + 108 + Ok(key_digest) 109 + } 110 + } 111 + 112 + #[derive(Debug, thiserror::Error)] 113 + pub enum PushCertificateError { 114 + #[error("Push certificate parameter missing: {0}")] 115 + MissingParameter(&'static str), 116 + #[error("{0}")] 117 + Encoding(#[from] axum::http::header::ToStrError), 118 + #[error("Invalid certificate status: {0}")] 119 + CertificateStatus(String), 120 + #[error("Invalid nonce status: {0}")] 121 + NonceStatus(String), 122 + #[error("Invalid nonce slop value: {0}")] 123 + NonceSlop(#[from] std::num::ParseIntError), 124 + } 125 + 126 + #[derive(Debug, thiserror::Error)] 127 + pub enum CertificateKeyError { 128 + #[error("Expected a key ID in the form 'SHA256:...")] 129 + Format, 130 + #[error("Failed to decode key ID")] 131 + Encoding(#[from] data_encoding::DecodeError), 132 + } 133 + 134 + impl<'a> TryFrom<&'a HeaderMap> for PushCertificate<'a> { 135 + type Error = PushCertificateError; 136 + fn try_from(value: &'a HeaderMap) -> Result<Self, Self::Error> { 137 + macro_rules! extract_param { 138 + ($name:ident, $header:literal) => { 139 + let $name = value 140 + .get($header) 141 + .ok_or(PushCertificateError::MissingParameter($header))? 142 + .to_str()?; 143 + }; 144 + (optional $name:ident, $header:literal) => { 145 + let $name = value.get($header).map(|value| value.to_str()).transpose()?; 146 + }; 147 + } 148 + 149 + extract_param!(cert, "x-gordian-git-push-cert"); 150 + extract_param!(key, "x-gordian-git-push-cert-key"); 151 + extract_param!(signer, "x-gordian-git-push-cert-signer"); 152 + extract_param!(status, "x-gordian-git-push-cert-status"); 153 + extract_param!(nonce, "x-gordian-git-push-cert-nonce"); 154 + extract_param!(nonce_status, "x-gordian-git-push-cert-nonce-status"); 155 + extract_param!(optional nonce_slop, "x-gordian-git-push-cert-nonce-slop"); 156 + 157 + let status = status.parse()?; 158 + let nonce_status = nonce_status.parse()?; 159 + let nonce_slop = nonce_slop 160 + .map(|s| s.parse()) 161 + .transpose()? 162 + .unwrap_or_default(); 163 + 164 + Ok(Self { 165 + cert, 166 + key, 167 + signer, 168 + status, 169 + nonce, 170 + nonce_status, 171 + nonce_slop, 172 + }) 173 + } 174 + }