From abfeb9825c8f72b799a0d98cce0562d8402a0368 Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Mon, 29 Apr 2024 17:21:28 +0000 Subject: [PATCH] Add a source IP allowlist for user-facing services - Add database table and updates with an allowlist of IPs. This is currently structured as a table with one row, and an array of INETs as the allowlist. Includes a CHECK constraint that the list is not empty -- NULL is used to indicate the lack of an allowlist. - Add Nexus API for viewing / updating allowlist. Also does basic sanity checks on the list, most importantly that the source IP the request came from is _on_ the list. VPC firewall rules are updated after the database has been updated successfully. - Add the allowlist to wicket example config file, and plumb through wicket UI, client, server, and into bootstrap agent. - Add the allowlist into the rack initialization request, and insert it into the database during Nexus's internal server handling of that request. - Read allowlist and convert to firewall rules when plumbing any service firewall rules to sled agents. This works by modifying existing firewall rules for the internal service VPC. The host filters should always be empty here, so this is simple and well-defined. It also lets us keep the right protocol and port filters on the rules. - Add method for waiting on this plumbing _before_ starting Nexus's external server, to ensure the IP allowlist is set before anything can reach Nexus. - Add background task in Nexus for ensuring service VPC rules only. This runs pretty infrequently now (5 minutes), but the allowlist should only be updated very rarely. - Include allowlist default on deserialization in the sled-agent, so that it applies to existing customer installations that've already been RSS'd. - Note: This also relaxes the regular expression we've been using for IPv6 networks. It was previously checking only for ULAs, while we now need it to represent any valid network. Adds tests for the regex too. --- bootstore/src/schemes/v0/peer.rs | 4 +- clients/bootstrap-agent-client/Cargo.toml | 2 +- clients/bootstrap-agent-client/src/lib.rs | 5 + clients/nexus-client/src/lib.rs | 54 +++++ clients/wicketd-client/src/lib.rs | 6 +- common/src/api/external/mod.rs | 81 ++++++- common/src/api/internal/shared.rs | 137 ++++++++++- dev-tools/omdb/src/bin/omdb/nexus.rs | 19 ++ dev-tools/omdb/tests/env.out | 15 ++ dev-tools/omdb/tests/successes.out | 12 + nexus-config/src/nexus_config.rs | 16 ++ nexus/db-model/src/allow_list.rs | 93 ++++++++ nexus/db-model/src/lib.rs | 2 + nexus/db-model/src/schema.rs | 9 + nexus/db-model/src/schema_versions.rs | 3 +- .../db-queries/src/db/datastore/allow_list.rs | 217 ++++++++++++++++++ nexus/db-queries/src/db/datastore/mod.rs | 1 + nexus/db-queries/src/db/datastore/rack.rs | 17 ++ .../src/db/fixed_data/allow_list.rs | 11 + nexus/db-queries/src/db/fixed_data/mod.rs | 8 + nexus/examples/config.toml | 1 + nexus/networking/src/firewall_rules.rs | 104 ++++++++- nexus/src/app/allow_list.rs | 164 +++++++++++++ nexus/src/app/background/init.rs | 23 +- nexus/src/app/background/mod.rs | 1 + .../app/background/service_firewall_rules.rs | 67 ++++++ nexus/src/app/mod.rs | 1 + nexus/src/app/rack.rs | 1 + nexus/src/app/sled.rs | 8 +- nexus/src/external_api/http_entrypoints.rs | 50 ++++ nexus/src/lib.rs | 8 +- nexus/tests/config.test.toml | 1 + nexus/tests/integration_tests/endpoints.rs | 21 ++ nexus/tests/output/nexus_tags.txt | 2 + nexus/types/src/external_api/params.rs | 17 +- nexus/types/src/external_api/views.rs | 18 +- nexus/types/src/internal_api/params.rs | 3 + openapi/bootstrap-agent.json | 57 ++++- openapi/nexus-internal.json | 55 ++++- openapi/nexus.json | 152 +++++++++++- openapi/sled-agent.json | 4 +- openapi/wicketd.json | 56 ++++- .../tests/output/self-stat-schema.json | 4 +- schema/all-zone-requests.json | 2 +- schema/all-zones-requests.json | 2 +- schema/crdb/add-allowed-source-ips/up.sql | 6 + schema/crdb/dbinit.sql | 15 +- schema/deployment-config.json | 2 +- schema/rss-service-plan-v3.json | 2 +- schema/rss-sled-plan.json | 57 ++++- schema/start-sled-agent-request.json | 2 +- sled-agent/src/bootstrap/params.rs | 18 ++ sled-agent/src/rack_setup/config.rs | 2 + sled-agent/src/rack_setup/plan/service.rs | 2 + sled-agent/src/rack_setup/service.rs | 10 + sled-agent/src/sim/server.rs | 1 + .../madrid-rss-sled-plan.json | 3 + smf/nexus/multi-sled/config-partial.toml | 1 + smf/nexus/single-sled/config-partial.toml | 1 + .../gimlet-standalone/config-rss.toml | 12 + smf/sled-agent/non-gimlet/config-rss.toml | 12 + wicket-common/src/example.rs | 9 +- wicket-common/src/rack_setup.rs | 41 +++- .../src/cli/rack_setup/config_template.toml | 11 + wicket/src/cli/rack_setup/config_toml.rs | 1 - wicket/src/ui/panes/rack_setup.rs | 26 +++ wicketd/src/rss_config.rs | 8 +- 67 files changed, 1718 insertions(+), 58 deletions(-) create mode 100644 nexus/db-model/src/allow_list.rs create mode 100644 nexus/db-queries/src/db/datastore/allow_list.rs create mode 100644 nexus/db-queries/src/db/fixed_data/allow_list.rs create mode 100644 nexus/src/app/allow_list.rs create mode 100644 nexus/src/app/background/service_firewall_rules.rs create mode 100644 schema/crdb/add-allowed-source-ips/up.sql 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(), }, } }