diff --git a/Cargo.lock b/Cargo.lock index ff2340871bc..032dc25a79e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5082,6 +5082,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "ipnetwork", "omicron-common", "progenitor", "regress", diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 63ccfca1771..3d8ee960b29 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1005,33 +1005,11 @@ impl JsonSchema for Ipv4Net { })), instance_type: Some(schemars::schema::InstanceType::String.into()), string: Some(Box::new(schemars::schema::StringValidation { - // Addresses must be from an RFC 1918 private address space pattern: Some( concat!( - r#"^("#, - // 10.0.0.0/8 (10.0.0.0 .. 10.255.255.255) - r#"10\."#, + r#"^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}"#, r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\."#, - r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\."#, - r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\/([8-9]|1[0-9]|2[0-9]|3[0-2])|"#, - // 172.16.0.0/12 (172.16.0.0 .. 172.31.255.255) - r#"172\."#, - r#"(1[6-9]|2[0-9]|3[0-1])"#, - r#"\."#, - r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\."#, - r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\/(1[2-9]|2[0-9]|3[0-2])|"#, - // 192.168.0.0/16 (192.168.0.0 .. 192.168.255.255) - r#"192\.168\."#, - r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\."#, - r#"([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])"#, - r#"\/(1[6-9]|2[0-9]|3[0-2])"#, - r#")$"#, + r#"/([8-9]|1[0-9]|2[0-9]|3[0-2])$"#, ) .to_string(), ), diff --git a/nexus/db-model/src/name.rs b/nexus/db-model/src/name.rs index 158a4c545a1..96603530333 100644 --- a/nexus/db-model/src/name.rs +++ b/nexus/db-model/src/name.rs @@ -22,6 +22,7 @@ use serde::{Deserialize, Serialize}; AsExpression, FromSqlRow, Eq, + Hash, PartialEq, Ord, PartialOrd, diff --git a/nexus/src/app/instance.rs b/nexus/src/app/instance.rs index ed2417d8a3a..518e8d4bb99 100644 --- a/nexus/src/app/instance.rs +++ b/nexus/src/app/instance.rs @@ -30,6 +30,7 @@ use omicron_common::api::external::InternalContext; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; use omicron_common::api::external::UpdateResult; +use omicron_common::api::external::Vni; use omicron_common::api::internal::nexus; use sled_agent_client::types::InstanceRuntimeStateMigrateParams; use sled_agent_client::types::InstanceRuntimeStateRequested; @@ -556,6 +557,32 @@ impl super::Nexus { let source_nat = SourceNatConfig::from(snat_ip.into_iter().next().unwrap()); + // Gather the firewall rules for the VPC this instance is in. + // The NIC info we gathered above doesn't have VPC information + // because the sled agent doesn't care about that directly, + // so we fetch it via the first interface's VNI. (It doesn't + // matter which one we use because all NICs must be in the + // same VPC; see the check in project_create_instance.) + let firewall_rules = if let Some(nic) = nics.first() { + let vni = Vni::try_from(nic.vni.0)?; + let vpc = self + .db_datastore + .resolve_vni_to_vpc(opctx, db::model::Vni(vni)) + .await?; + let (.., authz_vpc) = LookupPath::new(opctx, &self.db_datastore) + .vpc_id(vpc.id()) + .lookup_for(authz::Action::Read) + .await?; + let rules = self + .db_datastore + .vpc_list_firewall_rules(opctx, &authz_vpc) + .await?; + self.resolve_firewall_rules_for_sled_agent(opctx, &vpc, &rules) + .await? + } else { + vec![] + }; + // Gather the SSH public keys of the actor make the request so // that they may be injected into the new image via cloud-init. // TODO-security: this should be replaced with a lookup based on @@ -596,6 +623,7 @@ impl super::Nexus { nics, source_nat, external_ips, + firewall_rules, disks: disk_reqs, cloud_init_bytes: Some(base64::encode( db_instance.generate_cidata(&public_keys)?, diff --git a/nexus/src/app/sagas/instance_migrate.rs b/nexus/src/app/sagas/instance_migrate.rs index 373c1585f76..eac1c8e6e2d 100644 --- a/nexus/src/app/sagas/instance_migrate.rs +++ b/nexus/src/app/sagas/instance_migrate.rs @@ -221,12 +221,16 @@ async fn sim_instance_migrate( } let source_nat = SourceNatConfig::from(snat_ip.into_iter().next().unwrap()); + // The TODOs below are tracked in + // https://github.com/oxidecomputer/omicron/issues/1783 let instance_hardware = InstanceHardware { runtime: runtime.into(), // TODO: populate NICs nics: vec![], source_nat, external_ips, + // TODO: populate firewall rules + firewall_rules: vec![], // TODO: populate disks disks: vec![], // TODO: populate cloud init bytes diff --git a/nexus/src/app/vpc.rs b/nexus/src/app/vpc.rs index 9c6950abbaa..121e73d05e3 100644 --- a/nexus/src/app/vpc.rs +++ b/nexus/src/app/vpc.rs @@ -7,6 +7,8 @@ use crate::authz; use crate::context::OpContext; use crate::db; +use crate::db::identity::Asset; +use crate::db::identity::Resource; use crate::db::lookup::LookupPath; use crate::db::model::Name; use crate::db::model::VpcRouterKind; @@ -17,6 +19,7 @@ use omicron_common::api::external; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; use omicron_common::api::external::IdentityMetadataCreateParams; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; @@ -27,6 +30,13 @@ use omicron_common::api::external::RouterRouteCreateParams; use omicron_common::api::external::RouterRouteKind; use omicron_common::api::external::UpdateResult; use omicron_common::api::external::VpcFirewallRuleUpdateParams; +use sled_agent_client::types::IpNet; +use sled_agent_client::types::NetworkInterface; + +use futures::future::join_all; +use ipnetwork::IpNetwork; +use std::collections::{HashMap, HashSet}; +use std::net::IpAddr; use uuid::Uuid; impl super::Nexus { @@ -166,13 +176,19 @@ impl super::Nexus { } SubnetError::External(e) => e, })?; - let rules = db::model::VpcFirewallRule::vec_from_params( - authz_vpc.id(), - defaults::DEFAULT_FIREWALL_RULES.clone(), - ); + + // Save and send the default firewall rules for the new VPC. + let rules = self + .default_firewall_rules_for_vpc( + authz_vpc.id(), + params.identity.name.clone().into(), + ) + .await?; self.db_datastore - .vpc_update_firewall_rules(opctx, &authz_vpc, rules) + .vpc_update_firewall_rules(opctx, &authz_vpc, rules.clone()) .await?; + self.send_sled_agents_firewall_rules(opctx, &db_vpc, &rules).await?; + Ok(db_vpc) } @@ -315,18 +331,435 @@ impl super::Nexus { vpc_name: &Name, params: &VpcFirewallRuleUpdateParams, ) -> UpdateResult> { - let (.., authz_vpc) = LookupPath::new(opctx, &self.db_datastore) - .organization_name(organization_name) - .project_name(project_name) - .vpc_name(vpc_name) - .lookup_for(authz::Action::Modify) - .await?; + let (.., authz_vpc, db_vpc) = + LookupPath::new(opctx, &self.db_datastore) + .organization_name(organization_name) + .project_name(project_name) + .vpc_name(vpc_name) + .fetch_for(authz::Action::Modify) + .await?; let rules = db::model::VpcFirewallRule::vec_from_params( authz_vpc.id(), params.clone(), ); - self.db_datastore + let rules = self + .db_datastore .vpc_update_firewall_rules(opctx, &authz_vpc, rules) - .await + .await?; + self.send_sled_agents_firewall_rules(opctx, &db_vpc, &rules).await?; + Ok(rules) + } + + /// Customize the default firewall rules for a particular VPC + /// by replacing the name `default` with the VPC's actual name. + async fn default_firewall_rules_for_vpc( + &self, + vpc_id: Uuid, + vpc_name: Name, + ) -> Result, Error> { + let mut rules = db::model::VpcFirewallRule::vec_from_params( + vpc_id, + defaults::DEFAULT_FIREWALL_RULES.clone(), + ); + for rule in rules.iter_mut() { + for target in rule.targets.iter_mut() { + match target.0 { + external::VpcFirewallRuleTarget::Vpc(ref mut name) + if name.as_str() == "default" => + { + *name = vpc_name.clone().into() + } + _ => { + return Err(external::Error::internal_error( + "unexpected target in default firewall rule", + )) + } + } + if let Some(ref mut filter_hosts) = rule.filter_hosts { + for host in filter_hosts.iter_mut() { + match host.0 { + external::VpcFirewallRuleHostFilter::Vpc( + ref mut name, + ) if name.as_str() == "default" => { + *name = vpc_name.clone().into() + } + _ => return Err(external::Error::internal_error( + "unexpected host filter in default firewall rule" + )), + } + } + } + } + } + debug!(self.log, "default firewall rules for vpc {}", vpc_name; "rules" => ?&rules); + Ok(rules) + } + + async fn send_sled_agents_firewall_rules( + &self, + opctx: &OpContext, + vpc: &db::model::Vpc, + rules: &[db::model::VpcFirewallRule], + ) -> Result<(), Error> { + let rules_for_sled = self + .resolve_firewall_rules_for_sled_agent(opctx, &vpc, rules) + .await?; + debug!(self.log, "resolved {} rules for sleds", rules_for_sled.len()); + let sled_rules_request = + sled_agent_client::types::VpcFirewallRulesEnsureBody { + rules: rules_for_sled, + }; + + let vpc_to_sleds = + self.db_datastore.vpc_resolve_to_sleds(vpc.id()).await?; + debug!(self.log, "resolved sleds for vpc {}", vpc.name(); "vpc_to_sled" => ?vpc_to_sleds); + + let mut sled_requests = Vec::with_capacity(vpc_to_sleds.len()); + for sled in &vpc_to_sleds { + let sled_id = sled.id(); + let vpc_id = vpc.id(); + let sled_rules_request = sled_rules_request.clone(); + sled_requests.push(async move { + self.sled_client(&sled_id) + .await? + .vpc_firewall_rules_put(&vpc_id, &sled_rules_request) + .await + .map_err(|e| Error::internal_error(&e.to_string())) + }); + } + + debug!(self.log, "sending firewall rules to sled agents"); + let results = join_all(sled_requests).await; + // TODO-correctness: handle more than one failure in the sled-agent requests + // https://github.com/oxidecomputer/omicron/issues/1791 + for (sled, result) in vpc_to_sleds.iter().zip(results) { + if let Err(e) = result { + warn!(self.log, "failed to update firewall rules on sled agent"; + "sled_id" => %sled.id(), + "vpc_id" => %vpc.id(), + "error" => %e); + return Err(e); + } + } + info!( + self.log, + "updated firewall rules on {} sleds", + vpc_to_sleds.len() + ); + + Ok(()) + } + + pub(crate) async fn resolve_firewall_rules_for_sled_agent( + &self, + opctx: &OpContext, + vpc: &db::model::Vpc, + rules: &[db::model::VpcFirewallRule], + ) -> Result, Error> { + // Collect the names of instances, subnets, and VPCs that are either + // targets or host filters. We have to find the sleds for all the + // targets, and we'll need information about the IP addresses or + // subnets for things that are specified as host filters as well. + let mut instances: HashSet = HashSet::new(); + let mut subnets: HashSet = HashSet::new(); + let mut vpcs: HashSet = HashSet::new(); + for rule in rules { + for target in &rule.targets { + match &target.0 { + external::VpcFirewallRuleTarget::Instance(name) => { + instances.insert(name.clone().into()); + } + external::VpcFirewallRuleTarget::Subnet(name) => { + subnets.insert(name.clone().into()); + } + external::VpcFirewallRuleTarget::Vpc(name) => { + if name != vpc.name() { + return Err(Error::invalid_request( + "cross-VPC firewall target unsupported", + )); + } + vpcs.insert(name.clone().into()); + } + external::VpcFirewallRuleTarget::Ip(_) + | external::VpcFirewallRuleTarget::IpNet(_) => { + vpcs.insert(vpc.name().clone().into()); + } + } + } + + for host in rule.filter_hosts.iter().flatten() { + match &host.0 { + external::VpcFirewallRuleHostFilter::Instance(name) => { + instances.insert(name.clone().into()); + } + external::VpcFirewallRuleHostFilter::Subnet(name) => { + subnets.insert(name.clone().into()); + } + external::VpcFirewallRuleHostFilter::Vpc(name) => { + if name != vpc.name() { + return Err(Error::invalid_request( + "cross-VPC firewall host filter unsupported", + )); + } + vpcs.insert(name.clone().into()); + } + // We don't need to resolve anything for Ip(Net)s. + external::VpcFirewallRuleHostFilter::Ip(_) => (), + external::VpcFirewallRuleHostFilter::IpNet(_) => (), + } + } + } + + // Resolve named instances, VPCs, and subnets. + // TODO-correctness: It's possible the resolving queries produce + // inconsistent results due to concurrent changes. They should be + // transactional. + type NetMap = HashMap>; + type NicMap = HashMap>; + let no_networks: Vec = Vec::new(); + let no_interfaces: Vec = Vec::new(); + + let mut instance_interfaces: NicMap = HashMap::new(); + for instance_name in &instances { + if let Ok((.., authz_instance)) = + LookupPath::new(opctx, &self.db_datastore) + .project_id(vpc.project_id) + .instance_name(instance_name) + .lookup_for(authz::Action::ListChildren) + .await + { + for iface in self + .db_datastore + .derive_guest_network_interface_info(opctx, &authz_instance) + .await? + { + instance_interfaces + .entry(instance_name.0.clone()) + .or_insert_with(Vec::new) + .push(iface); + } + } + } + + let mut vpc_interfaces: NicMap = HashMap::new(); + for vpc_name in &vpcs { + if let Ok((.., authz_vpc)) = + LookupPath::new(opctx, &self.db_datastore) + .project_id(vpc.project_id) + .vpc_name(vpc_name) + .lookup_for(authz::Action::ListChildren) + .await + { + for iface in self + .db_datastore + .derive_vpc_network_interface_info(opctx, &authz_vpc) + .await? + { + vpc_interfaces + .entry(vpc_name.0.clone()) + .or_insert_with(Vec::new) + .push(iface); + } + } + } + + let mut subnet_interfaces: NicMap = HashMap::new(); + for subnet_name in &subnets { + if let Ok((.., authz_subnet)) = + LookupPath::new(opctx, &self.db_datastore) + .project_id(vpc.project_id) + .vpc_name(&Name::from(vpc.name().clone())) + .vpc_subnet_name(subnet_name) + .lookup_for(authz::Action::ListChildren) + .await + { + for iface in self + .db_datastore + .derive_subnet_network_interface_info(opctx, &authz_subnet) + .await? + { + subnet_interfaces + .entry(subnet_name.0.clone()) + .or_insert_with(Vec::new) + .push(iface); + } + } + } + + let subnet_networks: NetMap = self + .db_datastore + .resolve_vpc_subnets_to_ip_networks(vpc, subnets) + .await? + .into_iter() + .map(|(name, v)| (name.0, v)) + .collect(); + + debug!( + self.log, + "resolved names for firewall rules"; + "instance_interfaces" => ?instance_interfaces, + "vpc_interfaces" => ?vpc_interfaces, + "subnet_interfaces" => ?subnet_interfaces, + "subnet_networks" => ?subnet_networks, + ); + + // Compile resolved rules for the sled agents. + let mut sled_agent_rules = Vec::with_capacity(rules.len()); + for rule in rules { + // TODO: what is the correct behavior when a name is not found? + // Options: + // (1) Fail update request (though note this can still arise + // from things like instance deletion) + // (2) Allow update request, ignore this rule (but store it + // in case it becomes valid later). This is consistent + // with the semantics of the rules. Rules with bad + // references should likely at least be flagged to users. + // We currently adopt option (2), as this allows users to add + // firewall rules (including default rules) before instances + // and their interfaces are instantiated. + + // Collect unique network interface targets. + // This would be easier if `NetworkInterface` were `Hash`, + // but that's not easy because it's a generated type. We + // use the pair (VNI, MAC) as a unique interface identifier. + let mut nics = HashSet::new(); + let mut targets = Vec::with_capacity(rule.targets.len()); + let mut push_target_nic = |nic: &NetworkInterface| { + if nics.insert((*nic.vni, (*nic.mac).clone())) { + targets.push(nic.clone()); + } + }; + for target in &rule.targets { + match &target.0 { + external::VpcFirewallRuleTarget::Vpc(name) => { + vpc_interfaces + .get(&name) + .unwrap_or(&no_interfaces) + .iter() + .for_each(&mut push_target_nic); + } + external::VpcFirewallRuleTarget::Subnet(name) => { + subnet_interfaces + .get(&name) + .unwrap_or(&no_interfaces) + .iter() + .for_each(&mut push_target_nic); + } + external::VpcFirewallRuleTarget::Instance(name) => { + instance_interfaces + .get(&name) + .unwrap_or(&no_interfaces) + .iter() + .for_each(&mut push_target_nic); + } + external::VpcFirewallRuleTarget::Ip(addr) => { + vpc_interfaces + .get(vpc.name()) + .unwrap_or(&no_interfaces) + .iter() + .filter(|nic| nic.ip == *addr) + .for_each(&mut push_target_nic); + } + external::VpcFirewallRuleTarget::IpNet(net) => { + vpc_interfaces + .get(vpc.name()) + .unwrap_or(&no_interfaces) + .iter() + .filter(|nic| match (net, nic.ip) { + (external::IpNet::V4(net), IpAddr::V4(ip)) => { + net.contains(ip) + } + (external::IpNet::V6(net), IpAddr::V6(ip)) => { + net.contains(ip) + } + (_, _) => false, + }) + .for_each(&mut push_target_nic); + } + } + } + if !rule.targets.is_empty() && targets.is_empty() { + // Target not found; skip this rule. + continue; + } + + let filter_hosts = match &rule.filter_hosts { + None => None, + Some(hosts) => { + let mut host_addrs = Vec::with_capacity(hosts.len()); + for host in hosts { + match &host.0 { + external::VpcFirewallRuleHostFilter::Instance( + name, + ) => { + for interface in instance_interfaces + .get(&name) + .unwrap_or(&no_interfaces) + { + host_addrs.push(IpNet::from(interface.ip)) + } + } + external::VpcFirewallRuleHostFilter::Subnet( + name, + ) => { + for subnet in subnet_networks + .get(&name) + .unwrap_or(&no_networks) + { + host_addrs.push(IpNet::from(*subnet)); + } + } + external::VpcFirewallRuleHostFilter::Ip(addr) => { + host_addrs.push(IpNet::from(*addr)) + } + external::VpcFirewallRuleHostFilter::IpNet(net) => { + host_addrs.push(IpNet::from(*net)) + } + external::VpcFirewallRuleHostFilter::Vpc(name) => { + for interface in vpc_interfaces + .get(&name) + .unwrap_or(&no_interfaces) + { + host_addrs.push(IpNet::from(interface.ip)) + } + } + } + } + if !hosts.is_empty() && host_addrs.is_empty() { + // Filter host not found; skip this rule. + continue; + } + Some(host_addrs) + } + }; + + let filter_ports = rule + .filter_ports + .as_ref() + .map(|ports| ports.iter().map(|v| v.0.into()).collect()); + + let filter_protocols = + rule.filter_protocols.as_ref().map(|protocols| { + protocols.iter().map(|v| v.0.into()).collect() + }); + + sled_agent_rules.push(sled_agent_client::types::VpcFirewallRule { + status: rule.status.0.into(), + direction: rule.direction.0.into(), + targets, + filter_hosts, + filter_ports, + filter_protocols, + action: rule.action.0.into(), + priority: rule.priority.0 .0, + }); + } + debug!( + self.log, + "resolved firewall rules for sled agents"; + "sled_agent_rules" => ?sled_agent_rules, + ); + + Ok(sled_agent_rules) } } diff --git a/nexus/src/db/datastore/network_interface.rs b/nexus/src/db/datastore/network_interface.rs index 45acdcb0f82..c49ea2dc7da 100644 --- a/nexus/src/db/datastore/network_interface.rs +++ b/nexus/src/db/datastore/network_interface.rs @@ -10,6 +10,7 @@ use crate::context::OpContext; use crate::db; use crate::db::collection_insert::AsyncInsertError; use crate::db::collection_insert::DatastoreCollection; +use crate::db::cte_utils::BoxedQuery; use crate::db::error::public_error_from_diesel_pool; use crate::db::error::ErrorHandler; use crate::db::error::TransactionError; @@ -35,6 +36,39 @@ use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; use sled_agent_client::types as sled_client_types; +/// OPTE requires information that's currently split across the network +/// interface and VPC subnet tables. +#[derive(Debug, diesel::Queryable)] +struct NicInfo { + name: db::model::Name, + ip: ipnetwork::IpNetwork, + mac: db::model::MacAddr, + ipv4_block: db::model::Ipv4Net, + ipv6_block: db::model::Ipv6Net, + vni: db::model::Vni, + primary: bool, + slot: i16, +} + +impl From for sled_client_types::NetworkInterface { + fn from(nic: NicInfo) -> sled_client_types::NetworkInterface { + let ip_subnet = if nic.ip.is_ipv4() { + external::IpNet::V4(nic.ipv4_block.0) + } else { + external::IpNet::V6(nic.ipv6_block.0) + }; + sled_client_types::NetworkInterface { + name: sled_client_types::Name::from(&nic.name.0), + ip: nic.ip.ip(), + mac: sled_client_types::MacAddr::from(nic.mac.0), + subnet: sled_client_types::IpNet::from(ip_subnet), + vni: sled_client_types::Vni::from(nic.vni.0), + primary: nic.primary, + slot: u8::try_from(nic.slot).unwrap(), + } + } +} + impl DataStore { /// Create a network interface attached to the provided instance. pub async fn instance_create_network_interface( @@ -145,57 +179,20 @@ impl DataStore { Ok(()) } - /// Return the information about an instance's network interfaces required - /// for the sled agent to instantiate them via OPTE. - /// - /// OPTE requires information that's currently split across the network - /// interface and VPC subnet tables. This query just joins those for each - /// NIC in the given instance. - pub(crate) async fn derive_guest_network_interface_info( + /// Return information about network interfaces required for the sled + /// agent to instantiate or modify them via OPTE. This function takes + /// a partially constructed query over the network interface table so + /// that we can use it for instances, VPCs, and subnets. + async fn derive_network_interface_info( &self, opctx: &OpContext, - authz_instance: &authz::Instance, + partial_query: BoxedQuery, ) -> ListResultVec { - opctx.authorize(authz::Action::ListChildren, authz_instance).await?; - use db::schema::network_interface; use db::schema::vpc; use db::schema::vpc_subnet; - // The record type for the results of the below JOIN query - #[derive(Debug, diesel::Queryable)] - struct NicInfo { - name: db::model::Name, - ip: ipnetwork::IpNetwork, - mac: db::model::MacAddr, - ipv4_block: db::model::Ipv4Net, - ipv6_block: db::model::Ipv6Net, - vni: db::model::Vni, - primary: bool, - slot: i16, - } - - impl From for sled_client_types::NetworkInterface { - fn from(nic: NicInfo) -> sled_client_types::NetworkInterface { - let ip_subnet = if nic.ip.is_ipv4() { - external::IpNet::V4(nic.ipv4_block.0) - } else { - external::IpNet::V6(nic.ipv6_block.0) - }; - sled_client_types::NetworkInterface { - name: sled_client_types::Name::from(&nic.name.0), - ip: nic.ip.ip(), - mac: sled_client_types::MacAddr::from(nic.mac.0), - subnet: sled_client_types::IpNet::from(ip_subnet), - vni: sled_client_types::Vni::from(nic.vni.0), - primary: nic.primary, - slot: u8::try_from(nic.slot).unwrap(), - } - } - } - - let rows = network_interface::table - .filter(network_interface::instance_id.eq(authz_instance.id())) + let rows = partial_query .filter(network_interface::time_deleted.is_null()) .inner_join( vpc_subnet::table @@ -227,6 +224,63 @@ impl DataStore { .collect()) } + /// Return the information about an instance's network interfaces required + /// for the sled agent to instantiate them via OPTE. + pub(crate) async fn derive_guest_network_interface_info( + &self, + opctx: &OpContext, + authz_instance: &authz::Instance, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, authz_instance).await?; + + use db::schema::network_interface; + self.derive_network_interface_info( + opctx, + network_interface::table + .filter(network_interface::instance_id.eq(authz_instance.id())) + .into_boxed(), + ) + .await + } + + /// Return information about all VNICs connected to a VPC required + /// for the sled agent to instantiate firewall rules via OPTE. + pub(crate) async fn derive_vpc_network_interface_info( + &self, + opctx: &OpContext, + authz_vpc: &authz::Vpc, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, authz_vpc).await?; + + use db::schema::network_interface; + self.derive_network_interface_info( + opctx, + network_interface::table + .filter(network_interface::vpc_id.eq(authz_vpc.id())) + .into_boxed(), + ) + .await + } + + /// Return information about all VNICs connected to a VpcSubnet required + /// for the sled agent to instantiate firewall rules via OPTE. + pub(crate) async fn derive_subnet_network_interface_info( + &self, + opctx: &OpContext, + authz_subnet: &authz::VpcSubnet, + ) -> ListResultVec { + opctx.authorize(authz::Action::ListChildren, authz_subnet).await?; + + use db::schema::network_interface; + self.derive_network_interface_info( + opctx, + network_interface::table + .filter(network_interface::subnet_id.eq(authz_subnet.id())) + .into_boxed(), + ) + .await + } + /// List network interfaces associated with a given instance. pub async fn instance_list_network_interfaces( &self, diff --git a/nexus/src/db/datastore/vpc.rs b/nexus/src/db/datastore/vpc.rs index 8b0f5abbfbb..82b210ab473 100644 --- a/nexus/src/db/datastore/vpc.rs +++ b/nexus/src/db/datastore/vpc.rs @@ -20,6 +20,8 @@ use crate::db::model::Name; use crate::db::model::NetworkInterface; use crate::db::model::RouterRoute; use crate::db::model::RouterRouteUpdate; +use crate::db::model::Sled; +use crate::db::model::Vni; use crate::db::model::Vpc; use crate::db::model::VpcFirewallRule; use crate::db::model::VpcRouter; @@ -27,6 +29,7 @@ use crate::db::model::VpcRouterUpdate; use crate::db::model::VpcSubnet; use crate::db::model::VpcSubnetUpdate; use crate::db::model::VpcUpdate; +use crate::db::model::{Ipv4Net, Ipv6Net}; use crate::db::pagination::paginated; use crate::db::queries::vpc::InsertVpcQuery; use crate::db::queries::vpc_subnet::FilterConflictingVpcSubnetRangesQuery; @@ -35,14 +38,17 @@ use async_bb8_diesel::AsyncConnection; use async_bb8_diesel::AsyncRunQueryDsl; use chrono::Utc; use diesel::prelude::*; +use ipnetwork::IpNetwork; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DataPageParams; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; use omicron_common::api::external::ListResultVec; +use omicron_common::api::external::LookupResult; use omicron_common::api::external::LookupType; use omicron_common::api::external::ResourceType; use omicron_common::api::external::UpdateResult; +use std::collections::BTreeMap; use uuid::Uuid; impl DataStore { @@ -318,6 +324,31 @@ impl DataStore { }) } + /// Return the list of `Sled`s hosting instances with network interfaces + /// on the provided VPC. + pub async fn vpc_resolve_to_sleds( + &self, + vpc_id: Uuid, + ) -> Result, Error> { + // Resolve each VNIC in the VPC to the Sled it's on, so we know which + // Sleds to notify when firewall rules change. + use db::schema::{instance, network_interface, sled}; + network_interface::table + .inner_join( + instance::table + .on(instance::id.eq(network_interface::instance_id)), + ) + .inner_join(sled::table.on(sled::id.eq(instance::active_server_id))) + .filter(network_interface::vpc_id.eq(vpc_id)) + .filter(network_interface::time_deleted.is_null()) + .filter(instance::time_deleted.is_null()) + .select(Sled::as_select()) + .distinct() + .get_results_async(self.pool()) + .await + .map_err(|e| public_error_from_diesel_pool(e, ErrorHandler::Server)) + } + pub async fn vpc_list_subnets( &self, opctx: &OpContext, @@ -675,4 +706,68 @@ impl DataStore { ) }) } + + /// Identify all subnets in use by each VpcSubnet + pub async fn resolve_vpc_subnets_to_ip_networks< + T: IntoIterator, + >( + &self, + vpc: &Vpc, + subnet_names: T, + ) -> Result>, Error> { + #[derive(diesel::Queryable)] + struct SubnetIps { + name: Name, + ipv4_block: Ipv4Net, + ipv6_block: Ipv6Net, + } + + use db::schema::vpc_subnet; + let subnets = vpc_subnet::table + .filter(vpc_subnet::vpc_id.eq(vpc.id())) + .filter(vpc_subnet::name.eq_any(subnet_names)) + .filter(vpc_subnet::time_deleted.is_null()) + .select(( + vpc_subnet::name, + vpc_subnet::ipv4_block, + vpc_subnet::ipv6_block, + )) + .get_results_async::(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool(e, ErrorHandler::Server) + })?; + + let mut result = BTreeMap::new(); + for subnet in subnets { + let entry = result.entry(subnet.name).or_insert_with(Vec::new); + entry.push(IpNetwork::V4(subnet.ipv4_block.0 .0)); + entry.push(IpNetwork::V6(subnet.ipv6_block.0 .0)); + } + Ok(result) + } + + /// Look up a VPC by VNI. + pub async fn resolve_vni_to_vpc( + &self, + opctx: &OpContext, + vni: Vni, + ) -> LookupResult { + use db::schema::vpc::dsl; + dsl::vpc + .filter(dsl::vni.eq(vni)) + .filter(dsl::time_deleted.is_null()) + .select(Vpc::as_select()) + .get_result_async(self.pool_authorized(opctx).await?) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ErrorHandler::NotFoundByLookup( + ResourceType::Vpc, + LookupType::ByCompositeId("VNI".to_string()), + ), + ) + }) + } } diff --git a/nexus/tests/integration_tests/vpc_firewall.rs b/nexus/tests/integration_tests/vpc_firewall.rs index e8242de4c23..d8eade9c5c9 100644 --- a/nexus/tests/integration_tests/vpc_firewall.rs +++ b/nexus/tests/integration_tests/vpc_firewall.rs @@ -46,7 +46,7 @@ async fn test_vpc_firewall(cptestctx: &ControlPlaneTestContext) { let default_vpc_firewall = format!("{}/firewall/rules", default_vpc_url); let rules = get_rules(client, &default_vpc_firewall).await; assert!(rules.iter().all(|r| r.vpc_id == default_vpc.identity.id)); - assert!(is_default_firewall_rules(&rules)); + assert!(is_default_firewall_rules("default", &rules)); // Create another VPC and make sure it gets the default rules. let other_vpc = "second-vpc"; @@ -55,7 +55,7 @@ async fn test_vpc_firewall(cptestctx: &ControlPlaneTestContext) { let vpc2 = create_vpc(&client, &org_name, &project_name, &other_vpc).await; let rules = get_rules(client, &other_vpc_firewall).await; assert!(rules.iter().all(|r| r.vpc_id == vpc2.identity.id)); - assert!(is_default_firewall_rules(&rules)); + assert!(is_default_firewall_rules(other_vpc, &rules)); // Modify one VPC's firewall let new_rules = vec![ @@ -106,21 +106,21 @@ async fn test_vpc_firewall(cptestctx: &ControlPlaneTestContext) { .parsed_body::() .unwrap() .rules; - assert!(!is_default_firewall_rules(&updated_rules)); + assert!(!is_default_firewall_rules("default", &updated_rules)); assert_eq!(updated_rules.len(), new_rules.len()); assert_eq!(updated_rules[0].identity.name, "allow-icmp"); assert_eq!(updated_rules[1].identity.name, "deny-all-incoming"); // Make sure the firewall is changed let rules = get_rules(client, &default_vpc_firewall).await; - assert!(!is_default_firewall_rules(&rules)); + assert!(!is_default_firewall_rules("default", &rules)); assert_eq!(rules.len(), new_rules.len()); assert_eq!(rules[0].identity.name, "allow-icmp"); assert_eq!(rules[1].identity.name, "deny-all-incoming"); // Make sure the other firewall is unchanged let rules = get_rules(client, &other_vpc_firewall).await; - assert!(is_default_firewall_rules(&rules)); + assert!(is_default_firewall_rules(other_vpc, &rules)); // DELETE is unsupported NexusRequest::expect_failure( @@ -177,7 +177,10 @@ async fn get_rules( .rules } -fn is_default_firewall_rules(rules: &Vec) -> bool { +fn is_default_firewall_rules( + vpc_name: &str, + rules: &Vec, +) -> bool { let default_rules = vec![ VpcFirewallRule { identity: IdentityMetadata { @@ -191,7 +194,7 @@ fn is_default_firewall_rules(rules: &Vec) -> bool { status: VpcFirewallRuleStatus::Enabled, direction: VpcFirewallRuleDirection::Inbound, targets: vec![VpcFirewallRuleTarget::Vpc( - "default".parse().unwrap(), + vpc_name.parse().unwrap(), )], filters: VpcFirewallRuleFilter { hosts: None, @@ -216,11 +219,11 @@ fn is_default_firewall_rules(rules: &Vec) -> bool { status: VpcFirewallRuleStatus::Enabled, direction: VpcFirewallRuleDirection::Inbound, targets: vec![VpcFirewallRuleTarget::Vpc( - "default".parse().unwrap(), + vpc_name.parse().unwrap(), )], filters: VpcFirewallRuleFilter { hosts: Some(vec![VpcFirewallRuleHostFilter::Vpc( - "default".parse().unwrap(), + vpc_name.parse().unwrap(), )]), protocols: None, ports: None, @@ -242,7 +245,7 @@ fn is_default_firewall_rules(rules: &Vec) -> bool { status: VpcFirewallRuleStatus::Enabled, direction: VpcFirewallRuleDirection::Inbound, targets: vec![VpcFirewallRuleTarget::Vpc( - "default".parse().unwrap(), + vpc_name.parse().unwrap(), )], filters: VpcFirewallRuleFilter { hosts: None, @@ -269,7 +272,7 @@ fn is_default_firewall_rules(rules: &Vec) -> bool { status: VpcFirewallRuleStatus::Enabled, direction: VpcFirewallRuleDirection::Inbound, targets: vec![VpcFirewallRuleTarget::Vpc( - "default".parse().unwrap(), + vpc_name.parse().unwrap(), )], filters: VpcFirewallRuleFilter { hosts: None, diff --git a/openapi/nexus.json b/openapi/nexus.json index 54dba50ab7a..195a2202d3d 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -9476,7 +9476,7 @@ "title": "An IPv4 subnet", "description": "An IPv4 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^(10\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\/([8-9]|1[0-9]|2[0-9]|3[0-2])|172\\.(1[6-9]|2[0-9]|3[0-1])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\/(1[2-9]|2[0-9]|3[0-2])|192\\.168\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\/(1[6-9]|2[0-9]|3[0-2]))$" + "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])/([8-9]|1[0-9]|2[0-9]|3[0-2])$" }, "Ipv4Range": { "description": "A non-decreasing IPv4 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 e420ce95f00..a39b8f46923 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -352,6 +352,44 @@ } } } + }, + "/vpc/{vpc_id}/firewall/rules": { + "put": { + "operationId": "vpc_firewall_rules_put", + "parameters": [ + { + "in": "path", + "name": "vpc_id", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VpcFirewallRulesEnsureBody" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "resource updated" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } } }, "components": { @@ -914,6 +952,12 @@ "format": "ip" } }, + "firewall_rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VpcFirewallRule" + } + }, "nics": { "type": "array", "items": { @@ -930,6 +974,7 @@ "required": [ "disks", "external_ips", + "firewall_rules", "nics", "runtime", "source_nat" @@ -1262,7 +1307,7 @@ "title": "An IPv4 subnet", "description": "An IPv4 subnet, including prefix and subnet mask", "type": "string", - "pattern": "^(10\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\/([8-9]|1[0-9]|2[0-9]|3[0-2])|172\\.(1[6-9]|2[0-9]|3[0-1])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\/(1[2-9]|2[0-9]|3[0-2])|192\\.168\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\/(1[6-9]|2[0-9]|3[0-2]))$" + "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])/([8-9]|1[0-9]|2[0-9]|3[0-2])$" }, "Ipv6Net": { "example": "fd12:3456::/64", @@ -1271,6 +1316,15 @@ "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}:)\\/([1-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8])$" }, + "L4PortRange": { + "example": "22", + "title": "A range of IP ports", + "description": "An inclusive-inclusive range of IP ports. The second port may be omitted to represent a single port", + "type": "string", + "pattern": "^[0-9]{1,5}(-[0-9]{1,5})?$", + "minLength": 1, + "maxLength": 11 + }, "MacAddr": { "example": "ff:ff:ff:ff:ff:ff", "title": "A MAC address", @@ -1660,6 +1714,105 @@ ] } ] + }, + "VpcFirewallRule": { + "description": "VPC firewall rule after object name resolution has been performed by Nexus", + "type": "object", + "properties": { + "action": { + "$ref": "#/components/schemas/VpcFirewallRuleAction" + }, + "direction": { + "$ref": "#/components/schemas/VpcFirewallRuleDirection" + }, + "filter_hosts": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/IpNet" + } + }, + "filter_ports": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/L4PortRange" + } + }, + "filter_protocols": { + "nullable": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/VpcFirewallRuleProtocol" + } + }, + "priority": { + "type": "integer", + "format": "uint16", + "minimum": 0 + }, + "status": { + "$ref": "#/components/schemas/VpcFirewallRuleStatus" + }, + "targets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NetworkInterface" + } + } + }, + "required": [ + "action", + "direction", + "priority", + "status", + "targets" + ] + }, + "VpcFirewallRuleAction": { + "type": "string", + "enum": [ + "allow", + "deny" + ] + }, + "VpcFirewallRuleDirection": { + "type": "string", + "enum": [ + "inbound", + "outbound" + ] + }, + "VpcFirewallRuleProtocol": { + "description": "The protocols that may be specified in a firewall rule's filter", + "type": "string", + "enum": [ + "TCP", + "UDP", + "ICMP" + ] + }, + "VpcFirewallRuleStatus": { + "type": "string", + "enum": [ + "disabled", + "enabled" + ] + }, + "VpcFirewallRulesEnsureBody": { + "description": "Update firewall rules for a VPC", + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "$ref": "#/components/schemas/VpcFirewallRule" + } + } + }, + "required": [ + "rules" + ] } } } diff --git a/sled-agent-client/Cargo.toml b/sled-agent-client/Cargo.toml index 6df37c698e6..29cb1e19f8a 100644 --- a/sled-agent-client/Cargo.toml +++ b/sled-agent-client/Cargo.toml @@ -6,6 +6,7 @@ license = "MPL-2.0" [dependencies] async-trait = "0.1" +ipnetwork = "0.20" progenitor = { git = "https://github.com/oxidecomputer/progenitor" } regress = "0.4.1" reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "stream"] } diff --git a/sled-agent-client/src/lib.rs b/sled-agent-client/src/lib.rs index 1d04733127c..9f22257d7dc 100644 --- a/sled-agent-client/src/lib.rs +++ b/sled-agent-client/src/lib.rs @@ -235,6 +235,58 @@ impl From for types::IpNet { } } +impl From for types::Ipv4Net { + fn from(n: ipnetwork::Ipv4Network) -> Self { + Self::try_from(n.to_string()).unwrap_or_else(|e| panic!("{}: {}", n, e)) + } +} + +impl From for types::Ipv6Net { + fn from(n: ipnetwork::Ipv6Network) -> Self { + Self::try_from(n.to_string()).unwrap_or_else(|e| panic!("{}: {}", n, e)) + } +} + +impl From for types::IpNet { + fn from(n: ipnetwork::IpNetwork) -> Self { + use ipnetwork::IpNetwork; + match n { + IpNetwork::V4(v4) => Self::V4(v4.into()), + IpNetwork::V6(v6) => Self::V6(v6.into()), + } + } +} + +impl From for types::Ipv4Net { + fn from(n: std::net::Ipv4Addr) -> Self { + Self::try_from(format!("{n}/32")) + .unwrap_or_else(|e| panic!("{}: {}", n, e)) + } +} + +impl From for types::Ipv6Net { + fn from(n: std::net::Ipv6Addr) -> Self { + Self::try_from(format!("{n}/128")) + .unwrap_or_else(|e| panic!("{}: {}", n, e)) + } +} + +impl From for types::IpNet { + fn from(s: std::net::IpAddr) -> Self { + use std::net::IpAddr; + match s { + IpAddr::V4(v4) => Self::V4(v4.into()), + IpAddr::V6(v6) => Self::V6(v6.into()), + } + } +} + +impl From for types::L4PortRange { + fn from(s: omicron_common::api::external::L4PortRange) -> Self { + Self::try_from(s.to_string()).unwrap_or_else(|e| panic!("{}: {}", s, e)) + } +} + impl From for types::UpdateArtifact { @@ -261,6 +313,57 @@ impl From } } +impl From + for types::VpcFirewallRuleAction +{ + fn from(s: omicron_common::api::external::VpcFirewallRuleAction) -> Self { + use omicron_common::api::external::VpcFirewallRuleAction::*; + match s { + Allow => Self::Allow, + Deny => Self::Deny, + } + } +} + +impl From + for types::VpcFirewallRuleDirection +{ + fn from( + s: omicron_common::api::external::VpcFirewallRuleDirection, + ) -> Self { + use omicron_common::api::external::VpcFirewallRuleDirection::*; + match s { + Inbound => Self::Inbound, + Outbound => Self::Outbound, + } + } +} + +impl From + for types::VpcFirewallRuleStatus +{ + fn from(s: omicron_common::api::external::VpcFirewallRuleStatus) -> Self { + use omicron_common::api::external::VpcFirewallRuleStatus::*; + match s { + Enabled => Self::Enabled, + Disabled => Self::Disabled, + } + } +} + +impl From + for types::VpcFirewallRuleProtocol +{ + fn from(s: omicron_common::api::external::VpcFirewallRuleProtocol) -> Self { + use omicron_common::api::external::VpcFirewallRuleProtocol::*; + match s { + Tcp => Self::Tcp, + Udp => Self::Udp, + Icmp => Self::Icmp, + } + } +} + /// Exposes additional [`Client`] interfaces for use by the test suite. These /// are bonus endpoints, not generated in the real client. #[async_trait] diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index 9547405cc44..e2e8bae0cd2 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -7,6 +7,7 @@ use crate::params::{ DatasetEnsureBody, DiskEnsureBody, InstanceEnsureBody, InstanceSerialConsoleData, InstanceSerialConsoleRequest, ServiceEnsureBody, + VpcFirewallRulesEnsureBody, }; use crate::serial::ByteOffset; use dropshot::{ @@ -39,6 +40,7 @@ pub fn api() -> SledApiDescription { api.register(instance_serial_get)?; api.register(instance_issue_disk_snapshot_request)?; api.register(issue_disk_snapshot_request)?; + api.register(vpc_firewall_rules_put)?; Ok(()) } @@ -290,3 +292,29 @@ async fn issue_disk_snapshot_request( snapshot_id: body.snapshot_id, })) } + +/// Path parameters for VPC requests (sled agent API) +#[derive(Deserialize, JsonSchema)] +struct VpcPathParam { + vpc_id: Uuid, +} + +#[endpoint { + method = PUT, + path = "/vpc/{vpc_id}/firewall/rules", +}] +async fn vpc_firewall_rules_put( + rqctx: Arc>, + path_params: Path, + body: TypedBody, +) -> Result { + let sa = rqctx.context(); + let vpc_id = path_params.into_inner().vpc_id; + let body_args = body.into_inner(); + + sa.firewall_rules_ensure(vpc_id, &body_args.rules[..]) + .await + .map_err(Error::from)?; + + Ok(HttpResponseUpdatedNoContent()) +} diff --git a/sled-agent/src/instance.rs b/sled-agent/src/instance.rs index e40d3e94b42..7482c5fa7f6 100644 --- a/sled-agent/src/instance.rs +++ b/sled-agent/src/instance.rs @@ -18,6 +18,7 @@ use crate::opte::PortManager; use crate::opte::PortTicket; use crate::params::NetworkInterface; use crate::params::SourceNatConfig; +use crate::params::VpcFirewallRule; use crate::params::{ InstanceHardware, InstanceMigrateParams, InstanceRuntimeStateRequested, InstanceSerialConsoleData, @@ -225,6 +226,7 @@ struct InstanceInner { requested_nics: Vec, source_nat: SourceNatConfig, external_ips: Vec, + firewall_rules: Vec, // Disk related properties requested_disks: Vec, @@ -492,6 +494,7 @@ impl Instance { requested_nics: initial.nics, source_nat: initial.source_nat, external_ips: initial.external_ips, + firewall_rules: initial.firewall_rules, requested_disks: initial.disks, cloud_init_bytes: initial.cloud_init_bytes, state: InstanceStates::new(initial.runtime), @@ -541,6 +544,7 @@ impl Instance { nic, snat, external_ips, + &inner.firewall_rules, )?; opte_ports.push(port); port_tickets.push(port_ticket); @@ -896,6 +900,7 @@ mod test { last_port: 16_384, }, external_ips: vec![], + firewall_rules: vec![], disks: vec![], cloud_init_bytes: None, } diff --git a/sled-agent/src/instance_manager.rs b/sled-agent/src/instance_manager.rs index 733bc29c2f9..1d5f7420800 100644 --- a/sled-agent/src/instance_manager.rs +++ b/sled-agent/src/instance_manager.rs @@ -10,7 +10,7 @@ use crate::nexus::LazyNexusClient; use crate::opte::PortManager; use crate::params::{ InstanceHardware, InstanceMigrateParams, InstanceRuntimeStateRequested, - InstanceSerialConsoleData, + InstanceSerialConsoleData, VpcFirewallRule, }; use crate::serial::ByteOffset; use macaddr::MacAddr6; @@ -212,6 +212,19 @@ impl InstanceManager { .await .map_err(Error::from) } + + pub async fn firewall_rules_ensure( + &self, + rules: &[VpcFirewallRule], + ) -> Result<(), Error> { + info!( + &self.inner.log, + "Ensuring VPC firewall rules"; + "rules" => ?&rules, + ); + self.inner.port_manager.firewall_rules_ensure(rules)?; + Ok(()) + } } /// Represents membership of an instance in the [`InstanceManager`]. @@ -290,6 +303,7 @@ mod test { last_port: 1 << 14 - 1, }, external_ips: vec![], + firewall_rules: vec![], disks: vec![], cloud_init_bytes: None, } diff --git a/sled-agent/src/opte/illumos/firewall_rules.rs b/sled-agent/src/opte/illumos/firewall_rules.rs new file mode 100644 index 00000000000..13c53358a89 --- /dev/null +++ b/sled-agent/src/opte/illumos/firewall_rules.rs @@ -0,0 +1,170 @@ +// 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/. + +//! Convert Omicron VPC firewall rules to OPTE firewall rules. + +use crate::opte::Vni; +use crate::params::VpcFirewallRule; +use macaddr::MacAddr6; +use omicron_common::api::external::IpNet; +use omicron_common::api::external::VpcFirewallRuleAction; +use omicron_common::api::external::VpcFirewallRuleDirection; +use omicron_common::api::external::VpcFirewallRuleProtocol; +use omicron_common::api::external::VpcFirewallRuleStatus; +use oxide_vpc::api::Action; +use oxide_vpc::api::Address; +use oxide_vpc::api::Direction; +use oxide_vpc::api::Filters; +use oxide_vpc::api::FirewallRule; +use oxide_vpc::api::Ipv4Cidr; +use oxide_vpc::api::Ipv4PrefixLen; +use oxide_vpc::api::Ports; +use oxide_vpc::api::ProtoFilter; +use oxide_vpc::api::Protocol; + +trait FromVpcFirewallRule { + fn action(self: &Self) -> Action; + fn direction(self: &Self) -> Direction; + fn disabled(self: &Self) -> bool; + fn hosts(self: &Self) -> Vec
; + fn ports(self: &Self) -> Ports; + fn priority(self: &Self) -> u16; + fn protos(self: &Self) -> Vec; +} + +impl FromVpcFirewallRule for VpcFirewallRule { + fn action(self: &Self) -> Action { + match self.action { + VpcFirewallRuleAction::Allow => Action::Allow, + VpcFirewallRuleAction::Deny => Action::Deny, + } + } + + fn direction(self: &Self) -> Direction { + match self.direction { + VpcFirewallRuleDirection::Inbound => Direction::In, + VpcFirewallRuleDirection::Outbound => Direction::Out, + } + } + + fn disabled(self: &Self) -> bool { + match self.status { + VpcFirewallRuleStatus::Disabled => false, + VpcFirewallRuleStatus::Enabled => true, + } + } + + fn hosts(self: &Self) -> Vec
{ + self.filter_hosts.as_ref().map_or_else( + || vec![Address::Any], + |hosts| { + hosts + .iter() + .map(|host| match host { + IpNet::V4(net) if net.prefix() == 32 => { + Address::Ip(net.ip().into()) + } + IpNet::V4(net) => Address::Subnet(Ipv4Cidr::new( + net.ip().into(), + Ipv4PrefixLen::new(net.prefix()).unwrap(), + )), + IpNet::V6(_net) => { + todo!("IPv6 host filters") + } + }) + .collect::>() + }, + ) + } + + fn ports(self: &Self) -> Ports { + match self.filter_ports { + Some(ref ports) if ports.len() > 0 => Ports::PortList( + ports + .iter() + .flat_map(|range| { + (range.first.0.get()..=range.last.0.get()) + .collect::>() + }) + .collect::>(), + ), + _ => Ports::Any, + } + } + + fn priority(self: &Self) -> u16 { + self.priority.0 + } + + fn protos(self: &Self) -> Vec { + self.filter_protocols.as_ref().map_or_else( + || vec![ProtoFilter::Any], + |protos| { + protos + .iter() + .map(|proto| { + ProtoFilter::Proto(match proto { + VpcFirewallRuleProtocol::Tcp => Protocol::TCP, + VpcFirewallRuleProtocol::Udp => Protocol::UDP, + VpcFirewallRuleProtocol::Icmp => Protocol::ICMP, + }) + }) + .collect::>() + }, + ) + } +} + +/// Translate from a slice of VPC firewall rules to a vector of OPTE rules +/// that match a given port's VNI and MAC address. OPTE rules can only encode +/// a single host address and protocol, so we must unroll rules with multiple +/// hosts/protocols. +pub fn opte_firewall_rules( + rules: &[VpcFirewallRule], + vni: &Vni, + mac: &MacAddr6, +) -> Vec { + rules + .iter() + .filter(|rule| rule.disabled()) + .filter(|rule| { + rule.targets.is_empty() // no targets means apply everywhere + || rule.targets.iter().any(|nic| { + // (VNI, MAC) is a unique identifier for the NIC. + u32::from(nic.vni) == u32::from(*vni) && nic.mac.0 == *mac + }) + }) + .map(|rule| { + let priority = rule.priority(); + let action = rule.action(); + let direction = rule.direction(); + let ports = rule.ports(); + let protos = rule.protos(); + let hosts = rule.hosts(); + protos + .iter() + .map(|proto| { + hosts + .iter() + .map(|hosts| FirewallRule { + priority, + action, + direction, + filters: { + let mut filters = Filters::new(); + filters + .set_hosts(hosts.clone()) + .set_protocol(proto.clone()) + .set_ports(ports.clone()); + filters + }, + }) + .collect::>() + }) + .collect::>>() + }) + .flatten() + .flatten() + .collect::>() +} diff --git a/sled-agent/src/opte/illumos/mod.rs b/sled-agent/src/opte/illumos/mod.rs index d5cf1201cd2..1aaadabb034 100644 --- a/sled-agent/src/opte/illumos/mod.rs +++ b/sled-agent/src/opte/illumos/mod.rs @@ -11,9 +11,11 @@ use slog::Logger; use std::fs; use std::path::Path; +mod firewall_rules; mod port; mod port_manager; +pub use firewall_rules::opte_firewall_rules; pub use port::Port; pub use port_manager::PortManager; pub use port_manager::PortTicket; diff --git a/sled-agent/src/opte/illumos/port.rs b/sled-agent/src/opte/illumos/port.rs index abe84b13930..63b24a12f46 100644 --- a/sled-agent/src/opte/illumos/port.rs +++ b/sled-agent/src/opte/illumos/port.rs @@ -28,7 +28,7 @@ struct PortInner { // Emulated PCI slot for the guest NIC, passed to Propolis slot: u8, // Geneve VNI for the VPC - _vni: Vni, + vni: Vni, // IP address of the hosting sled _underlay_ip: Ipv6Addr, // The external IP address and port range provided for this port, to allow @@ -122,7 +122,7 @@ impl Port { _subnet: subnet, mac, slot, - _vni: vni, + vni: vni, _underlay_ip: underlay_ip, _source_nat: source_nat, external_ips, @@ -141,6 +141,10 @@ impl Port { &self.inner.mac } + pub fn vni(&self) -> &Vni { + &self.inner.vni + } + pub fn vnic_name(&self) -> &str { &self.inner.vnic } diff --git a/sled-agent/src/opte/illumos/port_manager.rs b/sled-agent/src/opte/illumos/port_manager.rs index 46a656dcd64..9fa694f1aff 100644 --- a/sled-agent/src/opte/illumos/port_manager.rs +++ b/sled-agent/src/opte/illumos/port_manager.rs @@ -8,12 +8,14 @@ use crate::illumos::dladm::Dladm; use crate::illumos::dladm::PhysicalLink; use crate::illumos::dladm::VnicSource; use crate::opte::default_boundary_services; +use crate::opte::opte_firewall_rules; use crate::opte::Error; use crate::opte::Gateway; use crate::opte::Port; use crate::opte::Vni; use crate::params::NetworkInterface; use crate::params::SourceNatConfig; +use crate::params::VpcFirewallRule; use ipnetwork::IpNetwork; use macaddr::MacAddr6; use opte_ioctl::OpteHdl; @@ -26,6 +28,7 @@ use oxide_vpc::api::Ipv4PrefixLen; use oxide_vpc::api::MacAddr; use oxide_vpc::api::RouterTarget; use oxide_vpc::api::SNat4Cfg; +use oxide_vpc::api::SetFwRulesReq; use oxide_vpc::api::VpcCfg; use slog::debug; use slog::info; @@ -176,6 +179,7 @@ impl PortManager { nic: &NetworkInterface, source_nat: Option, external_ips: Option>, + firewall_rules: &[VpcFirewallRule], ) -> Result<(Port, PortTicket), Error> { // TODO-completess: Remove IPv4 restrictions once OPTE supports virtual // IPv6 networks. @@ -297,6 +301,19 @@ impl PortManager { "port_name" => &port_name, ); + // Initialize firewall rules for the new port. + let rules = opte_firewall_rules(firewall_rules, &vni, &mac); + debug!( + self.inner.log, + "Setting firewall rules"; + "port_name" => &port_name, + "rules" => ?&rules, + ); + hdl.set_fw_rules(&SetFwRulesReq { + port_name: port_name.clone(), + rules, + })?; + // Create a VNIC on top of this device, to hook Viona into. // // Viona is the illumos MAC provider that implements the VIRTIO @@ -447,6 +464,25 @@ impl PortManager { ); Ok((port, ticket)) } + + pub fn firewall_rules_ensure( + &self, + rules: &[VpcFirewallRule], + ) -> Result<(), Error> { + let hdl = OpteHdl::open(OpteHdl::DLD_CTL)?; + for ((_, port_name), port) in self.inner.ports.lock().unwrap().iter() { + let rules = opte_firewall_rules(rules, port.vni(), port.mac()); + let port_name = port_name.clone(); + info!( + self.inner.log, + "Setting OPTE firewall rules"; + "port" => ?&port_name, + "rules" => ?&rules, + ); + hdl.set_fw_rules(&SetFwRulesReq { port_name, rules })?; + } + Ok(()) + } } pub struct PortTicket { diff --git a/sled-agent/src/opte/non_illumos/port_manager.rs b/sled-agent/src/opte/non_illumos/port_manager.rs index c244cd230cd..371a1e32fae 100644 --- a/sled-agent/src/opte/non_illumos/port_manager.rs +++ b/sled-agent/src/opte/non_illumos/port_manager.rs @@ -12,6 +12,7 @@ use crate::opte::Port; use crate::opte::Vni; use crate::params::NetworkInterface; use crate::params::SourceNatConfig; +use crate::params::VpcFirewallRule; use ipnetwork::IpNetwork; use macaddr::MacAddr6; use slog::debug; @@ -111,6 +112,7 @@ impl PortManager { nic: &NetworkInterface, source_nat: Option, external_ips: Option>, + _firewall_rules: &[VpcFirewallRule], ) -> Result<(Port, PortTicket), Error> { // TODO-completess: Remove IPv4 restrictions once OPTE supports virtual // IPv6 networks. @@ -181,6 +183,14 @@ impl PortManager { ); Ok((port, ticket)) } + + pub fn firewall_rules_ensure( + &self, + rules: &[VpcFirewallRule], + ) -> Result<(), Error> { + info!(self.inner.log, "Ignoring {} firewall rules", rules.len()); + Ok(()) + } } pub struct PortTicket { diff --git a/sled-agent/src/params.rs b/sled-agent/src/params.rs index 3466daea90a..4daaa399082 100644 --- a/sled-agent/src/params.rs +++ b/sled-agent/src/params.rs @@ -40,6 +40,25 @@ pub struct SourceNatConfig { pub last_port: u16, } +/// Update firewall rules for a VPC +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct VpcFirewallRulesEnsureBody { + pub rules: Vec, +} + +/// VPC firewall rule after object name resolution has been performed by Nexus +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +pub struct VpcFirewallRule { + pub status: external::VpcFirewallRuleStatus, + pub direction: external::VpcFirewallRuleDirection, + pub targets: Vec, + pub filter_hosts: Option>, + pub filter_ports: Option>, + pub filter_protocols: Option>, + pub action: external::VpcFirewallRuleAction, + pub priority: external::VpcFirewallRulePriority, +} + /// Used to request a Disk state change #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, JsonSchema)] #[serde(rename_all = "lowercase", tag = "state", content = "instance")] @@ -82,6 +101,7 @@ pub struct InstanceHardware { /// Zero or more external IP addresses (either floating or ephemeral), /// provided to an instance to allow inbound connectivity. pub external_ips: Vec, + pub firewall_rules: Vec, pub disks: Vec, pub cloud_init_bytes: Option, } diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index 88cbe889297..5202f4c795f 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -6,7 +6,7 @@ use crate::params::{ DiskEnsureBody, InstanceEnsureBody, InstanceSerialConsoleData, - InstanceSerialConsoleRequest, + InstanceSerialConsoleRequest, VpcFirewallRulesEnsureBody, }; use crate::serial::ByteOffset; use dropshot::endpoint; @@ -43,6 +43,7 @@ pub fn api() -> SledApiDescription { api.register(instance_serial_get)?; api.register(instance_issue_disk_snapshot_request)?; api.register(issue_disk_snapshot_request)?; + api.register(vpc_firewall_rules_put)?; Ok(()) } @@ -284,3 +285,25 @@ async fn issue_disk_snapshot_request( snapshot_id: body.snapshot_id, })) } + +/// Path parameters for VPC requests (sled agent API) +#[derive(Deserialize, JsonSchema)] +struct VpcPathParam { + vpc_id: Uuid, +} + +#[endpoint { + method = PUT, + path = "/vpc/{vpc_id}/firewall/rules", +}] +async fn vpc_firewall_rules_put( + rqctx: Arc>>, + path_params: Path, + body: TypedBody, +) -> Result { + let _sa = rqctx.context(); + let _vpc_id = path_params.into_inner().vpc_id; + let _body_args = body.into_inner(); + + Ok(HttpResponseUpdatedNoContent()) +} diff --git a/sled-agent/src/sled_agent.rs b/sled-agent/src/sled_agent.rs index fab5b1de999..3829959dbc5 100644 --- a/sled-agent/src/sled_agent.rs +++ b/sled-agent/src/sled_agent.rs @@ -17,7 +17,7 @@ use crate::nexus::LazyNexusClient; use crate::params::{ DatasetKind, DiskStateRequested, InstanceHardware, InstanceMigrateParams, InstanceRuntimeStateRequested, InstanceSerialConsoleData, - ServiceEnsureBody, + ServiceEnsureBody, VpcFirewallRule, }; use crate::services::{self, ServiceManager}; use crate::storage_manager::StorageManager; @@ -431,6 +431,14 @@ impl SledAgent { // means. Currently unimplemented. todo!(); } + + pub async fn firewall_rules_ensure( + &self, + _vpc_id: Uuid, + rules: &[VpcFirewallRule], + ) -> Result<(), Error> { + self.instances.firewall_rules_ensure(rules).await.map_err(Error::from) + } } // Delete all underlay addresses created directly over the etherstub VNIC used