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 5, 2024
1 parent 2f65249 commit abfeb98
Show file tree
Hide file tree
Showing 67 changed files with 1,718 additions and 58 deletions.
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,
// `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.
///
/// 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>);

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

0 comments on commit abfeb98

Please sign in to comment.