diff --git a/Cargo.lock b/Cargo.lock index 53d58594847..1b526a5f43b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1376,6 +1376,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "0.7.14" @@ -1613,6 +1623,7 @@ dependencies = [ "lazy_static", "libc", "macaddr", + "mime_guess", "newtype_derive", "omicron-common", "omicron-rpaths", @@ -3753,6 +3764,15 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.7" diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 4333203906f..0d0ef9609f8 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -23,6 +23,7 @@ ipnetwork = "0.18" lazy_static = "1.4.0" libc = "0.2.108" macaddr = { version = "1.0.1", features = [ "serde_std" ]} +mime_guess = "2.0.3" newtype_derive = "0.1.6" oso = "0.23" oximeter-client = { path = "../oximeter-client" } diff --git a/nexus/examples/config-file.toml b/nexus/examples/config-file.toml index 7de19abbac8..9ca2ae19bb3 100644 --- a/nexus/examples/config-file.toml +++ b/nexus/examples/config-file.toml @@ -5,6 +5,13 @@ # Identifier for this instance of Nexus id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" +[console] +# Directory of static assets for the console. Relative to nexus/. +assets_directory = "tests/fixtures" # TODO: figure out value +cache_control_max_age_minutes = 10 +session_idle_timeout_minutes = 60 +session_absolute_timeout_minutes = 480 + # List of authentication schemes to support. # # This is not fleshed out yet and the only reason to change it now is for @@ -12,8 +19,6 @@ id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" # yet. [authn] schemes_external = [] -session_idle_timeout_minutes = 60 -session_absolute_timeout_minutes = 480 [database] # URL for connecting to the database diff --git a/nexus/examples/config.toml b/nexus/examples/config.toml index a01cc24750f..255b8ad7a82 100644 --- a/nexus/examples/config.toml +++ b/nexus/examples/config.toml @@ -5,6 +5,13 @@ # Identifier for this instance of Nexus id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" +[console] +# Directory of static assets for the console. Relative to nexus/. +assets_directory = "tests/fixtures" # TODO: figure out value +cache_control_max_age_minutes = 10 +session_idle_timeout_minutes = 60 +session_absolute_timeout_minutes = 480 + # List of authentication schemes to support. # # This is not fleshed out yet and the only reason to change it now is for @@ -12,9 +19,7 @@ id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" # yet. [authn] # TODO(https://github.com/oxidecomputer/omicron/issues/372): Remove "spoof". -schemes_external = ["spoof"] -session_idle_timeout_minutes = 60 -session_absolute_timeout_minutes = 480 +schemes_external = ["spoof", "session_cookie"] [database] # URL for connecting to the database diff --git a/nexus/src/authn/external/cookies.rs b/nexus/src/authn/external/cookies.rs index e6dd02b1fef..c2edd24aaaf 100644 --- a/nexus/src/authn/external/cookies.rs +++ b/nexus/src/authn/external/cookies.rs @@ -1,5 +1,10 @@ use anyhow::Context; +use async_trait::async_trait; use cookie::{Cookie, CookieJar, ParseError}; +use dropshot::{ + Extractor, ExtractorMetadata, HttpError, RequestContext, ServerContext, +}; +use std::sync::Arc; pub fn parse_cookies( headers: &http::HeaderMap, @@ -19,6 +24,26 @@ pub fn parse_cookies( } Ok(cookies) } +pub struct Cookies(pub CookieJar); + +NewtypeFrom! { () pub struct Cookies(pub CookieJar); } +NewtypeDeref! { () pub struct Cookies(pub CookieJar); } + +#[async_trait] +impl Extractor for Cookies { + async fn from_request( + rqctx: Arc>, + ) -> Result { + let request = &rqctx.request.lock().await; + let cookies = parse_cookies(request.headers()) + .unwrap_or_else(|_| CookieJar::new()); + Ok(cookies.into()) + } + + fn metadata() -> ExtractorMetadata { + ExtractorMetadata { paginated: false, parameters: vec![] } + } +} #[cfg(test)] mod test { diff --git a/nexus/src/authn/external/mod.rs b/nexus/src/authn/external/mod.rs index cc0d66d2f1a..c8f09c1831f 100644 --- a/nexus/src/authn/external/mod.rs +++ b/nexus/src/authn/external/mod.rs @@ -4,7 +4,7 @@ use crate::authn; use async_trait::async_trait; use authn::Reason; -mod cookies; +pub mod cookies; pub mod session_cookie; pub mod spoof; diff --git a/nexus/src/authn/external/session_cookie.rs b/nexus/src/authn/external/session_cookie.rs index 8a361734f68..fb2d235c883 100644 --- a/nexus/src/authn/external/session_cookie.rs +++ b/nexus/src/authn/external/session_cookie.rs @@ -49,6 +49,21 @@ pub const SESSION_COOKIE_COOKIE_NAME: &str = "session"; pub const SESSION_COOKIE_SCHEME_NAME: authn::SchemeName = authn::SchemeName("session_cookie"); +/// Generate session cookie header +pub fn session_cookie_header_value(token: &str, max_age: Duration) -> String { + format!( + "{}={}; Secure; HttpOnly; SameSite=Lax; Max-Age={}", + SESSION_COOKIE_COOKIE_NAME, + token, + max_age.num_seconds() + ) +} + +/// Generate session cookie with empty token and max-age=0 so browser deletes it +pub fn clear_session_cookie_header_value() -> String { + session_cookie_header_value("", Duration::zero()) +} + /// Implements an authentication scheme where we check the DB to see if we have /// a session matching the token in a cookie ([`SESSION_COOKIE_COOKIE_NAME`]) on /// the request. This is meant to be used by the web console. @@ -148,8 +163,9 @@ fn get_token_from_cookie( #[cfg(test)] mod test { use super::{ - get_token_from_cookie, Details, HttpAuthnScheme, - HttpAuthnSessionCookie, Reason, SchemeResult, Session, SessionStore, + get_token_from_cookie, session_cookie_header_value, Details, + HttpAuthnScheme, HttpAuthnSessionCookie, Reason, SchemeResult, Session, + SessionStore, }; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; @@ -355,4 +371,22 @@ mod test { let token = get_token_from_cookie(&headers); assert_eq!(token, None); } + + #[test] + fn test_session_cookie_value() { + assert_eq!( + session_cookie_header_value("abc", Duration::seconds(5)), + "session=abc; Secure; HttpOnly; SameSite=Lax; Max-Age=5" + ); + + assert_eq!( + session_cookie_header_value("abc", Duration::seconds(-5)), + "session=abc; Secure; HttpOnly; SameSite=Lax; Max-Age=-5" + ); + + assert_eq!( + session_cookie_header_value("", Duration::zero()), + "session=; Secure; HttpOnly; SameSite=Lax; Max-Age=0" + ); + } } diff --git a/nexus/src/config.rs b/nexus/src/config.rs index 9faccaebd01..104b4b04e10 100644 --- a/nexus/src/config.rs +++ b/nexus/src/config.rs @@ -23,6 +23,13 @@ use std::path::{Path, PathBuf}; pub struct AuthnConfig { /** allowed authentication schemes for external HTTP server */ pub schemes_external: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct ConsoleConfig { + pub assets_directory: PathBuf, + /** how long the browser can cache static assets */ + pub cache_control_max_age_minutes: u32, /** how long a session can be idle before expiring */ pub session_idle_timeout_minutes: u32, /** how long a session can exist before expiring */ @@ -40,6 +47,8 @@ pub struct Config { pub dropshot_internal: ConfigDropshot, /** Identifier for this instance of Nexus */ pub id: uuid::Uuid, + /** Console-related tunables */ + pub console: ConsoleConfig, /** Server-wide logging configuration. */ pub log: ConfigLogging, /** Database parameters */ @@ -149,7 +158,10 @@ impl Config { #[cfg(test)] mod test { - use super::{AuthnConfig, Config, LoadError, LoadErrorKind, SchemeName}; + use super::{ + AuthnConfig, Config, ConsoleConfig, LoadError, LoadErrorKind, + SchemeName, + }; use crate::db; use dropshot::ConfigDropshot; use dropshot::ConfigLogging; @@ -257,10 +269,13 @@ mod test { "valid", r##" id = "28b90dc4-c22a-65ba-f49a-f051fe01208f" - [authn] - schemes_external = [] + [console] + assets_directory = "tests/fixtures" + cache_control_max_age_minutes = 10 session_idle_timeout_minutes = 60 session_absolute_timeout_minutes = 480 + [authn] + schemes_external = [] [dropshot_external] bind_address = "10.1.2.3:4567" request_body_max_bytes = 1024 @@ -282,11 +297,13 @@ mod test { config, Config { id: "28b90dc4-c22a-65ba-f49a-f051fe01208f".parse().unwrap(), - authn: AuthnConfig { - schemes_external: Vec::new(), + console: ConsoleConfig { + assets_directory: "tests/fixtures".parse().unwrap(), + cache_control_max_age_minutes: 10, session_idle_timeout_minutes: 60, session_absolute_timeout_minutes: 480 }, + authn: AuthnConfig { schemes_external: Vec::new() }, dropshot_external: ConfigDropshot { bind_address: "10.1.2.3:4567" .parse::() @@ -316,10 +333,13 @@ mod test { "valid", r##" id = "28b90dc4-c22a-65ba-f49a-f051fe01208f" - [authn] - schemes_external = [ "spoof", "session_cookie" ] + [console] + assets_directory = "tests/fixtures" + cache_control_max_age_minutes = 10 session_idle_timeout_minutes = 60 session_absolute_timeout_minutes = 480 + [authn] + schemes_external = [ "spoof", "session_cookie" ] [dropshot_external] bind_address = "10.1.2.3:4567" request_body_max_bytes = 1024 @@ -351,10 +371,13 @@ mod test { "bad authn.schemes_external", r##" id = "28b90dc4-c22a-65ba-f49a-f051fe01208f" - [authn] - schemes_external = ["trust-me"] + [console] + assets_directory = "tests/fixtures" + cache_control_max_age_minutes = 10 session_idle_timeout_minutes = 60 session_absolute_timeout_minutes = 480 + [authn] + schemes_external = ["trust-me"] [dropshot_external] bind_address = "10.1.2.3:4567" request_body_max_bytes = 1024 diff --git a/nexus/src/context.rs b/nexus/src/context.rs index 41f60f1e4ac..5db0a8af3a3 100644 --- a/nexus/src/context.rs +++ b/nexus/src/context.rs @@ -19,7 +19,9 @@ use oximeter::types::ProducerRegistry; use oximeter_instruments::http::{HttpService, LatencyTracker}; use slog::Logger; use std::collections::BTreeMap; +use std::env; use std::fmt::Debug; +use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; use std::time::SystemTime; @@ -43,15 +45,19 @@ pub struct ServerContext { pub external_latencies: LatencyTracker, /** registry of metric producers */ pub producer_registry: ProducerRegistry, - /** the whole config */ - pub tunables: Tunables, + /** tunable settings needed for the console at runtime */ + pub console_config: ConsoleConfig, } -pub struct Tunables { +pub struct ConsoleConfig { /** how long a session can be idle before expiring */ pub session_idle_timeout: Duration, /** how long a session can exist before expiring */ pub session_absolute_timeout: Duration, + /** how long browsers can cache static assets */ + pub cache_control_max_age: Duration, + /** directory containing static assets */ + pub assets_directory: PathBuf, } impl ServerContext { @@ -64,7 +70,7 @@ impl ServerContext { log: Logger, pool: db::Pool, config: &config::Config, - ) -> Arc { + ) -> Result, String> { let nexus_schemes = config .authn .schemes_external @@ -101,7 +107,20 @@ impl ServerContext { .register_producer(external_latencies.clone()) .unwrap(); - Arc::new(ServerContext { + let assets_directory = PathBuf::from( + env::var("CARGO_MANIFEST_DIR") + .map_err(|_e| "env var CARGO_MANIFEST_DIR must be defined because the path for static assets is relative to it")?, + ) + .join(config.console.assets_directory.to_owned()); + + if !assets_directory.exists() { + return Err("assets_directory must exist at start time".to_string()); + } + + // TODO: check for particular assets, like console index.html + // leaving that out for now so we don't break nexus in dev for everyone + + Ok(Arc::new(ServerContext { nexus: Nexus::new_with_id( rack_id, log.new(o!("component" => "nexus")), @@ -115,15 +134,19 @@ impl ServerContext { internal_latencies, external_latencies, producer_registry, - tunables: Tunables { + console_config: ConsoleConfig { session_idle_timeout: Duration::minutes( - config.authn.session_idle_timeout_minutes.into(), + config.console.session_idle_timeout_minutes.into(), ), session_absolute_timeout: Duration::minutes( - config.authn.session_absolute_timeout_minutes.into(), + config.console.session_absolute_timeout_minutes.into(), + ), + assets_directory, + cache_control_max_age: Duration::minutes( + config.console.cache_control_max_age_minutes.into(), ), }, - }) + })) } } @@ -362,11 +385,11 @@ impl SessionStore for Arc { } fn session_idle_timeout(&self) -> Duration { - self.tunables.session_idle_timeout + self.console_config.session_idle_timeout } fn session_absolute_timeout(&self) -> Duration { - self.tunables.session_absolute_timeout + self.console_config.session_absolute_timeout } } diff --git a/nexus/src/external_api/console_api.rs b/nexus/src/external_api/console_api.rs new file mode 100644 index 00000000000..5d5835b2355 --- /dev/null +++ b/nexus/src/external_api/console_api.rs @@ -0,0 +1,342 @@ +/*! + * Handler functions (entrypoints) for console-related routes. + * + * This was originally conceived as a separate dropshot server from the external API, + * but in order to avoid CORS issues for now, we are serving these routes directly + * from the external API. + */ +use crate::authn::external::{ + cookies::Cookies, + session_cookie::{ + clear_session_cookie_header_value, session_cookie_header_value, + SessionStore, SESSION_COOKIE_COOKIE_NAME, + }, +}; +use crate::authn::{TEST_USER_UUID_PRIVILEGED, TEST_USER_UUID_UNPRIVILEGED}; +use crate::context::OpContext; +use crate::ServerContext; +use dropshot::{endpoint, HttpError, Path, RequestContext, TypedBody}; +use http::{header, Response, StatusCode}; +use hyper::Body; +use mime_guess; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc}; +use uuid::Uuid; + +#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LoginParams { + pub username: String, +} + +// This is just for demo purposes. we will probably end up with a real username/password login +// endpoint, but I think it will only be for use while setting up the rack +#[endpoint { + method = POST, + path = "/login", + // TODO: this should be unpublished, but for now it's convenient for the + // console to use the generated client for this request +}] +pub async fn spoof_login( + rqctx: Arc>>, + params: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let nexus = &apictx.nexus; + let params = params.into_inner(); + let user_id: Option = match params.username.as_str() { + "privileged" => Some(TEST_USER_UUID_PRIVILEGED.parse().unwrap()), + "unprivileged" => Some(TEST_USER_UUID_UNPRIVILEGED.parse().unwrap()), + _ => None, + }; + + if user_id.is_none() { + return Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header(header::SET_COOKIE, clear_session_cookie_header_value()) + .body("".into())?); // TODO: failed login response body? + } + + let session = nexus + // TODO: obviously + .session_create(user_id.unwrap()) + .await?; + Ok(Response::builder() + .status(StatusCode::OK) + .header( + header::SET_COOKIE, + session_cookie_header_value( + &session.token, + apictx.session_idle_timeout(), + ), + ) + .body("ok".into())?) // TODO: what do we return from login? +} + +// Log user out of web console by deleting session in both server and browser +#[endpoint { + // important for security that this be a POST despite the empty req body + method = POST, + path = "/logout", + // TODO: this should be unpublished, but for now it's convenient for the + // console to use the generated client for this request +}] +pub async fn logout( + rqctx: Arc>>, + cookies: Cookies, +) -> Result, HttpError> { + let nexus = &rqctx.context().nexus; + let opctx = OpContext::for_external_api(&rqctx).await; + let token = cookies.get(SESSION_COOKIE_COOKIE_NAME); + + if opctx.is_ok() && token.is_some() { + nexus.session_hard_delete(token.unwrap().value().to_string()).await?; + } + + // If user's session was already expired, they failed auth and their session + // was automatically deleted by the auth scheme. If they have no session + // (e.g., they cleared their cookies while sitting on the page) they will + // also fail auth. + + // Even if the user failed auth, we don't want to send them back a 401 like + // we would for a normal request. They are in fact logged out like they + // intended, and we should send the standard success response. + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::SET_COOKIE, clear_session_cookie_header_value()) + .body("".into())?) +} + +#[derive(Deserialize, JsonSchema)] +pub struct RestPathParam { + path: Vec, +} + +// Serve the console bundle without an auth gate just for the login form. This +// is meant to stand in for the customers identity provider. Since this is a +// placeholder, it's easiest to build the form into the console bundle. If we +// really wanted a login form, we would probably make it a standalone page, +// otherwise the user is downloading a bunch of JS for nothing. +#[endpoint { + method = GET, + path = "/login", + unpublished = true, +}] +pub async fn spoof_login_form( + rqctx: Arc>>, +) -> Result, HttpError> { + serve_console_index(rqctx.context()).await +} + +// Dropshot does not have route match ranking and does not allow overlapping +// route definitions, so we cannot have a catchall `/*` route for console pages +// and then also define, e.g., `/api/blah/blah` and give the latter priority +// because it's a more specific match. So for now we simply give the console +// catchall route a prefix to avoid overlap. Long-term, if a route prefix is +// part of the solution, we would probably prefer it to be on the API endpoints, +// not on the console pages. Conveniently, all the console page routes start +// with /orgs already. +#[endpoint { + method = GET, + path = "/orgs/{path:.*}", + unpublished = true, +}] +pub async fn console_page( + rqctx: Arc>>, + _path_params: Path, +) -> Result, HttpError> { + let opctx = OpContext::for_external_api(&rqctx).await; + + // if authed, serve HTML page with bundle in script tag + + // HTML doesn't need to be static -- we'll probably find a reason to do some minimal + // templating, e.g., putting a CSRF token in the page + + // amusingly, at least to start out, I don't think we care about the path + // because the real routing is all client-side. we serve the same HTML + // regardless, the app starts on the client and renders the right page and + // makes the right API requests. + if let Ok(opctx) = opctx { + if opctx.authn.actor().is_some() { + return serve_console_index(rqctx.context()).await; + } + } + + // otherwise redirect to idp + Ok(Response::builder() + .status(StatusCode::FOUND) + .header(http::header::LOCATION, "/login") + .body("".into())?) +} + +/// Fetch a static asset from the configured assets directory. 404 on virtually +/// all errors. No auth. NO SENSITIVE FILES. +#[endpoint { + method = GET, + path = "/assets/{path:.*}", + unpublished = true, +}] +pub async fn asset( + rqctx: Arc>>, + path_params: Path, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let path = path_params.into_inner().path; + + let file = find_file(path, &apictx.console_config.assets_directory)?; + let file_contents = + tokio::fs::read(&file).await.map_err(|_| not_found("EBADF"))?; + + // Derive the MIME type from the file name + let content_type = mime_guess::from_path(&file) + .first() + .map_or_else(|| "text/plain".to_string(), |m| m.to_string()); + + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, &content_type) + .header(http::header::CACHE_CONTROL, cache_control_header_value(apictx)) + .body(file_contents.into())?) +} + +fn cache_control_header_value(apictx: &Arc) -> String { + format!( + "max-age={}", + apictx.console_config.cache_control_max_age.num_seconds() + ) +} + +async fn serve_console_index( + apictx: &Arc, +) -> Result, HttpError> { + let file = apictx + .console_config + .assets_directory + .join(PathBuf::from("index.html")); + let file_contents = + tokio::fs::read(&file).await.map_err(|_| not_found("EBADF"))?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "text/html; charset=UTF-8") + .header(http::header::CACHE_CONTROL, cache_control_header_value(apictx)) + .body(file_contents.into())?) +} + +fn not_found(internal_msg: &str) -> HttpError { + HttpError::for_not_found(None, internal_msg.to_string()) +} + +/// Starting from `root_dir`, follow the segments of `path` down the file tree +/// until we find a file (or not). Do not follow symlinks. +fn find_file( + path: Vec, + root_dir: &PathBuf, +) -> Result { + let mut current = root_dir.to_owned(); // start from `root_dir` + for segment in &path { + // If we hit a non-directory thing already and we still have segments + // left in the path, bail. We have nowhere to go. + if !current.is_dir() { + return Err(not_found("ENOENT")); + } + + current.push(segment); + + // Don't follow symlinks. + // Error means either the user doesn't have permission to pull + // metadata or the path doesn't exist. + let m = current.symlink_metadata().map_err(|_| not_found("ENOENT"))?; + if m.file_type().is_symlink() { + return Err(not_found("EMLINK")); + } + } + + // can't serve a directory + if current.is_dir() { + return Err(not_found("EISDIR")); + } + + Ok(current) +} + +#[cfg(test)] +mod test { + use super::find_file; + use http::StatusCode; + use std::{env::current_dir, path::PathBuf}; + + fn get_path(path_str: &str) -> Vec { + path_str.split("/").map(|s| s.to_string()).collect() + } + + #[test] + fn test_find_file_finds_file() { + let root = current_dir().unwrap(); + let file = find_file(get_path("tests/fixtures/hello.txt"), &root); + assert!(file.is_ok()); + } + + #[test] + fn test_find_file_404_on_nonexistent() { + let root = current_dir().unwrap(); + let error = + find_file(get_path("tests/fixtures/nonexistent.svg"), &root) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + assert_eq!(error.internal_message, "ENOENT".to_string()); + } + + #[test] + fn test_find_file_404_on_nonexistent_nested() { + let root = current_dir().unwrap(); + let error = + find_file(get_path("tests/fixtures/a/b/c/nonexistent.svg"), &root) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + assert_eq!(error.internal_message, "ENOENT".to_string()); + } + + #[test] + fn test_find_file_404_on_directory() { + let root = current_dir().unwrap(); + let error = find_file(get_path("tests/fixtures/a_directory"), &root) + .unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + assert_eq!(error.internal_message, "EISDIR".to_string()); + } + + #[test] + fn test_find_file_404_on_symlink() { + let root = current_dir().unwrap(); + let path_str = "tests/fixtures/a_symlink"; + + // the file in question does exist and is a symlink + assert!(root + .join(PathBuf::from(path_str)) + .symlink_metadata() + .unwrap() + .file_type() + .is_symlink()); + + // so we 404 + let error = find_file(get_path(path_str), &root).unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + assert_eq!(error.internal_message, "EMLINK".to_string()); + } + + #[test] + fn test_find_file_wont_follow_symlink() { + let root = current_dir().unwrap(); + let path_str = "tests/fixtures/a_symlink/another_file.txt"; + + // the file in question does exist + assert!(root.join(PathBuf::from(path_str)).exists()); + + // but it 404s because the path goes through a symlink + let error = find_file(get_path(path_str), &root).unwrap_err(); + assert_eq!(error.status_code, StatusCode::NOT_FOUND); + assert_eq!(error.internal_message, "EMLINK".to_string()); + } +} diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 47df19c6cd9..47d0eb84d2d 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -6,8 +6,10 @@ use crate::db; use crate::db::model::Name; use crate::ServerContext; -use super::params; -use super::views::{Organization, Project}; +use super::{ + console_api, params, + views::{Organization, Project}, +}; use crate::context::OpContext; use dropshot::endpoint; use dropshot::ApiDescription; @@ -140,6 +142,12 @@ pub fn external_api() -> NexusApiDescription { api.register(sagas_get)?; api.register(sagas_get_saga)?; + api.register(console_api::spoof_login)?; + api.register(console_api::spoof_login_form)?; + api.register(console_api::logout)?; + api.register(console_api::console_page)?; + api.register(console_api::asset)?; + Ok(()) } diff --git a/nexus/src/external_api/mod.rs b/nexus/src/external_api/mod.rs index df2e9092b8a..be47fbda546 100644 --- a/nexus/src/external_api/mod.rs +++ b/nexus/src/external_api/mod.rs @@ -1,3 +1,4 @@ +pub mod console_api; pub mod http_entrypoints; pub mod params; pub mod views; diff --git a/nexus/src/lib.rs b/nexus/src/lib.rs index be0c412077b..2eb7850dae5 100644 --- a/nexus/src/lib.rs +++ b/nexus/src/lib.rs @@ -91,22 +91,20 @@ impl Server { let ctxlog = log.new(o!("component" => "ServerContext")); let pool = db::Pool::new(&config.database); - let apictx = ServerContext::new(rack_id, ctxlog, pool, &config); + let apictx = ServerContext::new(rack_id, ctxlog, pool, &config)?; - let c1 = Arc::clone(&apictx); let http_server_starter_external = dropshot::HttpServerStarter::new( &config.dropshot_external, external_api(), - c1, + Arc::clone(&apictx), &log.new(o!("component" => "dropshot_external")), ) .map_err(|error| format!("initializing external server: {}", error))?; - let c2 = Arc::clone(&apictx); let http_server_starter_internal = dropshot::HttpServerStarter::new( &config.dropshot_internal, internal_api(), - c2, + Arc::clone(&apictx), &log.new(o!("component" => "dropshot_internal")), ) .map_err(|error| format!("initializing internal server: {}", error))?; @@ -125,22 +123,24 @@ impl Server { * or until something else initiates a graceful shutdown. */ pub async fn wait_for_finish(self) -> Result<(), String> { - let result_external = self.http_server_external.await; - let result_internal = self.http_server_internal.await; - - match (result_external, result_internal) { - (Ok(()), Ok(())) => Ok(()), - (Err(error_external), Err(error_internal)) => Err(format!( - "errors from both external and internal HTTP \ - servers(external: \"{}\", internal: \"{}\"", - error_external, error_internal - )), - (Err(error_external), Ok(())) => { - Err(format!("external server: {}", error_external)) - } - (Ok(()), Err(error_internal)) => { - Err(format!("internal server: {}", error_internal)) - } + let errors = vec![ + self.http_server_external + .await + .map_err(|e| format!("external: {}", e)), + self.http_server_internal + .await + .map_err(|e| format!("internal: {}", e)), + ] + .into_iter() + .filter(Result::is_err) + .map(|r| r.unwrap_err()) + .collect::>(); + + if errors.len() > 0 { + let msg = format!("errors shutting down: ({})", errors.join(", ")); + Err(msg) + } else { + Ok(()) } } diff --git a/nexus/tests/common/http_testing.rs b/nexus/tests/common/http_testing.rs index 9b332e74fe4..235f79630a3 100644 --- a/nexus/tests/common/http_testing.rs +++ b/nexus/tests/common/http_testing.rs @@ -55,6 +55,8 @@ pub struct RequestBuilder<'a> { expected_status: Option, allowed_headers: Option>, + // doesn't need Option<> because if it's empty, we don't check anything + expected_response_headers: http::HeaderMap, } impl<'a> RequestBuilder<'a> { @@ -73,12 +75,15 @@ impl<'a> RequestBuilder<'a> { body: hyper::Body::empty(), expected_status: None, allowed_headers: Some(vec![ + http::header::CACHE_CONTROL, http::header::CONTENT_LENGTH, http::header::CONTENT_TYPE, http::header::DATE, + http::header::LOCATION, http::header::SET_COOKIE, http::header::HeaderName::from_static("x-request-id"), ]), + expected_response_headers: http::HeaderMap::new(), error: None, } } @@ -91,30 +96,14 @@ impl<'a> RequestBuilder<'a> { KE: std::error::Error + Send + Sync + 'static, VE: std::error::Error + Send + Sync + 'static, { - let header_name_dbg = format!("{:?}", name); - let header_value_dbg = format!("{:?}", value); - let header_name: Result = - name.try_into().with_context(|| { - format!("converting header name {}", header_name_dbg) - }); - let header_value: Result = - value.try_into().with_context(|| { - format!( - "converting value for header {}: {}", - header_name_dbg, header_value_dbg - ) - }); - match (header_name, header_value) { - (Ok(name), Ok(value)) => { - self.headers.append(name, value); - } - (Err(error), _) => { + match parse_header_pair(name, value) { + Err(error) => { self.error = Some(error); } - (_, Err(error)) => { - self.error = Some(error); + Ok((name, value)) => { + self.headers.append(name, value); } - }; + } self } @@ -165,6 +154,32 @@ impl<'a> RequestBuilder<'a> { self } + /// Add header and value to check for at execution time + /// + /// Behaves like header() rather than expect_allowed_headers() in that it + /// takes one header at a time rather than a whole set. + pub fn expect_response_header( + mut self, + name: K, + value: V, + ) -> Self + where + K: TryInto + Debug, + V: TryInto + Debug, + KE: std::error::Error + Send + Sync + 'static, + VE: std::error::Error + Send + Sync + 'static, + { + match parse_header_pair(name, value) { + Err(error) => { + self.error = Some(error); + } + Ok((name, value)) => { + self.expected_response_headers.append(name, value); + } + } + self + } + /// Make the HTTP request using the given `client`, verify the returned /// response, and make the response available to the caller /// @@ -227,6 +242,25 @@ impl<'a> RequestBuilder<'a> { } } + // Check that we do have all expected headers + for (header_name, expected_value) in + self.expected_response_headers.iter() + { + ensure!( + headers.contains_key(header_name), + "response did not contain expected header {:?}", + header_name + ); + let actual_value = headers.get(header_name).unwrap(); + ensure!( + actual_value == expected_value, + "response contained expected header {:?}, but with value {:?} instead of expected {:?}", + header_name, + actual_value, + expected_value, + ); + } + // Sanity check the Date header in the response. This check assumes // that the server is running on the same system as the client, which is // currently true because this is running in a test suite. We may need @@ -298,7 +332,7 @@ impl<'a> RequestBuilder<'a> { }; if status.is_client_error() || status.is_server_error() { let error_body = test_response - .response_body::() + .parsed_body::() .context("parsing error body")?; ensure!( error_body.request_id == request_id_header, @@ -313,18 +347,44 @@ impl<'a> RequestBuilder<'a> { } } +fn parse_header_pair( + name: K, + value: V, +) -> Result<(http::header::HeaderName, http::header::HeaderValue), anyhow::Error> +where + K: TryInto + Debug, + V: TryInto + Debug, + KE: std::error::Error + Send + Sync + 'static, + VE: std::error::Error + Send + Sync + 'static, +{ + let header_name_dbg = format!("{:?}", name); + let header_value_dbg = format!("{:?}", value); + + let header_name = name.try_into().with_context(|| { + format!("converting header name {}", header_name_dbg) + })?; + let header_value = value.try_into().with_context(|| { + format!( + "converting value for header {}: {}", + header_name_dbg, header_value_dbg + ) + })?; + + Ok((header_name, header_value)) +} + /// Represents a response from an HTTP server pub struct TestResponse { pub status: http::StatusCode, pub headers: http::HeaderMap, - body: bytes::Bytes, + pub body: bytes::Bytes, } impl TestResponse { /// Parse the response body as an instance of `R` and returns it /// /// Fails if the body could not be parsed as an `R`. - pub fn response_body( + pub fn parsed_body( &self, ) -> Result { serde_json::from_slice(self.body.as_ref()) @@ -434,7 +494,7 @@ pub mod dropshot_compat { .execute() .await .unwrap() - .response_body::() + .parsed_body::() .unwrap() } @@ -450,7 +510,7 @@ pub mod dropshot_compat { .execute() .await .unwrap() - .response_body::>() + .parsed_body::>() .unwrap() } @@ -468,7 +528,7 @@ pub mod dropshot_compat { .execute() .await .unwrap() - .response_body::() + .parsed_body::() .unwrap() } } diff --git a/nexus/tests/common/resource_helpers.rs b/nexus/tests/common/resource_helpers.rs index abfa05fd06a..6d1a1e2ccaf 100644 --- a/nexus/tests/common/resource_helpers.rs +++ b/nexus/tests/common/resource_helpers.rs @@ -25,7 +25,7 @@ where .execute() .await .expect("failed to make request") - .response_body() + .parsed_body() .unwrap() } @@ -44,7 +44,7 @@ pub async fn create_organization( .execute() .await .expect("failed to make request") - .response_body() + .parsed_body() .unwrap() } diff --git a/nexus/tests/config.test.toml b/nexus/tests/config.test.toml index d1ad5b008df..a2333531689 100644 --- a/nexus/tests/config.test.toml +++ b/nexus/tests/config.test.toml @@ -6,12 +6,17 @@ # NOTE: The test suite always overrides this. id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" -# List of authentication schemes to support. -[authn] -schemes_external = [ "spoof" ] +[console] +# Directory of static assets for the console. Relative to nexus/. +assets_directory = "tests/fixtures" +cache_control_max_age_minutes = 10 session_idle_timeout_minutes = 60 session_absolute_timeout_minutes = 480 +# List of authentication schemes to support. +[authn] +schemes_external = [ "spoof", "session_cookie" ] + # # NOTE: for the test suite, the database URL will be replaced with one # appropriate for the database that's started by the test runner. @@ -27,11 +32,7 @@ url = "postgresql://root@127.0.0.1:0/omicron?sslmode=disable" [dropshot_external] bind_address = "127.0.0.1:0" -# -# NOTE: for the test suite, the port MUST be 0 (in order to bind to any -# available port) because the test suite will be running many servers -# concurrently. -# +# port must be 0. see above [dropshot_internal] bind_address = "127.0.0.1:0" diff --git a/nexus/tests/fixtures/a_directory/another_file.txt b/nexus/tests/fixtures/a_directory/another_file.txt new file mode 100644 index 00000000000..d9ea2697614 --- /dev/null +++ b/nexus/tests/fixtures/a_directory/another_file.txt @@ -0,0 +1 @@ +some words \ No newline at end of file diff --git a/nexus/tests/fixtures/a_symlink b/nexus/tests/fixtures/a_symlink new file mode 120000 index 00000000000..dcd68e42424 --- /dev/null +++ b/nexus/tests/fixtures/a_symlink @@ -0,0 +1 @@ +./a_directory \ No newline at end of file diff --git a/nexus/tests/fixtures/hello.txt b/nexus/tests/fixtures/hello.txt new file mode 100644 index 00000000000..d2aeeec3fa6 --- /dev/null +++ b/nexus/tests/fixtures/hello.txt @@ -0,0 +1 @@ +hello there \ No newline at end of file diff --git a/nexus/tests/fixtures/index.html b/nexus/tests/fixtures/index.html new file mode 100644 index 00000000000..6c70bcfe4d4 --- /dev/null +++ b/nexus/tests/fixtures/index.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/nexus/tests/test_authz.rs b/nexus/tests/test_authz.rs index fe071b5b80e..7cc73c4c16f 100644 --- a/nexus/tests/test_authz.rs +++ b/nexus/tests/test_authz.rs @@ -94,6 +94,6 @@ async fn try_create_organization( .execute() .await .expect("failed to make request") - .response_body() + .parsed_body() .unwrap() } diff --git a/nexus/tests/test_console_api.rs b/nexus/tests/test_console_api.rs new file mode 100644 index 00000000000..e591b6a386f --- /dev/null +++ b/nexus/tests/test_console_api.rs @@ -0,0 +1,209 @@ +use dropshot::test_util::ClientTestContext; +use http::header::HeaderName; +use http::{header, method::Method, StatusCode}; + +pub mod common; +use common::http_testing::{RequestBuilder, TestResponse}; +use common::test_setup; +use omicron_common::api::external::IdentityMetadataCreateParams; +use omicron_nexus::external_api::console_api::LoginParams; +use omicron_nexus::external_api::params::OrganizationCreate; + +extern crate slog; + +#[tokio::test] +async fn test_sessions() { + let cptestctx = test_setup("test_sessions").await; + let testctx = &cptestctx.external_client; + + // logout always gives the same response whether you have a session or not + RequestBuilder::new(&testctx, Method::POST, "/logout") + .expect_status(Some(StatusCode::NO_CONTENT)) + .expect_response_header( + header::SET_COOKIE, + "session=; Secure; HttpOnly; SameSite=Lax; Max-Age=0", + ) + .execute() + .await + .expect("failed to clear cookie and 204 on logout"); + + // log in and pull the token out of the header so we can use it for authed requests + let session_token = log_in_and_extract_token(&testctx).await; + + let org_params = OrganizationCreate { + identity: IdentityMetadataCreateParams { + name: "my-org".parse().unwrap(), + description: "an org".to_string(), + }, + }; + + // hitting auth-gated API endpoint without session cookie 401s + RequestBuilder::new(&testctx, Method::POST, "/organizations") + .body(Some(org_params.clone())) + .expect_status(Some(StatusCode::UNAUTHORIZED)) + .execute() + .await + .expect("failed to 401 on unauthed API request"); + + // console pages don't 401, they 302 + RequestBuilder::new(&testctx, Method::GET, "/orgs/whatever") + .expect_status(Some(StatusCode::FOUND)) + .execute() + .await + .expect("failed to 302 on unauthed console page request"); + + // now make same requests with cookie + RequestBuilder::new(&testctx, Method::POST, "/organizations") + .header(header::COOKIE, &session_token) + .body(Some(org_params.clone())) + // TODO: explicit expect_status not needed. decide whether to keep it anyway + .expect_status(Some(StatusCode::CREATED)) + .execute() + .await + .expect("failed to create org with session cookie"); + + RequestBuilder::new(&testctx, Method::GET, "/orgs/whatever") + .header(header::COOKIE, &session_token) + .expect_status(Some(StatusCode::OK)) + .execute() + .await + .expect("failed to get console page with session cookie"); + + // logout with an actual session should delete the session in the db + RequestBuilder::new(&testctx, Method::POST, "/logout") + .header(header::COOKIE, &session_token) + .expect_status(Some(StatusCode::NO_CONTENT)) + // logout also clears the cookie client-side + .expect_response_header( + header::SET_COOKIE, + "session=; Secure; HttpOnly; SameSite=Lax; Max-Age=0", + ) + .execute() + .await + .expect("failed to log out"); + + // now the same requests with the same session cookie should 401/302 because + // logout also deletes the session server-side + RequestBuilder::new(&testctx, Method::POST, "/organizations") + .header(header::COOKIE, &session_token) + .body(Some(org_params)) + .expect_status(Some(StatusCode::UNAUTHORIZED)) + .execute() + .await + .expect("failed to get 401 for unauthed API request"); + + RequestBuilder::new(&testctx, Method::GET, "/orgs/whatever") + .header(header::COOKIE, &session_token) + .expect_status(Some(StatusCode::FOUND)) + .execute() + .await + .expect("failed to get 302 for unauthed console request"); + + cptestctx.teardown().await; +} + +#[tokio::test] +async fn test_console_pages() { + let cptestctx = test_setup("test_console_pages").await; + let testctx = &cptestctx.external_client; + + // request to console page route without auth should redirect to IdP + let _ = RequestBuilder::new(&testctx, Method::GET, "/orgs/irrelevant-path") + .expect_status(Some(StatusCode::FOUND)) + .expect_response_header(header::LOCATION, "/login") + .execute() + .await + .expect("failed to redirect to IdP on auth failure"); + + let session_token = log_in_and_extract_token(&testctx).await; + + // hit console page with session, should get back HTML response + let console_page = + RequestBuilder::new(&testctx, Method::GET, "/orgs/irrelevant-path") + .header(http::header::COOKIE, session_token) + .expect_status(Some(StatusCode::OK)) + .expect_response_header( + http::header::CONTENT_TYPE, + "text/html; charset=UTF-8", + ) + .execute() + .await + .expect("failed to get console index"); + + assert_eq!(console_page.body, "".as_bytes()); + + cptestctx.teardown().await; +} + +#[tokio::test] +async fn text_login_form() { + let cptestctx = test_setup("test_login_form").await; + let testctx = &cptestctx.external_client; + + // login route returns bundle too, but is not auth gated + let console_page = RequestBuilder::new(&testctx, Method::GET, "/login") + .expect_status(Some(StatusCode::OK)) + .expect_response_header( + http::header::CONTENT_TYPE, + "text/html; charset=UTF-8", + ) + .execute() + .await + .expect("failed to get login form"); + + assert_eq!(console_page.body, "".as_bytes()); + + cptestctx.teardown().await; +} + +#[tokio::test] +async fn test_assets() { + let cptestctx = test_setup("test_assets").await; + let testctx = &cptestctx.external_client; + + // nonexistent file 404s + let _ = + RequestBuilder::new(&testctx, Method::GET, "/assets/nonexistent.svg") + .expect_status(Some(StatusCode::NOT_FOUND)) + .execute() + .await + .expect("failed to 404 on nonexistent asset"); + + // symlink 404s + let _ = RequestBuilder::new(&testctx, Method::GET, "/assets/a_symlink") + .expect_status(Some(StatusCode::NOT_FOUND)) + .execute() + .await + .expect("failed to 404 on symlink"); + + // existing file is returned + let resp = RequestBuilder::new(&testctx, Method::GET, "/assets/hello.txt") + .execute() + .await + .expect("failed to get existing file"); + + assert_eq!(resp.body, "hello there".as_bytes()); + + cptestctx.teardown().await; +} + +fn get_header_value(resp: TestResponse, header_name: HeaderName) -> String { + resp.headers.get(header_name).unwrap().to_str().unwrap().to_string() +} + +async fn log_in_and_extract_token(testctx: &ClientTestContext) -> String { + let login = RequestBuilder::new(&testctx, Method::POST, "/login") + .body(Some(LoginParams { username: "privileged".to_string() })) + .expect_status(Some(StatusCode::OK)) + .execute() + .await + .expect("failed to log in"); + + let session_cookie = get_header_value(login, header::SET_COOKIE); + let (session_token, rest) = session_cookie.split_once("; ").unwrap(); + + assert!(session_token.starts_with("session=")); + assert_eq!(rest, "Secure; HttpOnly; SameSite=Lax; Max-Age=3600"); + + session_token.to_string() +} diff --git a/nexus/tests/test_disks.rs b/nexus/tests/test_disks.rs index bbf893ed802..8c6ecabc923 100644 --- a/nexus/tests/test_disks.rs +++ b/nexus/tests/test_disks.rs @@ -73,7 +73,7 @@ async fn test_disks() { .execute() .await .expect("unexpected success") - .response_body::() + .parsed_body::() .unwrap(); assert_eq!(error.message, "not found: disk with name \"just-rainsticks\""); diff --git a/openapi/nexus.json b/openapi/nexus.json index dcad0530566..7a86eafa1cf 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -174,6 +174,28 @@ } } }, + "/login": { + "post": { + "operationId": "spoof_login", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginParams" + } + } + }, + "required": true + }, + "responses": {} + } + }, + "/logout": { + "post": { + "operationId": "logout", + "responses": {} + } + }, "/organizations": { "get": { "description": "List all organizations.", @@ -3166,6 +3188,17 @@ "minLength": 1, "maxLength": 11 }, + "LoginParams": { + "type": "object", + "properties": { + "username": { + "type": "string" + } + }, + "required": [ + "username" + ] + }, "Name": { "title": "A name used in the API", "description": "Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'.", diff --git a/smf/nexus/config.toml b/smf/nexus/config.toml index 5b61e1c2b8d..e5d47fbc674 100644 --- a/smf/nexus/config.toml +++ b/smf/nexus/config.toml @@ -5,11 +5,16 @@ # Identifier for this instance of Nexus id = "e6bff1ff-24fb-49dc-a54e-c6a350cd4d6c" +[console] +# Directory of static assets for the console. Relative to nexus/. +assets_directory = "tests/fixtures" # TODO: figure out value +cache_control_max_age_minutes = 10 +session_idle_timeout_minutes = 60 +session_absolute_timeout_minutes = 480 + [authn] # TODO(https://github.com/oxidecomputer/omicron/issues/372): Remove "spoof". schemes_external = ["spoof"] -session_idle_timeout_minutes = 60 -session_absolute_timeout_minutes = 480 [database] # URL for connecting to the database