initial commit

This commit is contained in:
Navid Sassan 2024-02-18 20:44:31 +01:00
parent 64a81a9b13
commit 8715683b4e
5 changed files with 519 additions and 0 deletions

15
Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[package]
name = "bw-menu"
version = "0.1.0"
authors = ["Navid Sassan <navid@sassan.ch>"]
edition = "2021"
[dependencies]
clap = { version = "4.4", features = ["derive"] }
env_logger = "0.10"
itertools = "0.11"
log = "0.4"
reqwest = { version = "0.11", features = ["blocking", "json"] }
serde = { version = "1.0", features = ["derive"] }

87
src/bitwarden_api/mod.rs Normal file
View File

@ -0,0 +1,87 @@
use reqwest;
pub mod model;
// https://bitwarden.com/help/vault-management-api/
pub fn sync() -> Result<model::response::Sync, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let response = client.post("http://localhost:8087/sync")
.send()?
.json::<model::response::Sync>()?;
if response.success {
Ok(response)
} else {
Err("success != true in JSON response".into())
}
}
pub fn status() -> Result<model::response::Status, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let response = client.get("http://localhost:8087/status")
.send()?
.json::<model::response::Status>()?;
if response.success {
Ok(response)
} else {
Err("success != true in JSON response".into())
}
}
pub fn list_object_items() -> Result<model::response::ListObjectItems, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let response = client.get("http://localhost:8087/list/object/items")
// let response = client.get("http://localhost:8087/list/object/items?folderId=50d2f507-8f0a-416c-80a4-afb100a77700")
.send()?;
// dbg!(response.text());
// panic!();
let json = response.json::<model::response::ListObjectItems>()?;
if json.success {
Ok(json)
} else {
Err("success != true in JSON response".into())
}
}
pub fn list_object_collections() -> Result<model::response::ListObjectCollections, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let response = client.get("http://localhost:8087/list/object/collections")
.send()?
.json::<model::response::ListObjectCollections>()?;
if response.success {
Ok(response)
} else {
Err("success != true in JSON response".into())
}
}
pub fn list_object_folders() -> Result<model::response::ListObjectFolders, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let response = client.get("http://localhost:8087/list/object/folders")
.send()?
.json::<model::response::ListObjectFolders>()?;
if response.success {
Ok(response)
} else {
Err("success != true in JSON response".into())
}
}
pub fn object_item(id: &String) -> Result<model::response::ObjectItem, Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::new();
let response = client.get(format!("http://localhost:8087/object/item/{}", id))
.send()?
.json::<model::response::ObjectItem>()?;
if response.success {
Ok(response)
} else {
Err("success != true in JSON response".into())
}
}

View File

@ -0,0 +1,2 @@
#[allow(non_snake_case)]
pub mod response;

View File

