diff --git a/Cargo.lock b/Cargo.lock index ce7d1d682f4..bc9590735ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3536,6 +3536,7 @@ dependencies = [ "anyhow", "async-trait", "chrono", + "ipnetwork", "omicron-common", "percent-encoding", "progenitor", diff --git a/common/src/sql/dbinit.sql b/common/src/sql/dbinit.sql index 9aff6d1f9ce..6c618ef26f9 100644 --- a/common/src/sql/dbinit.sql +++ b/common/src/sql/dbinit.sql @@ -438,6 +438,11 @@ CREATE UNIQUE INDEX ON omicron.public.network_interface ( ) WHERE time_deleted IS NULL; +CREATE INDEX ON omicron.public.network_interface ( + instance_id +) WHERE + time_deleted IS NULL; + /* Ensure we do not assign the same MAC twice within a VPC * See RFD174's discussion on the scope of virtual MACs */ diff --git a/nexus/src/db/datastore.rs b/nexus/src/db/datastore.rs index 9cc0293fe07..e0433343bf3 100644 --- a/nexus/src/db/datastore.rs +++ b/nexus/src/db/datastore.rs @@ -54,6 +54,7 @@ use omicron_common::api::external::{ CreateResult, IdentityMetadataCreateParams, }; use omicron_common::bail_unless; +use std::collections::HashMap; use std::convert::TryFrom; use std::sync::Arc; use uuid::Uuid; @@ -847,6 +848,149 @@ impl DataStore { } } + /// Identify all IPs in use by each instance + // TODO: how to name/where to put this + pub async fn resolve_instances_to_interfaces< + T: IntoIterator, + >( + &self, + vpc: &Vpc, + instance_names: T, + ) -> Result>, Error> { + use db::schema::{instance, network_interface}; + // TODO-performance: paginate the results of this query? + let ifaces = network_interface::table + .inner_join( + instance::table + .on(instance::id.eq(network_interface::instance_id)), + ) + .select((instance::name, NetworkInterface::as_select())) + .filter(instance::project_id.eq(vpc.project_id)) + .filter(instance::name.eq_any(instance_names)) + .filter(network_interface::time_deleted.is_null()) + .filter(instance::time_deleted.is_null()) + .get_results_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ResourceType::Instance, + LookupType::Other("Resolving to IPs".to_string()), + ) + })?; + + let mut result = HashMap::with_capacity(ifaces.len()); + for (name, iface) in ifaces.into_iter() { + result.entry(name).or_insert(vec![]).push(iface) + } + Ok(result) + } + + /// Identify all VNICs connected to each VpcSubnet + // TODO: how to name/where to put this + pub async fn resolve_subnets_to_interfaces>( + &self, + vpc: &Vpc, + subnet_names: T, + ) -> Result>, Error> { + use db::schema::{network_interface, vpc_subnet}; + // TODO-performance: paginate the results of this query? + let subnets = network_interface::table + .inner_join( + vpc_subnet::table + .on(vpc_subnet::id.eq(network_interface::subnet_id)), + ) + .select((vpc_subnet::name, NetworkInterface::as_select())) + .filter(vpc_subnet::vpc_id.eq(vpc.id())) + .filter(vpc_subnet::name.eq_any(subnet_names)) + .filter(network_interface::time_deleted.is_null()) + .filter(vpc_subnet::time_deleted.is_null()) + .get_results_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ResourceType::VpcSubnet, + LookupType::Other("Resolving to interfaces".to_string()), + ) + })?; + let mut result = HashMap::with_capacity(subnets.len()); + for (name, interface) in subnets.into_iter() { + let entry = result.entry(name).or_insert(vec![]); + entry.push(interface); + } + Ok(result) + } + + /// Identify all VNICs connected to each Vpc + // TODO: how to name/where to put this + pub async fn resolve_vpcs_to_interfaces>( + &self, + project_id: &Uuid, + vpc_names: T, + ) -> Result>, Error> { + use db::schema::{network_interface, vpc}; + // TODO-performance: paginate the results of this query? + let interfaces = network_interface::table + .inner_join(vpc::table.on(vpc::id.eq(network_interface::vpc_id))) + .select((vpc::name, NetworkInterface::as_select())) + .filter(vpc::project_id.eq(*project_id)) + .filter(vpc::name.eq_any(vpc_names)) + .filter(network_interface::time_deleted.is_null()) + .filter(vpc::time_deleted.is_null()) + .get_results_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ResourceType::Vpc, + LookupType::Other("Resolving to interfaces".to_string()), + ) + })?; + let mut result = HashMap::with_capacity(interfaces.len()); + for (name, interface) in interfaces.into_iter() { + let entry = result.entry(name).or_insert(vec![]); + entry.push(interface); + } + Ok(result) + } + + /// Identify all subnets in use by each VpcSubnet + // TODO: how to name/where to put this + pub async fn resolve_subnets_to_ips>( + &self, + vpc: &Vpc, + subnet_names: T, + ) -> Result>, Error> { + use db::schema::vpc_subnet; + // TODO-performance: paginate the results of this query? + let subnets = vpc_subnet::table + .select(VpcSubnet::as_select()) + .filter(vpc_subnet::vpc_id.eq(vpc.id())) + .filter(vpc_subnet::name.eq_any(subnet_names)) + .filter(vpc_subnet::time_deleted.is_null()) + .get_results_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ResourceType::VpcSubnet, + LookupType::Other("Resolving to IPs".to_string()), + ) + })?; + let mut result = HashMap::with_capacity(subnets.len()); + for subnet in subnets { + let entry = result.entry(subnet.name().clone()).or_insert(vec![]); + if let Some(block) = subnet.ipv4_block { + entry.push(ipnetwork::IpNetwork::V4(block.0 .0)) + } + if let Some(block) = subnet.ipv6_block { + entry.push(ipnetwork::IpNetwork::V6(block.0 .0)) + } + } + Ok(result) + } + /* * Disks */ @@ -1616,6 +1760,35 @@ impl DataStore { }) } + pub async fn vpc_resolve_to_sleds( + &self, + vpc_id: &Uuid, + ) -> Result, Error> { + use db::schema::{instance, network_interface, sled}; + + // Resolve each VNIC in the VPC to the Sled its on, so we know which + // Sleds to notify when firewall rules change. + 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()) + .get_results_async(self.pool()) + .await + .map_err(|e| { + public_error_from_diesel_pool( + e, + ResourceType::Vpc, + LookupType::Other("Resolving to sleds".to_string()), + ) + }) + } + pub async fn vpc_list_subnets( &self, vpc_id: &Uuid, diff --git a/nexus/src/db/model.rs b/nexus/src/db/model.rs index 7894acbf568..ae3ea524099 100644 --- a/nexus/src/db/model.rs +++ b/nexus/src/db/model.rs @@ -102,6 +102,7 @@ macro_rules! impl_enum_type { AsExpression, FromSqlRow, Eq, + Hash, PartialEq, Ord, PartialOrd, @@ -1434,7 +1435,7 @@ impl_enum_type!( #[postgres(type_name = "vpc_firewall_rule_status", type_schema = "public")] pub struct VpcFirewallRuleStatusEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow)] #[sql_type = "VpcFirewallRuleStatusEnum"] pub struct VpcFirewallRuleStatus(pub external::VpcFirewallRuleStatus); @@ -1449,7 +1450,7 @@ impl_enum_type!( #[postgres(type_name = "vpc_firewall_rule_direction", type_schema = "public")] pub struct VpcFirewallRuleDirectionEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow)] #[sql_type = "VpcFirewallRuleDirectionEnum"] pub struct VpcFirewallRuleDirection(pub external::VpcFirewallRuleDirection); @@ -1464,7 +1465,7 @@ impl_enum_type!( #[postgres(type_name = "vpc_firewall_rule_action", type_schema = "public")] pub struct VpcFirewallRuleActionEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow)] #[sql_type = "VpcFirewallRuleActionEnum"] pub struct VpcFirewallRuleAction(pub external::VpcFirewallRuleAction); @@ -1479,7 +1480,7 @@ impl_enum_type!( #[postgres(type_name = "vpc_firewall_rule_protocol", type_schema = "public")] pub struct VpcFirewallRuleProtocolEnum; - #[derive(Clone, Debug, AsExpression, FromSqlRow)] + #[derive(Clone, Copy, Debug, AsExpression, FromSqlRow)] #[sql_type = "VpcFirewallRuleProtocolEnum"] pub struct VpcFirewallRuleProtocol(pub external::VpcFirewallRuleProtocol); diff --git a/nexus/src/nexus.rs b/nexus/src/nexus.rs index a94f2610ce4..4766a50ac42 100644 --- a/nexus/src/nexus.rs +++ b/nexus/src/nexus.rs @@ -23,6 +23,7 @@ use crate::sagas; use anyhow::anyhow; use anyhow::Context; use async_trait::async_trait; +use futures::future::join_all; use futures::future::ready; use futures::StreamExt; use hex; @@ -43,6 +44,7 @@ use omicron_common::api::external::Ipv6Net; use omicron_common::api::external::ListResult; use omicron_common::api::external::ListResultVec; use omicron_common::api::external::LookupResult; +use omicron_common::api::external::NetworkInterface; use omicron_common::api::external::PaginationOrder; use omicron_common::api::external::ResourceType; use omicron_common::api::external::RouteDestination; @@ -65,6 +67,7 @@ use oximeter_producer::register; use rand::{rngs::StdRng, RngCore, SeedableRng}; use sled_agent_client::Client as SledAgentClient; use slog::Logger; +use std::collections::{HashMap, HashSet}; use std::convert::TryInto; use std::net::SocketAddr; use std::sync::Arc; @@ -1699,14 +1702,281 @@ impl Nexus { vpc.id(), params.clone(), ); - let result = self + let rules = self .db_datastore .vpc_update_firewall_rules(&vpc.id(), rules) + .await?; + + self.send_sled_agents_firewall_rules(&vpc, &rules).await?; + + let result = rules.into_iter().map(|rule| rule.into()).collect(); + Ok(result) + } + + async fn send_sled_agents_firewall_rules( + &self, + vpc: &db::model::Vpc, + rules: &Vec, + ) -> Result<(), Error> { + let rules_for_sled = + self.resolve_firewall_rules_for_sled_agent(&vpc, &rules).await?; + 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?; + 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 + }); + } + let results = join_all(sled_requests).await; + // TODO-correctness: Actually do something about the failures here + 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); + } + } + + Ok(()) + } + + // TODO: move this somewhere else + async fn resolve_firewall_rules_for_sled_agent( + &self, + vpc: &db::model::Vpc, + rules: &Vec, + ) -> Result, Error> { + // Gather list of Instances and Subnets to resolve + let mut instances = HashSet::new(); + let mut subnets = HashSet::new(); + let mut vpcs = 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().0 { + return Err(Error::InvalidRequest { + message: + "cross-vpc firewall target unsupported" + .to_string(), + }); + } + vpcs.insert(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()); + } + // We don't need to resolve anything for Ip + external::VpcFirewallRuleHostFilter::Ip(_) => (), + external::VpcFirewallRuleHostFilter::Vpc(name) => { + if *name != vpc.name().0 { + return Err(Error::InvalidRequest { + message: + "cross-vpc firewall target unsupported" + .to_string(), + }); + } + vpcs.insert(name.clone().into()); + } + // TODO: How do we resolve InternetGateway targets? + external::VpcFirewallRuleHostFilter::InternetGateway(_) => { + return Err(Error::InvalidRequest { + message: "inetgw firewall host filters unsupported" + .to_string(), + }); + } + } + } + } + + // TODO-correctness: It's possible these three queries produce + // inconsistent results due to concurrent changes. These should be + // transactional + let instance_interfaces: HashMap< + external::Name, + Vec, + > = self + .db_datastore + .resolve_instances_to_interfaces(vpc, instances) .await? .into_iter() - .map(|rule| rule.into()) + .map(|(name, v)| { + (name.0, v.into_iter().map(|iface| iface.into()).collect()) + }) .collect(); - Ok(result) + let subnet_networks: HashMap< + external::Name, + Vec, + > = self + .db_datastore + .resolve_subnets_to_ips(vpc, subnets.clone()) + .await? + .into_iter() + .map(|(name, v)| (name.0, v)) + .collect(); + let subnet_interfaces: HashMap> = + self.db_datastore + .resolve_subnets_to_interfaces(vpc, subnets) + .await? + .into_iter() + .map(|(name, v)| { + (name.0, v.into_iter().map(|iface| iface.into()).collect()) + }) + .collect(); + let vpc_interfaces: HashMap> = + self.db_datastore + .resolve_vpcs_to_interfaces(&vpc.project_id, vpcs) + .await? + .into_iter() + .map(|(name, v)| { + (name.0, v.into_iter().map(|iface| iface.into()).collect()) + }) + .collect(); + + let mut sled_agent_rules = Vec::with_capacity(rules.len()); + for rule in rules { + let mut targets = Vec::with_capacity(rule.targets.len()); + for target in &rule.targets { + match &target.0 { + // 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 + external::VpcFirewallRuleTarget::Instance(name) => { + for interface in + instance_interfaces.get(&name).ok_or_else(|| { + Error::not_found_by_name( + ResourceType::Instance, + &name, + ) + })? + { + targets.push(interface.into()); + } + } + external::VpcFirewallRuleTarget::Subnet(name) => { + for interface in + subnet_interfaces.get(&name).ok_or_else(|| { + Error::not_found_by_name( + ResourceType::VpcSubnet, + &name, + ) + })? + { + targets.push(interface.into()); + } + } + external::VpcFirewallRuleTarget::Vpc(name) => { + for interface in + vpc_interfaces.get(&name).ok_or_else(|| { + Error::not_found_by_name( + ResourceType::Vpc, + &name, + ) + })? + { + targets.push(interface.into()); + } + } + }; + } + + 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 { + // TODO: See above about handling missing names + external::VpcFirewallRuleHostFilter::Instance(name) => { + for interface in instance_interfaces.get(&name).ok_or_else(|| { + Error::not_found_by_name( + ResourceType::Instance, + &name, + ) + })? { + host_addrs.push(ipnetwork::IpNetwork::from(interface.ip).into()); + } + } + external::VpcFirewallRuleHostFilter::Subnet(name) => { + for subnet in subnet_networks.get(&name).ok_or_else(|| { + Error::not_found_by_name( + ResourceType::VpcSubnet, + &name, + ) + })? { + host_addrs.push(subnet.clone().into()); + } + } + external::VpcFirewallRuleHostFilter::Ip(addr) => { + host_addrs.push(ipnetwork::IpNetwork::from(*addr).into()); + } + external::VpcFirewallRuleHostFilter::Vpc(name) => { + for interface in vpc_interfaces.get(&name).ok_or_else(|| { + Error::not_found_by_name( + ResourceType::Vpc, + &name, + ) + })? { + host_addrs.push(ipnetwork::IpNetwork::from(interface.ip).into()); + } + } + // TODO: How do we resolve InternetGateway targets? + external::VpcFirewallRuleHostFilter::InternetGateway(_) => (), + } + } + Some(host_addrs) + } + }; + + sled_agent_rules.push(sled_agent_client::types::VpcFirewallRule { + status: rule.status.0.into(), + direction: rule.direction.0.into(), + targets, + filter_hosts, + filter_ports: rule + .filter_ports + .as_ref() + .map(|ports| ports.iter().map(|v| v.0.into()).collect()), + filter_protocols: rule.filter_protocols.as_ref().map( + |protocols| protocols.iter().map(|v| v.0.into()).collect(), + ), + action: rule.action.0.into(), + priority: rule.priority.0 .0, + }); + } + Ok(sled_agent_rules) } pub async fn vpc_list_subnets( diff --git a/nexus/tests/test_subnet_allocation.rs b/nexus/tests/test_subnet_allocation.rs new file mode 100644 index 00000000000..5110b29f6c7 --- /dev/null +++ b/nexus/tests/test_subnet_allocation.rs @@ -0,0 +1,136 @@ +// 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/. + +/*! + * Tests that subnet allocation will successfully allocate the entire space of a + * subnet and error appropriately when the space is exhausted. + */ + +use http::method::Method; +use http::StatusCode; +use omicron_common::api::external::{ + ByteCount, IdentityMetadataCreateParams, IdentityMetadataUpdateParams, + Instance, InstanceCpuCount, Ipv4Net, NetworkInterface, +}; +use omicron_nexus::external_api::params; +use std::net::IpAddr; + +use dropshot::test_util::objects_list_page; +use dropshot::test_util::objects_post; +use dropshot::test_util::ClientTestContext; +use dropshot::HttpErrorResponseBody; + +pub mod common; +use common::resource_helpers::{create_organization, create_project}; +use common::test_setup; + +async fn create_instance( + client: &ClientTestContext, + url_instances: &String, + name: &str, +) { + let new_instance = params::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: "".to_string(), + }, + ncpus: InstanceCpuCount(1), + memory: ByteCount::from_mebibytes_u32(256), + hostname: name.to_string(), + }; + objects_post::<_, Instance>(&client, url_instances, new_instance.clone()) + .await; +} + +async fn create_instance_expect_failure( + client: &ClientTestContext, + url_instances: &String, + name: &str, +) -> HttpErrorResponseBody { + let new_instance = params::InstanceCreate { + identity: IdentityMetadataCreateParams { + name: name.parse().unwrap(), + description: "".to_string(), + }, + ncpus: InstanceCpuCount(1), + memory: ByteCount::from_mebibytes_u32(256), + hostname: name.to_string(), + }; + client + .make_request_error_body( + Method::POST, + &url_instances, + new_instance, + StatusCode::NOT_FOUND, + ) + .await +} + +#[tokio::test] +async fn test_subnet_allocation() { + let cptestctx = test_setup("test_subnet_allocation").await; + let client = &cptestctx.external_client; + + let organization_name = "test-org"; + let project_name = "springfield-squidport"; + + // Create a project that we'll use for testing. + create_organization(&client, organization_name).await; + create_project(&client, organization_name, project_name).await; + let url_instances = format!( + "/organizations/{}/projects/{}/instances", + organization_name, project_name + ); + + // Modify the default VPC to have a very small subnet so we don't need to + // issue many requests + let url_subnet = format!( + "/organizations/{}/projects/{}/vpcs/default/subnets/default", + organization_name, project_name + ); + let subnet = "192.168.42.0/29".parse().unwrap(); + let subnet_update = params::VpcSubnetUpdate { + identity: IdentityMetadataUpdateParams { + name: Some("default".parse().unwrap()), + description: None, + }, + ipv4_block: Some(Ipv4Net(subnet)), + ipv6_block: None, + }; + client + .make_request( + Method::PUT, + &url_subnet, + Some(subnet_update), + StatusCode::OK, + ) + .await + .unwrap(); + + // The valid addresses for allocation in `subnet` are 192.168.42.5 and + // 192.168.42.6. The rest are reserved as described in RFD21. + create_instance(client, &url_instances, "i1").await; + create_instance(client, &url_instances, "i2").await; + + // This should fail from address exhaustion + let error = + create_instance_expect_failure(client, &url_instances, "i3").await; + assert_eq!(error.message, "no available IP addresses"); + + // Verify the subnet lists the two addresses as in use + let url_ips = format!("{}/ips", url_subnet); + let network_interfaces = + objects_list_page::(client, &url_ips).await.items; + assert_eq!(network_interfaces.len(), 2); + assert_eq!( + network_interfaces[0].ip, + "192.168.42.5".parse::().unwrap() + ); + assert_eq!( + network_interfaces[1].ip, + "192.168.42.6".parse::().unwrap() + ); + + cptestctx.teardown().await; +} diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index ec525b2b23c..a426065088a 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -87,6 +87,38 @@ } } } + }, + "/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" + } + } + } } }, "components": { @@ -547,6 +579,18 @@ "destroyed" ] }, + "IpNetwork": { + "description": "IPv4 (in dotted quad) or IPv6address, followed by a slash and prefix length", + "type": "string" + }, + "L4PortRange": { + "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": { "title": "A MAC address", "description": "A Media Access Control address, in EUI-48 format", @@ -611,6 +655,105 @@ "subnet_id", "vpc_id" ] + }, + "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/IpNetwork" + } + }, + "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": "Sent to a sled agent to establish the current 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 2f233c4ebdc..4066339e3d3 100644 --- a/sled-agent-client/Cargo.toml +++ b/sled-agent-client/Cargo.toml @@ -10,6 +10,7 @@ async-trait = "0.1" progenitor = { git = "https://github.com/oxidecomputer/progenitor" } reqwest = { version = "0.11", default-features = false, features = ["rustls-tls"] } percent-encoding = "2.1.0" +ipnetwork = "0.18" [dependencies.chrono] version = "0.4" diff --git a/sled-agent-client/src/lib.rs b/sled-agent-client/src/lib.rs index c210c7d190c..86ab54aa31f 100644 --- a/sled-agent-client/src/lib.rs +++ b/sled-agent-client/src/lib.rs @@ -284,6 +284,83 @@ impl From for types::MacAddr { Self(s.0.to_string()) } } + +impl From for types::IpNetwork { + fn from(s: ipnetwork::IpNetwork) -> Self { + Self(s.to_string()) + } +} + +impl From for types::L4PortRange { + fn from(s: omicron_common::api::external::L4PortRange) -> Self { + Self(s.to_string()) + } +} + +impl From + for types::VpcFirewallRuleAction +{ + fn from(s: omicron_common::api::external::VpcFirewallRuleAction) -> Self { + match s { + omicron_common::api::external::VpcFirewallRuleAction::Allow => { + Self::Allow + } + omicron_common::api::external::VpcFirewallRuleAction::Deny => { + Self::Deny + } + } + } +} + +impl From + for types::VpcFirewallRuleDirection +{ + fn from( + s: omicron_common::api::external::VpcFirewallRuleDirection, + ) -> Self { + match s { + omicron_common::api::external::VpcFirewallRuleDirection::Inbound => { + Self::Inbound + } + omicron_common::api::external::VpcFirewallRuleDirection::Outbound => { + Self::Outbound + } + } + } +} + +impl From + for types::VpcFirewallRuleStatus +{ + fn from(s: omicron_common::api::external::VpcFirewallRuleStatus) -> Self { + match s { + omicron_common::api::external::VpcFirewallRuleStatus::Enabled => { + Self::Enabled + } + omicron_common::api::external::VpcFirewallRuleStatus::Disabled => { + Self::Disabled + } + } + } +} + +impl From + for types::VpcFirewallRuleProtocol +{ + fn from(s: omicron_common::api::external::VpcFirewallRuleProtocol) -> Self { + match s { + omicron_common::api::external::VpcFirewallRuleProtocol::Tcp => { + Self::Tcp + } + omicron_common::api::external::VpcFirewallRuleProtocol::Udp => { + Self::Udp + } + omicron_common::api::external::VpcFirewallRuleProtocol::Icmp => { + Self::Icmp + } + } + } +} /** * Exposes additional [`Client`] interfaces for use by the test suite. These * are bonus endpoints, not generated in the real client. diff --git a/sled-agent/src/http_entrypoints.rs b/sled-agent/src/http_entrypoints.rs index d7b433118c9..0514c035c49 100644 --- a/sled-agent/src/http_entrypoints.rs +++ b/sled-agent/src/http_entrypoints.rs @@ -4,11 +4,12 @@ //! HTTP entrypoint functions for the sled agent's exposed API -use super::params::DiskEnsureBody; +use super::params::{DiskEnsureBody, VpcFirewallRulesEnsureBody}; use dropshot::endpoint; use dropshot::ApiDescription; use dropshot::HttpError; use dropshot::HttpResponseOk; +use dropshot::HttpResponseUpdatedNoContent; use dropshot::Path; use dropshot::RequestContext; use dropshot::TypedBody; @@ -30,6 +31,7 @@ pub fn api() -> SledApiDescription { fn register_endpoints(api: &mut SledApiDescription) -> Result<(), String> { api.register(instance_put)?; api.register(disk_put)?; + api.register(vpc_firewall_rules_put)?; Ok(()) } @@ -93,3 +95,29 @@ async fn disk_put( .map_err(|e| Error::from(e))?, )) } + +/// Path parameters for VPC requests (sled agent API) +#[derive(Deserialize, JsonSchema)] +struct VpcPathParam { + vpc_id: Uuid, +} + +// TODO: Evaluate whether we want to keep this endpoint as is. This was +// developed ad-hoc in preparation for the Milestone 3 demo. +#[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(); + + // TODO: Actually send the firewall rules to OPTE + + Ok(HttpResponseUpdatedNoContent()) +} diff --git a/sled-agent/src/params.rs b/sled-agent/src/params.rs index 993b31b6328..7946d01f51d 100644 --- a/sled-agent/src/params.rs +++ b/sled-agent/src/params.rs @@ -2,6 +2,11 @@ // 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/. +use omicron_common::api::external::{ + L4PortRange, NetworkInterface, VpcFirewallRuleAction, + VpcFirewallRuleDirection, VpcFirewallRulePriority, VpcFirewallRuleProtocol, + VpcFirewallRuleStatus, +}; use omicron_common::api::internal::nexus::DiskRuntimeState; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -39,3 +44,28 @@ pub struct DiskEnsureBody { /// requested runtime state of the Disk pub target: DiskStateRequested, } + +/// Sent to a sled agent to establish the current 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: VpcFirewallRuleStatus, + pub direction: VpcFirewallRuleDirection, + pub targets: Vec, + #[schemars(with = "Option>")] + pub filter_hosts: Option>, + pub filter_ports: Option>, + pub filter_protocols: Option>, + pub action: VpcFirewallRuleAction, + pub priority: VpcFirewallRulePriority, +} + +#[derive(JsonSchema)] +#[serde(remote = "ipnetwork::IpNetwork")] +/// IPv4 (in dotted quad) or IPv6address, followed by a slash and prefix length +struct IpNetworkDef(String); diff --git a/sled-agent/src/sim/http_entrypoints.rs b/sled-agent/src/sim/http_entrypoints.rs index f90cdf5af30..6e589966b14 100644 --- a/sled-agent/src/sim/http_entrypoints.rs +++ b/sled-agent/src/sim/http_entrypoints.rs @@ -6,7 +6,7 @@ * HTTP entrypoint functions for the sled agent's exposed API */ -use crate::params::DiskEnsureBody; +use crate::params::{DiskEnsureBody, VpcFirewallRulesEnsureBody}; use dropshot::endpoint; use dropshot::ApiDescription; use dropshot::HttpError; @@ -36,6 +36,7 @@ pub fn api() -> SledApiDescription { api.register(instance_poke_post)?; api.register(disk_put)?; api.register(disk_poke_post)?; + api.register(vpc_firewall_rules_put)?; Ok(()) } @@ -129,3 +130,29 @@ async fn disk_poke_post( sa.disk_poke(disk_id).await; Ok(HttpResponseUpdatedNoContent()) } + +/// Path parameters for VPC requests (sled agent API) +#[derive(Deserialize, JsonSchema)] +struct VpcPathParam { + vpc_id: Uuid, +} + +// TODO: Evaluate whether we want to keep this endpoint as is. This was +// developed ad-hoc in preparation for the Milestone 3 demo. +#[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(); + + // TODO: Actually send the firewall rules to OPTE + + Ok(HttpResponseUpdatedNoContent()) +}