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);
}
}