feat: add basic ssh manager application

This commit is contained in:
kjuulh 2025-07-06 20:05:22 +02:00
commit 0b098b25ba
11 changed files with 2033 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1647
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "nossh"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.98"
clap = { version = "4.5.40", features = ["derive", "env"] }
skim = "0.20.2"
tokio = { version = "1.46.1", features = ["full"] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

19
DESIGN.md Normal file
View File

@ -0,0 +1,19 @@
# Design
The tools is an ssh tool for quickly finding your relevant ssh endpoints. Using a fuzzy finder.
```
$ nossh
1. ratchet:22
2. somegateway:222
# 3. git.front.kjuulh.io # This is the selected item
> git.fr
# pressed: Enter
git.front.kjuulh.io$: echo 'now at this machine'
```
Based on its own config
Based on ~/.ssh/config
Cache history
nossh can be used as just a normal ssh

44
src/commands.rs Normal file
View File

@ -0,0 +1,44 @@
pub mod interactive {
use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::{
ssh_command::SshCommandState, ssh_config::SshConfigServiceState, state::State,
user_fuzzy_find::UserFuzzyFindState,
};
#[derive(clap::Parser, Default)]
pub struct InteractiveCommand {}
impl InteractiveCommand {
pub fn new() -> Self {
Self::default()
}
pub async fn execute(&self, state: &State, ssh_config_path: &Path) -> anyhow::Result<()> {
// 1. Get a list of items in ~/.ssh/config
let items = state
.ssh_config_service()
.get_ssh_items(ssh_config_path)
.await
.context("failed to get ssh items")?;
tracing::trace!("found ssh items: {:#?}", items);
// 2. Present the list, and allow the user to choose an item
let item = state
.user_fuzzy_find()
.get_ssh_item_from_user(&items)
.await?;
tracing::debug!("found ssh item: '{}'", item);
// 3. Perform ssh
// call the cmdline parse in all pipes, with the hostname as the destination
// ssh something
state.ssh_command().start_ssh_session(item).await?;
Ok(())
}
}
}

89
src/main.rs Normal file
View File

@ -0,0 +1,89 @@
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use crate::{
commands::interactive::InteractiveCommand, ssh_command::SshCommandState,
ssh_config::SshConfigServiceState, state::State, user_fuzzy_find::UserFuzzyFindState,
};
mod commands;
mod ssh_command;
mod ssh_config;
mod state;
mod user_fuzzy_find;
#[derive(Parser)]
#[command(author, version, about, allow_external_subcommands = true)]
pub struct Command {
#[arg(long = "ssh-config-path", env = "SSH_CONFIG_PATH")]
ssh_config_path: Option<PathBuf>,
#[command(subcommand)]
commands: Option<Subcommands>,
}
#[derive(clap::Subcommand)]
pub enum Subcommands {
Interactive(InteractiveCommand),
#[command(external_subcommand)]
External(Vec<String>),
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::from_default_env().add_directive("nossh=debug".parse().unwrap()),
)
.init();
let command = Command::parse();
let state = State::new();
tracing::debug!("welcome to nossh, your ssh manager");
let ssh_config_path = match &command.ssh_config_path {
Some(path) => path.to_path_buf(),
None => {
let home = std::env::var("HOME").context(
"failed to find home, this is required if no SSH_CONFIG_PATH is provided",
)?;
PathBuf::from(home).join(".ssh").join("config")
}
};
let Some(commands) = &command.commands else {
InteractiveCommand::default()
.execute(&state, &ssh_config_path)
.await?;
return Ok(());
};
match &commands {
Subcommands::Interactive(cmd) => {
tracing::debug!("running interactive");
cmd.execute(&state, &ssh_config_path).await?;
}
Subcommands::External(items) => {
// Send to ssh
state
.ssh_command()
.start_ssh_session_from_raw(items.iter().map(|i| i.as_str()).collect())
.await?;
// Remember result
}
}
return Ok(());
Ok(())
}

53
src/ssh_command.rs Normal file
View File

@ -0,0 +1,53 @@
use std::process::Stdio;
use anyhow::Context;
use crate::{ssh_config::SshItem, state::State};
pub struct SshCommand {}
impl SshCommand {
pub async fn start_ssh_session(&self, ssh_item: &SshItem) -> anyhow::Result<()> {
let host = ssh_item.to_host();
tracing::info!("starting ssh session at: {}", ssh_item);
// ssh something
let mut cmd = tokio::process::Command::new("ssh");
cmd.arg(host);
let mut process = cmd.spawn()?;
let res = process.wait().await.context("ssh call failed");
tracing::debug!("ssh call finished to host: {}", ssh_item);
res?;
Ok(())
}
pub async fn start_ssh_session_from_raw(&self, raw: Vec<&str>) -> anyhow::Result<()> {
let pretty_raw = raw.join(" ");
tracing::info!("starting ssh session at: {}", pretty_raw);
let mut cmd = tokio::process::Command::new("ssh");
cmd.args(raw);
let mut process = cmd.spawn()?;
let res = process.wait().await.context("ssh call failed");
tracing::debug!("ssh call finished to host: {}", pretty_raw);
res?;
Ok(())
}
}
pub trait SshCommandState {
fn ssh_command(&self) -> SshCommand;
}
impl SshCommandState for State {
fn ssh_command(&self) -> SshCommand {
SshCommand {}
}
}

