diff options
Diffstat (limited to 'server/src')
| -rw-r--r-- | server/src/authorized_keys.rs | 17 | ||||
| -rw-r--r-- | server/src/git_root.rs | 142 | ||||
| -rw-r--r-- | server/src/main.rs | 64 | ||||
| -rw-r--r-- | server/src/tests.rs | 8 |
4 files changed, 215 insertions, 16 deletions
diff --git a/server/src/authorized_keys.rs b/server/src/authorized_keys.rs index 21651ef..81885b3 100644 --- a/server/src/authorized_keys.rs +++ b/server/src/authorized_keys.rs @@ -1,9 +1,11 @@ use futures::stream::TryStreamExt; +use log::{error, info}; use rocket::fairing::{self, AdHoc}; use rocket::serde::Deserialize; use rocket::{Build, Rocket}; use rocket_db_pools::{sqlx, Database}; use std::borrow::Cow; +use std::os::unix::fs::MetadataExt; use std::path::{Path, PathBuf}; use std::sync::Mutex; use tokio::fs; @@ -86,8 +88,18 @@ impl AuthorizedKeys { let tmp = path.with_extension("new"); fs::write(&tmp, content.as_bytes()).await?; + if let Ok(metadata) = fs::metadata(path).await { + // Try to replicate ownership and permissions of original file + fs::set_permissions(&tmp, metadata.permissions()) + .await + .unwrap_or(()); + std::os::unix::fs::chown(&tmp, Some(metadata.uid()), Some(metadata.gid())) + .unwrap_or(()); + } fs::rename(tmp, path).await?; + info!("Updated {path:?}, {} keys", keys.len()); + Ok(()) } } @@ -127,7 +139,10 @@ async fn setup_users(rocket: Rocket<Build>) -> fairing::Result { Some(roots) => match Db::fetch(&rocket) { Some(db) => match setup_users_keys(roots, config, db).await { Ok(_) => Ok(rocket), - Err(_) => Err(rocket), + Err(e) => { + error!("{e:?}"); + Err(rocket) + } }, None => Err(rocket), }, diff --git a/server/src/git_root.rs b/server/src/git_root.rs index f818495..01cc2b5 100644 --- a/server/src/git_root.rs +++ b/server/src/git_root.rs @@ -1,4 +1,5 @@ use futures::{future::TryFutureExt, stream::TryStreamExt}; +use log::{error, trace}; use rmp_serde::{decode, Serializer}; use rocket::fairing::{self, AdHoc}; use rocket::serde::ser::Serialize; @@ -7,11 +8,15 @@ use rocket::{Build, Rocket}; use rocket_db_pools::{sqlx, Database, Pool}; use std::borrow::Cow; use std::collections::HashMap; +use std::fs::Permissions; use std::ops::Deref; -use std::path::PathBuf; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use tokio::fs; use tokio::net::{UnixListener, UnixStream}; use tokio::task; +use url::Url; use crate::api_model; use crate::fs_utils; @@ -43,14 +48,16 @@ impl Roots { db: &Db, project_id: &str, remote: &str, + remote_key: &str, main_branch: &str, - ) -> Result<(), git::Error> { + ) -> Result<(), anyhow::Error> { let project_id = project_id.to_string(); let repo = setup_project_root( config, db, &project_id, remote.to_string(), + remote_key.to_string(), main_branch.to_string(), ) .await?; @@ -115,13 +122,22 @@ async fn git_process_prehook( let mut errors: Vec<String> = Vec::new(); for row in receive { + trace!( + "prehook {} {} {}", + row.old_value, + row.new_value, + row.reference + ); + if !valid_branch_name(row.reference.as_str()) { if row.reference.starts_with("refs/heads/") { + error!("{}: Bad branch name", row.reference); errors.push(format!( "{}: Bad branch name", row.reference.strip_prefix("refs/heads/").unwrap() )); } else { + error!("{}: Only branches are allowed", row.reference); errors.push(format!("{}: Only branches are allowed", row.reference)); } continue; @@ -141,11 +157,13 @@ async fn git_process_prehook( { Ok(_) => {} Err(e) => { + error!("{e:?}"); errors.push(e.message); continue; } }, None => { + error!("{branch}: Missing commiter"); errors.push(format!("{branch}: Missing commiter")); continue; } @@ -177,6 +195,7 @@ async fn git_process_prehook( // All branches should be connected to a branch, but in case of errors this might // be relevant. if result.is_ok() { + error!("{branch}: Not allowed to delete branch, delete review instead."); errors.push(format!( "{branch}: Not allowed to delete branch, delete review instead." )); @@ -187,6 +206,7 @@ async fn git_process_prehook( let (state, rewrite) = match result { Ok(data) => data, Err(e) => { + error!("{e:?}"); errors.push(e.message); continue; } @@ -194,10 +214,12 @@ async fn git_process_prehook( match state { api_model::ReviewState::Dropped => { + error!("{branch}: Review is dropped, no pushes allowed"); errors.push(format!("{branch}: Review is dropped, no pushes allowed")); continue; } api_model::ReviewState::Closed => { + error!("{branch}: Review is closed, no pushes allowed"); errors.push(format!("{branch}: Review is closed, no pushes allowed")); continue; } @@ -223,10 +245,12 @@ async fn git_process_prehook( if equal_content { continue; } + error!("{branch}: History rewrite not allowed"); errors.push(format!("{}: History rewrite not allowed as final result does not match. Please check locally with `git diff {} {}`", branch, row.old_value, row.new_value)); } api_model::Rewrite::Rebase => {} api_model::Rewrite::Disabled => { + error!("{}: Non fast-forward not allowed", row.reference); errors.push(format!( "Non fast-forward not allowed for review: {}", row.reference @@ -257,12 +281,20 @@ async fn git_process_posthook( let mut updated: Vec<u64> = Vec::new(); for row in receive { + trace!( + "posthook {} {} {}", + row.old_value, + row.new_value, + row.reference + ); + let branch = row.reference.strip_prefix("refs/heads/").unwrap(); if row.old_value == git::EMPTY { let commiter = match repo.get_commiter(row.reference.as_str()).await { Ok(user) => user, Err(e) => { + error!("{e:?}"); messages.push(format!("{branch}: {e}")); continue; } @@ -288,6 +320,7 @@ async fn git_process_posthook( )); } Err(e) => { + error!("{e:?}"); messages.push(format!("{branch}: Error {e}",)); } }; @@ -319,6 +352,7 @@ async fn git_process_posthook( updated.push(id); } Err(e) => { + error!("{e:?}"); messages.push(format!("{branch}: Error {e}",)); } } @@ -328,6 +362,7 @@ async fn git_process_posthook( } }, Err(e) => { + error!("{e:?}"); messages.push(format!("{branch}: Error {e}",)); } } @@ -402,38 +437,120 @@ async fn git_socket_listen( } } +fn get_host(url: &str) -> String { + match Url::parse(url) { + Ok(u) => match u.host_str() { + Some(h) => h.to_string(), + None => String::new(), + }, + Err(_) => String::new(), + } +} + +async fn write_private_file<P: AsRef<Path>, D: AsRef<[u8]>>( + path: P, + data: D, +) -> anyhow::Result<()> { + fs::write(&path, data).await?; + let permissions = Permissions::from_mode(0o600); + fs::set_permissions(&path, permissions).await?; + Ok(()) +} + +async fn write_ssh_config(ssh_config: &Path, host: &str, remote_key: &str) -> anyhow::Result<()> { + let host_pattern = if host.is_empty() { "*" } else { host }; + let basedir = ssh_config.parent().unwrap(); + let identity_file = basedir.join("ssh_identity"); + let known_hosts = basedir.join("ssh_known_hosts"); + + let mut identity_data = String::from("-----BEGIN OPENSSH PRIVATE KEY-----\n"); + { + let mut left = remote_key; + while left.len() > 70 { + let (a, b) = left.split_at(70); + identity_data.push_str(a); + identity_data.push('\n'); + left = b; + } + identity_data.push_str(left); + identity_data.push('\n'); + } + identity_data.push_str("-----END OPENSSH PRIVATE KEY-----\n"); + + let config_data = format!( + "Host {host_pattern} +IdentityFile ./{} +PasswordAuthentication no +StrictHostKeyChecking accept-new +UpdateHostKeys yes +UserKnownHostsFile ./{} +", + identity_file.file_name().unwrap().to_str().unwrap(), + known_hosts.file_name().unwrap().to_str().unwrap() + ); + + let (a, b, c) = tokio::join!( + write_private_file(identity_file, identity_data), + fs::write(known_hosts, "\n"), + fs::write(ssh_config, config_data), + ); + a?; + b?; + c?; + Ok(()) +} + async fn setup_project_root( config: &Config<'_>, db: &Db, project_id: &String, remote: String, + remote_key: String, main_branch: String, -) -> Result<Arc<git::Repository>, git::Error> { +) -> Result<Arc<git::Repository>, anyhow::Error> { let mut path = PathBuf::from(config.git_server_root.to_string()); path.push(project_id); + info!("{project_id}: Setup repo at {path:?}"); let githook = PathBuf::from(config.git_hook.to_string()); + let ssh_config = path.join("ssh_config"); let repo = Arc::new(git::Repository::new( path, true, Some(remote), Some(project_id), Some(githook), + Some(ssh_config), )); repo.setup().await?; + write_ssh_config( + repo.ssh_config().unwrap(), + get_host(repo.remote().unwrap()).as_str(), + remote_key.as_str(), + ) + .await?; + if !repo.remote().unwrap().is_empty() && !main_branch.is_empty() { let bg_repo = repo.clone(); - tokio::spawn(async move { bg_repo.fetch(main_branch).await }); + let bg_project_id = project_id.clone(); + tokio::spawn(async move { + match bg_repo.fetch(&main_branch).await { + Ok(()) => {} + Err(e) => { + error!("{bg_project_id}: fetch {main_branch} returned {e:?}"); + } + } + }); } let socket_repo = repo.clone(); let socket_db = db.deref().clone(); + let bg_project_id = project_id.clone(); tokio::spawn(async move { match git_socket_listen(socket_repo, socket_db).await { Ok(()) => {} Err(e) => { - // TODO: Log - print!("git_socket_listen returned {:?}", e) + error!("{bg_project_id}: git_socket_listen returned {e:?}"); } } }); @@ -444,17 +561,17 @@ async fn setup_project_root( async fn setup_projects_roots(roots: &Roots, config: &Config<'_>, db: &Db) -> anyhow::Result<()> { fs_utils::create_dir_allow_existing(PathBuf::from(config.git_server_root.to_string())).await?; - let projects = sqlx::query!("SELECT id,remote,main_branch FROM projects") + let projects = sqlx::query!("SELECT id,remote,remote_key,main_branch FROM projects") .fetch(&**db) - .map_ok(|r| (r.id, r.remote, r.main_branch)) + .map_ok(|r| (r.id, r.remote, r.remote_key, r.main_branch)) .try_collect::<Vec<_>>() .await .unwrap(); let mut project_repo: HashMap<String, Arc<git::Repository>> = HashMap::new(); - for (id, remote, main_branch) in projects { - let repo = setup_project_root(config, db, &id, remote, main_branch).await?; + for (id, remote, remote_key, main_branch) in projects { + let repo = setup_project_root(config, db, &id, remote, remote_key, main_branch).await?; project_repo.insert(id, repo); } @@ -472,7 +589,10 @@ async fn setup_projects(rocket: Rocket<Build>) -> fairing::Result { Some(roots) => match Db::fetch(&rocket) { Some(db) => match setup_projects_roots(roots, config, db).await { Ok(_) => Ok(rocket), - Err(_) => Err(rocket), + Err(e) => { + error!("{e:?}"); + Err(rocket) + } }, None => Err(rocket), }, diff --git a/server/src/main.rs b/server/src/main.rs index 9bdfeaf..973febe 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -36,6 +36,7 @@ struct Db(sqlx::MySqlPool); #[derive(OpenApi)] #[openapi( paths( + healthcheck, projects, project, project_new, @@ -107,6 +108,18 @@ async fn projects( }) } +fn abbrivate_key(key: &str) -> String { + let len = key.len(); + if len <= 24 { + // Realistically this will only happen for empty keys, + // but make sure to NEVER send the whole key if it is really short. + return String::new(); + } + let start = &key[0..8]; + let end = &key[len - 8..len]; + return format!("{start}...{end}"); +} + async fn get_project( db: &mut <<Db as Database>::Pool as Pool>::Connection, projectid: &str, @@ -129,7 +142,7 @@ async fn get_project( .unwrap(); let project = sqlx::query!( - "SELECT id,title,description,remote,main_branch FROM projects WHERE id=?", + "SELECT id,title,description,remote,remote_key,main_branch FROM projects WHERE id=?", projectid ) .fetch_one(&mut **db) @@ -138,6 +151,7 @@ async fn get_project( title: r.title, description: r.description, remote: r.remote, + remote_key_abbrev: abbrivate_key(r.remote_key.as_str()), main_branch: r.main_branch, users, }) @@ -166,6 +180,19 @@ async fn project( get_project(&mut conn, projectid).await } +// Remove linebreaks and potential openssh wrapper +fn cleanup_key(key: &str) -> String { + let mut ret = String::with_capacity(key.len()); + let mut lines = key.lines(); + while let Some(line) = lines.next() { + match line { + "-----BEGIN OPENSSH PRIVATE KEY-----" | "-----END OPENSSH PRIVATE KEY-----" => {} + _ => ret.push_str(line), + } + } + return ret; +} + #[utoipa::path( responses( (status = 200, description = "Project created", body = api_model::Project), @@ -185,6 +212,10 @@ async fn project_new( data: Json<api_model::ProjectData<'_>>, ) -> Result<Json<api_model::Project>, Custom<String>> { let remote = data.remote.unwrap_or(""); + let remote_key = match &data.remote_key { + Some(data) => cleanup_key(data.as_str()), + None => String::new(), + }; let main_branch = data.main_branch.unwrap_or("main"); let mut conn = db.get().await.unwrap(); @@ -192,11 +223,12 @@ async fn project_new( let mut tx = conn.begin().await.unwrap(); sqlx::query!( - "INSERT INTO projects (id, title, description, remote, main_branch) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO projects (id, title, description, remote, remote_key, main_branch) VALUES (?, ?, ?, ?, ?, ?)", projectid, data.title.unwrap_or("Unnamed"), data.description.unwrap_or(""), remote, + remote_key, main_branch, ) .execute(&mut *tx) @@ -214,8 +246,15 @@ async fn project_new( } roots_state - .new_project(git_roots_config, db, projectid, remote, main_branch) - .map_err(|e| Custom(Status::InternalServerError, e.message)) + .new_project( + git_roots_config, + db, + projectid, + remote, + remote_key.as_str(), + main_branch, + ) + .map_err(|e| Custom(Status::InternalServerError, format!("{e}"))) .await?; Ok(get_project(&mut conn, projectid).await.unwrap()) @@ -278,6 +317,9 @@ async fn project_update( if let Some(remote) = &data.remote { update_builder.set("remote", remote); } + if let Some(remote_key) = &data.remote_key { + update_builder.set("remote_key", cleanup_key(remote_key)); + } if let Some(main_branch) = &data.main_branch { update_builder.set("main_branch", main_branch); } @@ -1060,6 +1102,19 @@ async fn user_keys( }) } +#[utoipa::path( + responses( + (status = 200, description = "All good"), + ), + security( + (), + ), +)] +#[get("/healthcheck")] +fn healthcheck() -> &'static str { + "" +} + async fn run_migrations(rocket: Rocket<Build>) -> fairing::Result { match Db::fetch(&rocket) { Some(db) => match sqlx::migrate!().run(&**db).await { @@ -1082,6 +1137,7 @@ fn rocket_from_config(figment: Figment) -> Rocket<Build> { basepath, // Remember to update openapi paths when you add something here. routes