diff --git a/Cargo.lock b/Cargo.lock index ed93f9366f6..9a943332eda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3843,6 +3843,7 @@ dependencies = [ "progenitor", "regress", "reqwest", + "schemars", "serde", "serde_json", "slog", @@ -4029,6 +4030,7 @@ dependencies = [ "hyper", "internal-dns", "nexus-db-queries", + "nexus-passwords", "nexus-test-interface", "nexus-types", "omicron-common 0.1.0", diff --git a/dev-tools/src/bin/omicron-dev.rs b/dev-tools/src/bin/omicron-dev.rs index 0fd078cc977..903e71d5a75 100644 --- a/dev-tools/src/bin/omicron-dev.rs +++ b/dev-tools/src/bin/omicron-dev.rs @@ -370,12 +370,17 @@ async fn cmd_run_all(args: &RunAllArgs) -> Result<(), anyhow::Error> { cptestctx.external_dns_server.local_address() ); println!( - " \ - (e.g. `dig @{} -p {} SOME_DNS_NAME.{}`)", + "omicron-dev: e.g. `dig @{} -p {} {}.sys.{}`", cptestctx.external_dns_server.local_address().ip(), cptestctx.external_dns_server.local_address().port(), + cptestctx.silo_name, cptestctx.external_dns_zone_name, ); + println!("omicron-dev: silo name: {}", cptestctx.silo_name,); + println!( + "omicron-dev: privileged user name: {}", + cptestctx.user_name.as_ref(), + ); // Wait for a signal. let caught_signal = signal_stream.next().await; diff --git a/nexus-client/Cargo.toml b/nexus-client/Cargo.toml index ae9af38bb75..fbf75196429 100644 --- a/nexus-client/Cargo.toml +++ b/nexus-client/Cargo.toml @@ -11,6 +11,7 @@ omicron-common.workspace = true progenitor.workspace = true regress.workspace = true reqwest = { workspace = true, features = ["rustls-tls", "stream"] } +schemars.workspace = true serde.workspace = true serde_json.workspace = true slog.workspace = true diff --git a/nexus-client/src/lib.rs b/nexus-client/src/lib.rs index d4a28d26883..db66b6e9cea 100644 --- a/nexus-client/src/lib.rs +++ b/nexus-client/src/lib.rs @@ -5,9 +5,21 @@ //! Interface for making API requests to the Oxide control plane at large //! from within the control plane -use omicron_common::generate_logging_api; - -generate_logging_api!("../openapi/nexus-internal.json"); +progenitor::generate_api!( + spec = "../openapi/nexus-internal.json", + derives = [schemars::JsonSchema, PartialEq], + inner_type = slog::Logger, + pre_hook = (|log: &slog::Logger, request: &reqwest::Request| { + slog::debug!(log, "client request"; + "method" => %request.method(), + "uri" => %request.url(), + "body" => ?&request.body(), + ); + }), + post_hook = (|log: &slog::Logger, result: &Result<_, _>| { + slog::debug!(log, "client response"; "result" => ?result); + }), +); impl omicron_common::api::external::ClientError for types::Error { fn message(&self) -> String { diff --git a/nexus/db-queries/src/db/datastore/dns.rs b/nexus/db-queries/src/db/datastore/dns.rs index c43ad5637dd..5cdc031a1b0 100644 --- a/nexus/db-queries/src/db/datastore/dns.rs +++ b/nexus/db-queries/src/db/datastore/dns.rs @@ -56,41 +56,45 @@ impl DataStore { .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) } - /// Fetch the DNS zone for the "external" DNS group + /// List all DNS zones in a DNS group without pagination /// - /// **Where possible, we should avoid assuming that there is only one - /// external DNS zone.** This generality is intended to support renaming - /// the external DNS zone in the future (by having a second one with the - /// name during a transitionary period). However, there are some cases - /// where this isn't practical today. This function lists external DNS - /// zones, ensures that there's exactly one, and returns it. If there are - /// some other number of external DNS zones, this function returns an - /// internal error. - pub async fn dns_zone_external( + /// We do not generally expect there to be more than 1-2 DNS zones in a + /// group (and nothing today creates more than one). + async fn dns_zones_list_all( &self, opctx: &OpContext, - ) -> LookupResult { - opctx.authorize(authz::Action::Read, &authz::DNS_CONFIG).await?; - + conn: &(impl async_bb8_diesel::AsyncConnection< + crate::db::pool::DbConnection, + ConnErr, + > + Sync), + dns_group: DnsGroup, + ) -> ListResultVec + where + ConnErr: From + Send + 'static, + ConnErr: Into, + { use db::schema::dns_zone::dsl; + const LIMIT: usize = 5; + + opctx.authorize(authz::Action::Read, &authz::DNS_CONFIG).await?; let list = dsl::dns_zone - .filter(dsl::dns_group.eq(DnsGroup::External)) - .limit(2) + .filter(dsl::dns_group.eq(dns_group)) + .order(dsl::zone_name.asc()) + .limit(i64::try_from(LIMIT).unwrap()) .select(DnsZone::as_select()) - .load_async(self.pool()) + .load_async(conn) .await .map_err(|e| { - public_error_from_diesel_pool(e, ErrorHandler::Server) + public_error_from_diesel_pool(e.into(), ErrorHandler::Server) })?; - match list.len() { - 1 => Ok(list.into_iter().next().unwrap()), - 0 => Err(Error::internal_error( - "expected exactly one external DNS zone, found 0", - )), - _ => Err(Error::internal_error( - "expected exactly one external DNS zone, found at least two", - )), - } + + bail_unless!( + list.len() < LIMIT, + "unexpectedly at least {} zones in DNS group {}", + LIMIT, + dns_group + ); + Ok(list) } /// Get the latest version for a given DNS group @@ -99,6 +103,27 @@ impl DataStore { opctx: &OpContext, dns_group: DnsGroup, ) -> LookupResult { + self.dns_group_latest_version_conn( + opctx, + self.pool_authorized(opctx).await?, + dns_group, + ) + .await + } + + pub async fn dns_group_latest_version_conn( + &self, + opctx: &OpContext, + conn: &(impl async_bb8_diesel::AsyncConnection< + crate::db::pool::DbConnection, + ConnErr, + > + Sync), + dns_group: DnsGroup, + ) -> LookupResult + where + ConnErr: From + Send + 'static, + ConnErr: Into, + { opctx.authorize(authz::Action::Read, &authz::DNS_CONFIG).await?; use db::schema::dns_version::dsl; let versions = dsl::dns_version @@ -106,10 +131,10 @@ impl DataStore { .order_by(dsl::version.desc()) .limit(1) .select(DnsVersion::as_select()) - .load_async(self.pool_authorized(opctx).await?) + .load_async(conn) .await .map_err(|e| { - public_error_from_diesel_pool(e, ErrorHandler::Server) + public_error_from_diesel_pool(e.into(), ErrorHandler::Server) })?; bail_unless!( @@ -388,9 +413,12 @@ impl DataStore { { opctx.authorize(authz::Action::Modify, &authz::DNS_CONFIG).await?; + let zones = + self.dns_zones_list_all(opctx, conn, update.dns_group).await?; + let result = conn .transaction_async(|c| async move { - self.dns_update_internal(opctx, &c, update) + self.dns_update_internal(opctx, &c, update, zones) .await .map_err(TransactionError::CustomError) }) @@ -415,6 +443,7 @@ impl DataStore { ConnErr, > + Sync), update: DnsVersionUpdateBuilder, + zones: Vec, ) -> Result<(), Error> where ConnErr: From + Send + 'static, @@ -429,29 +458,33 @@ impl DataStore { // operations fail spuriously as far as the client is concerned). We // expect these problems to be small or unlikely at small scale but // significant as the system scales up. - let dns_group = update.dns_zone.dns_group; - let version = self.dns_group_latest_version(opctx, dns_group).await?; + let dns_group = update.dns_group; + let version = + self.dns_group_latest_version_conn(opctx, conn, dns_group).await?; let new_version_num = nexus_db_model::Generation(version.version.next()); let new_version = DnsVersion { - dns_group: update.dns_zone.dns_group, + dns_group: update.dns_group, version: new_version_num, time_created: chrono::Utc::now(), creator: update.creator, comment: update.comment, }; + let dns_zone_ids: Vec<_> = zones.iter().map(|z| z.id).collect(); let new_names = update .names_added .into_iter() - .map(|(name, records)| { - DnsName::new( - update.dns_zone.id, - name, - new_version_num, - None, - records, - ) + .flat_map(|(name, records)| { + dns_zone_ids.iter().map(move |dns_zone_id| { + DnsName::new( + *dns_zone_id, + name.clone(), + new_version_num, + None, + records.clone(), + ) + }) }) .collect::, _>>()?; let ntoadd = new_names.len(); @@ -480,10 +513,10 @@ impl DataStore { // update, we would (temporarily) violate that constraint if we did // this in the other order. let to_remove = update.names_removed; - let ntoremove = to_remove.len(); + let ntoremove = to_remove.len() * dns_zone_ids.len(); let nremoved = diesel::update( dsl::dns_name - .filter(dsl::dns_zone_id.eq(update.dns_zone.id)) + .filter(dsl::dns_zone_id.eq_any(dns_zone_ids)) .filter(dsl::name.eq_any(to_remove)) .filter(dsl::version_removed.is_null()), ) @@ -527,7 +560,7 @@ impl DataStore { } } -/// Helper for changing the configuration of a DNS zone +/// Helper for changing the configuration of all the DNS zone in a DNS group /// /// A DNS zone's configuration consists of the DNS names in the zone and their /// associated DNS records. Any change to the zone configuration consists of a @@ -541,8 +574,13 @@ impl DataStore { /// these changes transactionally to the database. The changes are then /// propagated asynchronously to the DNS servers. No changes are made (to /// either the database or the DNS servers) while you modify this object. +/// +/// This object changes all of the zones associated with a particular DNS group +/// because the assumption right now is that they're equivalent. (In practice, +/// we should only ever have one zone in each group right now.) +#[derive(Clone)] pub struct DnsVersionUpdateBuilder { - dns_zone: DnsZone, + dns_group: DnsGroup, comment: String, creator: String, names_added: HashMap>, @@ -550,7 +588,7 @@ pub struct DnsVersionUpdateBuilder { } impl DnsVersionUpdateBuilder { - /// Begin describing a new change to the given DNS zone + /// Begin describing a new change to the given DNS group /// /// `comment` is a short text summary of why this change is being made, /// aimed at people looking through the change history while debugging. @@ -560,12 +598,12 @@ impl DnsVersionUpdateBuilder { /// "creator" describes the component making the change. This is generally /// the current Nexus instance's uuid. pub fn new( - dns_zone: DnsZone, + dns_group: DnsGroup, comment: String, creator: String, ) -> DnsVersionUpdateBuilder { DnsVersionUpdateBuilder { - dns_zone, + dns_group, comment, creator, names_added: HashMap::new(), @@ -1314,19 +1352,12 @@ mod test { #[test] fn test_dns_builder_basic() { - let dns_zone = DnsZone { - id: Uuid::new_v4(), - time_created: Utc::now(), - dns_group: DnsGroup::External, - zone_name: String::from("oxide.test"), - }; - let mut dns_update = DnsVersionUpdateBuilder::new( - dns_zone.clone(), + DnsGroup::External, String::from("basic test"), String::from("the basic test"), ); - assert_eq!(dns_update.dns_zone.id, dns_zone.id); + assert_eq!(dns_update.dns_group, DnsGroup::External); assert_eq!(dns_update.comment, "basic test"); assert_eq!(dns_update.creator, "the basic test"); assert!(dns_update.names_added.is_empty()); @@ -1390,7 +1421,7 @@ mod test { #[tokio::test] async fn test_dns_update() { - let logctx = dev::test_setup_log("test_dns_uniqueness"); + let logctx = dev::test_setup_log("test_dns_update"); let mut db = test_setup_database(&logctx.log).await; let (opctx, datastore) = datastore_test(&logctx, &db).await; let now = Utc::now(); @@ -1474,8 +1505,8 @@ mod test { { let mut update = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), - String::from("update 1: set up zone1"), + DnsGroup::External, + String::from("update 1: set up zone1/zone2"), String::from("the test suite"), ); update.add_name(String::from("n1"), records1.clone()).unwrap(); @@ -1491,36 +1522,6 @@ mod test { .await .unwrap(); assert_eq!(dns_config.generation, 2); - assert_eq!(dns_config.zones.len(), 1); - assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); - assert_eq!( - dns_config.zones[0].records, - HashMap::from([ - ("n1".to_string(), records1.clone()), - ("n2".to_string(), records2.clone()), - ]) - ); - - // We should be able to add the same names to a different zone with - // different values. - { - let mut update = DnsVersionUpdateBuilder::new( - dns_zone2.clone(), - String::from("update 2: set up zone2"), - String::from("the test suite"), - ); - update.add_name(String::from("n1"), records2.clone()).unwrap(); - update.add_name(String::from("n2"), records1.clone()).unwrap(); - - let conn = datastore.pool_for_tests().await.unwrap(); - datastore.dns_update(&opctx, conn, update).await.unwrap(); - } - - let dns_config = datastore - .dns_config_read(&opctx, DnsGroup::External) - .await - .unwrap(); - assert_eq!(dns_config.generation, 3); assert_eq!(dns_config.zones.len(), 2); assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); assert_eq!( @@ -1531,20 +1532,14 @@ mod test { ]) ); assert_eq!(dns_config.zones[1].zone_name, "oxide2.test"); - assert_eq!( - dns_config.zones[1].records, - HashMap::from([ - ("n1".to_string(), records2.clone()), - ("n2".to_string(), records1.clone()), - ]) - ); + assert_eq!(dns_config.zones[0].records, dns_config.zones[1].records,); - // Now change "n1" in zone1 by removing it and adding it again. - // This should work. "n1" in zone2 should be unaffected. + // Now change "n1" in the group by removing it and adding it again. + // This should work and affect both zones. { let mut update = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), - String::from("update 3: change n1 in zone1 only"), + DnsGroup::External, + String::from("update 2: change n1 in zone1/zone2"), String::from("the test suite"), ); update.remove_name(String::from("n1")).unwrap(); @@ -1558,7 +1553,7 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config.generation, 4); + assert_eq!(dns_config.generation, 3); assert_eq!(dns_config.zones.len(), 2); assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); assert_eq!( @@ -1569,19 +1564,13 @@ mod test { ]) ); assert_eq!(dns_config.zones[1].zone_name, "oxide2.test"); - assert_eq!( - dns_config.zones[1].records, - HashMap::from([ - ("n1".to_string(), records2.clone()), - ("n2".to_string(), records1.clone()), - ]) - ); + assert_eq!(dns_config.zones[0].records, dns_config.zones[1].records,); - // Now just remove "n1" in zone1 altogether. + // Now just remove "n1" in this group altogether. { let mut update = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), - String::from("update 4: remove n1 in zone1"), + DnsGroup::External, + String::from("update 3: remove n1 in zone1/zone2"), String::from("the test suite"), ); update.remove_name(String::from("n1")).unwrap(); @@ -1594,7 +1583,7 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config.generation, 5); + assert_eq!(dns_config.generation, 4); assert_eq!(dns_config.zones.len(), 2); assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); assert_eq!( @@ -1602,19 +1591,13 @@ mod test { HashMap::from([("n2".to_string(), records2.clone()),]) ); assert_eq!(dns_config.zones[1].zone_name, "oxide2.test"); - assert_eq!( - dns_config.zones[1].records, - HashMap::from([ - ("n1".to_string(), records2.clone()), - ("n2".to_string(), records1.clone()), - ]) - ); + assert_eq!(dns_config.zones[0].records, dns_config.zones[1].records,); // Now add "n1" back -- again. { let mut update = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), - String::from("update 5: add n1 in zone1"), + DnsGroup::External, + String::from("update 4: add n1 in zone1/zone2"), String::from("the test suite"), ); update.add_name(String::from("n1"), records2.clone()).unwrap(); @@ -1627,7 +1610,7 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config.generation, 6); + assert_eq!(dns_config.generation, 5); assert_eq!(dns_config.zones.len(), 2); assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); assert_eq!( @@ -1638,19 +1621,13 @@ mod test { ]) ); assert_eq!(dns_config.zones[1].zone_name, "oxide2.test"); - assert_eq!( - dns_config.zones[1].records, - HashMap::from([ - ("n1".to_string(), records2.clone()), - ("n2".to_string(), records1.clone()), - ]) - ); + assert_eq!(dns_config.zones[0].records, dns_config.zones[1].records,); // Now, try concurrent updates to different DNS groups. Both should // succeed. { let mut update1 = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), + DnsGroup::External, String::from("update: concurrent part 1"), String::from("the test suite"), ); @@ -1687,7 +1664,7 @@ mod test { // Now start another transaction that updates the same DNS group and // have it complete before the first one does. let mut update2 = DnsVersionUpdateBuilder::new( - dns_zone3.clone(), + DnsGroup::Internal, String::from("update: concurrent part 2"), String::from("the test suite"), ); @@ -1705,7 +1682,7 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config.generation, 7); + assert_eq!(dns_config.generation, 6); assert_eq!(dns_config.zones.len(), 2); assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); assert_eq!( @@ -1713,13 +1690,7 @@ mod test { HashMap::from([("n2".to_string(), records2.clone()),]) ); assert_eq!(dns_config.zones[1].zone_name, "oxide2.test"); - assert_eq!( - dns_config.zones[1].records, - HashMap::from([ - ("n1".to_string(), records2.clone()), - ("n2".to_string(), records1.clone()), - ]) - ); + assert_eq!(dns_config.zones[0].records, dns_config.zones[1].records,); let dns_config = datastore .dns_config_read(&opctx, DnsGroup::Internal) .await @@ -1735,7 +1706,7 @@ mod test { // Failure case: cannot remove a name that didn't exist. { let mut update = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), + DnsGroup::External, String::from("bad update: remove non-existent name"), String::from("the test suite"), ); @@ -1747,7 +1718,7 @@ mod test { assert_eq!( error.to_string(), "Internal Error: updated wrong number of dns_name \ - records: expected 1, actually marked 0 for removal" + records: expected 2, actually marked 0 for removal" ); } @@ -1755,12 +1726,12 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config.generation, 7); + assert_eq!(dns_config.generation, 6); // Failure case: cannot add a name that already exists. { let mut update = DnsVersionUpdateBuilder::new( - dns_zone1.clone(), + DnsGroup::External, String::from("bad update: remove non-existent name"), String::from("the test suite"), ); @@ -1778,7 +1749,7 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config.generation, 7); + assert_eq!(dns_config.generation, 6); assert_eq!(dns_config.zones.len(), 2); assert_eq!(dns_config.zones[0].zone_name, "oxide1.test"); assert_eq!( @@ -1786,13 +1757,7 @@ mod test { HashMap::from([("n2".to_string(), records2.clone()),]) ); assert_eq!(dns_config.zones[1].zone_name, "oxide2.test"); - assert_eq!( - dns_config.zones[1].records, - HashMap::from([ - ("n1".to_string(), records2.clone()), - ("n2".to_string(), records1.clone()), - ]) - ); + assert_eq!(dns_config.zones[0].records, dns_config.zones[1].records,); db.cleanup().await.unwrap(); logctx.cleanup_successful(); diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index b2029587891..e8947226241 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -276,10 +276,10 @@ mod test { use crate::db::lookup::LookupPath; use crate::db::model::{ BlockSize, ComponentUpdate, ComponentUpdateIdentity, ConsoleSession, - Dataset, DatasetKind, DnsGroup, ExternalIp, InitialDnsGroup, - PhysicalDisk, PhysicalDiskKind, Project, Rack, Region, Service, - ServiceKind, SiloUser, Sled, SledBaseboard, SledSystemHardware, SshKey, - SystemUpdate, UpdateableComponentType, VpcSubnet, Zpool, + Dataset, DatasetKind, ExternalIp, PhysicalDisk, PhysicalDiskKind, + Project, Rack, Region, Service, ServiceKind, SiloUser, Sled, + SledBaseboard, SledSystemHardware, SshKey, SystemUpdate, + UpdateableComponentType, VpcSubnet, Zpool, }; use crate::db::queries::vpc_subnet::FilterConflictingVpcSubnetRangesQuery; use assert_matches::assert_matches; @@ -291,7 +291,6 @@ mod test { self, ByteCount, Error, IdentityMetadataCreateParams, LookupType, Name, }; use omicron_test_utils::dev; - use std::collections::HashMap; use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV6}; use std::num::NonZeroU32; @@ -1225,35 +1224,11 @@ mod test { assert_eq!(result.id(), rack.id()); assert_eq!(result.initialized, false); - let internal_dns = InitialDnsGroup::new( - DnsGroup::Internal, - internal_dns::DNS_ZONE, - "test suite", - "test suite", - HashMap::new(), - ); - - let external_dns = InitialDnsGroup::new( - DnsGroup::External, - "testing.oxide.example", - "test suite", - "test suite", - HashMap::new(), - ); - // Initialize the Rack. let result = datastore .rack_set_initialized( &opctx, - RackInit { - rack_id: rack.id(), - services: vec![], - datasets: vec![], - service_ip_pool_ranges: vec![], - certificates: vec![], - internal_dns: internal_dns.clone(), - external_dns: external_dns.clone(), - }, + RackInit { rack_id: rack.id(), ..Default::default() }, ) .await .unwrap(); @@ -1263,15 +1238,7 @@ mod test { let result = datastore .rack_set_initialized( &opctx, - RackInit { - rack_id: rack.id(), - services: vec![], - datasets: vec![], - service_ip_pool_ranges: vec![], - certificates: vec![], - internal_dns, - external_dns, - }, + RackInit { rack_id: rack.id(), ..Default::default() }, ) .await .unwrap(); diff --git a/nexus/db-queries/src/db/datastore/rack.rs b/nexus/db-queries/src/db/datastore/rack.rs index 0d408da53c0..a486de817e6 100644 --- a/nexus/db-queries/src/db/datastore/rack.rs +++ b/nexus/db-queries/src/db/datastore/rack.rs @@ -4,9 +4,12 @@ //! [`DataStore`] methods on [`Rack`]s. +use super::dns::DnsVersionUpdateBuilder; use super::DataStore; use super::SERVICE_IP_POOL_NAME; use crate::authz; +use crate::authz::FleetRole; +use crate::authz::SiloRole; use crate::context::OpContext; use crate::db; use crate::db::collection_insert::AsyncInsertError; @@ -33,6 +36,12 @@ use diesel::prelude::*; use diesel::upsert::excluded; use nexus_db_model::ExternalIp; use nexus_db_model::InitialDnsGroup; +use nexus_db_model::PasswordHashString; +use nexus_db_model::SiloUser; +use nexus_db_model::SiloUserPasswordHash; +use nexus_types::external_api::params as external_params; +use nexus_types::external_api::shared; +use nexus_types::external_api::shared::IdentityType; use nexus_types::external_api::shared::IpRange; use nexus_types::identity::Resource; use nexus_types::internal_api::params as internal_params; @@ -46,6 +55,7 @@ use std::net::IpAddr; use uuid::Uuid; /// Groups arguments related to rack initialization +#[derive(Clone)] pub struct RackInit { pub rack_id: Uuid, pub services: Vec, @@ -54,6 +64,10 @@ pub struct RackInit { pub certificates: Vec, pub internal_dns: InitialDnsGroup, pub external_dns: InitialDnsGroup, + pub recovery_silo: external_params::SiloCreate, + pub recovery_user_id: external_params::UserId, + pub recovery_user_password_hash: nexus_passwords::PasswordHashString, + pub dns_update: DnsVersionUpdateBuilder, } impl DataStore { @@ -126,6 +140,8 @@ impl DataStore { DatasetInsert { err: AsyncInsertError, zpool_id: Uuid }, RackUpdate(PoolError), DnsSerialization(Error), + Silo(Error), + RoleAssignment(Error), } type TxnError = TransactionError; @@ -278,6 +294,8 @@ impl DataStore { } info!(log, "Inserted certificates"); + // Insert the initial contents of the internal and external DNS + // zones. Self::load_dns_data(&conn, internal_dns) .await .map_err(RackInitError::DnsSerialization) @@ -290,6 +308,117 @@ impl DataStore { .map_err(TxnError::CustomError)?; info!(log, "Populated DNS tables for external DNS"); + // Create the initial Recovery Silo + let db_silo = self.silo_create_conn( + &conn, + opctx, + opctx, + rack_init.recovery_silo, + rack_init.dns_update + ) + .await + .map_err(RackInitError::Silo) + .map_err(TxnError::CustomError)?; + info!(log, "Created recovery silo"); + + // Create the first user in the initial Recovery Silo + let silo_user_id = Uuid::new_v4(); + let silo_user = SiloUser::new( + db_silo.id(), + silo_user_id, + rack_init.recovery_user_id.as_ref().to_owned(), + ); + { + use db::schema::silo_user::dsl; + diesel::insert_into(dsl::silo_user) + .values(silo_user) + .execute_async(&conn) + .await?; + } + info!(log, "Created recovery user"); + + // Set that user's password. + let hash = SiloUserPasswordHash::new( + silo_user_id, + PasswordHashString::from( + rack_init.recovery_user_password_hash + ) + ); + { + use db::schema::silo_user_password_hash::dsl; + diesel::insert_into(dsl::silo_user_password_hash) + .values(hash) + .execute_async(&conn) + .await?; + } + info!(log, "Created recovery user's password"); + + // Grant that user "Fleet Admin" privileges and Admin privileges + // on the Recovery Silo. + // + // First, fetch the current set of role assignments for the + // Fleet so that we can modify it. + let old_fleet_role_asgns = self + .role_assignment_fetch_visible_conn( + opctx, + &authz::FLEET, + &conn + ) + .await + .map_err(RackInitError::RoleAssignment) + .map_err(TxnError::CustomError)? + .into_iter() + .map(|r| r.try_into()) + .collect::, _>>() + .map_err(RackInitError::RoleAssignment) + .map_err(TxnError::CustomError)?; + let new_fleet_role_asgns = old_fleet_role_asgns + .into_iter() + .chain(std::iter::once(shared::RoleAssignment { + identity_type: IdentityType::SiloUser, + identity_id: silo_user_id, + role_name: FleetRole::Admin, + })) + .collect::>(); + + // This is very subtle: we must generate both of these pairs of + // queries before we execute any of them, and we must not + // attempt to do any authz checks after this in the same + // transaction because they may deadlock with our query. + let (q1, q2) = Self::role_assignment_replace_visible_queries( + opctx, + &authz::FLEET, + &new_fleet_role_asgns + ) + .await + .map_err(RackInitError::RoleAssignment) + .map_err(TxnError::CustomError)?; + let authz_silo = authz::Silo::new( + authz::FLEET, + db_silo.id(), + LookupType::ById(db_silo.id()) + ); + let (q3, q4) = Self::role_assignment_replace_visible_queries( + opctx, + &authz_silo, + &[shared::RoleAssignment { + identity_type: IdentityType::SiloUser, + identity_id: silo_user_id, + role_name: SiloRole::Admin, + }] + ) + .await + .map_err(RackInitError::RoleAssignment) + .map_err(TxnError::CustomError)?; + debug!(log, "Generated role assignment queries"); + + q1.execute_async(&conn).await?; + q2.execute_async(&conn).await?; + info!(log, "Granted Fleet privileges"); + q3.execute_async(&conn).await?; + q4.execute_async(&conn).await?; + info!(log, "Granted Silo privileges"); + let rack = diesel::update(rack_dsl::rack) .filter(rack_dsl::id.eq(rack_id)) .set(( @@ -358,6 +487,16 @@ impl DataStore { "failed to serialize initial DNS records: {:#}", err )) }, + TxnError::CustomError(RackInitError::Silo(err)) => { + Error::internal_error(&format!( + "failed to create recovery Silo: {:#}", err + )) + }, + TxnError::CustomError(RackInitError::RoleAssignment(err)) => { + Error::internal_error(&format!( + "failed to assign role to initial user: {:#}", err + )) + }, TxnError::Pool(e) => { Error::internal_error(&format!("Transaction error: {}", e)) } @@ -449,6 +588,7 @@ mod test { use crate::db::datastore::test::{ sled_baseboard_for_test, sled_system_hardware_for_test, }; + use crate::db::lookup::LookupPath; use crate::db::model::ExternalIp; use crate::db::model::IpKind; use crate::db::model::IpPoolRange; @@ -457,61 +597,79 @@ mod test { use internal_params::DnsRecord; use nexus_db_model::{DnsGroup, InitialDnsGroup}; use nexus_test_utils::db::test_setup_database; + use nexus_types::external_api::shared::SiloIdentityMode; use nexus_types::identity::Asset; + use omicron_common::api::external::http_pagination::PaginatedBy; + use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_test_utils::dev; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddrV6}; + use std::num::NonZeroU32; + + // Default impl is for tests only, and really just so that tests can more + // easily specify just the parts that they want. + impl Default for RackInit { + fn default() -> Self { + RackInit { + rack_id: Uuid::parse_str(nexus_test_utils::RACK_UUID).unwrap(), + services: vec![], + datasets: vec![], + service_ip_pool_ranges: vec![], + certificates: vec![], + internal_dns: InitialDnsGroup::new( + DnsGroup::Internal, + internal_dns::DNS_ZONE, + "test suite", + "test suite", + HashMap::new(), + ), + external_dns: InitialDnsGroup::new( + DnsGroup::External, + internal_dns::DNS_ZONE, + "test suite", + "test suite", + HashMap::new(), + ), + recovery_silo: external_params::SiloCreate { + identity: IdentityMetadataCreateParams { + name: "test-silo".parse().unwrap(), + description: String::new(), + }, + discoverable: false, + identity_mode: SiloIdentityMode::LocalOnly, + admin_group_name: None, + }, + recovery_user_id: "test-user".parse().unwrap(), + // empty string password + recovery_user_password_hash: "$argon2id$v=19$m=98304,t=13,\ + p=1$d2t2UHhOdWt3NkYyY1l3cA$pIvmXrcTk/\ + nsUzWvBQIeuMJk96ijye/oIXHCj15xg+M" + .parse() + .unwrap(), + dns_update: DnsVersionUpdateBuilder::new( + DnsGroup::External, + "test suite".to_string(), + "test suite".to_string(), + ), + } + } + } fn rack_id() -> Uuid { Uuid::parse_str(nexus_test_utils::RACK_UUID).unwrap() } - fn internal_dns_empty() -> InitialDnsGroup { - InitialDnsGroup::new( - DnsGroup::Internal, - internal_dns::DNS_ZONE, - "test suite", - "test suite", - HashMap::new(), - ) - } - - fn external_dns_empty() -> InitialDnsGroup { - InitialDnsGroup::new( - DnsGroup::External, - "testing.oxide.example", - "test suite", - "test suite", - HashMap::new(), - ) - } - #[tokio::test] async fn rack_set_initialized_empty() { let logctx = dev::test_setup_log("rack_set_initialized_empty"); let mut db = test_setup_database(&logctx.log).await; let (opctx, datastore) = datastore_test(&logctx, &db).await; let before = Utc::now(); - - let services = vec![]; - let datasets = vec![]; - let service_ip_pool_ranges = vec![]; - let certificates = vec![]; + let rack_init = RackInit::default(); // Initializing the rack with no data is odd, but allowed. let rack = datastore - .rack_set_initialized( - &opctx, - RackInit { - rack_id: rack_id(), - services: services.clone(), - datasets: datasets.clone(), - service_ip_pool_ranges: service_ip_pool_ranges.clone(), - certificates: certificates.clone(), - internal_dns: internal_dns_empty(), - external_dns: external_dns_empty(), - }, - ) + .rack_set_initialized(&opctx, rack_init.clone()) .await .expect("Failed to initialize rack"); @@ -519,6 +677,7 @@ mod test { assert_eq!(rack.id(), rack_id()); assert!(rack.initialized); + // Verify the DNS configuration. let dns_internal = datastore .dns_config_read(&opctx, DnsGroup::Internal) .await @@ -532,24 +691,69 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_internal.generation, dns_external.generation); + // The external DNS zone has an extra update due to the initial Silo + // creation. + assert_eq!(dns_internal.generation + 1, dns_external.generation); assert_eq!(dns_internal.zones, dns_external.zones); - // It should also be idempotent. - let rack2 = datastore - .rack_set_initialized( + // Verify the details about the initial Silo. + let silos = datastore + .silos_list( &opctx, - RackInit { - rack_id: rack_id(), - services, - datasets, - service_ip_pool_ranges, - certificates, - internal_dns: internal_dns_empty(), - external_dns: external_dns_empty(), + &PaginatedBy::Name(DataPageParams { + marker: None, + limit: NonZeroU32::new(2).unwrap(), + direction: dropshot::PaginationOrder::Ascending, + }), + ) + .await + .expect("Failed to list Silos"); + // It should *not* show up in the list because it's not discoverable. + assert_eq!(silos.len(), 0); + let (authz_silo, db_silo) = LookupPath::new(&opctx, &datastore) + .silo_name(&nexus_db_model::Name( + rack_init.recovery_silo.identity.name.clone(), + )) + .fetch() + .await + .expect("Failed to lookup Silo"); + assert!(!db_silo.discoverable); + + // Verify that the user exists and has the password (hash) that we + // expect. + let silo_users = datastore + .silo_users_list( + &opctx, + &authz::SiloUserList::new(authz_silo.clone()), + &DataPageParams { + marker: None, + limit: NonZeroU32::new(2).unwrap(), + direction: dropshot::PaginationOrder::Ascending, }, ) .await + .expect("failed to list users"); + assert_eq!(silo_users.len(), 1); + assert_eq!( + silo_users[0].external_id, + rack_init.recovery_user_id.as_ref() + ); + let authz_silo_user = authz::SiloUser::new( + authz_silo, + silo_users[0].id(), + LookupType::ById(silo_users[0].id()), + ); + let hash = datastore + .silo_user_password_hash_fetch(&opctx, &authz_silo_user) + .await + .expect("Failed to lookup password hash") + .expect("Found no password hash"); + assert_eq!(hash.hash.0, rack_init.recovery_user_password_hash); + + // It should also be idempotent. + let rack2 = datastore + .rack_set_initialized(&opctx, rack_init) + .await .expect("Failed to initialize rack"); assert_eq!(rack.time_modified(), rack2.time_modified()); @@ -635,21 +839,15 @@ mod test { external_address: nexus_ip, }, }]; - let datasets = vec![]; let service_ip_pool_ranges = vec![IpRange::from(nexus_ip)]; - let certificates = vec![]; let rack = datastore .rack_set_initialized( &opctx, RackInit { - rack_id: rack_id(), services: services.clone(), - datasets: datasets.clone(), service_ip_pool_ranges, - certificates: certificates.clone(), - internal_dns: internal_dns_empty(), - external_dns: external_dns_empty(), + ..Default::default() }, ) .await @@ -750,7 +948,6 @@ mod test { let service_ip_pool_ranges = vec![IpRange::try_from((nexus_ip_start, nexus_ip_end)) .expect("Cannot create IP Range")]; - let certificates = vec![]; let internal_records = vec![ DnsRecord::Aaaa("fe80::1:2:3:4".parse().unwrap()), @@ -778,13 +975,12 @@ mod test { .rack_set_initialized( &opctx, RackInit { - rack_id: rack_id(), services: services.clone(), datasets: datasets.clone(), service_ip_pool_ranges, - certificates: certificates.clone(), internal_dns, external_dns, + ..Default::default() }, ) .await @@ -885,15 +1081,15 @@ mod test { .dns_config_read(&opctx, DnsGroup::External) .await .unwrap(); - assert_eq!(dns_config_external.generation, 1); + assert_eq!(dns_config_external.generation, 2); assert_eq!(dns_config_external.zones.len(), 1); assert_eq!( dns_config_external.zones[0].zone_name, "test-suite.oxide.test", ); assert_eq!( - dns_config_external.zones[0].records, - HashMap::from([("api.sys".to_string(), external_records)]), + dns_config_external.zones[0].records.get("api.sys"), + Some(&external_records) ); db.cleanup().await.unwrap(); @@ -921,22 +1117,11 @@ mod test { external_address: nexus_ip, }, }]; - let datasets = vec![]; - let service_ip_pool_ranges = vec![]; - let certificates = vec![]; let result = datastore .rack_set_initialized( &opctx, - RackInit { - rack_id: rack_id(), - services: services.clone(), - datasets: datasets.clone(), - service_ip_pool_ranges, - certificates: certificates.clone(), - internal_dns: internal_dns_empty(), - external_dns: external_dns_empty(), - }, + RackInit { services: services.clone(), ..Default::default() }, ) .await; assert!(result.is_err()); @@ -989,9 +1174,7 @@ mod test { }, }, ]; - let datasets = vec![]; let service_ip_pool_ranges = vec![IpRange::from(nexus_ip)]; - let certificates = vec![]; let result = datastore .rack_set_initialized( @@ -999,11 +1182,8 @@ mod test { RackInit { rack_id: rack_id(), services: services.clone(), - datasets: datasets.clone(), service_ip_pool_ranges, - certificates: certificates.clone(), - internal_dns: internal_dns_empty(), - external_dns: external_dns_empty(), + ..Default::default() }, ) .await; diff --git a/nexus/db-queries/src/db/datastore/role.rs b/nexus/db-queries/src/db/datastore/role.rs index 106e0f0fe4e..ba217ff3505 100644 --- a/nexus/db-queries/src/db/datastore/role.rs +++ b/nexus/db-queries/src/db/datastore/role.rs @@ -21,8 +21,10 @@ use crate::db::model::IdentityType; use crate::db::model::RoleAssignment; use crate::db::model::RoleBuiltin; use crate::db::pagination::paginated_multicolumn; +use crate::db::pool::DbConnection; use async_bb8_diesel::AsyncConnection; use async_bb8_diesel::AsyncRunQueryDsl; +use async_bb8_diesel::PoolError; use diesel::prelude::*; use nexus_types::external_api::shared; use omicron_common::api::external::DataPageParams; @@ -194,6 +196,28 @@ impl DataStore { opctx: &OpContext, authz_resource: &T, ) -> ListResultVec { + self.role_assignment_fetch_visible_conn( + opctx, + authz_resource, + self.pool_authorized(opctx).await?, + ) + .await + } + + pub async fn role_assignment_fetch_visible_conn< + T: authz::ApiResourceWithRoles + AuthorizedResource + Clone, + ConnErr, + >( + &self, + opctx: &OpContext, + authz_resource: &T, + conn: &(impl async_bb8_diesel::AsyncConnection + + Sync), + ) -> ListResultVec + where + ConnErr: From + Send + 'static, + PoolError: From, + { opctx.authorize(authz::Action::ReadPolicy, authz_resource).await?; let resource_type = authz_resource.resource_type(); let resource_id = authz_resource.resource_id(); @@ -205,9 +229,11 @@ impl DataStore { .order(dsl::role_name.asc()) .then_order_by(dsl::identity_id.asc()) .select(RoleAssignment::as_select()) - .load_async::(self.pool_authorized(opctx).await?) + .load_async::(conn) .await - .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + .map_err(|e| { + public_error_from_diesel_pool(e.into(), ErrorHandler::Server) + }) } /// Removes all existing externally-visble role assignments on diff --git a/nexus/db-queries/src/db/datastore/silo.rs b/nexus/db-queries/src/db/datastore/silo.rs index a39a1c151dd..f22e3002ec0 100644 --- a/nexus/db-queries/src/db/datastore/silo.rs +++ b/nexus/db-queries/src/db/datastore/silo.rs @@ -21,6 +21,7 @@ use crate::db::model::Name; use crate::db::model::Silo; use crate::db::model::VirtualProvisioningCollection; use crate::db::pagination::paginated; +use crate::db::pool::DbConnection; use async_bb8_diesel::AsyncConnection; use async_bb8_diesel::AsyncRunQueryDsl; use async_bb8_diesel::PoolError; @@ -35,6 +36,7 @@ use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupType; +use omicron_common::api::external::ResourceType; use ref_cast::RefCast; use uuid::Uuid; @@ -84,13 +86,59 @@ impl DataStore { .returning(Silo::as_returning())) } + /// Create a Silo + /// + /// This function accepts two different OpContexts. Different authz checks + /// are used for different OpContexts: + /// + /// * `nexus_opctx`: used to authorize operations that are part of Silo + /// creation that we expect end users (even Fleet Administrators) may not + /// have. This includes creating a group within the new Silo and reading/ + /// modifying the fleet-wide external DNS configuration. This OpContext + /// generally represents an internal Nexus identity operating _on behalf_ + /// of the user in `opctx` to do these things _in this specific + /// (controlled) context_. + /// + /// * `opctx`: used for everything else, where we actually want to check + /// whether the end user creating this Silo should be allowed to do so pub async fn silo_create( &self, opctx: &OpContext, - group_opctx: &OpContext, + nexus_opctx: &OpContext, new_silo_params: params::SiloCreate, dns_update: DnsVersionUpdateBuilder, ) -> CreateResult { + let conn = self.pool_authorized(opctx).await?; + self.silo_create_conn( + conn, + opctx, + nexus_opctx, + new_silo_params, + dns_update, + ) + .await + } + + pub async fn silo_create_conn( + &self, + conn: &(impl async_bb8_diesel::AsyncConnection + + Sync), + opctx: &OpContext, + nexus_opctx: &OpContext, + new_silo_params: params::SiloCreate, + dns_update: DnsVersionUpdateBuilder, + ) -> CreateResult + where + ConnErr: From + Send + 'static, + PoolError: From, + TransactionError: From, + + CalleeConnErr: From + Send + 'static, + PoolError: From, + TransactionError: From, + async_bb8_diesel::Connection: + AsyncConnection, + { let silo_id = Uuid::new_v4(); let silo_group_id = Uuid::new_v4(); @@ -108,7 +156,7 @@ impl DataStore { { let silo_admin_group_ensure_query = DataStore::silo_group_ensure_query( - &group_opctx, + &nexus_opctx, &authz_silo, db::model::SiloGroup::new( silo_group_id, @@ -147,41 +195,49 @@ impl DataStore { None }; - self.pool_authorized(opctx) - .await? - .transaction_async(|conn| async move { - let silo = silo_create_query.get_result_async(&conn).await?; - self.virtual_provisioning_collection_create_on_connection( - &conn, - VirtualProvisioningCollection::new( - silo.id(), - CollectionTypeProvisioned::Silo, - ), - ) - .await?; - - if let Some(query) = silo_admin_group_ensure_query { - query.get_result_async(&conn).await?; - } - - if let Some(queries) = silo_admin_group_role_assignment_queries - { - let (delete_old_query, insert_new_query) = queries; - delete_old_query.execute_async(&conn).await?; - insert_new_query.execute_async(&conn).await?; - } - - self.dns_update(group_opctx, &conn, dns_update).await?; - - Ok(silo) - }) - .await - .map_err(|e| match e { - TransactionError::CustomError(e) => e, - TransactionError::Pool(e) => { - public_error_from_diesel_pool(e, ErrorHandler::Server) - } - }) + conn.transaction_async(|conn| async move { + let silo = silo_create_query + .get_result_async(&conn) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e.into(), + ErrorHandler::Conflict( + ResourceType::Silo, + new_silo_params.identity.name.as_str(), + ), + ) + })?; + self.virtual_provisioning_collection_create_on_connection( + &conn, + VirtualProvisioningCollection::new( + silo.id(), + CollectionTypeProvisioned::Silo, + ), + ) + .await?; + + if let Some(query) = silo_admin_group_ensure_query { + query.get_result_async(&conn).await?; + } + + if let Some(queries) = silo_admin_group_role_assignment_queries { + let (delete_old_query, insert_new_query) = queries; + delete_old_query.execute_async(&conn).await?; + insert_new_query.execute_async(&conn).await?; + } + + self.dns_update(nexus_opctx, &conn, dns_update).await?; + + Ok(silo) + }) + .await + .map_err(|e| match e { + TransactionError::CustomError(e) => e, + TransactionError::Pool(e) => { + public_error_from_diesel_pool(e, ErrorHandler::Server) + } + }) } pub async fn silos_list_by_id( diff --git a/nexus/passwords/src/lib.rs b/nexus/passwords/src/lib.rs index 75404c5f4fa..b2e6f854ed5 100644 --- a/nexus/passwords/src/lib.rs +++ b/nexus/passwords/src/lib.rs @@ -161,6 +161,77 @@ impl Hasher { } } +/// Parses the given PHC-format password hash string and returns it only if it +/// meets some basic requirements (which match the way we generate password +/// hashes). +pub fn parse_phc_hash(s: &str) -> Result { + let hash = PasswordHashString::new(s) + .map_err(|e| format!("password hash: {}", e))?; + verify_strength(&hash)?; + Ok(hash) +} + +fn verify_strength(hash: &PasswordHashString) -> Result<(), String> { + if hash.algorithm() != ARGON2_ALGORITHM.ident() { + return Err(format!( + "password hash: algorithm: expected {}, found {}", + ARGON2_ALGORITHM, + hash.algorithm() + )); + } + + match hash.salt() { + None => return Err("password hash: expected salt".to_string()), + Some(s) if s.len() < argon2::RECOMMENDED_SALT_LEN => { + return Err(format!( + "password hash: salt: expected at least {} bytes", + argon2::RECOMMENDED_SALT_LEN + )); + } + _ => (), + }; + + match hash.hash() { + None => return Err("password hash: expected hash".to_string()), + Some(s) if s.len() < argon2::Params::DEFAULT_OUTPUT_LEN => { + return Err(format!( + "password hash: output: expected at least {} bytes", + argon2::Params::DEFAULT_OUTPUT_LEN + )); + } + _ => (), + }; + + let params = argon2::Params::try_from(&hash.password_hash()) + .map_err(|e| format!("password hash: argon2 parameters: {}", e))?; + if params.m_cost() < ARGON2_COST_M_KIB { + return Err(format!( + "password hash: parameter 'm': expected at least {} (KiB), \ + found {}", + ARGON2_COST_M_KIB, + params.m_cost() + )); + } + + if params.t_cost() < ARGON2_COST_T { + return Err(format!( + "password hash: parameter 't': expected at least {}, found {}", + ARGON2_COST_T, + params.t_cost() + )); + } + + if params.p_cost() < ARGON2_COST_P { + return Err(format!( + "password hash: parameter 'p': expected at least {}, found {}", + ARGON2_COST_P, + params.p_cost() + )); + } + + Ok(()) +} + #[cfg(test)] mod test { use super::external_password_argon; @@ -171,6 +242,8 @@ mod test { use super::ARGON2_COST_P; use super::ARGON2_COST_T; use super::MAX_PASSWORD_LENGTH; + use crate::parse_phc_hash; + use crate::verify_strength; use crate::MIN_EXPECTED_PASSWORD_VERIFY_TIME; use argon2::password_hash::PasswordHashString; use argon2::password_hash::SaltString; @@ -210,6 +283,9 @@ mod test { println!("hashed: {}", hash_str); println!("structured hash: {:?}", hash); + // Verify that the generated hash matches our own requirements. + verify_strength(&hash_str).unwrap(); + // Verify that salt strings are at least as long as we think they are // (16 bytes). assert!(SaltString::generate(rand::thread_rng()).len() >= 16); @@ -275,6 +351,7 @@ mod test { assert_ne!(hash_str, hash_str2); assert!(hasher.verify_password(&password, &hash_str2).unwrap()); assert!(!hasher.verify_password(&bad_password, &hash_str2).unwrap()); + verify_strength(&hash_str2).unwrap(); // If we create a new hasher and hash the same password, we should also // get a different string. It should behave the same way. @@ -284,6 +361,7 @@ mod test { assert_ne!(hash_str2, hash_str3); assert!(hasher.verify_password(&password, &hash_str2).unwrap()); assert!(!hasher.verify_password(&bad_password, &hash_str2).unwrap()); + verify_strength(&hash_str3).unwrap(); } #[test] @@ -298,11 +376,13 @@ mod test { Hasher::new(external_password_argon(), known_rng.clone()); hasher.create_password(&password).unwrap() }; + verify_strength(&hash1).unwrap(); let hash2 = { let mut hasher = Hasher::new(external_password_argon(), known_rng); hasher.create_password(&password).unwrap() }; assert_eq!(hash1, hash2); + verify_strength(&hash2).unwrap(); } // Verifies that known password hashes continue to verify as we expect. @@ -370,6 +450,7 @@ mod test { // parameters are because that's encoded in the hash string. let password = Password::new(PASSWORD_STR).unwrap(); let password_hash_str = hasher.create_password(&password).unwrap(); + verify_strength(&password_hash_str).unwrap(); assert!(argon2alt::verify_encoded( password_hash_str.as_ref(), PASSWORD_STR.as_bytes() @@ -416,4 +497,74 @@ mod test { .unwrap(); assert_eq!(alt_hash, password_hash_str.to_string()); } + + #[test] + fn test_weak_hashes() { + assert_eq!( + parse_phc_hash("dummy").unwrap_err(), + "password hash: password hash string missing field" + ); + // This input was generated from argon2.online using the empty string as + // input. + let _ = parse_phc_hash( + "$argon2id$v=19$m=98304,t=13,p=1$MDEyMzQ1Njc4OTAxMjM0NQ\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I", + ) + .unwrap(); + + // The following inputs were constructed by taking the valid hash above + // and adjusting the string by hand. + assert_eq!( + parse_phc_hash( + "$argon2i$v=19$m=98304,t=13,p=1$MDEyMzQ1Njc4OTAxMjM0NQ\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I" + ) + .unwrap_err(), + "password hash: algorithm: expected argon2id, found argon2i" + ); + assert_eq!( + parse_phc_hash( + "$argon2id$v=19$m=98304,t=13,p=1$\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I" + ) + .unwrap_err(), + // sic + "password hash: salt invalid: value to short", + ); + assert_eq!( + parse_phc_hash( + "$argon2id$v=19$m=98304,t=13,p=1$MDEyMzQ1Njc\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I" + ) + .unwrap_err(), + "password hash: salt: expected at least 16 bytes", + ); + assert_eq!( + parse_phc_hash( + "$argon2id$v=19$m=4096,t=13,p=1$MDEyMzQ1Njc4OTAxMjM0NQ\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I" + ) + .unwrap_err(), + "password hash: parameter 'm': expected at least 98304 (KiB), \ + found 4096" + ); + assert_eq!( + parse_phc_hash( + "$argon2id$v=19$m=98304,t=12,p=1$MDEyMzQ1Njc4OTAxMjM0NQ\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I" + ) + .unwrap_err(), + "password hash: parameter 't': expected at least 13, found 12" + ); + assert_eq!( + parse_phc_hash( + "$argon2id$v=19$m=98304,t=13,p=0$MDEyMzQ1Njc4OTAxMjM0NQ\ + $tFRlFMnzazQduuAkXOEi6k9g88nwBbUV8rJI0PjT8/I" + ) + .unwrap_err(), + // sic + "password hash: argon2 parameters: invalid parameter value: \ + value to short" + ); + } } diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 2a5def14835..18d2f34e852 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -13,7 +13,11 @@ use crate::internal_api::params::RackInitializationRequest; use nexus_db_model::DnsGroup; use nexus_db_model::InitialDnsGroup; use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::datastore::DnsVersionUpdateBuilder; use nexus_db_queries::db::datastore::RackInit; +use nexus_types::external_api::params::SiloCreate; +use nexus_types::external_api::shared::SiloIdentityMode; +use nexus_types::internal_api::params::DnsRecord; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadataCreateParams; @@ -21,6 +25,7 @@ use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::Name; use std::collections::HashMap; +use std::net::IpAddr; use uuid::Uuid; impl super::Nexus { @@ -138,6 +143,37 @@ impl super::Nexus { HashMap::new(), ); + let silo_name = &request.recovery_silo.silo_name; + let dns_records = request + .services + .iter() + .filter_map(|s| match &s.kind { + nexus_types::internal_api::params::ServiceKind::Nexus { + external_address, + } => Some(match external_address { + IpAddr::V4(addr) => DnsRecord::A(*addr), + IpAddr::V6(addr) => DnsRecord::Aaaa(*addr), + }), + _ => None, + }) + .collect(); + let mut dns_update = DnsVersionUpdateBuilder::new( + DnsGroup::External, + format!("create silo: {:?}", silo_name), + self.id.to_string(), + ); + dns_update.add_name(Self::silo_dns_name(silo_name), dns_records)?; + + let recovery_silo = SiloCreate { + identity: IdentityMetadataCreateParams { + name: request.recovery_silo.silo_name, + description: "built-in recovery Silo".to_string(), + }, + discoverable: false, + identity_mode: SiloIdentityMode::LocalOnly, + admin_group_name: None, + }; + self.db_datastore .rack_set_initialized( opctx, @@ -149,6 +185,13 @@ impl super::Nexus { certificates, internal_dns, external_dns, + recovery_silo, + recovery_user_id: request.recovery_silo.user_name, + recovery_user_password_hash: request + .recovery_silo + .user_password_hash + .into(), + dns_update, }, ) .await?; diff --git a/nexus/src/app/silo.rs b/nexus/src/app/silo.rs index df6dd1bdb47..6ffed2c99ce 100644 --- a/nexus/src/app/silo.rs +++ b/nexus/src/app/silo.rs @@ -14,7 +14,7 @@ use crate::external_api::params; use crate::external_api::shared; use crate::{authn, authz}; use anyhow::Context; -use nexus_db_model::UserProvisionType; +use nexus_db_model::{DnsGroup, UserProvisionType}; use nexus_db_queries::context::OpContext; use nexus_db_queries::db::datastore::DnsVersionUpdateBuilder; use nexus_types::internal_api::params::DnsRecord; @@ -68,7 +68,9 @@ impl super::Nexus { /// _within_ the control plane DNS zone (i.e., without that zone's suffix) /// /// This specific naming scheme is determined under RFD 357. - fn silo_dns_name(name: &omicron_common::api::external::Name) -> String { + pub(crate) fn silo_dns_name( + name: &omicron_common::api::external::Name, + ) -> String { // RFD 4 constrains resource names (including Silo names) to DNS-safe // strings, which is why it's safe to directly put the name of the // resource into the DNS name rather than doing any kind of escaping. @@ -80,17 +82,17 @@ impl super::Nexus { opctx: &OpContext, new_silo_params: params::SiloCreate, ) -> CreateResult { - // Silo group creation happens as Nexus's "external authn" context, - // not the user's context here. The user may not have permission to - // create arbitrary groups in the Silo, but we allow them to create - // this one in this case. - let external_authn_opctx = self.opctx_external_authn(); + // Silo creation involves several operations that ordinary users cannot + // generally do, like reading and modifying the fleet-wide external DNS + // config. Nexus assumes its own identity to do these operations in + // this (very specific) context. + let nexus_opctx = self.opctx_external_authn(); let datastore = self.datastore(); // Set up an external DNS name for this Silo's API and console // endpoints (which are the same endpoint). let dns_records: Vec = datastore - .nexus_external_addresses(external_authn_opctx) + .nexus_external_addresses(nexus_opctx) .await? .into_iter() .map(|addr| match addr { @@ -101,19 +103,14 @@ impl super::Nexus { let silo_name = &new_silo_params.identity.name; let mut dns_update = DnsVersionUpdateBuilder::new( - datastore.dns_zone_external(external_authn_opctx).await?, + DnsGroup::External, format!("create silo: {:?}", silo_name), self.id.to_string(), ); dns_update.add_name(Self::silo_dns_name(silo_name), dns_records)?; let silo = datastore - .silo_create( - &opctx, - &external_authn_opctx, - new_silo_params, - dns_update, - ) + .silo_create(&opctx, &nexus_opctx, new_silo_params, dns_update) .await?; self.background_tasks .activate(&self.background_tasks.task_external_dns_config); @@ -138,7 +135,7 @@ impl super::Nexus { let (.., authz_silo, db_silo) = silo_lookup.fetch_for(authz::Action::Delete).await?; let mut dns_update = DnsVersionUpdateBuilder::new( - datastore.dns_zone_external(opctx).await?, + DnsGroup::External, format!("delete silo: {:?}", db_silo.name()), self.id.to_string(), ); diff --git a/nexus/src/lib.rs b/nexus/src/lib.rs index 3252734e8b6..b348f10e573 100644 --- a/nexus/src/lib.rs +++ b/nexus/src/lib.rs @@ -208,6 +208,7 @@ impl nexus_test_interface::NexusServer for Server { config: &Config, services: Vec, external_dns_zone_name: &str, + recovery_silo: nexus_types::internal_api::params::RecoverySiloConfig, ) -> Self { // Perform the "handoff from RSS". // @@ -253,6 +254,7 @@ impl nexus_test_interface::NexusServer for Server { certs: vec![], internal_dns_zone_config: DnsConfigBuilder::new().build(), external_dns_zone_name: external_dns_zone_name.to_owned(), + recovery_silo, }, ) .await diff --git a/nexus/test-interface/src/lib.rs b/nexus/test-interface/src/lib.rs index 6a727eeb9b8..9019f1d9ce9 100644 --- a/nexus/test-interface/src/lib.rs +++ b/nexus/test-interface/src/lib.rs @@ -51,6 +51,7 @@ pub trait NexusServer { config: &Config, services: Vec, external_dns_zone_name: &str, + recovery_silo: nexus_types::internal_api::params::RecoverySiloConfig, ) -> Self; async fn get_http_server_external_address(&self) -> Option; diff --git a/nexus/test-utils/Cargo.toml b/nexus/test-utils/Cargo.toml index 72059c6ba17..184013cd96a 100644 --- a/nexus/test-utils/Cargo.toml +++ b/nexus/test-utils/Cargo.toml @@ -16,6 +16,7 @@ http.workspace = true hyper.workspace = true internal-dns.workspace = true nexus-db-queries.workspace = true +nexus-passwords.workspace = true nexus-test-interface.workspace = true nexus-types.workspace = true omicron-common.workspace = true diff --git a/nexus/test-utils/src/lib.rs b/nexus/test-utils/src/lib.rs index 590d10e5049..0033d1b080b 100644 --- a/nexus/test-utils/src/lib.rs +++ b/nexus/test-utils/src/lib.rs @@ -11,9 +11,11 @@ use dropshot::ConfigDropshot; use dropshot::ConfigLogging; use dropshot::ConfigLoggingLevel; use nexus_test_interface::NexusServer; +use nexus_types::external_api::params::UserId; +use nexus_types::internal_api::params::RecoverySiloConfig; use nexus_types::internal_api::params::ServiceKind; use nexus_types::internal_api::params::ServicePutRequest; -use omicron_common::api::external::IdentityMetadata; +use omicron_common::api::external::{IdentityMetadata, Name}; use omicron_common::api::internal::nexus::ProducerEndpoint; use omicron_common::nexus_config; use omicron_sled_agent::sim; @@ -47,6 +49,13 @@ pub const TEST_HARDWARE_THREADS: u32 = 16; /// The reported amount of physical RAM for an emulated sled agent. pub const TEST_PHYSICAL_RAM: u64 = 32 * (1 << 30); +/// Password for the user created by the test suite +/// +/// This is only used by the test suite and `omicron-dev run-all` (the latter of +/// which uses the test suite setup code for most of its operation). These are +/// both transient deployments with no sensitive data. +pub const TEST_SUITE_PASSWORD: &str = "oxide"; + pub struct ControlPlaneTestContext { pub external_client: ClientTestContext, pub internal_client: ClientTestContext, @@ -64,6 +73,8 @@ pub struct ControlPlaneTestContext { pub external_dns_config_server: dropshot::HttpServer, pub external_dns_resolver: trust_dns_resolver::TokioAsyncResolver, + pub silo_name: Name, + pub user_name: UserId, } impl ControlPlaneTestContext { @@ -278,6 +289,19 @@ pub async fn test_setup_with_config( }; let external_dns_zone_name = internal_dns::names::DNS_ZONE_EXTERNAL_TESTING.to_string(); + let silo_name: Name = "test-suite-silo".parse().unwrap(); + let user_name = UserId::try_from("test-privileged".to_string()).unwrap(); + let user_password_hash = nexus_passwords::Hasher::default() + .create_password( + &nexus_passwords::Password::new(TEST_SUITE_PASSWORD).unwrap(), + ) + .unwrap() + .into(); + let recovery_silo = RecoverySiloConfig { + silo_name: silo_name.clone(), + user_name: user_name.clone(), + user_password_hash, + }; let server = N::start( nexus_internal, @@ -289,6 +313,7 @@ pub async fn test_setup_with_config( nexus_service, ], &external_dns_zone_name, + recovery_silo, ) .await; @@ -350,6 +375,8 @@ pub async fn test_setup_with_config( external_dns_server, external_dns_config_server, external_dns_resolver, + silo_name, + user_name, } } diff --git a/nexus/tests/integration_tests/rack.rs b/nexus/tests/integration_tests/rack.rs index 36ac1faa4b0..7b4df53c7e1 100644 --- a/nexus/tests/integration_tests/rack.rs +++ b/nexus/tests/integration_tests/rack.rs @@ -2,9 +2,14 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +use http::Method; +use http::StatusCode; use nexus_test_utils::http_testing::AuthnMode; use nexus_test_utils::http_testing::NexusRequest; +use nexus_test_utils::http_testing::RequestBuilder; +use nexus_test_utils::TEST_SUITE_PASSWORD; use nexus_test_utils_macros::nexus_test; +use omicron_nexus::external_api::params; use omicron_nexus::external_api::views::Rack; use omicron_nexus::TestInterfaces; @@ -42,3 +47,33 @@ async fn test_get_own_rack(cptestctx: &ControlPlaneTestContext) { assert_eq!(expected_id, rack.identity.id); } + +#[nexus_test] +async fn test_rack_initialization(cptestctx: &ControlPlaneTestContext) { + // The ControlPlaneTestContext has already done rack initialization. Here + // we can verify some of the higher-level consequences. + let client = &cptestctx.external_client; + + // Verify that the initial user can log in with the expected password. + // This password is (implicitly) determined by the password hash that we + // provide when setting up the rack (when setting up the + // ControlPlaneTestContext). We use the status code to verify a successful + // login. + let login_url = format!("/login/{}/local", cptestctx.silo_name); + let username = cptestctx.user_name.clone(); + let password: params::Password = TEST_SUITE_PASSWORD.parse().unwrap(); + let _ = RequestBuilder::new(&client, Method::POST, &login_url) + .body(Some(¶ms::UsernamePasswordCredentials { username, password })) + .expect_status(Some(StatusCode::SEE_OTHER)) + .execute() + .await + .expect("failed to log in"); + + // Verify the external DNS record for the initial Silo. + crate::integration_tests::silos::verify_silo_dns_name( + cptestctx, + cptestctx.silo_name.as_str(), + true, + ) + .await; +} diff --git a/nexus/tests/integration_tests/silos.rs b/nexus/tests/integration_tests/silos.rs index 9c901a2fac7..9d3d6433bd8 100644 --- a/nexus/tests/integration_tests/silos.rs +++ b/nexus/tests/integration_tests/silos.rs @@ -35,7 +35,6 @@ use base64::Engine; use http::method::Method; use http::StatusCode; use httptest::{matchers::*, responders::*, Expectation, Server}; -use internal_dns::names::DNS_ZONE_EXTERNAL_TESTING; use std::convert::Infallible; use std::net::Ipv4Addr; use std::time::Duration; @@ -50,6 +49,32 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) { let client = &cptestctx.external_client; let nexus = &cptestctx.server.apictx().nexus; + // Verify that we cannot create a name with the same name as the recovery + // Silo that was created during rack initialization. + let error: dropshot::HttpErrorResponseBody = + NexusRequest::expect_failure_with_body( + client, + StatusCode::BAD_REQUEST, + Method::POST, + "/v1/system/silos", + ¶ms::SiloCreate { + identity: IdentityMetadataCreateParams { + name: cptestctx.silo_name.clone(), + description: "a silo".to_string(), + }, + discoverable: false, + identity_mode: shared::SiloIdentityMode::LocalOnly, + admin_group_name: None, + }, + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap() + .parsed_body() + .unwrap(); + assert_eq!(error.message, "already exists: silo \"test-suite-silo\""); + // Create two silos: one discoverable, one not create_silo( &client, @@ -2099,7 +2124,7 @@ async fn run_user_tests( assert_eq!(last_users, existing_users); } -async fn verify_silo_dns_name( +pub async fn verify_silo_dns_name( cptestctx: &ControlPlaneTestContext, silo_name: &str, should_exist: bool, @@ -2107,7 +2132,8 @@ async fn verify_silo_dns_name( // The DNS naming scheme for Silo DNS names is just: // $silo_name.sys.$delegated_name // This is determined by RFD 357 and also implemented in Nexus. - let dns_name = format!("{}.sys.{}", silo_name, DNS_ZONE_EXTERNAL_TESTING); + let dns_name = + format!("{}.sys.{}", silo_name, cptestctx.external_dns_zone_name); // We assume that in the test suite, Nexus's "external" address is // localhost. diff --git a/nexus/types/Cargo.toml b/nexus/types/Cargo.toml index cbf982f4064..846658656a0 100644 --- a/nexus/types/Cargo.toml +++ b/nexus/types/Cargo.toml @@ -8,7 +8,6 @@ license = "MPL-2.0" anyhow.workspace = true chrono.workspace = true base64.workspace = true -dns-service-client.workspace = true futures.workspace = true newtype_derive.workspace = true openssl.workspace = true @@ -21,5 +20,6 @@ steno.workspace = true uuid.workspace = true api_identity.workspace = true +dns-service-client.workspace = true nexus-passwords.workspace = true omicron-common.workspace = true diff --git a/nexus/types/src/internal_api/params.rs b/nexus/types/src/internal_api/params.rs index 01358c35024..ce86de17131 100644 --- a/nexus/types/src/internal_api/params.rs +++ b/nexus/types/src/internal_api/params.rs @@ -4,8 +4,10 @@ //! Params define the request bodies of API endpoints for creating or updating resources. +use crate::external_api::params::UserId; use crate::external_api::shared::IpRange; use omicron_common::api::external::ByteCount; +use omicron_common::api::external::Name; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::fmt; @@ -228,7 +230,7 @@ impl std::fmt::Debug for Certificate { } } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct RackInitializationRequest { /// Services on the rack which have been created by RSS. pub services: Vec, @@ -243,6 +245,8 @@ pub struct RackInitializationRequest { pub internal_dns_zone_config: dns_service_client::types::DnsConfigParams, /// delegated DNS name for external DNS pub external_dns_zone_name: String, + /// configuration for the initial (recovery) Silo + pub recovery_silo: RecoverySiloConfig, } pub type DnsConfigParams = dns_service_client::types::DnsConfigParams; @@ -250,6 +254,63 @@ pub type DnsConfigZone = dns_service_client::types::DnsConfigZone; pub type DnsRecord = dns_service_client::types::DnsRecord; pub type Srv = dns_service_client::types::Srv; +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct RecoverySiloConfig { + pub silo_name: Name, + pub user_name: UserId, + pub user_password_hash: PasswordHash, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(try_from = "String")] +pub struct PasswordHash(nexus_passwords::PasswordHashString); + +impl From for PasswordHash { + fn from(value: nexus_passwords::PasswordHashString) -> Self { + PasswordHash(value) + } +} + +impl From for nexus_passwords::PasswordHashString { + fn from(value: PasswordHash) -> Self { + value.0 + } +} + +impl JsonSchema for PasswordHash { + fn schema_name() -> String { + "PasswordHash".to_string() + } + + fn json_schema( + _: &mut schemars::gen::SchemaGenerator, + ) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + metadata: Some(Box::new(schemars::schema::Metadata { + title: Some("A password hash in PHC string format".to_string()), + description: Some( + "Password hashes must be in PHC (Password Hashing \ + Competition) string format. Passwords must be hashed \ + with Argon2id. Password hashes may be rejected if the \ + parameters appear not to be secure enough." + .to_string(), + ), + ..Default::default() + })), + instance_type: Some(schemars::schema::InstanceType::String.into()), + ..Default::default() + } + .into() + } +} + +impl TryFrom for PasswordHash { + type Error = String; + fn try_from(value: String) -> Result { + Ok(PasswordHash(nexus_passwords::parse_phc_hash(&value)?)) + } +} + /// Message used to notify Nexus that this oximeter instance is up and running. #[derive(Debug, Clone, Copy, JsonSchema, Serialize, Deserialize)] pub struct OximeterInfo { diff --git a/openapi/bootstrap-agent.json b/openapi/bootstrap-agent.json index 0a32b80df68..997be3e1921 100644 --- a/openapi/bootstrap-agent.json +++ b/openapi/bootstrap-agent.json @@ -246,6 +246,14 @@ "last" ] }, + "Name": { + "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 '-'. Names cannot be a UUID though they may contain a UUID.", + "type": "string" + }, + "PasswordHash": { + "description": "Password hashes must be in PHC (Password Hashing Competition) string format. Passwords must be hashed with Argon2id. Password hashes may be rejected if the parameters appear not to be secure enough.", + "type": "string" + }, "RackInitializeRequest": { "description": "Configuration for the \"rack setup service\".\n\nThe Rack Setup Service should be responsible for one-time setup actions, such as CockroachDB placement and initialization. Without operator intervention, however, these actions need a way to be automated in our deployment.", "type": "object", @@ -265,6 +273,10 @@ "type": "string" } }, + "external_dns_zone_name": { + "description": "DNS name for the DNS zone delegated to the rack for external DNS", + "type": "string" + }, "internal_services_ip_pool_ranges": { "description": "Ranges of the service IP pool which may be used for internal services.", "type": "array", @@ -288,20 +300,53 @@ "rack_subnet": { "type": "string", "format": "ipv6" + }, + "recovery_silo": { + "description": "Configuration of the Recovery Silo (the initial Silo)", + "allOf": [ + { + "$ref": "#/components/schemas/RecoverySiloConfig" + } + ] } }, "required": [ "bootstrap_discovery", "dns_servers", + "external_dns_zone_name", "internal_services_ip_pool_ranges", "ntp_servers", "rack_secret_threshold", - "rack_subnet" + "rack_subnet", + "recovery_silo" + ] + }, + "RecoverySiloConfig": { + "type": "object", + "properties": { + "silo_name": { + "$ref": "#/components/schemas/Name" + }, + "user_name": { + "$ref": "#/components/schemas/UserId" + }, + "user_password_hash": { + "$ref": "#/components/schemas/PasswordHash" + } + }, + "required": [ + "silo_name", + "user_name", + "user_password_hash" ] }, "SemverVersion": { "type": "string", "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "UserId": { + "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 '-'. Names cannot be a UUID though they may contain a UUID.", + "type": "string" } } } diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index 13d43af51b2..79d54135cf0 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -2110,6 +2110,14 @@ } ] }, + "Name": { + "title": "A name unique within the parent collection", + "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 '-'. Names cannot be a UUID though they may contain a UUID.", + "type": "string", + "pattern": "^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z][a-z0-9-]*[a-zA-Z0-9]*$", + "minLength": 1, + "maxLength": 63 + }, "NodeName": { "description": "Unique name for a saga [`Node`]\n\nEach node requires a string name that's unique within its DAG. The name is used to identify its output. Nodes that depend on a given node (either directly or indirectly) can access the node's output using its name.", "type": "string" @@ -2133,6 +2141,11 @@ "collector_id" ] }, + "PasswordHash": { + "title": "A password hash in PHC string format", + "description": "Password hashes must be in PHC (Password Hashing Competition) string format. Passwords must be hashed with Argon2id. Password hashes may be rejected if the parameters appear not to be secure enough.", + "type": "string" + }, "PhysicalDiskDeleteRequest": { "type": "object", "properties": { @@ -2325,6 +2338,14 @@ "$ref": "#/components/schemas/IpRange" } }, + "recovery_silo": { + "description": "configuration for the initial (recovery) Silo", + "allOf": [ + { + "$ref": "#/components/schemas/RecoverySiloConfig" + } + ] + }, "services": { "description": "Services on the rack which have been created by RSS.", "type": "array", @@ -2339,9 +2360,29 @@ "external_dns_zone_name", "internal_dns_zone_config", "internal_services_ip_pool_ranges", + "recovery_silo", "services" ] }, + "RecoverySiloConfig": { + "type": "object", + "properties": { + "silo_name": { + "$ref": "#/components/schemas/Name" + }, + "user_name": { + "$ref": "#/components/schemas/UserId" + }, + "user_password_hash": { + "$ref": "#/components/schemas/PasswordHash" + } + }, + "required": [ + "silo_name", + "user_name", + "user_password_hash" + ] + }, "Saga": { "description": "Sagas\n\nThese are currently only intended for observability by developers. We will eventually want to flesh this out into something more observable for end users.", "type": "object", @@ -2842,6 +2883,14 @@ "weight" ] }, + "UserId": { + "title": "A name unique within the parent collection", + "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 '-'. Names cannot be a UUID though they may contain a UUID.", + "type": "string", + "pattern": "^(?![0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$)^[a-z][a-z0-9-]*[a-zA-Z0-9]*$", + "minLength": 1, + "maxLength": 63 + }, "ZpoolPutRequest": { "description": "Sent by a sled agent on startup to Nexus to request further instruction", "type": "object", diff --git a/sled-agent/src/bootstrap/params.rs b/sled-agent/src/bootstrap/params.rs index b9185eef320..7b28d4bef7f 100644 --- a/sled-agent/src/bootstrap/params.rs +++ b/sled-agent/src/bootstrap/params.rs @@ -51,8 +51,16 @@ pub struct RackInitializeRequest { // TODO(https://github.com/oxidecomputer/omicron/issues/1530): Eventually, // we want to configure multiple pools. pub internal_services_ip_pool_ranges: Vec, + + /// DNS name for the DNS zone delegated to the rack for external DNS + pub external_dns_zone_name: String, + + /// Configuration of the Recovery Silo (the initial Silo) + pub recovery_silo: RecoverySiloConfig, } +pub type RecoverySiloConfig = nexus_client::types::RecoverySiloConfig; + /// Configuration information for launching a Sled Agent. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct SledAgentRequest { @@ -167,13 +175,15 @@ mod tests { let path = manifest.join("../smf/sled-agent/non-gimlet/config-rss.toml"); - let contents = std::fs::read_to_string(path).unwrap(); - let _: RackInitializeRequest = toml::from_str(&contents).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + let _: RackInitializeRequest = toml::from_str(&contents) + .unwrap_or_else(|e| panic!("failed to parse {:?}: {}", &path, e)); let path = manifest .join("../smf/sled-agent/gimlet-standalone/config-rss.toml"); - let contents = std::fs::read_to_string(path).unwrap(); - let _: RackInitializeRequest = toml::from_str(&contents).unwrap(); + let contents = std::fs::read_to_string(&path).unwrap(); + let _: RackInitializeRequest = toml::from_str(&contents) + .unwrap_or_else(|e| panic!("failed to parse {:?}: {}", &path, e)); } #[test] diff --git a/sled-agent/src/rack_setup/config.rs b/sled-agent/src/rack_setup/config.rs index 7b880c32b54..c80568329a6 100644 --- a/sled-agent/src/rack_setup/config.rs +++ b/sled-agent/src/rack_setup/config.rs @@ -40,6 +40,7 @@ impl SetupServiceConfig { mod test { use super::*; use crate::bootstrap::params::BootstrapAddressDiscovery; + use crate::bootstrap::params::RecoverySiloConfig; use omicron_common::address::IpRange; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -51,9 +52,21 @@ mod test { rack_secret_threshold: 0, ntp_servers: vec![String::from("test.pool.example.com")], dns_servers: vec![String::from("1.1.1.1")], + external_dns_zone_name: String::from("oxide.test"), internal_services_ip_pool_ranges: vec![IpRange::from(IpAddr::V4( Ipv4Addr::new(129, 168, 1, 20), ))], + recovery_silo: RecoverySiloConfig { + silo_name: "test-silo".parse().unwrap(), + user_name: "dummy".parse().unwrap(), + // This is a hash for the password "oxide". It doesn't matter, + // though; it's not used. + user_password_hash: "$argon2id$v=19$m=98304,t=13,p=1$\ + RUlWc0ZxaHo0WFdrN0N6ZQ$S8p52j85GPvMhR/\ + ek3GL0el/oProgTwWpHJZ8lsQQoY" + .parse() + .unwrap(), + }, }; assert_eq!( diff --git a/sled-agent/src/rack_setup/service.rs b/sled-agent/src/rack_setup/service.rs index 0f769d9e257..9d711d90ea6 100644 --- a/sled-agent/src/rack_setup/service.rs +++ b/sled-agent/src/rack_setup/service.rs @@ -737,10 +737,8 @@ impl ServiceInner { // the need for unencrypted communication. certs: vec![], internal_dns_zone_config: d2n_params(&service_plan.dns_config), - // TODO This eventually needs to come from the person setting up the - // system. - external_dns_zone_name: - internal_dns::names::DNS_ZONE_EXTERNAL_TESTING.to_owned(), + external_dns_zone_name: config.external_dns_zone_name.clone(), + recovery_silo: config.recovery_silo.clone(), }; let notify_nexus = || async { diff --git a/sled-agent/src/sim/server.rs b/sled-agent/src/sim/server.rs index 0da2e5bc1c9..80c508d8567 100644 --- a/sled-agent/src/sim/server.rs +++ b/sled-agent/src/sim/server.rs @@ -300,6 +300,23 @@ impl Server { }); } + let recovery_silo = NexusTypes::RecoverySiloConfig { + silo_name: "demo-silo".parse().unwrap(), + user_name: "demo-privileged".parse().unwrap(), + // The following is a hash for the password "oxide". This is + // (obviously) only intended for transient deployments in + // development with no sensitive data or resources. You can change + // this value to any other supported hash. The only thing that + // needs to be changed with this hash are the instructions given to + // individuals running this program who then want to log in as this + // user. For more on what's supported, see the API docs for this + // type and the specific constraints in the nexus-passwords crate. + user_password_hash: "$argon2id$v=19$m=98304,t=13,p=1$\ + RUlWc0ZxaHo0WFdrN0N6ZQ$S8p52j85GPvMhR/ek3GL0el/oProgTwWpHJZ8lsQQoY" + .parse() + .unwrap(), + }; + let rack_init_request = NexusTypes::RackInitializationRequest { services, datasets, @@ -308,6 +325,7 @@ impl Server { internal_dns_zone_config: d2n_params(&dns_config), external_dns_zone_name: internal_dns::names::DNS_ZONE_EXTERNAL_TESTING.to_owned(), + recovery_silo, }; Ok(( diff --git a/smf/sled-agent/gimlet-standalone/config-rss.toml b/smf/sled-agent/gimlet-standalone/config-rss.toml index 95fdd4b07a4..459da305bc4 100644 --- a/smf/sled-agent/gimlet-standalone/config-rss.toml +++ b/smf/sled-agent/gimlet-standalone/config-rss.toml @@ -17,9 +17,26 @@ rack_secret_threshold = 1 ntp_servers = [ "ntp.eng.oxide.computer" ] dns_servers = [ "1.1.1.1", "9.9.9.9" ] +# Delegated external DNS zone name +external_dns_zone_name = "oxide.test" + # The IP ranges configured as part of the services IP Pool. # e.g., Nexus will be configured to use an address from this # pool as its external IP. [[internal_services_ip_pool_ranges]] first = "192.168.1.20" last = "192.168.1.22" + +# Configuration for the initial Silo, user, and password. +[recovery_silo] +silo_name = "recovery" +user_name = "recovery" +# The following is a hash for the password "oxide". This is (obviously) only +# intended for transient deployments in development with no sensitive data or +# resources. You can change this value to any other supported hash. The only +# things that need to be changed with this hash are (1) the instructions given +# to individuals running this program who then want to log in as this user, and +# (2) the end-to-end tests, which use this password to log in to a +# newly-initialized rack. For more on what's supported, see the API docs for +# this type and the specific constraints in the nexus-passwords crate. +user_password_hash = "$argon2id$v=19$m=98304,t=13,p=1$RUlWc0ZxaHo0WFdrN0N6ZQ$S8p52j85GPvMhR/ek3GL0el/oProgTwWpHJZ8lsQQoY" diff --git a/smf/sled-agent/non-gimlet/config-rss.toml b/smf/sled-agent/non-gimlet/config-rss.toml index 95fdd4b07a4..836fb2cbe58 100644 --- a/smf/sled-agent/non-gimlet/config-rss.toml +++ b/smf/sled-agent/non-gimlet/config-rss.toml @@ -17,9 +17,25 @@ rack_secret_threshold = 1 ntp_servers = [ "ntp.eng.oxide.computer" ] dns_servers = [ "1.1.1.1", "9.9.9.9" ] +# Delegated external DNS zone name +external_dns_zone_name = "oxide.test" + # The IP ranges configured as part of the services IP Pool. # e.g., Nexus will be configured to use an address from this # pool as its external IP. [[internal_services_ip_pool_ranges]] first = "192.168.1.20" last = "192.168.1.22" + +# Configuration for the initial Silo, user, and password. +[recovery_silo] +silo_name = "recovery" +user_name = "recovery" +# The following is a hash for the password "oxide". This is (obviously) only +# intended for transient deployments in development with no sensitive data or +# resources. You can change this value to any other supported hash. The only +# thing that needs to be changed with this hash are the instructions given to +# individuals running this program who then want to log in as this user. For +# more on what's supported, see the API docs for this type and the specific +# constraints in the nexus-passwords crate. +user_password_hash = "$argon2id$v=19$m=98304,t=13,p=1$RUlWc0ZxaHo0WFdrN0N6ZQ$S8p52j85GPvMhR/ek3GL0el/oProgTwWpHJZ8lsQQoY"