tirbofish/kitgit · diff
merge: feat/deploy-keys into integrate/all-features
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,14 @@ +-- Repo-scoped SSH deploy keys (read-only or read-write). +CREATE TABLE IF NOT EXISTS deploy_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'deploy', + public_key TEXT NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + read_only BOOLEAN NOT NULL DEFAULT TRUE, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS deploy_keys_repo_id_idx ON deploy_keys(repo_id); +CREATE INDEX IF NOT EXISTS deploy_keys_fingerprint_idx ON deploy_keys(fingerprint); @@ -0,0 +1,98 @@ +//! Repo-scoped SSH deploy keys (read-only or read-write). + +use anyhow::Result; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct DeployKey { + pub id: Uuid, + pub repo_id: Uuid, + pub name: String, + pub public_key: String, + pub fingerprint: String, + pub read_only: bool, + pub created_by: Option<Uuid>, + pub created_at: DateTime<Utc>, +} + +impl DeployKey { + pub fn permission_label(&self) -> &'static str { + if self.read_only { + "Read-only" + } else { + "Read-write" + } + } +} + +pub async fn add_deploy_key( + pool: &PgPool, + repo_id: Uuid, + name: &str, + public_key: &str, + fingerprint: &str, + read_only: bool, + created_by: Option<Uuid>, +) -> Result<DeployKey> { + let clash: Option<(Uuid,)> = sqlx::query_as( + "SELECT id FROM ssh_keys WHERE rtrim(fingerprint, '=') = rtrim($1, '=') LIMIT 1", + ) + .bind(fingerprint) + .fetch_optional(pool) + .await?; + if clash.is_some() { + anyhow::bail!("fingerprint already registered as a user SSH key"); + } + Ok(sqlx::query_as::<_, DeployKey>( + r#" + INSERT INTO deploy_keys (repo_id, name, public_key, fingerprint, read_only, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + "#, + ) + .bind(repo_id) + .bind(name) + .bind(public_key) + .bind(fingerprint) + .bind(read_only) + .bind(created_by) + .fetch_one(pool) + .await?) +} + +pub async fn list_deploy_keys(pool: &PgPool, repo_id: Uuid) -> Result<Vec<DeployKey>> { + Ok(sqlx::query_as::<_, DeployKey>( + "SELECT * FROM deploy_keys WHERE repo_id = $1 ORDER BY created_at", + ) + .bind(repo_id) + .fetch_all(pool) + .await?) +} + +pub async fn delete_deploy_key(pool: &PgPool, repo_id: Uuid, id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM deploy_keys WHERE id = $1 AND repo_id = $2") + .bind(id) + .bind(repo_id) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn deploy_key_by_fingerprint( + pool: &PgPool, + fingerprint: &str, +) -> Result<Option<DeployKey>> { + let normalized = fingerprint.trim_end_matches('='); + Ok(sqlx::query_as::<_, DeployKey>( + r#" + SELECT * FROM deploy_keys + WHERE rtrim(fingerprint, '=') = $1 + "#, + ) + .bind(normalized) + .fetch_optional(pool) + .await?) +} @@ -1,6 +1,9 @@ +pub mod deploy_keys; pub mod models; pub mod queries; +pub use deploy_keys::DeployKey; + use anyhow::{Context, Result}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; @@ -101,28 +101,46 @@ impl Server for SshServer { state: self.state.clone(), user_id: None, username: None, + deploy_key: None, stdin: None, receive_meta: None, } } } +/// Authenticated via a repo-scoped deploy key (not a user key). +struct DeployKeyAuth { + repo_id: Uuid, + read_only: bool, + created_by: Option<Uuid>, + name: String, +} + struct SshHandler { state: AppState, user_id: Option<Uuid>, username: Option<String>, + deploy_key: Option<DeployKeyAuth>, stdin: Option<Arc<Mutex<tokio::process::ChildStdin>>>, receive_meta: Option<(String, String)>, } impl SshHandler { + fn is_authenticated(&self) -> bool { + self.user_id.is_some() || self.deploy_key.is_some() + } + /// Print `static/text.txt` (with `{user}` substituted) and close — no shell access. async fn greet_and_quit( &self, channel: ChannelId, session: &mut Session, ) -> Result<(), anyhow::Error> { - let username = self.username.as_deref().unwrap_or("git"); + let username = self + .username + .as_deref() + .or_else(|| self.deploy_key.as_ref().map(|d| d.name.as_str())) + .unwrap_or("git"); let path = self.state.config.static_dir.join("text.txt"); let mut msg = tokio::fs::read_to_string(&path).await.unwrap_or_else(|_| { format!( @@ -159,6 +177,20 @@ impl Handler for SshHandler { } self.user_id = Some(user.id); self.username = Some(user.username); + self.deploy_key = None; + return Ok(Auth::Accept); + } + if let Some(key) = + crate::db::deploy_keys::deploy_key_by_fingerprint(&self.state.pool, &fp).await? + { + self.user_id = None; + self.username = Some(format!("deploy:{}", key.name)); + self.deploy_key = Some(DeployKeyAuth { + repo_id: key.repo_id, + read_only: key.read_only, + created_by: key.created_by, + name: key.name, + }); return Ok(Auth::Accept); } tracing::debug!("ssh publickey rejected fp={fp}"); @@ -205,7 +237,7 @@ impl Handler for SshHandler { channel: ChannelId, session: &mut Session, ) -> Result<(), Self::Error> { - if self.user_id.is_none() { + if !self.is_authenticated() { session.channel_failure(channel)?; return Ok(()); } @@ -219,10 +251,10 @@ impl Handler for SshHandler { session: &mut Session, ) -> Result<(), Self::Error> { let cmd = String::from_utf8_lossy(data).to_string(); - let Some(user_id) = self.user_id else { + if !self.is_authenticated() { session.channel_failure(channel)?; return Ok(()); - }; + } let (service, repo_path) = match parse_git_command(&cmd) { Ok(v) => v, @@ -239,11 +271,26 @@ impl Handler for SshHandler { session.close(channel)?; return Ok(()); }; - let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?; - let allowed = match service { - "git-upload-pack" => access.can_read(), - "git-receive-pack" => access.can_write() && !repo.archived, - _ => false, + + let allowed = if let Some(dk) = &self.deploy_key { + // Deploy keys only unlock the single repo they were added to. + if dk.repo_id != repo.id { + false + } else { + match service { + "git-upload-pack" => true, + "git-receive-pack" => !dk.read_only && !repo.archived, + _ => false, + } + } + } else { + let user_id = self.user_id.expect("authenticated without deploy key"); + let access = queries::repo_access(&self.state.pool, &repo, Some(user_id)).await?; + match service { + "git-upload-pack" => access.can_read(), + "git-receive-pack" => access.can_write() && !repo.archived, + _ => false, + } }; if !allowed { session.data(channel, b"access denied\n".as_slice())?; @@ -311,33 +358,39 @@ impl Handler for SshHandler { let mut g = stdin.lock().await; let _ = g.shutdown().await; } - if let (Some(uid), Some((owner, name))) = (self.user_id, self.receive_meta.take()) { - if let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await { - if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await { - let _ = queries::record_activity( - &self.state.pool, - Some(user.id), - Some(repo.id), - "push", - "pushed", - serde_json::json!({}), - ) - .await; - let _ = queries::bump_commit_activity( - &self.state.pool, - user.id, - chrono::Utc::now().date_naive(), - 1, - ) - .await; - if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name) - { - if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) { - let stats = crate::git::languages::detect_languages(&files); - let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await; - } - } - } + let Some((owner, name)) = self.receive_meta.take() else { + return Ok(()); + }; + let Ok(Some((repo, _))) = queries::get_repo(&self.state.pool, &owner, &name).await else { + return Ok(()); + }; + let actor_id = self + .user_id + .or_else(|| self.deploy_key.as_ref().and_then(|d| d.created_by)); + if let Some(uid) = actor_id { + if let Ok(Some(user)) = queries::get_user_by_id(&self.state.pool, uid).await { + let _ = queries::record_activity( + &self.state.pool, + Some(user.id), + Some(repo.id), + "push", + "pushed", + serde_json::json!({}), + ) + .await; + let _ = queries::bump_commit_activity( + &self.state.pool, + user.id, + chrono::Utc::now().date_naive(), + 1, + ) + .await; + } + } + if let Ok(g) = crate::git::open_bare(&self.state.config.repos_dir(), &owner, &name) { + if let Ok(files) = crate::git::walk_files(&g, &repo.default_branch) { + let stats = crate::git::languages::detect_languages(&files); + let _ = queries::set_language_stats(&self.state.pool, repo.id, stats).await; } } Ok(()) @@ -377,4 +430,4 @@ fn split_repo_path(path: &str) -> Result<(String, String)> { let owner = parts.next().context("owner")?.to_string(); let name = parts.next().context("name")?.to_string(); Ok((owner, name)) -} +} @@ -267,6 +267,14 @@ pub fn app_router(state: AppState) -> Router { post(routes::collab_remove), ) .route( + "/{owner}/{repo}/settings/deploy-keys", + post(routes::deploy_key_add), + ) + .route( + "/{owner}/{repo}/settings/deploy-keys/{id}/delete", + post(routes::deploy_key_delete), + ) + .route( "/{owner}/{repo}/settings/branch-rules", post(routes_extra::branch_rule_add), ) @@ -3925,7 +3925,8 @@ pub async fn repo_settings( } let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?; - let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?; +let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?; + let deploy_keys = crate::db::deploy_keys::list_deploy_keys(&state.pool, repository.id).await?; Ok(RepoSettingsTemplate { viewer, owner: owner_user, @@ -3937,6 +3938,7 @@ pub async fn repo_settings( clone_ssh, branch_rules, mirror, + deploy_keys, is_site_admin, error: None, }) @@ -4067,6 +4069,61 @@ pub async fn collab_remove( } #[derive(Deserialize)] +pub struct DeployKeyAddForm { + pub name: String, + pub public_key: String, + /// When set (checkbox), key may push; otherwise read-only. + pub write_access: Option<String>, +} + +pub async fn deploy_key_add( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<DeployKeyAddForm>, +) -> AppResult<Response> { + let (repository, _o, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + let created_by = viewer.map(|u| u.id); + let name = form.name.trim(); + let public_key = form.public_key.trim(); + if name.is_empty() || public_key.is_empty() { + return Err(AppError::bad("name and public key required")); + } + let fp = fingerprint_ssh_pubkey(public_key).map_err(|e| AppError::bad(e.to_string()))?; + let read_only = !checkbox(&form.write_access); + crate::db::deploy_keys::add_deploy_key( + &state.pool, + repository.id, + name, + public_key, + &fp, + read_only, + created_by, + ) + .await + .map_err(|e| AppError::bad(format!("could not add deploy key: {e}")))?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn deploy_key_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo, id)): Path<(String, String, Uuid)>, +) -> AppResult<Response> { + let (repository, _o, _viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + if !access.can_admin() { + return Err(AppError::forbidden()); + } + crate::db::deploy_keys::delete_deploy_key(&state.pool, repository.id, id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +#[derive(Deserialize)] pub struct ArchiveForm { pub archived: Option<String>, } @@ -3,6 +3,7 @@ use crate::db::models::{ Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, RepoMirror, Repository, SshKey, User, UserEmail, }; +use crate::db::DeployKey; use askama::Template; use askama_web::WebTemplate; use chrono::{DateTime, Utc}; @@ -531,7 +532,8 @@ pub struct RepoSettingsTemplate { pub collaborators: Vec<CollaboratorView>, pub branches: Vec<String>, pub branch_rules: Vec<BranchRule>, - pub mirror: Option<RepoMirror>, +pub mirror: Option<RepoMirror>, + pub deploy_keys: Vec<DeployKey>, pub clone_http: String, pub clone_ssh: String, pub is_site_admin: bool, @@ -243,6 +243,47 @@ </form> </div> + <div class="kg-settings-section"> + <span class="kg-kicker">deploy keys</span> + <p class="kg-muted">Repo-scoped SSH keys for CI and automation. Read-only by default; write access allows push to this repository only.</p> + {% if deploy_keys.is_empty() %} + <p class="kg-muted">No deploy keys.</p> + {% else %} + <ul class="kg-list"> + {% for key in deploy_keys %} + <li> + <span> + <strong>{{ key.name }}</strong> + <span class="kg-meta">{{ key.fingerprint }}</span> + <span class="kg-badge">{{ key.permission_label() }}</span> + </span> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/deploy-keys/{{ key.id }}/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button> + </form> + </li> + {% endfor %} + </ul> + {% endif %} + + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/deploy-keys" style="margin-top:1rem"> + <label> + name + <input type="text" name="name" required placeholder="ci" /> + </label> + <label> + public key + <textarea name="public_key" rows="3" required placeholder="ssh-ed25519 AAAA…"></textarea> + </label> + <label class="row"> + <input type="checkbox" name="write_access" /> + allow write access (push) + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">add deploy key</button> + </div> + </form> + </div> + {% if access.can_owner() || is_site_admin %} <div class="kg-danger-zone"> <span class="kg-kicker">danger zone</span>