AkurAI Build
Menu

Public repository / 8d41f1e10aceed387d425b43038f2070e399a349

AkurAI-Build / src/server/ssh_api.rs

Latest change e8aa34a9be50d7d7ea72f66991ff82dfca7d23e1 - OLI-276: Git over SSH, forks and mirrors, code search through akurai-index, repository insights by Ólafur Búi Ólafsson

//! SSH keys per user (OLI-276): API, settings page, and the authorized_keys
//! file the forge rewrites after every change.

use std::{collections::BTreeMap, path::PathBuf, sync::Arc};

use axum::{
    Json,
    body::{Body, to_bytes},
    extract::{Path, State},
    http::{HeaderMap, Request, StatusCode},
    response::{Html, IntoResponse, Redirect, Response},
};
use minijinja::context;
use serde::Deserialize;
use tracing::{info, warn};

use super::access_api::{Identity, identify};
use super::{ApiError, AppState, Envelope, ok, unix_time, valid_session};
use crate::ssh_keys::{self, ShellCommand, SshKey};

/// Where the forge keeps the SSH account's authorized keys and how each line
/// is forced into `akurai git-shell`.
#[derive(Clone, Debug)]
pub struct AuthorizedKeysSink {
    pub path: PathBuf,
    pub shell: ShellCommand,
}

/// Rewrite the authorized_keys file from the database (no-op when SSH is off).
pub(super) fn publish_authorized_keys(state: &AppState) {
    let Some(sink) = &state.authorized_keys else {
        return;
    };
    match ssh_keys::authorized_keys(&state.database, &sink.shell)
        .and_then(|text| ssh_keys::write_authorized_keys(&sink.path, &text))
    {
        Ok(()) => info!(path = %sink.path.display(), "authorized_keys rewritten"),
        Err(error) => warn!(%error, path = %sink.path.display(), "authorized_keys not written"),
    }
}

/// The signed-in person (session or PAT); deploy keys and the controller token own no keys.
fn subject_of(headers: &HeaderMap, state: &AppState) -> Result<String, ApiError> {
    match identify(headers, state)? {
        Identity::Session { email, .. } => Ok(email),
        Identity::Token(principal) => principal
            .subject
            .ok_or_else(|| ApiError::forbidden("deploy keys cannot own SSH keys")),
        Identity::Master => Err(ApiError::forbidden(
            "the controller token owns no SSH keys; sign in or use a personal access token",
        )),
    }
}

#[derive(Deserialize)]
pub(super) struct NewKey {
    name: String,
    public_key: String,
}