@ -0,0 +1,125 @@
use serde::Deserialize;
// TODO: either use rbw-agent as the backend, or adjust structs to rbw db.rs & api.rs
#[derive(Debug, Deserialize)]
pub struct Sync {
pub success: bool,
}
#[derive(Debug, Deserialize)]
pub struct Status {
pub success: bool,
pub data: StatusData,
}
#[derive(Debug, Deserialize)]
pub struct StatusData {
pub object: String,
pub template: StatusDataTemplate,
}
#[derive(Debug, Deserialize)]
pub struct StatusDataTemplate {
pub serverUrl: String,
pub lastSync: String,
pub userEmail: String,
pub userId: String,
pub status: String,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectItems {
pub success: bool,
pub data: ListObjectItemsData,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectItemsData {
pub object: String,
pub data: Vec<ListObjectItemsDataData>,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectItemsDataData {
pub object: String,
pub id: String,
pub organizationId: Option<String>,
pub folderId: Option<String>,
#[serde(rename="type")]
pub _type: i32,
pub reprompt: i32,
pub name: String,
pub notes: Option<String>,
pub favorite: bool,
pub login: Option<ListObjectItemsDataLogin>,
pub collectionIds: Vec<String>,
pub revisionDate: String,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectItemsDataLogin {
pub uris: Option<Vec<ListObjectItemsDataLoginURIs>>,
pub username: Option<String>,
pub password: Option<String>,
pub totp: Option<String>,
pub passwordRevisionDate: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectItemsDataLoginURIs {
#[serde(rename="match")]
pub _match: Option<i32>,
pub uri: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectCollections {
pub success: bool,
pub data: ListObjectCollectionsData,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectCollectionsData {
pub object: String,
pub data: Vec<ListObjectCollectionsDataData>,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectCollectionsDataData {
pub object: String,
pub id: String,
pub organizationId: String,
pub name: String,
pub externalId: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectFolders {
pub success: bool,
pub data: ListObjectFoldersData,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectFoldersData {
pub object: String,
pub data: Vec<ListObjectFoldersDataData>,
}
#[derive(Debug, Deserialize)]
pub struct ListObjectFoldersDataData {
pub object: String,
pub id: Option<String>,
pub name: String,
}
#[derive(Debug, Deserialize)]
pub struct ObjectItem {
pub success: bool,
pub data: ListObjectItemsDataData,
}

290
src/main.rs Normal file
View File

@ -0,0 +1,290 @@
use clap::{Parser, Subcommand};
use itertools::Itertools;
use log::{debug, error, info};
use std::env;
use std::io::Write;
use std::process::Stdio;
use std::{process, collections::HashMap};
use crate::bitwarden_api::model;
mod bitwarden_api;
// GOALS:
// * `search` item via dmenu / rofi / cli
// * `search --mark` parameter that remembers the marked entry
// * `mark get {field}` returns the field of the marked element (id, title, folder, username, password, etc)
// * "scroll" through marked history, mark as active again
// * automatically execute `export BW_SESSION="$(bw unlock --raw)" && bw serve --hostname 127.0.0.1&` if required
// * unlock with system keyring if possible
// * autotype?
#[derive(Parser)]
#[command(author, version, about, long_about = None)] // Read from `Cargo.toml`
struct Cli {
#[command(subcommand)]
command: CliCommands,
}
#[derive(Subcommand)]
enum CliCommands {
Select {
// Show the details of the item
title: Option<String>,
},
}
fn main() {
env_logger::init();
let cli = Cli::parse();
match &cli.command {
CliCommands::Select{title: _} => {
// the title is always ignored, we want the id which we get via the environment variables
debug!("Running in select mode");
rofi();
}
}
}
// the flow is:
// 1. bw-menu starts rofi in the script mode
// 2. rofi calls bw-menu calls bw-menu to get a list of items to display
// 3. the user interacts with the list
// 4. rofi sets environment variables, and calls bw-menu with the title of the selected item
// 5. bw-menu acts based on the environment variables
fn rofi() {
let rofi_retv = env::var("ROFI_RETV");
let rofi_info = env::var("ROFI_INFO");
dbg!(&rofi_retv);
dbg!(&rofi_info);
match rofi_retv {
Err(_) => {
// this means rofi is not running yet, so let's start it
debug!("starting initial rofi process");
let _ = process::Command::new("rofi")
.arg("-show")
.arg("bw")
.arg("-modes")
.arg("bw:./target/debug/bw-menu select") // todo: set the path according to argv[0]
// we need to remap some default so that we can use the keys for custom binds
.arg("-kb-accept-alt")
.arg("F12")
.arg("-kb-accept-custom")
.arg("F11")
// set our custom keybinds
.arg("-kb-custom-1")
.arg("Shift+Return")
.arg("-kb-custom-2")
.arg("Control+Return")
.spawn()
.expect("Failed to spawn child process"); // TODO: better error handling
}
Ok(_) => {
// this means we are being called by rofi as a custom script
// either generate the list, or act on input
let rofi_retv = rofi_retv.unwrap_or_default().to_string().parse::<i32>().unwrap_or(0);
let rofi_info = rofi_info.unwrap_or_default();
if rofi_retv == 0 {
// 0: Initial call of script
generate_rofi_list(get_bitwarden_logins());
} else {
let item = match crate::bitwarden_api::object_item(&rofi_info) {
Ok(response) => response,
Err(msg) => {
error!("Failed to get Bitwarden item with id {}:\n{}", rofi_info, msg);
process::exit(1);
}
};
// 1: Selected an entry
if rofi_retv == 1 {
info!("copy password");
if let Some(login) = item.data.login {
if let Some(password) = login.password {
save_to_clipboard(password);
}
}
// 10-28: Custom keybinding 1-19
} else if rofi_retv == 10 {
info!("copy username");
if let Some(login) = item.data.login {
if let Some(username) = login.username {
save_to_clipboard(username);
}
}
} else if rofi_retv == 11 {
info!("show item");
println!("\0prompt\x1fSelect field");
println!("\0message\x1fReturn | Copy field");
println!("{}\0icon\x1f{}\x1finfo\x1f{}", "TODO", "folder", "single-item:todo");
// TODO: not sure how to proceed here. currently pressing return leads to the "copy password" action
// one option is to pass the action in the info and parsing that
// if let Some(login) = item.data.login {
// if let Some(username) = login.username {
// println!("Username: {}", username);
// }
// }
}
// we are done here
process::exit(0);
}
}
}
}
fn get_bitwarden_logins() -> HashMap<String, model::response::ListObjectItemsDataData> {
match crate::bitwarden_api::status() {
Ok(response) => {
if response.data.template.status != "unlocked" {
error!("Vault is {}, should be unlocked!", response.data.template.status);
process::exit(1);
}
}
Err(msg) => {
error!("Failed to get Bitwarden status:\n{msg}");
process::exit(1);
}
}
match crate::bitwarden_api::sync() {
Ok(_) => info!("Sync'd Bitwarden Vault."),
Err(msg) => {
error!("Failed to sync Bitwarden Vault:\n{msg}");
process::exit(1);
}
}
let items = match crate::bitwarden_api::list_object_items() {
Ok(response) => {
info!("Got list of Bitwarden items.");
response.data.data
}
Err(msg) => {
error!("Failed to get list of Bitwarden items:\n{msg}");
process::exit(1);
}
};
let collections: HashMap<String, String> = match crate::bitwarden_api::list_object_collections() {
Ok(response) => {
info!("Got list of Bitwarden collections.");
response.data.data.iter()
.map(|item| (item.id.clone(), item.name.clone()))
.collect()
}
Err(msg) => {
error!("Failed to get list of Bitwarden collections:\n{msg}");
process::exit(1);
}
};
let folders: HashMap<String, String> = match crate::bitwarden_api::list_object_folders() {
Ok(response) => {
info!("Got list of Bitwarden folders.");
response.data.data.iter()
.filter(|item| item.id.is_some())
.map(|item| (item.id.as_ref().unwrap().clone(), item.name.clone()))
.collect()
}
Err(msg) => {
error!("Failed to get list of Bitwarden folders:\n{msg}");
process::exit(1);
}
};
let mut logins: HashMap<String, model::response::ListObjectItemsDataData> = HashMap::new();
for item in items {
if item.login.is_some() {
let title = get_title(&item, &folders, &collections);
debug!("Login: {}", title);
logins.insert(title, item);
}
// break;
}
// dbg!(&logins);
logins
}
fn generate_rofi_list(logins: HashMap<String, model::response::ListObjectItemsDataData>) {
println!("\0prompt\x1fSelect item");
println!("\0message\x1fReturn | Copy password\rShift+Return | Copy username or selected field\rControl+Return | Show item");
println!("\0use-hot-keys\x1ftrue");
println!("\0no-custom\x1ftrue");
println!("\0markup-rows\x1ftrue");
for title in logins.keys().sorted() {
print!("{title}");
let icon = "folder"; // TODO: what icons are available? ~/.icons seems to work
println!("\0icon\x1f{}\x1finfo\x1f{}", icon, logins[title].id);
}
}
fn get_title(item: &model::response::ListObjectItemsDataData, folders: &HashMap<String, String>, collections: &HashMap<String, String>) -> String {
let mut title = String::new();
let login = item.login.as_ref().unwrap();
if item.folderId.is_some() {
title.push_str(&format!("{}/", folders[item.folderId.as_ref().unwrap()]));
}
// TODO: how do we handle multiple collections nicely? i dont like the pipe for searching. my
// fav solution right now is to display the entry multiple times, eg "000/000-p-xca01", "001/000-p-xca01"
for collection_id in &item.collectionIds {
title.push_str(&collections[collection_id]);
}
if !item.collectionIds.is_empty() {
title.push_str("/");
}
if title.len() == 0 {
title.push_str("ungrouped/");
}
title.push_str(&item.name);
if login.username.is_some() {
title.push_str(&format!(" ({})", login.username.as_ref().unwrap()));
}
title
}
fn save_to_clipboard(input: String) {
// using xclip blocks the app. maybe piping the output to /dev/null would fix this
// let mut cmd = process::Command::new("xclip")
// .arg("-in")
// .arg("-selection")
// .arg("clipboard")
// .stdin(Stdio::piped())
// .spawn()
// .expect("Failed to spawn child process");
let mut cmd = process::Command::new("xsel")
.arg("--input")
.arg("--clipboard")
.stdin(Stdio::piped())
.spawn()
.expect("Failed to spawn child process"); // TODO: better error handling
let mut stdin = cmd.stdin.take().expect("Failed to open stdin.");
let child_thread = std::thread::spawn(move || {
stdin.write_all(input.as_bytes()).expect("Failed to write to stdin");
});
let result = child_thread.join();
match result {
Ok(_) => debug!("Child thread completed successfully"),
Err(e) => error!("Child thread encountered an error: {:?}", e),
}
}