94
src/ssh_config.rs Normal file
View File

@ -0,0 +1,94 @@
use std::{collections::BTreeMap, fmt::Display, path::Path};
use anyhow::Context;
use crate::state::State;
pub struct SshConfigService {}
impl SshConfigService {
// Get list of hostnames
#[tracing::instrument(skip(self), level = "trace")]
pub async fn get_ssh_items(&self, ssh_config_path: &Path) -> anyhow::Result<SshItems> {
if !ssh_config_path.exists() {
anyhow::bail!(
"was unable to find ssh config file at the given path: {}",
ssh_config_path.display()
)
}
tracing::trace!("reading ssh config");
// 1. get ssh config
let ssh_config_content = tokio::fs::read_to_string(ssh_config_path)
.await
.context("failed to read ssh config file, check that it a normal ssh config file")?;
// 2. parse what we care about
let ssh_config_lines = ssh_config_content.lines();
let ssh_config_hosts = ssh_config_lines
.into_iter()
.filter(|item| item.starts_with("Host "))
.map(|item| item.trim_start_matches("Host ").trim_start().trim_end())
.collect::<Vec<_>>();
// 3. model into our own definition
let ssh_items: BTreeMap<String, SshItem> = ssh_config_hosts
.into_iter()
.map(|s| (s.to_string(), s.into()))
.collect();
Ok(SshItems { items: ssh_items })
}
}
#[derive(Debug, Clone)]
pub struct SshItem {
host: String,
}
impl SshItem {
pub fn to_host(&self) -> &str {
&self.host
}
}
impl Display for SshItem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.host)
}
}
#[derive(Debug)]
pub struct SshItems {
items: BTreeMap<String, SshItem>,
}
impl SshItems {
pub fn to_vec(&self) -> Vec<&SshItem> {
self.items.values().collect()
}
pub fn from_choice(&self, choice: &str) -> Option<&SshItem> {
self.items.get(choice)
}
}
impl From<&str> for SshItem {
fn from(value: &str) -> Self {
Self {
host: value.to_string(),
}
}
}
pub trait SshConfigServiceState {
fn ssh_config_service(&self) -> SshConfigService;
}
impl SshConfigServiceState for State {
fn ssh_config_service(&self) -> SshConfigService {
SshConfigService {}
}
}

8
src/state.rs Normal file
View File

@ -0,0 +1,8 @@
#[derive(Clone)]
pub struct State {}
impl State {
pub fn new() -> Self {
Self {}
}
}

61
src/user_fuzzy_find.rs Normal file
View File

@ -0,0 +1,61 @@
use skim::prelude::*;
use crate::{
ssh_config::{SshItem, SshItems},
state::State,
};
pub struct UserFuzzyFind {}
impl UserFuzzyFind {
pub async fn get_ssh_item_from_user<'a>(
&self,
items: &'a SshItems,
) -> anyhow::Result<&'a SshItem> {
let skim_options = SkimOptionsBuilder::default()
.no_multi(true)
.build()
.expect("failed to build skim config");
let (tx, rx): (SkimItemSender, SkimItemReceiver) = skim::prelude::unbounded();
for item in items.to_vec().into_iter().cloned() {
tx.send(Arc::new(item))
.expect("we should never have enough items that we exceed unbounded");
}
let chosen_items = Skim::run_with(&skim_options, Some(rx))
.and_then(|output| if output.is_abort { None } else { Some(output) })
.map(|item| item.selected_items)
.ok_or(anyhow::anyhow!("failed to find an ssh item"))?;
let chosen_item = chosen_items
.first()
.expect("there should never be more than 1 skip item");
let output = chosen_item.output();
let chosen_ssh_item = items
.from_choice(&output) // Cow, str, String
.expect("always find an ssh item from a choice");
tracing::debug!("the user chose item: {chosen_ssh_item:#?}");
Ok(chosen_ssh_item)
}
}
pub trait UserFuzzyFindState {
fn user_fuzzy_find(&self) -> UserFuzzyFind;
}
impl UserFuzzyFindState for State {
fn user_fuzzy_find(&self) -> UserFuzzyFind {
UserFuzzyFind {}
}
}
impl SkimItem for SshItem {
fn text(&self) -> std::borrow::Cow<str> {
format!("{self}").into()
}
}

5
testdata/.ssh/config vendored Normal file
View File

@ -0,0 +1,5 @@
Host something
Bogus key
Host something here as well
Host ratchet