Skip to content

Commit 89504e6

Browse files
authored
Merge pull request #19 from bitwarden/anders/cache-subcommand
Introduce `cache` sub command
2 parents ac8f47a + 96a2e15 commit 89504e6

File tree

5 files changed

+174
-48
lines changed

5 files changed

+174
-48
lines changed

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ bw-remote (CLI binary)
6060
- **bw-noise-protocol** — Noise NNpsk2 handshake, `MultiDeviceTransport` for encrypted messaging, XChaCha20-Poly1305 transport encryption, session state persistence for resumption.
6161
- **bw-proxy** — WebSocket relay server (`bw-proxy` binary) and `ProxyProtocolClient` library. Three-phase protocol: authentication, rendezvous, messaging. Default listen address: `ws://localhost:8080`.
6262
- **bw-rat-client**`RemoteClient` (untrusted device requesting credentials) and `UserClient` (trusted device serving credentials). Uses trait abstractions (`SessionStore`, `IdentityProvider`, `ProxyClient`) and async event/response channels.
63-
- **bw-remote** — CLI driver with interactive TUI (ratatui + crossterm) and non-interactive single-shot mode. Subcommands: `connect`, `listen`, `clear-cache`, `list-cache`, `list-devices`, `clear-keypairs`. Integrates with `bw` CLI for credential lookup via `bw get item`.
63+
- **bw-remote** — CLI driver with interactive TUI (ratatui + crossterm) and non-interactive single-shot mode. Subcommands: `connect`, `listen`, `cache` (with `clear`/`list`), `list-devices`, `clear-keypairs`. Integrates with `bw` CLI for credential lookup via `bw get item`.
6464
- **bw-error / bw-error-macro** — Error handling utilities ported from Bitwarden's `sdk-internal`.
6565

6666
## Key Design Patterns

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,9 @@ Connect to a user-client through a proxy to request credentials over a secure ch
3535
Usage: bw-remote [OPTIONS] [COMMAND]
3636
3737
Commands:
38-
clear-cache Clear all cached sessions
39-
list-cache List cached sessions
40-
connect Connect to proxy and request credentials (default)
41-
listen Listen for remote client connections (user-client mode)
38+
cache Manage the session cache
39+
connect Connect to proxy and request credentials (default)
40+
listen Listen for remote client connections (user-client mode)
4241
help Print this message or the help of the given subcommand(s)
4342
4443
Options:
Lines changed: 134 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,157 @@
11
//! Cache management commands
22
//!
3-
//! Commands for managing the session cache:
4-
//! - `clear-cache`: Clear all cached sessions
5-
//! - `list-cache`: List all cached sessions with fingerprints
3+
//! Commands for managing the session cache and identity keys:
4+
//! - `cache list`: List cached sessions and identity fingerprints
5+
//! - `cache clear [sessions|all]`: Clear cached sessions and/or identity keys
66
77
use bw_rat_client::SessionStore;
8-
use clap::Args;
8+
use clap::{Args, Subcommand, ValueEnum};
99
use color_eyre::eyre::Result;
1010

1111
use super::util::format_relative_time;
12-
use crate::storage::FileSessionCache;
12+
use crate::storage::{FileIdentityStorage, FileSessionCache};
1313

