🐸 Frogue(Fed)

~ololduck/Frogue

Raw

//! Adding and removing the SSH keys a user connects with.
//!
//! A key is stored in the canonical OpenSSH one-line form, not as it was
//! pasted, so that what the server shows back is exactly what the server
//! holds, and the fingerprint - which is what a connection is looked up by -
//! is computed here rather than trusted from the request.

use std::sync::Arc;

use actix_web::{HttpResponse, get, post, web};
use russh::keys::HashAlg;
use russh::keys::ssh_key::PublicKey;
use sea_orm::ActiveValue::Set;
use sea_orm::{ActiveModelTrait, ColumnTrait, DbErr, EntityTrait, QueryFilter};
use serde::Deserialize;
use tokio::sync::RwLock;
use tracing::error;

use crate::AppState;
use crate::auth::CurrentUser;
use crate::entities::ssh_keys;
use crate::state_read;
use crate::views::{self, SshKeyForm, SshKeySummary};
use crate::webui::repositories::acting_user;

/// What the add-key form posts.
#[derive(Debug, Deserialize)]
pub struct NewSshKey {
    key: String,
}

/// The longest a key's name (its comment) is kept. The column allows 255; a
/// comment longer than this is trimmed rather than refused.
const MAX_KEY_NAME: usize = 255;

#[get("/~{user_or_team}/ssh-keys")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn get_ssh_keys(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    user_or_team: web::Path<String>,
) -> HttpResponse {
    let user = match acting_user(&data, &current, user_or_team.as_str()).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    let keys = {
        let state = state_read(&data).await;
        match list_keys(&state, user.id).await {
            Ok(keys) => keys,
            Err(error) => {
                error!(%error, owner = %user.name, "listing SSH keys failed");
                return HttpResponse::InternalServerError().finish();
            }
        }
    };

    views::ssh_keys(
        Some(&current.name),
        &user.name,
        &keys,
        &SshKeyForm::default(),
    )
}

