diff --git a/bootstore/src/schemes/v0/peer.rs b/bootstore/src/schemes/v0/peer.rs index efb916a61fa..1d676953ac2 100644 --- a/bootstore/src/schemes/v0/peer.rs +++ b/bootstore/src/schemes/v0/peer.rs @@ -52,8 +52,8 @@ pub enum NodeRequestError { Send, #[error( - "Network config update failed because it is out of date. Attempted - update generation: {attempted_update_generation}, current generation: + "Network config update failed because it is out of date. Attempted \ + update generation: {attempted_update_generation}, current generation: \ {current_generation}" )] StaleNetworkConfig { diff --git a/clients/bootstrap-agent-client/Cargo.toml b/clients/bootstrap-agent-client/Cargo.toml index 5cd2846225d..ce97789fb27 100644 --- a/clients/bootstrap-agent-client/Cargo.toml +++ b/clients/bootstrap-agent-client/Cargo.toml @@ -12,8 +12,8 @@ regress.workspace = true reqwest = { workspace = true, features = [ "json", "rustls-tls", "stream" ] } schemars.workspace = true serde.workspace = true +serde_json.workspace = true sled-hardware-types.workspace = true slog.workspace = true uuid.workspace = true omicron-workspace-hack.workspace = true -serde_json.workspace = true diff --git a/clients/bootstrap-agent-client/src/lib.rs b/clients/bootstrap-agent-client/src/lib.rs index 3d69fc474b5..be309cc3e2a 100644 --- a/clients/bootstrap-agent-client/src/lib.rs +++ b/clients/bootstrap-agent-client/src/lib.rs @@ -23,6 +23,11 @@ progenitor::generate_api!( Ipv4Network = ipnetwork::Ipv4Network, Ipv6Network = ipnetwork::Ipv6Network, IpNetwork = ipnetwork::IpNetwork, + IpNet = omicron_common::api::external::IpNet, + Ipv4Net = omicron_common::api::external::Ipv4Net, + Ipv6Net = omicron_common::api::external::Ipv6Net, + IpAllowList = omicron_common::api::external::IpAllowList, + AllowedSourceIps = omicron_common::api::external::AllowedSourceIps, } ); diff --git a/clients/nexus-client/src/lib.rs b/clients/nexus-client/src/lib.rs index 8408e64df89..92cc3ff27e7 100644 --- a/clients/nexus-client/src/lib.rs +++ b/clients/nexus-client/src/lib.rs @@ -420,3 +420,57 @@ impl TryFrom }) } } + +impl TryFrom<&omicron_common::api::external::Ipv4Net> for types::Ipv4Net { + type Error = String; + + fn try_from( + net: &omicron_common::api::external::Ipv4Net, + ) -> Result { + types::Ipv4Net::try_from(net.to_string()).map_err(|e| e.to_string()) + } +} + +impl TryFrom<&omicron_common::api::external::Ipv6Net> for types::Ipv6Net { + type Error = String; + + fn try_from( + net: &omicron_common::api::external::Ipv6Net, + ) -> Result { + types::Ipv6Net::try_from(net.to_string()).map_err(|e| e.to_string()) + } +} + +impl TryFrom<&omicron_common::api::external::IpNet> for types::IpNet { + type Error = String; + + fn try_from( + net: &omicron_common::api::external::IpNet, + ) -> Result { + use omicron_common::api::external::IpNet; + match net { + IpNet::V4(v4) => types::Ipv4Net::try_from(v4).map(types::IpNet::V4), + IpNet::V6(v6) => types::Ipv6Net::try_from(v6).map(types::IpNet::V6), + } + } +} + +impl TryFrom<&omicron_common::api::external::AllowedSourceIps> + for types::AllowedSourceIps +{ + type Error = String; + + fn try_from( + ips: &omicron_common::api::external::AllowedSourceIps, + ) -> Result { + use omicron_common::api::external::AllowedSourceIps; + match ips { + AllowedSourceIps::Any => Ok(types::AllowedSourceIps::Any), + AllowedSourceIps::List(list) => list + .iter() + .map(TryInto::try_into) + .collect::, _>>() + .map(types::AllowedSourceIps::List), + } + } +} diff --git a/clients/wicketd-client/src/lib.rs b/clients/wicketd-client/src/lib.rs index 27ef2ad2197..4248c3719f2 100644 --- a/clients/wicketd-client/src/lib.rs +++ b/clients/wicketd-client/src/lib.rs @@ -40,11 +40,13 @@ progenitor::generate_api!( RackOperationStatus = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, RackNetworkConfigV1 = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, UplinkConfig = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, - CurrentRssUserConfigSensitive = { derives = [ PartialEq, Eq, Serialize, Deserialize ] }, - CurrentRssUserConfig = { derives = [ PartialEq, Eq, Serialize, Deserialize ] }, + CurrentRssUserConfigInsensitive = { derives = [ PartialEq, Serialize, Deserialize ] }, + CurrentRssUserConfigSensitive = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, + CurrentRssUserConfig = { derives = [ PartialEq, Serialize, Deserialize ] }, GetLocationResponse = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, }, replace = { + AllowedSourceIps = omicron_common::api::internal::shared::AllowedSourceIps, Baseboard = sled_hardware_types::Baseboard, BgpAuthKey = wicket_common::rack_setup::BgpAuthKey, BgpAuthKeyId = wicket_common::rack_setup::BgpAuthKeyId, diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 4fb15c8e8eb..6981f10fa66 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -9,6 +9,7 @@ mod error; pub mod http_pagination; +pub use crate::api::internal::shared::AllowedSourceIps; pub use crate::api::internal::shared::SwitchLocation; use crate::update::ArtifactHash; use crate::update::ArtifactId; @@ -847,6 +848,7 @@ impl JsonSchema for Hostname { pub enum ResourceType { AddressLot, AddressLotBlock, + AllowList, BackgroundTask, BgpConfig, BgpAnnounceSet, @@ -1334,6 +1336,60 @@ impl From for Ipv6Net { } } +const IPV6_NET_REGEX: &str = concat!( + r#"^("#, + r#"([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|"#, + r#"([0-9a-fA-F]{1,4}:){1,7}:|"#, + r#"([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|"#, + r#"([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|"#, + r#"([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|"#, + r#"([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|"#, + r#"([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|"#, + r#"[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|"#, + r#":((:[0-9a-fA-F]{1,4}){1,7}|:)|"#, + r#"fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|"#, + r#"::(ffff(:0{1,4}){0,1}:){0,1}"#, + r#"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"#, + r#"(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|"#, + r#"([0-9a-fA-F]{1,4}:){1,4}:"#, + r#"((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}"#, + r#"(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])"#, + r#")\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$"#, +); + +#[cfg(test)] +#[test] +fn test_ipv6_regex() { + let re = regress::Regex::new(IPV6_NET_REGEX).unwrap(); + for case in [ + "1:2:3:4:5:6:7:8", + "1:a:2:b:3:c:4:d", + "1::", + "::1", + "::", + "1::3:4:5:6:7:8", + "1:2::4:5:6:7:8", + "1:2:3::5:6:7:8", + "1:2:3:4::6:7:8", + "1:2:3:4:5::7:8", + "1:2:3:4:5:6::8", + "1:2:3:4:5:6:7::", + "2001::", + "fd00::", + "::100:1", + "fd12:3456::", + ] { + for prefix in 0..=128 { + let net = format!("{case}/{prefix}"); + assert!( + re.find(&net).is_some(), + "Expected to match IPv6 case: {}", + prefix, + ); + } + } +} + impl JsonSchema for Ipv6Net { fn schema_name() -> String { "Ipv6Net".to_string() @@ -1354,18 +1410,7 @@ impl JsonSchema for Ipv6Net { })), instance_type: Some(schemars::schema::InstanceType::String.into()), string: Some(Box::new(schemars::schema::StringValidation { - pattern: Some( - // Conforming to unique local addressing scheme, - // `fd00::/8`. - concat!( - r#"^([fF][dD])[0-9a-fA-F]{2}:("#, - r#"([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}"#, - r#"|([0-9a-fA-F]{1,4}:){1,6}:)"#, - r#"([0-9a-fA-F]{1,4})?"#, - r#"\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$"#, - ) - .to_string(), - ), + pattern: Some(IPV6_NET_REGEX.to_string()), ..Default::default() })), ..Default::default() @@ -1431,6 +1476,18 @@ impl IpNet { } } } + + /// Return true if the provided address is contained in self. + /// + /// This returns false if the address and the network are of different IP + /// families. + pub fn contains(&self, addr: IpAddr) -> bool { + match (self, addr) { + (IpNet::V4(net), IpAddr::V4(ip)) => net.contains(ip), + (IpNet::V6(net), IpAddr::V6(ip)) => net.contains(ip), + (_, _) => false, + } + } } impl From for IpNet { diff --git a/common/src/api/internal/shared.rs b/common/src/api/internal/shared.rs index c45c6a51043..6bd40d3ff0a 100644 --- a/common/src/api/internal/shared.rs +++ b/common/src/api/internal/shared.rs @@ -6,7 +6,7 @@ use crate::{ address::NUM_SOURCE_NAT_PORTS, - api::external::{self, BfdMode, ImportExportPolicy, Name}, + api::external::{self, BfdMode, ImportExportPolicy, IpNet, Name}, }; use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network}; use schemars::JsonSchema; @@ -192,7 +192,7 @@ pub struct BgpConfig { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] pub struct BgpPeerConfig { - /// The autonomous sysetm number of the router the peer belongs to. + /// The autonomous system number of the router the peer belongs to. pub asn: u32, /// Switch port the peer is reachable on. pub port: String, @@ -507,3 +507,136 @@ impl fmt::Display for PortFec { } } } + +/// Description of source IPs allowed to reach rack services. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(rename_all = "snake_case", tag = "allow", content = "ips")] +pub enum AllowedSourceIps { + /// Allow traffic from any external IP address. + Any, + /// Restrict access to a specific set of source IP addresses or subnets. + /// + /// All others are prevented from reaching rack services. + List(IpAllowList), +} + +impl TryFrom> for AllowedSourceIps { + type Error = &'static str; + fn try_from(list: Vec) -> Result { + IpAllowList::try_from(list).map(Self::List) + } +} + +impl TryFrom<&[IpNetwork]> for AllowedSourceIps { + type Error = &'static str; + fn try_from(list: &[IpNetwork]) -> Result { + IpAllowList::try_from(list).map(Self::List) + } +} + +/// A non-empty allowlist of IP subnets. +#[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] +#[serde(try_from = "Vec", into = "Vec")] +#[schemars(transparent)] +pub struct IpAllowList(Vec); + +impl IpAllowList { + /// Return the entries of the list as a slice. + pub fn as_slice(&self) -> &[IpNet] { + &self.0 + } + + /// Return an iterator over the entries of the list. + pub fn iter(&self) -> impl Iterator { + self.0.iter() + } + + /// Consume the list into an iterator. + pub fn into_iter(self) -> impl Iterator { + self.0.into_iter() + } + + /// Return the number of entries in the allowlist. + /// + /// Note that this is always >= 1, though we return a usize for simplicity. + pub fn len(&self) -> usize { + self.0.len() + } +} + +impl From for Vec { + fn from(list: IpAllowList) -> Self { + list.0 + } +} + +impl TryFrom> for IpAllowList { + type Error = &'static str; + fn try_from(list: Vec) -> Result { + if list.is_empty() { + return Err("IP allowlist must not be empty"); + } + Ok(Self(list)) + } +} + +impl TryFrom<&[IpNetwork]> for IpAllowList { + type Error = &'static str; + fn try_from(list: &[IpNetwork]) -> Result { + if list.is_empty() { + return Err("IP allowlist must not be empty"); + } + Ok(Self(list.iter().copied().map(Into::into).collect())) + } +} + +#[cfg(test)] +mod tests { + use crate::api::{ + external::{IpNet, Ipv4Net, Ipv6Net}, + internal::shared::AllowedSourceIps, + }; + use ipnetwork::{Ipv4Network, Ipv6Network}; + use std::net::{Ipv4Addr, Ipv6Addr}; + + #[test] + fn test_deserialize_allowed_source_ips() { + let parsed: AllowedSourceIps = serde_json::from_str( + r#"{"allow":"list","ips":["127.0.0.1","10.0.0.0/24","fd00::1/64"]}"#, + ) + .unwrap(); + assert_eq!( + parsed, + AllowedSourceIps::try_from(vec![ + IpNet::from(Ipv4Addr::LOCALHOST), + IpNet::V4(Ipv4Net( + Ipv4Network::new(Ipv4Addr::new(10, 0, 0, 0), 24).unwrap() + )), + IpNet::V6(Ipv6Net( + Ipv6Network::new( + Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1), + 64 + ) + .unwrap() + )), + ]) + .unwrap() + ); + } + + #[test] + fn test_deserialize_unknown_string() { + serde_json::from_str::(r#"{"allow":"wat"}"#) + .expect_err( + "Should not be able to deserialize from unknown variant name", + ); + } + + #[test] + fn test_deserialize_any_into_allowed_external_ips() { + assert_eq!( + AllowedSourceIps::Any, + serde_json::from_str(r#"{"allow":"any"}"#).unwrap(), + ); + } +} diff --git a/dev-tools/omdb/src/bin/omdb/nexus.rs b/dev-tools/omdb/src/bin/omdb/nexus.rs index 8fc14828520..8383df34f36 100644 --- a/dev-tools/omdb/src/bin/omdb/nexus.rs +++ b/dev-tools/omdb/src/bin/omdb/nexus.rs @@ -889,6 +889,25 @@ fn print_task_details(bgtask: &BackgroundTask, details: &serde_json::Value) { ); } }; + } else if name == "service_firewall_rule_propagation" { + #[derive(Deserialize)] + struct TaskSuccess { + /// Elapsed duration of the propagation + elapsed: std::time::Duration, + } + + match serde_json::from_value::(details.clone()) { + Err(error) => eprintln!( + "warning: failed to interpret task details: {:?}: {:?}", + error, details + ), + Ok(success) => { + println!( + " successfully propagated rules in {:?}", + success.elapsed, + ); + } + } } else { println!( "warning: unknown background task: {:?} \ diff --git a/dev-tools/omdb/tests/env.out b/dev-tools/omdb/tests/env.out index c8605d38b26..e69626a74bf 100644 --- a/dev-tools/omdb/tests/env.out +++ b/dev-tools/omdb/tests/env.out @@ -97,6 +97,11 @@ task: "region_replacement" detects if a region requires replacing and begins the process +task: "service_firewall_rule_propagation" + propagates VPC firewall rules for Omicron services with external network + connectivity + + task: "service_zone_nat_tracker" ensures service zone nat records are recorded in NAT RPW table @@ -199,6 +204,11 @@ task: "region_replacement" detects if a region requires replacing and begins the process +task: "service_firewall_rule_propagation" + propagates VPC firewall rules for Omicron services with external network + connectivity + + task: "service_zone_nat_tracker" ensures service zone nat records are recorded in NAT RPW table @@ -288,6 +298,11 @@ task: "region_replacement" detects if a region requires replacing and begins the process +task: "service_firewall_rule_propagation" + propagates VPC firewall rules for Omicron services with external network + connectivity + + task: "service_zone_nat_tracker" ensures service zone nat records are recorded in NAT RPW table diff --git a/dev-tools/omdb/tests/successes.out b/dev-tools/omdb/tests/successes.out index 9dcf9ec61a3..52bcb56e50d 100644 --- a/dev-tools/omdb/tests/successes.out +++ b/dev-tools/omdb/tests/successes.out @@ -274,6 +274,11 @@ task: "region_replacement" detects if a region requires replacing and begins the process +task: "service_firewall_rule_propagation" + propagates VPC firewall rules for Omicron services with external network + connectivity + + task: "service_zone_nat_tracker" ensures service zone nat records are recorded in NAT RPW table @@ -430,6 +435,13 @@ task: "region_replacement" number of region replacements started ok: 0 number of region replacement start errors: 0 +task: "service_firewall_rule_propagation" + configured period: every 5m + currently executing: no + last completed activation: , triggered by an explicit signal + started at (s ago) and ran for ms + last completion reported error: Object (of type ById(.....................)) not found: allow-list + task: "service_zone_nat_tracker" configured period: every 30s currently executing: no diff --git a/nexus-config/src/nexus_config.rs b/nexus-config/src/nexus_config.rs index 540a3471505..32180a77d86 100644 --- a/nexus-config/src/nexus_config.rs +++ b/nexus-config/src/nexus_config.rs @@ -375,6 +375,8 @@ pub struct BackgroundTaskConfig { pub switch_port_settings_manager: SwitchPortSettingsManagerConfig, /// configuration for region replacement task pub region_replacement: RegionReplacementConfig, + /// configuration for service VPC firewall propagation task + pub service_firewall_propagation: ServiceFirewallPropagationConfig, } #[serde_as] @@ -519,6 +521,14 @@ pub struct RegionReplacementConfig { pub period_secs: Duration, } +#[serde_as] +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ServiceFirewallPropagationConfig { + /// period (in seconds) for periodic activations of this background task + #[serde_as(as = "DurationSeconds")] + pub period_secs: Duration, +} + /// Configuration for a nexus server #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct PackageConfig { @@ -755,6 +765,7 @@ mod test { sync_service_zone_nat.period_secs = 30 switch_port_settings_manager.period_secs = 30 region_replacement.period_secs = 30 + service_firewall_propagation.period_secs = 300 [default_region_allocation_strategy] type = "random" seed = 0 @@ -883,6 +894,10 @@ mod test { region_replacement: RegionReplacementConfig { period_secs: Duration::from_secs(30), }, + service_firewall_propagation: + ServiceFirewallPropagationConfig { + period_secs: Duration::from_secs(300), + } }, default_region_allocation_strategy: crate::nexus_config::RegionAllocationStrategy::Random { @@ -949,6 +964,7 @@ mod test { sync_service_zone_nat.period_secs = 30 switch_port_settings_manager.period_secs = 30 region_replacement.period_secs = 30 + service_firewall_propagation.period_secs = 300 [default_region_allocation_strategy] type = "random" "##, diff --git a/nexus/db-model/src/allow_list.rs b/nexus/db-model/src/allow_list.rs new file mode 100644 index 00000000000..5b6ca67ddff --- /dev/null +++ b/nexus/db-model/src/allow_list.rs @@ -0,0 +1,93 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// 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/5.0/. + +// Copyright 2024 Oxide Computer Company + +//! Database representation of allowed source IP address, for implementing basic +//! IP allowlisting. + +use crate::schema::allow_list; +use chrono::DateTime; +use chrono::Utc; +use ipnetwork::IpNetwork; +use nexus_types::external_api::params; +use nexus_types::external_api::views; +use omicron_common::api::external; +use omicron_common::api::external::Error; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +/// Database model for an allowlist of source IP addresses. +#[derive( + Queryable, Insertable, Selectable, Clone, Debug, Serialize, Deserialize, +)] +#[diesel(table_name = allow_list)] +pub struct AllowList { + pub id: Uuid, + pub time_created: DateTime, + pub time_modified: DateTime, + pub allowed_ips: Option>, +} + +impl AllowList { + /// Construct a new allowlist record. + pub fn new(id: Uuid, allowed_ips: external::AllowedSourceIps) -> Self { + let now = Utc::now(); + let allowed_ips = match allowed_ips { + external::AllowedSourceIps::Any => None, + external::AllowedSourceIps::List(list) => { + Some(list.into_iter().map(Into::into).collect()) + } + }; + Self { id, time_created: now, time_modified: now, allowed_ips } + } + + /// Create an `AllowedSourceIps` type from the contained address. + pub fn allowed_source_ips( + &self, + ) -> Result { + match &self.allowed_ips { + Some(list) => external::AllowedSourceIps::try_from(list.as_slice()) + .map_err(|_| { + Error::internal_error( + "Allowlist from database is empty, but NULL \ + should be used to allow any source IP", + ) + }), + None => Ok(external::AllowedSourceIps::Any), + } + } +} + +#[derive(AsChangeset)] +#[diesel(table_name = allow_list, treat_none_as_null = true)] +pub struct AllowListUpdate { + /// The new list of allowed IPs. + pub allowed_ips: Option>, +} + +impl From for AllowListUpdate { + fn from(params: params::AllowListUpdate) -> Self { + let allowed_ips = match params.allowed_ips { + external::AllowedSourceIps::Any => None, + external::AllowedSourceIps::List(list) => { + Some(list.into_iter().map(Into::into).collect()) + } + }; + Self { allowed_ips } + } +} + +impl TryFrom for views::AllowList { + type Error = Error; + + fn try_from(db: AllowList) -> Result { + db.allowed_source_ips().map(|allowed_ips| Self { + time_created: db.time_created, + time_modified: db.time_modified, + allowed_ips, + }) + } +} diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index 6495a0c9604..c7b495b0948 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -10,6 +10,7 @@ extern crate diesel; extern crate newtype_derive; mod address_lot; +mod allow_list; mod bfd; mod bgp; mod block_size; @@ -114,6 +115,7 @@ mod db { pub use self::macaddr::*; pub use self::unsigned::*; pub use address_lot::*; +pub use allow_list::*; pub use bfd::*; pub use bgp::*; pub use block_size::*; diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 07b2b81eedb..4117079880c 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -1669,6 +1669,15 @@ table! { } } +table! { + allow_list (id) { + id -> Uuid, + time_created -> Timestamptz, + time_modified -> Timestamptz, + allowed_ips -> Nullable>, + } +} + table! { db_metadata (singleton) { singleton -> Bool, diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index 6d6b863023d..326ae7ec11e 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(56, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(57, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -29,6 +29,7 @@ static KNOWN_VERSIONS: Lazy> = Lazy::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(57, "add-allowed-source-ips"), KnownVersion::new(56, "bgp-oxpop-features"), KnownVersion::new(55, "add-lookup-sled-by-policy-and-state-index"), KnownVersion::new(54, "blueprint-add-external-ip-id"), diff --git a/nexus/db-queries/src/db/datastore/allow_list.rs b/nexus/db-queries/src/db/datastore/allow_list.rs new file mode 100644 index 00000000000..672dc32614c --- /dev/null +++ b/nexus/db-queries/src/db/datastore/allow_list.rs @@ -0,0 +1,217 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// 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/. + +//! Datastore methods for operating on source IP allowlists. + +use crate::authz; +use crate::context::OpContext; +use crate::db::error::public_error_from_diesel; +use crate::db::error::ErrorHandler; +use crate::db::fixed_data::allow_list::USER_FACING_SERVICES_ALLOW_LIST_ID; +use crate::db::DbConnection; +use async_bb8_diesel::AsyncRunQueryDsl; +use diesel::ExpressionMethods; +use diesel::QueryDsl; +use diesel::SelectableHelper; +use nexus_db_model::schema::allow_list; +use nexus_db_model::AllowList; +use omicron_common::api::external::AllowedSourceIps; +use omicron_common::api::external::Error; +use omicron_common::api::external::LookupType; +use omicron_common::api::external::ResourceType; + +impl super::DataStore { + /// Fetch the list of allowed source IPs. + /// + /// This is currently effectively a singleton, since it is populated by RSS + /// and we only provide APIs in Nexus to update it, not create / delete. + pub async fn allow_list_view( + &self, + opctx: &OpContext, + ) -> Result { + opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + let conn = self.pool_connection_authorized(opctx).await?; + allow_list::dsl::allow_list + .find(USER_FACING_SERVICES_ALLOW_LIST_ID) + .first_async::(&*conn) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::AllowList, + LookupType::ById(USER_FACING_SERVICES_ALLOW_LIST_ID), + ), + ) + }) + } + + /// Upsert and return a list of allowed source IPs. + pub async fn allow_list_upsert( + &self, + opctx: &OpContext, + allowed_ips: AllowedSourceIps, + ) -> Result { + let conn = self.pool_connection_authorized(opctx).await?; + Self::allow_list_upsert_on_connection(opctx, &conn, allowed_ips).await + } + + pub(crate) async fn allow_list_upsert_on_connection( + opctx: &OpContext, + conn: &async_bb8_diesel::Connection, + allowed_ips: AllowedSourceIps, + ) -> Result { + use allow_list::dsl; + opctx.authorize(authz::Action::Modify, &authz::FLEET).await?; + let record = + AllowList::new(USER_FACING_SERVICES_ALLOW_LIST_ID, allowed_ips); + diesel::insert_into(dsl::allow_list) + .values(record.clone()) + .returning(AllowList::as_returning()) + .on_conflict(dsl::id) + .do_update() + .set(( + dsl::allowed_ips.eq(record.allowed_ips), + dsl::time_modified.eq(record.time_modified), + )) + .get_result_async(conn) + .await + .map_err(|e| public_error_from_diesel(e, ErrorHandler::Server)) + } +} + +#[cfg(test)] +mod tests { + use crate::db::{ + datastore::test_utils::datastore_test, + fixed_data::allow_list::USER_FACING_SERVICES_ALLOW_LIST_ID, + }; + use nexus_test_utils::db::test_setup_database; + use omicron_common::api::external::{ + self, Error, LookupType, ResourceType, + }; + use omicron_test_utils::dev; + + #[tokio::test] + async fn test_allowed_source_ip_database_ops() { + let logctx = dev::test_setup_log("test_allowed_source_ip_database_ops"); + let mut db = test_setup_database(&logctx.log).await; + let (opctx, datastore) = datastore_test(&logctx, &db).await; + + // There should be nothing to begin with. + let result = datastore + .allow_list_view(&opctx) + .await + .expect_err("Expected query to fail when there are no records"); + assert_eq!( + result, + Error::ObjectNotFound { + type_name: ResourceType::AllowList, + lookup_type: LookupType::ById( + USER_FACING_SERVICES_ALLOW_LIST_ID + ) + }, + "Expected an ObjectNotFound error when there is no IP allowlist" + ); + + // Upsert an allowlist, with some specific IPs. + let ips = + vec!["10.0.0.0/8".parse().unwrap(), "::1/64".parse().unwrap()]; + let allowed_ips = + external::AllowedSourceIps::try_from(ips.as_slice()).unwrap(); + let record = datastore + .allow_list_upsert(&opctx, allowed_ips) + .await + .expect("Expected this insert to succeed"); + assert_eq!( + record.id, USER_FACING_SERVICES_ALLOW_LIST_ID, + "Record should have hard-coded allowlist ID" + ); + assert_eq!( + ips, + record.allowed_ips.unwrap(), + "Inserted and re-read incorrect allowed source ips" + ); + + // Upsert a new list, verify it's changed. + let new_ips = + vec!["10.0.0.0/4".parse().unwrap(), "fd00::10/32".parse().unwrap()]; + let allowed_ips = + external::AllowedSourceIps::try_from(new_ips.as_slice()).unwrap(); + let new_record = datastore + .allow_list_upsert(&opctx, allowed_ips) + .await + .expect("Expected this insert to succeed"); + assert_eq!( + new_record.id, USER_FACING_SERVICES_ALLOW_LIST_ID, + "Record should have hard-coded allowlist ID" + ); + assert_eq!( + record.time_created, new_record.time_created, + "Time created should not have changed" + ); + assert!( + record.time_modified < new_record.time_modified, + "Time modified should have changed" + ); + assert_eq!( + &new_ips, + new_record.allowed_ips.as_ref().unwrap(), + "Updated allowed IPs are incorrect" + ); + + // Insert an allowlist letting anything in, and check it. + let record = new_record; + let allowed_ips = external::AllowedSourceIps::Any; + let new_record = datastore + .allow_list_upsert(&opctx, allowed_ips) + .await + .expect("Expected this insert to succeed"); + assert_eq!( + new_record.id, USER_FACING_SERVICES_ALLOW_LIST_ID, + "Record should have hard-coded allowlist ID" + ); + assert_eq!( + record.time_created, new_record.time_created, + "Time created should not have changed" + ); + assert!( + record.time_modified < new_record.time_modified, + "Time modified should have changed" + ); + assert!( + new_record.allowed_ips.is_none(), + "NULL should be used in the DB to represent any allowed source IPs", + ); + + // Lastly change it back to a real list again. + let record = new_record; + let allowed_ips = + external::AllowedSourceIps::try_from(new_ips.as_slice()).unwrap(); + let new_record = datastore + .allow_list_upsert(&opctx, allowed_ips) + .await + .expect("Expected this insert to succeed"); + assert_eq!( + new_record.id, USER_FACING_SERVICES_ALLOW_LIST_ID, + "Record should have hard-coded allowlist ID" + ); + assert_eq!( + record.time_created, new_record.time_created, + "Time created should not have changed" + ); + assert!( + record.time_modified < new_record.time_modified, + "Time modified should have changed" + ); + assert_eq!( + &new_ips, + new_record.allowed_ips.as_ref().unwrap(), + "Updated allowed IPs are incorrect" + ); + + db.cleanup().await.expect("failed to cleanup database"); + logctx.cleanup_successful(); + } +} diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index b91b4cc9604..9ade7200d4b 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -48,6 +48,7 @@ use std::sync::Arc; use uuid::Uuid; mod address_lot; +mod allow_list; mod bfd; mod bgp; mod bootstore; diff --git a/nexus/db-queries/src/db/datastore/rack.rs b/nexus/db-queries/src/db/datastore/rack.rs index 43c55e104e1..a072d65147a 100644 --- a/nexus/db-queries/src/db/datastore/rack.rs +++ b/nexus/db-queries/src/db/datastore/rack.rs @@ -55,6 +55,7 @@ use nexus_types::external_api::shared::IdentityType; use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::shared::SiloRole; use nexus_types::identity::Resource; +use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadataCreateParams; @@ -86,6 +87,7 @@ pub struct RackInit { pub recovery_user_id: external_params::UserId, pub recovery_user_password_hash: omicron_passwords::PasswordHashString, pub dns_update: DnsVersionUpdateBuilder, + pub allowed_source_ips: AllowedSourceIps, } /// Possible errors while trying to initialize rack @@ -106,6 +108,8 @@ enum RackInitError { Retryable(DieselError), // Other non-retryable database error Database(DieselError), + // Error adding initial allowed source IP list + AllowedSourceIpError(Error), } // Catch-all for Diesel error conversion into RackInitError, which @@ -169,6 +173,7 @@ impl From for Error { "failed operation due to database error: {:#}", err )), + RackInitError::AllowedSourceIpError(err) => err, } } } @@ -859,6 +864,17 @@ impl DataStore { } })?; + // Insert the initial source IP allowlist for requests to + // user-facing services. + Self::allow_list_upsert_on_connection( + opctx, + &conn, + rack_init.allowed_source_ips, + ).await.map_err(|e| { + err.set(RackInitError::AllowedSourceIpError(e)).unwrap(); + DieselError::RollbackTransaction + })?; + let rack = diesel::update(rack_dsl::rack) .filter(rack_dsl::id.eq(rack_id)) .set(( @@ -1054,6 +1070,7 @@ mod test { "test suite".to_string(), "test suite".to_string(), ), + allowed_source_ips: AllowedSourceIps::Any, } } } diff --git a/nexus/db-queries/src/db/fixed_data/allow_list.rs b/nexus/db-queries/src/db/fixed_data/allow_list.rs new file mode 100644 index 00000000000..33178f55305 --- /dev/null +++ b/nexus/db-queries/src/db/fixed_data/allow_list.rs @@ -0,0 +1,11 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// 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/. + +// Copyright 2024 Oxide Computer Company + +//! Fixed data for source IP allowlist implementation. + +/// UUID of singleton source IP allowlist. +pub static USER_FACING_SERVICES_ALLOW_LIST_ID: uuid::Uuid = + uuid::uuid!("001de000-a110-4000-8000-000000000000"); diff --git a/nexus/db-queries/src/db/fixed_data/mod.rs b/nexus/db-queries/src/db/fixed_data/mod.rs index 4f896eb5d10..13444141cb3 100644 --- a/nexus/db-queries/src/db/fixed_data/mod.rs +++ b/nexus/db-queries/src/db/fixed_data/mod.rs @@ -30,9 +30,11 @@ // 001de000-4401 built-in services project // 001de000-074c built-in services vpc // 001de000-c470 built-in services vpc subnets +// 001de000-all0 singleton ID for source IP allowlist ("all0" is like "allow") use once_cell::sync::Lazy; +pub mod allow_list; pub mod project; pub mod role_assignment; pub mod role_builtin; @@ -65,6 +67,7 @@ fn assert_valid_uuid(id: &uuid::Uuid) { #[cfg(test)] mod test { + use super::allow_list::USER_FACING_SERVICES_ALLOW_LIST_ID; use super::assert_valid_uuid; use super::FLEET_ID; @@ -72,4 +75,9 @@ mod test { fn test_builtin_fleet_id_is_valid() { assert_valid_uuid(&FLEET_ID); } + + #[test] + fn test_allowlist_id_is_valid() { + assert_valid_uuid(&USER_FACING_SERVICES_ALLOW_LIST_ID); + } } diff --git a/nexus/examples/config.toml b/nexus/examples/config.toml index f7c5e44cf0f..eac6c6a489c 100644 --- a/nexus/examples/config.toml +++ b/nexus/examples/config.toml @@ -113,6 +113,7 @@ blueprints.period_secs_execute = 60 sync_service_zone_nat.period_secs = 30 switch_port_settings_manager.period_secs = 30 region_replacement.period_secs = 30 +service_firewall_propagation.period_secs = 300 [default_region_allocation_strategy] # allocate region on 3 random distinct zpools, on 3 random distinct sleds. diff --git a/nexus/networking/src/firewall_rules.rs b/nexus/networking/src/firewall_rules.rs index 85c457d5226..dc67ce5937a 100644 --- a/nexus/networking/src/firewall_rules.rs +++ b/nexus/networking/src/firewall_rules.rs @@ -9,6 +9,7 @@ use ipnetwork::IpNetwork; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; use nexus_db_queries::db; +use nexus_db_queries::db::fixed_data::vpc::SERVICES_VPC_ID; use nexus_db_queries::db::identity::Asset; use nexus_db_queries::db::identity::Resource; use nexus_db_queries::db::lookup; @@ -16,12 +17,14 @@ use nexus_db_queries::db::lookup::LookupPath; use nexus_db_queries::db::model::Name; use nexus_db_queries::db::DataStore; use omicron_common::api::external; +use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::external::Error; use omicron_common::api::external::IpNet; use omicron_common::api::external::ListResultVec; use omicron_common::api::internal::nexus::HostIdentifier; use omicron_common::api::internal::shared::NetworkInterface; use slog::debug; +use slog::error; use slog::info; use slog::warn; use slog::Logger; @@ -186,6 +189,44 @@ pub async fn resolve_firewall_rules_for_sled_agent( "subnet_networks" => ?subnet_networks, ); + // Lookup an rules implied by the user-facing services IP allowlist. + // + // These rules are implicit, and not stored in the firewall rule table, + // since they're logically a different thing. However, we implement the + // allowlist by _modifying_ any existing rules targeting the internal Oxide + // services VPC. The point here is to restrict the hosts allowed to make + // connections, but otherwise leave the rules unmodified. For example, we + // want to make sure that our external DNS server only receives UDP traffic + // on port 53. Adding a _new_ firewall rule for the allowlist with higher + // priority would remove this port / protocol requirement. Instead, we + // modify the rules in-place. + let allowed_ips = if allowlist_applies_to_vpc(vpc) { + let allowed_ips = + lookup_allowed_source_ips(datastore, opctx, log).await?; + match &allowed_ips { + AllowedSourceIps::Any => { + debug!( + log, + "Allowlist for user-facing services is set to \ + allow any inbound traffic. Existing VPC firewall \ + rules will not be modified." + ); + } + AllowedSourceIps::List(list) => { + debug!( + log, + "Found allowlist for user-facing services \ + with explicit IP list. Existing VPC firewall \ + rules will be modified to match."; + "allow_list" => ?list, + ); + } + } + Some(allowed_ips) + } else { + None + }; + // Compile resolved rules for the sled agents. let mut sled_agent_rules = Vec::with_capacity(rules.len()); for rule in rules { @@ -266,9 +307,43 @@ pub async fn resolve_firewall_rules_for_sled_agent( continue; } - let filter_hosts = match &rule.filter_hosts { - None => None, - Some(hosts) => { + // Construct the set of filter hosts from the DB rule. + let filter_hosts = match (&rule.filter_hosts, &allowed_ips) { + // No host filters, but we need to insert the allowlist entries. + // + // This is the expected case when applying rules for the built-in + // Oxide-services VPCs, which do not contain host filters. (See + // `nexus_db_queries::fixed_data::vpc_firewall_rule` for those + // rules.) If those rules change to include any filter hosts, this + // logic needs to change as well. + (None, Some(allowed_ips)) => match allowed_ips { + AllowedSourceIps::Any => None, + AllowedSourceIps::List(list) => Some( + list.iter() + .copied() + .map(|ip| HostIdentifier::Ip(ip).into()) + .collect(), + ), + }, + + // No rules exist, and we don't need to add anything for the + // allowlist. + (None, None) => None, + + (Some(_), Some(_)) => { + return Err(Error::internal_error( + "While trying to apply the user-facing services allowlist, \ + we found unexpected host filters already in the rules. These \ + are expected to have no built-in rules which filter on \ + the hosts, so that we can modify the rules to apply the \ + allowlist without worrying about destroying those built-in \ + host filters." + )); + } + + // There are host filters, but we don't need to apply the allowlist + // to this VPC either, so insert the rules as-is. + (Some(hosts), None) => { let mut host_addrs = Vec::with_capacity(hosts.len()); for host in hosts { match &host.0 { @@ -439,3 +514,26 @@ pub async fn plumb_service_firewall_rules( .await?; Ok(()) } + +/// Return true if the user-facing services allowlist applies to a VPC. +fn allowlist_applies_to_vpc(vpc: &db::model::Vpc) -> bool { + vpc.id() == *SERVICES_VPC_ID +} + +/// Return the list of allowed IPs from the database. +async fn lookup_allowed_source_ips( + datastore: &DataStore, + opctx: &OpContext, + log: &Logger, +) -> Result { + match datastore.allow_list_view(opctx).await { + Ok(allowed) => { + slog::trace!(log, "fetched allowlist from DB"; "allowed" => ?allowed); + allowed.allowed_source_ips() + } + Err(e) => { + error!(log, "failed to fetch allowlist from DB"; "err" => ?e); + Err(e) + } + } +} diff --git a/nexus/src/app/allow_list.rs b/nexus/src/app/allow_list.rs new file mode 100644 index 00000000000..b113ed886b9 --- /dev/null +++ b/nexus/src/app/allow_list.rs @@ -0,0 +1,164 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// 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/. + +// Copyright 2024 Oxide Computer Company + +//! Nexus methods for operating on source IP allowlists. + +use nexus_db_queries::context::OpContext; +use nexus_types::external_api::params; +use nexus_types::external_api::views::AllowList; +use omicron_common::api::external; +use omicron_common::api::external::Error; +use std::net::IpAddr; + +impl super::Nexus { + /// Fetch the allowlist of source IPs that can reach user-facing services. + pub async fn allow_list_view( + &self, + opctx: &OpContext, + ) -> Result { + self.db_datastore + .allow_list_view(opctx) + .await + .and_then(AllowList::try_from) + } + + /// Upsert the allowlist of source IPs that can reach user-facing services. + pub async fn allow_list_upsert( + &self, + opctx: &OpContext, + remote_addr: IpAddr, + params: params::AllowListUpdate, + ) -> Result { + if let external::AllowedSourceIps::List(list) = ¶ms.allowed_ips { + // Size limits on the allowlist. + const MAX_ALLOWLIST_LENGTH: usize = 1000; + if list.len() > MAX_ALLOWLIST_LENGTH { + let message = format!( + "Source IP allowlist is limited to {} entries, found {}", + MAX_ALLOWLIST_LENGTH, + list.len(), + ); + return Err(Error::invalid_request(message)); + } + + // Some basic sanity-checks on the addresses in the allowlist. + // + // The most important part here is checking that the source address + // the request came from is on the allowlist. This is our only real + // guardrail to prevent accidentally preventing any future access to + // the rack! + let mut contains_remote = false; + for entry in list.iter() { + contains_remote = entry.contains(remote_addr); + if entry.ip().is_unspecified() { + return Err(Error::invalid_request( + "Source IP allowlist may not contain the \ + unspecified address. Use \"any\" to allow \ + any source to connect to user-facing services.", + )); + } + if entry.prefix() == 0 { + return Err(Error::invalid_request( + "Source IP allowlist entries may not have \ + a netmask of /0.", + )); + } + } + if !contains_remote { + return Err(Error::invalid_request( + "The source IP allow list would prevent access \ + from the current client! Ensure that the allowlist \ + contains an entry that continues to allow access \ + from this peer.", + )); + } + }; + + // Actually insert the new allowlist. + let list = self + .db_datastore + .allow_list_upsert(opctx, params.allowed_ips.clone()) + .await + .and_then(AllowList::try_from)?; + + // Notify the sled-agents of the updated firewall rules. + // + // Importantly, we need to use a different `opctx` from that we're + // passed in here. This call requires access to Oxide-internal data + // around our VPC, and so we must use a context that's authorized for + // that. + // + // TODO-debugging: It's unfortunate that we're using this new logger, + // since that means we lose things like the original actor and request + // ID. It would be great if we could insert additional key-value pairs + // into the logger itself here, or "merge" the two in some other way. + info!( + opctx.log, + "updated user-facing services allow list, switching to \ + internal opcontext to plumb rules to sled-agents"; + "new_allowlist" => ?params.allowed_ips, + ); + let new_opctx = self.opctx_for_internal_api(); + match nexus_networking::plumb_service_firewall_rules( + self.datastore(), + &new_opctx, + &[], + &new_opctx, + &new_opctx.log, + ) + .await + { + Ok(_) => { + info!(self.log, "plumbed updated IP allowlist to sled-agents"); + Ok(list) + } + Err(e) => { + error!( + self.log, + "failed to update sled-agents with new allowlist"; + "error" => ?e + ); + let message = "Failed to plumb allowlist as firewall rules \ + to relevant sled agents. The request must be retried for them \ + to take effect."; + Err(Error::unavail(message)) + } + } + } + + /// Wait until we've applied the user-facing services allowlist. + /// + /// This will block until we've plumbed this allowlist and passed it to the + /// sled-agents responsible. This should only be called from + /// rack-initialization handling. + pub(crate) async fn await_ip_allowlist_plumbing(&self) { + let opctx = self.opctx_for_internal_api(); + loop { + match nexus_networking::plumb_service_firewall_rules( + self.datastore(), + &opctx, + &[], + &opctx, + &opctx.log, + ) + .await + { + Ok(_) => { + info!(self.log, "plumbed initial IP allowlist"); + return; + } + Err(e) => { + error!( + self.log, + "failed to plumb initial IP allowlist"; + "error" => ?e + ); + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + } + } + } +} diff --git a/nexus/src/app/background/init.rs b/nexus/src/app/background/init.rs index 9997953921a..8ceff3e2b8a 100644 --- a/nexus/src/app/background/init.rs +++ b/nexus/src/app/background/init.rs @@ -18,6 +18,7 @@ use super::nat_cleanup; use super::phantom_disks; use super::physical_disk_adoption; use super::region_replacement; +use super::service_firewall_rules; use super::sync_service_zone_nat::ServiceZoneNatTracker; use super::sync_switch_configuration::SwitchPortSettingsManager; use crate::app::oximeter::PRODUCER_LEASE_DURATION; @@ -90,6 +91,10 @@ pub struct BackgroundTasks { /// task handle for the task that detects if regions need replacement and /// begins the process pub task_region_replacement: common::TaskHandle, + + /// task handle for propagation of VPC firewall rules for Omicron services + /// with external network connectivity, + pub task_service_firewall_propagation: common::TaskHandle, } impl BackgroundTasks { @@ -325,7 +330,7 @@ impl BackgroundTasks { // process let task_region_replacement = { let detector = region_replacement::RegionReplacementDetector::new( - datastore, + datastore.clone(), saga_request.clone(), ); @@ -341,6 +346,21 @@ impl BackgroundTasks { task }; + // Background task: service firewall rule propagation + let task_service_firewall_propagation = driver.register( + String::from("service_firewall_rule_propagation"), + String::from( + "propagates VPC firewall rules for Omicron \ + services with external network connectivity", + ), + config.service_firewall_propagation.period_secs, + Box::new(service_firewall_rules::ServiceRulePropagator::new( + datastore.clone(), + )), + opctx.child(BTreeMap::new()), + vec![], + ); + BackgroundTasks { driver, task_internal_dns_config, @@ -360,6 +380,7 @@ impl BackgroundTasks { task_service_zone_nat_tracker, task_switch_port_settings_manager, task_region_replacement, + task_service_firewall_propagation, } } diff --git a/nexus/src/app/background/mod.rs b/nexus/src/app/background/mod.rs index 0e3b1624041..526ed84e4f0 100644 --- a/nexus/src/app/background/mod.rs +++ b/nexus/src/app/background/mod.rs @@ -20,6 +20,7 @@ mod networking; mod phantom_disks; mod physical_disk_adoption; mod region_replacement; +mod service_firewall_rules; mod status; mod sync_service_zone_nat; mod sync_switch_configuration; diff --git a/nexus/src/app/background/service_firewall_rules.rs b/nexus/src/app/background/service_firewall_rules.rs new file mode 100644 index 00000000000..bbd05771cb7 --- /dev/null +++ b/nexus/src/app/background/service_firewall_rules.rs @@ -0,0 +1,67 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// 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/. + +//! Background task for propagating VPC firewall rules for Omicron services. +//! +//! This is intended to propagate only the rules related to Oxide-managed +//! programs, like Nexus or external DNS. These are special -- they are very +//! unlikely to change and also relatively small. This task is not intended to +//! handle general changes to customer-visible VPC firewalls, and is mostly in +//! place to propagate changes in the IP allowlist for user-facing services. + +use super::common::BackgroundTask; +use futures::future::BoxFuture; +use futures::FutureExt; +use nexus_db_queries::context::OpContext; +use nexus_db_queries::db::DataStore; +use std::sync::Arc; + +pub struct ServiceRulePropagator { + datastore: Arc, +} + +impl ServiceRulePropagator { + pub fn new(datastore: Arc) -> Self { + Self { datastore } + } +} + +impl BackgroundTask for ServiceRulePropagator { + fn activate<'a>( + &'a mut self, + opctx: &'a OpContext, + ) -> BoxFuture<'a, serde_json::Value> { + async { + let log = opctx + .log + .new(slog::o!("component" => "service-firewall-rule-progator")); + debug!( + log, + "starting background task for service \ + firewall rule propagation" + ); + let start = std::time::Instant::now(); + let res = nexus_networking::plumb_service_firewall_rules( + &self.datastore, + opctx, + &[], + opctx, + &log, + ) + .await; + if let Err(e) = res { + error!( + log, + "failed to propagate service firewall rules"; + "error" => ?e, + ); + serde_json::json!({"error" : e.to_string()}) + } else { + debug!(log, "successfully propagated service firewall rules"); + serde_json::json!({"elapsed": start.elapsed()}) + } + } + .boxed() + } +} diff --git a/nexus/src/app/mod.rs b/nexus/src/app/mod.rs index 263f7e4f325..f8cccd89a44 100644 --- a/nexus/src/app/mod.rs +++ b/nexus/src/app/mod.rs @@ -39,6 +39,7 @@ use uuid::Uuid; // The implementation of Nexus is large, and split into a number of submodules // by resource. mod address_lot; +mod allow_list; pub(crate) mod background; mod bfd; mod bgp; diff --git a/nexus/src/app/rack.rs b/nexus/src/app/rack.rs index 3157f32f755..4c5fb332acf 100644 --- a/nexus/src/app/rack.rs +++ b/nexus/src/app/rack.rs @@ -651,6 +651,7 @@ impl super::Nexus { .user_password_hash .into(), dns_update, + allowed_source_ips: request.allowed_source_ips, }, ) .await?; diff --git a/nexus/src/app/sled.rs b/nexus/src/app/sled.rs index 8aa0573d468..c7fc651823e 100644 --- a/nexus/src/app/sled.rs +++ b/nexus/src/app/sled.rs @@ -273,7 +273,13 @@ impl super::Nexus { address: SocketAddrV6, kind: DatasetKind, ) -> Result<(), Error> { - info!(self.log, "upserting dataset"; "zpool_id" => zpool_id.to_string(), "dataset_id" => id.to_string(), "address" => address.to_string()); + info!( + self.log, + "upserting dataset"; + "zpool_id" => zpool_id.to_string(), + "dataset_id" => id.to_string(), + "address" => address.to_string() + ); let dataset = db::model::Dataset::new(id, zpool_id, address, kind); self.db_datastore.dataset_upsert(dataset).await?; Ok(()) diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 305d0837268..5283d2b30e0 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -291,6 +291,9 @@ pub(crate) fn external_api() -> NexusApiDescription { api.register(networking_bfd_disable)?; api.register(networking_bfd_status)?; + api.register(networking_allow_list_view)?; + api.register(networking_allow_list_update)?; + api.register(utilization_view)?; // Fleet-wide API operations @@ -3771,6 +3774,53 @@ async fn networking_bfd_status( apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await } +/// Get user-facing services IP allowlist +#[endpoint { + method = GET, + path = "/v1/system/networking/allow-list", + tags = ["system/networking"], +}] +async fn networking_allow_list_view( + rqctx: RequestContext>, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + nexus + .allow_list_view(&opctx) + .await + .map(HttpResponseOk) + .map_err(HttpError::from) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + +/// Update user-facing services IP allowlist +#[endpoint { + method = PUT, + path = "/v1/system/networking/allow-list", + tags = ["system/networking"], +}] +async fn networking_allow_list_update( + rqctx: RequestContext>, + params: TypedBody, +) -> Result, HttpError> { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.nexus; + let params = params.into_inner(); + let opctx = crate::context::op_context_for_external_api(&rqctx).await?; + let remote_addr = rqctx.request.remote_addr().ip(); + nexus + .allow_list_upsert(&opctx, remote_addr, params) + .await + .map(HttpResponseOk) + .map_err(HttpError::from) + }; + apictx.external_latencies.instrument_dropshot_handler(&rqctx, handler).await +} + // Images /// List images diff --git a/nexus/src/lib.rs b/nexus/src/lib.rs index a8dd3d0233f..4dfb91c4e05 100644 --- a/nexus/src/lib.rs +++ b/nexus/src/lib.rs @@ -40,7 +40,7 @@ use omicron_common::address::IpRange; use omicron_common::api::external::Error; use omicron_common::api::internal::nexus::{ProducerEndpoint, ProducerKind}; use omicron_common::api::internal::shared::{ - ExternalPortDiscovery, RackNetworkConfig, SwitchLocation, + AllowedSourceIps, ExternalPortDiscovery, RackNetworkConfig, SwitchLocation, }; use omicron_common::FileKv; use oximeter::types::ProducerRegistry; @@ -137,6 +137,11 @@ impl Server { let opctx = apictx.nexus.opctx_for_service_balancer(); apictx.nexus.await_rack_initialization(&opctx).await; + // While we've started our internal server, we need to wait until we've + // definitely implemented our source IP allowlist for making requests to + // the external server we're about to start. + apictx.nexus.await_ip_allowlist_plumbing().await; + // Launch the external server. let tls_config = apictx .nexus @@ -317,6 +322,7 @@ impl nexus_test_interface::NexusServer for Server { bgp: Vec::new(), bfd: Vec::new(), }, + allowed_source_ips: AllowedSourceIps::Any, }, ) .await diff --git a/nexus/tests/config.test.toml b/nexus/tests/config.test.toml index 94cf34ee410..8b3d20a024f 100644 --- a/nexus/tests/config.test.toml +++ b/nexus/tests/config.test.toml @@ -109,6 +109,7 @@ blueprints.period_secs_execute = 600 sync_service_zone_nat.period_secs = 30 switch_port_settings_manager.period_secs = 30 region_replacement.period_secs = 30 +service_firewall_propagation.period_secs = 300 [default_region_allocation_strategy] # we only have one sled in the test environment, so we need to use the diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index 3dc4042d859..cc73ab088c1 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -25,6 +25,7 @@ use nexus_types::external_api::shared::IpRange; use nexus_types::external_api::shared::Ipv4Range; use nexus_types::external_api::views::SledProvisionPolicy; use omicron_common::api::external::AddressLotKind; +use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::external::ByteCount; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::IdentityMetadataUpdateParams; @@ -877,6 +878,13 @@ pub static DEMO_USER_CREATE: Lazy = password: params::UserPassword::LoginDisallowed, }); +// Allowlist for user-facing services. +pub static ALLOW_LIST_URL: Lazy = + Lazy::new(|| String::from("/v1/system/networking/allow-list")); +pub static ALLOW_LIST_UPDATE: Lazy = Lazy::new(|| { + params::AllowListUpdate { allowed_ips: AllowedSourceIps::Any } +}); + /// Describes an API endpoint to be verified by the "unauthorized" test /// /// These structs are also used to check whether we're covering all endpoints in @@ -2400,5 +2408,18 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { ), ], }, + + // User-facing services IP allowlist + VerifyEndpoint { + url: &ALLOW_LIST_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::Get, + AllowedMethod::Put( + serde_json::to_value(&*ALLOW_LIST_UPDATE).unwrap(), + ), + ], + }, ] }); diff --git a/nexus/tests/output/nexus_tags.txt b/nexus/tests/output/nexus_tags.txt index 1a4e218bf34..a32fe5c4b9f 100644 --- a/nexus/tests/output/nexus_tags.txt +++ b/nexus/tests/output/nexus_tags.txt @@ -174,6 +174,8 @@ networking_address_lot_block_list GET /v1/system/networking/address- networking_address_lot_create POST /v1/system/networking/address-lot networking_address_lot_delete DELETE /v1/system/networking/address-lot/{address_lot} networking_address_lot_list GET /v1/system/networking/address-lot +networking_allow_list_update PUT /v1/system/networking/allow-list +networking_allow_list_view GET /v1/system/networking/allow-list networking_bfd_disable POST /v1/system/networking/bfd-disable networking_bfd_enable POST /v1/system/networking/bfd-enable networking_bfd_status GET /v1/system/networking/bfd-status diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index 2c81c0ebc6a..912cbeab4ff 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -9,10 +9,10 @@ use crate::external_api::shared; use base64::Engine; use chrono::{DateTime, Utc}; use omicron_common::api::external::{ - AddressLotKind, BfdMode, ByteCount, Hostname, IdentityMetadataCreateParams, - IdentityMetadataUpdateParams, ImportExportPolicy, InstanceCpuCount, IpNet, - Ipv4Net, Ipv6Net, Name, NameOrId, PaginationOrder, RouteDestination, - RouteTarget, SemverVersion, + AddressLotKind, AllowedSourceIps, BfdMode, ByteCount, Hostname, + IdentityMetadataCreateParams, IdentityMetadataUpdateParams, + ImportExportPolicy, InstanceCpuCount, IpNet, Ipv4Net, Ipv6Net, Name, + NameOrId, PaginationOrder, RouteDestination, RouteTarget, SemverVersion, }; use schemars::JsonSchema; use serde::{ @@ -2100,3 +2100,12 @@ pub struct TimeseriesQuery { /// A timeseries query string, written in the Oximeter query language. pub query: String, } + +// Allowed source IPs + +/// Parameters for updating allowed source IPs +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct AllowListUpdate { + /// The new list of allowed source IPs. + pub allowed_ips: AllowedSourceIps, +} diff --git a/nexus/types/src/external_api/views.rs b/nexus/types/src/external_api/views.rs index 80ef24fcb22..e0ba36f1604 100644 --- a/nexus/types/src/external_api/views.rs +++ b/nexus/types/src/external_api/views.rs @@ -12,8 +12,9 @@ use api_identity::ObjectIdentity; use chrono::DateTime; use chrono::Utc; use omicron_common::api::external::{ - ByteCount, Digest, Error, IdentityMetadata, InstanceState, Ipv4Net, - Ipv6Net, Name, ObjectIdentity, RoleName, SimpleIdentity, + AllowedSourceIps as ExternalAllowedSourceIps, ByteCount, Digest, Error, + IdentityMetadata, InstanceState, Ipv4Net, Ipv6Net, Name, ObjectIdentity, + RoleName, SimpleIdentity, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -948,3 +949,16 @@ pub struct Ping { /// returns anything at all. pub status: PingStatus, } + +// ALLOWED SOURCE IPS + +/// Allowlist of IPs or subnets that can make requests to user-facing services. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct AllowList { + /// Time the list was created. + pub time_created: DateTime, + /// Time the list was last modified. + pub time_modified: DateTime, + /// The allowlist of IPs or subnets. + pub allowed_ips: ExternalAllowedSourceIps, +} diff --git a/nexus/types/src/internal_api/params.rs b/nexus/types/src/internal_api/params.rs index 15ab1549529..143ca1be8b8 100644 --- a/nexus/types/src/internal_api/params.rs +++ b/nexus/types/src/internal_api/params.rs @@ -13,6 +13,7 @@ use omicron_common::api::external::ByteCount; use omicron_common::api::external::Generation; use omicron_common::api::external::MacAddr; use omicron_common::api::external::Name; +use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::ExternalPortDiscovery; use omicron_common::api::internal::shared::RackNetworkConfig; use omicron_common::api::internal::shared::SourceNatConfig; @@ -243,6 +244,8 @@ pub struct RackInitializationRequest { pub external_port_count: ExternalPortDiscovery, /// Initial rack network configuration pub rack_network_config: RackNetworkConfig, + /// IPs or subnets allowed to make requests to user-facing services + pub allowed_source_ips: AllowedSourceIps, } pub type DnsConfigParams = dns_service_client::types::DnsConfigParams; diff --git a/openapi/bootstrap-agent.json b/openapi/bootstrap-agent.json index 827cd8e5d44..01811a09690 100644 --- a/openapi/bootstrap-agent.json +++ b/openapi/bootstrap-agent.json @@ -161,6 +161,48 @@ }, "components": { "schemas": { + "AllowedSourceIps": { + "description": "Description of source IPs allowed to reach rack services.", + "oneOf": [ + { + "description": "Allow traffic from any external IP address.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "any" + ] + } + }, + "required": [ + "allow" + ] + }, + { + "description": "Restrict access to a specific set of source IP addresses or subnets.\n\nAll others are prevented from reaching rack services.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "list" + ] + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + } + }, + "required": [ + "allow", + "ips" + ] + } + ] + }, "Baseboard": { "description": "Describes properties that should uniquely identify a Gimlet.", "oneOf": [ @@ -339,7 +381,7 @@ ] }, "asn": { - "description": "The autonomous sysetm number of the router the peer belongs to.", + "description": "The autonomous system number of the router the peer belongs to.", "type": "integer", "format": "uint32", "minimum": 0 @@ -679,7 +721,7 @@ "title": "An IPv6 subnet", "description": "An IPv6 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Network": { "type": "string", @@ -816,6 +858,17 @@ "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", "properties": { + "allowed_source_ips": { + "description": "IPs or subnets allowed to make requests to user-facing services", + "default": { + "allow": "any" + }, + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, "bootstrap_discovery": { "description": "Describes how bootstrap addresses should be collected during RSS.", "allOf": [ diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index 45eec6cfb2c..2fb0622266e 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -1386,6 +1386,48 @@ "dependency" ] }, + "AllowedSourceIps": { + "description": "Description of source IPs allowed to reach rack services.", + "oneOf": [ + { + "description": "Allow traffic from any external IP address.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "any" + ] + } + }, + "required": [ + "allow" + ] + }, + { + "description": "Restrict access to a specific set of source IP addresses or subnets.\n\nAll others are prevented from reaching rack services.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "list" + ] + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + } + }, + "required": [ + "allow", + "ips" + ] + } + ] + }, "BackgroundTask": { "description": "Background tasks\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", @@ -1578,7 +1620,7 @@ ] }, "asn": { - "description": "The autonomous sysetm number of the router the peer belongs to.", + "description": "The autonomous system number of the router the peer belongs to.", "type": "integer", "format": "uint32", "minimum": 0 @@ -3272,7 +3314,7 @@ "title": "An IPv6 subnet", "description": "An IPv6 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Network": { "type": "string", @@ -3942,6 +3984,14 @@ "RackInitializationRequest": { "type": "object", "properties": { + "allowed_source_ips": { + "description": "IPs or subnets allowed to make requests to user-facing services", + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, "blueprint": { "description": "Blueprint describing services initialized by RSS.", "allOf": [ @@ -4023,6 +4073,7 @@ } }, "required": [ + "allowed_source_ips", "blueprint", "certs", "datasets", diff --git a/openapi/nexus.json b/openapi/nexus.json index a12540379f5..74efd47a662 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -6305,6 +6305,68 @@ } } }, + "/v1/system/networking/allow-list": { + "get": { + "tags": [ + "system/networking" + ], + "summary": "Get user-facing services IP allowlist", + "operationId": "networking_allow_list_view", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllowList" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + }, + "put": { + "tags": [ + "system/networking" + ], + "summary": "Update user-facing services IP allowlist", + "operationId": "networking_allow_list_update", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllowListUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AllowList" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/system/networking/bfd-disable": { "post": { "tags": [ @@ -9187,6 +9249,94 @@ "switch_histories" ] }, + "AllowList": { + "description": "Allowlist of IPs or subnets that can make requests to user-facing services.", + "type": "object", + "properties": { + "allowed_ips": { + "description": "The allowlist of IPs or subnets.", + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, + "time_created": { + "description": "Time the list was created.", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "Time the list was last modified.", + "type": "string", + "format": "date-time" + } + }, + "required": [ + "allowed_ips", + "time_created", + "time_modified" + ] + }, + "AllowListUpdate": { + "description": "Parameters for updating allowed source IPs", + "type": "object", + "properties": { + "allowed_ips": { + "description": "The new list of allowed source IPs.", + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + } + }, + "required": [ + "allowed_ips" + ] + }, + "AllowedSourceIps": { + "description": "Description of source IPs allowed to reach rack services.", + "oneOf": [ + { + "description": "Allow traffic from any external IP address.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "any" + ] + } + }, + "required": [ + "allow" + ] + }, + { + "description": "Restrict access to a specific set of source IP addresses or subnets.\n\nAll others are prevented from reaching rack services.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "list" + ] + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + } + }, + "required": [ + "allow", + "ips" + ] + } + ] + }, "Baseboard": { "description": "Properties that uniquely identify an Oxide hardware component", "type": "object", @@ -14490,7 +14640,7 @@ "title": "An IPv6 subnet", "description": "An IPv6 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Range": { "description": "A non-decreasing IPv6 address range, inclusive of both ends.\n\nThe first address must be less than or equal to the last address.", diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index 37ef5124bdc..d4b625805db 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -1486,7 +1486,7 @@ ] }, "asn": { - "description": "The autonomous sysetm number of the router the peer belongs to.", + "description": "The autonomous system number of the router the peer belongs to.", "type": "integer", "format": "uint32", "minimum": 0 @@ -3438,7 +3438,7 @@ "title": "An IPv6 subnet", "description": "An IPv6 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Network": { "type": "string", diff --git a/openapi/wicketd.json b/openapi/wicketd.json index f4c5b34a6e0..74440305952 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -778,6 +778,48 @@ "message" ] }, + "AllowedSourceIps": { + "description": "Description of source IPs allowed to reach rack services.", + "oneOf": [ + { + "description": "Allow traffic from any external IP address.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "any" + ] + } + }, + "required": [ + "allow" + ] + }, + { + "description": "Restrict access to a specific set of source IP addresses or subnets.\n\nAll others are prevented from reaching rack services.", + "type": "object", + "properties": { + "allow": { + "type": "string", + "enum": [ + "list" + ] + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + } + }, + "required": [ + "allow", + "ips" + ] + } + ] + }, "ArtifactHashId": { "description": "A hash-based identifier for an artifact.\n\nSome places, e.g. the installinator, request artifacts by hash rather than by name and version. This type indicates that.", "type": "object", @@ -1227,6 +1269,14 @@ "description": "The subset of `RackInitializeRequest` that the user fills in as clear text (e.g., via an uploaded config file).", "type": "object", "properties": { + "allowed_source_ips": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, "bootstrap_sleds": { "type": "array", "items": { @@ -1711,7 +1761,7 @@ "title": "An IPv6 subnet", "description": "An IPv6 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Network": { "type": "string", @@ -2300,6 +2350,9 @@ "description": "The portion of `CurrentRssUserConfig` that can be posted in one shot; it is provided by the wicket user uploading a TOML file, currently.\n\nThis is the \"write\" version of [`CurrentRssUserConfigInsensitive`], with some different fields.", "type": "object", "properties": { + "allowed_source_ips": { + "$ref": "#/components/schemas/AllowedSourceIps" + }, "bootstrap_sleds": { "description": "List of slot numbers only.\n\n`wicketd` will map this back to sleds with the correct `SpIdentifier` based on the `bootstrap_sleds` it provides in `CurrentRssUserConfigInsensitive`.", "type": "array", @@ -2344,6 +2397,7 @@ } }, "required": [ + "allowed_source_ips", "bootstrap_sleds", "dns_servers", "external_dns_ips", diff --git a/oximeter/collector/tests/output/self-stat-schema.json b/oximeter/collector/tests/output/self-stat-schema.json index 8017d618807..1b18362a263 100644 --- a/oximeter/collector/tests/output/self-stat-schema.json +++ b/oximeter/collector/tests/output/self-stat-schema.json @@ -39,7 +39,7 @@ } ], "datum_type": "cumulative_u64", - "created": "2024-02-05T23:03:00.842290108Z" + "created": "2024-05-03T22:37:51.326086935Z" }, "oximeter_collector:failed_collections": { "timeseries_name": "oximeter_collector:failed_collections", @@ -86,6 +86,6 @@ } ], "datum_type": "cumulative_u64", - "created": "2024-02-05T23:03:00.842943988Z" + "created": "2024-05-03T22:37:51.327389025Z" } } \ No newline at end of file diff --git a/schema/all-zone-requests.json b/schema/all-zone-requests.json index e37fbfde590..66792f52c44 100644 --- a/schema/all-zone-requests.json +++ b/schema/all-zone-requests.json @@ -191,7 +191,7 @@ "fd12:3456::/64" ], "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "MacAddr": { "title": "A MAC address", diff --git a/schema/all-zones-requests.json b/schema/all-zones-requests.json index 0ac9e760a83..bb4dba2520b 100644 --- a/schema/all-zones-requests.json +++ b/schema/all-zones-requests.json @@ -75,7 +75,7 @@ "fd12:3456::/64" ], "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "MacAddr": { "title": "A MAC address", diff --git a/schema/crdb/add-allowed-source-ips/up.sql b/schema/crdb/add-allowed-source-ips/up.sql new file mode 100644 index 00000000000..f1f84e9a2c1 --- /dev/null +++ b/schema/crdb/add-allowed-source-ips/up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS omicron.public.allow_list ( + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + allowed_ips INET[] CHECK (array_length(allowed_ips, 1) > 0) +); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index ce84d74c2b1..0b358e73285 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -3805,6 +3805,19 @@ CREATE TABLE IF NOT EXISTS omicron.public.db_metadata ( CHECK (singleton = true) ); +-- An allowlist of IP addresses that can make requests to user-facing services. +CREATE TABLE IF NOT EXISTS omicron.public.allow_list ( + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + -- A nullable list of allowed source IPs. + -- + -- NULL is used to indicate _any_ source IP is allowed. A _non-empty_ list + -- represents an explicit allow list of IPs or IP subnets. Note that the + -- list itself may never be empty. + allowed_ips INET[] CHECK (array_length(allowed_ips, 1) > 0) +); + /* * Keep this at the end of file so that the database does not contain a version * until it is fully populated. @@ -3816,7 +3829,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - ( TRUE, NOW(), NOW(), '56.0.0', NULL) + (TRUE, NOW(), NOW(), '57.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT; diff --git a/schema/deployment-config.json b/schema/deployment-config.json index be6018a6ace..9fa4ba2159b 100644 --- a/schema/deployment-config.json +++ b/schema/deployment-config.json @@ -132,7 +132,7 @@ "fd12:3456::/64" ], "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Subnet": { "description": "Wraps an [`Ipv6Network`] with a compile-time prefix length.", diff --git a/schema/rss-service-plan-v3.json b/schema/rss-service-plan-v3.json index 0b7a1468ff6..bab3e916ba4 100644 --- a/schema/rss-service-plan-v3.json +++ b/schema/rss-service-plan-v3.json @@ -189,7 +189,7 @@ "fd12:3456::/64" ], "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "MacAddr": { "title": "A MAC address", diff --git a/schema/rss-sled-plan.json b/schema/rss-sled-plan.json index d6e9baf9ddc..a349fbb6058 100644 --- a/schema/rss-sled-plan.json +++ b/schema/rss-sled-plan.json @@ -23,6 +23,48 @@ } }, "definitions": { + "AllowedSourceIps": { + "description": "Description of source IPs allowed to reach rack services.", + "oneOf": [ + { + "description": "Allow traffic from any external IP address.", + "type": "object", + "required": [ + "allow" + ], + "properties": { + "allow": { + "type": "string", + "enum": [ + "any" + ] + } + } + }, + { + "description": "Restrict access to a specific set of source IP addresses or subnets.\n\nAll others are prevented from reaching rack services.", + "type": "object", + "required": [ + "allow", + "ips" + ], + "properties": { + "allow": { + "type": "string", + "enum": [ + "list" + ] + }, + "ips": { + "type": "array", + "items": { + "$ref": "#/definitions/IpNet" + } + } + } + } + ] + }, "Baseboard": { "description": "Describes properties that should uniquely identify a Gimlet.", "oneOf": [ @@ -212,7 +254,7 @@ ] }, "asn": { - "description": "The autonomous sysetm number of the router the peer belongs to.", + "description": "The autonomous system number of the router the peer belongs to.", "type": "integer", "format": "uint32", "minimum": 0.0 @@ -541,7 +583,7 @@ "fd12:3456::/64" ], "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Network": { "type": "string", @@ -698,6 +740,17 @@ "recovery_silo" ], "properties": { + "allowed_source_ips": { + "description": "IPs or subnets allowed to make requests to user-facing services", + "default": { + "allow": "any" + }, + "allOf": [ + { + "$ref": "#/definitions/AllowedSourceIps" + } + ] + }, "bootstrap_discovery": { "description": "Describes how bootstrap addresses should be collected during RSS.", "allOf": [ diff --git a/schema/start-sled-agent-request.json b/schema/start-sled-agent-request.json index b03058d106f..7a7745617c0 100644 --- a/schema/start-sled-agent-request.json +++ b/schema/start-sled-agent-request.json @@ -32,7 +32,7 @@ "fd12:3456::/64" ], "type": "string", - "pattern": "^([fF][dD])[0-9a-fA-F]{2}:(([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:)([0-9a-fA-F]{1,4})?\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" + "pattern": "^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, "Ipv6Subnet": { "description": "Wraps an [`Ipv6Network`] with a compile-time prefix length.", diff --git a/sled-agent/src/bootstrap/params.rs b/sled-agent/src/bootstrap/params.rs index c901fb31ac5..cc5c0648d6d 100644 --- a/sled-agent/src/bootstrap/params.rs +++ b/sled-agent/src/bootstrap/params.rs @@ -7,6 +7,7 @@ use anyhow::{bail, Result}; use async_trait::async_trait; use omicron_common::address::{self, Ipv6Subnet, SLED_PREFIX}; +use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::internal::shared::RackNetworkConfig; use omicron_common::ledger::Ledgerable; use schemars::JsonSchema; @@ -41,6 +42,8 @@ struct UnvalidatedRackInitializeRequest { external_certificates: Vec, recovery_silo: RecoverySiloConfig, rack_network_config: RackNetworkConfig, + #[serde(default = "default_allowed_source_ips")] + allowed_source_ips: AllowedSourceIps, } /// Configuration for the "rack setup service". @@ -87,6 +90,17 @@ pub struct RackInitializeRequest { /// Initial rack network configuration pub rack_network_config: RackNetworkConfig, + + /// IPs or subnets allowed to make requests to user-facing services + #[serde(default = "default_allowed_source_ips")] + pub allowed_source_ips: AllowedSourceIps, +} + +/// This field was added after several racks were already deployed. RSS plans +/// for those racks should default to allowing any source IP, since that is +/// effectively what they did. +const fn default_allowed_source_ips() -> AllowedSourceIps { + AllowedSourceIps::Any } // This custom debug implementation hides the private keys. @@ -105,6 +119,7 @@ impl std::fmt::Debug for RackInitializeRequest { external_certificates: _, recovery_silo, rack_network_config, + allowed_source_ips, } = &self; f.debug_struct("RackInitializeRequest") @@ -121,6 +136,7 @@ impl std::fmt::Debug for RackInitializeRequest { .field("external_certificates", &"") .field("recovery_silo", recovery_silo) .field("rack_network_config", rack_network_config) + .field("allowed_source_ips", allowed_source_ips) .finish() } } @@ -161,6 +177,7 @@ impl TryFrom for RackInitializeRequest { external_certificates: value.external_certificates, recovery_silo: value.recovery_silo, rack_network_config: value.rack_network_config, + allowed_source_ips: value.allowed_source_ips, }) } } @@ -495,6 +512,7 @@ mod tests { bgp: Vec::new(), bfd: Vec::new(), }, + allowed_source_ips: AllowedSourceIps::Any, }; // Valid configs: all external DNS IPs are contained in the IP pool diff --git a/sled-agent/src/rack_setup/config.rs b/sled-agent/src/rack_setup/config.rs index fba753657e9..91cdc5c9b5f 100644 --- a/sled-agent/src/rack_setup/config.rs +++ b/sled-agent/src/rack_setup/config.rs @@ -94,6 +94,7 @@ mod test { use anyhow::Context; use camino::Utf8PathBuf; use omicron_common::address::IpRange; + use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::RackNetworkConfig; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -129,6 +130,7 @@ mod test { bgp: Vec::new(), bfd: Vec::new(), }, + allowed_source_ips: AllowedSourceIps::Any, }; assert_eq!( diff --git a/sled-agent/src/rack_setup/plan/service.rs b/sled-agent/src/rack_setup/plan/service.rs index 6e3ce4a6acd..b4a6fe76f6e 100644 --- a/sled-agent/src/rack_setup/plan/service.rs +++ b/sled-agent/src/rack_setup/plan/service.rs @@ -1164,6 +1164,7 @@ mod tests { use crate::bootstrap::params::BootstrapAddressDiscovery; use crate::bootstrap::params::RecoverySiloConfig; use omicron_common::address::IpRange; + use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::RackNetworkConfig; const EXPECTED_RESERVED_ADDRESSES: u16 = 2; @@ -1267,6 +1268,7 @@ mod tests { bgp: Vec::new(), bfd: Vec::new(), }, + allowed_source_ips: AllowedSourceIps::Any, }; let mut svp = ServicePortBuilder::new(&config); diff --git a/sled-agent/src/rack_setup/service.rs b/sled-agent/src/rack_setup/service.rs index 97ad054cc80..2a3e7b2de87 100644 --- a/sled-agent/src/rack_setup/service.rs +++ b/sled-agent/src/rack_setup/service.rs @@ -844,6 +844,15 @@ impl ServiceInner { }) .collect(); + // Convert the IP allowlist into the Nexus types. + // + // This is really infallible. We have a list of IpNet's here, which + // we're converting to Nexus client types through their string + // representation. + let allowed_source_ips = + NexusTypes::AllowedSourceIps::try_from(&config.allowed_source_ips) + .expect("Expected valid Nexus IP networks"); + let request = NexusTypes::RackInitializationRequest { blueprint, physical_disks, @@ -856,6 +865,7 @@ impl ServiceInner { recovery_silo: config.recovery_silo.clone(), rack_network_config, external_port_count: port_discovery_mode.into(), + allowed_source_ips, }; let notify_nexus = || async { diff --git a/sled-agent/src/sim/server.rs b/sled-agent/src/sim/server.rs index 5da887aa053..ebee0adc1fc 100644 --- a/sled-agent/src/sim/server.rs +++ b/sled-agent/src/sim/server.rs @@ -534,6 +534,7 @@ pub async fn run_standalone_server( bgp: Vec::new(), bfd: Vec::new(), }, + allowed_source_ips: NexusTypes::AllowedSourceIps::Any, }; handoff_to_nexus(&log, &config, &rack_init_request).await?; diff --git a/sled-agent/tests/output/new-rss-sled-plans/madrid-rss-sled-plan.json b/sled-agent/tests/output/new-rss-sled-plans/madrid-rss-sled-plan.json index 6c2b3b45c28..108914a26f6 100644 --- a/sled-agent/tests/output/new-rss-sled-plans/madrid-rss-sled-plan.json +++ b/sled-agent/tests/output/new-rss-sled-plans/madrid-rss-sled-plan.json @@ -162,6 +162,9 @@ ], "bgp": [], "bfd": [] + }, + "allowed_source_ips": { + "allow": "any" } } } \ No newline at end of file diff --git a/smf/nexus/multi-sled/config-partial.toml b/smf/nexus/multi-sled/config-partial.toml index 400a9877863..62e8b51b076 100644 --- a/smf/nexus/multi-sled/config-partial.toml +++ b/smf/nexus/multi-sled/config-partial.toml @@ -55,6 +55,7 @@ blueprints.period_secs_execute = 60 sync_service_zone_nat.period_secs = 30 switch_port_settings_manager.period_secs = 30 region_replacement.period_secs = 30 +service_firewall_propagation.period_secs = 300 [default_region_allocation_strategy] # by default, allocate across 3 distinct sleds diff --git a/smf/nexus/single-sled/config-partial.toml b/smf/nexus/single-sled/config-partial.toml index 524d521c89b..d5a4b4eb77a 100644 --- a/smf/nexus/single-sled/config-partial.toml +++ b/smf/nexus/single-sled/config-partial.toml @@ -55,6 +55,7 @@ blueprints.period_secs_execute = 60 sync_service_zone_nat.period_secs = 30 switch_port_settings_manager.period_secs = 30 region_replacement.period_secs = 30 +service_firewall_propagation.period_secs = 300 [default_region_allocation_strategy] # by default, allocate without requirement for distinct sleds. diff --git a/smf/sled-agent/gimlet-standalone/config-rss.toml b/smf/sled-agent/gimlet-standalone/config-rss.toml index 6c874d9a70a..616d8d496b0 100644 --- a/smf/sled-agent/gimlet-standalone/config-rss.toml +++ b/smf/sled-agent/gimlet-standalone/config-rss.toml @@ -118,6 +118,18 @@ switch = "switch0" # Neighbors we expect to peer with over BGP on this port. bgp_peers = [] +# An allowlist of source IPs that can make requests to user-facing services can +# be specified here. It can be written as the string "any" ... +[allowed_source_ips] +allow = "any" + +# ... or as a list of IP subnets, like so: +# allow = "list" +# ips = ["10.0.0.1/32", "192.168.1.0/24"] +# +# Note that single IP addresses must include the netmask as well, so `/32` or +# `/128`. + # Configuration for the initial Silo, user, and password. # # You don't need to change the silo or user names. diff --git a/smf/sled-agent/non-gimlet/config-rss.toml b/smf/sled-agent/non-gimlet/config-rss.toml index d0b4f94d9f4..d897f7ba4b7 100644 --- a/smf/sled-agent/non-gimlet/config-rss.toml +++ b/smf/sled-agent/non-gimlet/config-rss.toml @@ -118,6 +118,18 @@ switch = "switch0" # Neighbors we expect to peer with over BGP on this port. bgp_peers = [] +# An allowlist of source IPs that can make requests to user-facing services can +# be specified here. It can be written as the string "any" ... +[allowed_source_ips] +allow = "any" + +# ... or as a list of IP subnets, like so: +# allow = "list" +# ips = ["10.0.0.1/32", "192.168.1.0/24"] +# +# Note that single IP addresses must include the netmask as well, so `/32` or +# `/128`. + # Configuration for the initial Silo, user, and password. # # You don't need to change the silo or user names. diff --git a/wicket-common/src/example.rs b/wicket-common/src/example.rs index e9e49b314be..16b5df67681 100644 --- a/wicket-common/src/example.rs +++ b/wicket-common/src/example.rs @@ -10,8 +10,11 @@ use gateway_client::types::{SpIdentifier, SpType}; use maplit::{btreemap, btreeset}; use omicron_common::{ address::{IpRange, Ipv4Range}, - api::internal::shared::{ - BgpConfig, BgpPeerConfig, PortFec, PortSpeed, RouteConfig, + api::{ + external::AllowedSourceIps, + internal::shared::{ + BgpConfig, BgpPeerConfig, PortFec, PortSpeed, RouteConfig, + }, }, }; use sled_hardware_types::Baseboard; @@ -210,6 +213,7 @@ impl ExampleRackSetupData { external_dns_ips, ntp_servers, rack_network_config: Some(rack_network_config), + allowed_source_ips: Some(AllowedSourceIps::Any), }; for tweak in tweaks { @@ -243,6 +247,7 @@ impl ExampleRackSetupData { .rack_network_config .clone() .unwrap(), + allowed_source_ips: AllowedSourceIps::Any, }; Self { diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index 672e7cd4578..5e89bfdde27 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -2,7 +2,7 @@ // 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/. -// Copyright 2023 Oxide Computer Company +// Copyright 2024 Oxide Computer Company pub use gateway_client::types::SpIdentifier as GatewaySpIdentifier; pub use gateway_client::types::SpType as GatewaySpType; @@ -12,6 +12,7 @@ use omicron_common::api::external::ImportExportPolicy; use omicron_common::api::external::IpNet; use omicron_common::api::external::Name; use omicron_common::api::external::SwitchLocation; +use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::BgpConfig; use omicron_common::api::internal::shared::BgpPeerConfig; use omicron_common::api::internal::shared::PortFec; @@ -46,6 +47,7 @@ pub struct CurrentRssUserConfigInsensitive { pub external_dns_ips: Vec, pub external_dns_zone_name: String, pub rack_network_config: Option, + pub allowed_source_ips: Option, } /// The portion of `CurrentRssUserConfig` that can be posted in one shot; it is @@ -67,6 +69,7 @@ pub struct PutRssUserConfigInsensitive { pub external_dns_ips: Vec, pub external_dns_zone_name: String, pub rack_network_config: UserSpecifiedRackNetworkConfig, + pub allowed_source_ips: AllowedSourceIps, } #[derive( @@ -372,7 +375,17 @@ impl fmt::Debug for BgpAuthKey { /// Describes insensitive information about a BGP authentication key. /// /// This information is considered okay to display in the UI. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + PartialOrd, + Ord, +)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum BgpAuthKeyInfo { /// TCP-MD5 authentication. @@ -403,7 +416,17 @@ impl BgpAuthKeyInfo { /// This is part of a wicketd response, but is returned here because our /// tooling turns BTreeMaps into HashMaps. So we use a `replace` directive /// instead. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + PartialOrd, + Ord, +)] pub struct GetBgpAuthKeyInfoResponse { /// Information about the requested keys. /// @@ -417,7 +440,17 @@ pub struct GetBgpAuthKeyInfoResponse { /// This is part of a wicketd response, but is returned here because our /// tooling turns BTreeMaps into HashMaps. So we use a `replace` directive /// instead. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[derive( + Clone, + Debug, + Serialize, + Deserialize, + PartialEq, + Eq, + JsonSchema, + PartialOrd, + Ord, +)] #[serde(tag = "status", rename_all = "snake_case")] pub enum BgpAuthKeyStatus { /// The key was specified but hasn't been set yet. diff --git a/wicket/src/cli/rack_setup/config_template.toml b/wicket/src/cli/rack_setup/config_template.toml index 63b0fb4429a..5c70f286f18 100644 --- a/wicket/src/cli/rack_setup/config_template.toml +++ b/wicket/src/cli/rack_setup/config_template.toml @@ -38,6 +38,17 @@ internal_services_ip_pool_ranges = [] # Confirm this list contains all expected sleds before continuing! bootstrap_sleds = [] +# Allowlist of source IPs that can make requests to user-facing services. +[allowed_source_ips] +# Any external IPs to make requests. This is the default. +allow = "any" + +# Use the below two lines to only allow requests from the specified IP subnets. +# Requests from any other source IPs are refused. Note that individual addresses +# must include the netmask, e.g., `1.2.3.4/32`. +# allow = "list" +# ips = [ "1.2.3.4/5", "5.6.7.8/10" ] + # TODO: docs on network config [rack_network_config] infra_ip_first = "" diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index 641d09fe35a..aa1a384efbd 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -485,7 +485,6 @@ mod tests { "tests/output/example_non_empty.toml", &template, ); - let parsed: PutRssUserConfigInsensitive = toml::de::from_str(&template).unwrap(); assert_eq!(example.put_insensitive, parsed); diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index 88c5891bb59..f74baa3f2c7 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -19,6 +19,7 @@ use crate::Control; use crate::State; use itertools::Itertools; use omicron_common::address::IpRange; +use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::BgpConfig; use omicron_common::api::internal::shared::RouteConfig; use ratatui::layout::Constraint; @@ -653,6 +654,7 @@ fn rss_config_text<'a>( external_dns_ips, external_dns_zone_name, rack_network_config, + allowed_source_ips, } = &config.insensitive; // Special single-line values, where we convert some kind of condition into @@ -1082,6 +1084,30 @@ fn rss_config_text<'a>( .map(|ip| plain_list_item(ip.to_string())) .collect(), ); + + // Add the allowlist for connecting to user-facing rack services. + let allowed_source_ip_spans = match &allowed_source_ips { + None | Some(AllowedSourceIps::Any) => { + vec![plain_list_item(String::from("Any"))] + } + Some(AllowedSourceIps::List(list)) => list + .iter() + .map(|net| { + let as_str = if net.first_address() == net.last_address() { + net.ip().to_string() + } else { + net.to_string() + }; + plain_list_item(as_str) + }) + .collect(), + }; + append_list( + &mut spans, + "Allowed source IPs for user-facing services: ".into(), + allowed_source_ip_spans, + ); + append_list( &mut spans, "Sleds: ".into(), diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 4994d0f1ec6..6eaa89d65c6 100644 --- a/wicketd/src/rss_config.rs +++ b/wicketd/src/rss_config.rs @@ -28,6 +28,7 @@ use omicron_common::address; use omicron_common::address::Ipv4Range; use omicron_common::address::Ipv6Subnet; use omicron_common::address::RACK_PREFIX; +use omicron_common::api::external::AllowedSourceIps; use omicron_common::api::external::SwitchLocation; use once_cell::sync::Lazy; use sled_hardware_types::Baseboard; @@ -87,7 +88,7 @@ pub(crate) struct CurrentRssConfig { // // Currently these are always TCP-MD5 keys, bgp_auth_keys: BTreeMap>, - + allowed_source_ips: Option, // External certificates are uploaded in two separate actions (cert then // key, or vice versa). Here we store a partial certificate; once we have // both parts, we validate it and promote it to be a member of @@ -299,6 +300,10 @@ impl CurrentRssConfig { user_password_hash, }, rack_network_config, + allowed_source_ips: self + .allowed_source_ips + .clone() + .unwrap_or(AllowedSourceIps::Any), }; Ok(request) @@ -576,6 +581,7 @@ impl From<&'_ CurrentRssConfig> for CurrentRssUserConfig { external_dns_ips: rss.external_dns_ips.clone(), external_dns_zone_name: rss.external_dns_zone_name.clone(), rack_network_config: rss.rack_network_config.clone(), + allowed_source_ips: rss.allowed_source_ips.clone(), }, } }