fix(cli): let the CLI take its gRPC token from the environment #420
No files matched your search
@@ -12,4 +12,4 @@ pub use args::{
|
||||
SystemSubcommand,
|
||||
};
|
||||
pub use output::JsonResponse;
|
||||
pub use runner::HeadlessRunner;
|
||||
pub use runner::{HeadlessRunner, GRPC_TOKEN_ENV};
|
||||
+223
-50
@@ -5,6 +5,7 @@ use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use nuncio_store::{DatabaseEngine, DatabaseError};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::args::{
|
||||
@@ -14,6 +15,40 @@ use crate::args::{
|
||||
|
||||
use crate::output::{format_json, format_json_error, format_json_error_with_info};
|
||||
|
||||
/// Environment variable that supplies the gRPC bearer token directly, bypassing
|
||||
/// the OS keyring entirely.
|
||||
///
|
||||
/// The keyring remains the default and only this client-side lookup honours the
|
||||
/// override: `nunciod` always mints and owns the authoritative token, so the
|
||||
/// daemon must never accept one from its environment.
|
||||
///
|
||||
/// The override exists because the keyring item's ACL is bound to the process
|
||||
/// that created it (`nunciod`). A *different* binary reading the same item — the
|
||||
/// CLI — makes the platform keychain ask the logged-in user for consent, which
|
||||
/// nothing can answer without an interactive desktop session.
|
||||
pub const GRPC_TOKEN_ENV: &str = "NUNCIO_GRPC_TOKEN";
|
||||
|
||||
/// Upper bound on how long a keyring read may block before the CLI gives up and
|
||||
/// reports an actionable error. Generous enough that a human can answer a
|
||||
/// keychain consent prompt, short enough that an unattended run always
|
||||
/// terminates instead of stalling forever.
|
||||
const KEYRING_READ_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Normalizes a raw environment value into a usable token override.
|
||||
///
|
||||
/// Surrounding whitespace is stripped (shell heredocs and `env` files routinely
|
||||
/// add a trailing newline) and an empty value is treated as "not set", so
|
||||
/// `NUNCIO_GRPC_TOKEN=` cannot silently authenticate with an empty token.
|
||||
fn normalize_token_override(raw: Option<String>) -> Option<String> {
|
||||
raw.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Reads the gRPC bearer token override from the process environment.
|
||||
fn grpc_token_from_env() -> Option<String> {
|
||||
normalize_token_override(std::env::var(GRPC_TOKEN_ENV).ok())
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -326,7 +361,9 @@ pub enum RunnerError {
|
||||
/// `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.
|
||||
/// [`SecretManager::mock`] so no test ever touches the real vault. Setting
|
||||
/// [`GRPC_TOKEN_ENV`] overrides that lookup entirely, which is how the CLI is
|
||||
/// driven from a non-interactive session.
|
||||
///
|
||||
/// `account add` persists through the daemon so the account (and its
|
||||
/// password, stored ONLY in the daemon's OS keyring vault) survives past
|
||||
@@ -337,6 +374,10 @@ pub struct HeadlessRunner {
|
||||
db: DatabaseEngine,
|
||||
secrets: Arc<SecretManager>,
|
||||
grpc_addr: String,
|
||||
// When present, the bearer token to send instead of consulting `secrets`.
|
||||
// Resolved once at construction so a single CLI invocation cannot change
|
||||
// which credential it authenticates with halfway through.
|
||||
token_override: Option<String>,
|
||||
// Kept only to hold the ephemeral database's backing directory open for
|
||||
// the runner's lifetime: dropping it would unlink the directory out from
|
||||
// under `db` while the pool may still need to open new connections.
|
||||
@@ -347,10 +388,13 @@ impl HeadlessRunner {
|
||||
/// Initialize a new `HeadlessRunner` with an ephemeral database, the
|
||||
/// real OS keyring vault ([`SecretManager::production`]), and the gRPC
|
||||
/// daemon address resolved from [`nuncio_proto::grpc_addr_from_env`].
|
||||
/// [`GRPC_TOKEN_ENV`], when set, supplies the bearer token instead of the
|
||||
/// keyring.
|
||||
pub async fn ephemeral() -> Result<Self, RunnerError> {
|
||||
Self::ephemeral_with(
|
||||
Self::ephemeral_with_token_override(
|
||||
Arc::new(SecretManager::production()),
|
||||
nuncio_proto::grpc_addr_from_env(),
|
||||
grpc_token_from_env(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -362,9 +406,28 @@ impl HeadlessRunner {
|
||||
/// `system status`: pass a [`SecretManager::mock`]-backed instance
|
||||
/// (never the real OS keyring) and the address of a test-local gRPC
|
||||
/// server.
|
||||
/// The ambient [`GRPC_TOKEN_ENV`] override is deliberately NOT consulted
|
||||
/// here, so a variable that happens to be exported in a developer's shell
|
||||
/// can never change what an injected-vault test authenticates with; tests
|
||||
/// that want the override pass it explicitly to
|
||||
/// [`Self::ephemeral_with_token_override`].
|
||||
pub async fn ephemeral_with(
|
||||
secrets: Arc<SecretManager>,
|
||||
grpc_addr: String,
|
||||
) -> Result<Self, RunnerError> {
|
||||
Self::ephemeral_with_token_override(secrets, grpc_addr, None).await
|
||||
}
|
||||
|
||||
/// Initialize a new `HeadlessRunner` with an ephemeral database, an
|
||||
/// explicit secret vault + gRPC daemon address, and an explicit bearer
|
||||
/// token override.
|
||||
///
|
||||
/// `token_override`, when `Some`, is used verbatim as the `authorization:
|
||||
/// Bearer` credential and the vault is never consulted for it.
|
||||
pub async fn ephemeral_with_token_override(
|
||||
secrets: Arc<SecretManager>,
|
||||
grpc_addr: String,
|
||||
token_override: Option<String>,
|
||||
) -> Result<Self, RunnerError> {
|
||||
let (db, db_dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
@@ -376,9 +439,59 @@ impl HeadlessRunner {
|
||||
secrets,
|
||||
_db_dir: db_dir,
|
||||
grpc_addr,
|
||||
token_override: normalize_token_override(token_override),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token, preferring an explicit override over the
|
||||
/// OS keyring.
|
||||
///
|
||||
/// The keyring read runs on a detached OS thread behind a bounded timeout
|
||||
/// rather than inline. On macOS a keychain item whose ACL does not trust
|
||||
/// this binary makes the read block on a GUI consent prompt that never
|
||||
/// arrives in a headless session, and that call cannot be cancelled — so
|
||||
/// the timeout must be able to abandon the thread. A plain
|
||||
/// `std::thread::spawn` is used instead of `spawn_blocking` precisely
|
||||
/// because Tokio joins its blocking pool during runtime shutdown, which
|
||||
/// would reintroduce the hang at process exit; a detached thread is reaped
|
||||
/// when the process ends.
|
||||
///
|
||||
/// The returned error never carries token material — only the vault's own
|
||||
/// failure description, which is keyed by account name, not secret value.
|
||||
async fn resolve_grpc_token(&self) -> Result<String, String> {
|
||||
if let Some(token) = &self.token_override {
|
||||
return Ok(token.clone());
|
||||
}
|
||||
|
||||
let secrets = Arc::clone(&self.secrets);
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
std::thread::spawn(move || {
|
||||
let resolved = secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.map(hex::encode)
|
||||
.map_err(|e| e.to_string());
|
||||
// A closed receiver means the timeout already fired; nothing to do.
|
||||
let _ = tx.send(resolved);
|
||||
});
|
||||
|
||||
match tokio::time::timeout(KEYRING_READ_TIMEOUT, rx).await {
|
||||
Ok(Ok(Ok(token))) => Ok(token),
|
||||
Ok(Ok(Err(e))) => Err(format!("failed to read gRPC bearer token from vault: {e}")),
|
||||
Ok(Err(_)) => {
|
||||
Err("gRPC bearer token lookup ended without producing a result".to_string())
|
||||
}
|
||||
Err(_) => Err(format!(
|
||||
"timed out after {}s reading the gRPC bearer token from the OS keyring: the \
|
||||
keyring is most likely waiting on an interactive access-consent prompt that \
|
||||
cannot be answered in this session (the token belongs to nunciod, so another \
|
||||
binary reading it needs consent). Run this command from a logged-in desktop \
|
||||
session, or set {GRPC_TOKEN_ENV} to the daemon's bearer token to bypass the \
|
||||
keyring entirely",
|
||||
KEYRING_READ_TIMEOUT.as_secs()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the underlying `EventBus`.
|
||||
#[allow(dead_code)]
|
||||
pub fn event_bus(&self) -> &EventBus {
|
||||
@@ -1412,17 +1525,13 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
|
||||
/// `nuncio.v1.Export` service at `self.grpc_addr`, shared by
|
||||
/// [`Self::handle_mail_export`].
|
||||
async fn connect_export_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedExportClient, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_export(&self.grpc_addr, &token)
|
||||
.await
|
||||
@@ -1524,17 +1633,13 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
|
||||
/// `nuncio.v1.Audit` service at `self.grpc_addr`, shared by
|
||||
/// [`Self::handle_audit_list`] / [`Self::handle_audit_verify`].
|
||||
async fn connect_audit_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedAuditClient, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_audit(&self.grpc_addr, &token)
|
||||
.await
|
||||
@@ -1994,17 +2099,13 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
|
||||
/// `nuncio.v1.Filters` service at `self.grpc_addr`, shared by every
|
||||
/// `filter` handler above.
|
||||
async fn connect_filters_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedFiltersClient, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_filters(&self.grpc_addr, &token)
|
||||
.await
|
||||
@@ -2459,51 +2560,39 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_accounts(&self.grpc_addr, &token)
|
||||
.await
|
||||
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
|
||||
/// `nuncio.v1.Mail` service at `self.grpc_addr`, shared by every
|
||||
/// `mail`/`folder` read-path handler above.
|
||||
async fn connect_mail_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedMailClient, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_mail(&self.grpc_addr, &token)
|
||||
.await
|
||||
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
|
||||
/// `nuncio.v1.Calendar` service at `self.grpc_addr`, shared by
|
||||
/// [`Self::handle_cal_list`] and [`Self::handle_cal_sync`].
|
||||
async fn connect_calendar_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedCalendarClient, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_calendar(&self.grpc_addr, &token)
|
||||
.await
|
||||
@@ -2612,17 +2701,13 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the gRPC bearer token from the injected vault and dials the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]) and dials the
|
||||
/// `nuncio.v1.Contacts` service at `self.grpc_addr`, shared by every
|
||||
/// `contact` handler above.
|
||||
async fn connect_contacts_client(
|
||||
&self,
|
||||
) -> Result<nuncio_proto::client::AuthenticatedContactsClient, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
nuncio_proto::client::connect_contacts(&self.grpc_addr, &token)
|
||||
.await
|
||||
@@ -2991,16 +3076,12 @@ impl HeadlessRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the gRPC bearer token from the injected vault, connects to the
|
||||
/// Resolves the gRPC bearer token (see [`Self::resolve_grpc_token`]), connects to the
|
||||
/// daemon over gRPC, and calls `GetStatus`. Returns the full live health
|
||||
/// response on success, or a human-readable error string describing
|
||||
/// exactly what failed (vault, connection, or the RPC itself).
|
||||
async fn query_daemon_status(&self) -> Result<nuncio_proto::v1::GetStatusResponse, 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);
|
||||
let token = self.resolve_grpc_token().await?;
|
||||
|
||||
let mut client = nuncio_proto::client::connect_system(&self.grpc_addr, &token)
|
||||
.await
|
||||
@@ -3039,6 +3120,98 @@ fn sync_state_label(state: i32) -> &'static str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalize_token_override_trims_and_rejects_blank_values() {
|
||||
assert_eq!(
|
||||
normalize_token_override(Some(" deadbeef\n".to_string())),
|
||||
Some("deadbeef".to_string())
|
||||
);
|
||||
assert_eq!(normalize_token_override(Some(String::new())), None);
|
||||
assert_eq!(normalize_token_override(Some(" ".to_string())), None);
|
||||
assert_eq!(normalize_token_override(None), None);
|
||||
}
|
||||
|
||||
/// The env override must be picked up when set and must leave the runner on
|
||||
/// the keyring path when it is unset or blank.
|
||||
#[test]
|
||||
fn grpc_token_from_env_reads_the_override_only_when_it_is_non_empty() {
|
||||
std::env::remove_var(GRPC_TOKEN_ENV);
|
||||
assert_eq!(grpc_token_from_env(), None);
|
||||
|
||||
std::env::set_var(GRPC_TOKEN_ENV, "");
|
||||
assert_eq!(grpc_token_from_env(), None);
|
||||
|
||||
std::env::set_var(GRPC_TOKEN_ENV, " c0ffee ");
|
||||
assert_eq!(grpc_token_from_env(), Some("c0ffee".to_string()));
|
||||
|
||||
std::env::remove_var(GRPC_TOKEN_ENV);
|
||||
}
|
||||
|
||||
/// An injected override must be used verbatim and must never reach the
|
||||
/// vault -- proven here by giving the runner a vault whose token is
|
||||
/// necessarily different from the override.
|
||||
#[tokio::test]
|
||||
async fn resolve_grpc_token_prefers_the_override_over_the_vault() {
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let vault_token = hex::encode(
|
||||
secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mock vault mints a token"),
|
||||
);
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with_token_override(
|
||||
Arc::clone(&secrets),
|
||||
"127.0.0.1:1".to_string(),
|
||||
Some(" override-token ".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
let resolved = runner
|
||||
.resolve_grpc_token()
|
||||
.await
|
||||
.expect("override resolves without touching the vault");
|
||||
assert_eq!(resolved, "override-token");
|
||||
assert_ne!(resolved, vault_token);
|
||||
}
|
||||
|
||||
/// With no override the vault remains the source of the token.
|
||||
#[tokio::test]
|
||||
async fn resolve_grpc_token_falls_back_to_the_vault_without_an_override() {
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let vault_token = hex::encode(
|
||||
secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mock vault mints a token"),
|
||||
);
|
||||
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::clone(&secrets), "127.0.0.1:1".to_string())
|
||||
.await
|
||||
.expect("runner init");
|
||||
|
||||
assert_eq!(
|
||||
runner
|
||||
.resolve_grpc_token()
|
||||
.await
|
||||
.expect("vault resolves the token"),
|
||||
vault_token
|
||||
);
|
||||
}
|
||||
|
||||
/// `ephemeral_with` must ignore an ambient override so an exported variable
|
||||
/// in a developer's shell cannot silently change what tests authenticate
|
||||
/// with.
|
||||
#[tokio::test]
|
||||
async fn ephemeral_with_ignores_an_ambient_env_override() {
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let runner =
|
||||
HeadlessRunner::ephemeral_with(Arc::clone(&secrets), "127.0.0.1:1".to_string())
|
||||
.await
|
||||
.expect("runner init");
|
||||
assert_eq!(runner.token_override, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tls_mode_accepts_all_valid_modes_and_rejects_garbage() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Headless bearer-token resolution for the reference CLI.
|
||||
//!
|
||||
//! `nunciod` mints the gRPC bearer token into the OS keyring, so the keyring
|
||||
//! item's access control list trusts the *daemon* binary. Any other binary
|
||||
//! reading that item -- the CLI -- is a different program, and platform
|
||||
//! keychains answer that by asking the logged-in user for consent. Nothing can
|
||||
//! answer that prompt from CI, an ssh session, or a background shell, so the
|
||||
//! read blocks forever. `NUNCIO_GRPC_TOKEN` is the explicit, opt-in escape
|
||||
//! hatch; the keyring stays the default.
|
||||
//!
|
||||
//! These tests boot the real daemon gRPC server on an ephemeral loopback port
|
||||
//! (mock vault, no real keychain, no live network) and drive the real
|
||||
//! `HeadlessRunner` against it to prove:
|
||||
//!
|
||||
//! - an override authenticates and the vault is never consulted for it;
|
||||
//! - with no override the keyring path is unchanged and still authenticates;
|
||||
//! - a wrong override fails honestly and the token never reaches the output
|
||||
//! or the telemetry.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use nuncio_cli::{Commands, HeadlessRunner, SystemSubcommand};
|
||||
use nuncio_core::EventBus;
|
||||
use nuncio_filter::FilterEngine;
|
||||
use nuncio_store::db::DatabaseEngine;
|
||||
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing::span::Attributes;
|
||||
use tracing::{Event, Id, Subscriber};
|
||||
use tracing_subscriber::layer::Context as LayerContext;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// A bearer token that is unmistakable if it ever leaks into output or logs.
|
||||
const SENTINEL_TOKEN: &str = "SENTINELheadlessCliBearerTokenVvWw001";
|
||||
|
||||
/// Boots the real daemon gRPC server on an ephemeral loopback port,
|
||||
/// authenticated by `token`. Returns the address; the temp dir is kept alive
|
||||
/// by the returned guard.
|
||||
async fn boot_daemon(secrets: Arc<SecretManager>, 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, db_dir) = DatabaseEngine::connect_ephemeral()
|
||||
.await
|
||||
.expect("connect ephemeral test db");
|
||||
let filter_engine = Arc::new(FilterEngine::new(Vec::new()).expect("empty rule set"));
|
||||
tokio::spawn(async move {
|
||||
// The temp dir must outlive the server, so it is moved into the task.
|
||||
let _db_dir = db_dir;
|
||||
let _ = nunciod::grpc::serve_on_listener(
|
||||
listener,
|
||||
Arc::new(EventBus::new()),
|
||||
Arc::new(db),
|
||||
filter_engine,
|
||||
secrets,
|
||||
token,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// Runs `system status` through the reference CLI against `addr`.
|
||||
async fn run_status(runner: &HeadlessRunner) -> String {
|
||||
runner
|
||||
.execute_command(
|
||||
&Commands::System {
|
||||
action: SystemSubcommand::Status,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// An explicitly supplied token must authenticate on its own, with the runner's
|
||||
/// vault holding a *different* token -- which is only possible if the override
|
||||
/// short-circuits the keyring entirely.
|
||||
#[tokio::test]
|
||||
async fn env_override_authenticates_without_consulting_the_vault() {
|
||||
let daemon_secrets = Arc::new(SecretManager::mock());
|
||||
let token = hex::encode(
|
||||
daemon_secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mock vault mints gRPC bearer token"),
|
||||
);
|
||||
let addr = boot_daemon(Arc::clone(&daemon_secrets), token.clone()).await;
|
||||
|
||||
// A vault the CLI could only fail with: an independent mock mints an
|
||||
// unrelated token.
|
||||
let unrelated_vault = Arc::new(SecretManager::mock());
|
||||
let unrelated_token = hex::encode(
|
||||
unrelated_vault
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mock vault mints gRPC bearer token"),
|
||||
);
|
||||
assert_ne!(unrelated_token, token);
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with_token_override(
|
||||
unrelated_vault,
|
||||
addr.to_string(),
|
||||
Some(token),
|
||||
)
|
||||
.await
|
||||
.expect("cli headless runner initializes");
|
||||
|
||||
let out = run_status(&runner).await;
|
||||
assert!(
|
||||
out.contains(r#""status":"ok""#),
|
||||
"override should authenticate, got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// With no override the CLI must behave exactly as before: read the token from
|
||||
/// the injected vault and authenticate with it.
|
||||
#[tokio::test]
|
||||
async fn keyring_path_is_unchanged_when_no_override_is_set() {
|
||||
let secrets = Arc::new(SecretManager::mock());
|
||||
let token = hex::encode(
|
||||
secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mock vault mints gRPC bearer token"),
|
||||
);
|
||||
let addr = boot_daemon(Arc::clone(&secrets), token).await;
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with(secrets, addr.to_string())
|
||||
.await
|
||||
.expect("cli headless runner initializes");
|
||||
|
||||
let out = run_status(&runner).await;
|
||||
assert!(
|
||||
out.contains(r#""status":"ok""#),
|
||||
"vault-resolved token should authenticate, got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Telemetry capture: the token may never appear in logs. ----
|
||||
|
||||
#[derive(Default)]
|
||||
struct Sink(Mutex<Vec<String>>);
|
||||
|
||||
impl Sink {
|
||||
fn push_all(&self, values: Vec<String>) {
|
||||
if let Ok(mut guard) = self.0.lock() {
|
||||
guard.extend(values);
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<String> {
|
||||
self.0.lock().map(|g| g.clone()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
struct FieldVisitor(Vec<String>);
|
||||
|
||||
impl Visit for FieldVisitor {
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
self.0.push(format!("{}={}", field.name(), value));
|
||||
}
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
self.0.push(format!("{}={:?}", field.name(), value));
|
||||
}
|
||||
}
|
||||
|
||||
struct CaptureLayer(Arc<Sink>);
|
||||
|
||||
impl<S> tracing_subscriber::Layer<S> for CaptureLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: LayerContext<'_, S>) {
|
||||
let mut visitor = FieldVisitor(vec![format!("span={}", attrs.metadata().name())]);
|
||||
attrs.record(&mut visitor);
|
||||
self.0.push_all(visitor.0);
|
||||
}
|
||||
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
|
||||
let mut visitor = FieldVisitor(vec![format!("event={}", event.metadata().target())]);
|
||||
event.record(&mut visitor);
|
||||
self.0.push_all(visitor.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// One process-wide capturing subscriber, installed once: a global subscriber
|
||||
/// can only be set once per test binary and every test here shares it.
|
||||
fn capture_sink() -> Arc<Sink> {
|
||||
static SINK: OnceLock<Arc<Sink>> = OnceLock::new();
|
||||
Arc::clone(SINK.get_or_init(|| {
|
||||
let sink = Arc::new(Sink::default());
|
||||
let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&sink)));
|
||||
let _ = tracing::subscriber::set_global_default(subscriber);
|
||||
sink
|
||||
}))
|
||||
}
|
||||
|
||||
/// A wrong override must fail honestly rather than hang or fabricate a status,
|
||||
/// and the token must never reach the command output or the telemetry.
|
||||
#[tokio::test]
|
||||
async fn rejected_override_never_echoes_the_token_to_output_or_logs() {
|
||||
let sink = capture_sink();
|
||||
|
||||
let daemon_secrets = Arc::new(SecretManager::mock());
|
||||
let token = hex::encode(
|
||||
daemon_secrets
|
||||
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
|
||||
.expect("mock vault mints gRPC bearer token"),
|
||||
);
|
||||
let addr = boot_daemon(Arc::clone(&daemon_secrets), token).await;
|
||||
|
||||
let runner = HeadlessRunner::ephemeral_with_token_override(
|
||||
Arc::new(SecretManager::mock()),
|
||||
addr.to_string(),
|
||||
Some(SENTINEL_TOKEN.to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("cli headless runner initializes");
|
||||
|
||||
let out = run_status(&runner).await;
|
||||
assert!(
|
||||
out.contains(r#""status":"error""#),
|
||||
"a wrong token must be rejected, got: {out}"
|
||||
);
|
||||
assert!(
|
||||
!out.contains(SENTINEL_TOKEN),
|
||||
"the bearer token must never be echoed in command output: {out}"
|
||||
);
|
||||
|
||||
// Proves the capture is live, so the absence assertion below cannot pass
|
||||
// by capturing nothing at all.
|
||||
tracing::info!(marker = "headless-cli-token-capture-live", "capture probe");
|
||||
|
||||
let captured = sink.snapshot();
|
||||
assert!(
|
||||
captured
|
||||
.iter()
|
||||
.any(|line| line.contains("headless-cli-token-capture-live")),
|
||||
"the capturing subscriber must be recording telemetry"
|
||||
);
|
||||
for line in &captured {
|
||||
assert!(
|
||||
!line.contains(SENTINEL_TOKEN),
|
||||
"the bearer token leaked into telemetry: {line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,34 @@ cargo run -p nunciod # gRPC on 127.0.0.1:9420
|
||||
cargo run -p nuncio-cli -- system status
|
||||
```
|
||||
|
||||
### Driving the CLI headlessly
|
||||
|
||||
`nunciod` mints the gRPC bearer token into the OS keyring, so the keychain
|
||||
item's ACL trusts **`nunciod`**. `nuncio-cli` is a different binary, so reading
|
||||
the same item makes macOS ask the logged-in user for consent (*"nuncio-cli
|
||||
wants to access key … in your keychain"*). Interactively you click Allow. In CI,
|
||||
over ssh, or in a background shell there is nobody to click it — the read
|
||||
blocks, and before this was fixed the CLI hung with no output at all.
|
||||
|
||||
Set `NUNCIO_GRPC_TOKEN` to bypass the keyring for the CLI only:
|
||||
|
||||
```bash
|
||||
NUNCIO_GRPC_TOKEN=<token> cargo run -p nuncio-cli -- system status
|
||||
```
|
||||
|
||||
When it is set and non-empty the CLI uses it verbatim and never touches the
|
||||
keyring. When it is unset, behaviour is unchanged: the token still comes from
|
||||
the keyring, now behind a bounded timeout that reports an actionable error
|
||||
instead of stalling forever. The token is the daemon's hex-encoded
|
||||
`grpc-bearer-token` keychain item; read it once from a session that can answer
|
||||
the prompt (`security find-generic-password -s mx.nuncio.vault -a
|
||||
grpc-bearer-token -w`) and pass it through your own secret store. It is a
|
||||
credential: never commit it, never log it, never echo it into a shell history
|
||||
file.
|
||||
|
||||
The daemon itself is unaffected — it *creates* the item rather than reading one
|
||||
another binary owns, so its own startup raises no consent prompt.
|
||||
|
||||
Schema migrations were removed: databases are **created, never migrated**. A
|
||||
store from before that change errors on read rather than upgrading. Deleting it
|
||||
is correct — accounts and credentials live in the OS keyring and survive; cached
|
||||
|
||||
Reference in new issue
Block a user