Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

net: finalize implementation #4

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ route-pattern = { version = "0.1.0", optional = true }
regex-intersect = { version = "1.2.0", optional = true }
form_urlencoded = { version = "1.1.0", optional = true }
urlencoding = { version = "2.1.2", optional = true }
trust-dns-resolver = { version = "0.22.0", optional = true }
ipnet = { version = "2.5.1", optional = true }

[dev-dependencies.tokio]
version = "1.5"
Expand Down Expand Up @@ -111,6 +113,7 @@ yaml-builtins = ["dep:serde_yaml"]
glob-builtins = ["dep:globset"]
regex-builtins = ["dep:regex", "dep:route-pattern", "dep:regex-intersect"]
urlquery-builtins = ["dep:form_urlencoded", "dep:urlencoding"]
net-builtins = ["dep:ipnet", "dep:trust-dns-resolver"]

all-crypto-builtins = [
"crypto-digest-builtins",
Expand All @@ -133,6 +136,7 @@ all-builtins = [
"glob-builtins",
"regex-builtins",
"urlquery-builtins",
"net-builtins",
]

[[test]]
Expand Down
2 changes: 2 additions & 0 deletions src/builtins/impls/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ pub mod http;
pub mod io;
#[cfg(feature = "json-builtins")]
pub mod json;
#[cfg(feature = "net-builtins")]
pub mod net;

pub mod object;
pub mod opa;
#[cfg(feature = "rng")]
Expand Down
225 changes: 216 additions & 9 deletions src/builtins/impls/net.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,136 @@
// limitations under the License.

//! Builtins related to network operations and IP handling
//!
//! Note: spec says to return a hashset, but we're returning Vec for order
//! stability. where it is not inherently unique, we make guarantee that items
//! are unique as in a hash before converting to a vec. representation on the
//! result side in OPA is always an array (JSON)

use std::collections::HashSet;
use std::{collections::HashSet, net::IpAddr, str::FromStr};

use anyhow::{bail, Result};
use anyhow::{bail, Context, Result};
use ipnet::IpNet;
use serde_json::Number;
use trust_dns_resolver::TokioAsyncResolver;

/// A unified address or CIDR block type
#[allow(clippy::upper_case_acronyms)]
#[derive(Debug, Clone, Copy)]
enum Addr {
CIDR(IpNet),
IP(IpAddr),
}

/// builds a generic IP or CIDR container
fn addr_from_cidr_or_ip(s: &str) -> Result<Addr> {
IpNet::from_str(s).map(Addr::CIDR).or_else(|_| {
IpAddr::from_str(s)
.map(Addr::IP)
.context(format!("cannot parse {} into cidr or ip", s))
})
}

/// this expand upon the go side handling additional cases where:
/// ip can contain ip (by way of strict equality)
/// cidr contains ip, but also ip is contained within cidr (flip the arguments)
/// * in the Go case, it's assumed thatmut root call of `cidr_contains_matches`
/// LHS is CIDR and RHS is "CIDR or IP". here by being direction agnosic we
/// make no such assumption and handle a larger set of cases
#[allow(clippy::similar_names)]
fn addr_contains(left: &Addr, right: &Addr) -> bool {
match (left, right) {
(Addr::CIDR(cidr), Addr::IP(ip)) | (Addr::IP(ip), Addr::CIDR(cidr)) => cidr.contains(ip),
(Addr::CIDR(lcidr), Addr::CIDR(rcidr)) => lcidr.contains(rcidr),
(Addr::IP(lip), Addr::IP(rip)) => lip == rip,
}
}

/// this is modeled after the Go side, where we want to get a term generically
/// from various IP/CIDR nestings:
/// "x":"y" -> "y"
/// "x":["y", "rest"] -> "y"
/// etc.
/// in this case, we work on the lowest level term: atom or array, and in the
/// case of array we always take the first atom.
fn get_addr_term(t: &serde_json::Value) -> Result<Addr> {
if let Some(s) = t.as_str() {
addr_from_cidr_or_ip(s)
} else if let Some(v) = t.as_array() {
let s = v
.first()
.context("value '' does not contain an address or cidr in first position")?
.as_str()
.context("value is not an address")?;
addr_from_cidr_or_ip(s)
} else {
bail!("value '{:?}' does not contain an address or cidr", t)
}
}

/// match one of the LHS items against a collection from the RHS
fn match_collection<I>(
item: &Addr,
item_key: &serde_json::Value,
iter: I,
) -> Result<Vec<(serde_json::Value, serde_json::Value)>>
where
I: Iterator<Item = Result<(serde_json::Value, Addr)>>,
{
Ok(iter
.collect::<Result<Vec<_>>>()?
.into_iter()
.filter(|(_, candidate)| addr_contains(item, candidate))
.map(|(candidate_key, _)| (item_key.clone(), candidate_key))
.collect::<Vec<_>>())
}

/// match one of the LHS items against an atom, collection, or hash
fn match_any(
item: &Addr,
item_key: &serde_json::Value,
cidrs_or_ips: &serde_json::Value,
) -> Result<Vec<(serde_json::Value, serde_json::Value)>> {
if let Some(rs) = cidrs_or_ips.as_str() {
let ip_or_cidr = addr_from_cidr_or_ip(rs)?;
if addr_contains(item, &ip_or_cidr) {
Ok(vec![(item_key.clone(), cidrs_or_ips.clone())])
} else {
Ok(vec![])
}
} else if let Some(rv) = cidrs_or_ips.as_array() {
match_collection(
item,
item_key,
rv.iter().enumerate().map(|(idx, el)| {
get_addr_term(el).map(|t| (serde_json::Value::Number(Number::from(idx)), t))
}),
)
} else if let Some(robj) = cidrs_or_ips.as_object() {
match_collection(
item,
item_key,
robj.iter().map(|(k, el)| {
get_addr_term(el).map(|t| (serde_json::Value::String(k.to_string()), t))
}),
)
} else {
bail!("cannot match against this data type")
}
}

/// flatten `cidr_contains_matches` top level matching results
fn flatten_matches<I>(iter: I) -> Result<Vec<serde_json::Value>>
where
I: Iterator<Item = Result<Vec<(serde_json::Value, serde_json::Value)>>>,
{
Ok(iter
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.map(|(v1, v2)| serde_json::Value::Array(vec![v1, v2]))
.collect::<Vec<_>>())
}

/// Checks if collections of cidrs or ips are contained within another
/// collection of cidrs and returns matches. This function is similar to
Expand All @@ -28,15 +154,71 @@ pub fn cidr_contains_matches(
cidrs: serde_json::Value,
cidrs_or_ips: serde_json::Value,
) -> Result<serde_json::Value> {
bail!("not implemented");
let res = if let Some(s) = cidrs.as_str() {
match_any(
&addr_from_cidr_or_ip(s)?,
&serde_json::Value::String(s.to_string()),
&cidrs_or_ips,
)?
.into_iter()
.map(|(v1, v2)| serde_json::Value::Array(vec![v1, v2]))
.collect::<Vec<_>>()
} else if let Some(v) = cidrs.as_array() {
flatten_matches(
v.iter()
.map(get_addr_term)
.collect::<Result<Vec<_>>>()?
.into_iter()
.enumerate()
.map(|(idx, item)| {
match_any(
&item,
&serde_json::Value::Number(Number::from(idx)),
&cidrs_or_ips,
)
}),
)?
} else if let Some(obj) = cidrs.as_object() {
flatten_matches(
obj.iter()
.map(|(k, el)| get_addr_term(el).map(|item| (k, item)))
.collect::<Result<Vec<_>>>()?
.into_iter()
.map(|(k, item)| {
match_any(
&item,
&serde_json::Value::String(k.to_string()),
&cidrs_or_ips,
)
}),
)?
} else {
bail!("cannot match against these arguments")
};

Ok(serde_json::Value::Array(res))
}

/// Expands CIDR to set of hosts (e.g., `net.cidr_expand("192.168.0.0/30")`
/// generates 4 hosts: `{"192.168.0.0", "192.168.0.1", "192.168.0.2",
/// "192.168.0.3"}`).
#[tracing::instrument(name = "net.cidr_expand", err)]
pub fn cidr_expand(cidr: String) -> Result<HashSet<String>> {
bail!("not implemented");
pub fn cidr_expand(cidr: String) -> Result<Vec<String>> {
// IpNet.hosts() is too smart because it excludes the
// broadcast and network IP. which is why we're doing it manually here to
// include all addresses in range including broadcast and network:
let addrs = match IpNet::from_str(&cidr)? {
IpNet::V4(net) => ipnet::Ipv4AddrRange::new(net.network(), net.broadcast())
.map(|addr| addr.to_string())
.collect::<HashSet<_>>(),
IpNet::V6(net) => ipnet::Ipv6AddrRange::new(net.network(), net.broadcast())
.map(|addr| addr.to_string())
.collect::<HashSet<_>>(),
};
let mut res = addrs.into_iter().collect::<Vec<_>>();
res.sort();

Ok(res)
}

/// Merges IP addresses and subnets into the smallest possible list of CIDRs
Expand All @@ -47,13 +229,38 @@ pub fn cidr_expand(cidr: String) -> Result<HashSet<String>> {
/// Supports both IPv4 and IPv6 notations. IPv6 inputs need a prefix length
/// (e.g. "/128").
#[tracing::instrument(name = "net.cidr_merge", err)]
pub fn cidr_merge(addrs: serde_json::Value) -> Result<HashSet<String>> {
bail!("not implemented");
pub fn cidr_merge(addrs: serde_json::Value) -> Result<Vec<String>> {
if let Some(addrs) = addrs.as_array() {
let ipnets = addrs
.iter()
.map(|addr| {
addr.as_str()
.and_then(|s| IpNet::from_str(s).ok())
.context("is not an address string")
})
.collect::<Result<Vec<_>>>()?;
Ok(IpNet::aggregate(&ipnets)
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>())
} else {
bail!("data type not supported: {}", addrs);
}
}

/// Returns the set of IP addresses (both v4 and v6) that the passed-in `name`
/// resolves to using the standard name resolution mechanisms available.
#[tracing::instrument(name = "net.lookup_ip_addr", err)]
pub async fn lookup_ip_addr(name: String) -> Result<HashSet<String>> {
bail!("not implemented");
pub async fn lookup_ip_addr(name: String) -> Result<Vec<String>> {
let resolver = TokioAsyncResolver::tokio(
trust_dns_resolver::config::ResolverConfig::default(),
trust_dns_resolver::config::ResolverOpts::default(),
)?;

let response = resolver.lookup_ip(&name).await?;

Ok(response
.iter()
.map(|addr| addr.to_string())
.collect::<Vec<_>>())
}
5 changes: 5 additions & 0 deletions src/builtins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,15 @@ pub fn resolve<C: EvaluationContext>(name: &str) -> Result<Box<dyn Builtin<C>>>
#[cfg(feature = "json-builtins")]
"json.patch" => Ok(self::impls::json::patch.wrap()),

#[cfg(feature = "net-builtins")]
"net.cidr_contains_matches" => Ok(self::impls::net::cidr_contains_matches.wrap()),
#[cfg(feature = "net-builtins")]
"net.cidr_expand" => Ok(self::impls::net::cidr_expand.wrap()),
#[cfg(feature = "net-builtins")]
"net.cidr_merge" => Ok(self::impls::net::cidr_merge.wrap()),
#[cfg(feature = "net-builtins")]
"net.lookup_ip_addr" => Ok(self::impls::net::lookup_ip_addr.wrap()),

"object.union_n" => Ok(self::impls::object::union_n.wrap()),
"opa.runtime" => Ok(self::impls::opa::runtime.wrap()),

Expand Down
53 changes: 53 additions & 0 deletions tests/infra-fixtures/test-net.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package test

output_1 := net.cidr_contains_matches({"x": "1.1.0.0/16"}, [["1.1.1.128", "foo"]])

output_2 := net.cidr_contains_matches("1.1.0.0/16", "1.1.1.128")

output_3 := net.cidr_contains_matches(["1.1.0.0/16"], ["1.1.1.128"])

output_4 := net.cidr_contains_matches([["1.1.0.0/16"]], [["1.1.1.128"]])

output_5 := net.cidr_contains_matches([["1.1.0.0/16", "x"]], [["1.1.1.128", "y"], ["2.2.2.2", "z"]])

output_6 := net.cidr_contains_matches([["1.1.0.0/16", "x"]], {"y": ["1.1.1.128", "foo"], "z": ["2.2.2.2", "bar"]})

output_7 := net.cidr_contains_matches([["1.1.0.0/16", "x"]], {"y": "1.1.1.128", "z": "2.2.2.2"})

# from docs

docs_test_1 := net.cidr_contains_matches("1.1.1.0/24", "1.1.1.128")

docs_test_2 := net.cidr_contains_matches(["1.1.1.0/24", "1.1.2.0/24"], "1.1.1.128")

docs_test_3 := net.cidr_contains_matches([["1.1.0.0/16", "foo"], "1.1.2.0/24"], ["1.1.1.128", ["1.1.254.254", "bar"]])

# https://github.com/open-policy-agent/opa/issues/3252
# notice sets are reversing the order on WASM, stated by design
# and a WASM vs Rego thing we'll "have to live with"
# the engine will flip
# ["1.1.0.0/16", "foo"], "1.1.2.0/24"
# to be:
# "1.1.2.0/24", ["1.1.0.0/16", "foo"],
# and so, index in the result is '1' and not '0' like in the docs
docs_test_4 := net.cidr_contains_matches({"1.1.2.0/24", ["1.1.0.0/16", "foo"]}, {"x": "1.1.1.128", "y": ["1.1.254.254", "bar"]})

# switching to arrays works as intended, which is the recommended workaround
docs_test_4_1 := net.cidr_contains_matches([["1.1.0.0/16", "foo"], "1.1.2.0/24"], {"x": "1.1.1.128", "y": ["1.1.254.254", "bar"]})

cidr_expand_1 := net.cidr_expand("1.1.0.0/30")

cidr_expand_2 := net.cidr_expand("1.1.0.0/32")

cidr_expand_3 := net.cidr_expand("2002::1234:abcd:ffff:c0a8:101/128")

cidr_expand_4 := net.cidr_expand("2002::1234:abcd:ffff:c0a8:101/127")

cidr_merge_1 := net.cidr_merge(["192.0.128.0/24", "192.0.129.0/24"])

cidr_merge_2 := net.cidr_merge(["1900::1/96", "1900::20/64"])

# will example.com forever be: 93.184.216.34 ?
# taking the risk of years in the future this test fails because they
# changed IPs. So this will go out to network to do the lookup:
net_lookup := net.lookup_ip_addr("www.example.com")
1 change: 1 addition & 0 deletions tests/smoke_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ integration_test!(test_yaml, "test-yaml");
integration_test!(test_glob, "test-glob");
integration_test!(test_regex, "test-regex");
integration_test!(test_urlquery, "test-urlquery");
integration_test!(test_net, "test-net");

/*
#[tokio::test]
Expand Down
Loading