Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a source IP allowlist for user-facing services #5686

Merged
merged 1 commit into from
May 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bootstore/src/schemes/v0/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion clients/bootstrap-agent-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions clients/bootstrap-agent-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
);

Expand Down
54 changes: 54 additions & 0 deletions clients/nexus-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,3 +420,57 @@ impl TryFrom<types::ProducerEndpoint>
})
}
}

impl TryFrom<&omicron_common::api::external::Ipv4Net> for types::Ipv4Net {
type Error = String;

fn try_from(
net: &omicron_common::api::external::Ipv4Net,
) -> Result<Self, Self::Error> {
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<Self, Self::Error> {
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<Self, Self::Error> {
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<Self, Self::Error> {
use omicron_common::api::external::AllowedSourceIps;
match ips {
AllowedSourceIps::Any => Ok(types::AllowedSourceIps::Any),
AllowedSourceIps::List(list) => list
.iter()
.map(TryInto::try_into)
.collect::<Result<Vec<_>, _>>()
.map(types::AllowedSourceIps::List),
}
}
}
6 changes: 4 additions & 2 deletions clients/wicketd-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
81 changes: 69 additions & 12 deletions common/src/api/external/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -847,6 +848,7 @@ impl JsonSchema for Hostname {
pub enum ResourceType {
AddressLot,
AddressLotBlock,
AllowList,
BackgroundTask,
BgpConfig,
BgpAnnounceSet,
Expand Down Expand Up @@ -1334,6 +1336,60 @@ impl From<ipnetwork::Ipv6Network> 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()
Expand All @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we confident that no other locations are depending on these being local addresses?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so. The place we care is when creating VPC prefixes and VPC Subnets, both of which use Ipv6Net::is_unique_local() to verify that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just hit this in a Nexus integration test that was depending on a local address. (Me hitting it was because of a different bug, but I think Nexus is expecting these to be local in at least some cases.) The specific error I saw was

thread 'integration_tests::rack::test_sled_add' panicked at clients/sled-agent-client/src/lib.rs:451:58:
0:0:0:21::/64: doesn't match 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])$"

when I (incorrectly) tried to create a sled with that subnet (which is a legal IPv6 addr but not local).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh man, we've just been so lucky

// `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()
Expand Down Expand Up @@ -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<ipnetwork::IpNetwork> for IpNet {
Expand Down
137 changes: 135 additions & 2 deletions common/src/api/internal/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
bnaecker marked this conversation as resolved.
Show resolved Hide resolved
///
/// All others are prevented from reaching rack services.
List(IpAllowList),
}

impl TryFrom<Vec<IpNet>> for AllowedSourceIps {
type Error = &'static str;
fn try_from(list: Vec<IpNet>) -> Result<Self, Self::Error> {
IpAllowList::try_from(list).map(Self::List)
}
}

impl TryFrom<&[IpNetwork]> for AllowedSourceIps {
type Error = &'static str;
fn try_from(list: &[IpNetwork]) -> Result<Self, Self::Error> {
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<IpNet>", into = "Vec<IpNet>")]
#[schemars(transparent)]
pub struct IpAllowList(Vec<IpNet>);
bnaecker marked this conversation as resolved.
Show resolved Hide resolved

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<Item = &IpNet> {
self.0.iter()
}

/// Consume the list into an iterator.
pub fn into_iter(self) -> impl Iterator<Item = IpNet> {
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<IpAllowList> for Vec<IpNet> {
fn from(list: IpAllowList) -> Self {
list.0
}
}

impl TryFrom<Vec<IpNet>> for IpAllowList {
type Error = &'static str;
fn try_from(list: Vec<IpNet>) -> Result<Self, Self::Error> {
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<Self, Self::Error> {
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::<AllowedSourceIps>(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(),
);
}
}
Loading
Loading