Skip to content

Commit

Permalink
Add a source IP allowlist for user-facing services
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
bnaecker committed May 4, 2024
1 parent 472d9f7 commit 9b7be39
Show file tree
Hide file tree
Showing 70 changed files with 1,809 additions and 76 deletions.
42 changes: 22 additions & 20 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
1 change: 1 addition & 0 deletions clients/bootstrap-agent-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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
Expand Down
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 @@ -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,
}
);

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 @@ -419,3 +419,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),
}
}
}
5 changes: 3 additions & 2 deletions clients/wicketd-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
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,
// `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
Loading

0 comments on commit 9b7be39

Please sign in to comment.