Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 108 additions & 19 deletions crates/bsk-cli/src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ pub enum DaemonCmd {
/// Stop then start the daemon.
Restart(StartArgs),

/// Generate a one-use remote browser pairing link.
Pair,
/// Generate a local pairing link (or a remote link in server mode).
Pair {
/// Replace this local browser/device after successful activation.
#[arg(long)]
replace: Option<String>,
},

/// List paired remote browsers (no credentials are displayed).
/// List local and remote devices (no credentials are displayed).
Devices,

/// Revoke a device grant, or all grants and outstanding pairing links.
Expand All @@ -37,6 +41,19 @@ pub enum DaemonCmd {
#[arg(long)]
all: bool,
},
/// Rotate a local device key on its next connection.
Rotate { device_id: String },
/// Explicitly allow or stop allowing unverified local connections.
TrustLocal {
#[arg(value_enum)]
setting: TrustSetting,
},
}

#[derive(Debug, Clone, Copy, clap::ValueEnum)]
pub enum TrustSetting {
On,
Off,
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
Expand Down Expand Up @@ -182,37 +199,109 @@ pub fn dispatch(cmd: DaemonCmd) -> anyhow::Result<()> {
daemon::start::run_stop().map_err(|e| e.context("restart failed during stop phase"))?;
daemon::start::run_start(args)
}
DaemonCmd::Pair => {
println!("{}", authorization_store()?.pair()?);
DaemonCmd::Pair { replace } => {
super::ensure_daemon::ensure_daemon()?;
let value = maintain(
bsk_protocol::Method::LocalAuthPair,
serde_json::json!({"replace":replace}),
)?;
println!(
"{}",
value["link"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("invalid pairing reply"))?
);
Ok(())
}
DaemonCmd::Devices => {
println!(
"{}",
serde_json::to_string_pretty(&authorization_store()?.devices()?)?
serde_json::to_string_pretty(&maintain(
bsk_protocol::Method::LocalAuthDevices,
serde_json::json!({})
)?)?
);
Ok(())
}
DaemonCmd::Revoke { device_id, all } => {
let store = authorization_store()?;
if all {
store.revoke_all()?;
} else {
anyhow::ensure!(
store.revoke(device_id.as_deref().unwrap_or_default())?,
"device grant not found"
);
}
maintain(
bsk_protocol::Method::LocalAuthRevoke,
serde_json::json!({"device_id":device_id,"all":all}),
)?;
println!("Authorization revoked");
Ok(())
}
DaemonCmd::Rotate { device_id } => {
maintain(
bsk_protocol::Method::LocalAuthRotate,
serde_json::json!({"device_id":device_id}),
)?;
println!("Key rotation scheduled for the next connection");
Ok(())
}
DaemonCmd::TrustLocal { setting } => {
use daemon::local_auth::LocalAuthMode;
let mode = match setting {
TrustSetting::On => LocalAuthMode::TrustLocal,
TrustSetting::Off => LocalAuthMode::Required,
};
maintain(
bsk_protocol::Method::LocalAuthSetMode,
serde_json::json!({"mode":mode}),
)?;
match setting {
TrustSetting::On => println!(
"Warning: unverified local connections are allowed. Enable skipping in the extension separately."
),
TrustSetting::Off => println!("Local pairing verification is required"),
}
Ok(())
}
}
}

fn authorization_store() -> anyhow::Result<daemon::remote::authorization::AuthorizationStore> {
Ok(daemon::remote::authorization::AuthorizationStore::at_home(
&daemon::paths::ensure_bsk_home()?,
))
fn maintain(
method: bsk_protocol::Method,
params: serde_json::Value,
) -> anyhow::Result<serde_json::Value> {
let home = daemon::paths::ensure_bsk_home()?;
match daemon::lockfile::acquire() {
Ok(lock) => {
let local = daemon::local_auth::LocalAuth::open(&lock)?;
daemon::local_auth::maintenance::execute(
&local,
&home,
DEFAULT_WS_PORT,
false,
method,
params,
)
}
Err(error) if error.is::<daemon::lockfile::AlreadyLocked>() => {
// Once another process owns the lifecycle lock, never touch files
// offline, even if IPC is not ready yet or the request fails.
tracing::debug!("daemon maintenance waiting for IPC after lifecycle lock contention");
let running = daemon::probe::wait_for_ready(Duration::from_secs(10))?;
let path = running.info.sock_path.clone();
drop(running);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
let mut client = crate::ipc_client::Client::connect_path(path).await?;
client
.call(method, &params, Duration::from_secs(10))
.await?
.map_err(|error| {
anyhow::anyhow!(
"{}; if the daemon is older, run `bsk update` and restart it",
error.message
)
})
})
}
Err(error) => Err(error),
}
}

