feat(grpc): persistent account management with keyring-only credentials
Add an authenticated Accounts gRPC service (AddAccount/ListAccounts), mounted with the same bearer interceptor as System (every service authenticated). The daemon now uses a persistent DB (~/.nuncio/nuncio.db; was ephemeral temp_dir) and stores account passwords ONLY in the OS keyring -- never in SQLite, logs, Debug/Serialize output, or the ListAccounts response (CLI PasswordArg redacts; rpassword prompt, no echo). Wire nuncio-cli account add/list to the daemon over gRPC. Tests prove persistence across restart and credential secrecy. Security-reviewed, APPROVED. Refs #156 #157
This commit is contained in:
13 files changed
+1363
-141
No files matched your search
@@ -1,7 +1,53 @@
|
||||
//! Command-line argument hierarchy and Clap subcommand parsing.
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// Carries an account password credential from an interactive `rpassword`
|
||||
/// prompt (see `nuncio-cli`'s `main`) through to `Accounts/AddAccount`
|
||||
/// without ever being reachable as a Clap CLI flag, printed by `Debug`, or
|
||||
/// carried by value through `Serialize`/`Deserialize` (backlog stories
|
||||
/// 1.C.1 / 1.C.2, GH #156 / GH #157).
|
||||
///
|
||||
/// `AccountSubcommand::Add`'s `password` field is `#[arg(skip)]`, so Clap
|
||||
/// never parses it from argv; `main` populates it exactly once, right after
|
||||
/// `Cli::parse()`, by prompting without echo. `Debug`, `Serialize` are
|
||||
/// hand-implemented below to redact the value on principle -- defense in
|
||||
/// depth in case a future caller logs or serializes a `Commands` value --
|
||||
/// and `Deserialize` always yields an empty password, since a password
|
||||
/// must never round-trip through a serialized command payload.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PasswordArg(pub String);
|
||||
|
||||
impl std::fmt::Debug for PasswordArg {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("PasswordArg(\"[REDACTED]\")")
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for PasswordArg {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.0 == other.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for PasswordArg {}
|
||||
|
||||
impl Serialize for PasswordArg {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str("[REDACTED]")
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PasswordArg {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Discard whatever was serialized (see the redacting `Serialize`
|
||||
// impl above) and always yield an empty password rather than ever
|
||||
// reconstructing a real credential from a deserialized payload.
|
||||
let _ = String::deserialize(deserializer);
|
||||
Ok(PasswordArg(String::new()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Nuncio CLI — Pure Noun + Verb Developer-First Mail & Calendar Automation.
|
||||
#[derive(Parser, Debug, Clone, PartialEq, Eq)]
|
||||
@@ -160,6 +206,12 @@ pub enum AccountSubcommand {
|
||||
/// SMTP connection transport mode (implicit_tls, start_tls, plain).
|
||||
#[arg(long, default_value = "implicit_tls", help = "SMTP transport mode")]
|
||||
smtp_mode: String,
|
||||
/// Account password credential. Never a CLI flag -- always read
|
||||
/// interactively without echo (via `rpassword`) in `main` and
|
||||
/// injected here immediately before dispatch to the daemon's
|
||||
/// `Accounts/AddAccount` RPC. See [`PasswordArg`].
|
||||
#[arg(skip)]
|
||||
password: PasswordArg,
|
||||
},
|
||||
/// Display details for a specific configured account.
|
||||
Show {
|
||||
@@ -368,6 +420,7 @@ mod tests {
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: PasswordArg::default(),
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -414,4 +467,53 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_add_password_is_never_parsed_from_argv() {
|
||||
// `--password` is deliberately not a recognized flag: the password
|
||||
// MUST always come from an interactive, non-echoing prompt in
|
||||
// `main`, never from argv (which would land it in shell history and
|
||||
// process listings).
|
||||
let cli_add = Cli::parse_from([
|
||||
"nuncio",
|
||||
"account",
|
||||
"add",
|
||||
"--email",
|
||||
"a@b.com",
|
||||
"--imap-host",
|
||||
"imap.b.com",
|
||||
]);
|
||||
match cli_add.command {
|
||||
Commands::Account {
|
||||
action: AccountSubcommand::Add { password, .. },
|
||||
} => assert_eq!(password, PasswordArg::default()),
|
||||
other => panic!("expected Account::Add, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_arg_debug_and_serialize_always_redact_the_value() {
|
||||
let secret = PasswordArg("super-secret-value".to_string());
|
||||
assert_eq!(format!("{secret:?}"), "PasswordArg(\"[REDACTED]\")");
|
||||
|
||||
let json = serde_json::to_string(&secret).expect("serializes");
|
||||
assert_eq!(json, "\"[REDACTED]\"");
|
||||
assert!(!json.contains("super-secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_arg_deserialize_never_reconstructs_a_real_credential() {
|
||||
let deserialized: PasswordArg =
|
||||
serde_json::from_str("\"super-secret-value\"").expect("deserializes");
|
||||
assert_eq!(deserialized, PasswordArg::default());
|
||||
assert_eq!(deserialized.0, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_arg_equality_and_default() {
|
||||
assert_eq!(PasswordArg::default(), PasswordArg(String::new()));
|
||||
assert_ne!(PasswordArg("a".to_string()), PasswordArg("b".to_string()));
|
||||
let cloned = PasswordArg("clone-me".to_string()).clone();
|
||||
assert_eq!(cloned.0, "clone-me");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ pub mod output;
|
||||
pub mod runner;
|
||||
|
||||
pub use args::{
|
||||
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, SystemSubcommand,
|
||||
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, PasswordArg,
|
||||
SystemSubcommand,
|
||||
};
|
||||
pub use output::JsonResponse;
|
||||
pub use runner::HeadlessRunner;
|
||||
@@ -1,9 +1,25 @@
|
||||
use clap::Parser;
|
||||
use nuncio_cli::{args::Cli, HeadlessRunner};
|
||||
use nuncio_cli::{args::Cli, AccountSubcommand, Commands, HeadlessRunner, PasswordArg};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let cli = Cli::parse();
|
||||
let mut cli = Cli::parse();
|
||||
|
||||
// `account add`'s password is deliberately never a Clap-parsed CLI
|
||||
// flag (see `PasswordArg`'s doc comment): read it here, interactively
|
||||
// and without echo, immediately after parsing argv and before any
|
||||
// other work, then inject it into the already-parsed command. This is
|
||||
// the ONLY place in the whole `nuncio-cli` binary that ever reads a raw
|
||||
// password from the terminal.
|
||||
if let Commands::Account {
|
||||
action: AccountSubcommand::Add { password, .. },
|
||||
} = &mut cli.command
|
||||
{
|
||||
let entered = rpassword::prompt_password("Account password: ")
|
||||
.map_err(|e| format!("failed to read password: {e}"))?;
|
||||
*password = PasswordArg(entered);
|
||||
}
|
||||
|
||||
let runner = HeadlessRunner::ephemeral().await?;
|
||||
|
||||
let output_str = runner.execute_command(&cli.command, cli.json).await;
|
||||
|
||||
+427
-71
@@ -14,6 +14,31 @@ use crate::args::{
|
||||
|
||||
use crate::output::{format_json, format_json_error};
|
||||
|
||||
/// Parses a `--imap-mode`/`--smtp-mode` CLI string into `nuncio_core::TlsMode`.
|
||||
/// Rejects anything else rather than silently defaulting, since silently
|
||||
/// falling back to e.g. `Plain` for a typo'd mode would be a serious
|
||||
/// transport-security footgun.
|
||||
fn parse_tls_mode(mode: &str) -> Result<nuncio_core::TlsMode, String> {
|
||||
match mode {
|
||||
"implicit_tls" => Ok(nuncio_core::TlsMode::ImplicitTls),
|
||||
"start_tls" => Ok(nuncio_core::TlsMode::StartTls),
|
||||
"plain" => Ok(nuncio_core::TlsMode::Plain),
|
||||
other => Err(format!(
|
||||
"invalid tls mode '{other}' (expected implicit_tls, start_tls, or plain)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a `nuncio_core::TlsMode` onto its wire-format `nuncio.v1.TlsMode`
|
||||
/// enum value, mirroring `nunciod::grpc`'s server-side mapping.
|
||||
fn map_tls_mode_to_proto(mode: nuncio_core::TlsMode) -> nuncio_proto::v1::TlsMode {
|
||||
match mode {
|
||||
nuncio_core::TlsMode::ImplicitTls => nuncio_proto::v1::TlsMode::ImplicitTls,
|
||||
nuncio_core::TlsMode::StartTls => nuncio_proto::v1::TlsMode::StartTls,
|
||||
nuncio_core::TlsMode::Plain => nuncio_proto::v1::TlsMode::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors emitted by the CLI headless runner.
|
||||
#[derive(Error, Debug)]
|
||||
pub enum RunnerError {
|
||||
@@ -29,11 +54,19 @@ pub enum RunnerError {
|
||||
///
|
||||
/// Most commands operate against an ephemeral local engine (database +
|
||||
/// event bus) for now. `system status` (see [`Self::handle_system_status`])
|
||||
/// is the exception: it is a thin gRPC client of the real `nunciod` daemon's
|
||||
/// `nuncio.v1.System` API (backlog story 1.A.3 / GH-150), authenticated by a
|
||||
/// bearer token read from an injected [`SecretManager`] — production code
|
||||
/// uses [`SecretManager::production`] (the real OS keyring), while tests
|
||||
/// inject [`SecretManager::mock`] so no test ever touches the real vault.
|
||||
/// and `account add` / `account list` (backlog stories 1.C.1 / 1.C.2,
|
||||
/// GH #156 / GH #157, see [`Self::handle_add_account`] /
|
||||
/// [`Self::handle_accounts_list`]) are the exceptions: they are thin gRPC
|
||||
/// clients of the real `nunciod` daemon's `nuncio.v1.System` and
|
||||
/// `nuncio.v1.Accounts` APIs, authenticated by a bearer token read from an
|
||||
/// injected [`SecretManager`] — production code uses
|
||||
/// [`SecretManager::production`] (the real OS keyring), while tests inject
|
||||
/// [`SecretManager::mock`] so no test ever touches the real vault.
|
||||
///
|
||||
/// `account add` persists through the daemon so the account (and its
|
||||
/// password, stored ONLY in the daemon's OS keyring vault) survives past
|
||||
/// this CLI process exiting -- unlike this runner's own ephemeral local
|
||||
/// `db`, which is thrown away when the process exits.
|
||||
pub struct HeadlessRunner {
|
||||
event_bus: EventBus,
|
||||
db: DatabaseEngine,
|
||||
@@ -102,9 +135,16 @@ impl HeadlessRunner {
|
||||
smtp_port: _,
|
||||
imap_mode,
|
||||
smtp_mode,
|
||||
password,
|
||||
} => {
|
||||
self.handle_add_account(
|
||||
email, imap_host, *imap_port, imap_mode, smtp_mode, json_mode,
|
||||
email,
|
||||
imap_host,
|
||||
*imap_port,
|
||||
imap_mode,
|
||||
smtp_mode,
|
||||
&password.0,
|
||||
json_mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -757,6 +797,17 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// `account add`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Accounts` API (backlog story 1.C.1 / 1.C.2,
|
||||
/// GH #156 / GH #157).
|
||||
///
|
||||
/// The account configuration AND `password` are sent to the daemon in a
|
||||
/// single `AddAccount` RPC; the daemon is solely responsible for
|
||||
/// writing the password to the OS keyring vault and the config to its
|
||||
/// persistent database -- this runner's own ephemeral local `db` is
|
||||
/// never touched for this command, so the account survives this CLI
|
||||
/// process exiting. `password` is never logged here, only forwarded.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_add_account(
|
||||
&self,
|
||||
email: &str,
|
||||
@@ -764,63 +815,149 @@ impl HeadlessRunner {
|
||||
imap_port: u16,
|
||||
imap_mode: &str,
|
||||
smtp_mode: &str,
|
||||
password: &str,
|
||||
json_mode: bool,
|
||||
) -> String {
|
||||
let imap_tls_mode = match parse_tls_mode(imap_mode) {
|
||||
Ok(mode) => mode,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
let smtp_tls_mode = match parse_tls_mode(smtp_mode) {
|
||||
Ok(mode) => mode,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
let keyring_key = format!("nuncio/{}", email);
|
||||
let account_id = format!("acct-{}", email.replace('@', "-at-").replace('.', "-"));
|
||||
|
||||
let acct = nuncio_core::AccountConfig {
|
||||
let proto_config = nuncio_proto::v1::AccountConfig {
|
||||
id: account_id.clone(),
|
||||
name: email.to_string(),
|
||||
email_address: email.to_string(),
|
||||
protocol: nuncio_core::AccountProtocol::ImapSmtp,
|
||||
protocol: nuncio_proto::v1::AccountProtocol::ImapSmtp.into(),
|
||||
server_host: imap_host.to_string(),
|
||||
server_port: imap_port,
|
||||
server_port: u32::from(imap_port),
|
||||
use_tls: true,
|
||||
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
|
||||
imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(),
|
||||
smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(),
|
||||
keyring_secret_key: keyring_key.clone(),
|
||||
sync_interval_secs: 300,
|
||||
};
|
||||
|
||||
if let Err(e) = self.db.save_account(&acct).await {
|
||||
if json_mode {
|
||||
return format_json_error(&e.to_string());
|
||||
} else {
|
||||
return format!("Failed to save account: {e}");
|
||||
}
|
||||
}
|
||||
let mut client = match self.connect_accounts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"configured": true,
|
||||
"account_id": account_id,
|
||||
"email": email,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": imap_port,
|
||||
"imap_mode": imap_mode,
|
||||
"smtp_mode": smtp_mode,
|
||||
"keyring_key": keyring_key
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Account '{}' (ID: {}) saved to SQLite database and configured for IMAP ({}:{}, mode: {}) and SMTP (mode: {})",
|
||||
email, account_id, imap_host, imap_port, imap_mode, smtp_mode
|
||||
)
|
||||
match client
|
||||
.add_account(nuncio_proto::v1::AddAccountRequest {
|
||||
config: Some(proto_config),
|
||||
password: password.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let account_id = response.into_inner().id;
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"configured": true,
|
||||
"account_id": account_id,
|
||||
"email": email,
|
||||
"imap_host": imap_host,
|
||||
"imap_port": imap_port,
|
||||
"imap_mode": imap_mode,
|
||||
"smtp_mode": smtp_mode,
|
||||
"keyring_key": keyring_key
|
||||
}))
|
||||
} else {
|
||||
format!(
|
||||
"Account '{}' (ID: {}) added via nunciod daemon and configured for IMAP ({}:{}, mode: {}) and SMTP (mode: {})",
|
||||
email, account_id, imap_host, imap_port, imap_mode, smtp_mode
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected add_account: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `account list`: a real thin gRPC client of the running `nunciod`
|
||||
/// daemon's `nuncio.v1.Accounts` API (backlog story 1.C.1 / 1.C.2,
|
||||
/// GH #156 / GH #157). The daemon's `ListAccounts` response never
|
||||
/// contains password credentials (see `nunciod::grpc`'s `Accounts`
|
||||
/// implementation), so there is nothing to scrub here.
|
||||
async fn handle_accounts_list(&self, json_mode: bool) -> String {
|
||||
let accounts = self.db.list_accounts().await.unwrap_or_default();
|
||||
let mut client = match self.connect_accounts_client().await {
|
||||
Ok(client) => client,
|
||||
Err(e) => return Self::render_error(&e, json_mode),
|
||||
};
|
||||
|
||||
match client
|
||||
.list_accounts(nuncio_proto::v1::ListAccountsRequest {})
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let accounts = response.into_inner().accounts;
|
||||
if json_mode {
|
||||
let accounts_json: Vec<serde_json::Value> = accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
json!({
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"email_address": a.email_address,
|
||||
"protocol": a.protocol().as_str_name(),
|
||||
"server_host": a.server_host,
|
||||
"server_port": a.server_port,
|
||||
"use_tls": a.use_tls,
|
||||
"imap_tls_mode": a.imap_tls_mode().as_str_name(),
|
||||
"smtp_tls_mode": a.smtp_tls_mode().as_str_name(),
|
||||
"keyring_secret_key": a.keyring_secret_key,
|
||||
"sync_interval_secs": a.sync_interval_secs,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
format_json(&json!({ "accounts": accounts_json }))
|
||||
} else {
|
||||
format!(
|
||||
"Configured Accounts: {} account(s) registered",
|
||||
accounts.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(status) => Self::render_error(
|
||||
&format!("nunciod daemon rejected list_accounts: {status}"),
|
||||
json_mode,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// `nuncio.v1.Accounts` service at `self.grpc_addr`, shared by
|
||||
/// [`Self::handle_add_account`] and [`Self::handle_accounts_list`].
|
||||
async fn connect_accounts_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedAccountsClient, String> {
|
||||
let token_bytes = self
|
||||
.secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
|
||||
let token = hex::encode(token_bytes);
|
||||
|
||||
nuncio_proto::client::connect_accounts(&self.grpc_addr, &token)
|
||||
.await
|
||||
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
|
||||
}
|
||||
|
||||
/// Formats an error message consistently for both JSON and human-text
|
||||
/// output modes.
|
||||
fn render_error(message: &str, json_mode: bool) -> String {
|
||||
if json_mode {
|
||||
format_json(&json!({
|
||||
"accounts": accounts
|
||||
}))
|
||||
format_json_error(message)
|
||||
} else {
|
||||
format!(
|
||||
"Configured Accounts: {} account(s) registered",
|
||||
accounts.len()
|
||||
)
|
||||
format!("Error: {message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,41 +1024,108 @@ impl HeadlessRunner {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_tls_mode_accepts_all_valid_modes_and_rejects_garbage() {
|
||||
assert_eq!(
|
||||
parse_tls_mode("implicit_tls").expect("valid mode"),
|
||||
nuncio_core::TlsMode::ImplicitTls
|
||||
);
|
||||
assert_eq!(
|
||||
parse_tls_mode("start_tls").expect("valid mode"),
|
||||
nuncio_core::TlsMode::StartTls
|
||||
);
|
||||
assert_eq!(
|
||||
parse_tls_mode("plain").expect("valid mode"),
|
||||
nuncio_core::TlsMode::Plain
|
||||
);
|
||||
|
||||
let err = parse_tls_mode("not-a-real-mode").expect_err("garbage mode must be rejected");
|
||||
assert!(err.contains("invalid tls mode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_tls_mode_to_proto_mirrors_every_variant() {
|
||||
assert_eq!(
|
||||
map_tls_mode_to_proto(nuncio_core::TlsMode::ImplicitTls),
|
||||
nuncio_proto::v1::TlsMode::ImplicitTls
|
||||
);
|
||||
assert_eq!(
|
||||
map_tls_mode_to_proto(nuncio_core::TlsMode::StartTls),
|
||||
nuncio_proto::v1::TlsMode::StartTls
|
||||
);
|
||||
assert_eq!(
|
||||
map_tls_mode_to_proto(nuncio_core::TlsMode::Plain),
|
||||
nuncio_proto::v1::TlsMode::Plain
|
||||
);
|
||||
}
|
||||
|
||||
/// `account add` must reject an invalid `--imap-mode`/`--smtp-mode`
|
||||
/// string BEFORE ever dialing the daemon -- proven here by pointing at
|
||||
/// an address nothing is listening on and confirming the failure is the
|
||||
/// validation error, not a connection error.
|
||||
#[tokio::test]
|
||||
async fn account_add_rejects_invalid_tls_mode_before_dialing_the_daemon() {
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), "127.0.0.1:1".into())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let bad_imap_mode = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "x@y.com".to_string(),
|
||||
imap_host: "imap.y.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.y.com".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "not-a-real-mode".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: crate::args::PasswordArg("pw".to_string()),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(bad_imap_mode.contains("invalid tls mode"));
|
||||
assert!(!bad_imap_mode.contains("unreachable"));
|
||||
|
||||
let bad_smtp_mode = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "x@y.com".to_string(),
|
||||
imap_host: "imap.y.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "smtp.y.com".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "also-not-real".to_string(),
|
||||
password: crate::args::PasswordArg("pw".to_string()),
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(bad_smtp_mode.starts_with("Error: invalid tls mode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn headless_runner_executes_all_pure_noun_verb_commands() {
|
||||
let runner = HeadlessRunner::ephemeral()
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
// Account Noun Commands
|
||||
let acct_add = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "james.maes@kof22.com".to_string(),
|
||||
imap_host: "mail.kof22.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.kof22.com".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(acct_add.contains(r#""email":"james.maes@kof22.com""#));
|
||||
|
||||
let acct_list = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(acct_list.contains("acct-james-maes-at-kof22-com"));
|
||||
|
||||
// Account Noun Commands: `Add`/`List` are exercised separately
|
||||
// below via `ephemeral_with` + `SecretManager::mock()` against a
|
||||
// live test gRPC server, for the exact same reason `system status`
|
||||
// is (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157): they are
|
||||
// now real gRPC clients of the `nunciod` daemon's `Accounts` API,
|
||||
// so they must never run against this `ephemeral()`-constructed
|
||||
// runner's production `SecretManager` or its (unreachable in CI)
|
||||
// default gRPC address. `Show` still reads this runner's own
|
||||
// ephemeral local `db` (out of scope for this backlog story), so it
|
||||
// is safe to exercise here.
|
||||
let acct_show = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
@@ -1165,6 +1369,158 @@ mod tests {
|
||||
assert!(text_out.contains("9.9.9"));
|
||||
}
|
||||
|
||||
/// Reference-client proof for backlog stories 1.C.1 / 1.C.2 (GH #156 /
|
||||
/// GH #157): boots a stub `nuncio.v1.Accounts` gRPC server (mirroring
|
||||
/// `system_status_reports_live_daemon_status_over_grpc_when_reachable`'s
|
||||
/// `StubSystem` pattern above) and drives the real `HeadlessRunner`'s
|
||||
/// `account add` / `account list` gRPC client paths against it.
|
||||
///
|
||||
/// Real persistence-across-restart and credential-secrecy (never in
|
||||
/// SQLite, never in `ListAccounts`) are proven by `nunciod`'s own
|
||||
/// `grpc::tests`; this test exists purely to prove the CLI's connect +
|
||||
/// call + JSON-format happy path, AND that the password the CLI sends
|
||||
/// over the wire is exactly what the caller supplied (never mangled,
|
||||
/// dropped, or substituted).
|
||||
#[tokio::test]
|
||||
async fn account_add_and_list_round_trip_over_grpc_to_a_stub_daemon() {
|
||||
use nuncio_proto::v1::accounts_server::{Accounts as AccountsService, AccountsServer};
|
||||
use nuncio_proto::v1::{
|
||||
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
|
||||
AddAccountRequest, AddAccountResponse, ListAccountsRequest, ListAccountsResponse,
|
||||
TlsMode as TlsModeProto,
|
||||
};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Minimal test-only stub of `nuncio.v1.Accounts`: records the last
|
||||
/// `AddAccountRequest` it received (so this test can assert on
|
||||
/// exactly what the CLI sent over the wire, including the
|
||||
/// password) and returns a fixed `ListAccounts` response.
|
||||
#[derive(Default)]
|
||||
struct StubAccounts {
|
||||
last_add_request: Arc<Mutex<Option<AddAccountRequest>>>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl AccountsService for StubAccounts {
|
||||
async fn add_account(
|
||||
&self,
|
||||
request: tonic::Request<AddAccountRequest>,
|
||||
) -> Result<tonic::Response<AddAccountResponse>, tonic::Status> {
|
||||
let req = request.into_inner();
|
||||
let id = req
|
||||
.config
|
||||
.as_ref()
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_default();
|
||||
*self
|
||||
.last_add_request
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = Some(req);
|
||||
Ok(tonic::Response::new(AddAccountResponse { id }))
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_request: tonic::Request<ListAccountsRequest>,
|
||||
) -> Result<tonic::Response<ListAccountsResponse>, tonic::Status> {
|
||||
Ok(tonic::Response::new(ListAccountsResponse {
|
||||
accounts: vec![AccountConfigProto {
|
||||
id: "acct-stub-1".to_string(),
|
||||
name: "Stub Account".to_string(),
|
||||
email_address: "stub@nuncio.mx".to_string(),
|
||||
protocol: AccountProtocolProto::ImapSmtp.into(),
|
||||
server_host: "imap.nuncio.mx".to_string(),
|
||||
server_port: 993,
|
||||
use_tls: true,
|
||||
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
keyring_secret_key: "nuncio/acct-stub-1".to_string(),
|
||||
sync_interval_secs: 300,
|
||||
}],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
let stub = StubAccounts::default();
|
||||
let probe = stub.last_add_request.clone();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
tokio::spawn(async move {
|
||||
let _ = tonic::transport::Server::builder()
|
||||
.add_service(AccountsServer::new(stub))
|
||||
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
|
||||
.await;
|
||||
});
|
||||
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("ephemeral runner initializes");
|
||||
|
||||
let add_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "james.maes@kof22.com".to_string(),
|
||||
imap_host: "mail.kof22.com".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.kof22.com".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: crate::args::PasswordArg("s3cr3t-cli-password".to_string()),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(add_out.contains(r#""email":"james.maes@kof22.com""#));
|
||||
assert!(add_out.contains("acct-james-maes-at-kof22-com"));
|
||||
// The password must never be echoed back in the CLI's own output.
|
||||
assert!(!add_out.contains("s3cr3t-cli-password"));
|
||||
|
||||
// ...but it MUST have been sent to the daemon intact, exactly as
|
||||
// the caller supplied it.
|
||||
let recorded = probe
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
.expect("stub daemon received an add_account request");
|
||||
assert_eq!(recorded.password, "s3cr3t-cli-password");
|
||||
assert_eq!(
|
||||
recorded
|
||||
.config
|
||||
.expect("config present on the request")
|
||||
.keyring_secret_key,
|
||||
"nuncio/james.maes@kof22.com"
|
||||
);
|
||||
|
||||
let list_out = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert!(list_out.contains("acct-stub-1"));
|
||||
assert!(list_out.contains("stub@nuncio.mx"));
|
||||
assert!(list_out.contains(r#""protocol":"ACCOUNT_PROTOCOL_IMAP_SMTP""#));
|
||||
|
||||
let list_out_text = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(list_out_text.contains("1 account(s) registered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_error_display() {
|
||||
let err = RunnerError::InitFailed("failed to open database".to_string());
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use nuncio_cli::{
|
||||
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, HeadlessRunner, MailSubcommand,
|
||||
SystemSubcommand,
|
||||
PasswordArg, SystemSubcommand,
|
||||
};
|
||||
use nuncio_store::vault::SecretManager;
|
||||
use serde_json::Value;
|
||||
@@ -31,36 +31,11 @@ async fn system_test_cli_noun_verb_execution_matrix() {
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
|
||||
// 2. Account list & add
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
|
||||
let out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "test@nuncio.mx".to_string(),
|
||||
imap_host: "mail.nuncio.mx".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let json: Value = serde_json::from_str(&out).expect("valid json");
|
||||
assert_eq!(json["status"], "ok");
|
||||
// 2. Account list & add are real gRPC clients of the `nunciod` daemon's
|
||||
// `Accounts` API (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157), so
|
||||
// -- exactly like `system status` above -- they are exercised
|
||||
// separately below via `ephemeral_with` + `SecretManager::mock()`
|
||||
// rather than through this `ephemeral()`-constructed runner.
|
||||
|
||||
// 3. Folder list
|
||||
let out: String = runner
|
||||
@@ -145,3 +120,62 @@ async fn system_status_reports_honest_error_when_daemon_unreachable() {
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
}
|
||||
|
||||
/// `account add` / `account list` are real gRPC clients of the `nunciod`
|
||||
/// daemon's `Accounts` API (backlog stories 1.C.1 / 1.C.2, GH #156 /
|
||||
/// GH #157). With no daemon reachable, both must report a clear, honest
|
||||
/// error rather than fabricating success or silently falling back to any
|
||||
/// local state.
|
||||
#[tokio::test]
|
||||
async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
drop(listener); // free the port; nothing is listening on it now
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let list_out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::List,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let list_json: Value = serde_json::from_str(&list_out).expect("valid json");
|
||||
assert_eq!(list_json["status"], "error");
|
||||
assert!(list_json["error"]
|
||||
.as_str()
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
|
||||
let add_out: String = runner
|
||||
.execute_command(
|
||||
&Commands::Account {
|
||||
action: AccountSubcommand::Add {
|
||||
email: "test@nuncio.mx".to_string(),
|
||||
imap_host: "mail.nuncio.mx".to_string(),
|
||||
imap_port: 993,
|
||||
smtp_host: "mail.nuncio.mx".to_string(),
|
||||
smtp_port: 465,
|
||||
imap_mode: "implicit_tls".to_string(),
|
||||
smtp_mode: "implicit_tls".to_string(),
|
||||
password: PasswordArg("irrelevant-unreachable-daemon".to_string()),
|
||||
},
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let add_json: Value = serde_json::from_str(&add_out).expect("valid json");
|
||||
assert_eq!(add_json["status"], "error");
|
||||
assert!(add_json["error"]
|
||||
.as_str()
|
||||
.expect("error message present")
|
||||
.contains("unreachable"));
|
||||
// The password must never leak into an error message either.
|
||||
assert!(!add_out.contains("irrelevant-unreachable-daemon"));
|
||||
}
|
||||
@@ -129,3 +129,88 @@ message EventError {
|
||||
|
||||
// Mirrors `CoreEvent::ShuttingDown`, which carries no fields.
|
||||
message ShuttingDown {}
|
||||
|
||||
// Accounts manages persisted mail/calendar account configurations
|
||||
// (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157).
|
||||
//
|
||||
// SECURITY (GH #165): this service MUST be mounted behind the SAME
|
||||
// `BearerAuthInterceptor` that guards `System` -- every gRPC service `nunciod`
|
||||
// exposes MUST be authenticated, with no exceptions carved out for "just one
|
||||
// more" unauthenticated service. See `nunciod::grpc::serve_on_listener`.
|
||||
service Accounts {
|
||||
// AddAccount persists a new account configuration to the daemon's
|
||||
// database and stores its password credential in the OS keyring vault.
|
||||
// The password is NEVER written to the database and NEVER echoed back.
|
||||
rpc AddAccount(AddAccountRequest) returns (AddAccountResponse);
|
||||
// ListAccounts returns every persisted account configuration. Password
|
||||
// credentials are NEVER included in this response.
|
||||
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::AccountProtocol`.
|
||||
enum AccountProtocol {
|
||||
ACCOUNT_PROTOCOL_UNSPECIFIED = 0;
|
||||
ACCOUNT_PROTOCOL_JMAP = 1;
|
||||
ACCOUNT_PROTOCOL_IMAP_SMTP = 2;
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::TlsMode`.
|
||||
enum TlsMode {
|
||||
TLS_MODE_UNSPECIFIED = 0;
|
||||
TLS_MODE_IMPLICIT_TLS = 1;
|
||||
TLS_MODE_START_TLS = 2;
|
||||
TLS_MODE_PLAIN = 3;
|
||||
}
|
||||
|
||||
// Mirrors `nuncio_core::AccountConfig` field-for-field, MINUS the password
|
||||
// credential: this message is used both as the request payload (alongside
|
||||
// a separate `password` field) and as the response payload of
|
||||
// `ListAccounts`, and the password MUST NEVER appear in the latter.
|
||||
message AccountConfig {
|
||||
// Unique identifier for the account configuration.
|
||||
string id = 1;
|
||||
// User-friendly display name (e.g. "Work Mail").
|
||||
string name = 2;
|
||||
// Primary email address associated with the account.
|
||||
string email_address = 3;
|
||||
// Protocol engine type.
|
||||
AccountProtocol protocol = 4;
|
||||
// Server hostname or IP address.
|
||||
string server_host = 5;
|
||||
// Server port number (1..=65535).
|
||||
uint32 server_port = 6;
|
||||
// Whether TLS connection encryption is enabled.
|
||||
bool use_tls = 7;
|
||||
// IMAP connection security transport mode.
|
||||
TlsMode imap_tls_mode = 8;
|
||||
// SMTP connection security transport mode.
|
||||
TlsMode smtp_tls_mode = 9;
|
||||
// Keyring entry key used to retrieve OS vault credentials.
|
||||
string keyring_secret_key = 10;
|
||||
// Background sync polling interval in seconds (minimum 10s).
|
||||
uint64 sync_interval_secs = 11;
|
||||
}
|
||||
|
||||
// AddAccountRequest carries the full account configuration plus the raw
|
||||
// password credential. `password` is written ONLY to the OS keyring vault
|
||||
// (under `config.keyring_secret_key`) by the daemon -- it is never persisted
|
||||
// to SQLite, never logged, and never echoed back in `AddAccountResponse`.
|
||||
message AddAccountRequest {
|
||||
AccountConfig config = 1;
|
||||
string password = 2;
|
||||
}
|
||||
|
||||
// AddAccountResponse deliberately carries only the persisted account's id,
|
||||
// never the password nor the full config (callers already have both).
|
||||
message AddAccountResponse {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
// ListAccountsRequest currently carries no parameters.
|
||||
message ListAccountsRequest {}
|
||||
|
||||
// ListAccountsResponse. `accounts` MUST NEVER contain password credentials
|
||||
// -- see `AccountConfig`'s doc comment.
|
||||
message ListAccountsResponse {
|
||||
repeated AccountConfig accounts = 1;
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
//! implementation of the client-side authentication handshake with the
|
||||
//! server-side interceptor in `nunciod::grpc`.
|
||||
|
||||
use crate::v1::accounts_client::AccountsClient;
|
||||
use crate::v1::system_client::SystemClient;
|
||||
use crate::v1::{Event, SubscribeRequest};
|
||||
use thiserror::Error;
|
||||
@@ -83,35 +84,63 @@ impl tonic::service::Interceptor for BearerTokenInterceptor {
|
||||
pub type AuthenticatedSystemClient =
|
||||
SystemClient<InterceptedService<Channel, BearerTokenInterceptor>>;
|
||||
|
||||
/// Dials the `nuncio.v1.System` gRPC endpoint at `addr` (a `host:port` pair,
|
||||
/// e.g. `127.0.0.1:9420`) and returns a client that injects
|
||||
/// `authorization: Bearer <token>` metadata on every call.
|
||||
/// The authenticated `nuncio.v1.Accounts` client type returned by
|
||||
/// [`connect_accounts`].
|
||||
pub type AuthenticatedAccountsClient =
|
||||
AccountsClient<InterceptedService<Channel, BearerTokenInterceptor>>;
|
||||
|
||||
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
|
||||
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
|
||||
/// token instead) and returns the connected [`Channel`], shared by every
|
||||
/// per-service `connect_*` helper in this module so they agree on identical
|
||||
/// endpoint-parsing and connection-error handling.
|
||||
///
|
||||
/// `addr` is expected to be a loopback address; this helper does not
|
||||
/// enforce that itself, matching the daemon's own loopback-only bind
|
||||
/// contract (see `nunciod::grpc::serve`).
|
||||
pub async fn connect_system(
|
||||
addr: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedSystemClient, ConnectError> {
|
||||
let interceptor = BearerTokenInterceptor::new(token)?;
|
||||
|
||||
async fn dial(addr: &str) -> Result<Channel, ConnectError> {
|
||||
let uri = format!("http://{addr}");
|
||||
let endpoint = Endpoint::from_shared(uri).map_err(|source| ConnectError::InvalidEndpoint {
|
||||
addr: addr.to_string(),
|
||||
source,
|
||||
})?;
|
||||
let channel = endpoint
|
||||
endpoint
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|source| ConnectError::Transport {
|
||||
addr: addr.to_string(),
|
||||
source,
|
||||
})?;
|
||||
})
|
||||
}
|
||||
|
||||
/// Dials the `nuncio.v1.System` gRPC endpoint at `addr` and returns a client
|
||||
/// that injects `authorization: Bearer <token>` metadata on every call.
|
||||
pub async fn connect_system(
|
||||
addr: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedSystemClient, ConnectError> {
|
||||
let interceptor = BearerTokenInterceptor::new(token)?;
|
||||
let channel = dial(addr).await?;
|
||||
Ok(SystemClient::with_interceptor(channel, interceptor))
|
||||
}
|
||||
|
||||
/// Dials the `nuncio.v1.Accounts` gRPC endpoint at `addr` and returns a
|
||||
/// client that injects `authorization: Bearer <token>` metadata on every
|
||||
/// call (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157).
|
||||
///
|
||||
/// `Accounts` is guarded by the exact same `BearerAuthInterceptor` as
|
||||
/// `System` on the server side (see `nunciod::grpc::serve_on_listener`,
|
||||
/// GH #165), so this shares [`BearerTokenInterceptor`] and [`dial`] with
|
||||
/// [`connect_system`] rather than hand-rolling a second auth handshake.
|
||||
pub async fn connect_accounts(
|
||||
addr: &str,
|
||||
token: &str,
|
||||
) -> Result<AuthenticatedAccountsClient, ConnectError> {
|
||||
let interceptor = BearerTokenInterceptor::new(token)?;
|
||||
let channel = dial(addr).await?;
|
||||
Ok(AccountsClient::with_interceptor(channel, interceptor))
|
||||
}
|
||||
|
||||
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
|
||||
/// authenticated client (as returned by [`connect_system`]), reusing the
|
||||
/// bearer-token interceptor already attached to `client` rather than
|
||||
@@ -172,6 +201,23 @@ mod tests {
|
||||
assert!(matches!(err, ConnectError::InvalidToken(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_accounts_fails_closed_on_invalid_token() {
|
||||
let err = connect_accounts("127.0.0.1:0", "tok\ntoken")
|
||||
.await
|
||||
.expect_err("invalid token must fail before dialing");
|
||||
assert!(matches!(err, ConnectError::InvalidToken(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_accounts_reports_transport_error_when_daemon_unreachable() {
|
||||
let err = connect_accounts("127.0.0.1:1", "abc123")
|
||||
.await
|
||||
.expect_err("connecting to an unreachable daemon must fail");
|
||||
assert!(matches!(err, ConnectError::Transport { .. }));
|
||||
assert!(err.to_string().contains("127.0.0.1:1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_system_reports_transport_error_when_daemon_unreachable() {
|
||||
// Port 0 alone is not connectable; using an explicit closed port on
|
||||
|
||||
@@ -34,3 +34,4 @@ hex.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nuncio-cli = { path = "../nuncio-cli" }
|
||||
sqlx.workspace = true
|
||||
+506
-16
@@ -8,13 +8,18 @@
|
||||
//! transport is out of scope here and lands in a later story.
|
||||
|
||||
use nuncio_core::{CoreEvent, EventBus};
|
||||
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
|
||||
use nuncio_proto::v1::event::Kind;
|
||||
use nuncio_proto::v1::system_server::{System, SystemServer};
|
||||
use nuncio_proto::v1::{
|
||||
BatchFilterProgress, DatabaseRecovered, Event, EventError, FilterExecuted, GetStatusRequest,
|
||||
GetStatusResponse, MessageFlagsChanged, ShuttingDown, SubscribeRequest, SyncCompleted,
|
||||
SyncStarted, UpdateAvailable,
|
||||
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
|
||||
AddAccountRequest, AddAccountResponse, BatchFilterProgress, DatabaseRecovered, Event,
|
||||
EventError, FilterExecuted, GetStatusRequest, GetStatusResponse, ListAccountsRequest,
|
||||
ListAccountsResponse, MessageFlagsChanged, ShuttingDown, SubscribeRequest, SyncCompleted,
|
||||
SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
|
||||
};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::SecretManager;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use subtle::ConstantTimeEq;
|
||||
@@ -170,6 +175,167 @@ impl System for SystemGrpcService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a `nuncio_core::AccountProtocol` onto its wire-format
|
||||
/// `nuncio.v1.AccountProtocol` enum value.
|
||||
fn map_account_protocol_to_proto(protocol: nuncio_core::AccountProtocol) -> AccountProtocolProto {
|
||||
match protocol {
|
||||
nuncio_core::AccountProtocol::Jmap => AccountProtocolProto::Jmap,
|
||||
nuncio_core::AccountProtocol::ImapSmtp => AccountProtocolProto::ImapSmtp,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a wire-format `nuncio.v1.AccountProtocol` enum value back onto
|
||||
/// `nuncio_core::AccountProtocol`. `Unspecified` is treated as an invalid
|
||||
/// request rather than silently defaulting to a protocol the caller never
|
||||
/// asked for.
|
||||
fn map_account_protocol_from_proto(
|
||||
protocol: AccountProtocolProto,
|
||||
) -> Result<nuncio_core::AccountProtocol, Status> {
|
||||
match protocol {
|
||||
AccountProtocolProto::Jmap => Ok(nuncio_core::AccountProtocol::Jmap),
|
||||
AccountProtocolProto::ImapSmtp => Ok(nuncio_core::AccountProtocol::ImapSmtp),
|
||||
AccountProtocolProto::Unspecified => {
|
||||
Err(Status::invalid_argument("account protocol is required"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a `nuncio_core::TlsMode` onto its wire-format `nuncio.v1.TlsMode`
|
||||
/// enum value.
|
||||
fn map_tls_mode_to_proto(mode: nuncio_core::TlsMode) -> TlsModeProto {
|
||||
match mode {
|
||||
nuncio_core::TlsMode::ImplicitTls => TlsModeProto::ImplicitTls,
|
||||
nuncio_core::TlsMode::StartTls => TlsModeProto::StartTls,
|
||||
nuncio_core::TlsMode::Plain => TlsModeProto::Plain,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a wire-format `nuncio.v1.TlsMode` enum value back onto
|
||||
/// `nuncio_core::TlsMode`. `Unspecified` is rejected rather than silently
|
||||
/// defaulting.
|
||||
fn map_tls_mode_from_proto(mode: TlsModeProto) -> Result<nuncio_core::TlsMode, Status> {
|
||||
match mode {
|
||||
TlsModeProto::ImplicitTls => Ok(nuncio_core::TlsMode::ImplicitTls),
|
||||
TlsModeProto::StartTls => Ok(nuncio_core::TlsMode::StartTls),
|
||||
TlsModeProto::Plain => Ok(nuncio_core::TlsMode::Plain),
|
||||
TlsModeProto::Unspecified => Err(Status::invalid_argument("tls mode is required")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a `nuncio_core::AccountConfig` onto its wire-format
|
||||
/// `nuncio.v1.AccountConfig` representation for `ListAccounts` responses.
|
||||
/// The password credential is NEVER part of `AccountConfig` (it lives only
|
||||
/// in the OS keyring, keyed by `keyring_secret_key`), so there is nothing to
|
||||
/// scrub here -- there is simply no field to carry it.
|
||||
fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountConfigProto {
|
||||
AccountConfigProto {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
email_address: config.email_address,
|
||||
protocol: map_account_protocol_to_proto(config.protocol).into(),
|
||||
server_host: config.server_host,
|
||||
server_port: u32::from(config.server_port),
|
||||
use_tls: config.use_tls,
|
||||
imap_tls_mode: map_tls_mode_to_proto(config.imap_tls_mode).into(),
|
||||
smtp_tls_mode: map_tls_mode_to_proto(config.smtp_tls_mode).into(),
|
||||
keyring_secret_key: config.keyring_secret_key,
|
||||
sync_interval_secs: config.sync_interval_secs,
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a wire-format `nuncio.v1.AccountConfig` request payload back onto
|
||||
/// `nuncio_core::AccountConfig`. Rejects a `server_port` outside `1..=65535`
|
||||
/// with `Status::invalid_argument` rather than silently truncating it.
|
||||
fn map_account_config_from_proto(
|
||||
config: AccountConfigProto,
|
||||
) -> Result<nuncio_core::AccountConfig, Status> {
|
||||
let protocol = map_account_protocol_from_proto(config.protocol())?;
|
||||
let imap_tls_mode = map_tls_mode_from_proto(config.imap_tls_mode())?;
|
||||
let smtp_tls_mode = map_tls_mode_from_proto(config.smtp_tls_mode())?;
|
||||
let server_port = u16::try_from(config.server_port)
|
||||
.map_err(|_| Status::invalid_argument("server_port must be in range 1..=65535"))?;
|
||||
|
||||
Ok(nuncio_core::AccountConfig {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
email_address: config.email_address,
|
||||
protocol,
|
||||
server_host: config.server_host,
|
||||
server_port,
|
||||
use_tls: config.use_tls,
|
||||
imap_tls_mode,
|
||||
smtp_tls_mode,
|
||||
keyring_secret_key: config.keyring_secret_key,
|
||||
sync_interval_secs: config.sync_interval_secs,
|
||||
})
|
||||
}
|
||||
|
||||
/// `nuncio.v1.Accounts` gRPC service implementation backed by the daemon's
|
||||
/// live [`DatabaseEngine`] and [`SecretManager`] vault (backlog stories
|
||||
/// 1.C.1 / 1.C.2, GH #156 / GH #157).
|
||||
///
|
||||
/// Persists [`nuncio_core::AccountConfig`] rows via `DatabaseEngine::save_account`
|
||||
/// and routes the password credential exclusively to the OS keyring vault via
|
||||
/// `SecretManager::set_secret` -- the password is never written to SQLite,
|
||||
/// never logged, and never echoed back in any RPC response.
|
||||
struct AccountsGrpcService {
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
}
|
||||
|
||||
#[tonic::async_trait]
|
||||
impl Accounts for AccountsGrpcService {
|
||||
async fn add_account(
|
||||
&self,
|
||||
request: Request<AddAccountRequest>,
|
||||
) -> Result<Response<AddAccountResponse>, Status> {
|
||||
let req = request.into_inner();
|
||||
let proto_config = req
|
||||
.config
|
||||
.ok_or_else(|| Status::invalid_argument("config is required"))?;
|
||||
if req.password.is_empty() {
|
||||
return Err(Status::invalid_argument("password is required"));
|
||||
}
|
||||
|
||||
let config = map_account_config_from_proto(proto_config)?;
|
||||
config
|
||||
.validate()
|
||||
.map_err(|e| Status::invalid_argument(e.to_string()))?;
|
||||
|
||||
// Store the password credential in the OS keyring FIRST, keyed by
|
||||
// this account's `keyring_secret_key` -- it is never written to
|
||||
// SQLite. If this fails, the account row below is deliberately
|
||||
// never saved, so a failed credential write can never leave behind
|
||||
// an account config with no retrievable password.
|
||||
self.secrets
|
||||
.set_secret(&config.keyring_secret_key, &req.password)
|
||||
.map_err(|e| Status::internal(format!("failed to store credential in vault: {e}")))?;
|
||||
|
||||
self.db
|
||||
.save_account(&config)
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to persist account: {e}")))?;
|
||||
|
||||
Ok(Response::new(AddAccountResponse { id: config.id }))
|
||||
}
|
||||
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
_request: Request<ListAccountsRequest>,
|
||||
) -> Result<Response<ListAccountsResponse>, Status> {
|
||||
let accounts = self
|
||||
.db
|
||||
.list_accounts()
|
||||
.await
|
||||
.map_err(|e| Status::internal(format!("failed to list accounts: {e}")))?
|
||||
.into_iter()
|
||||
.map(map_account_config_to_proto)
|
||||
.collect();
|
||||
|
||||
Ok(Response::new(ListAccountsResponse { accounts }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
|
||||
/// server.
|
||||
///
|
||||
@@ -217,8 +383,8 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
|
||||
}
|
||||
|
||||
/// Binds a loopback TCP listener at `addr` and serves the `nuncio.v1.System`
|
||||
/// gRPC service on it, authenticated by `token`, until the transport server
|
||||
/// errors.
|
||||
/// and `nuncio.v1.Accounts` gRPC services on it, both authenticated by
|
||||
/// `token`, until the transport server errors.
|
||||
///
|
||||
/// `addr` MUST be a loopback address (e.g. `127.0.0.1:PORT`); callers are
|
||||
/// responsible for passing loopback-only addresses (see
|
||||
@@ -226,6 +392,8 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
|
||||
pub async fn serve(
|
||||
addr: &str,
|
||||
event_bus: Arc<EventBus>,
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
token: impl Into<Arc<str>>,
|
||||
) -> Result<(), GrpcServeError> {
|
||||
let listener = TcpListener::bind(addr)
|
||||
@@ -234,11 +402,21 @@ pub async fn serve(
|
||||
addr: addr.to_string(),
|
||||
source,
|
||||
})?;
|
||||
serve_on_listener(listener, event_bus, token).await
|
||||
serve_on_listener(listener, event_bus, db, secrets, token).await
|
||||
}
|
||||
|
||||
/// Serves the `nuncio.v1.System` gRPC service on an already-bound
|
||||
/// [`TcpListener`].
|
||||
/// Serves the `nuncio.v1.System` and `nuncio.v1.Accounts` gRPC services on
|
||||
/// an already-bound [`TcpListener`].
|
||||
///
|
||||
/// # Security (GH #165)
|
||||
///
|
||||
/// EVERY service mounted on this server MUST be wrapped in its own
|
||||
/// [`BearerAuthInterceptor`] via `*Server::with_interceptor`, exactly like
|
||||
/// `System` and `Accounts` below -- never `add_service(SomeServer::new(...))`
|
||||
/// unwrapped. This is a hard, non-negotiable invariant: an un-intercepted
|
||||
/// service mounted here would be reachable by any local process without
|
||||
/// authentication. If a future story adds another service, mount it the
|
||||
/// same way.
|
||||
///
|
||||
/// Exposed separately from [`serve`] so tests can bind an ephemeral loopback
|
||||
/// port (`127.0.0.1:0`), read back the OS-assigned port via
|
||||
@@ -248,14 +426,23 @@ pub async fn serve(
|
||||
pub async fn serve_on_listener(
|
||||
listener: TcpListener,
|
||||
event_bus: Arc<EventBus>,
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
token: impl Into<Arc<str>>,
|
||||
) -> Result<(), GrpcServeError> {
|
||||
let service = SystemGrpcService { event_bus };
|
||||
let interceptor = BearerAuthInterceptor::new(token);
|
||||
let svc = SystemServer::with_interceptor(service, interceptor);
|
||||
let token: Arc<str> = token.into();
|
||||
|
||||
let system_service = SystemGrpcService { event_bus };
|
||||
let system_interceptor = BearerAuthInterceptor::new(token.clone());
|
||||
let system_svc = SystemServer::with_interceptor(system_service, system_interceptor);
|
||||
|
||||
let accounts_service = AccountsGrpcService { db, secrets };
|
||||
let accounts_interceptor = BearerAuthInterceptor::new(token);
|
||||
let accounts_svc = AccountsServer::with_interceptor(accounts_service, accounts_interceptor);
|
||||
|
||||
Server::builder()
|
||||
.add_service(svc)
|
||||
.add_service(system_svc)
|
||||
.add_service(accounts_svc)
|
||||
.serve_with_incoming(TcpListenerStream::new(listener))
|
||||
.await
|
||||
.map_err(GrpcServeError::Transport)
|
||||
@@ -265,12 +452,35 @@ pub async fn serve_on_listener(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nuncio_core::{CoreCommand, EngineStatus};
|
||||
use nuncio_proto::v1::accounts_client::AccountsClient;
|
||||
use nuncio_proto::v1::system_client::SystemClient;
|
||||
use tonic::Code;
|
||||
|
||||
/// Spawns a test server backed by a fresh ephemeral database and a
|
||||
/// fresh [`SecretManager::mock`] vault -- the right default for every
|
||||
/// test that only exercises `System` or doesn't care about pre-existing
|
||||
/// account/keyring state. Tests that DO care (persistence-across-restart,
|
||||
/// credential-secrecy) build their own `db`/`secrets` and call
|
||||
/// [`spawn_test_server_with`] directly instead.
|
||||
async fn spawn_test_server(
|
||||
event_bus: Arc<EventBus>,
|
||||
token: &str,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
spawn_test_server_with(event_bus, Arc::new(db), secrets, token).await
|
||||
}
|
||||
|
||||
/// Spawns a test server on an ephemeral loopback port backed by the
|
||||
/// given `db` and `secrets`, so tests can share (and re-open) the same
|
||||
/// database path / mock keyring state across multiple server instances.
|
||||
async fn spawn_test_server_with(
|
||||
event_bus: Arc<EventBus>,
|
||||
db: Arc<DatabaseEngine>,
|
||||
secrets: Arc<SecretManager>,
|
||||
token: &str,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
@@ -278,7 +488,7 @@ mod tests {
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
let token = token.to_string();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = serve_on_listener(listener, event_bus, token).await;
|
||||
let _ = serve_on_listener(listener, event_bus, db, secrets, token).await;
|
||||
});
|
||||
(addr, handle)
|
||||
}
|
||||
@@ -400,8 +610,12 @@ mod tests {
|
||||
// `spawn_test_server` helper above which pre-binds the listener
|
||||
// itself and calls `serve_on_listener` directly.
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let _handle = tokio::spawn(async move {
|
||||
let _ = serve("127.0.0.1:0", event_bus, "token").await;
|
||||
let _ = serve("127.0.0.1:0", event_bus, Arc::new(db), secrets, "token").await;
|
||||
});
|
||||
// Yield so the spawned task is polled at least once and actually
|
||||
// reaches the bind + delegate-to-`serve_on_listener` call before
|
||||
@@ -412,9 +626,19 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn serve_fails_closed_when_bind_address_is_invalid() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let err = serve("not-a-valid-addr", event_bus, "token")
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect_err("invalid bind address must fail");
|
||||
.expect("connect ephemeral test db");
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let err = serve(
|
||||
"not-a-valid-addr",
|
||||
event_bus,
|
||||
Arc::new(db),
|
||||
secrets,
|
||||
"token",
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid bind address must fail");
|
||||
assert!(matches!(err, GrpcServeError::Bind { .. }));
|
||||
}
|
||||
|
||||
@@ -568,4 +792,270 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_account_config_proto(id: &str, keyring_secret_key: &str) -> AccountConfigProto {
|
||||
AccountConfigProto {
|
||||
id: id.to_string(),
|
||||
name: "Restart Test Account".to_string(),
|
||||
email_address: format!("{id}@nuncio.mx"),
|
||||
protocol: AccountProtocolProto::ImapSmtp.into(),
|
||||
server_host: "imap.nuncio.mx".to_string(),
|
||||
server_port: 993,
|
||||
use_tls: true,
|
||||
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
|
||||
keyring_secret_key: keyring_secret_key.to_string(),
|
||||
sync_interval_secs: 60,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_account_rejects_missing_bearer_token() {
|
||||
// Confirms `Accounts` is mounted behind its own `BearerAuthInterceptor`
|
||||
// exactly like `System` (GH #165: no un-intercepted service).
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.add_account(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-noauth",
|
||||
"nuncio/acct-noauth",
|
||||
)),
|
||||
password: "irrelevant".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_accounts_rejects_missing_bearer_token() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.list_accounts(ListAccountsRequest {})
|
||||
.await
|
||||
.expect_err("missing bearer token must be rejected");
|
||||
assert_eq!(err.code(), Code::Unauthenticated);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_account_rejects_empty_password() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto("acct-nopw", "nuncio/acct-nopw")),
|
||||
password: String::new(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("empty password must be rejected");
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_account_rejects_missing_config() {
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let err = client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: None,
|
||||
password: "some-password".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect_err("missing config must be rejected");
|
||||
assert_eq!(err.code(), Code::InvalidArgument);
|
||||
}
|
||||
|
||||
/// Backlog story 1.C.1 (GH #156): proves an account added through the
|
||||
/// daemon persists across a daemon restart. Simulates the restart by
|
||||
/// dropping the first `DatabaseEngine`/server pair and opening a brand
|
||||
/// new one at the SAME on-disk database path (a real persistent temp
|
||||
/// file, not `connect_ephemeral`'s throwaway one), then calling
|
||||
/// `ListAccounts` against the second server instance.
|
||||
#[tokio::test]
|
||||
async fn add_account_persists_across_a_simulated_daemon_restart() {
|
||||
let dir = tempfile::tempdir().expect("create persistent temp dir");
|
||||
let db_path = dir.path().join("accounts_restart_test.db");
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
|
||||
// "First daemon run".
|
||||
let db_first_run = Arc::new(
|
||||
DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.expect("open db on first run"),
|
||||
);
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
Arc::new(EventBus::new()),
|
||||
db_first_run.clone(),
|
||||
secrets.clone(),
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let add_response = client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-restart-1",
|
||||
"nuncio/acct-restart-1",
|
||||
)),
|
||||
password: "restart-secret-pw".to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("add_account succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(add_response.id, "acct-restart-1");
|
||||
|
||||
db_first_run.close().await;
|
||||
|
||||
// "Second daemon run": a brand new `DatabaseEngine`, opened at the
|
||||
// exact same `db_path`, sharing the SAME mock `SecretManager` (so
|
||||
// the previously-provisioned keyring entry is still readable, just
|
||||
// like a real OS keyring survives a daemon restart).
|
||||
let db_second_run = Arc::new(
|
||||
DatabaseEngine::connect_file(&db_path, &secrets)
|
||||
.await
|
||||
.expect("re-open db on second run"),
|
||||
);
|
||||
let (addr2, _handle2) = spawn_test_server_with(
|
||||
Arc::new(EventBus::new()),
|
||||
db_second_run,
|
||||
secrets.clone(),
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client2 = AccountsClient::connect(format!("http://{addr2}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
let list_response = client2
|
||||
.list_accounts(authed_bearer_request(ListAccountsRequest {}))
|
||||
.await
|
||||
.expect("list_accounts succeeds")
|
||||
.into_inner();
|
||||
|
||||
assert_eq!(list_response.accounts.len(), 1);
|
||||
let restored = &list_response.accounts[0];
|
||||
assert_eq!(restored.id, "acct-restart-1");
|
||||
assert_eq!(restored.email_address, "acct-restart-1@nuncio.mx");
|
||||
assert_eq!(restored.keyring_secret_key, "nuncio/acct-restart-1");
|
||||
|
||||
// The credential itself must have survived too, in the (mock)
|
||||
// keyring -- never in the database.
|
||||
let stored_password = secrets
|
||||
.get_secret("nuncio/acct-restart-1")
|
||||
.expect("password still retrievable from vault after restart");
|
||||
assert_eq!(stored_password, "restart-secret-pw");
|
||||
}
|
||||
|
||||
/// Backlog story 1.C.2 (GH #157): proves the password credential is
|
||||
/// retrievable from the (mock) keyring, but never appears in any
|
||||
/// `accounts` SQLite column, and never appears in the `ListAccounts`
|
||||
/// wire response.
|
||||
#[tokio::test]
|
||||
async fn add_account_password_never_touches_sqlite_or_the_list_response() {
|
||||
const PASSWORD: &str = "super-secret-credential-42";
|
||||
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let db = Arc::new(db);
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let (addr, _handle) = spawn_test_server_with(
|
||||
Arc::new(EventBus::new()),
|
||||
db.clone(),
|
||||
secrets.clone(),
|
||||
"correct-token",
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut client = AccountsClient::connect(format!("http://{addr}"))
|
||||
.await
|
||||
.expect("client connects");
|
||||
client
|
||||
.add_account(authed_bearer_request(AddAccountRequest {
|
||||
config: Some(sample_account_config_proto(
|
||||
"acct-secrecy-1",
|
||||
"nuncio/acct-secrecy-1",
|
||||
)),
|
||||
password: PASSWORD.to_string(),
|
||||
}))
|
||||
.await
|
||||
.expect("add_account succeeds");
|
||||
|
||||
// The password IS retrievable from the (mock) keyring vault, keyed
|
||||
// by this account's `keyring_secret_key`.
|
||||
let stored_password = secrets
|
||||
.get_secret("nuncio/acct-secrecy-1")
|
||||
.expect("password stored in vault");
|
||||
assert_eq!(stored_password, PASSWORD);
|
||||
|
||||
// The password is NEVER present in any `accounts` table column.
|
||||
#[allow(clippy::type_complexity)]
|
||||
let rows: Vec<(
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
String,
|
||||
i64,
|
||||
i64,
|
||||
String,
|
||||
i64,
|
||||
)> = sqlx::query_as(
|
||||
"SELECT id, name, email_address, protocol, server_host, server_port, use_tls, \
|
||||
keyring_secret_key, sync_interval_secs FROM accounts",
|
||||
)
|
||||
.fetch_all(db.pool())
|
||||
.await
|
||||
.expect("query accounts table directly");
|
||||
assert_eq!(rows.len(), 1);
|
||||
let (id, name, email_address, protocol, server_host, _port, _tls, keyring_key, _interval) =
|
||||
&rows[0];
|
||||
for text_column in [
|
||||
id.as_str(),
|
||||
name.as_str(),
|
||||
email_address.as_str(),
|
||||
protocol.as_str(),
|
||||
server_host.as_str(),
|
||||
keyring_key.as_str(),
|
||||
] {
|
||||
assert!(
|
||||
!text_column.contains(PASSWORD),
|
||||
"password leaked into an accounts DB column: {text_column:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The password is NEVER present in the `ListAccounts` response.
|
||||
let list_response = client
|
||||
.list_accounts(authed_bearer_request(ListAccountsRequest {}))
|
||||
.await
|
||||
.expect("list_accounts succeeds")
|
||||
.into_inner();
|
||||
assert_eq!(list_response.accounts.len(), 1);
|
||||
let response_debug = format!("{list_response:?}");
|
||||
assert!(
|
||||
!response_debug.contains(PASSWORD),
|
||||
"password leaked into the ListAccounts response: {response_debug}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,30 @@ pub mod grpc;
|
||||
pub mod orchestrator;
|
||||
pub use orchestrator::SelfHealingSyncOrchestrator;
|
||||
|
||||
/// Default PERSISTENT database path for `nunciod`: `~/.nuncio/nuncio.db`
|
||||
/// (or `%USERPROFILE%\.nuncio\nuncio.db` on Windows, since `USERPROFILE` is
|
||||
/// checked first). This is deliberately NOT a temp/ephemeral path -- it is
|
||||
/// what makes accounts (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157)
|
||||
/// and every other piece of daemon state survive a daemon restart.
|
||||
///
|
||||
/// Mirrors `nuncio_store::recovery::CorruptedBackupManager::default_backup_dir`'s
|
||||
/// existing `~/.nuncio/...` convention so all of `nunciod`'s on-disk state
|
||||
/// lives under the same root directory.
|
||||
///
|
||||
/// Callers that need a different (e.g. isolated, ephemeral) path -- tests,
|
||||
/// CI, multiple daemon instances on one machine -- MUST set the
|
||||
/// `NUNCIO_DB_PATH` environment variable instead of calling this directly;
|
||||
/// see `main`'s boot sequence.
|
||||
pub fn default_db_path() -> std::path::PathBuf {
|
||||
if let Ok(home_str) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) {
|
||||
std::path::PathBuf::from(home_str)
|
||||
.join(".nuncio")
|
||||
.join("nuncio.db")
|
||||
} else {
|
||||
std::path::PathBuf::from(".nuncio").join("nuncio.db")
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment variable that opts `nunciod` in to its autonomous
|
||||
/// background auto-update-check loop.
|
||||
///
|
||||
@@ -62,4 +86,31 @@ mod tests {
|
||||
std::env::remove_var(AUTO_UPDATE_ENV_VAR);
|
||||
assert!(!auto_update_task_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_db_path_is_persistent_under_dot_nuncio_directory() {
|
||||
// Deliberately does not mutate `USERPROFILE`/`HOME` (real env vars
|
||||
// many other things -- `tempfile`, `keyring`, etc. -- may also
|
||||
// read); instead this asserts the path SHAPE holds regardless of
|
||||
// which of the two branches the current process environment takes,
|
||||
// proving the default is a stable `.nuncio/nuncio.db` layout and
|
||||
// never a bare OS temp directory.
|
||||
let path = default_db_path();
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|n| n.to_str()),
|
||||
Some("nuncio.db"),
|
||||
"default db path must end in nuncio.db, got {path:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|n| n.to_str()),
|
||||
Some(".nuncio"),
|
||||
"default db path must live under a .nuncio directory, got {path:?}"
|
||||
);
|
||||
assert!(
|
||||
!path.starts_with(std::env::temp_dir()),
|
||||
"default db path must never be an ephemeral temp path, got {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing::info!("Starting Nuncio Central Daemon Service (nunciod)...");
|
||||
|
||||
let event_bus = Arc::new(EventBus::new());
|
||||
// PERSISTENT database path (backlog stories 1.C.1 / 1.C.2, GH #156 /
|
||||
// GH #157): defaults to `~/.nuncio/nuncio.db` -- NOT a temp/ephemeral
|
||||
// path -- so accounts (and everything else) survive a daemon restart.
|
||||
// `NUNCIO_DB_PATH` overrides it, which tests/CI use to point at an
|
||||
// isolated temp path instead of touching a real user's home directory.
|
||||
let db_path = std::env::var("NUNCIO_DB_PATH")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| std::env::temp_dir().join("nuncio_main.db"));
|
||||
.unwrap_or_else(|_| nunciod::default_db_path());
|
||||
|
||||
let orchestrator = nunciod::SelfHealingSyncOrchestrator::new(&db_path, event_bus.clone());
|
||||
let (db, _summary) = orchestrator.initialize_and_recover().await?;
|
||||
@@ -278,26 +283,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
})
|
||||
});
|
||||
|
||||
// gRPC `nuncio.v1.System` server (backlog story 1.A.2 / GH-149).
|
||||
// gRPC `nuncio.v1.System` + `nuncio.v1.Accounts` server (backlog
|
||||
// stories 1.A.2 / GH-149 and 1.C.1 + 1.C.2 / GH-156 + GH-157).
|
||||
//
|
||||
// Runs ALONGSIDE the existing JSON-RPC IPC server below; the migration
|
||||
// off the hand-rolled JSON-RPC transport happens in later stories. The
|
||||
// bearer token is minted (or loaded, on subsequent runs) from the real
|
||||
// OS keyring vault via `SecretManager::production()`, fails closed if
|
||||
// the keyring is unavailable, and is never logged.
|
||||
let grpc_secrets = nuncio_store::vault::SecretManager::production();
|
||||
// the keyring is unavailable, and is never logged. `Accounts` reuses
|
||||
// this SAME `SecretManager` to write account password credentials to
|
||||
// the OS keyring (never to SQLite) -- see `nunciod::grpc`'s security
|
||||
// comment (GH #165) on why EVERY mounted service shares this one
|
||||
// `BearerAuthInterceptor`.
|
||||
let grpc_secrets = Arc::new(nuncio_store::vault::SecretManager::production());
|
||||
let grpc_token_bytes = grpc_secrets
|
||||
.get_or_create_key_bytes(nuncio_store::vault::GRPC_TOKEN_ACCOUNT, 32)
|
||||
.map_err(|e| format!("failed to provision gRPC bearer token from vault: {e}"))?;
|
||||
let grpc_token = hex::encode(grpc_token_bytes);
|
||||
let grpc_addr = nunciod::grpc::grpc_addr_from_env();
|
||||
tracing::info!(
|
||||
"nunciod gRPC (nuncio.v1.System) starting on {} (loopback only)",
|
||||
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts) starting on {} (loopback only)",
|
||||
grpc_addr
|
||||
);
|
||||
let grpc_event_bus = event_bus.clone();
|
||||
let grpc_db = db.clone();
|
||||
let _grpc_task = tokio::spawn(async move {
|
||||
if let Err(e) = nunciod::grpc::serve(&grpc_addr, grpc_event_bus, grpc_token).await {
|
||||
if let Err(e) = nunciod::grpc::serve(
|
||||
&grpc_addr,
|
||||
grpc_event_bus,
|
||||
grpc_db,
|
||||
grpc_secrets,
|
||||
grpc_token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("nunciod gRPC server failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
use nuncio_cli::{Commands, HeadlessRunner, MailSubcommand, SystemSubcommand};
|
||||
use nuncio_core::ipc::{IpcClient, IpcDaemonServer};
|
||||
use nuncio_core::{CoreCommand, EventBus};
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -143,8 +144,19 @@ async fn cli_system_status_round_trips_over_grpc_to_live_daemon() {
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
let server_event_bus = event_bus.clone();
|
||||
let (server_db, _server_db_dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let server_secrets = secrets.clone();
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = nunciod::grpc::serve_on_listener(listener, server_event_bus, token).await;
|
||||
let _ = nunciod::grpc::serve_on_listener(
|
||||
listener,
|
||||
server_event_bus,
|
||||
Arc::new(server_db),
|
||||
server_secrets,
|
||||
token,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
// 4. Drive the real `nuncio-cli` `HeadlessRunner`'s gRPC `system status`
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
use nuncio_core::{CoreCommand, EngineStatus, EventBus};
|
||||
use nuncio_proto::v1::system_client::SystemClient;
|
||||
use nuncio_proto::v1::GetStatusRequest;
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nunciod::grpc::serve_on_listener;
|
||||
use std::sync::Arc;
|
||||
@@ -26,13 +27,21 @@ use tonic::{Code, Request};
|
||||
/// Because `TcpListener::bind` has already succeeded (the OS is accepting
|
||||
/// connections on the returned port) before this function returns, callers
|
||||
/// can dial `addr` immediately with no fixed sleep.
|
||||
///
|
||||
/// Backs the mounted `nuncio.v1.Accounts` service with its own fresh
|
||||
/// ephemeral database + mock keyring, since this test file only exercises
|
||||
/// `System` and doesn't care about account/keyring state.
|
||||
async fn start_server(event_bus: Arc<EventBus>, token: String) -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind ephemeral loopback port");
|
||||
let addr = listener.local_addr().expect("listener has local addr");
|
||||
let (db, _dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
tokio::spawn(async move {
|
||||
let _ = serve_on_listener(listener, event_bus, token).await;
|
||||
let _ = serve_on_listener(listener, event_bus, Arc::new(db), secrets, token).await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user