#[post("/~{user_or_team}/ssh-keys")]
#[tracing::instrument(skip_all, fields(user_or_team = %user_or_team.as_str()))]
async fn post_ssh_key(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    user_or_team: web::Path<String>,
    form: web::Form<NewSshKey>,
) -> HttpResponse {
    let user = match acting_user(&data, &current, user_or_team.as_str()).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    let pasted = form.into_inner().key;
    let trimmed = pasted.trim();

    // Parsing here rather than storing the text as given: a key that does not
    // parse would sit in the table looking fine while never authenticating
    // anything, which is the worst of both.
    let key = match PublicKey::from_openssh(trimmed) {
        Ok(key) => key,
        Err(error) => {
            return views::ssh_keys(
                Some(&current.name),
                &user.name,
                &list_or_empty(&data, user.id).await,
                &SshKeyForm {
                    key: Some(pasted),
                    error: Some(format!(
                        "That is not a public key this understands ({error}). Paste the \
                         contents of a `.pub` file, or the output of `ssh-add -L`."
                    )),
                },
            );
        }
    };

    let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
    let algorithm = key.algorithm().to_string();

    // The comment is what the user called the key, and is optional. Falling
    // back to the fingerprint keeps the list readable when it is empty.
    let name = {
        let comment = key.comment().to_string();
        let comment = comment.trim();
        if comment.is_empty() {
            fingerprint.clone()
        } else {
            comment.chars().take(MAX_KEY_NAME).collect()
        }
    };

    let stored = key.to_openssh().unwrap_or_else(|_| trimmed.to_string());

    let state = state_read(&data).await;

    let existing = match ssh_keys::Entity::find()
        .filter(ssh_keys::Column::Fingerprint.eq(&fingerprint))
        .one(&state.db)
        .await
    {
        Ok(existing) => existing,
        Err(error) => {
            error!(%error, user = %user.name, "checking for an existing SSH key failed");
            drop(state);
            return HttpResponse::InternalServerError().finish();
        }
    };

    if let Some(existing) = existing {
        let message = if existing.user_id == user.id {
            "That key is already on your account.".to_string()
        } else {
            // Deliberately vague about whose it is, but not silent: the person
            // pasting it should know it will not work for them.
            "That key is already registered to another account.".to_string()
        };
        drop(state);

        return views::ssh_keys(
            Some(&current.name),
            &user.name,
            &list_or_empty(&data, user.id).await,
            &SshKeyForm {
                key: Some(pasted),
                error: Some(message),
            },
        );
    }

    let inserted = ssh_keys::ActiveModel {
        user_id: Set(user.id),
        name: Set(name),
        algorithm: Set(algorithm),
        fingerprint: Set(fingerprint),
        public_key: Set(stored),
        ..Default::default()
    }
    .insert(&state.db)
    .await;

    drop(state);

    match inserted {
        Ok(_) => HttpResponse::SeeOther()
            .append_header(("Location", format!("/~{}/ssh-keys", user.name)))
            .finish(),
        Err(error) => {
            error!(%error, user = %user.name, "storing an SSH key failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

#[post("/~{user_or_team}/ssh-keys/{id}/delete")]
#[tracing::instrument(
    skip_all,
    fields(owner = tracing::field::Empty, key_id = tracing::field::Empty)
)]
async fn post_delete_ssh_key(
    data: web::Data<Arc<RwLock<AppState>>>,
    current: CurrentUser,
    path: web::Path<(String, i64)>,
) -> HttpResponse {
    let (user_or_team, id) = path.into_inner();

    let span = tracing::Span::current();
    span.record("owner", user_or_team.as_str());
    span.record("key_id", id);

    let user = match acting_user(&data, &current, &user_or_team).await {
        Ok(user) => user,
        Err(response) => return *response,
    };

    let state = state_read(&data).await;

    // Scoped by the owner as well as the id: the id alone would let a signed-in
    // user delete somebody else's key by guessing a number.
    let deleted = ssh_keys::Entity::delete_many()
        .filter(ssh_keys::Column::Id.eq(id))
        .filter(ssh_keys::Column::UserId.eq(user.id))
        .exec(&state.db)
        .await;

    drop(state);

    match deleted {
        // No rows removed is not an error worth showing: the key is not there,
        // which is what the visitor asked for.
        Ok(_) => HttpResponse::SeeOther()
            .append_header(("Location", format!("/~{}/ssh-keys", user.name)))
            .finish(),
        Err(error) => {
            error!(%error, user = %user.name, id, "deleting an SSH key failed");
            HttpResponse::InternalServerError().finish()
        }
    }
}

/// `owner_id`'s keys, as the list renders them.
///
/// A database failure is handed back as itself rather than as a response: the
/// caller decides what a page should say about it, and this is a data
/// question, not a presentation one.
async fn list_keys(state: &AppState, owner_id: i64) -> Result<Vec<SshKeySummary>, DbErr> {
    let keys = ssh_keys::Entity::find()
        .filter(ssh_keys::Column::UserId.eq(owner_id))
        .all(&state.db)
        .await?;

    Ok(keys
        .into_iter()
        .map(|key| SshKeySummary {
            id: key.id,
            name: key.name,
            algorithm: key.algorithm,
            fingerprint: key.fingerprint,
            last_used: key
                .last_used_at
                .map(|at| at.format("%Y-%m-%d %H:%M UTC").to_string()),
        })
        .collect())
}

/// The list to render a form back with, degraded to empty when it cannot be
/// read: a failure to list is worth a page without the list, not a page
/// without the form the visitor was trying to fill in.
async fn list_or_empty(
    data: &web::Data<Arc<RwLock<AppState>>>,
    owner_id: i64,
) -> Vec<SshKeySummary> {
    let state = state_read(data).await;
    list_keys(&state, owner_id).await.unwrap_or_default()
}