feat(cli): D-Bus client wrapper + full subcommand dispatcher
Lands plan tasks C2 (Daemon proxy wrapping all 9 D-Bus methods via Proxy::new_owned), C3 (status/list/policy-show), C4 (enroll/remove/policy- set/apply/validate), and C5 (pending/recovery). Bundled because dispatch needs the bus wrapper to compile cleanly under -D warnings. Phase 4/5/12 placeholders in the dispatcher: policy apply re-saves to trigger PolicyChanged; policy validate is a TOML round-trip; pending list and recovery list are no-op messages until the corresponding D-Bus methods land. 5 clap-parser tests pass (was 5; same — parser shape unchanged in this commit). Workspace clippy clean.
This commit is contained in:
@@ -1,2 +1,59 @@
|
||||
// Phase 3+6+7 plan: D-Bus client wrapper. Stubbed in Task C1; real methods
|
||||
// land in Task C2.
|
||||
//! Thin async D-Bus wrapper around `io.dangerousthings.AuthForge1`. Each method
|
||||
//! is one `Proxy::call` round-trip. Connection + proxy hold owned state so the
|
||||
//! Daemon handle can be passed around freely.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use authforge_common::policy::Policy;
|
||||
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult};
|
||||
|
||||
const BUS_NAME: &str = "io.dangerousthings.AuthForge";
|
||||
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
|
||||
const IFACE: &str = "io.dangerousthings.AuthForge1";
|
||||
|
||||
pub(crate) struct Daemon {
|
||||
proxy: zbus::Proxy<'static>,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
pub async fn connect() -> Result<Self> {
|
||||
let conn = zbus::Connection::system()
|
||||
.await
|
||||
.context("connecting to system D-Bus")?;
|
||||
let proxy = zbus::Proxy::new_owned(conn, BUS_NAME, OBJECT_PATH, IFACE)
|
||||
.await
|
||||
.context("constructing AuthForge proxy")?;
|
||||
Ok(Self { proxy })
|
||||
}
|
||||
|
||||
pub async fn list_credentials(&self, user: &str) -> Result<Vec<Credential>> {
|
||||
Ok(self.proxy.call("ListCredentials", &(user,)).await?)
|
||||
}
|
||||
|
||||
pub async fn enroll_own(&self, user: &str, nickname: &str) -> Result<Credential> {
|
||||
Ok(self.proxy.call("EnrollOwn", &(user, nickname)).await?)
|
||||
}
|
||||
|
||||
pub async fn remove_own(&self, user: &str, cred_id: &str) -> Result<()> {
|
||||
Ok(self.proxy.call("RemoveOwn", &(user, cred_id)).await?)
|
||||
}
|
||||
|
||||
pub async fn get_policy(&self) -> Result<Policy> {
|
||||
Ok(self.proxy.call("GetPolicy", &()).await?)
|
||||
}
|
||||
|
||||
pub async fn set_policy(&self, p: &Policy) -> Result<PolicyApplyResult> {
|
||||
Ok(self.proxy.call("SetPolicy", &(p,)).await?)
|
||||
}
|
||||
|
||||
pub async fn set_pending_flag(&self, user: &str, flag: &PendingFlag) -> Result<()> {
|
||||
Ok(self.proxy.call("SetPendingFlag", &(user, flag)).await?)
|
||||
}
|
||||
|
||||
pub async fn clear_pending_flag(&self, user: &str) -> Result<()> {
|
||||
Ok(self.proxy.call("ClearPendingFlag", &(user,)).await?)
|
||||
}
|
||||
|
||||
pub async fn generate_recovery_code(&self, user: &str) -> Result<String> {
|
||||
Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,208 @@
|
||||
use crate::bus::Daemon;
|
||||
use anyhow::Result;
|
||||
|
||||
pub(crate) async fn dispatch(_json: bool, cmd: super::Cmd) -> Result<()> {
|
||||
eprintln!("authforgectl: subcommand dispatcher not yet wired in this build");
|
||||
let _ = cmd;
|
||||
pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
|
||||
let d = Daemon::connect().await?;
|
||||
|
||||
match cmd {
|
||||
super::Cmd::Status => {
|
||||
let pol = d.get_policy().await?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&pol)?);
|
||||
} else {
|
||||
println!("authforge daemon: connected");
|
||||
println!("policy stacks configured: {}", pol.stacks.len());
|
||||
for (name, sp) in &pol.stacks {
|
||||
println!(" {}: {:?} ({:?})", name, sp.mode, sp.methods);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::List { user } => {
|
||||
let user = user.unwrap_or_else(whoami_or_user);
|
||||
let creds = d.list_credentials(&user).await?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&creds)?);
|
||||
} else if creds.is_empty() {
|
||||
println!("no credentials for {user}");
|
||||
} else {
|
||||
for c in creds {
|
||||
println!(
|
||||
"{}\t{}\t{:?}\t{:?}",
|
||||
c.id, c.nickname, c.method, c.transport
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Policy {
|
||||
cmd: super::PolicyCmd::Show,
|
||||
} => {
|
||||
let pol = d.get_policy().await?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&pol)?);
|
||||
} else {
|
||||
println!("{}", toml::to_string_pretty(&pol).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
|
||||
// Write-side subcommands (Task C4-C5).
|
||||
super::Cmd::Enroll { user, nickname } => {
|
||||
let user = user.unwrap_or_else(whoami_or_user);
|
||||
let nickname = nickname.unwrap_or_else(|| "Security Key".to_string());
|
||||
let cred = d.enroll_own(&user, &nickname).await?;
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&cred)?);
|
||||
} else {
|
||||
println!(
|
||||
"enrolled credential {} ({}) for {}",
|
||||
cred.id, cred.nickname, user
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Remove { user, cred_id } => {
|
||||
let user = user.unwrap_or_else(whoami_or_user);
|
||||
d.remove_own(&user, &cred_id).await?;
|
||||
if !json {
|
||||
println!("removed credential {cred_id} for {user}");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Policy {
|
||||
cmd:
|
||||
super::PolicyCmd::Set {
|
||||
stack,
|
||||
mode,
|
||||
methods,
|
||||
},
|
||||
} => {
|
||||
use authforge_common::policy::StackPolicy;
|
||||
use authforge_common::types::{Method, Mode};
|
||||
let m = match mode.as_str() {
|
||||
"disabled" => Mode::Disabled,
|
||||
"optional" => Mode::Optional,
|
||||
"required" => Mode::Required,
|
||||
other => anyhow::bail!("unknown mode: {other}"),
|
||||
};
|
||||
let methods: Result<Vec<Method>> = methods
|
||||
.into_iter()
|
||||
.map(|s| match s.as_str() {
|
||||
"fido2" => Ok(Method::Fido2),
|
||||
"totp" => Ok(Method::Totp),
|
||||
other => Err(anyhow::anyhow!("unknown method: {other}")),
|
||||
})
|
||||
.collect();
|
||||
let mut pol = d.get_policy().await?;
|
||||
pol.stacks.insert(
|
||||
stack.clone(),
|
||||
StackPolicy {
|
||||
mode: m,
|
||||
methods: methods?,
|
||||
},
|
||||
);
|
||||
let _ = d.set_policy(&pol).await?;
|
||||
if !json {
|
||||
println!("policy updated for stack {stack}");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Policy {
|
||||
cmd: super::PolicyCmd::Apply { force: _ },
|
||||
} => {
|
||||
// Phase 4 wires real pam-auth-update; for now we re-save current
|
||||
// policy so the daemon emits PolicyChanged.
|
||||
let pol = d.get_policy().await?;
|
||||
let _ = d.set_policy(&pol).await?;
|
||||
if !json {
|
||||
println!("policy re-applied (Phase 4 wires real pam-auth-update)");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Policy {
|
||||
cmd: super::PolicyCmd::Validate,
|
||||
} => {
|
||||
// Phase 5 wires the lockout simulator. For now: syntax round-trip.
|
||||
let pol = d.get_policy().await?;
|
||||
let _ = toml::to_string_pretty(&pol)?;
|
||||
if !json {
|
||||
println!("policy is syntactically valid");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Pending {
|
||||
cmd: super::PendingCmd::Set { user, methods },
|
||||
} => {
|
||||
use authforge_common::types::{Method, PendingFlag};
|
||||
let methods: Result<Vec<Method>> = methods
|
||||
.into_iter()
|
||||
.map(|s| match s.as_str() {
|
||||
"fido2" => Ok(Method::Fido2),
|
||||
"totp" => Ok(Method::Totp),
|
||||
other => Err(anyhow::anyhow!("unknown method: {other}")),
|
||||
})
|
||||
.collect();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let flag = PendingFlag {
|
||||
required_methods: methods?,
|
||||
created_unix: now,
|
||||
deadline_unix: 0,
|
||||
re_enroll: false,
|
||||
};
|
||||
d.set_pending_flag(&user, &flag).await?;
|
||||
if !json {
|
||||
println!("pending flag set for {user}");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Pending {
|
||||
cmd: super::PendingCmd::Clear { user },
|
||||
} => {
|
||||
d.clear_pending_flag(&user).await?;
|
||||
if !json {
|
||||
println!("pending flag cleared for {user}");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Pending {
|
||||
cmd: super::PendingCmd::List,
|
||||
} => {
|
||||
// ListPending lands when GUI needs it (Phase 8/12 adjustment).
|
||||
if !json {
|
||||
println!("(ListPending D-Bus method lands when GUI needs it)");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Recovery {
|
||||
cmd: super::RecoveryCmd::Generate { user },
|
||||
} => {
|
||||
let code = d.generate_recovery_code(&user).await?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({ "user": user, "code": code })
|
||||
);
|
||||
} else {
|
||||
println!("{code}");
|
||||
}
|
||||
}
|
||||
|
||||
super::Cmd::Recovery {
|
||||
cmd: super::RecoveryCmd::List { user: _ },
|
||||
} => {
|
||||
if !json {
|
||||
println!("(ListRecovery lands in Phase 12)");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn whoami_or_user() -> String {
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("LOGNAME"))
|
||||
.unwrap_or_else(|_| "unknown".into())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user