pub(super) async fn list(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Json<Envelope<Vec<SshKey>>>, ApiError> {
    let subject = subject_of(&headers, &state)?;
    let keys = ssh_keys::keys_of(&state.database, &subject).map_err(ApiError::internal)?;
    Ok(ok(keys))
}

pub(super) async fn create(
    State(state): State<AppState>,
    headers: HeaderMap,
    Json(body): Json<NewKey>,
) -> Result<(StatusCode, Json<Envelope<SshKey>>), ApiError> {
    let subject = subject_of(&headers, &state)?;
    let key = ssh_keys::add_key(&state.database, &subject, &body.name, &body.public_key)
        .map_err(ApiError::bad)?;
    publish_authorized_keys(&state);
    Ok((StatusCode::CREATED, ok(key)))
}

pub(super) async fn remove(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<i64>,
) -> Result<Json<Envelope<serde_json::Value>>, ApiError> {
    let subject = subject_of(&headers, &state)?;
    if !ssh_keys::remove_key(&state.database, &subject, id).map_err(ApiError::internal)? {
        return Err(ApiError::not_found("no such SSH key of yours"));
    }
    publish_authorized_keys(&state);
    Ok(ok(serde_json::json!({ "id": id, "removed": true })))
}

/// `git@host:Name.git` style address shown on the page, when SSH is on.
fn ssh_address(state: &AppState, repository: &str) -> Option<String> {
    state
        .ssh_address
        .as_deref()
        .map(|address| format!("{address}:{repository}.git"))
}

pub(super) async fn page(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Response, ApiError> {
    if !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    render(&state, &headers, None).await
}

async fn render(
    state: &AppState,
    headers: &HeaderMap,
    error: Option<String>,
) -> Result<Response, ApiError> {
    let subject = subject_of(headers, state)?;
    let keys = ssh_keys::keys_of(&state.database, &subject).map_err(ApiError::internal)?;
    let rendered = state
        .templates
        .get_template("ssh-keys.html")
        .map_err(ApiError::internal)?
        .render(context! {
            version => env!("CARGO_PKG_VERSION"),
            keys => keys,
            subject => subject,
            enabled => state.authorized_keys.is_some(),
            example => ssh_address(state, "Name"),
            error => error,
            now => unix_time()?,
        })
        .map_err(ApiError::internal)?;
    Ok(Html(rendered).into_response())
}

/// Minimal `application/x-www-form-urlencoded` parsing (axum's form feature is off).
pub(super) fn form_fields(body: &str) -> BTreeMap<String, String> {
    fn decode(text: &str) -> String {
        let bytes = text.as_bytes();
        let mut out = Vec::with_capacity(bytes.len());
        let mut i = 0;
        while i < bytes.len() {
            match bytes[i] {
                b'+' => out.push(b' '),
                b'%' if i + 2 < bytes.len() => {
                    let hex = &text[i + 1..i + 3];
                    match u8::from_str_radix(hex, 16) {
                        Ok(byte) => {
                            out.push(byte);
                            i += 2;
                        }
                        Err(_) => out.push(b'%'),
                    }
                }
                byte => out.push(byte),
            }
            i += 1;
        }
        String::from_utf8_lossy(&out).into_owned()
    }
    body.split('&')
        .filter(|pair| !pair.is_empty())
        .map(|pair| match pair.split_once('=') {
            Some((key, value)) => (decode(key), decode(value)),
            None => (decode(pair), String::new()),
        })
        .collect()
}

const MAX_FORM: usize = 16 * 1024;

pub(super) async fn page_add(
    State(state): State<AppState>,
    headers: HeaderMap,
    request: Request<Body>,
) -> Result<Response, ApiError> {
    if !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let subject = subject_of(&headers, &state)?;
    let body = to_bytes(request.into_body(), MAX_FORM)
        .await
        .map_err(ApiError::bad)?;
    let fields = form_fields(&String::from_utf8_lossy(&body));
    let name = fields.get("name").map(String::as_str).unwrap_or_default();
    let key = fields
        .get("public_key")
        .map(String::as_str)
        .unwrap_or_default();
    match ssh_keys::add_key(&state.database, &subject, name, key) {
        Ok(_) => {
            publish_authorized_keys(&state);
            Ok(Redirect::to("/app/settings/ssh-keys").into_response())
        }
        Err(error) => render(&state, &headers, Some(error.to_string())).await,
    }
}

pub(super) async fn page_remove(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(id): Path<i64>,
) -> Result<Response, ApiError> {
    if !valid_session(&headers, &state) {
        return Ok(Redirect::to("/login").into_response());
    }
    let subject = subject_of(&headers, &state)?;
    if ssh_keys::remove_key(&state.database, &subject, id).map_err(ApiError::internal)? {
        publish_authorized_keys(&state);
    }
    Ok(Redirect::to("/app/settings/ssh-keys").into_response())
}

/// Build the sink for `serve`: the current binary with the same global arguments.
pub fn sink_for(
    path: PathBuf,
    data: &std::path::Path,
    key_file: &std::path::Path,
    token_file: &std::path::Path,
    forge_url: &str,
    admins: &[String],
) -> anyhow::Result<Arc<AuthorizedKeysSink>> {
    let exe = std::env::current_exe()?;
    let mut prefix = vec![
        exe.to_string_lossy().into_owned(),
        "--data".into(),
        data.to_string_lossy().into_owned(),
    ];
    prefix.extend(["--key-file".into(), key_file.to_string_lossy().into_owned()]);
    let mut suffix = vec![
        "--token-file".into(),
        token_file.to_string_lossy().into_owned(),
        "--forge-url".into(),
        forge_url.to_owned(),
    ];
    if !admins.is_empty() {
        suffix.extend(["--admins".into(), admins.join(",")]);
    }
    Ok(Arc::new(AuthorizedKeysSink {
        path,
        shell: ShellCommand { prefix, suffix },
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::{
        Router,
        http::header,
        routing::{delete, get, post},
    };
    use tower::ServiceExt;

    fn app(state: AppState) -> Router {
        Router::new()
            .route("/api/ssh-keys", get(list).post(create))
            .route("/api/ssh-keys/{id}", delete(remove))
            .route("/app/settings/ssh-keys", get(page).post(page_add))
            .route("/app/settings/ssh-keys/{id}/remove", post(page_remove))
            .with_state(state)
    }

    async fn send(
        app: &Router,
        method: &str,
        uri: &str,
        auth: Option<&str>,
        body: serde_json::Value,
    ) -> (StatusCode, serde_json::Value) {
        let mut request = Request::builder()
            .method(method)
            .uri(uri)
            .header(header::CONTENT_TYPE, "application/json");
        if let Some(auth) = auth {
            request = request.header(header::AUTHORIZATION, auth);
        }
        let response = app
            .clone()
            .oneshot(request.body(Body::from(body.to_string())).expect("request"))
            .await
            .expect("response");
        let status = response.status();
        let bytes = to_bytes(response.into_body(), usize::MAX)
            .await
            .expect("body");
        (
            status,
            serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null),
        )
    }

    #[test]
    fn form_fields_decode_plus_and_percent_escapes() {
        let fields =
            form_fields("name=my+laptop&public_key=ssh-ed25519%20AAAA%2Bx%3D%3D%20c&empty=&flag");
        assert_eq!(fields["name"], "my laptop");
        assert_eq!(fields["public_key"], "ssh-ed25519 AAAA+x== c");
        assert_eq!(fields["empty"], "");
        assert_eq!(fields["flag"], "");
    }

    #[tokio::test]
    async fn keys_belong_to_the_token_subject_and_rewrite_the_authorized_keys_file() {
        let dir = tempfile::tempdir().expect("tempdir");
        let mut state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        state.authorized_keys = Some(Arc::new(AuthorizedKeysSink {
            path: dir.path().join("akurai_build_keys"),
            shell: ShellCommand {
                prefix: vec!["/opt/akurai".into()],
                suffix: vec![],
            },
        }));
        let now = unix_time().ok().expect("now") as i64;
        let (_, ada) = crate::access::create_token(
            &state.database,
            &crate::access::NewToken {
                kind: crate::access::Kind::Pat,
                name: "t",
                subject: Some("ada@example"),
                repository: None,
                role: crate::access::Role::Admin,
                ttl_secs: None,
            },
            now,
        )
        .expect("pat");
        let app = app(state.clone());
        let bearer = format!("Bearer {ada}");

        let (status, body) =
            send(&app, "GET", "/api/ssh-keys", None, serde_json::Value::Null).await;
        assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}");
        let (status, _) = send(
            &app,
            "GET",
            "/api/ssh-keys",
            Some("Bearer api-token-123"),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(
            status,
            StatusCode::FORBIDDEN,
            "controller token owns no keys"
        );

        let (status, body) = send(
            &app,
            "POST",
            "/api/ssh-keys",
            Some(&bearer),
            serde_json::json!({"name": "laptop", "public_key": crate::ssh_keys::testing::ED25519}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "{body}");
        let id = body["data"]["id"].as_i64().expect("id");
        assert_eq!(body["data"]["subject"], "ada@example");
        let file = std::fs::read_to_string(dir.path().join("akurai_build_keys")).expect("written");
        assert!(
            file.contains(&format!(
                "/opt/akurai git-shell --key-id {id}\",restrict ssh-ed25519"
            )),
            "{file}"
        );

        let (status, body) = send(
            &app,
            "POST",
            "/api/ssh-keys",
            Some(&bearer),
            serde_json::json!({"name": "again", "public_key": crate::ssh_keys::testing::ED25519}),
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");

        let (status, body) = send(
            &app,
            "GET",
            "/api/ssh-keys",
            Some(&bearer),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        assert_eq!(body["data"].as_array().map(Vec::len), Some(1));

        let (status, _) = send(
            &app,
            "DELETE",
            &format!("/api/ssh-keys/{id}"),
            Some(&bearer),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::OK);
        let file =
            std::fs::read_to_string(dir.path().join("akurai_build_keys")).expect("rewritten");
        assert!(!file.contains("ssh-ed25519"), "{file}");
        let (status, _) = send(
            &app,
            "DELETE",
            &format!("/api/ssh-keys/{id}"),
            Some(&bearer),
            serde_json::Value::Null,
        )
        .await;
        assert_eq!(status, StatusCode::NOT_FOUND);
    }
}