14-
/// Arguments for the clear-cache command
14+
/// Which client type to operate on
15+
#[derive(Clone, Copy, Debug, ValueEnum)]
16+
pub enum ClientType {
17+
/// Only the remote (connect) side
18+
Remote,
19+
/// Only the user (listen) side
20+
User,
21+
}
22+
23+
/// What to clear
24+
#[derive(Clone, Copy, Debug, Default, ValueEnum)]
25+
pub enum ClearScope {
26+
/// Clear sessions only (keep identity key)
27+
Sessions,
28+
/// Clear sessions and delete identity key
29+
#[default]
30+
All,
31+
}
32+
33+
/// Manage the session cache
1534
#[derive(Args)]
16-
pub struct ClearCacheArgs;
35+
pub struct CacheArgs {
36+
#[command(subcommand)]
37+
command: CacheCommands,
1738

18-
impl ClearCacheArgs {
19-
/// Execute the clear-cache command
39+
/// Limit to a specific client type (omit for both)
40+
#[arg(long, global = true)]
41+
client_type: Option<ClientType>,
42+
}
43+
44+
#[derive(Subcommand)]
45+
enum CacheCommands {
46+
/// Clear cached sessions and/or identity keys
47+
Clear {
48+
/// What to clear: "sessions" (keep identity key) or "all" (sessions + identity key)
49+
#[arg(default_value = "all")]
50+
scope: ClearScope,
51+
},
52+
/// List cached sessions and identity info
53+
List,
54+
}
55+
56+
impl CacheArgs {
2057
pub fn run(self) -> Result<()> {
21-
let mut cache = FileSessionCache::load_or_create("remote_client")?;
22-
cache.clear()?;
23-
println!("Session cache cleared.");
24-
Ok(())
58+
match self.command {
59+
CacheCommands::Clear { scope } => clear_cache(self.client_type, scope),
60+
CacheCommands::List => list_cache(self.client_type),
61+
}
2562
}
2663
}
2764

28-
/// Arguments for the list-cache command
29-
#[derive(Args)]
30-
pub struct ListCacheArgs;
65+
/// Describes one client side for display and storage lookup
66+
struct CacheSide {
67+
label: &'static str,
68+
description: &'static str,
69+
storage_name: &'static str,
70+
}
3171

32-
impl ListCacheArgs {
33-
/// Execute the list-cache command
34-
pub fn run(self) -> Result<()> {
35-
let cache = FileSessionCache::load_or_create("remote_client")?;
36-
let mut sessions = cache.list_sessions();
72+
const REMOTE_SIDE: CacheSide = CacheSide {
73+
label: "Remote",
74+
description: "connect",
75+
storage_name: "remote_client",
76+
};
3777

38-
if sessions.is_empty() {
39-
println!("No cached sessions.");
40-
return Ok(());
78+
const USER_SIDE: CacheSide = CacheSide {
79+
label: "User",
80+
description: "listen",
81+
storage_name: "user_client",
82+
};
83+
84+
fn sides_for(client_type: Option<ClientType>) -> Vec<&'static CacheSide> {
85+
match client_type {
86+
Some(ClientType::Remote) => vec![&REMOTE_SIDE],
87+
Some(ClientType::User) => vec![&USER_SIDE],
88+
None => vec![&REMOTE_SIDE, &USER_SIDE],
89+
}
90+
}
91+
92+
fn clear_cache(client_type: Option<ClientType>, scope: ClearScope) -> Result<()> {
93+
let sides = sides_for(client_type);
94+
95+
for side in &sides {
96+
// Always clear sessions
97+
let mut cache = FileSessionCache::load_or_create(side.storage_name)?;
98+
cache.clear()?;
99+
println!(
100+
"{} ({}) session cache cleared.",
101+
side.label, side.description
102+
);
103+
104+
// Clear identity key if scope is All
105+
if matches!(scope, ClearScope::All) {
106+
FileIdentityStorage::delete(side.storage_name)?;
107+
println!(
108+
"{} ({}) identity key deleted.",
109+
side.label, side.description
110+
);
41111
}
112+
}
42113

43-
// Sort by last_connected descending (most recent first)
44-
sessions.sort_by(|a, b| b.3.cmp(&a.3));
114+
Ok(())
115+
}
45116

46-
for (fingerprint, name, _cached_at, last_connected) in &sessions {
47-
let hex = hex::encode(fingerprint.0);
48-
let relative = format_relative_time(*last_connected);
49-
if let Some(name) = name {
50-
println!("{name} {hex} (last used: {relative})");
51-
} else {
52-
println!("{hex} (last used: {relative})");
53-
}
117+
fn list_cache(client_type: Option<ClientType>) -> Result<()> {
118+
let sides = sides_for(client_type);
119+
for (i, side) in sides.iter().enumerate() {
120+
if i > 0 {
121+
println!();
54122
}
55123

56-
Ok(())
124+
// Load identity fingerprint (if key exists)
125+
let fingerprint = FileIdentityStorage::load_fingerprint(side.storage_name)?;
126+
let fp_display = match &fingerprint {
127+
Some(fp) => hex::encode(fp.0),
128+
None => "no identity key".to_string(),
129+
};
130+
131+
println!(
132+
"{} ({}) \u{2014} identity: {}",
133+
side.label, side.description, fp_display
134+
);
135+
136+
// Load and display sessions
137+
let cache = FileSessionCache::load_or_create(side.storage_name)?;
138+
let mut sessions = cache.list_sessions();
139+
140+
if sessions.is_empty() {
141+
println!(" No cached sessions.");
142+
} else {
143+
sessions.sort_by(|a, b| b.3.cmp(&a.3));
144+
for (session_fp, name, _cached_at, last_connected) in &sessions {
145+
let hex = hex::encode(session_fp.0);
146+
let relative = format_relative_time(*last_connected);
147+
if let Some(name) = name {
148+
println!(" {name} {hex} (last used: {relative})");
149+
} else {
150+
println!(" {hex} (last used: {relative})");
151+
}
152+
}
153+
}
57154
}
155+
156+
Ok(())
58157
}

crates/bw-remote/src/command/mod.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use color_eyre::eyre::Result;
1515

1616
use output::OutputFormat;
1717

18-
pub use cache::{ClearCacheArgs, ListCacheArgs};
18+
pub use cache::CacheArgs;
1919
pub use connect::ConnectArgs;
2020
pub use listen::ListenArgs;
2121

@@ -64,10 +64,8 @@ pub struct Cli {
6464

6565
#[derive(Subcommand)]
6666
pub enum Commands {
67-
/// Clear all cached sessions
68-
ClearCache(ClearCacheArgs),
69-
/// List cached sessions
70-
ListCache(ListCacheArgs),
67+
/// Manage the session cache
68+
Cache(CacheArgs),
7169
/// Connect to proxy and request credentials (default)
7270
Connect(ConnectArgs),
7371
/// Listen for remote client connections (user-client mode)
@@ -77,8 +75,7 @@ pub enum Commands {
7775
/// Process the parsed command and execute the appropriate handler
7876
pub async fn process_command(cli: Cli) -> Result<()> {
7977
match cli.command {
80-
Some(Commands::ClearCache(args)) => args.run(),
81-
Some(Commands::ListCache(args)) => args.run(),
78+
Some(Commands::Cache(args)) => args.run(),
8279
Some(Commands::Connect(args)) => args.run().await,
8380
Some(Commands::Listen(args)) => args.run().await,
8481
None => {

crates/bw-remote/src/storage/identity_storage.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::fs;
22
use std::path::{Path, PathBuf};
33

4-
use bw_proxy::IdentityKeyPair;
4+
use bw_proxy::{IdentityFingerprint, IdentityKeyPair};
55
use bw_rat_client::{IdentityProvider, RemoteClientError};
66
use tracing::{debug, info};
77

@@ -35,6 +35,37 @@ impl FileIdentityStorage {
3535
Ok(Self { keypair })
3636
}
3737

38+
/// Load the identity fingerprint without generating a new key if none exists.
39+
///
40+
/// Returns `None` if no key file exists, `Some(fingerprint)` if it does.
41+
pub fn load_fingerprint(
42+
storage_name: &str,
43+
) -> Result<Option<IdentityFingerprint>, RemoteClientError> {
44+
let storage_path = Self::default_storage_path(storage_name)?;
45+
if !storage_path.exists() {
46+
return Ok(None);
47+
}
48+
let keypair = Self::load_from_file(&storage_path)?;
49+
Ok(Some(keypair.identity().fingerprint()))
50+
}
51+
52+
/// Delete the identity key file for the given storage name.
53+
///
54+
/// Does nothing if the file does not exist.
55+
pub fn delete(storage_name: &str) -> Result<(), RemoteClientError> {
56+
let storage_path = Self::default_storage_path(storage_name)?;
57+
match fs::remove_file(&storage_path) {
58+
Ok(()) => {
59+
info!("Deleted identity key file: {:?}", storage_path);
60+
Ok(())
61+
}
62+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
63+
Err(e) => Err(RemoteClientError::IdentityStorageFailed(format!(
64+
"Failed to delete identity file: {e}"
65+
))),
66+
}
67+
}
68+
3869
/// Get the default storage path (~/.bw-remote/identity.key)
3970
fn default_storage_path(storage_name: &str) -> Result<PathBuf, RemoteClientError> {
4071
let home_dir = dirs::home_dir().ok_or_else(|| {

0 commit comments

Comments
 (0)