diff --git a/clients/bootstrap-agent-client/src/lib.rs b/clients/bootstrap-agent-client/src/lib.rs index 61ebd04e1bc..5da286148f9 100644 --- a/clients/bootstrap-agent-client/src/lib.rs +++ b/clients/bootstrap-agent-client/src/lib.rs @@ -22,6 +22,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, } ); @@ -66,3 +71,58 @@ impl From for types::Baseboard { } } } + +/* +impl TryFrom<&types::Ipv4Net> for external::Ipv4Net { + type Error = String; + + fn try_from( + net: &types::Ipv4Net, + ) -> Result { + net.parse().map(external::Ipv4Net).map_err(|e| e.to_string()) + } +} + +impl TryFrom<&types::Ipv6Net> for external::Ipv6Net { + type Error = String; + + fn try_from( + net: &types::Ipv6Net, + ) -> Result { + net.parse().map(external::Ipv6Net).map_err(|e| e.to_string()) + } +} + +impl TryFrom<&types::IpNet> for external::IpNet { + type Error = String; + + fn try_from( + net: &types::IpNet, + ) -> Result { + match net { + types::IpNet::V4(v4) => external::Ipv4Net::try_from(v4).map(external::IpNet::V4), + types::IpNet::V6(v6) => external::Ipv6Net::try_from(v6).map(external::IpNet::V6), + } + } +} + +impl TryFrom<&types::AllowedSourceIps> for external::AllowedSourceIps +{ + type Error = String; + + fn try_from( + ips: &types::AllowedSourceIps, + ) -> Result { + match ips { + types::AllowedSourceIps::Any => Ok(external::AllowedSourceIps::Any), + types::AllowedSourceIps::List(list) => { + let vec = list + .iter() + .map(TryFrom::try_from) + .collect::, _>>()?; + external::AllowedSourceIps::try_from(vec).map_err(|e| e.to_string()) + } + } + } +} +*/ diff --git a/clients/wicketd-client/src/lib.rs b/clients/wicketd-client/src/lib.rs index a59f31ceca7..a45f6edd311 100644 --- a/clients/wicketd-client/src/lib.rs +++ b/clients/wicketd-client/src/lib.rs @@ -44,12 +44,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 ] }, - CurrentRssUserConfigInsensitive = { derives = [ PartialEq, Eq, Serialize, Deserialize ] }, + CurrentRssUserConfigInsensitive = { derives = [ PartialEq, Serialize, Deserialize ] }, CurrentRssUserConfigSensitive = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, - CurrentRssUserConfig = { derives = [ PartialEq, Eq, Serialize, Deserialize ] }, + CurrentRssUserConfig = { derives = [ PartialEq, Serialize, Deserialize ] }, GetLocationResponse = { derives = [ PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize ] }, }, replace = { + AllowedSourceIps = omicron_common::api::internal::shared::AllowedSourceIps, BgpConfig = omicron_common::api::internal::shared::BgpConfig, BgpPeerConfig = omicron_common::api::internal::shared::BgpPeerConfig, ClearUpdateStateResponse = wicket_common::rack_update::ClearUpdateStateResponse, diff --git a/openapi/bootstrap-agent.json b/openapi/bootstrap-agent.json index 688e4440530..010da9fb7bd 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": [ @@ -305,7 +347,7 @@ "format": "ipv4" }, "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 @@ -449,6 +491,26 @@ "request_id" ] }, + "IpNet": { + "oneOf": [ + { + "title": "v4", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4Net" + } + ] + }, + { + "title": "v6", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Net" + } + ] + } + ] + }, "IpNetwork": { "oneOf": [ { @@ -489,6 +551,13 @@ } ] }, + "Ipv4Net": { + "example": "192.168.1.0/24", + "title": "An IPv4 subnet", + "description": "An IPv4 subnet, including prefix and subnet mask", + "type": "string", + "pattern": "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])/([0-9]|1[0-9]|2[0-9]|3[0-2])$" + }, "Ipv4Network": { "type": "string", "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\/(3[0-2]|[0-2]?[0-9])$" @@ -511,6 +580,13 @@ "last" ] }, + "Ipv6Net": { + "example": "fd12:3456::/64", + "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])$" + }, "Ipv6Network": { "type": "string", "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])\")[/](12[0-8]|1[0-1][0-9]|[0-9]?[0-9])$" @@ -646,6 +722,14 @@ "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", + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, "bootstrap_discovery": { "description": "Describes how bootstrap addresses should be collected during RSS.", "allOf": [ @@ -721,6 +805,7 @@ } }, "required": [ + "allowed_source_ips", "bootstrap_discovery", "dns_servers", "external_certificates", diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index 0875d0d53a8..ab662f307d1 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -1452,7 +1452,7 @@ "format": "ipv4" }, "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 diff --git a/openapi/wicketd.json b/openapi/wicketd.json index b9645a174f8..07cc3158c35 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -699,6 +699,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", @@ -844,7 +886,7 @@ "format": "ipv4" }, "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 @@ -1092,6 +1134,14 @@ "CurrentRssUserConfigInsensitive": { "type": "object", "properties": { + "allowed_source_ips": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/AllowedSourceIps" + } + ] + }, "bootstrap_sleds": { "type": "array", "items": { @@ -1446,6 +1496,26 @@ "installable" ] }, + "IpNet": { + "oneOf": [ + { + "title": "v4", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv4Net" + } + ] + }, + { + "title": "v6", + "allOf": [ + { + "$ref": "#/components/schemas/Ipv6Net" + } + ] + } + ] + }, "IpNetwork": { "oneOf": [ { @@ -1486,6 +1556,13 @@ } ] }, + "Ipv4Net": { + "example": "192.168.1.0/24", + "title": "An IPv4 subnet", + "description": "An IPv4 subnet, including prefix and subnet mask", + "type": "string", + "pattern": "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])/([0-9]|1[0-9]|2[0-9]|3[0-2])$" + }, "Ipv4Network": { "type": "string", "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\/(3[0-2]|[0-2]?[0-9])$" @@ -1508,6 +1585,13 @@ "last" ] }, + "Ipv6Net": { + "example": "fd12:3456::/64", + "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])$" + }, "Ipv6Network": { "type": "string", "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])\")[/](12[0-8]|1[0-1][0-9]|[0-9]?[0-9])$" @@ -2132,6 +2216,9 @@ "PutRssUserConfigInsensitive": { "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", @@ -2176,6 +2263,7 @@ } }, "required": [ + "allowed_source_ips", "bootstrap_sleds", "dns_servers", "external_dns_ips", diff --git a/wicket-common/src/rack_setup.rs b/wicket-common/src/rack_setup.rs index 761877e9a4b..30c0159a5e2 100644 --- a/wicket-common/src/rack_setup.rs +++ b/wicket-common/src/rack_setup.rs @@ -2,9 +2,10 @@ // 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 use omicron_common::address; +use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::BgpConfig; use omicron_common::api::internal::shared::PortConfigV1; use schemars::JsonSchema; @@ -40,4 +41,5 @@ pub struct PutRssUserConfigInsensitive { pub external_dns_ips: Vec, pub external_dns_zone_name: String, pub rack_network_config: UserSpecifiedRackNetworkConfig, + pub allowed_source_ips: AllowedSourceIps, } diff --git a/wicket/src/cli/rack_setup/config_toml.rs b/wicket/src/cli/rack_setup/config_toml.rs index 9b777a1726f..af2745e6ca6 100644 --- a/wicket/src/cli/rack_setup/config_toml.rs +++ b/wicket/src/cli/rack_setup/config_toml.rs @@ -351,7 +351,8 @@ mod tests { use super::*; use omicron_common::api::external::SwitchLocation; use omicron_common::api::internal::shared::{ - BgpConfig, BgpPeerConfig, PortConfigV1, PortFec, PortSpeed, RouteConfig, + AllowedSourceIps, BgpConfig, BgpPeerConfig, PortConfigV1, PortFec, + PortSpeed, RouteConfig, }; use std::net::Ipv6Addr; use wicket_common::rack_setup::PutRssUserConfigInsensitive; @@ -389,6 +390,9 @@ mod tests { external_dns_ips: value.external_dns_ips, ntp_servers: value.ntp_servers, rack_network_config, + allowed_source_ips: value + .allowed_source_ips + .unwrap_or(AllowedSourceIps::Any), } } @@ -458,6 +462,7 @@ mod tests { originate: vec!["10.0.0.0/16".parse().unwrap()], }], }), + allowed_source_ips: Some(AllowedSourceIps::Any), }; let template = TomlTemplate::populate(&config).to_string(); let parsed: PutRssUserConfigInsensitive = diff --git a/wicket/src/ui/panes/rack_setup.rs b/wicket/src/ui/panes/rack_setup.rs index ab85c638193..30a0939d338 100644 --- a/wicket/src/ui/panes/rack_setup.rs +++ b/wicket/src/ui/panes/rack_setup.rs @@ -17,6 +17,7 @@ use crate::Action; use crate::Cmd; use crate::Control; use crate::State; +use omicron_common::api::internal::shared::AllowedSourceIps; use ratatui::layout::Constraint; use ratatui::layout::Direction; use ratatui::layout::Layout; @@ -791,6 +792,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 &insensitive.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.prefix() == 32 || net.prefix() == 128 { + 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/http_entrypoints.rs b/wicketd/src/http_entrypoints.rs index e87581b7bef..5c24732bc29 100644 --- a/wicketd/src/http_entrypoints.rs +++ b/wicketd/src/http_entrypoints.rs @@ -32,6 +32,7 @@ use http::StatusCode; use internal_dns::resolver::Resolver; use omicron_common::address; use omicron_common::api::external::SemverVersion; +use omicron_common::api::internal::shared::AllowedSourceIps; use omicron_common::api::internal::shared::SwitchLocation; use omicron_common::update::ArtifactHashId; use omicron_common::update::ArtifactId; @@ -173,6 +174,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, } // This is a summary of the subset of `RackInitializeRequest` that is sensitive; diff --git a/wicketd/src/rss_config.rs b/wicketd/src/rss_config.rs index 7e66f21b63b..f8d456f9a92 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 once_cell::sync::Lazy; use sled_hardware_types::Baseboard; use slog::warn; @@ -70,6 +71,7 @@ pub(crate) struct CurrentRssConfig { external_certificates: Vec, recovery_silo_password_hash: Option, rack_network_config: Option, + 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 @@ -275,6 +277,10 @@ impl CurrentRssConfig { user_password_hash, }, rack_network_config, + allowed_source_ips: self + .allowed_source_ips + .clone() + .unwrap_or(AllowedSourceIps::Any), }; Ok(request) @@ -452,6 +458,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(), }, } }