feat: move into crates
This commit is contained in:
1733
crates/nossh/Cargo.lock
generated
Normal file
1733
crates/nossh/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
crates/nossh/Cargo.toml
Normal file
16
crates/nossh/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "nossh"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.98"
|
||||
clap = { version = "4.5.40", features = ["derive", "env"] }
|
||||
dirs = "6.0.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = { version = "1.0.140", features = ["preserve_order"] }
|
||||
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
crates/nossh/DESIGN.md
Normal file
19
crates/nossh/DESIGN.md
Normal 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
|
||||
|
1
crates/nossh/src/commands.rs
Normal file
1
crates/nossh/src/commands.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod interactive;
|
46
crates/nossh/src/commands/interactive.rs
Normal file
46
crates/nossh/src/commands/interactive.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::{
|
||||
ssh_command::SshCommandState, ssh_command_database::SshCommandDatabaseState,
|
||||
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 mut items = state
|
||||
.ssh_config_service()
|
||||
.get_ssh_items(ssh_config_path)
|
||||
.await
|
||||
.context("failed to get ssh items")?;
|
||||
|
||||
let mut database_items = state.ssh_command_database().get_items().await?;
|
||||
items.append(&mut database_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
crates/nossh/src/main.rs
Normal file
89
crates/nossh/src/main.rs
Normal 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_command_database::SshCommandDatabaseState, state::State,
|
||||
};
|
||||
|
||||
mod commands;
|
||||
|
||||
mod ssh_command;
|
||||
mod ssh_command_database;
|
||||
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())
|
||||
.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) => {
|
||||
let items_ref: Vec<&str> = items.iter().map(|i| i.as_str()).collect();
|
||||
|
||||
// Send to ssh
|
||||
state
|
||||
.ssh_command()
|
||||
.start_ssh_session_from_raw(&items_ref)
|
||||
.await?;
|
||||
|
||||
// Remember result
|
||||
state.ssh_command_database().add_item(&items_ref).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
51
crates/nossh/src/ssh_command.rs
Normal file
51
crates/nossh/src/ssh_command.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
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.args(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: &[&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 {}
|
||||
}
|
||||
}
|
146
crates/nossh/src/ssh_command_database.rs
Normal file
146
crates/nossh/src/ssh_command_database.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::{
|
||||
ssh_config::{SshItem, SshItems},
|
||||
state::State,
|
||||
};
|
||||
|
||||
pub struct SshCommandDatabase {}
|
||||
|
||||
impl SshCommandDatabase {
|
||||
pub async fn get_items(&self) -> anyhow::Result<SshItems> {
|
||||
let database_path = self.ensure_get_database_file_path().await?;
|
||||
let database = if database_path.exists() {
|
||||
let content = tokio::fs::read(&database_path)
|
||||
.await
|
||||
.context("failed to read nossh database file")?;
|
||||
|
||||
let database: Database = serde_json::from_slice(&content)?;
|
||||
|
||||
database
|
||||
} else {
|
||||
Database::default()
|
||||
};
|
||||
|
||||
let entries = database.get_entries();
|
||||
|
||||
let ssh_items = entries
|
||||
.into_iter()
|
||||
.map(|e| {
|
||||
(
|
||||
e.join(" "),
|
||||
SshItem::Raw {
|
||||
contents: e.clone(),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(SshItems { items: ssh_items })
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), level = "trace")]
|
||||
pub async fn add_item(&self, raw_session: &[&str]) -> anyhow::Result<()> {
|
||||
tracing::debug!("adding item to database");
|
||||
let database_path = self.ensure_get_database_file_path().await?;
|
||||
|
||||
let mut database = if database_path.exists() {
|
||||
let content = tokio::fs::read(&database_path)
|
||||
.await
|
||||
.context("failed to read nossh database file")?;
|
||||
|
||||
let database: Database = serde_json::from_slice(&content)?;
|
||||
|
||||
database
|
||||
} else {
|
||||
Database::default()
|
||||
};
|
||||
|
||||
database.add_raw(raw_session);
|
||||
|
||||
let mut database_file = tokio::fs::File::create(database_path)
|
||||
.await
|
||||
.context("failed to create nossh database file")?;
|
||||
|
||||
let database_file_content = serde_json::to_vec_pretty(&database)?;
|
||||
|
||||
database_file
|
||||
.write_all(&database_file_content)
|
||||
.await
|
||||
.context("failed to write data to database file")?;
|
||||
database_file
|
||||
.flush()
|
||||
.await
|
||||
.context("failed to flush nossh database file")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_database_file_path(&self) -> PathBuf {
|
||||
dirs::data_local_dir()
|
||||
.expect("requires having a data dir, if using nossh")
|
||||
.join("nossh")
|
||||
.join("nossh.database.json")
|
||||
}
|
||||
|
||||
async fn ensure_get_database_file_path(&self) -> anyhow::Result<PathBuf> {
|
||||
let file_dir = self.get_database_file_path();
|
||||
|
||||
if let Some(parent) = file_dir.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.context("failed to create data dir for nossh")?;
|
||||
}
|
||||
|
||||
Ok(file_dir)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize, Deserialize)]
|
||||
struct Database {
|
||||
entries: BTreeMap<String, DatabaseEntry>,
|
||||
}
|
||||
impl Database {
|
||||
fn add_raw(&mut self, raw_session: &[&str]) {
|
||||
self.entries.insert(
|
||||
raw_session.join(" "),
|
||||
DatabaseEntry::Raw {
|
||||
contents: raw_session.iter().map(|r| r.to_string()).collect(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn get_entries(&self) -> Vec<&Vec<String>> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for v in self.entries.values() {
|
||||
match v {
|
||||
DatabaseEntry::Raw { contents } => {
|
||||
items.push(contents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "entry_type")]
|
||||
enum DatabaseEntry {
|
||||
Raw { contents: Vec<String> },
|
||||
}
|
||||
|
||||
pub trait SshCommandDatabaseState {
|
||||
fn ssh_command_database(&self) -> SshCommandDatabase;
|
||||
}
|
||||
|
||||
impl SshCommandDatabaseState for State {
|
||||
fn ssh_command_database(&self) -> SshCommandDatabase {
|
||||
SshCommandDatabase {}
|
||||
}
|
||||
}
|
105
crates/nossh/src/ssh_config.rs
Normal file
105
crates/nossh/src/ssh_config.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
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 enum SshItem {
|
||||
Own(String),
|
||||
Raw { contents: Vec<String> },
|
||||
}
|
||||
|
||||
impl SshItem {
|
||||
pub fn to_host(&self) -> Vec<&str> {
|
||||
match self {
|
||||
SshItem::Own(own) => vec![own],
|
||||
SshItem::Raw { contents } => contents.iter().map(|c| c.as_str()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for SshItem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let host = match self {
|
||||
SshItem::Own(o) => o.to_string(),
|
||||
SshItem::Raw { contents } => contents.join(" "),
|
||||
};
|
||||
|
||||
f.write_str(&host)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SshItems {
|
||||
pub items: BTreeMap<String, SshItem>,
|
||||
}
|
||||
|
||||
impl SshItems {
|
||||
pub fn to_vec(&self) -> Vec<&SshItem> {
|
||||
self.items.values().collect()
|
||||
}
|
||||
|
||||
pub fn get_choice(&self, choice: &str) -> Option<&SshItem> {
|
||||
self.items.get(choice)
|
||||
}
|
||||
|
||||
pub fn append(&mut self, other: &mut SshItems) {
|
||||
self.items.append(&mut other.items);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for SshItem {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::Own(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SshConfigServiceState {
|
||||
fn ssh_config_service(&self) -> SshConfigService;
|
||||
}
|
||||
|
||||
impl SshConfigServiceState for State {
|
||||
fn ssh_config_service(&self) -> SshConfigService {
|
||||
SshConfigService {}
|
||||
}
|
||||
}
|
8
crates/nossh/src/state.rs
Normal file
8
crates/nossh/src/state.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[derive(Clone, Default)]
|
||||
pub struct State {}
|
||||
|
||||
impl State {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
61
crates/nossh/src/user_fuzzy_find.rs
Normal file
61
crates/nossh/src/user_fuzzy_find.rs
Normal 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
|
||||
.get_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
crates/nossh/testdata/.ssh/config
vendored
Normal file
5
crates/nossh/testdata/.ssh/config
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
Host something
|
||||
Bogus key
|
||||
Host something here as well
|
||||
Host ratchet
|
||||
|
Reference in New Issue
Block a user