#[cfg(test)]
Expand Down
13 changes: 13 additions & 0 deletions crates/bsk-cli/src/daemon/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
);
}
let body = match method {
Method::LocalAuthPair
| Method::LocalAuthDevices
| Method::LocalAuthRevoke
| Method::LocalAuthRotate
| Method::LocalAuthSetMode => {
super::local_auth::maintenance::handle(
state.clone(),
status.ws_port,
method,
params,
)
.await
}
Method::SystemPing => {
let result = PingResult { pong: true };
ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null))
Expand Down
30 changes: 30 additions & 0 deletions crates/bsk-cli/src/daemon/json_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//! Durable, private atomic replacement. Callers serialize writes with the
//! daemon lifecycle lock and their in-process state mutex.

use anyhow::{Context, Result};
use serde::{Serialize, de::DeserializeOwned};
use std::{fs, io::Write, path::Path};

pub fn read<T: DeserializeOwned + Default>(path: &Path) -> Result<T> {
match fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map_err(|_| anyhow::anyhow!("invalid authorization store")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error.into()),
}
}

pub fn write<T: Serialize>(path: &Path, state: &T) -> Result<()> {
let parent = path.parent().context("authorization directory missing")?;
// NamedTempFile creates a private (0600 on Unix) file in the same directory.
let mut file = tempfile::NamedTempFile::new_in(parent)?;
serde_json::to_writer(file.as_file_mut(), state)?;
file.as_file_mut().flush()?;
file.as_file().sync_all()?;
file.persist(path).map_err(|error| error.error)?;
// Windows does not permit opening a directory this way. On Unix a failed
// directory sync must not be mistaken for a durable commit.
#[cfg(unix)]
fs::File::open(parent)?.sync_all()?;
Ok(())
}
135 changes: 135 additions & 0 deletions crates/bsk-cli/src/daemon/local_auth/maintenance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//! IPC and offline maintenance share exactly the same operations.

use super::{LocalAuth, LocalAuthMode};
use anyhow::{Result, ensure};
use bsk_protocol::{ErrorCode, Method, ResponseBody, RpcError};
use serde::Deserialize;
use serde_json::{Value, json};
use std::{path::Path, sync::Arc};

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PairParams {
replace: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RevokeParams {
device_id: Option<String>,
#[serde(default)]
all: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RotateParams {
device_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ModeParams {
mode: LocalAuthMode,
}
fn params<T: serde::de::DeserializeOwned>(value: Value) -> Result<T> {
serde_json::from_value(value)
.map_err(|_| anyhow::anyhow!("invalid pairing maintenance parameters"))
}

pub fn execute(
local: &LocalAuth,
home: &Path,
port: u16,
server: bool,
method: Method,
value: Value,
) -> Result<Value> {
let remote = super::super::remote::authorization::AuthorizationStore::at_home(home);
match method {
Method::LocalAuthPair => {
let args: PairParams = params(value)?;
let link = if server {
ensure!(
args.replace.is_none(),
"--replace is only available for local pairing"
);
remote.pair()?
} else {
local.store.pair(port, args.replace.as_deref())?
};
Ok(json!({"link": link}))
}
Method::LocalAuthDevices => {
let mut devices = serde_json::to_value(local.store.devices())?
.as_array()
.unwrap()
.clone();
for device in remote.devices()? {
let mut device = serde_json::to_value(device)?;
device["kind"] = json!("remote");
devices.push(device);
}
Ok(Value::Array(devices))
}
Method::LocalAuthRevoke => {
let args: RevokeParams = params(value)?;
ensure!(
args.all != args.device_id.is_some(),
"specify a device or --all"
);
if args.all {
local.store.revoke(None)?;
remote.revoke_all()?;
} else {
let id = args.device_id.as_deref().unwrap();
if local.store.resolve(id).is_ok() {
local.store.revoke(Some(id))?;
} else {
ensure!(remote.revoke(id)?, "device grant not found");
}
}
Ok(json!({"revoked":true}))
}
Method::LocalAuthRotate => {
let args: RotateParams = params(value)?;
local.store.request_rotation(&args.device_id)?;
Ok(json!({"rotate_on_next_connection":true}))
}
Method::LocalAuthSetMode => {
let args: ModeParams = params(value)?;
local.set_mode(home, args.mode)?;
Ok(json!({"local_auth":args.mode}))
}
_ => anyhow::bail!("unsupported pairing maintenance operation"),
}
}

pub async fn handle(
state: Arc<super::super::state::DaemonState>,
port: u16,
method: Method,
params: Value,
) -> ResponseBody {
let result = tokio::task::spawn_blocking(move || {
execute(
&state.local_auth,
&super::super::paths::ensure_bsk_home()?,
port,
state.config.server.is_some(),
method,
params,
)
})
.await;
match result {
Ok(Ok(value)) => ResponseBody::Ok(value),
Ok(Err(error)) => ResponseBody::Err(RpcError {
code: ErrorCode::InvalidParams,
message: error.to_string(),
data: None,
}),
Err(_) => ResponseBody::Err(RpcError {
code: ErrorCode::ProtocolError,
message: "pairing maintenance failed".into(),
data: None,
}),
}
}
Loading
Loading