diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4e98e7ab..45d866c4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `ts keys rotate` and `ts keys deactivate` to drive a deployed service's signing-key admin routes, reading the admin password from `TRUSTED_SERVER_ADMIN_PASSWORD` only. `ts keys deactivate --delete` asks for confirmation first; runs whose stdin or stderr is not a terminal must pass `--yes` (#1430). - Add opt-in mobile ad-render tracing through `[integrations.gpt_diagnostics].trace_page_enabled` (default `false`, requiring GPT diagnostics). The same-tab `/_ts/trace` viewer presents bounded, redacted browser-local request, auction and GPT observations with copy, download, share and independent local/server cleanup controls. See the GPT diagnostics guide for deployment and rollout checks. - Add the opt-in `[trusted_forwarder]` section with required `auth_header` and secret-store `shared_secret` reference fields for authenticated public-origin forwarding. - `[rewrite] include_domains` limits creative asset rewriting to listed hosts. When non-empty, only asset URLs on matching hosts are proxied through `/first-party/proxy`, so only those hosts receive the EC ID on newly rewritten or signed asset URLs. The list is not enforced when a proxy URL is fetched, so previously issued proxy URLs can still forward the EC ID to hosts later removed from it. Other assets keep their original URL, and `/first-party/sign` returns `502` for them so the creative runtime loads them directly. Click-through links are not affected, and `exclude_domains` still wins. Entries must be host names, optionally prefixed with `*.`, dotted-quad IPv4 addresses, or bracketed IPv6 addresses; entries with a scheme, port, path, spaces or non-ASCII characters are rejected at load. Upgrading: deploy the binary first, then push a config that sets `include_domains`. Rolling back: remove `include_domains`, push the resulting config, then roll back the binary; older binaries reject the key because `[rewrite]` denies unknown fields. diff --git a/crates/trusted-server-cli/src/admin_client.rs b/crates/trusted-server-cli/src/admin_client.rs new file mode 100644 index 0000000000..8ba3ff8bb6 --- /dev/null +++ b/crates/trusted-server-cli/src/admin_client.rs @@ -0,0 +1,204 @@ +//! Shared client for a deployed service's `^/_ts/admin` routes. +//! +//! `ts cache purge` and `ts keys` all call basic-auth admin routes on a running service. +//! They share one implementation of the transport rules here so that the service URL +//! checks ([`crate::url_guard::service_endpoint`]), the password source, redirect refusal +//! and 401 guidance cannot drift apart between commands. + +use core::error::Error; +use std::time::Duration; + +use reqwest::StatusCode; + +use crate::error::CliResult; + +/// Environment variable carrying the admin password. +/// +/// Read from the environment and never accepted as a flag: an argument is visible to every +/// other process on the host through `ps`, and lands in shell history. +pub const ADMIN_PASSWORD_ENVIRONMENT_VARIABLE: &str = "TRUSTED_SERVER_ADMIN_PASSWORD"; + +/// Hint appended to a 401 failure, shared by every admin command. +pub(crate) const UNAUTHORIZED_HINT: &str = + " — check the admin username and $TRUSTED_SERVER_ADMIN_PASSWORD"; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Shorter than [`REQUEST_TIMEOUT`] so a stalled TCP or TLS handshake fails as a connect +/// error rather than as an overall timeout that reqwest does not mark as one. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// The raw answer from an admin route. +pub(crate) struct AdminResponse { + /// HTTP status the service answered with. + pub(crate) status: StatusCode, + /// Response body as text. + pub(crate) body: String, +} + +/// Read the admin password from [`ADMIN_PASSWORD_ENVIRONMENT_VARIABLE`]. +/// +/// # Errors +/// +/// Returns an error explaining where the password must come from when it is unset. +pub(crate) fn admin_password() -> CliResult { + std::env::var(ADMIN_PASSWORD_ENVIRONMENT_VARIABLE).map_err(|_| { + format!( + "set {ADMIN_PASSWORD_ENVIRONMENT_VARIABLE} to the service's admin password \ + (it is read from the environment, never a flag, so it does not reach `ps` \ + output or shell history)" + ) + }) +} + +/// POST a JSON body to an admin route and return the status and body. +/// +/// The endpoint is validated and the password read before any connection is made, so an +/// unsafe URL or a missing credential never reaches the network. Redirects are not +/// followed, so a login page cannot masquerade as a success. +/// +/// # Errors +/// +/// Returns an error when the endpoint is invalid, the password is missing, or the +/// service cannot be reached or its body read. Non-success statuses are returned as an +/// [`AdminResponse`] for the caller to interpret. +pub(crate) fn post_admin_json( + service: &str, + path: &str, + username: &str, + body: String, +) -> CliResult { + let endpoint = crate::url_guard::service_endpoint(service, path)?; + let password = admin_password()?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("failed to start the HTTP runtime: {error}"))?; + + // Without this the first HTTPS request panics with "No provider set". + crate::tls::install_crypto_provider(); + + runtime.block_on(async { + let client = reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + // Without it the overall timeout fires first on a stalled handshake, and the + // message below would claim the request was sent when nothing left this machine. + .connect_timeout(CONNECT_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("failed to build the HTTP client: {error}"))?; + let response = client + .post(&endpoint) + .basic_auth(username, Some(&password)) + .header("content-type", "application/json") + .body(body) + .send() + .await + .map_err(|error| { + let is_connect = error.is_connect(); + let error = describe_cause(&error.without_url()); + if is_connect { + format!("could not reach {endpoint}: {error}") + } else { + // The request may have been delivered: a key rotation is not idempotent, so + // the operator must check before retrying rather than assume nothing ran. + format!( + "sent the request to {endpoint} but got no response ({error}); the \ + service may have applied it, so check its state before retrying" + ) + } + })?; + let status = response.status(); + let body = response.text().await.map_err(|error| { + let error = describe_cause(&error.without_url()); + // A status line means the service handled the request, so a retry could apply a + // rotation twice. + format!( + "{endpoint} answered {status} but its body could not be read ({error}); the \ + service may have applied the request, so check its state before retrying" + ) + })?; + Ok(AdminResponse { status, body }) + }) +} + +/// Describe an error by its causes rather than its generic top-level text. +/// +/// reqwest reports every transport failure as `error sending request`; the useful detail +/// (refused, timed out, reset) lives in the `source()` chain. The outermost message is kept +/// only when there is no cause, and repeated messages in the chain are dropped. +fn describe_cause(error: &(dyn Error + 'static)) -> String { + let mut messages: Vec = Vec::new(); + let mut cause = error.source(); + while let Some(current) = cause { + let message = current.to_string(); + if messages.last() != Some(&message) { + messages.push(message); + } + cause = current.source(); + } + if messages.is_empty() { + error.to_string() + } else { + messages.join(": ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, derive_more::Display)] + #[display("{message}")] + struct ChainError { + message: &'static str, + source: Option>, + } + + impl Error for ChainError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn Error + 'static)) + } + } + + fn chain(messages: &[&'static str]) -> ChainError { + let mut error: Option = None; + for message in messages.iter().rev() { + error = Some(ChainError { + message, + source: error.map(Box::new), + }); + } + error.expect("should build a non-empty chain") + } + + #[test] + fn describe_cause_drops_the_generic_outer_message_and_repeats() { + let error = chain(&[ + "error sending request", + "client error (Connect)", + "connection refused", + "connection refused", + ]); + + assert_eq!( + describe_cause(&error), + "client error (Connect): connection refused", + "should list the causes once each without the outer text" + ); + } + + #[test] + fn describe_cause_keeps_the_message_when_there_is_no_cause() { + let error = chain(&["error decoding response body"]); + + assert_eq!( + describe_cause(&error), + "error decoding response body", + "should fall back to the error's own text" + ); + } +} diff --git a/crates/trusted-server-cli/src/commands/cache/mod.rs b/crates/trusted-server-cli/src/commands/cache/mod.rs index 889852e10e..cc4e799793 100644 --- a/crates/trusted-server-cli/src/commands/cache/mod.rs +++ b/crates/trusted-server-cli/src/commands/cache/mod.rs @@ -44,12 +44,6 @@ pub struct PurgeArgs { pub username: String, } -/// Environment variable carrying the admin password. -/// -/// Read from the environment and never accepted as a flag: an argument is visible to every -/// other process on the host through `ps`, and lands in shell history. -pub const ADMIN_PASSWORD_ENVIRONMENT_VARIABLE: &str = "TRUSTED_SERVER_ADMIN_PASSWORD"; - /// Run a `ts cache` subcommand. /// /// # Errors diff --git a/crates/trusted-server-cli/src/commands/cache/purge.rs b/crates/trusted-server-cli/src/commands/cache/purge.rs index dcfa38880b..c1dafae470 100644 --- a/crates/trusted-server-cli/src/commands/cache/purge.rs +++ b/crates/trusted-server-cli/src/commands/cache/purge.rs @@ -1,14 +1,12 @@ //! Calling a deployed service's cache-purge endpoint. -use std::time::Duration; - use serde::Deserialize; -use crate::commands::cache::{ADMIN_PASSWORD_ENVIRONMENT_VARIABLE, PurgeArgs}; +use crate::admin_client::{AdminResponse, UNAUTHORIZED_HINT, post_admin_json}; +use crate::commands::cache::PurgeArgs; use crate::error::{CliResult, cli_error}; const PURGE_PATH: &str = "/_ts/admin/cache/purge"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// The purge request body, as the endpoint's parser expects it. /// @@ -30,26 +28,6 @@ fn request_body(args: &PurgeArgs) -> CliResult { } } -/// Join the service base URL and the purge path without doubling or dropping a slash. -fn purge_endpoint(service: &str) -> CliResult { - let mut url = - reqwest::Url::parse(service).map_err(|_| "--service must be an absolute HTTPS URL")?; - crate::url_guard::require_credential_safe_transport(&url, "--service")?; - if url.host().is_none() - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - || !url.path().trim_matches('/').is_empty() - { - return cli_error( - "--service must be a base HTTPS URL without credentials, a path, query, or fragment", - ); - } - url.set_path(PURGE_PATH); - Ok(url.to_string()) -} - /// The acknowledgment returned by the purge endpoint. #[derive(Deserialize)] struct PurgeAcknowledgment { @@ -67,45 +45,10 @@ struct PurgeAcknowledgment { /// status, redirects, or does not acknowledge the requested purge scope. pub fn run_purge(args: &PurgeArgs, out: &mut impl std::io::Write) -> CliResult<()> { let body = request_body(args)?; - let endpoint = purge_endpoint(&args.service)?; - - let password = std::env::var(ADMIN_PASSWORD_ENVIRONMENT_VARIABLE).map_err(|_| { - format!( - "set {ADMIN_PASSWORD_ENVIRONMENT_VARIABLE} to the service's admin password \ - (it is read from the environment, never a flag, so it does not reach `ps` \ - output or shell history)" - ) - })?; - - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| format!("failed to start the HTTP runtime: {error}"))?; - - // Without this the first HTTPS request panics with "No provider set". - crate::tls::install_crypto_provider(); - - let (status, response_body) = runtime.block_on(async { - let client = reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| format!("failed to build the HTTP client: {error}"))?; - let response = client - .post(&endpoint) - .basic_auth(&args.username, Some(&password)) - .header("content-type", "application/json") - .body(body) - .send() - .await - .map_err(|error| format!("could not reach {endpoint}: {error}"))?; - let status = response.status(); - let text = response - .text() - .await - .map_err(|error| format!("could not read the purge acknowledgment: {error}"))?; - Ok::<_, String>((status, text)) - })?; + let AdminResponse { + status, + body: response_body, + } = post_admin_json(&args.service, PURGE_PATH, &args.username, body)?; if status.is_success() { let acknowledgment: PurgeAcknowledgment = serde_json::from_str(&response_body) @@ -132,7 +75,7 @@ pub fn run_purge(args: &PurgeArgs, out: &mut impl std::io::Write) -> CliResult<( // Named individually, because each one sends the operator somewhere different and a // purge is usually run mid-incident. let hint = match status.as_u16() { - 401 => " — check the admin username and $TRUSTED_SERVER_ADMIN_PASSWORD", + 401 => UNAUTHORIZED_HINT, 404 => " — this service may predate the purge endpoint", 501 => " — the template cache is Fastly-backed; this adapter cannot purge", _ => "", @@ -210,59 +153,4 @@ mod tests { // Defaulting to --all would make a bare `ts cache purge` flush production. assert!(request_body(&args(false, None)).is_err()); } - - #[test] - fn the_endpoint_url_survives_a_trailing_slash() { - for service in [ - "https://edge.example.com", - "https://edge.example.com/", - "https://edge.example.com///", - ] { - assert_eq!( - purge_endpoint(service).expect("should accept a secure base URL"), - "https://edge.example.com/_ts/admin/cache/purge", - "{service} must resolve to one well-formed endpoint" - ); - } - } - - #[test] - fn only_loopback_services_may_use_plaintext_http() { - for service in [ - "http://127.0.0.1:8080", - "http://[::1]:8080", - "http://localhost:8080", - ] { - assert!( - purge_endpoint(service).is_ok(), - "should permit loopback development: {service}" - ); - } - for service in [ - "http://192.0.2.1", - "http://[2001:db8::1]", - "http://localhost.example.com", - ] { - assert!( - purge_endpoint(service).is_err(), - "should refuse remote plaintext transport: {service}" - ); - } - } - - #[test] - fn service_urls_cannot_redirect_the_purge_path_or_embed_credentials() { - for service in [ - "not a URL", - "https://example.com/path", - "https://example.com?query=1", - "https://example.com#fragment", - "https://user:example-password@example.com", - ] { - assert!( - purge_endpoint(service).is_err(), - "should require an unambiguous service base URL" - ); - } - } } diff --git a/crates/trusted-server-cli/src/commands/keys/mod.rs b/crates/trusted-server-cli/src/commands/keys/mod.rs new file mode 100644 index 0000000000..a856d7f6ee --- /dev/null +++ b/crates/trusted-server-cli/src/commands/keys/mod.rs @@ -0,0 +1,385 @@ +//! `ts keys` — request-signing key rotation through a deployed service's admin routes. +//! +//! These wrap `POST /_ts/admin/keys/rotate` and `POST /_ts/admin/keys/deactivate`. The +//! transport rules (service URL checks, admin username, password from +//! `TRUSTED_SERVER_ADMIN_PASSWORD`, 401 guidance) come from the admin client shared with +//! `ts cache purge`. + +use std::io::{BufRead, IsTerminal as _, Write}; + +use clap::Subcommand; +use serde::Deserialize; + +use crate::ad_templates::output::escape_terminal_text; +use crate::admin_client::{AdminResponse, UNAUTHORIZED_HINT, admin_password, post_admin_json}; +use crate::error::{CliResult, cli_error}; + +const ROTATE_PATH: &str = "/_ts/admin/keys/rotate"; +const DEACTIVATE_PATH: &str = "/_ts/admin/keys/deactivate"; + +/// Subcommands under `ts keys`. +#[derive(Debug, Subcommand)] +pub enum KeysCommand { + /// Generate a new request-signing key and make it the current key. + Rotate(RotateArgs), + /// Deactivate a request-signing key, optionally deleting it from storage. + Deactivate(DeactivateArgs), +} + +/// Arguments shared by every `ts keys` subcommand. +#[derive(Debug, clap::Args)] +pub struct ServiceArgs { + /// HTTPS base URL of the service; HTTP is permitted only for loopback development. + #[arg(long)] + pub service: String, + + /// Admin username for the service's `^/_ts/admin` basic auth. + #[arg(long, default_value = "admin")] + pub username: String, +} + +/// Arguments for `ts keys rotate`. +#[derive(Debug, clap::Args)] +pub struct RotateArgs { + /// Target service and admin credentials. + #[command(flatten)] + pub service: ServiceArgs, + + /// Explicit key identifier for the new key; the service generates one when omitted. + #[arg(long)] + pub kid: Option, +} + +/// Arguments for `ts keys deactivate`. +#[derive(Debug, clap::Args)] +pub struct DeactivateArgs { + /// Target service and admin credentials. + #[command(flatten)] + pub service: ServiceArgs, + + /// Key identifier to deactivate. + #[arg(long)] + pub kid: String, + + /// Delete the key from storage permanently instead of only deactivating it. + #[arg(long)] + pub delete: bool, + + /// Skip the `--delete` confirmation prompt; required for a non-interactive delete. + #[arg(long, requires = "delete")] + pub yes: bool, +} + +/// Source of operator confirmation for destructive operations. +pub trait Confirm { + /// Whether a person is present to answer a prompt. + fn is_interactive(&self) -> bool; + + /// Ask `question` and return whether the operator agreed. + /// + /// # Errors + /// + /// Returns an error when the prompt cannot be written or the answer read. + fn confirm(&mut self, question: &str) -> CliResult; +} + +/// [`Confirm`] backed by the process's stdin and stderr. +pub struct TerminalConfirm; + +impl Confirm for TerminalConfirm { + fn is_interactive(&self) -> bool { + // The prompt is written to stderr and answered on stdin, so both must reach a + // person, as `ts audit ad-templates generate` already requires. + std::io::stdin().is_terminal() && std::io::stderr().is_terminal() + } + + fn confirm(&mut self, question: &str) -> CliResult { + let mut stderr = std::io::stderr(); + write!(stderr, "{question} [y/N] ") + .and_then(|()| stderr.flush()) + .map_err(|error| format!("failed to write the confirmation prompt: {error}"))?; + let mut answer = String::new(); + std::io::stdin() + .lock() + .read_line(&mut answer) + .map_err(|error| format!("failed to read the confirmation answer: {error}"))?; + Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "YES" | "Yes")) + } +} + +/// Run a `ts keys` subcommand. +/// +/// # Errors +/// +/// Returns an error when the admin password is absent from the environment, the service +/// URL is unsafe, a `--delete` is not confirmed, the service cannot be reached, or the +/// service refuses the operation. +pub fn run( + command: KeysCommand, + confirm: &mut impl Confirm, + out: &mut impl Write, +) -> CliResult<()> { + match command { + KeysCommand::Rotate(args) => run_rotate(&args, out), + KeysCommand::Deactivate(args) => run_deactivate(&args, confirm, out), + } +} + +/// The rotate request body, as the endpoint's parser expects it. +fn rotate_body(args: &RotateArgs) -> String { + match args.kid.as_deref() { + Some(kid) => serde_json::json!({ "kid": kid }).to_string(), + None => "{}".to_owned(), + } +} + +/// The deactivate request body, as the endpoint's parser expects it. +fn deactivate_body(args: &DeactivateArgs) -> String { + serde_json::json!({ "kid": args.kid, "delete": args.delete }).to_string() +} + +#[derive(Deserialize)] +struct RotateResponse { + new_kid: String, + previous_kid: Option, + active_kids: Vec, +} + +#[derive(Deserialize)] +struct DeactivateResponse { + deactivated_kid: String, + deleted: bool, + remaining_active_kids: Vec, +} + +/// The `success` flag every key route returns, read before the full body. +#[derive(Deserialize)] +struct SuccessFlag { + success: bool, +} + +/// The failure fields every key route returns. +#[derive(Deserialize)] +struct FailureResponse { + error: Option, + message: Option, +} + +fn run_rotate(args: &RotateArgs, out: &mut impl Write) -> CliResult<()> { + let response = post_admin_json( + &args.service.service, + ROTATE_PATH, + &args.service.username, + rotate_body(args), + )?; + let parsed: RotateResponse = parse_success(&response, "key rotation")?; + writeln!( + out, + "new kid: {}\nprevious kid: {}\nactive kids: {}", + escape_terminal_text(&parsed.new_kid), + escape_terminal_text(parsed.previous_kid.as_deref().unwrap_or("(none)")), + escape_terminal_text(&join_kids(&parsed.active_kids)), + ) + .map_err(|error| format!("failed to write the rotation result: {error}")) +} + +fn run_deactivate( + args: &DeactivateArgs, + confirm: &mut impl Confirm, + out: &mut impl Write, +) -> CliResult<()> { + if args.delete && !args.yes { + // Settle every local refusal before asking, so an operator never confirms a + // permanent deletion that the CLI then rejects without sending it. + crate::url_guard::service_endpoint(&args.service.service, DEACTIVATE_PATH)?; + admin_password()?; + if !confirm.is_interactive() { + return cli_error( + "--delete removes the key from storage permanently; stdin or stderr is not a \ + terminal, so pass --yes to confirm the delete", + ); + } + let question = format!( + "Permanently delete signing key `{}`? This cannot be undone.", + args.kid + ); + if !confirm.confirm(&question)? { + return cli_error("key deletion cancelled; nothing was sent"); + } + } + + let response = post_admin_json( + &args.service.service, + DEACTIVATE_PATH, + &args.service.username, + deactivate_body(args), + )?; + let operation = if args.delete { + "key deletion" + } else { + "key deactivation" + }; + let parsed: DeactivateResponse = parse_success(&response, operation)?; + // A successful deactivation always leaves at least one active key, because the service + // refuses to remove the last one. An empty list means the service could not read the + // list back after the change, not that no keys remain. + let remaining = if parsed.remaining_active_kids.is_empty() { + "(unknown: the service could not list them)".to_owned() + } else { + parsed.remaining_active_kids.join(", ") + }; + writeln!( + out, + "deactivated kid: {}\ndeleted: {}\nremaining active kids: {}", + escape_terminal_text(&parsed.deactivated_kid), + if parsed.deleted { "yes" } else { "no" }, + escape_terminal_text(&remaining), + ) + .map_err(|error| format!("failed to write the deactivation result: {error}")) +} + +fn join_kids(kids: &[String]) -> String { + if kids.is_empty() { + "(none)".to_owned() + } else { + kids.join(", ") + } +} + +/// Parse a successful key-route response, or turn a failure into a readable error. +/// +/// A failure is a non-2xx status, or a 2xx whose body says `success: false` or does not +/// parse. The server's `error` field is surfaced so its refusals (an invalid kid, +/// deactivating the current kid, deactivating the last active key) reach the operator. +fn parse_success(response: &AdminResponse, operation: &str) -> CliResult +where + T: for<'de> Deserialize<'de>, +{ + let status = response.status; + // A `success: false` body may omit the success fields, so check the flag first. + let reported_success = + serde_json::from_str::(&response.body).is_ok_and(|flag| flag.success); + if status.is_success() && reported_success { + return serde_json::from_str::(&response.body).or_else(|error| { + cli_error(format!("{operation} returned an invalid response: {error}")) + }); + } + + let hint = match status.as_u16() { + 401 => UNAUTHORIZED_HINT, + 404 => " — this service may not expose the key admin routes", + _ => "", + }; + let detail = serde_json::from_str::(&response.body) + .ok() + .and_then(|failure| failure.error.or(failure.message)) + .unwrap_or_else(|| response.body.trim().to_owned()); + // The service, or anything in front of it, chooses these bytes, and JSON can carry a + // control character as `\u001b`. Escape after decoding so control sequences print as + // text, line by line so a multi-line body (such as an adapter's 501) keeps its breaks. + let detail = detail + .lines() + .map(|line| escape_terminal_text(line).into_owned()) + .collect::>() + .join("\n"); + if detail.is_empty() { + cli_error(format!("{operation} failed: {status}{hint}")) + } else { + cli_error(format!("{operation} failed: {status}{hint}\n{detail}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn service() -> ServiceArgs { + ServiceArgs { + service: "https://edge.example.com".to_owned(), + username: "admin".to_owned(), + } + } + + #[test] + fn rotate_without_a_kid_sends_an_empty_object() { + let body = rotate_body(&RotateArgs { + service: service(), + kid: None, + }); + assert_eq!(body, "{}", "should let the service generate the kid"); + } + + #[test] + fn rotate_with_a_kid_sends_it_escaped() { + let body = rotate_body(&RotateArgs { + service: service(), + kid: Some(r#"kid-"a""#.to_owned()), + }); + let parsed: serde_json::Value = serde_json::from_str(&body).expect("should stay valid"); + assert_eq!( + parsed, + serde_json::json!({"kid": r#"kid-"a""#}), + "should send the kid verbatim" + ); + } + + #[test] + fn deactivate_sends_kid_and_delete_flag() { + for delete in [false, true] { + let body = deactivate_body(&DeactivateArgs { + service: service(), + kid: "ts-2024-01-01".to_owned(), + delete, + yes: true, + }); + let parsed: serde_json::Value = + serde_json::from_str(&body).expect("should be valid JSON"); + assert_eq!( + parsed, + serde_json::json!({"kid": "ts-2024-01-01", "delete": delete}), + "should send the delete flag explicitly" + ); + } + } + + #[test] + fn failures_surface_the_servers_error_field() { + let response = AdminResponse { + status: reqwest::StatusCode::BAD_REQUEST, + body: serde_json::json!({ + "success": false, + "message": "Key deactivation failed", + "error": "cannot deactivate the current signing key" + }) + .to_string(), + }; + let error = parse_success::(&response, "key deactivation") + .err() + .expect("should fail"); + assert!( + error.contains("cannot deactivate the current signing key"), + "should show the server's error: {error}" + ); + } + + /// Parses [`KeysCommand`] the way `ts keys` does. + #[derive(Debug, clap::Parser)] + struct KeysCli { + #[command(subcommand)] + command: KeysCommand, + } + + #[test] + fn yes_without_delete_is_rejected() { + ::try_parse_from([ + "keys", + "deactivate", + "--service", + "https://edge.example.com", + "--kid", + "ts-2024-01-01", + "--yes", + ]) + .expect_err("should reject --yes without --delete; it only pre-approves a deletion"); + } +} diff --git a/crates/trusted-server-cli/src/commands/mod.rs b/crates/trusted-server-cli/src/commands/mod.rs index 0f4354f007..d48acb957c 100644 --- a/crates/trusted-server-cli/src/commands/mod.rs +++ b/crates/trusted-server-cli/src/commands/mod.rs @@ -4,8 +4,10 @@ pub(crate) mod pbs; // `dev` is `pub` so the macOS-gated `tests/proxy_e2e.rs` suite can reach // `commands::dev::proxy`, `origin` is `pub` so `tests/origin_probe.rs` can drive the // shareability probe against a local fixture, and `cache` is `pub` so -// `tests/cache_purge.rs` can drive a purge against one; the other command modules are +// `tests/cache_purge.rs` can drive a purge against one, and `keys` is `pub` so +// `tests/keys_rotation.rs` can do the same for key rotation; the other command modules are // crate-internal. pub mod cache; pub mod dev; +pub mod keys; pub mod origin; diff --git a/crates/trusted-server-cli/src/lib.rs b/crates/trusted-server-cli/src/lib.rs index 7f6b9e6e73..cea692210e 100644 --- a/crates/trusted-server-cli/src/lib.rs +++ b/crates/trusted-server-cli/src/lib.rs @@ -1,6 +1,8 @@ #[cfg(not(target_arch = "wasm32"))] mod ad_templates; #[cfg(not(target_arch = "wasm32"))] +mod admin_client; +#[cfg(not(target_arch = "wasm32"))] mod app_config; #[cfg(not(target_arch = "wasm32"))] mod error; diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index fa91e0a44d..2a9ea6d0a5 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -40,6 +40,9 @@ enum Command { Deploy(DeployArgs), /// Probe a deployed version until it reports healthy. Healthcheck(HealthcheckArgs), + /// Request-signing key rotation through a deployed service's admin routes. + #[command(subcommand)] + Keys(crate::commands::keys::KeysCommand), /// Trusted Server Prebid commands. Prebid(PrebidArgs), /// Provision platform resources through a target adapter. @@ -175,6 +178,16 @@ fn dispatch(args: Args) -> Result { let mut out = stdout.lock(); crate::commands::cache::run(command, &mut out).map(|()| RunOutcome::Success) } + Command::Keys(command) => { + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + crate::commands::keys::run( + command, + &mut crate::commands::keys::TerminalConfirm, + &mut out, + ) + .map(|()| RunOutcome::Success) + } Command::Origin(command) => { let stdout = std::io::stdout(); let mut out = stdout.lock(); diff --git a/crates/trusted-server-cli/src/url_guard.rs b/crates/trusted-server-cli/src/url_guard.rs index 240f10d543..18a5c44972 100644 --- a/crates/trusted-server-cli/src/url_guard.rs +++ b/crates/trusted-server-cli/src/url_guard.rs @@ -41,6 +41,32 @@ pub(crate) fn require_credential_safe_transport(url: &reqwest::Url, flag: &str) Ok(()) } +/// Join a service base URL and an endpoint path without doubling or dropping a slash. +/// +/// The service must be a bare base URL: credentials, a path, a query, or a fragment would +/// each let the typed value change which endpoint is reached. +/// +/// # Errors +/// +/// Returns an error when `service` is not an absolute URL, fails +/// [`require_credential_safe_transport`], or carries more than a scheme, host, and port. +pub(crate) fn service_endpoint(service: &str, path: &str) -> CliResult { + let mut url = + reqwest::Url::parse(service).map_err(|_| "--service must be an absolute HTTPS URL")?; + require_credential_safe_transport(&url, "--service")?; + if url.host().is_none() + || url.query().is_some() + || url.fragment().is_some() + || !url.path().trim_matches('/').is_empty() + { + return cli_error( + "--service must be a base HTTPS URL without credentials, a path, query, or fragment", + ); + } + url.set_path(path); + Ok(url.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -82,4 +108,60 @@ mod tests { ); assert!(check("https://user@example.com/").is_err()); } + + #[test] + fn the_endpoint_url_survives_a_trailing_slash() { + for service in [ + "https://edge.example.com", + "https://edge.example.com/", + "https://edge.example.com///", + ] { + assert_eq!( + service_endpoint(service, "/_ts/admin/cache/purge") + .expect("should accept a secure base URL"), + "https://edge.example.com/_ts/admin/cache/purge", + "{service} must resolve to one well-formed endpoint" + ); + } + } + + #[test] + fn only_loopback_services_may_use_plaintext_http() { + for service in [ + "http://127.0.0.1:8080", + "http://[::1]:8080", + "http://localhost:8080", + ] { + assert!( + service_endpoint(service, "/_ts/admin/keys/rotate").is_ok(), + "should permit loopback development: {service}" + ); + } + for service in [ + "http://192.0.2.1", // allow-domain: 192.0.2.1 + "http://[2001:db8::1]", // allow-domain: 2001:db8::1 + "http://localhost.example.com", + ] { + assert!( + service_endpoint(service, "/_ts/admin/keys/rotate").is_err(), + "should refuse remote plaintext transport: {service}" + ); + } + } + + #[test] + fn service_urls_cannot_redirect_the_endpoint_path_or_embed_credentials() { + for service in [ + "not a URL", + "https://example.com/path", + "https://example.com?query=1", + "https://example.com#fragment", + "https://user:example-password@example.com", + ] { + assert!( + service_endpoint(service, "/_ts/admin/keys/rotate").is_err(), + "should require an unambiguous service base URL: {service}" + ); + } + } } diff --git a/crates/trusted-server-cli/tests/keys_rotation.rs b/crates/trusted-server-cli/tests/keys_rotation.rs new file mode 100644 index 0000000000..6bc25feae4 --- /dev/null +++ b/crates/trusted-server-cli/tests/keys_rotation.rs @@ -0,0 +1,629 @@ +//! Tests for `ts keys rotate` and `ts keys deactivate` against a local fixture standing in +//! for a deployed service. +//! +//! Run with `./scripts/test-cli.sh`. + +mod support_origin; + +use support_origin::{FixtureResponse, FixtureServer}; +use trusted_server_cli::commands::keys::{ + Confirm, DeactivateArgs, KeysCommand, RotateArgs, ServiceArgs, run, +}; + +/// Serializes the environment across this binary's tests; see `tests/cache_purge.rs`. +static ENVIRONMENT: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// A scripted [`Confirm`] that records whether it was asked. +struct ScriptedConfirm { + interactive: bool, + answer: bool, + asked: bool, +} + +impl ScriptedConfirm { + fn new(interactive: bool, answer: bool) -> Self { + Self { + interactive, + answer, + asked: false, + } + } +} + +impl Confirm for ScriptedConfirm { + fn is_interactive(&self) -> bool { + self.interactive + } + + fn confirm(&mut self, _question: &str) -> Result { + self.asked = true; + Ok(self.answer) + } +} + +fn with_password(password: Option<&str>, body: impl FnOnce() -> T) -> T { + let _guard = ENVIRONMENT + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // SAFETY: the guard above makes this the only thread touching the environment. + unsafe { + match password { + Some(value) => std::env::set_var("TRUSTED_SERVER_ADMIN_PASSWORD", value), + None => std::env::remove_var("TRUSTED_SERVER_ADMIN_PASSWORD"), + } + } + let result = body(); + // SAFETY: as above; still holding the guard. + unsafe { + std::env::remove_var("TRUSTED_SERVER_ADMIN_PASSWORD"); + } + result +} + +fn service(server: &FixtureServer) -> ServiceArgs { + ServiceArgs { + service: server.url(""), + username: "admin".to_owned(), + } +} + +fn rotate(server: &FixtureServer, kid: Option<&str>) -> KeysCommand { + KeysCommand::Rotate(RotateArgs { + service: service(server), + kid: kid.map(str::to_owned), + }) +} + +fn deactivate(server: &FixtureServer, delete: bool, yes: bool) -> KeysCommand { + KeysCommand::Deactivate(DeactivateArgs { + service: service(server), + kid: "ts-2024-01-01".to_owned(), + delete, + yes, + }) +} + +fn run_with(command: KeysCommand, confirm: &mut ScriptedConfirm) -> (Result<(), String>, String) { + let mut out = Vec::new(); + let outcome = with_password(Some("admin-pass"), || run(command, confirm, &mut out)); + ( + outcome, + String::from_utf8(out).expect("should write UTF-8 output"), + ) +} + +fn rotate_success() -> String { + serde_json::json!({ + "success": true, + "message": "Key rotated successfully", + "new_kid": "ts-2024-02-01", + "previous_kid": "ts-2024-01-01", + "active_kids": ["ts-2024-01-01", "ts-2024-02-01"], + "jwk": {} + }) + .to_string() +} + +fn deactivate_success(deleted: bool) -> String { + serde_json::json!({ + "success": true, + "message": "Key deactivated successfully", + "deactivated_kid": "ts-2024-01-01", + "deleted": deleted, + "remaining_active_kids": ["ts-2024-02-01"] + }) + .to_string() +} + +#[test] +fn rotate_posts_json_with_credentials_and_prints_the_kids() { + let server = FixtureServer::start(|request| { + assert_eq!(request.method, "POST", "should POST"); + assert_eq!(request.path, "/_ts/admin/keys/rotate", "should hit rotate"); + assert_eq!( + request.header("authorization"), + Some("Basic YWRtaW46YWRtaW4tcGFzcw=="), + "should send basic auth for admin and the password from the environment" + ); + assert_eq!( + request.header("content-type"), + Some("application/json"), + "should send JSON" + ); + let body: serde_json::Value = + serde_json::from_slice(&request.body).expect("should parse body"); + assert_eq!(body, serde_json::json!({"kid": "ts-2024-02-01"})); + FixtureResponse::html(rotate_success()) + }); + + let (outcome, rendered) = run_with( + rotate(&server, Some("ts-2024-02-01")), + &mut ScriptedConfirm::new(false, false), + ); + + outcome.expect("should exit zero on success"); + assert!( + rendered.contains("new kid: ts-2024-02-01"), + "got: {rendered}" + ); + assert!( + rendered.contains("previous kid: ts-2024-01-01"), + "got: {rendered}" + ); + assert!( + rendered.contains("active kids: ts-2024-01-01, ts-2024-02-01"), + "got: {rendered}" + ); +} + +#[test] +fn rotate_without_a_kid_sends_an_empty_object() { + let server = FixtureServer::start(|request| { + let body: serde_json::Value = + serde_json::from_slice(&request.body).expect("should parse body"); + assert_eq!(body, serde_json::json!({}), "should omit the kid"); + FixtureResponse::html(rotate_success()) + }); + + let (outcome, _) = run_with( + rotate(&server, None), + &mut ScriptedConfirm::new(false, false), + ); + + outcome.expect("should succeed"); +} + +#[test] +fn deactivate_without_delete_sends_no_prompt_and_prints_the_result() { + let server = FixtureServer::start(|request| { + assert_eq!(request.path, "/_ts/admin/keys/deactivate"); + let body: serde_json::Value = + serde_json::from_slice(&request.body).expect("should parse body"); + assert_eq!( + body, + serde_json::json!({"kid": "ts-2024-01-01", "delete": false}) + ); + FixtureResponse::html(deactivate_success(false)) + }); + let mut confirm = ScriptedConfirm::new(false, false); + + let (outcome, rendered) = run_with(deactivate(&server, false, false), &mut confirm); + + outcome.expect("should succeed without confirmation"); + assert!(!confirm.asked, "should not prompt for a plain deactivation"); + assert!( + rendered.contains("deactivated kid: ts-2024-01-01"), + "got: {rendered}" + ); + assert!(rendered.contains("deleted: no"), "got: {rendered}"); + assert!( + rendered.contains("remaining active kids: ts-2024-02-01"), + "got: {rendered}" + ); +} + +#[test] +fn a_non_interactive_delete_without_yes_is_refused_before_sending() { + let server = FixtureServer::start(|_request| FixtureResponse::html(deactivate_success(true))); + + let (outcome, _) = run_with( + deactivate(&server, true, false), + &mut ScriptedConfirm::new(false, true), + ); + + let error = outcome.expect_err("should refuse an unconfirmed delete"); + assert!(error.contains("--yes"), "should name the opt-in: {error}"); + assert_eq!(server.request_count(), 0, "should send nothing"); +} + +#[test] +fn a_declined_interactive_delete_sends_nothing() { + let server = FixtureServer::start(|_request| FixtureResponse::html(deactivate_success(true))); + let mut confirm = ScriptedConfirm::new(true, false); + + let (outcome, _) = run_with(deactivate(&server, true, false), &mut confirm); + + assert!(outcome.is_err(), "should exit non-zero when declined"); + assert!(confirm.asked, "should prompt before deleting"); + assert_eq!(server.request_count(), 0, "should send nothing"); +} + +#[test] +fn a_confirmed_or_pre_approved_delete_sends_delete_true() { + for (interactive, yes) in [(true, false), (false, true)] { + let server = FixtureServer::start(|request| { + let body: serde_json::Value = + serde_json::from_slice(&request.body).expect("should parse body"); + assert_eq!( + body, + serde_json::json!({"kid": "ts-2024-01-01", "delete": true}) + ); + FixtureResponse::html(deactivate_success(true)) + }); + let mut confirm = ScriptedConfirm::new(interactive, true); + + let (outcome, rendered) = run_with(deactivate(&server, true, yes), &mut confirm); + + outcome.expect("should delete once confirmed"); + assert_eq!(confirm.asked, !yes, "should prompt only without --yes"); + assert!(rendered.contains("deleted: yes"), "got: {rendered}"); + assert_eq!(server.request_count(), 1); + } +} + +#[test] +fn minimal_2xx_failure_body_surfaces_the_error_field() { + let server = FixtureServer::start(|_request| { + FixtureResponse::html( + serde_json::json!({ + "success": false, + "error": "cannot deactivate the current signing key" + }) + .to_string(), + ) + }); + + let (outcome, rendered) = run_with( + deactivate(&server, false, false), + &mut ScriptedConfirm::new(false, false), + ); + + let error = outcome.expect_err("should exit non-zero on a refusal"); + assert!( + error.contains("cannot deactivate the current signing key"), + "should show the server's error, not a missing-field parse error: {error}" + ); + assert!(rendered.is_empty(), "should print no success output"); +} + +#[test] +fn server_refusals_exit_non_zero_with_the_error_field() { + for (status, error_text) in [ + (400u16, "kid must start with a lowercase ASCII letter"), + (500, "cannot deactivate the last active key"), + (200, "cannot deactivate the current signing key"), + ] { + let server = FixtureServer::start(move |_request| { + FixtureResponse::html( + serde_json::json!({ + "success": false, + "message": "Key deactivation failed", + "deactivated_kid": "ts-2024-01-01", + "deleted": false, + "remaining_active_kids": [], + "error": error_text + }) + .to_string(), + ) + .with_status(status) + }); + + let (outcome, rendered) = run_with( + deactivate(&server, false, false), + &mut ScriptedConfirm::new(false, false), + ); + + let error = outcome.expect_err("should exit non-zero on a refusal"); + assert!( + error.contains(error_text), + "should show the server's error for {status}: {error}" + ); + assert!(rendered.is_empty(), "should print no success output"); + } +} + +#[test] +fn unauthorized_matches_the_cache_purge_guidance() { + let server = FixtureServer::start(|_request| FixtureResponse::html("").with_status(401)); + + let (outcome, _) = run_with( + rotate(&server, None), + &mut ScriptedConfirm::new(false, false), + ); + + let error = outcome.expect_err("should fail on 401"); + assert!( + error.contains("TRUSTED_SERVER_ADMIN_PASSWORD"), + "should point at the credential: {error}" + ); +} + +#[test] +fn a_missing_password_fails_before_the_service_is_touched() { + let server = FixtureServer::start(|_request| FixtureResponse::html(rotate_success())); + let mut out = Vec::new(); + + let outcome = with_password(None, || { + run( + rotate(&server, None), + &mut ScriptedConfirm::new(false, false), + &mut out, + ) + }); + + assert!(outcome.is_err(), "should require the password"); + assert_eq!(server.request_count(), 0, "should send nothing"); +} + +#[test] +fn plaintext_remote_services_are_rejected() { + let mut out = Vec::new(); + let error = with_password(Some("admin-pass"), || { + run( + KeysCommand::Rotate(RotateArgs { + service: ServiceArgs { + service: "http://edge.example.com".to_owned(), + username: "admin".to_owned(), + }, + kid: None, + }), + &mut ScriptedConfirm::new(false, false), + &mut out, + ) + }) + .expect_err("should refuse insecure remote transport"); + assert!(error.contains("HTTPS"), "should explain: {error}"); +} + +#[test] +fn redirects_are_not_followed() { + let server = FixtureServer::start(|_request| { + FixtureResponse::html("") + .with_status(302) + .with_header("location", "/login") + }); + + let (outcome, _) = run_with( + rotate(&server, None), + &mut ScriptedConfirm::new(false, false), + ); + + assert!(outcome.is_err(), "should refuse a redirected rotation"); + assert_eq!(server.request_count(), 1, "should not follow the redirect"); +} + +#[test] +fn a_delete_settles_local_refusals_before_prompting() { + let server = FixtureServer::start(|_request| FixtureResponse::html(deactivate_success(true))); + let mut missing_password = ScriptedConfirm::new(true, true); + let mut out = Vec::new(); + + let outcome = with_password(None, || { + run( + deactivate(&server, true, false), + &mut missing_password, + &mut out, + ) + }); + + let error = outcome.expect_err("should require the password"); + assert!( + error.contains("TRUSTED_SERVER_ADMIN_PASSWORD"), + "should name the variable: {error}" + ); + assert!( + !missing_password.asked, + "should refuse before asking to confirm a missing password" + ); + + let mut insecure_service = ScriptedConfirm::new(true, true); + let error = with_password(Some("admin-pass"), || { + run( + KeysCommand::Deactivate(DeactivateArgs { + service: ServiceArgs { + service: "http://edge.example.com".to_owned(), + username: "admin".to_owned(), + }, + kid: "ts-2024-01-01".to_owned(), + delete: true, + yes: false, + }), + &mut insecure_service, + &mut out, + ) + }) + .expect_err("should refuse insecure remote transport"); + assert!(error.contains("HTTPS"), "should explain: {error}"); + assert!( + !insecure_service.asked, + "should refuse before asking to confirm an insecure service" + ); + assert_eq!(server.request_count(), 0, "should send nothing"); +} + +#[test] +fn an_empty_remaining_list_reads_as_unknown() { + let server = FixtureServer::start(|_request| { + FixtureResponse::html( + serde_json::json!({ + "success": true, + "message": "Key deactivated successfully", + "deactivated_kid": "ts-2024-01-01", + "deleted": false, + "remaining_active_kids": [] + }) + .to_string(), + ) + }); + + let (outcome, rendered) = run_with( + deactivate(&server, false, false), + &mut ScriptedConfirm::new(false, false), + ); + + outcome.expect("should succeed"); + assert!( + rendered.contains("remaining active kids: (unknown: the service could not list them)"), + "should not claim that no keys remain: {rendered}" + ); +} + +#[test] +fn service_text_in_a_failure_cannot_drive_the_terminal() { + let forged = "\u{1b}[2K\rkey deletion succeeded"; + for (status, body, kept) in [ + ( + 500u16, + serde_json::json!({"success": false, "error": forged}).to_string(), + "key deletion succeeded", + ), + ( + 502, + format!("upstream error{forged}\nsecond line"), + "\nsecond line", + ), + ] { + let server = FixtureServer::start(move |_request| { + FixtureResponse::html(body.clone()).with_status(status) + }); + + let (outcome, _) = run_with( + rotate(&server, None), + &mut ScriptedConfirm::new(false, false), + ); + + let error = outcome.expect_err("should exit non-zero on a failure"); + assert!( + !error.contains('\u{1b}') && !error.contains('\r'), + "should escape control characters from the service: {error:?}" + ); + assert!( + error.contains(kept), + "should still show the service's text and line breaks: {error:?}" + ); + } +} + +#[test] +fn kids_from_the_service_cannot_drive_the_terminal() { + let forged = "ts-2024-02-01\u{1b}[2K\rforged"; + let server = FixtureServer::start(move |request| { + let body = if request.path == "/_ts/admin/keys/rotate" { + serde_json::json!({ + "success": true, + "new_kid": forged, + "previous_kid": forged, + "active_kids": [forged], + }) + } else { + serde_json::json!({ + "success": true, + "deactivated_kid": forged, + "deleted": false, + "remaining_active_kids": [forged], + }) + }; + FixtureResponse::html(body.to_string()) + }); + + for command in [rotate(&server, None), deactivate(&server, false, false)] { + let (outcome, rendered) = run_with(command, &mut ScriptedConfirm::new(false, false)); + + outcome.expect("should succeed"); + assert!( + !rendered.contains('\u{1b}') && !rendered.contains('\r'), + "should escape control characters in kids from the service: {rendered:?}" + ); + } +} + +#[test] +fn a_dropped_reply_warns_that_the_service_may_have_applied_the_request() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("should bind a listener"); + let service = format!( + "http://{}", + listener.local_addr().expect("should read the address") + ); + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("should accept the request"); + let mut request = [0_u8; 4096]; + // Read the request, then close the connection without answering. + let _ = std::io::Read::read(&mut stream, &mut request); + }); + + let (outcome, _) = run_with( + KeysCommand::Rotate(RotateArgs { + service: ServiceArgs { + service, + username: "admin".to_owned(), + }, + kid: None, + }), + &mut ScriptedConfirm::new(false, false), + ); + worker.join().expect("should finish the fixture"); + + let error = outcome.expect_err("should fail when no reply comes back"); + assert!( + error.contains("may have applied it"), + "should warn that the rotation may have run: {error}" + ); +} + +#[test] +fn a_refused_connection_says_the_service_was_not_reached() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("should bind a listener"); + let service = format!( + "http://{}", + listener.local_addr().expect("should read the address") + ); + drop(listener); + + let (outcome, _) = run_with( + KeysCommand::Rotate(RotateArgs { + service: ServiceArgs { + service, + username: "admin".to_owned(), + }, + kid: None, + }), + &mut ScriptedConfirm::new(false, false), + ); + + let error = outcome.expect_err("should fail when nothing listens"); + assert!( + error.contains("could not reach"), + "should say the service was not reached: {error}" + ); + assert!( + !error.contains("error sending request"), + "should name the cause rather than reqwest's generic text: {error}" + ); +} + +#[test] +fn a_truncated_reply_warns_that_the_service_may_have_applied_the_request() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("should bind a listener"); + let service = format!( + "http://{}", + listener.local_addr().expect("should read the address") + ); + let worker = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("should accept the request"); + let mut request = [0_u8; 4096]; + let _ = std::io::Read::read(&mut stream, &mut request); + // Answer 200, promise more body than is sent, then close. + let _ = std::io::Write::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\ncontent-length: 400\r\n\r\n{\"success\":true", + ); + }); + + let (outcome, _) = run_with( + KeysCommand::Rotate(RotateArgs { + service: ServiceArgs { + service, + username: "admin".to_owned(), + }, + kid: None, + }), + &mut ScriptedConfirm::new(false, false), + ); + worker.join().expect("should finish the fixture"); + + let error = outcome.expect_err("should fail when the body is cut off"); + assert!( + error.contains("200 OK") && error.contains("may have applied the request"), + "should say the service answered and may have applied it: {error}" + ); +} diff --git a/docs/guide/api-reference.md b/docs/guide/api-reference.md index 99059adba0..9f2776416f 100644 --- a/docs/guide/api-reference.md +++ b/docs/guide/api-reference.md @@ -786,9 +786,19 @@ If omitted, auto-generates date-based ID (e.g., `ts-2025-01-15-A`). **Example:** +```bash +# TRUSTED_SERVER_ADMIN_PASSWORD holds the admin password +ts keys rotate --service https://edge.example.com [--kid custom-key-id] +``` + +`ts keys rotate` reads the password from `TRUSTED_SERVER_ADMIN_PASSWORD` only, +so it never reaches shell history or `ps`. Fallback with curl, reading +credentials from a netrc file (mode `600`) that holds one line, +`machine edge.example.com login admin password `: + ```bash curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ - -u admin:password \ + --netrc-file ~/.ts-admin.netrc \ -H "Content-Type: application/json" ``` @@ -843,9 +853,17 @@ defines no dedicated cache, CORS, or rate-limit policy. **Example:** +```bash +# TRUSTED_SERVER_ADMIN_PASSWORD holds the admin password +ts keys deactivate --service https://edge.example.com --kid ts-2025-01-14-A [--delete [--yes]] +``` + +`--delete` asks for confirmation before sending; a run whose stdin or stderr +is not a terminal must add `--yes`. Fallback with curl: + ```bash curl -X POST https://edge.example.com/_ts/admin/keys/deactivate \ - -u admin:password \ + --netrc-file ~/.ts-admin.netrc \ -H "Content-Type: application/json" \ -d '{"kid":"ts-2025-01-14-A","delete":true}' ``` diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 70227ca493..ccc52cd02e 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -23,6 +23,8 @@ commands are shown explicitly. | `ts auth logout` | Linux + macOS | Sign out (`wrangler logout` / `fastly profile delete` / `spin cloud logout`) | `ts auth logout --adapter ` | | `ts auth status` | Linux + macOS | Show the current session (`wrangler whoami` / `fastly profile list` / `spin cloud info`) | `ts auth status --adapter ` | | `ts build` | Linux + macOS | Build the project for a target adapter | `ts build --adapter [ADAPTER_ARGS]...` | +| `ts cache` | Linux + macOS | Shared template cache commands | `ts cache ` | +| `ts cache purge` | Linux + macOS | Purge cached templates and tagged origin responses through a deployed service's admin endpoint | `ts cache purge [OPTIONS] --service ` | | `ts config` | Linux + macOS | Trusted Server app-config commands | `ts config ` | | `ts config ad-templates` | Linux + macOS | Diagnose server-side ad-template configuration and path matching | `ts config ad-templates ` | | `ts config ad-templates check` | Linux + macOS | Assert that a page path or URL matches the expected slot set | `ts config ad-templates check [OPTIONS] <--expected-slot \|--expect-no-slots> ` | @@ -43,6 +45,9 @@ commands are shown explicitly. | `ts dev proxy ca regenerate` | macOS only | Regenerate the per-machine CA (invalidates prior trust) | `ts dev proxy ca regenerate` | | `ts dev proxy ca uninstall` | macOS only | Remove the CA from the OS trust store | `ts dev proxy ca uninstall` | | `ts healthcheck` | Linux + macOS | Probe a deployed version until it reports healthy | `ts healthcheck [OPTIONS] --adapter --domain --service-id --version ` | +| `ts keys` | Linux + macOS | Request-signing key rotation through a deployed service's admin routes | `ts keys ` | +| `ts keys deactivate` | Linux + macOS | Deactivate a request-signing key, optionally deleting it from storage | `ts keys deactivate [OPTIONS] --service --kid ` | +| `ts keys rotate` | Linux + macOS | Generate a new request-signing key and make it the current key | `ts keys rotate [OPTIONS] --service ` | | `ts prebid` | Linux + macOS | Trusted Server Prebid commands | `ts prebid ` | | `ts prebid client` | Linux + macOS | Generate a local external Prebid client bundle and update config metadata | `ts prebid client [OPTIONS]` | | `ts prebid server` | Linux + macOS | Configure and operate a self-hosted Prebid Server deployment | `ts prebid server [OPTIONS] ` | diff --git a/docs/guide/error-reference.md b/docs/guide/error-reference.md index c2ddcebbd9..65eabadf65 100644 --- a/docs/guide/error-reference.md +++ b/docs/guide/error-reference.md @@ -416,8 +416,8 @@ Signing key not found: ts-2025-01-A 3. Run key rotation to generate new key: ```bash -curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ - -u admin:password +# TRUSTED_SERVER_ADMIN_PASSWORD holds the admin password +ts keys rotate --service https://edge.example.com ``` --- @@ -439,8 +439,8 @@ curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ 1. Initialize keys using rotation endpoint: ```bash -curl -X POST https://edge.example.com/_ts/admin/keys/rotate \ - -u admin:password +# TRUSTED_SERVER_ADMIN_PASSWORD holds the admin password +ts keys rotate --service https://edge.example.com ``` 2. Verify Config Store has `active-kids` entry diff --git a/docs/guide/key-rotation.md b/docs/guide/key-rotation.md index 3f8b6f4d96..6a7348bd24 100644 --- a/docs/guide/key-rotation.md +++ b/docs/guide/key-rotation.md @@ -244,7 +244,34 @@ You should see a JWKS response with your public keys. ## Rotating Keys -### Using the Rotation Endpoint +### Using the `ts` CLI (Recommended) + +`ts keys rotate` calls the rotation endpoint for you. It reads the admin +password from `TRUSTED_SERVER_ADMIN_PASSWORD`, never from a flag, so the +password stays out of shell history and `ps` output. `--service` must be an +HTTPS base URL (plain HTTP is accepted only for loopback development), and +`--username` defaults to `admin`. + +```bash +# Read the password without echoing it +read -rs TRUSTED_SERVER_ADMIN_PASSWORD && export TRUSTED_SERVER_ADMIN_PASSWORD + +# Let Trusted Server generate a date-based key ID +ts keys rotate --service https://edge.example.com + +# Or supply a custom key ID +ts keys rotate --service https://edge.example.com --kid production-2024-q1 +``` + +The command prints the new kid, the previous kid and the active kids. A +refused rotation (for example an invalid kid) exits non-zero and prints the +server's `error` field. + +### Using the Rotation Endpoint with curl + +Use curl only when the `ts` CLI is unavailable. Add your admin Basic-auth +credentials to each request; prefer a `--netrc-file` over `-u user:password` +so the password does not land in shell history. **Endpoint**: `POST /_ts/admin/keys/rotate` @@ -360,7 +387,25 @@ Deactivate old keys after: 3. No more requests using the old key 4. Old signatures no longer need verification -### Deactivation Endpoint +### Using the `ts` CLI (Recommended) + +```bash +# Remove from active rotation but keep in storage +ts keys deactivate --service https://edge.example.com --kid ts-2024-01-15 + +# Delete permanently; asks for confirmation first +ts keys deactivate --service https://edge.example.com --kid ts-2024-01-15 --delete +``` + +`--delete` removes the key from storage for good, so the command asks before +sending. A run whose stdin or stderr is not a terminal (CI, cron, or stderr +redirected to a log) must pass `--yes` alongside `--delete`, or the command +refuses. The command prints the deactivated kid, +whether it was deleted and the remaining active kids. The server's refusals — +an invalid kid, deactivating the current kid, deactivating the last active key +— exit non-zero with the server's `error` message. + +### Deactivation Endpoint with curl **Endpoint**: `POST /_ts/admin/keys/deactivate`