From 6aae133d0a75cf423064b07c40ccce8830ee7e98 Mon Sep 17 00:00:00 2001 From: Adam Williams Date: Thu, 24 Oct 2024 17:24:03 -0600 Subject: [PATCH 1/2] Add fastly_acl hostcalls --- cli/src/main.rs | 2 + cli/tests/integration/acl.rs | 113 ++++++ cli/tests/integration/common.rs | 7 +- cli/tests/integration/main.rs | 1 + crates/adapter/src/fastly/core.rs | 66 +++- lib/compute-at-edge-abi/compute-at-edge.witx | 16 + lib/compute-at-edge-abi/typenames.witx | 16 + lib/data/viceroy-component-adapter.wasm | Bin 200935 -> 200166 bytes lib/src/acl.rs | 386 +++++++++++++++++++ lib/src/component/acl.rs | 47 +++ lib/src/component/mod.rs | 8 +- lib/src/config.rs | 20 +- lib/src/config/acl.rs | 67 ++++ lib/src/error.rs | 29 ++ lib/src/execute.rs | 17 + lib/src/lib.rs | 1 + lib/src/linking.rs | 11 +- lib/src/session.rs | 31 +- lib/src/wiggle_abi.rs | 2 + lib/src/wiggle_abi/acl.rs | 65 ++++ lib/src/wiggle_abi/entity.rs | 17 +- lib/wit/deps/fastly/compute.wit | 27 ++ test-fixtures/Cargo.toml | 5 + test-fixtures/data/my-acl-1.json | 8 + test-fixtures/data/my-acl-2.json | 6 + test-fixtures/src/bin/acl.rs | 62 +++ 26 files changed, 1000 insertions(+), 30 deletions(-) create mode 100644 cli/tests/integration/acl.rs create mode 100644 lib/src/acl.rs create mode 100644 lib/src/component/acl.rs create mode 100644 lib/src/config/acl.rs create mode 100644 lib/src/wiggle_abi/acl.rs create mode 100644 test-fixtures/data/my-acl-1.json create mode 100644 test-fixtures/data/my-acl-2.json create mode 100644 test-fixtures/src/bin/acl.rs diff --git a/cli/src/main.rs b/cli/src/main.rs index 7df98cae..6a625fb0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -312,6 +312,7 @@ async fn create_execution_context( if let Some(config_path) = args.config_path() { let config = FastlyConfig::from_file(config_path)?; + let acls = config.acls(); let backends = config.backends(); let device_detection = config.device_detection(); let geolocation = config.geolocation(); @@ -321,6 +322,7 @@ async fn create_execution_context( let backend_names = itertools::join(backends.keys(), ", "); ctx = ctx + .with_acls(acls.clone()) .with_backends(backends.clone()) .with_device_detection(device_detection.clone()) .with_geolocation(geolocation.clone()) diff --git a/cli/tests/integration/acl.rs b/cli/tests/integration/acl.rs new file mode 100644 index 00000000..ac8cd305 --- /dev/null +++ b/cli/tests/integration/acl.rs @@ -0,0 +1,113 @@ +use crate::{common::Test, common::TestResult, viceroy_test}; +use hyper::{body::to_bytes, StatusCode}; +use viceroy_lib::config::FastlyConfig; +use viceroy_lib::error::{AclConfigError, FastlyConfigError}; + +viceroy_test!(acl_works, |is_component| { + const FASTLY_TOML: &str = r#" + name = "acl" + description = "acl test" + authors = ["Test User "] + language = "rust" + [local_server] + acls.my-acl-1 = "../test-fixtures/data/my-acl-1.json" + acls.my-acl-2 = {file = "../test-fixtures/data/my-acl-2.json"} + "#; + + let resp = Test::using_fixture("acl.wasm") + .adapt_component(is_component) + .using_fastly_toml(FASTLY_TOML)? + .log_stderr() + .log_stdout() + .against_empty() + .await?; + + assert_eq!(resp.status(), StatusCode::OK); + assert!(to_bytes(resp.into_body()) + .await + .expect("can read body") + .to_vec() + .is_empty()); + + Ok(()) +}); + +fn bad_config_test(local_server_fragment: &str) -> Result { + let toml = format!( + r#" + name = "acl" + description = "acl test" + authors = ["Test User "] + language = "rust" + [local_server] + {} + "#, + local_server_fragment + ); + + toml.parse::() +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_config_invalid_path() -> TestResult { + const TOML_FRAGMENT: &str = "acls.bad = 1"; + match bad_config_test(TOML_FRAGMENT) { + Err(FastlyConfigError::InvalidAclDefinition { + err: AclConfigError::InvalidType, + .. + }) => (), + Err(_) => panic!( + "expected a FastlyConfigError::InvalidAclDefinition with AclConfigError::InvalidType" + ), + _ => panic!("Expected an error"), + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_config_missing_key() -> TestResult { + const TOML_FRAGMENT: &str = "acls.bad = { \"other\" = true }"; + match bad_config_test(TOML_FRAGMENT) { + Err(FastlyConfigError::InvalidAclDefinition { + err: AclConfigError::MissingFile, + .. + }) => (), + Err(_) => panic!( + "expected a FastlyConfigError::InvalidAclDefinition with AclConfigError::MissingFile" + ), + _ => panic!("Expected an error"), + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_config_missing_file() -> TestResult { + const TOML_FRAGMENT: &str = "acls.bad = \"/does/not/exist\""; + match bad_config_test(TOML_FRAGMENT) { + Err(FastlyConfigError::InvalidAclDefinition { + err: AclConfigError::IoError(_), + .. + }) => (), + Err(_) => panic!( + "expected a FastlyConfigError::InvalidAclDefinition with AclConfigError::IoError" + ), + _ => panic!("Expected an error"), + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn bad_config_invalid_json() -> TestResult { + const TOML_FRAGMENT: &str = "acls.bad = \"../Cargo.toml\""; + match bad_config_test(TOML_FRAGMENT) { + Err(FastlyConfigError::InvalidAclDefinition { + err: AclConfigError::JsonError(_), + .. + }) => (), + Err(_) => panic!( + "expected a FastlyConfigError::InvalidAclDefinition with AclConfigError::JsonError" + ), + _ => panic!("Expected an error"), + } + Ok(()) +} diff --git a/cli/tests/integration/common.rs b/cli/tests/integration/common.rs index f2c5239f..31beabb9 100644 --- a/cli/tests/integration/common.rs +++ b/cli/tests/integration/common.rs @@ -16,7 +16,7 @@ use viceroy_lib::config::UnknownImportBehavior; use viceroy_lib::{ body::Body, config::{ - DeviceDetection, Dictionaries, FastlyConfig, Geolocation, ObjectStores, SecretStores, + Acls, DeviceDetection, Dictionaries, FastlyConfig, Geolocation, ObjectStores, SecretStores, }, ExecuteCtx, ProfilingStrategy, ViceroyService, }; @@ -77,6 +77,7 @@ pub type TestResult = Result<(), Error>; /// A builder for running individual requests through a wasm fixture. pub struct Test { module_path: PathBuf, + acls: Acls, backends: TestBackends, device_detection: DeviceDetection, dictionaries: Dictionaries, @@ -99,6 +100,7 @@ impl Test { Self { module_path, + acls: Acls::new(), backends: TestBackends::new(), device_detection: DeviceDetection::new(), dictionaries: Dictionaries::new(), @@ -121,6 +123,7 @@ impl Test { Self { module_path, + acls: Acls::new(), backends: TestBackends::new(), device_detection: DeviceDetection::new(), dictionaries: Dictionaries::new(), @@ -140,6 +143,7 @@ impl Test { pub fn using_fastly_toml(self, fastly_toml: &str) -> Result { let config = fastly_toml.parse::()?; Ok(Self { + acls: config.acls().to_owned(), backends: TestBackends::from_backend_configs(config.backends()), device_detection: config.device_detection().to_owned(), dictionaries: config.dictionaries().to_owned(), @@ -328,6 +332,7 @@ impl Test { self.unknown_import_behavior, self.adapt_component, )? + .with_acls(self.acls.clone()) .with_backends(self.backends.backend_configs().await) .with_dictionaries(self.dictionaries.clone()) .with_device_detection(self.device_detection.clone()) diff --git a/cli/tests/integration/main.rs b/cli/tests/integration/main.rs index fe798ebe..b1d649cc 100644 --- a/cli/tests/integration/main.rs +++ b/cli/tests/integration/main.rs @@ -1,3 +1,4 @@ +mod acl; mod args; mod async_io; mod body; diff --git a/crates/adapter/src/fastly/core.rs b/crates/adapter/src/fastly/core.rs index ffbd1114..2fb04672 100644 --- a/crates/adapter/src/fastly/core.rs +++ b/crates/adapter/src/fastly/core.rs @@ -86,19 +86,20 @@ pub enum HttpKeepaliveMode { NoKeepalive = 1, } -pub type PendingObjectStoreLookupHandle = u32; -pub type PendingObjectStoreInsertHandle = u32; +pub type AclHandle = u32; +pub type AsyncItemHandle = u32; +pub type BodyHandle = u32; +pub type DictionaryHandle = u32; +pub type KVStoreHandle = u32; pub type PendingObjectStoreDeleteHandle = u32; +pub type PendingObjectStoreInsertHandle = u32; pub type PendingObjectStoreListHandle = u32; -pub type BodyHandle = u32; +pub type PendingObjectStoreLookupHandle = u32; pub type PendingRequestHandle = u32; pub type RequestHandle = u32; pub type ResponseHandle = u32; -pub type DictionaryHandle = u32; -pub type KVStoreHandle = u32; -pub type SecretStoreHandle = u32; pub type SecretHandle = u32; -pub type AsyncItemHandle = u32; +pub type SecretStoreHandle = u32; const INVALID_HANDLE: u32 = u32::MAX - 1; @@ -303,6 +304,57 @@ pub struct InspectConfig { pub workspace_len: u32, } +pub mod fastly_acl { + use super::*; + use crate::bindings::fastly::api::acl; + use core::slice; + + #[export_name = "fastly_acl#open"] + pub fn open( + acl_name_ptr: *const u8, + acl_name_len: usize, + acl_handle_out: *mut AclHandle, + ) -> FastlyStatus { + let acl_name = unsafe { slice::from_raw_parts(acl_name_ptr, acl_name_len) }; + match acl::open(acl_name) { + Ok(res) => { + unsafe { + *acl_handle_out = res; + } + FastlyStatus::OK + } + Err(e) => e.into(), + } + } + + #[export_name = "fastly_acl#lookup"] + pub fn lookup( + acl_handle: acl::AclHandle, + ip_octets: *const u8, + ip_len: usize, + body_handle_out: *mut BodyHandle, + acl_error_out: *mut acl::AclError, + ) -> FastlyStatus { + let ip = unsafe { slice::from_raw_parts(ip_octets, ip_len) }; + match acl::lookup(acl_handle, ip, u64::try_from(ip_len).trapping_unwrap()) { + Ok((Some(body_handle), acl_error)) => { + unsafe { + *body_handle_out = body_handle; + *acl_error_out = acl_error; + } + FastlyStatus::OK + } + Ok((None, acl_error)) => { + unsafe { + *acl_error_out = acl_error; + } + FastlyStatus::OK + } + Err(e) => e.into(), + } + } +} + pub mod fastly_abi { use super::*; diff --git a/lib/compute-at-edge-abi/compute-at-edge.witx b/lib/compute-at-edge-abi/compute-at-edge.witx index b728d74e..be8a3cfb 100644 --- a/lib/compute-at-edge-abi/compute-at-edge.witx +++ b/lib/compute-at-edge-abi/compute-at-edge.witx @@ -1142,3 +1142,19 @@ (result $err (expected $vcpu_ms (error $fastly_status))) ) ) + +(module $fastly_acl + (@interface func (export "open") + (param $name string) + (result $err (expected $acl_handle (error $fastly_status))) + ) + + (@interface func (export "lookup") + (param $acl $acl_handle) + (param $ip_octets (@witx const_pointer (@witx char8))) + (param $ip_len (@witx usize)) + (param $body_handle_out (@witx pointer $body_handle)) + (param $acl_error_out (@witx pointer $acl_error)) + (result $err (expected (error $fastly_status))) + ) +) diff --git a/lib/compute-at-edge-abi/typenames.witx b/lib/compute-at-edge-abi/typenames.witx index a8e18fba..58c23be5 100644 --- a/lib/compute-at-edge-abi/typenames.witx +++ b/lib/compute-at-edge-abi/typenames.witx @@ -115,6 +115,8 @@ (typename $secret_store_handle (handle)) ;;; A handle to an individual secret. (typename $secret_handle (handle)) +;;; A handle to an ACL. +(typename $acl_handle (handle)) ;;; A handle to an object supporting generic async operations. ;;; Can be either a `body_handle` or a `pending_request_handle`. ;;; @@ -491,3 +493,17 @@ ;;; This will map to the api's 429 codes $too_many_requests )) + +(typename $acl_error + (enum (@witx tag u32) + ;;; The $acl_error has not been initialized. + $uninitialized + ;;; There was no error. + $ok + ;;; This will map to the api's 204 code. + ;;; It indicates that the request succeeded, yet returned nothing. + $no_content + ;;; This will map to the api's 429 code. + ;;; Too many requests have been made. + $too_many_requests + )) diff --git a/lib/data/viceroy-component-adapter.wasm b/lib/data/viceroy-component-adapter.wasm index 57cfa86674316b58bacafc7b76ecbcae1d45a2cb..71f0109e4716d5c0e765221b4e507cc6b3ff0dba 100755 GIT binary patch delta 40603 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 ISr@kcfAnRr>i_@% delta 39709 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, +} + +impl Acls { + pub fn new() -> Self { + Self { + acls: HashMap::new(), + } + } + + pub fn get_acl(&self, name: &str) -> Option<&Arc> { + self.acls.get(name) + } + + pub fn insert(&mut self, name: String, acl: Acl) { + self.acls.insert(name, Arc::new(acl)); + } +} + +/// An acl is a collection of acl entries. +#[derive(Debug, Default, Deserialize)] +pub struct Acl { + pub(crate) entries: Vec, +} + +impl Acl { + /// Lookup performs a naive lookup of the given IP address + /// over the acls entries. + /// + /// If the IP matches multiple ACL entries, then: + /// - The most specific match is returned (longest mask), + /// - and in case of a tie, the last entry wins. + pub fn lookup(&self, ip: IpAddr) -> Option<&Entry> { + self.entries.iter().fold(None, |acc, entry| { + if let Some(mask) = entry.prefix.is_match(ip) { + if acc.is_none_or(|prev_match: &Entry| mask >= prev_match.prefix.mask) { + return Some(entry); + } + } + acc + }) + } +} + +/// An entry is an IP prefix and its associated action. +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct Entry { + prefix: Prefix, + action: Action, +} + +/// A prefix is an IP and network mask. +#[derive(Debug, PartialEq)] +pub struct Prefix { + ip: IpAddr, + mask: u8, +} + +impl Prefix { + pub(crate) fn new(ip: IpAddr, mask: u8) -> Self { + // Normalize IP based on mask. + let (ip, mask) = match ip { + IpAddr::V4(v4) => { + let mask = mask.clamp(1, 32); + let bit_mask = u32::MAX << (32 - mask); + ( + IpAddr::V4(Ipv4Addr::from_bits(v4.to_bits() & bit_mask)), + mask, + ) + } + IpAddr::V6(v6) => { + let mask = mask.clamp(1, 128); + let bit_mask = u128::MAX << (128 - mask); + ( + IpAddr::V6(Ipv6Addr::from_bits(v6.to_bits() & bit_mask)), + mask, + ) + } + }; + + Self { ip, mask } + } + + /// If the given IP matches the prefix, then the prefix's + /// mask is returned. + pub(crate) fn is_match(&self, ip: IpAddr) -> Option { + let masked = Self::new(ip, self.mask); + if masked.ip == self.ip { + Some(self.mask) + } else { + None + } + } +} + +impl Display for Prefix { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!("{}/{}", self.ip, self.mask)) + } +} + +impl<'de> Deserialize<'de> for Prefix { + fn deserialize(de: D) -> Result + where + D: Deserializer<'de>, + { + let v = String::deserialize(de)?; + let (ip, mask) = v.split_once('/').ok_or(D::Error::custom(format!( + "invalid format '{}': want IP/MASK", + v + )))?; + + let mask = mask + .parse::() + .map_err(|err| D::Error::custom(format!("invalid prefix {}: {}", mask, err)))?; + + // Detect whether the IP is v4 or v6. + let ip = match ip.contains(':') { + false => { + if !(1..=32).contains(&mask) { + return Err(D::Error::custom(format!( + "mask outside allowed range [1, 32]: {}", + mask + ))); + } + ip.parse::().map(IpAddr::V4) + } + true => { + if !(1..=128).contains(&mask) { + return Err(D::Error::custom(format!( + "mask outside allowed range [1, 128]: {}", + mask + ))); + } + ip.parse::().map(IpAddr::V6) + } + } + .map_err(|err| D::Error::custom(format!("invalid ip address {}: {}", ip, err)))?; + + Ok(Self::new(ip, mask)) + } +} + +impl Serialize for Prefix { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(format!("{}", self).as_str()) + } +} + +const ACTION_ALLOW: &str = "ALLOW"; +const ACTION_BLOCK: &str = "BLOCK"; + +/// An action for a prefix. +#[derive(Clone, Debug, PartialEq)] +pub enum Action { + Allow, + Block, + Other(String), +} + +impl<'de> Deserialize<'de> for Action { + fn deserialize(de: D) -> Result + where + D: Deserializer<'de>, + { + let action = String::deserialize(de)?; + Ok(match action.to_uppercase().as_str() { + ACTION_ALLOW => Self::Allow, + ACTION_BLOCK => Self::Block, + _ => Self::Other(action), + }) + } +} + +impl Serialize for Action { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Allow => serializer.serialize_str(ACTION_ALLOW), + Self::Block => serializer.serialize_str(ACTION_BLOCK), + Self::Other(other) => serializer.serialize_str(format!("Other({})", other).as_str()), + } + } +} + +#[test] +fn prefix_is_match() { + let prefix = Prefix::new(Ipv4Addr::new(192, 168, 100, 0).into(), 16); + + assert_eq!( + prefix.is_match(Ipv4Addr::new(192, 168, 100, 0).into()), + Some(16) + ); + assert_eq!( + prefix.is_match(Ipv4Addr::new(192, 168, 200, 200).into()), + Some(16) + ); + + assert_eq!(prefix.is_match(Ipv4Addr::new(192, 167, 0, 0).into()), None); + assert_eq!(prefix.is_match(Ipv4Addr::new(192, 169, 0, 0).into()), None); + + let prefix = Prefix::new(Ipv6Addr::new(0xFACE, 0, 0, 0, 0, 0, 0, 0).into(), 16); + assert_eq!( + prefix.is_match(Ipv6Addr::new(0xFACE, 1, 2, 3, 4, 5, 6, 7).into()), + Some(16) + ); + + let v4 = Ipv4Addr::new(192, 168, 200, 200); + let v4_as_v6 = v4.to_ipv6_mapped(); + + assert_eq!(Prefix::new(v4.into(), 8).is_match(v4_as_v6.into()), None); + assert_eq!(Prefix::new(v4_as_v6.into(), 8).is_match(v4.into()), None); +} + +#[test] +fn acl_lookup() { + let acl = Acl { + entries: vec![ + Entry { + prefix: Prefix::new(Ipv4Addr::new(192, 168, 100, 0).into(), 16), + action: Action::Block, + }, + Entry { + prefix: Prefix::new(Ipv4Addr::new(192, 168, 100, 0).into(), 24), + action: Action::Block, + }, + Entry { + prefix: Prefix::new(Ipv4Addr::new(192, 168, 100, 0).into(), 8), + action: Action::Block, + }, + ], + }; + + match acl.lookup(Ipv4Addr::new(192, 168, 100, 1).into()) { + Some(lookup_match) => { + assert_eq!(acl.entries[1], *lookup_match); + } + None => panic!("expected lookup match"), + }; + + match acl.lookup(Ipv4Addr::new(192, 168, 200, 1).into()) { + Some(lookup_match) => { + assert_eq!(acl.entries[0], *lookup_match); + } + None => panic!("expected lookup match"), + }; + + match acl.lookup(Ipv4Addr::new(192, 1, 1, 1).into()) { + Some(lookup_match) => { + assert_eq!(acl.entries[2], *lookup_match); + } + None => panic!("expected lookup match"), + }; + + if let Some(lookup_match) = acl.lookup(Ipv4Addr::new(1, 1, 1, 1).into()) { + panic!("expected no lookup match, got {:?}", lookup_match) + }; +} + +#[test] +fn acl_json_parse() { + let input = r#" + { "entries": [ + { "op": "create", "prefix": "1.2.3.0/24", "action": "BLOCK" }, + { "op": "update", "prefix": "192.168.0.0/16", "action": "BLOCK" }, + { "op": "create", "prefix": "23.23.23.23/32", "action": "ALLOW" }, + { "op": "update", "prefix": "1.2.3.4/32", "action": "ALLOW" }, + { "op": "update", "prefix": "1.2.3.4/8", "action": "ALLOW" } + ]} + "#; + let acl: Acl = serde_json::from_str(input).expect("can decode"); + + let want = vec![ + Entry { + prefix: Prefix { + ip: IpAddr::V4(Ipv4Addr::new(1, 2, 3, 0)), + mask: 24, + }, + action: Action::Block, + }, + Entry { + prefix: Prefix { + ip: IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0)), + mask: 16, + }, + action: Action::Block, + }, + Entry { + prefix: Prefix { + ip: IpAddr::V4(Ipv4Addr::new(23, 23, 23, 23)), + mask: 32, + }, + action: Action::Allow, + }, + Entry { + prefix: Prefix { + ip: IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), + mask: 32, + }, + action: Action::Allow, + }, + Entry { + prefix: Prefix { + ip: IpAddr::V4(Ipv4Addr::new(1, 0, 0, 0)), + mask: 8, + }, + action: Action::Allow, + }, + ]; + + assert_eq!(acl.entries, want); +} + +#[test] +fn prefix_json_roundtrip() { + let assert_roundtrips = |input: &str, want: &str| { + let prefix: Prefix = + serde_json::from_str(format!("\"{}\"", input).as_str()).expect("can decode"); + let got = serde_json::to_string(&prefix).expect("can encode"); + assert_eq!( + got, + format!("\"{}\"", want), + "'{}' roundtrip: got {}, want {}", + input, + got, + want + ); + }; + + assert_roundtrips("255.255.255.255/32", "255.255.255.255/32"); + assert_roundtrips("255.255.255.255/8", "255.0.0.0/8"); + + assert_roundtrips("2002::1234:abcd:ffff:c0a8:101/64", "2002:0:0:1234::/64"); + assert_roundtrips("2000::AB/32", "2000::/32"); + + // Invalid prefix. + assert!(serde_json::from_str::("\"1.2.3.4/33\"").is_err()); + assert!(serde_json::from_str::("\"200::/129\"").is_err()); + assert!(serde_json::from_str::("\"200::/none\"").is_err()); + + // Invalid IP. + assert!(serde_json::from_str::("\"1.2.3.four/16\"").is_err()); + assert!(serde_json::from_str::("\"200::end/32\"").is_err()); + + // Invalid format. + assert!(serde_json::from_str::("\"1.2.3.4\"").is_err()); + assert!(serde_json::from_str::("\"200::\"").is_err()); +} + +#[test] +fn action_json_roundtrip() { + let assert_roundtrips = |input: &str, want: &str| { + let action: Action = + serde_json::from_str(format!("\"{}\"", input).as_str()).expect("can decode"); + let got = serde_json::to_string(&action).expect("can encode"); + assert_eq!( + got, + format!("\"{}\"", want), + "'{}' roundtrip: got {}, want {}", + input, + got, + want + ); + }; + + assert_roundtrips("ALLOW", "ALLOW"); + assert_roundtrips("allow", "ALLOW"); + assert_roundtrips("BLOCK", "BLOCK"); + assert_roundtrips("block", "BLOCK"); + assert_roundtrips("POTATO", "Other(POTATO)"); + assert_roundtrips("potato", "Other(potato)"); +} diff --git a/lib/src/component/acl.rs b/lib/src/component/acl.rs new file mode 100644 index 00000000..ff070392 --- /dev/null +++ b/lib/src/component/acl.rs @@ -0,0 +1,47 @@ +use super::fastly::api::{acl, http_body, types}; +use crate::linking::ComponentCtx; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +#[async_trait::async_trait] +impl acl::Host for ComponentCtx { + async fn open(&mut self, acl_name: Vec) -> Result { + let acl_name = String::from_utf8(acl_name)?; + let handle = self + .session + .acl_handle_by_name(&acl_name) + .ok_or(types::Error::OptionalNone)?; + Ok(handle.into()) + } + + async fn lookup( + &mut self, + acl_handle: acl::AclHandle, + ip_octets: Vec, + ip_len: u64, + ) -> Result<(Option, acl::AclError), types::Error> { + let acl = self + .session + .acl_by_handle(acl_handle.into()) + .ok_or(types::Error::BadHandle)?; + + let ip: IpAddr = match ip_len { + 4 => IpAddr::V4(Ipv4Addr::from( + TryInto::<[u8; 4]>::try_into(ip_octets).unwrap(), + )), + 16 => IpAddr::V6(Ipv6Addr::from( + TryInto::<[u8; 16]>::try_into(ip_octets).unwrap(), + )), + _ => return Err(types::Error::InvalidArgument), + }; + + match acl.lookup(ip) { + Some(entry) => { + let body = + serde_json::to_vec_pretty(&entry).map_err(|_| types::Error::GenericError)?; + let body_handle = self.session.insert_body(body.into()); + Ok((Some(body_handle.into()), acl::AclError::Ok)) + } + None => Ok((None, acl::AclError::NoContent)), + } + } +} diff --git a/lib/src/component/mod.rs b/lib/src/component/mod.rs index f137840a..ca8b0462 100644 --- a/lib/src/component/mod.rs +++ b/lib/src/component/mod.rs @@ -45,9 +45,12 @@ pub fn link_host_functions(linker: &mut component::Linker) -> anyh wasmtime_wasi::bindings::cli::stdout::add_to_linker_get_host(linker, wrap)?; wasmtime_wasi::bindings::cli::stderr::add_to_linker_get_host(linker, wrap)?; + fastly::api::acl::add_to_linker(linker, |x| x)?; fastly::api::async_io::add_to_linker(linker, |x| x)?; fastly::api::backend::add_to_linker(linker, |x| x)?; fastly::api::cache::add_to_linker(linker, |x| x)?; + fastly::api::compute_runtime::add_to_linker(linker, |x| x)?; + fastly::api::config_store::add_to_linker(linker, |x| x)?; fastly::api::device_detection::add_to_linker(linker, |x| x)?; fastly::api::dictionary::add_to_linker(linker, |x| x)?; fastly::api::erl::add_to_linker(linker, |x| x)?; @@ -56,19 +59,18 @@ pub fn link_host_functions(linker: &mut component::Linker) -> anyh fastly::api::http_req::add_to_linker(linker, |x| x)?; fastly::api::http_resp::add_to_linker(linker, |x| x)?; fastly::api::http_types::add_to_linker(linker, |x| x)?; + fastly::api::kv_store::add_to_linker(linker, |x| x)?; fastly::api::log::add_to_linker(linker, |x| x)?; fastly::api::object_store::add_to_linker(linker, |x| x)?; - fastly::api::kv_store::add_to_linker(linker, |x| x)?; fastly::api::purge::add_to_linker(linker, |x| x)?; fastly::api::secret_store::add_to_linker(linker, |x| x)?; fastly::api::types::add_to_linker(linker, |x| x)?; fastly::api::uap::add_to_linker(linker, |x| x)?; - fastly::api::config_store::add_to_linker(linker, |x| x)?; - fastly::api::compute_runtime::add_to_linker(linker, |x| x)?; Ok(()) } +pub mod acl; pub mod async_io; pub mod backend; pub mod cache; diff --git a/lib/src/config.rs b/lib/src/config.rs index 605187f3..85143477 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -2,7 +2,7 @@ use { self::{ - backends::BackendsConfig, dictionaries::DictionariesConfig, + acl::AclConfig, backends::BackendsConfig, dictionaries::DictionariesConfig, object_store::ObjectStoreConfig, secret_store::SecretStoreConfig, }, crate::error::FastlyConfigError, @@ -25,6 +25,10 @@ pub use self::dictionaries::{Dictionary, LoadedDictionary}; pub type Dictionaries = HashMap; +/// Types and deserializers for acl configuration settings. +mod acl; +pub use crate::acl::Acls; + /// Types and deserializers for backend configuration settings. mod backends; @@ -84,6 +88,11 @@ impl FastlyConfig { self.language.as_str() } + /// Get the acl configuration. + pub fn acls(&self) -> &Acls { + &self.local_server.acls.0 + } + /// Get the backend configuration. pub fn backends(&self) -> &Backends { &self.local_server.backends.0 @@ -191,6 +200,7 @@ impl TryInto for TomlFastlyConfig { /// may be added in the future. #[derive(Clone, Debug, Default)] pub struct LocalServerConfig { + acls: AclConfig, backends: BackendsConfig, device_detection: DeviceDetection, geolocation: Geolocation, @@ -211,6 +221,7 @@ pub enum ExperimentalModule { /// a [`LocalServerConfig`] with [`TryInto::try_into`]. #[derive(Deserialize)] struct RawLocalServerConfig { + acls: Option, backends: Option
, device_detection: Option
, geolocation: Option
, @@ -225,6 +236,7 @@ impl TryInto for RawLocalServerConfig { type Error = FastlyConfigError; fn try_into(self) -> Result { let Self { + acls, backends, device_detection, geolocation, @@ -232,6 +244,11 @@ impl TryInto for RawLocalServerConfig { object_stores, secret_stores, } = self; + let acls = if let Some(acls) = acls { + acls.try_into()? + } else { + AclConfig::default() + }; let backends = if let Some(backends) = backends { backends.try_into()? } else { @@ -264,6 +281,7 @@ impl TryInto for RawLocalServerConfig { }; Ok(LocalServerConfig { + acls, backends, device_detection, geolocation, diff --git a/lib/src/config/acl.rs b/lib/src/config/acl.rs new file mode 100644 index 00000000..559ffc94 --- /dev/null +++ b/lib/src/config/acl.rs @@ -0,0 +1,67 @@ +use crate::acl; + +#[derive(Clone, Debug, Default)] +pub struct AclConfig(pub(crate) acl::Acls); + +mod deserialization { + use { + super::AclConfig, + crate::acl, + crate::error::{AclConfigError, FastlyConfigError}, + std::path::PathBuf, + std::{convert::TryFrom, fs}, + toml::value::Table, + }; + + impl TryFrom
for AclConfig { + type Error = FastlyConfigError; + fn try_from(toml: Table) -> Result { + let mut acls = acl::Acls::new(); + + for (name, value) in toml.iter() { + // Here we allow each table entry to be either a: + // - string: path to JSON file + // - table: must have a 'file' entry, which is the path to JSON file + let path = if let Some(path) = value.as_str() { + path + } else if let Some(tbl) = value.as_table() { + tbl.get("file") + .ok_or(FastlyConfigError::InvalidAclDefinition { + name: name.to_string(), + err: AclConfigError::MissingFile, + })? + .as_str() + .ok_or(FastlyConfigError::InvalidAclDefinition { + name: name.to_string(), + err: AclConfigError::MissingFile, + })? + } else { + return Err(FastlyConfigError::InvalidAclDefinition { + name: name.to_string(), + err: AclConfigError::InvalidType, + }); + }; + + let acl: acl::Acl = { + let path = PathBuf::from(path); + let fd = fs::File::open(path).map_err(|err| { + FastlyConfigError::InvalidAclDefinition { + name: name.to_string(), + err: AclConfigError::IoError(err), + } + })?; + serde_json::from_reader(fd).map_err(|err| { + FastlyConfigError::InvalidAclDefinition { + name: name.to_string(), + err: AclConfigError::JsonError(err), + } + })? + }; + + acls.insert(name.to_string(), acl); + } + + Ok(Self(acls)) + } + } +} diff --git a/lib/src/error.rs b/lib/src/error.rs index a7cd2fc0..4eea2cb9 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -299,6 +299,10 @@ pub enum HandleError { /// An async item handle was not valid. #[error("Invalid async item handle: {0}")] InvalidAsyncItemHandle(crate::wiggle_abi::types::AsyncItemHandle), + + /// An acl handle was not valid. + #[error("Invalid acl handle: {0}")] + InvalidAclHandle(crate::wiggle_abi::types::AclHandle), } /// Errors that can occur in a worker thread running a guest module. @@ -356,6 +360,13 @@ pub enum FastlyConfigError { err: GeolocationConfigError, }, + #[error("invalid configuration for '{name}': {err}")] + InvalidAclDefinition { + name: String, + #[source] + err: AclConfigError, + }, + #[error("invalid configuration for '{name}': {err}")] InvalidBackendDefinition { name: String, @@ -400,6 +411,24 @@ pub enum FastlyConfigError { InvalidManifestVersion(#[from] semver::SemVerError), } +/// Errors that may occur while validating acl configurations. +#[derive(Debug, thiserror::Error)] +pub enum AclConfigError { + /// An I/O error that occurred while processing a file. + #[error(transparent)] + IoError(std::io::Error), + + /// An error occurred parsing JSON. + #[error(transparent)] + JsonError(serde_json::error::Error), + + #[error("acl must be a TOML table or string")] + InvalidType, + + #[error("missing 'file' field")] + MissingFile, +} + /// Errors that may occur while validating backend configurations. #[derive(Debug, thiserror::Error)] pub enum BackendConfigError { diff --git a/lib/src/execute.rs b/lib/src/execute.rs index a8aaee0c..c66dd5bc 100644 --- a/lib/src/execute.rs +++ b/lib/src/execute.rs @@ -2,6 +2,7 @@ use { crate::{ + acl::Acls, adapt, body::Body, component as compute, @@ -72,6 +73,8 @@ pub struct ExecuteCtx { engine: Engine, /// An almost-linked Instance: each import function is linked, just needs a Store instance_pre: Arc, + /// The acls for this execution. + acls: Arc, /// The backends for this execution. backends: Arc, /// The device detection mappings for this execution. @@ -208,6 +211,7 @@ impl ExecuteCtx { Ok(Self { engine, instance_pre: Arc::new(instance_pre), + acls: Arc::new(Acls::new()), backends: Arc::new(Backends::default()), device_detection: Arc::new(DeviceDetection::default()), geolocation: Arc::new(Geolocation::default()), @@ -231,6 +235,17 @@ impl ExecuteCtx { &self.engine } + /// Get the acls for this execution context. + pub fn acls(&self) -> &Acls { + &self.acls + } + + /// Set the acls for this execution context. + pub fn with_acls(mut self, acls: Acls) -> Self { + self.acls = Arc::new(acls); + self + } + /// Get the backends for this execution context. pub fn backends(&self) -> &Backends { &self.backends @@ -461,6 +476,7 @@ impl ExecuteCtx { remote, active_cpu_time_us, &self, + self.acls.clone(), self.backends.clone(), self.device_detection.clone(), self.geolocation.clone(), @@ -619,6 +635,7 @@ impl ExecuteCtx { remote, active_cpu_time_us.clone(), &self, + self.acls.clone(), self.backends.clone(), self.device_detection.clone(), self.geolocation.clone(), diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 6bf2811c..d35b11a7 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -21,6 +21,7 @@ pub mod error; pub mod logging; pub mod session; +mod acl; mod async_io; pub mod component; mod downstream; diff --git a/lib/src/linking.rs b/lib/src/linking.rs index 29c058e0..2033fa71 100644 --- a/lib/src/linking.rs +++ b/lib/src/linking.rs @@ -287,25 +287,26 @@ pub fn link_host_functions( wasmtime_wasi::preview1::add_to_linker_async(linker, WasmCtx::wasi)?; wiggle_abi::fastly_abi::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_acl::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_async_io::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_backend::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_cache::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_compute_runtime::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_config_store::add_to_linker(linker, WasmCtx::session)?; - wiggle_abi::fastly_dictionary::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_device_detection::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_dictionary::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_erl::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_geo::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_http_body::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_http_cache::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_http_req::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_http_resp::add_to_linker(linker, WasmCtx::session)?; + wiggle_abi::fastly_kv_store::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_log::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_object_store::add_to_linker(linker, WasmCtx::session)?; - wiggle_abi::fastly_kv_store::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_purge::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_secret_store::add_to_linker(linker, WasmCtx::session)?; wiggle_abi::fastly_uap::add_to_linker(linker, WasmCtx::session)?; - wiggle_abi::fastly_async_io::add_to_linker(linker, WasmCtx::session)?; - wiggle_abi::fastly_backend::add_to_linker(linker, WasmCtx::session)?; - wiggle_abi::fastly_compute_runtime::add_to_linker(linker, WasmCtx::session)?; link_legacy_aliases(linker)?; Ok(()) } diff --git a/lib/src/session.rs b/lib/src/session.rs index edade7fd..8bb43633 100644 --- a/lib/src/session.rs +++ b/lib/src/session.rs @@ -22,6 +22,7 @@ use crate::object_store::KvStoreError; use { self::downstream::DownstreamResponse, crate::{ + acl::{Acl, Acls}, body::Body, config::{Backend, Backends, DeviceDetection, Dictionaries, Geolocation, LoadedDictionary}, error::{Error, HandleError}, @@ -31,11 +32,11 @@ use { streaming_body::StreamingBody, upstream::{SelectTarget, TlsConfig}, wiggle_abi::types::{ - self, BodyHandle, ContentEncodings, DictionaryHandle, EndpointHandle, KvInsertMode, - KvStoreDeleteHandle, KvStoreHandle, KvStoreInsertHandle, KvStoreListHandle, - KvStoreLookupHandle, PendingKvDeleteHandle, PendingKvInsertHandle, PendingKvListHandle, - PendingKvLookupHandle, PendingRequestHandle, RequestHandle, ResponseHandle, - SecretHandle, SecretStoreHandle, + self, AclHandle, BodyHandle, ContentEncodings, DictionaryHandle, EndpointHandle, + KvInsertMode, KvStoreDeleteHandle, KvStoreHandle, KvStoreInsertHandle, + KvStoreListHandle, KvStoreLookupHandle, PendingKvDeleteHandle, PendingKvInsertHandle, + PendingKvListHandle, PendingKvLookupHandle, PendingRequestHandle, RequestHandle, + ResponseHandle, SecretHandle, SecretStoreHandle, }, ExecuteCtx, }, @@ -97,6 +98,12 @@ pub struct Session { log_endpoints: PrimaryMap, /// A by-name map for logging endpoints. log_endpoints_by_name: HashMap, EndpointHandle>, + /// The ACLs configured for this execution. + /// + /// Populated prior to guest execution, and never modified. + acls: Arc, + /// Active ACL handles. + acl_handles: PrimaryMap>, /// The backends configured for this execution. /// /// Populated prior to guest execution, and never modified. @@ -164,6 +171,7 @@ impl Session { client_addr: SocketAddr, active_cpu_time_us: Arc, ctx: &ExecuteCtx, + acls: Arc, backends: Arc, device_detection: Arc, geolocation: Arc, @@ -197,6 +205,8 @@ impl Session { capture_logs: ctx.capture_logs(), log_endpoints: PrimaryMap::new(), log_endpoints_by_name: HashMap::new(), + acls, + acl_handles: PrimaryMap::new(), backends, device_detection, geolocation, @@ -591,6 +601,17 @@ impl Session { .ok_or(HandleError::InvalidEndpointHandle(handle)) } + // ----- ACLs API ----- + + pub fn acl_handle_by_name(&mut self, name: &str) -> Option { + let acl = self.acls.get_acl(name)?; + Some(self.acl_handles.push(acl.clone())) + } + + pub fn acl_by_handle(&self, handle: AclHandle) -> Option> { + self.acl_handles.get(handle).map(Arc::clone) + } + // ----- Backends API ----- /// Look up a backend by name. diff --git a/lib/src/wiggle_abi.rs b/lib/src/wiggle_abi.rs index e191b0b1..df74f73e 100644 --- a/lib/src/wiggle_abi.rs +++ b/lib/src/wiggle_abi.rs @@ -48,6 +48,7 @@ macro_rules! multi_value_result { }}; } +mod acl; mod backend_impl; mod body_impl; mod cache; @@ -76,6 +77,7 @@ wiggle::from_witx!({ witx: ["$CARGO_MANIFEST_DIR/compute-at-edge-abi/compute-at-edge.witx"], errors: { fastly_status => Error }, async: { + fastly_acl::lookup, fastly_async_io::{select}, fastly_object_store::{delete_async, pending_delete_wait, insert, insert_async, pending_insert_wait, lookup_async, pending_lookup_wait, list}, fastly_kv_store::{lookup, lookup_wait, insert, insert_wait, delete, delete_wait, list, list_wait}, diff --git a/lib/src/wiggle_abi/acl.rs b/lib/src/wiggle_abi/acl.rs new file mode 100644 index 00000000..d12cd83e --- /dev/null +++ b/lib/src/wiggle_abi/acl.rs @@ -0,0 +1,65 @@ +use crate::error::{Error, HandleError}; +use crate::session::Session; +use crate::wiggle_abi::{fastly_acl, types}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +#[wiggle::async_trait] +impl fastly_acl::FastlyAcl for Session { + /// Open a handle to an ACL by its linked name. + fn open( + &mut self, + memory: &mut wiggle::GuestMemory<'_>, + acl_name: wiggle::GuestPtr, + ) -> Result { + let acl_name = memory.as_str(acl_name)?.ok_or(Error::SharedMemory)?; + self.acl_handle_by_name(acl_name).ok_or(Error::ValueAbsent) + } + + /// Perform an ACL lookup operation using the given ACL handle. + /// + /// There are two levels of errors returned by this function: + /// - Error: These are general hostcall errors, e.g. handle not found. + /// - AclError: There are ACL-specific errors, e.g. 'no content'. + /// It's the callers responsibility to check both errors. + async fn lookup( + &mut self, + memory: &mut wiggle::GuestMemory<'_>, + acl_handle: types::AclHandle, + ip_octets: wiggle::GuestPtr, // This should be either a 4 or 16-byte array. + ip_len: u32, // Either 4 or 16. + body_handle_out: wiggle::GuestPtr, + acl_error_out: wiggle::GuestPtr, + ) -> Result<(), Error> { + let acl = self.acl_by_handle(acl_handle).ok_or(Error::HandleError( + HandleError::InvalidAclHandle(acl_handle), + ))?; + + let ip: IpAddr = { + let ip_octets = memory.to_vec(ip_octets.as_array(ip_len))?; + match ip_len { + 4 => IpAddr::V4(Ipv4Addr::from( + TryInto::<[u8; 4]>::try_into(ip_octets).unwrap(), + )), + 16 => IpAddr::V6(Ipv6Addr::from( + TryInto::<[u8; 16]>::try_into(ip_octets).unwrap(), + )), + _ => return Err(Error::InvalidArgument), + } + }; + + match acl.lookup(ip) { + Some(entry) => { + let body = + serde_json::to_vec_pretty(&entry).map_err(|err| Error::Other(err.into()))?; + let body_handle = self.insert_body(body.into()); + memory.write(body_handle_out, body_handle)?; + memory.write(acl_error_out, types::AclError::Ok)?; + Ok(()) + } + None => { + memory.write(acl_error_out, types::AclError::NoContent)?; + Ok(()) + } + } + } +} diff --git a/lib/src/wiggle_abi/entity.rs b/lib/src/wiggle_abi/entity.rs index 5b11940d..00547165 100644 --- a/lib/src/wiggle_abi/entity.rs +++ b/lib/src/wiggle_abi/entity.rs @@ -3,7 +3,7 @@ //! [ref]: https://docs.rs/cranelift-entity/latest/cranelift_entity/trait.EntityRef.html use super::types::{ - AsyncItemHandle, BodyHandle, DictionaryHandle, EndpointHandle, KvStoreHandle, + AclHandle, AsyncItemHandle, BodyHandle, DictionaryHandle, EndpointHandle, KvStoreHandle, ObjectStoreHandle, PendingRequestHandle, RequestHandle, ResponseHandle, SecretHandle, SecretStoreHandle, }; @@ -40,14 +40,15 @@ macro_rules! wiggle_entity { }; } +wiggle_entity!(AclHandle); +wiggle_entity!(AsyncItemHandle); wiggle_entity!(BodyHandle); -wiggle_entity!(RequestHandle); -wiggle_entity!(ResponseHandle); -wiggle_entity!(EndpointHandle); -wiggle_entity!(PendingRequestHandle); wiggle_entity!(DictionaryHandle); -wiggle_entity!(ObjectStoreHandle); +wiggle_entity!(EndpointHandle); wiggle_entity!(KvStoreHandle); -wiggle_entity!(SecretStoreHandle); +wiggle_entity!(ObjectStoreHandle); +wiggle_entity!(PendingRequestHandle); +wiggle_entity!(RequestHandle); +wiggle_entity!(ResponseHandle); wiggle_entity!(SecretHandle); -wiggle_entity!(AsyncItemHandle); +wiggle_entity!(SecretStoreHandle); diff --git a/lib/wit/deps/fastly/compute.wit b/lib/wit/deps/fastly/compute.wit index cc1076dc..73e4c8b0 100644 --- a/lib/wit/deps/fastly/compute.wit +++ b/lib/wit/deps/fastly/compute.wit @@ -850,6 +850,32 @@ interface secret-store { from-bytes: func(bytes: list) -> result; } +/* + * Fastly ACL + */ +interface acl { + + use types.{error}; + use http-types.{body-handle}; + + type acl-handle = u32; + + enum acl-error { + uninitialized, + ok, + no-content, + too-many-requests, + } + + open: func(name: list) -> result; + + lookup: func( + acl: acl-handle, + ip-octets: list, + ip-len: u64, + ) -> result, acl-error>, error>; +} + /* * Fastly backend */ @@ -1338,6 +1364,7 @@ world compute { import wasi:cli/stderr@0.2.0; import wasi:cli/stdin@0.2.0; + import acl; import async-io; import backend; import cache; diff --git a/test-fixtures/Cargo.toml b/test-fixtures/Cargo.toml index 3ff10508..e9701691 100644 --- a/test-fixtures/Cargo.toml +++ b/test-fixtures/Cargo.toml @@ -7,6 +7,11 @@ edition = "2021" license = "Apache-2.0 WITH LLVM-exception" publish = false +[features] +# Temporary feature used until the fastly SDK is updated +# to a version which contains the fastly_acl hostcalls. +acl_hostcalls = [] + [dependencies] anyhow = "1.0.86" base64 = "0.21.2" diff --git a/test-fixtures/data/my-acl-1.json b/test-fixtures/data/my-acl-1.json new file mode 100644 index 00000000..6ed02a26 --- /dev/null +++ b/test-fixtures/data/my-acl-1.json @@ -0,0 +1,8 @@ +{ + "entries": [ + { "prefix": "1.2.3.0/24", "action": "BLOCK" }, + { "prefix": "192.168.0.0/16", "action": "BLOCK" }, + { "prefix": "23.23.23.23/32", "action": "ALLOW" }, + { "prefix": "1.2.3.4/32", "action": "ALLOW" } + ] +} diff --git a/test-fixtures/data/my-acl-2.json b/test-fixtures/data/my-acl-2.json new file mode 100644 index 00000000..bb385ec9 --- /dev/null +++ b/test-fixtures/data/my-acl-2.json @@ -0,0 +1,6 @@ +{ + "entries": [ + { "prefix": "2000::/24", "action": "BLOCK" }, + { "prefix": "FACE::/16", "action": "ALLOW" } + ] +} diff --git a/test-fixtures/src/bin/acl.rs b/test-fixtures/src/bin/acl.rs new file mode 100644 index 00000000..a21b74ef --- /dev/null +++ b/test-fixtures/src/bin/acl.rs @@ -0,0 +1,62 @@ +//! A guest program to test that acls works properly. +use fastly::Error; + +fn main() -> Result<(), Error> { + // Temporary until fastly SDK is released which + // includes the fastly::acl module. + #[cfg(feature = "acl_hostcalls")] + { + use fastly::acl::Acl; + use std::net::{Ipv4Addr, Ipv6Addr}; + + match Acl::open("DOES-NOT-EXIST") { + Err(fastly::acl::OpenError::AclNotFound) => { /* OK */ } + Err(other) => panic!("expected error opening non-existant acl, got: {:?}", other), + _ => panic!("expected error opening non-existant acl, got Ok"), + } + + let acl1 = Acl::open("my-acl-1")?; + + match acl1.try_lookup(Ipv4Addr::new(192, 168, 1, 1).into())? { + Some(lookup_match) => { + assert_eq!(lookup_match.prefix(), "192.168.0.0/16"); + assert!(lookup_match.is_block()); + } + None => panic!("expected match"), + }; + match acl1.try_lookup(Ipv4Addr::new(23, 23, 23, 23).into())? { + Some(lookup_match) => { + assert_eq!(lookup_match.prefix(), "23.23.23.23/32"); + assert!(lookup_match.is_allow()); + } + None => panic!("expected match"), + }; + if let Some(lookup_match) = acl1.try_lookup(Ipv4Addr::new(100, 100, 100, 100).into())? { + panic!("expected no match, got: {:?}", lookup_match); + } + + let acl2 = Acl::open("my-acl-2")?; + + match acl2.try_lookup(Ipv6Addr::new(0x2000, 0, 0, 0, 0, 1, 2, 3).into())? { + Some(lookup_match) => { + assert_eq!(lookup_match.prefix(), "2000::/24"); + assert!(lookup_match.is_block()); + } + None => panic!("expected match"), + }; + match acl2.try_lookup(Ipv6Addr::new(0xFACE, 0, 2, 3, 4, 5, 6, 7).into())? { + Some(lookup_match) => { + assert_eq!(lookup_match.prefix(), "face::/16"); + assert!(lookup_match.is_allow()); + } + None => panic!("expected match"), + }; + if let Some(lookup_match) = + acl2.try_lookup(Ipv6Addr::new(0xFADE, 1, 2, 3, 4, 5, 6, 7).into())? + { + panic!("expected no match, got: {:?}", lookup_match); + }; + } + + Ok(()) +} From c18e3b6e8234c0117b1e64ffc3078ab7079706e8 Mon Sep 17 00:00:00 2001 From: Adam Williams Date: Thu, 31 Oct 2024 09:27:48 -0600 Subject: [PATCH 2/2] Better document and test the JSON format of ACLs --- lib/src/acl.rs | 19 +++++++++++++++++++ test-fixtures/data/my-acl-1.json | 8 ++++---- test-fixtures/data/my-acl-2.json | 4 ++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/src/acl.rs b/lib/src/acl.rs index ec16b994..5ad01882 100644 --- a/lib/src/acl.rs +++ b/lib/src/acl.rs @@ -27,6 +27,22 @@ impl Acls { } /// An acl is a collection of acl entries. +/// +/// The JSON representation of this struct intentionally matches the JSON +/// format used to create/update ACLs via api.fastly.com. The goal being +/// to allow users to use the same JSON in Viceroy as in production. +/// +/// Example: +/// +/// ```json +/// { "entries": [ +/// { "op": "create", "prefix": "1.2.3.0/24", "action": "BLOCK" }, +/// { "op": "create", "prefix": "23.23.23.23/32", "action": "ALLOW" }, +/// { "op": "update", "prefix": "FACE::/32", "action": "ALLOW" } +/// ]} +/// ``` +/// +/// Note that, in Viceroy, the `op` field is ignored. #[derive(Debug, Default, Deserialize)] pub struct Acl { pub(crate) entries: Vec, @@ -273,6 +289,9 @@ fn acl_lookup() { #[test] fn acl_json_parse() { + // In the following JSON, the `op` field should be ignored. It's included + // to assert that the JSON format used with api.fastly.com to create/modify + // ACLs can be used in Viceroy as well. let input = r#" { "entries": [ { "op": "create", "prefix": "1.2.3.0/24", "action": "BLOCK" }, diff --git a/test-fixtures/data/my-acl-1.json b/test-fixtures/data/my-acl-1.json index 6ed02a26..ee44a2e9 100644 --- a/test-fixtures/data/my-acl-1.json +++ b/test-fixtures/data/my-acl-1.json @@ -1,8 +1,8 @@ { "entries": [ - { "prefix": "1.2.3.0/24", "action": "BLOCK" }, - { "prefix": "192.168.0.0/16", "action": "BLOCK" }, - { "prefix": "23.23.23.23/32", "action": "ALLOW" }, - { "prefix": "1.2.3.4/32", "action": "ALLOW" } + { "op": "update", "prefix": "1.2.3.0/24", "action": "BLOCK" }, + { "op": "create", "prefix": "192.168.0.0/16", "action": "BLOCK" }, + { "op": "update", "prefix": "23.23.23.23/32", "action": "ALLOW" }, + { "op": "create", "prefix": "1.2.3.4/32", "action": "ALLOW" } ] } diff --git a/test-fixtures/data/my-acl-2.json b/test-fixtures/data/my-acl-2.json index bb385ec9..113a33e6 100644 --- a/test-fixtures/data/my-acl-2.json +++ b/test-fixtures/data/my-acl-2.json @@ -1,6 +1,6 @@ { "entries": [ - { "prefix": "2000::/24", "action": "BLOCK" }, - { "prefix": "FACE::/16", "action": "ALLOW" } + { "op": "update", "prefix": "2000::/24", "action": "BLOCK" }, + { "op": "create", "prefix": "FACE::/16", "action": "ALLOW" } ] }