diff --git a/changelog.d/2579.fixed.md b/changelog.d/2579.fixed.md new file mode 100644 index 00000000000..c07e0eb805c --- /dev/null +++ b/changelog.d/2579.fixed.md @@ -0,0 +1 @@ +Fixed a bug where enabling remote DNS prevented making a local connection with telnet. \ No newline at end of file diff --git a/changelog.d/2581.added.md b/changelog.d/2581.added.md new file mode 100644 index 00000000000..47c64149545 --- /dev/null +++ b/changelog.d/2581.added.md @@ -0,0 +1 @@ +Extended `feature.network.dns` config with an optional local/remote filter, following `feature.network.outgoing` pattern. \ No newline at end of file diff --git a/mirrord-schema.json b/mirrord-schema.json index d8c5d452206..f66dff4ab50 100644 --- a/mirrord-schema.json +++ b/mirrord-schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "LayerFileConfig", - "description": "mirrord allows for a high degree of customization when it comes to which features you want to enable, and how they should function.\n\nAll of the configuration fields have a default value, so a minimal configuration would be no configuration at all.\n\nThe configuration supports templating using the [Tera](https://keats.github.io/tera/docs/) template engine. Currently we don't provide additional values to the context, if you have anything you want us to provide please let us know.\n\nTo use a configuration file in the CLI, use the `-f ` flag. Or if using VSCode Extension or JetBrains plugin, simply create a `.mirrord/mirrord.json` file or use the UI.\n\nTo help you get started, here are examples of a basic configuration file, and a complete configuration file containing all fields.\n\n### Basic `config.json` {#root-basic}\n\n```json { \"target\": \"pod/bear-pod\", \"feature\": { \"env\": true, \"fs\": \"read\", \"network\": true } } ```\n\n### Basic `config.json` with templating {#root-basic-templating}\n\n```json { \"target\": \"{{ get_env(name=\"TARGET\", default=\"pod/fallback\") }}\", \"feature\": { \"env\": true, \"fs\": \"read\", \"network\": true } } ```\n\n### Complete `config.json` {#root-complete}\n\nDon't use this example as a starting point, it's just here to show you all the available options. ```json { \"accept_invalid_certificates\": false, \"skip_processes\": \"ide-debugger\", \"target\": { \"path\": \"pod/bear-pod\", \"namespace\": \"default\" }, \"connect_tcp\": null, \"agent\": { \"log_level\": \"info\", \"json_log\": false, \"labels\": { \"user\": \"meow\" }, \"annotations\": { \"cats.io/inject\": \"enabled\" }, \"namespace\": \"default\", \"image\": \"ghcr.io/metalbear-co/mirrord:latest\", \"image_pull_policy\": \"IfNotPresent\", \"image_pull_secrets\": [ { \"secret-key\": \"secret\" } ], \"ttl\": 30, \"ephemeral\": false, \"communication_timeout\": 30, \"startup_timeout\": 360, \"network_interface\": \"eth0\", \"flush_connections\": true }, \"feature\": { \"env\": { \"include\": \"DATABASE_USER;PUBLIC_ENV\", \"exclude\": \"DATABASE_PASSWORD;SECRET_ENV\", \"override\": { \"DATABASE_CONNECTION\": \"db://localhost:7777/my-db\", \"LOCAL_BEAR\": \"panda\" } }, \"fs\": { \"mode\": \"write\", \"read_write\": \".+\\\\.json\" , \"read_only\": [ \".+\\\\.yaml\", \".+important-file\\\\.txt\" ], \"local\": [ \".+\\\\.js\", \".+\\\\.mjs\" ] }, \"network\": { \"incoming\": { \"mode\": \"steal\", \"http_filter\": { \"header_filter\": \"host: api\\\\..+\" }, \"port_mapping\": [[ 7777, 8888 ]], \"ignore_localhost\": false, \"ignore_ports\": [9999, 10000] }, \"outgoing\": { \"tcp\": true, \"udp\": true, \"filter\": { \"local\": [\"tcp://1.1.1.0/24:1337\", \"1.1.5.0/24\", \"google.com\", \":53\"] }, \"ignore_localhost\": false, \"unix_streams\": \"bear.+\" }, \"dns\": false }, \"copy_target\": { \"scale_down\": false } }, \"operator\": true, \"kubeconfig\": \"~/.kube/config\", \"sip_binaries\": \"bash\", \"telemetry\": true, \"kube_context\": \"my-cluster\" } ```\n\n# Options {#root-options}", + "description": "mirrord allows for a high degree of customization when it comes to which features you want to enable, and how they should function.\n\nAll of the configuration fields have a default value, so a minimal configuration would be no configuration at all.\n\nThe configuration supports templating using the [Tera](https://keats.github.io/tera/docs/) template engine. Currently we don't provide additional values to the context, if you have anything you want us to provide please let us know.\n\nTo use a configuration file in the CLI, use the `-f ` flag. Or if using VSCode Extension or JetBrains plugin, simply create a `.mirrord/mirrord.json` file or use the UI.\n\nTo help you get started, here are examples of a basic configuration file, and a complete configuration file containing all fields.\n\n### Basic `config.json` {#root-basic}\n\n```json { \"target\": \"pod/bear-pod\", \"feature\": { \"env\": true, \"fs\": \"read\", \"network\": true } } ```\n\n### Basic `config.json` with templating {#root-basic-templating}\n\n```json { \"target\": \"{{ get_env(name=\"TARGET\", default=\"pod/fallback\") }}\", \"feature\": { \"env\": true, \"fs\": \"read\", \"network\": true } } ```\n\n### Complete `config.json` {#root-complete}\n\nDon't use this example as a starting point, it's just here to show you all the available options. ```json { \"accept_invalid_certificates\": false, \"skip_processes\": \"ide-debugger\", \"target\": { \"path\": \"pod/bear-pod\", \"namespace\": \"default\" }, \"connect_tcp\": null, \"agent\": { \"log_level\": \"info\", \"json_log\": false, \"labels\": { \"user\": \"meow\" }, \"annotations\": { \"cats.io/inject\": \"enabled\" }, \"namespace\": \"default\", \"image\": \"ghcr.io/metalbear-co/mirrord:latest\", \"image_pull_policy\": \"IfNotPresent\", \"image_pull_secrets\": [ { \"secret-key\": \"secret\" } ], \"ttl\": 30, \"ephemeral\": false, \"communication_timeout\": 30, \"startup_timeout\": 360, \"network_interface\": \"eth0\", \"flush_connections\": true }, \"feature\": { \"env\": { \"include\": \"DATABASE_USER;PUBLIC_ENV\", \"exclude\": \"DATABASE_PASSWORD;SECRET_ENV\", \"override\": { \"DATABASE_CONNECTION\": \"db://localhost:7777/my-db\", \"LOCAL_BEAR\": \"panda\" } }, \"fs\": { \"mode\": \"write\", \"read_write\": \".+\\\\.json\" , \"read_only\": [ \".+\\\\.yaml\", \".+important-file\\\\.txt\" ], \"local\": [ \".+\\\\.js\", \".+\\\\.mjs\" ] }, \"network\": { \"incoming\": { \"mode\": \"steal\", \"http_filter\": { \"header_filter\": \"host: api\\\\..+\" }, \"port_mapping\": [[ 7777, 8888 ]], \"ignore_localhost\": false, \"ignore_ports\": [9999, 10000] }, \"outgoing\": { \"tcp\": true, \"udp\": true, \"filter\": { \"local\": [\"tcp://1.1.1.0/24:1337\", \"1.1.5.0/24\", \"google.com\", \":53\"] }, \"ignore_localhost\": false, \"unix_streams\": \"bear.+\" }, \"dns\": { \"enabled\": true, \"filter\": { \"local\": [\"1.1.1.0/24:1337\", \"1.1.5.0/24\", \"google.com\"] } } }, \"copy_target\": { \"scale_down\": false } }, \"operator\": true, \"kubeconfig\": \"~/.kube/config\", \"sip_binaries\": \"bash\", \"telemetry\": true, \"kube_context\": \"my-cluster\" } ```\n\n# Options {#root-options}", "type": "object", "properties": { "accept_invalid_certificates": { @@ -550,6 +550,62 @@ }, "additionalProperties": false }, + "DnsFileConfig": { + "description": "Resolve DNS via the remote pod.\n\nDefaults to `true`.\n\nMind that: - DNS resolving can be done in multiple ways. Some frameworks use `getaddrinfo`/`gethostbyname` functions, while others communicate directly with the DNS server at port `53` and perform a sort of manual resolution. Just enabling the `dns` feature in mirrord might not be enough. If you see an address resolution error, try enabling the [`fs`](#feature-fs) feature, and setting `read_only: [\"/etc/resolv.conf\"]`. - DNS filter currently works only with frameworks that use `getaddrinfo`/`gethostbyname` functions.", + "type": "object", + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "filter": { + "title": "feature.network.dns.filter {#feature-network-dns-filter}", + "description": "Unstable: the precise syntax of this config is subject to change.", + "anyOf": [ + { + "$ref": "#/definitions/DnsFilterConfig" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "DnsFilterConfig": { + "description": "List of addresses/ports/subnets that should be resolved through either the remote pod or local app, depending how you set this up with either `remote` or `local`.\n\nYou may use this option to specify when DNS resolution is done from the remote pod (which is the default behavior when you enable remote DNS), or from the local app (default when you have remote DNS disabled).\n\nTakes a list of values, such as:\n\n- Only queries for hostname `my-service-in-cluster` will go through the remote pod.\n\n```json { \"remote\": [\"my-service-in-cluster\"] } ```\n\n- Only queries for addresses in subnet `1.1.1.0/24` with service port `1337`` will go through the remote pod.\n\n```json { \"remote\": [\"1.1.1.0/24:1337\"] } ```\n\n- Only queries for hostname `google.com` with service port `1337` or `7331` will go through the remote pod.\n\n```json { \"remote\": [\"google.com:1337\", \"google.com:7331\"] } ```\n\n- Only queries for `localhost` with service port `1337` will go through the local app.\n\n```json { \"local\": [\"localhost:1337\"] } ```\n\n- Only queries with service port `1337` or `7331` will go through the local app.\n\n```json { \"local\": [\":1337\", \":7331\"] } ```\n\nValid values follow this pattern: `[name|address|subnet/mask][:port]`.", + "oneOf": [ + { + "description": "DNS queries matching what is specified here will go through the remote pod, everything else will go through local.", + "type": "object", + "required": [ + "remote" + ], + "properties": { + "remote": { + "$ref": "#/definitions/VecOrSingle_for_String" + } + }, + "additionalProperties": false + }, + { + "description": "DNS queries matching what is specified here will go through the local app, everything else will go through the remote pod.", + "type": "object", + "required": [ + "local" + ], + "properties": { + "local": { + "$ref": "#/definitions/VecOrSingle_for_String" + } + }, + "additionalProperties": false + } + ] + }, "EnvFileConfig": { "description": "Allows the user to set or override the local process' environment variables with the ones from the remote pod.\n\nWhich environment variables to load from the remote pod are controlled by setting either [`include`](#feature-env-include) or [`exclude`](#feature-env-exclude).\n\nSee the environment variables [reference](https://mirrord.dev/docs/reference/env/) for more details.\n\n```json { \"feature\": { \"env\": { \"include\": \"DATABASE_USER;PUBLIC_ENV;MY_APP_*\", \"exclude\": \"DATABASE_PASSWORD;SECRET_ENV\", \"override\": { \"DATABASE_CONNECTION\": \"db://localhost:7777/my-db\", \"LOCAL_BEAR\": \"panda\" } } } } ```", "type": "object", @@ -1067,15 +1123,18 @@ ] }, "NetworkFileConfig": { - "description": "Controls mirrord network operations.\n\nSee the network traffic [reference](https://mirrord.dev/docs/reference/traffic/) for more details.\n\n```json { \"feature\": { \"network\": { \"incoming\": { \"mode\": \"steal\", \"http_filter\": { \"header_filter\": \"host: api\\\\..+\" }, \"port_mapping\": [[ 7777, 8888 ]], \"ignore_localhost\": false, \"ignore_ports\": [9999, 10000] }, \"outgoing\": { \"tcp\": true, \"udp\": true, \"filter\": { \"local\": [\"tcp://1.1.1.0/24:1337\", \"1.1.5.0/24\", \"google.com\", \":53\"] }, \"ignore_localhost\": false, \"unix_streams\": \"bear.+\" }, \"dns\": false } } } ```", + "description": "Controls mirrord network operations.\n\nSee the network traffic [reference](https://mirrord.dev/docs/reference/traffic/) for more details.\n\n```json { \"feature\": { \"network\": { \"incoming\": { \"mode\": \"steal\", \"http_filter\": { \"header_filter\": \"host: api\\\\..+\" }, \"port_mapping\": [[ 7777, 8888 ]], \"ignore_localhost\": false, \"ignore_ports\": [9999, 10000] }, \"outgoing\": { \"tcp\": true, \"udp\": true, \"filter\": { \"local\": [\"tcp://1.1.1.0/24:1337\", \"1.1.5.0/24\", \"google.com\", \":53\"] }, \"ignore_localhost\": false, \"unix_streams\": \"bear.+\" }, \"dns\": { \"enabled\": true, \"filter\": { \"local\": [\"1.1.1.0/24:1337\", \"1.1.5.0/24\", \"google.com\"] } } } } } ```", "type": "object", "properties": { "dns": { "title": "feature.network.dns {#feature-network-dns}", - "description": "Resolve DNS via the remote pod.\n\nDefaults to `true`.\n\n- Caveats: DNS resolving can be done in multiple ways, some frameworks will use `getaddrinfo`, while others will create a connection on port `53` and perform a sort of manual resolution. Just enabling the `dns` feature in mirrord might not be enough. If you see an address resolution error, try enabling the [`fs`](#feature-fs) feature, and setting `read_only: [\"/etc/resolv.conf\"]`.", - "type": [ - "boolean", - "null" + "anyOf": [ + { + "$ref": "#/definitions/ToggleableConfig_for_DnsFileConfig" + }, + { + "type": "null" + } ] }, "incoming": { @@ -1353,6 +1412,16 @@ } ] }, + "ToggleableConfig_for_DnsFileConfig": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/DnsFileConfig" + } + ] + }, "ToggleableConfig_for_EnvFileConfig": { "anyOf": [ { diff --git a/mirrord/cli/src/main.rs b/mirrord/cli/src/main.rs index 31b8f21ee23..144ee9b16be 100644 --- a/mirrord/cli/src/main.rs +++ b/mirrord/cli/src/main.rs @@ -24,7 +24,13 @@ use mirrord_analytics::{ }; use mirrord_config::{ config::{ConfigContext, MirrordConfig}, - feature::{fs::FsModeConfig, network::incoming::IncomingMode}, + feature::{ + fs::FsModeConfig, + network::{ + dns::{DnsConfig, DnsFilterConfig}, + incoming::IncomingMode, + }, + }, target::TargetDisplay, LayerConfig, LayerFileConfig, }; @@ -269,9 +275,28 @@ fn print_config

( }; messages.push(format!("outgoing: forwarding is {}", outgoing_info)); - let dns_info = match config.feature.network.dns { - true => "remotely", - false => "locally", + let dns_info = match &config.feature.network.dns { + DnsConfig { enabled: false, .. } => "locally", + DnsConfig { + enabled: true, + filter: None, + } => "remotely", + DnsConfig { + enabled: true, + filter: Some(DnsFilterConfig::Remote(filters)), + } if filters.is_empty() => "locally", + DnsConfig { + enabled: true, + filter: Some(DnsFilterConfig::Local(filters)), + } if filters.is_empty() => "remotely", + DnsConfig { + enabled: true, + filter: Some(DnsFilterConfig::Remote(..)), + } => "locally with exceptions", + DnsConfig { + enabled: true, + filter: Some(DnsFilterConfig::Local(..)), + } => "remotely with exceptions", }; messages.push(format!("dns: DNS will be resolved {}", dns_info)); diff --git a/mirrord/config/configuration.md b/mirrord/config/configuration.md index 2d818882baa..a54eeb2d93a 100644 --- a/mirrord/config/configuration.md +++ b/mirrord/config/configuration.md @@ -104,7 +104,12 @@ configuration file containing all fields. "ignore_localhost": false, "unix_streams": "bear.+" }, - "dns": false + "dns": { + "enabled": true, + "filter": { + "local": ["1.1.1.0/24:1337", "1.1.5.0/24", "google.com"] + } + } }, "copy_target": { "scale_down": false @@ -382,14 +387,18 @@ IP:PORT to connect to instead of using k8s api, for testing purposes. mirrord Experimental features. This shouldn't be used unless someone from MetalBear/mirrord tells you to. -## _experimental_ readlink {#fexperimental-readlink} +## _experimental_ readlink {#experimental-readlink} Enables the `readlink` hook. -## _experimental_ tcp_ping4_mock {#fexperimental-tcp_ping4_mock} +## _experimental_ tcp_ping4_mock {#experimental-tcp_ping4_mock} +# _experimental_ trust_any_certificate {#experimental-trust_any_certificate} + +Enables trusting any certificate on macOS, useful for + # feature {#root-feature} Controls mirrord features. @@ -686,7 +695,12 @@ for more details. "ignore_localhost": false, "unix_streams": "bear.+" }, - "dns": false + "dns": { + "enabled": true, + "filter": { + "local": ["1.1.1.0/24:1337", "1.1.5.0/24", "google.com"] + } + } } } } @@ -698,11 +712,71 @@ Resolve DNS via the remote pod. Defaults to `true`. -- Caveats: DNS resolving can be done in multiple ways, some frameworks will use -`getaddrinfo`, while others will create a connection on port `53` and perform a sort -of manual resolution. Just enabling the `dns` feature in mirrord might not be enough. -If you see an address resolution error, try enabling the [`fs`](#feature-fs) feature, -and setting `read_only: ["/etc/resolv.conf"]`. +Mind that: +- DNS resolving can be done in multiple ways. Some frameworks use +`getaddrinfo`/`gethostbyname` functions, while others communicate directly with the DNS server +at port `53` and perform a sort of manual resolution. Just enabling the `dns` feature in mirrord +might not be enough. If you see an address resolution error, try enabling the +[`fs`](#feature-fs) feature, and setting `read_only: ["/etc/resolv.conf"]`. +- DNS filter currently works only with frameworks that use `getaddrinfo`/`gethostbyname` + functions. + +#### feature.network.dns.filter {#feature-network-dns-filter} + +Unstable: the precise syntax of this config is subject to change. + +List of addresses/ports/subnets that should be resolved through either the remote pod or local +app, depending how you set this up with either `remote` or `local`. + +You may use this option to specify when DNS resolution is done from the remote pod (which +is the default behavior when you enable remote DNS), or from the local app (default when +you have remote DNS disabled). + +Takes a list of values, such as: + +- Only queries for hostname `my-service-in-cluster` will go through the remote pod. + +```json +{ + "remote": ["my-service-in-cluster"] +} +``` + +- Only queries for addresses in subnet `1.1.1.0/24` with service port `1337`` will go through + the remote pod. + +```json +{ + "remote": ["1.1.1.0/24:1337"] +} +``` + +- Only queries for hostname `google.com` with service port `1337` or `7331` +will go through the remote pod. + +```json +{ + "remote": ["google.com:1337", "google.com:7331"] +} +``` + +- Only queries for `localhost` with service port `1337` will go through the local app. + +```json +{ + "local": ["localhost:1337"] +} +``` + +- Only queries with service port `1337` or `7331` will go through the local app. + +```json +{ + "local": [":1337", ":7331"] +} +``` + +Valid values follow this pattern: `[name|address|subnet/mask][:port]`. ### feature.network.incoming {#feature-network-incoming} diff --git a/mirrord/config/src/config.rs b/mirrord/config/src/config.rs index 88c7f2bfe3b..b038efaf084 100644 --- a/mirrord/config/src/config.rs +++ b/mirrord/config/src/config.rs @@ -17,8 +17,15 @@ pub enum ConfigError { #[error("value for {1:?} not provided in {0:?} (env override {2:?})")] ValueNotProvided(&'static str, &'static str, Option<&'static str>), - #[error("value {0:?} for {1:?} is invalid.")] - InvalidValue(String, &'static str), + #[error("invalid {} value `{}`: {}", .name, .provided, .error)] + InvalidValue { + // Name of parsed env var or field path in the config. + name: &'static str, + // Value provided by the user. + provided: String, + // Error that occurred when processing the value. + error: Box, + }, #[error("mirrord-config: IO operation failed with `{0}`")] Io(#[from] std::io::Error), diff --git a/mirrord/config/src/config/from_env.rs b/mirrord/config/src/config/from_env.rs index 607203c6633..9770456721a 100644 --- a/mirrord/config/src/config/from_env.rs +++ b/mirrord/config/src/config/from_env.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::{marker::PhantomData, str::FromStr}; use super::ConfigContext; @@ -15,13 +16,18 @@ impl FromEnv { impl MirrordConfigSource for FromEnv where T: FromStr, + T::Err: 'static + Send + Sync + fmt::Display + std::error::Error, { type Value = T; fn source_value(self, _context: &mut ConfigContext) -> Option> { std::env::var(self.0).ok().map(|var| { - var.parse() - .map_err(|_| ConfigError::InvalidValue(var.to_string(), self.0)) + var.parse::() + .map_err(|err| ConfigError::InvalidValue { + name: self.0, + provided: var, + error: Box::new(err), + }) }) } } diff --git a/mirrord/config/src/feature/network.rs b/mirrord/config/src/feature/network.rs index 913b907262e..ab3ffc8d3db 100644 --- a/mirrord/config/src/feature/network.rs +++ b/mirrord/config/src/feature/network.rs @@ -1,13 +1,16 @@ +use dns::{DnsConfig, DnsFileConfig}; use mirrord_analytics::CollectAnalytics; use mirrord_config_derive::MirrordConfig; use schemars::JsonSchema; use self::{incoming::*, outgoing::*}; use crate::{ - config::{from_env::FromEnv, source::MirrordConfigSource, ConfigContext, ConfigError}, + config::{ConfigContext, ConfigError}, util::MirrordToggleableConfig, }; +pub mod dns; +pub mod filter; pub mod incoming; pub mod outgoing; @@ -38,7 +41,12 @@ pub mod outgoing; /// "ignore_localhost": false, /// "unix_streams": "bear.+" /// }, -/// "dns": false +/// "dns": { +/// "enabled": true, +/// "filter": { +/// "local": ["1.1.1.0/24:1337", "1.1.5.0/24", "google.com"] +/// } +/// } /// } /// } /// } @@ -56,30 +64,15 @@ pub struct NetworkConfig { pub outgoing: OutgoingConfig, /// ### feature.network.dns {#feature-network-dns} - /// - /// Resolve DNS via the remote pod. - /// - /// Defaults to `true`. - /// - /// - Caveats: DNS resolving can be done in multiple ways, some frameworks will use - /// `getaddrinfo`, while others will create a connection on port `53` and perform a sort - /// of manual resolution. Just enabling the `dns` feature in mirrord might not be enough. - /// If you see an address resolution error, try enabling the [`fs`](#feature-fs) feature, - /// and setting `read_only: ["/etc/resolv.conf"]`. - #[config(env = "MIRRORD_REMOTE_DNS", default = true)] - pub dns: bool, + #[config(toggleable, nested)] + pub dns: DnsConfig, } impl MirrordToggleableConfig for NetworkFileConfig { fn disabled_config(context: &mut ConfigContext) -> Result { - let dns = FromEnv::new("MIRRORD_REMOTE_DNS") - .source_value(context) - .transpose()? - .unwrap_or(false); - Ok(NetworkConfig { incoming: IncomingFileConfig::disabled_config(context)?, - dns, + dns: DnsFileConfig::disabled_config(context)?, outgoing: OutgoingFileConfig::disabled_config(context)?, }) } @@ -89,7 +82,7 @@ impl CollectAnalytics for &NetworkConfig { fn collect_analytics(&self, analytics: &mut mirrord_analytics::Analytics) { analytics.add("incoming", &self.incoming); analytics.add("outgoing", &self.outgoing); - analytics.add("dns", self.dns); + analytics.add("dns", &self.dns); } } @@ -131,7 +124,7 @@ mod tests { .unwrap(); assert_eq!(env.incoming, incoming.1); - assert_eq!(env.dns, dns.1); + assert_eq!(env.dns.enabled, dns.1); }, ); } diff --git a/mirrord/config/src/feature/network/dns.rs b/mirrord/config/src/feature/network/dns.rs new file mode 100644 index 00000000000..22aa217b572 --- /dev/null +++ b/mirrord/config/src/feature/network/dns.rs @@ -0,0 +1,172 @@ +use std::ops::Deref; + +use mirrord_analytics::CollectAnalytics; +use mirrord_config_derive::MirrordConfig; +use schemars::JsonSchema; +use serde::Deserialize; + +use super::filter::AddressFilter; +use crate::{ + config::{from_env::FromEnv, source::MirrordConfigSource, ConfigContext, ConfigError}, + util::{MirrordToggleableConfig, VecOrSingle}, +}; + +/// List of addresses/ports/subnets that should be resolved through either the remote pod or local +/// app, depending how you set this up with either `remote` or `local`. +/// +/// You may use this option to specify when DNS resolution is done from the remote pod (which +/// is the default behavior when you enable remote DNS), or from the local app (default when +/// you have remote DNS disabled). +/// +/// Takes a list of values, such as: +/// +/// - Only queries for hostname `my-service-in-cluster` will go through the remote pod. +/// +/// ```json +/// { +/// "remote": ["my-service-in-cluster"] +/// } +/// ``` +/// +/// - Only queries for addresses in subnet `1.1.1.0/24` with service port `1337`` will go through +/// the remote pod. +/// +/// ```json +/// { +/// "remote": ["1.1.1.0/24:1337"] +/// } +/// ``` +/// +/// - Only queries for hostname `google.com` with service port `1337` or `7331` +/// will go through the remote pod. +/// +/// ```json +/// { +/// "remote": ["google.com:1337", "google.com:7331"] +/// } +/// ``` +/// +/// - Only queries for `localhost` with service port `1337` will go through the local app. +/// +/// ```json +/// { +/// "local": ["localhost:1337"] +/// } +/// ``` +/// +/// - Only queries with service port `1337` or `7331` will go through the local app. +/// +/// ```json +/// { +/// "local": [":1337", ":7331"] +/// } +/// ``` +/// +/// Valid values follow this pattern: `[name|address|subnet/mask][:port]`. +#[derive(Deserialize, PartialEq, Eq, Clone, Debug, JsonSchema)] +#[serde(deny_unknown_fields, rename_all = "lowercase")] +pub enum DnsFilterConfig { + /// DNS queries matching what is specified here will go through the remote pod, everything else + /// will go through local. + Remote(VecOrSingle), + + /// DNS queries matching what is specified here will go through the local app, everything else + /// will go through the remote pod. + Local(VecOrSingle), +} + +/// Resolve DNS via the remote pod. +/// +/// Defaults to `true`. +/// +/// Mind that: +/// - DNS resolving can be done in multiple ways. Some frameworks use +/// `getaddrinfo`/`gethostbyname` functions, while others communicate directly with the DNS server +/// at port `53` and perform a sort of manual resolution. Just enabling the `dns` feature in mirrord +/// might not be enough. If you see an address resolution error, try enabling the +/// [`fs`](#feature-fs) feature, and setting `read_only: ["/etc/resolv.conf"]`. +/// - DNS filter currently works only with frameworks that use `getaddrinfo`/`gethostbyname` +/// functions. +#[derive(MirrordConfig, Default, PartialEq, Eq, Clone, Debug)] +#[config(map_to = "DnsFileConfig", derive = "JsonSchema")] +#[cfg_attr(test, config(derive = "PartialEq, Eq"))] +pub struct DnsConfig { + #[config(env = "MIRRORD_REMOTE_DNS", default = true)] + pub enabled: bool, + + /// #### feature.network.dns.filter {#feature-network-dns-filter} + /// + /// Unstable: the precise syntax of this config is subject to change. + #[config(default, unstable)] + pub filter: Option, +} + +impl DnsConfig { + pub fn verify(&self, context: &mut ConfigContext) -> Result<(), ConfigError> { + let filters = match &self.filter { + Some(..) if !self.enabled => { + context.add_warning( + "Remote DNS resolution is disabled, provided DNS filter will be ignored" + .to_string(), + ); + return Ok(()); + } + None => return Ok(()), + Some(DnsFilterConfig::Local(filters)) if filters.is_empty() => { + context.add_warning( + "Local DNS filter is empty, all DNS resolution will be done remotely" + .to_string(), + ); + return Ok(()); + } + Some(DnsFilterConfig::Remote(filters)) if filters.is_empty() => { + context.add_warning( + "Remote DNS filter is empty, all DNS resolution will be done locally" + .to_string(), + ); + return Ok(()); + } + Some(DnsFilterConfig::Local(filters)) => filters.deref(), + Some(DnsFilterConfig::Remote(filters)) => filters.deref(), + }; + + for filter in filters { + let Err(error) = filter.parse::() else { + continue; + }; + + return Err(ConfigError::InvalidValue { + name: "feature.network.dns.filter", + provided: filter.to_string(), + error: Box::new(error), + }); + } + + Ok(()) + } +} + +impl MirrordToggleableConfig for DnsFileConfig { + fn disabled_config(context: &mut ConfigContext) -> Result { + Ok(DnsConfig { + enabled: FromEnv::new("MIRRORD_REMOTE_DNS") + .source_value(context) + .unwrap_or(Ok(false))?, + ..Default::default() + }) + } +} + +impl CollectAnalytics for &DnsConfig { + fn collect_analytics(&self, analytics: &mut mirrord_analytics::Analytics) { + analytics.add("enabled", self.enabled); + + if let Some(filter) = self.filter.as_ref() { + match filter { + DnsFilterConfig::Remote(value) => analytics.add("dns_filter_remote", value.len()), + + DnsFilterConfig::Local(value) => analytics.add("dns_filter_local", value.len()), + } + } + } +} diff --git a/mirrord/config/src/feature/network/filter.rs b/mirrord/config/src/feature/network/filter.rs new file mode 100644 index 00000000000..c09896076e1 --- /dev/null +++ b/mirrord/config/src/feature/network/filter.rs @@ -0,0 +1,459 @@ +use std::{ + net::{IpAddr, SocketAddr}, + num::ParseIntError, + str::FromStr, +}; + +use nom::{ + branch::alt, + bytes::complete::{tag, take_until}, + character::complete::{alphanumeric1, digit1}, + combinator::opt, + multi::many1, + sequence::{delimited, preceded, terminated}, + IResult, +}; +use thiserror::Error; + +/// The protocols we support in [`ProtocolAndAddressFilter`]. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ProtocolFilter { + #[default] + Any, + Tcp, + Udp, +} + +#[derive(Error, Debug)] +#[error("invalid protocol: {0}")] +pub struct ParseProtocolError(String); + +impl FromStr for ProtocolFilter { + type Err = ParseProtocolError; + + fn from_str(s: &str) -> Result { + let lowercase = s.to_lowercase(); + + match lowercase.as_str() { + "any" => Ok(Self::Any), + "tcp" => Ok(Self::Tcp), + "udp" => Ok(Self::Udp), + invalid => Err(ParseProtocolError(invalid.to_string())), + } + } +} + +/// +/// Parsed addresses can be one of these 3 variants. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AddressFilter { + /// Only port was specified. + Port(u16), + + /// Just a plain old [`SocketAddr`], specified as `a.b.c.d:e`. + /// + /// We treat `0`s here as if it meant **any**, so `0.0.0.0` means we filter any IP, and `:0` + /// means any port. + Socket(SocketAddr), + + /// A named address, as we cannot resolve it here, specified as `name:a`. + /// + /// We can only resolve such names on the mirrord layer `connect` call, as we have to check if + /// the user enabled the DNS feature or not (and thus, resolve it through the remote pod, or + /// the local app). + Name(String, u16), + + /// Just a plain old subnet and a port, specified as `a.b.c.d/e:f`. + Subnet(ipnet::IpNet, u16), +} + +impl AddressFilter { + pub fn port(&self) -> u16 { + match self { + Self::Port(port) => *port, + Self::Name(_, port) => *port, + Self::Socket(socket) => socket.port(), + Self::Subnet(_, port) => *port, + } + } +} + +#[derive(Error, Debug)] +pub enum AddressFilterError { + #[error("parsing with nom failed: {0}")] + Nom(nom::Err>), + + #[error("parsing port number failed: {0}")] + ParsePort(ParseIntError), + + #[error("parsing left trailing value: {0}")] + TrailingValue(String), + + #[error("parsing subnet prefix length failed: {0}")] + ParseSubnetPrefixLength(ParseIntError), + + #[error("parsing subnet base IP address failed")] + ParseSubnetBaseAddress, + + #[error("invalid subnet: {0}")] + SubnetPrefixLen(#[from] ipnet::PrefixLenError), + + #[error("provided empty string")] + Empty, +} + +impl From>> for AddressFilterError { + fn from(value: nom::Err>) -> Self { + Self::Nom(value.to_owned()) + } +} + +impl FromStr for AddressFilter { + type Err = AddressFilterError; + + fn from_str(input: &str) -> Result { + // Perform the basic parsing. + let (rest, address) = address(input)?; + let (rest, subnet) = subnet(rest)?; + let (rest, port) = port(rest)?; + + if !rest.is_empty() { + return Err(Self::Err::TrailingValue(rest.to_string())); + } + + match (address, subnet, port) { + // Only port specified. + (None, None, Some(port)) => { + let port = port.parse::().map_err(AddressFilterError::ParsePort)?; + + Ok(Self::Port(port)) + } + + // Subnet specified. Address must be IP. + (Some(address), Some(subnet), port) => { + let as_ip = address + .parse::() + .map_err(|_| AddressFilterError::ParseSubnetBaseAddress)?; + let prefix_len = subnet + .parse::() + .map_err(AddressFilterError::ParseSubnetPrefixLength)?; + let ip_net = ipnet::IpNet::new(as_ip, prefix_len)?; + + let port = port + .map(u16::from_str) + .transpose() + .map_err(AddressFilterError::ParsePort)? + .unwrap_or(0); + + Ok(Self::Subnet(ip_net, port)) + } + + // Subnet not specified. Address can be a name or an IP. + (Some(address), None, _) => { + let port = port + .map(u16::from_str) + .transpose() + .map_err(AddressFilterError::ParsePort)? + .unwrap_or(0); + + let result = address + .parse::() + .map(|ip| Self::Socket(SocketAddr::new(ip, port))) + .unwrap_or(Self::Name(address, port)); + + Ok(result) + } + + // Subnet specified but address is missing, error. + (None, Some(_), _) => Err(AddressFilterError::ParseSubnetBaseAddress), + + // Nothing is specified, error. + (None, None, None) => Err(AddressFilterError::Empty), + } + } +} + +/// +/// The parsed filter with its [`ProtocolFilter`] and [`AddressFilter`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ProtocolAndAddressFilter { + /// Valid protocol types. + pub protocol: ProtocolFilter, + + /// Address|name|subnet we're going to filter. + pub address: AddressFilter, +} + +#[derive(Error, Debug)] +pub enum ProtocolAndAddressFilterError { + #[error(transparent)] + Address(#[from] AddressFilterError), + #[error(transparent)] + Protocol(#[from] ParseProtocolError), +} + +impl From>> for ProtocolAndAddressFilterError { + fn from(value: nom::Err>) -> Self { + Self::Address(value.into()) + } +} + +impl FromStr for ProtocolAndAddressFilter { + type Err = ProtocolAndAddressFilterError; + + fn from_str(input: &str) -> Result { + // Perform the basic parsing. + let (rest, protocol) = protocol(input)?; + let protocol = protocol.parse()?; + + let address = rest.parse().or_else(|error| match error { + AddressFilterError::Empty => Ok(AddressFilter::Port(0)), + other => Err(other), + })?; + + Ok(Self { protocol, address }) + } +} + +/// +/// +/// Parses `tcp://`, extracting the `tcp` part, and discarding the `://`. +fn protocol(input: &str) -> IResult<&str, &str> { + let (rest, protocol) = opt(terminated(take_until("://"), tag("://")))(input)?; + let protocol = protocol.unwrap_or("any"); + + Ok((rest, protocol)) +} + +/// +/// +/// We try to parse 3 different kinds of values here: +/// +/// 1. `name.with.dots`; +/// 2. `1.2.3.4.5.6`; +/// 3. `[dad:1337:fa57::0]` +/// +/// Where 1 and 2 are handled by `dotted_address`. +/// +/// The parser is not interested in only eating correct values here for hostnames, ip addresses, +/// etc., it just tries to get a good enough string that could be parsed by +/// `SocketAddr::parse`, or `IpNet::parse`. +fn address(input: &str) -> IResult<&str, Option> { + let ipv6 = many1(alt((alphanumeric1, tag(":")))); + let ipv6_host = delimited(tag("["), ipv6, tag("]")); + + let host_char = alt((alphanumeric1, tag("-"), tag("_"), tag("."))); + let dotted_address = many1(host_char); + + let (rest, address) = opt(alt((dotted_address, ipv6_host)))(input)?; + + let address = address.map(|addr| addr.concat()); + + Ok((rest, address)) +} + +/// +/// +/// Parses `/24`, extracting the `24` part, and discarding the `/`. +fn subnet(input: &str) -> IResult<&str, Option<&str>> { + let subnet_parser = preceded(tag("/"), digit1); + let (rest, subnet) = opt(subnet_parser)(input)?; + + Ok((rest, subnet)) +} + +/// +/// +/// Parses `:1337`, extracting the `1337` part, and discarding the `:`. +/// +/// Returns [`None`] if it doesn't parse anything. +fn port(input: &str) -> IResult<&str, Option<&str>> { + let port_parser = preceded(tag(":"), digit1); + let (rest, port) = opt(port_parser)(input)?; + + Ok((rest, port)) +} + +#[cfg(test)] +mod tests { + use ipnet::IpNet; + use rstest::{fixture, rstest}; + + use super::*; + + // Valid configs. + #[fixture] + fn full() -> &'static str { + "tcp://1.2.3.0/24:7777" + } + + #[fixture] + fn full_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Tcp, + address: AddressFilter::Subnet(IpNet::from_str("1.2.3.0/24").unwrap(), 7777), + } + } + + #[fixture] + fn ipv6() -> &'static str { + "tcp://[2800:3f0:4001:81e::2004]:7777" + } + + #[fixture] + fn ipv6_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Tcp, + address: AddressFilter::Socket( + SocketAddr::from_str("[2800:3f0:4001:81e::2004]:7777").unwrap(), + ), + } + } + + #[fixture] + fn protocol_only() -> &'static str { + "tcp://" + } + + #[fixture] + fn protocol_only_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Tcp, + address: AddressFilter::Port(0), + } + } + + #[fixture] + fn name() -> &'static str { + "tcp://google.com:7777" + } + + #[fixture] + fn name_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Tcp, + address: AddressFilter::Name("google.com".to_string(), 7777), + } + } + + #[fixture] + fn name_only() -> &'static str { + "rust-lang.org" + } + + #[fixture] + fn name_only_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Any, + address: AddressFilter::Name("rust-lang.org".to_string(), 0), + } + } + + #[fixture] + fn localhost() -> &'static str { + "localhost" + } + + #[fixture] + fn localhost_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Any, + address: AddressFilter::Name("localhost".to_string(), 0), + } + } + + #[fixture] + fn subnet_port() -> &'static str { + "1.2.3.0/24:7777" + } + + #[fixture] + fn subnet_port_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Any, + address: AddressFilter::Subnet(IpNet::from_str("1.2.3.0/24").unwrap(), 7777), + } + } + + #[fixture] + fn subnet_only() -> &'static str { + "1.2.3.0/24" + } + + #[fixture] + fn subnet_only_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Any, + address: AddressFilter::Subnet(IpNet::from_str("1.2.3.0/24").unwrap(), 0), + } + } + + #[fixture] + fn protocol_port() -> &'static str { + "udp://:7777" + } + + #[fixture] + fn protocol_port_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Udp, + address: AddressFilter::Port(7777), + } + } + + #[fixture] + fn port_only() -> &'static str { + ":7777" + } + + #[fixture] + fn port_only_converted() -> ProtocolAndAddressFilter { + ProtocolAndAddressFilter { + protocol: ProtocolFilter::Any, + address: AddressFilter::Port(7777), + } + } + + // Bad configs. + #[fixture] + fn name_with_subnet() -> &'static str { + "tcp://google.com/24:7777" + } + + #[fixture] + fn port_protocol() -> &'static str { + ":7777udp://" + } + + #[fixture] + fn fake_protocol() -> &'static str { + "meow://" + } + + #[rstest] + #[case(full(), full_converted())] + #[case(ipv6(), ipv6_converted())] + #[case(protocol_only(), protocol_only_converted())] + #[case(name(), name_converted())] + #[case(name_only(), name_only_converted())] + #[case(localhost(), localhost_converted())] + #[case(subnet_port(), subnet_port_converted())] + #[case(subnet_only(), subnet_only_converted())] + #[case(protocol_port(), protocol_port_converted())] + #[case(port_only(), port_only_converted())] + fn valid_filters(#[case] input: &'static str, #[case] converted: ProtocolAndAddressFilter) { + assert_eq!( + ProtocolAndAddressFilter::from_str(input).unwrap(), + converted + ); + } + + #[rstest] + #[case(name_with_subnet())] + #[case(port_protocol())] + #[case(fake_protocol())] + #[should_panic] + fn invalid_filters(#[case] input: &'static str) { + ProtocolAndAddressFilter::from_str(input).unwrap(); + } +} diff --git a/mirrord/config/src/feature/network/outgoing.rs b/mirrord/config/src/feature/network/outgoing.rs index 9892cb07e56..1a5790460b9 100644 --- a/mirrord/config/src/feature/network/outgoing.rs +++ b/mirrord/config/src/feature/network/outgoing.rs @@ -1,12 +1,11 @@ -use core::str::FromStr; -use std::net::SocketAddr; +use std::ops::Deref; use mirrord_analytics::CollectAnalytics; use mirrord_config_derive::MirrordConfig; use schemars::JsonSchema; use serde::Deserialize; -use thiserror::Error; +use super::filter::ProtocolAndAddressFilter; use crate::{ config::{from_env::FromEnv, source::MirrordConfigSource, ConfigContext, ConfigError}, util::{MirrordToggleableConfig, VecOrSingle}, @@ -153,213 +152,6 @@ impl MirrordToggleableConfig for OutgoingFileConfig { } } -/// -/// Errors related to parsing an [`OutgoingFilter`]. -#[derive(Debug, Error)] -pub enum OutgoingFilterError { - #[error("Nom: failed parsing with {0}!")] - Nom2(nom::Err>), - - #[error("Subnet: Failed parsing with {0}!")] - Subnet(#[from] ipnet::AddrParseError), - - #[error("ParseInt: Failed converting string into `u16` with {0}!")] - ParseInt(#[from] std::num::ParseIntError), - - #[error("Failed parsing protocol value of {0}!")] - InvalidProtocol(String), - - #[error("Found trailing value after parsing {0}!")] - TrailingValue(String), -} - -impl From>> for OutgoingFilterError { - fn from(value: nom::Err>) -> Self { - Self::Nom2(value.to_owned()) - } -} - -/// -/// The protocols we support on [`OutgoingFilter`]. -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ProtocolFilter { - #[default] - Any, - Tcp, - Udp, -} - -impl FromStr for ProtocolFilter { - type Err = OutgoingFilterError; - - fn from_str(s: &str) -> Result { - let lowercase = s.to_lowercase(); - - match lowercase.as_str() { - "any" => Ok(Self::Any), - "tcp" => Ok(Self::Tcp), - "udp" => Ok(Self::Udp), - invalid => Err(OutgoingFilterError::InvalidProtocol(invalid.to_string())), - } - } -} - -/// -/// Parsed addresses can be one of these 3 variants. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum AddressFilter { - /// Just a plain old [`SocketAddr`], specified as `a.b.c.d:e`. - /// - /// We treat `0`s here as if it meant **any**, so `0.0.0.0` means we filter any IP, and `:0` - /// means any port. - Socket(SocketAddr), - - /// A named address, as we cannot resolve it here, specified as `name:a`. - /// - /// We can only resolve such names on the mirrord layer `connect` call, as we have to check if - /// the user enabled the DNS feature or not (and thus, resolve it through the remote pod, or - /// the local app). - Name((String, u16)), - - /// Just a plain old subnet and a port, specified as `a.b.c.d/e:f`. - Subnet((ipnet::IpNet, u16)), -} - -/// -/// The parsed filter with its [`ProtocolFilter`] and [`AddressFilter`]. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct OutgoingFilter { - /// Valid protocol types. - pub protocol: ProtocolFilter, - - /// Address|name|subnet we're going to filter. - pub address: AddressFilter, -} - -/// -/// It's dangerous to go alone! -/// Take [this](https://github.com/rust-bakery/nom/blob/main/doc/choosing_a_combinator.md). -/// -/// [`nom`] works better with `u8` slices, instead of `str`s. -mod parser { - use nom::{ - branch::alt, - bytes::complete::{tag, take_until}, - character::complete::{alphanumeric1, digit1}, - combinator::opt, - multi::many1, - sequence::{delimited, preceded, terminated}, - IResult, - }; - - /// - /// - /// Parses `tcp://`, extracting the `tcp` part, and discarding the `://`. - pub(super) fn protocol(input: &str) -> IResult<&str, &str> { - let (rest, protocol) = opt(terminated(take_until("://"), tag("://")))(input)?; - let protocol = protocol.unwrap_or("any"); - - Ok((rest, protocol)) - } - - /// - /// - /// We try to parse 3 different kinds of values here: - /// - /// 1. `name.with.dots`; - /// 2. `1.2.3.4.5.6`; - /// 3. `[dad:1337:fa57::0]` - /// - /// Where 1 and 2 are handled by `dotted_address`. - /// - /// The parser is not interested in only eating correct values here for hostnames, ip addresses, - /// etc., it just tries to get a good enough string that could be parsed by - /// `SocketAddr::parse`, or `IpNet::parse`. - /// - /// Returns `0.0.0.0` if it doesn't parse anything. - pub(super) fn address(input: &str) -> IResult<&str, String> { - let ipv6 = many1(alt((alphanumeric1, tag(":")))); - let ipv6_host = delimited(tag("["), ipv6, tag("]")); - - let host_char = alt((alphanumeric1, tag("-"), tag("_"), tag("."))); - let dotted_address = many1(host_char); - - let (rest, address) = opt(alt((dotted_address, ipv6_host)))(input)?; - - let address = address - .map(|addr| addr.concat()) - .unwrap_or(String::from("0.0.0.0")); - - Ok((rest, address)) - } - - /// - /// - /// Parses `/24`, extracting the `24` part, and discarding the `/`. - pub(super) fn subnet(input: &str) -> IResult<&str, Option<&str>> { - let subnet_parser = preceded(tag("/"), digit1); - let (rest, subnet) = opt(subnet_parser)(input)?; - - Ok((rest, subnet)) - } - - /// - /// - /// Parses `:1337`, extracting the `1337` part, and discarding the `:`. - /// - /// Returns `0` if it doesn't parse anything. - pub(super) fn port(input: &str) -> IResult<&str, &str> { - let port_parser = preceded(tag(":"), digit1); - let (rest, port) = opt(port_parser)(input)?; - - let port = port.unwrap_or("0"); - - Ok((rest, port)) - } -} - -impl FromStr for OutgoingFilter { - type Err = OutgoingFilterError; - - #[tracing::instrument(level = "trace", ret)] - fn from_str(input: &str) -> Result { - use crate::feature::network::outgoing::parser::*; - - // Perform the basic parsing. - let (rest, protocol) = protocol(input)?; - let (rest, address) = address(rest)?; - let (rest, subnet) = subnet(rest)?; - let (rest, port) = port(rest)?; - - // Stringify and convert to proper types. - let protocol = protocol.parse()?; - let port = port.parse::()?; - - let address = subnet - .map(|subnet| format!("{address}/{subnet}").parse::()) - .transpose()? - .map_or_else( - // Try to parse as an IPv4 address. - || { - format!("{address}:{port}") - .parse::() - // Try again as IPv6. - .or_else(|_| format!("[{address}]:{port}").parse()) - .map(AddressFilter::Socket) - // Neither IPv4 nor IPv6, it's probably a name. - .unwrap_or(AddressFilter::Name((address.to_string(), port))) - }, - |subnet| AddressFilter::Subnet((subnet, port)), - ); - - if rest.is_empty() { - Ok(Self { protocol, address }) - } else { - Err(OutgoingFilterError::TrailingValue(rest.to_string())) - } - } -} - impl CollectAnalytics for &OutgoingConfig { fn collect_analytics(&self, analytics: &mut mirrord_analytics::Analytics) { analytics.add("tcp", self.tcp); @@ -386,14 +178,37 @@ impl CollectAnalytics for &OutgoingConfig { } } +impl OutgoingConfig { + pub fn verify(&self, _: &mut ConfigContext) -> Result<(), ConfigError> { + let filters = match self.filter.as_ref() { + None => return Ok(()), + Some(OutgoingFilterConfig::Local(filters)) => filters.deref(), + Some(OutgoingFilterConfig::Remote(filters)) => filters.deref(), + }; + + for filter in filters { + let Err(error) = filter.parse::() else { + continue; + }; + + return Err(ConfigError::InvalidValue { + name: "feature.network.outgoing.filter", + provided: filter.to_string(), + error: Box::new(error), + }); + } + + Ok(()) + } +} + #[cfg(test)] mod tests { - use ipnet::IpNet; - use rstest::{fixture, rstest}; + use rstest::rstest; - use super::*; use crate::{ config::{ConfigContext, MirrordConfig}, + feature::network::OutgoingFileConfig, util::{testing::with_env_vars, ToggleableConfig}, }; @@ -452,177 +267,4 @@ mod tests { }, ); } - - // Valid configs. - #[fixture] - fn full() -> &'static str { - "tcp://1.2.3.0/24:7777" - } - - #[fixture] - fn full_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Tcp, - address: AddressFilter::Subnet((IpNet::from_str("1.2.3.0/24").unwrap(), 7777)), - } - } - - #[fixture] - fn ipv6() -> &'static str { - "tcp://[2800:3f0:4001:81e::2004]:7777" - } - - #[fixture] - fn ipv6_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Tcp, - address: AddressFilter::Socket( - SocketAddr::from_str("[2800:3f0:4001:81e::2004]:7777").unwrap(), - ), - } - } - - #[fixture] - fn protocol_only() -> &'static str { - "tcp://" - } - - #[fixture] - fn protocol_only_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Tcp, - address: AddressFilter::Socket(SocketAddr::from_str("0.0.0.0:0").unwrap()), - } - } - - #[fixture] - fn name() -> &'static str { - "tcp://google.com:7777" - } - - #[fixture] - fn name_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Tcp, - address: AddressFilter::Name(("google.com".to_string(), 7777)), - } - } - - #[fixture] - fn name_only() -> &'static str { - "rust-lang.org" - } - - #[fixture] - fn name_only_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Any, - address: AddressFilter::Name(("rust-lang.org".to_string(), 0)), - } - } - - #[fixture] - fn localhost() -> &'static str { - "localhost" - } - - #[fixture] - fn localhost_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Any, - address: AddressFilter::Name(("localhost".to_string(), 0)), - } - } - - #[fixture] - fn subnet_port() -> &'static str { - "1.2.3.0/24:7777" - } - - #[fixture] - fn subnet_port_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Any, - address: AddressFilter::Subnet((IpNet::from_str("1.2.3.0/24").unwrap(), 7777)), - } - } - - #[fixture] - fn subnet_only() -> &'static str { - "1.2.3.0/24" - } - - #[fixture] - fn subnet_only_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Any, - address: AddressFilter::Subnet((IpNet::from_str("1.2.3.0/24").unwrap(), 0)), - } - } - - #[fixture] - fn protocol_port() -> &'static str { - "udp://:7777" - } - - #[fixture] - fn protocol_port_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Udp, - address: AddressFilter::Socket(SocketAddr::from_str("0.0.0.0:7777").unwrap()), - } - } - - #[fixture] - fn port_only() -> &'static str { - ":7777" - } - - #[fixture] - fn port_only_converted() -> OutgoingFilter { - OutgoingFilter { - protocol: ProtocolFilter::Any, - address: AddressFilter::Socket(SocketAddr::from_str("0.0.0.0:7777").unwrap()), - } - } - - // Bad configs. - #[fixture] - fn name_with_subnet() -> &'static str { - "tcp://google.com/24:7777" - } - - #[fixture] - fn port_protocol() -> &'static str { - ":7777udp://" - } - - #[fixture] - fn fake_protocol() -> &'static str { - "meow://" - } - - #[rstest] - #[case(full(), full_converted())] - #[case(ipv6(), ipv6_converted())] - #[case(protocol_only(), protocol_only_converted())] - #[case(name(), name_converted())] - #[case(name_only(), name_only_converted())] - #[case(localhost(), localhost_converted())] - #[case(subnet_port(), subnet_port_converted())] - #[case(subnet_only(), subnet_only_converted())] - #[case(protocol_port(), protocol_port_converted())] - #[case(port_only(), port_only_converted())] - fn valid_filters(#[case] input: &'static str, #[case] converted: OutgoingFilter) { - assert_eq!(OutgoingFilter::from_str(input).unwrap(), converted); - } - - #[rstest] - #[case(name_with_subnet())] - #[case(port_protocol())] - #[case(fake_protocol())] - #[should_panic] - fn invalid_filters(#[case] input: &'static str) { - OutgoingFilter::from_str(input).unwrap(); - } } diff --git a/mirrord/config/src/lib.rs b/mirrord/config/src/lib.rs index aac080cc92e..cd1f7dc490f 100644 --- a/mirrord/config/src/lib.rs +++ b/mirrord/config/src/lib.rs @@ -138,7 +138,12 @@ use crate::{ /// "ignore_localhost": false, /// "unix_streams": "bear.+" /// }, -/// "dns": false +/// "dns": { +/// "enabled": true, +/// "filter": { +/// "local": ["1.1.1.0/24:1337", "1.1.5.0/24", "google.com"] +/// } +/// } /// }, /// "copy_target": { /// "scale_down": false @@ -338,7 +343,7 @@ impl LayerConfig { if matches!( self.feature.network.outgoing.filter, Some(OutgoingFilterConfig::Remote(_)) - ) && !self.feature.network.dns + ) && !self.feature.network.dns.enabled { context.add_warning( "The mirrord outgoing traffic filter includes host names to be connected remotely, \ @@ -484,6 +489,9 @@ impl LayerConfig { )); } + self.feature.network.dns.verify(context)?; + self.feature.network.outgoing.verify(context)?; + Ok(()) } } @@ -745,7 +753,7 @@ mod tests { env: ToggleableConfig::Enabled(true).into(), fs: ToggleableConfig::Config(FsUserConfig::Simple(FsModeConfig::Write)).into(), network: Some(ToggleableConfig::Config(NetworkFileConfig { - dns: Some(false), + dns: Some(ToggleableConfig::Enabled(false)), incoming: Some(ToggleableConfig::Config(IncomingFileConfig::Advanced( Box::new(IncomingAdvancedFileConfig { mode: Some(IncomingMode::Mirror), diff --git a/mirrord/layer/src/detour.rs b/mirrord/layer/src/detour.rs index e2f3f8c3165..4fbc723ad14 100644 --- a/mirrord/layer/src/detour.rs +++ b/mirrord/layer/src/detour.rs @@ -204,6 +204,9 @@ pub(crate) enum Bypass { /// Hostname should be resolved locally. /// Currently this is the case only when the layer operates in the `trace only` mode. LocalHostname, + + /// DNS query should be done locally. + LocalDns, } /// [`ControlFlow`](std::ops::ControlFlow)-like enum to be used by hooks. diff --git a/mirrord/layer/src/lib.rs b/mirrord/layer/src/lib.rs index 07135a0b99f..51d053a20a7 100644 --- a/mirrord/layer/src/lib.rs +++ b/mirrord/layer/src/lib.rs @@ -325,7 +325,7 @@ fn layer_start(mut config: LayerConfig) { // Disable all features that require the agent if trace_only { config.feature.fs.mode = FsModeConfig::Local; - config.feature.network.dns = false; + config.feature.network.dns.enabled = false; config.feature.network.incoming.mode = IncomingMode::Off; config.feature.network.outgoing.tcp = false; config.feature.network.outgoing.udp = false; diff --git a/mirrord/layer/src/setup.rs b/mirrord/layer/src/setup.rs index ee16ed12fd3..f34638ffee3 100644 --- a/mirrord/layer/src/setup.rs +++ b/mirrord/layer/src/setup.rs @@ -17,7 +17,11 @@ use mirrord_protocol::{ }; use regex::RegexSet; -use crate::{debugger_ports::DebuggerPorts, file::filter::FileFilter, socket::OutgoingSelector}; +use crate::{ + debugger_ports::DebuggerPorts, + file::filter::FileFilter, + socket::{dns_selector::DnsSelector, OutgoingSelector}, +}; /// Complete layer setup. /// Contains [`LayerConfig`] and derived from it structs, which are used in multiple places across @@ -29,6 +33,7 @@ pub struct LayerSetup { debugger_ports: DebuggerPorts, remote_unix_streams: RegexSet, outgoing_selector: OutgoingSelector, + dns_selector: DnsSelector, proxy_address: SocketAddr, incoming_mode: IncomingMode, local_hostname: bool, @@ -52,8 +57,9 @@ impl LayerSetup { .expect("invalid unix stream regex set") .unwrap_or_default(); - let outgoing_selector: OutgoingSelector = - OutgoingSelector::new(&config.feature.network.outgoing); + let outgoing_selector = OutgoingSelector::new(&config.feature.network.outgoing); + + let dns_selector = DnsSelector::from(&config.feature.network.dns); let proxy_address = config .connect_tcp @@ -74,6 +80,7 @@ impl LayerSetup { debugger_ports, remote_unix_streams, outgoing_selector, + dns_selector, proxy_address, incoming_mode, local_hostname, @@ -107,7 +114,7 @@ impl LayerSetup { } pub fn remote_dns_enabled(&self) -> bool { - self.config.feature.network.dns + self.config.feature.network.dns.enabled } pub fn targetless(&self) -> bool { @@ -136,6 +143,10 @@ impl LayerSetup { &self.outgoing_selector } + pub fn dns_selector(&self) -> &DnsSelector { + &self.dns_selector + } + pub fn remote_unix_streams(&self) -> &RegexSet { &self.remote_unix_streams } diff --git a/mirrord/layer/src/socket.rs b/mirrord/layer/src/socket.rs index aa7be3630e4..a4a4e9af2ec 100644 --- a/mirrord/layer/src/socket.rs +++ b/mirrord/layer/src/socket.rs @@ -10,8 +10,9 @@ use std::{ use dashmap::DashMap; use hashbrown::hash_set::HashSet; use libc::{c_int, sockaddr, socklen_t}; -use mirrord_config::feature::network::outgoing::{ - AddressFilter, OutgoingConfig, OutgoingFilter, OutgoingFilterConfig, ProtocolFilter, +use mirrord_config::feature::network::{ + filter::{AddressFilter, ProtocolAndAddressFilter, ProtocolFilter}, + outgoing::{OutgoingConfig, OutgoingFilterConfig}, }; use mirrord_intproxy_protocol::{NetProtocol, PortUnsubscribe}; use mirrord_protocol::{ @@ -27,6 +28,7 @@ use crate::{ socket::ops::{remote_getaddrinfo, REMOTE_DNS_REVERSE_MAPPING}, }; +pub(crate) mod dns_selector; pub(super) mod hooks; pub(crate) mod ops; @@ -188,16 +190,16 @@ enum ConnectionThrough { Remote(SocketAddr), } -/// Holds the [`OutgoingFilter`]s set up by the user. +/// Holds the [`ProtocolAndAddressFilter`]s set up by the user in the [`OutgoingFilterConfig`]. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub(crate) enum OutgoingSelector { #[default] Unfiltered, /// If the address from `connect` matches this, then we send the connection through the /// remote pod. - Remote(HashSet), + Remote(HashSet), /// If the address from `connect` matches this, then we send the connection from the local app. - Local(HashSet), + Local(HashSet), } impl OutgoingSelector { @@ -205,12 +207,14 @@ impl OutgoingSelector { filters: I, tcp_enabled: bool, udp_enabled: bool, - ) -> HashSet { + ) -> HashSet { filters - .map(|filter| OutgoingFilter::from_str(filter).expect("invalid outgoing filter")) + .map(|filter| { + ProtocolAndAddressFilter::from_str(filter).expect("invalid outgoing filter") + }) .collect::>() .into_iter() - .filter(|OutgoingFilter { protocol, .. }| match protocol { + .filter(|ProtocolAndAddressFilter { protocol, .. }| match protocol { ProtocolFilter::Any => tcp_enabled || udp_enabled, ProtocolFilter::Tcp => tcp_enabled, ProtocolFilter::Udp => udp_enabled, @@ -339,14 +343,14 @@ impl OutgoingSelector { } } -/// [`OutgoingFilter`] extension. -trait OutgoingFilterExt { +/// [`ProtocolAndAddressFilter`] extension. +trait ProtocolAndAddressFilterExt { /// Matches the outgoing connection request (given as [[`SocketAddr`], [`NetProtocol`]] pair) /// against this filter. /// /// # Note on DNS resolution /// - /// This method may require a DNS resolution (when [`OutgoingFilter::address`] is + /// This method may require a DNS resolution (when [`ProtocolAndAddressFilter::address`] is /// [`AddressFilter::Name`]). If remote DNS is disabled or `force_local_dns` /// flag is used, the method uses local resolution [`ToSocketAddrs`]. Otherwise, it uses /// remote resolution [`remote_getaddrinfo`]. @@ -358,7 +362,7 @@ trait OutgoingFilterExt { ) -> HookResult; } -impl OutgoingFilterExt for OutgoingFilter { +impl ProtocolAndAddressFilterExt for ProtocolAndAddressFilter { fn matches( &self, address: SocketAddr, @@ -371,17 +375,13 @@ impl OutgoingFilterExt for OutgoingFilter { return Ok(false); }; - let port = match &self.address { - AddressFilter::Name((_, port)) => *port, - AddressFilter::Socket(addr) => addr.port(), - AddressFilter::Subnet((_, port)) => *port, - }; + let port = self.address.port(); if port != 0 && port != address.port() { return Ok(false); } match &self.address { - AddressFilter::Name((name, port)) => { + AddressFilter::Name(name, port) => { let resolved_ips = if crate::setup().remote_dns_enabled() && !force_local_dns { match remote_getaddrinfo(name.to_string()) { Ok(res) => res.into_iter().map(|(_, ip)| ip).collect(), @@ -420,13 +420,11 @@ impl OutgoingFilterExt for OutgoingFilter { Ok(resolved_ips.into_iter().any(|ip| ip == address.ip())) } - AddressFilter::Socket(addr) - if addr.ip().is_unspecified() || addr.ip() == address.ip() => - { - Ok(true) + AddressFilter::Socket(addr) => { + Ok(addr.ip().is_unspecified() || addr.ip() == address.ip()) } - AddressFilter::Subnet((net, _)) if net.contains(&address.ip()) => Ok(true), - _ => Ok(false), + AddressFilter::Subnet(net, _) => Ok(net.contains(&address.ip())), + AddressFilter::Port(..) => Ok(true), } } } diff --git a/mirrord/layer/src/socket/dns_selector.rs b/mirrord/layer/src/socket/dns_selector.rs new file mode 100644 index 00000000000..e83dbba2d9f --- /dev/null +++ b/mirrord/layer/src/socket/dns_selector.rs @@ -0,0 +1,86 @@ +use std::{net::IpAddr, ops::Deref}; + +use mirrord_config::feature::network::{ + dns::{DnsConfig, DnsFilterConfig}, + filter::AddressFilter, +}; +use tracing::Level; + +use crate::detour::{Bypass, Detour}; + +/// Generated from [`DnsConfig`] provided in the [`LayerConfig`](mirrord_config::LayerConfig). +/// Decides whether DNS queries are done locally or remotely. +#[derive(Debug)] +pub struct DnsSelector { + /// Filters provided in the config. + filters: Vec, + /// Whether a query matching one of [`Self::filters`] should be done locally. + filter_is_local: bool, +} + +impl DnsSelector { + /// Bypasses queries that should be done locally. + #[tracing::instrument(level = Level::DEBUG, ret)] + pub fn check_query(&self, node: &str, port: u16) -> Detour<()> { + let matched = self + .filters + .iter() + .filter(|filter| { + let filter_port = filter.port(); + filter_port == 0 || filter_port == port + }) + .any(|filter| match filter { + AddressFilter::Port(..) => true, + AddressFilter::Name(filter_name, _) => filter_name == node, + AddressFilter::Socket(filter_socket) => { + filter_socket.ip().is_unspecified() + || Some(filter_socket.ip()) == node.parse().ok() + } + AddressFilter::Subnet(filter_subnet, _) => { + let Ok(ip) = node.parse::() else { + return false; + }; + + filter_subnet.contains(&ip) + } + }); + + if matched == self.filter_is_local { + Detour::Bypass(Bypass::LocalDns) + } else { + Detour::Success(()) + } + } +} + +impl From<&DnsConfig> for DnsSelector { + fn from(value: &DnsConfig) -> Self { + if !value.enabled { + return Self { + filters: Default::default(), + filter_is_local: false, + }; + } + + let (filters, filter_is_local) = match &value.filter { + Some(DnsFilterConfig::Local(filters)) => (Some(filters.deref()), true), + Some(DnsFilterConfig::Remote(filters)) => (Some(filters.deref()), false), + None => (None, true), + }; + + let filters = filters + .into_iter() + .flatten() + .map(|filter| { + filter + .parse::() + .expect("bad address filter, should be verified in the CLI") + }) + .collect(); + + Self { + filters, + filter_is_local, + } + } +} diff --git a/mirrord/layer/src/socket/ops.rs b/mirrord/layer/src/socket/ops.rs index 4a4449429b2..f4933a61843 100644 --- a/mirrord/layer/src/socket/ops.rs +++ b/mirrord/layer/src/socket/ops.rs @@ -873,6 +873,7 @@ pub(super) fn getaddrinfo( })? .into(); + // Convert `service` to port let service = rawish_service .map(CStr::to_str) .transpose() @@ -884,7 +885,10 @@ pub(super) fn getaddrinfo( Bypass::CStrConversion })? - .map(String::from); + .and_then(|service| service.parse::().ok()) + .unwrap_or(0); + + crate::setup().dns_selector().check_query(&node, service)?; let raw_hints = raw_hints .cloned() @@ -897,9 +901,6 @@ pub(super) fn getaddrinfo( .. } = raw_hints; - // Convert `service` into a port. - let service = service.map_or(0, |s| s.parse().unwrap_or_default()); - // Some apps (gRPC on Python) use `::` to listen on all interfaces, and usually that just means // resolve on unspecified. So we just return that in IpV4 because we don't support ipv6. let resolved_addr = if node == "::" { @@ -1003,6 +1004,8 @@ pub(super) fn gethostbyname(raw_name: Option<&CStr>) -> Detour<*mut hostent> { })? .into(); + crate::setup().dns_selector().check_query(&name, 0)?; + let hosts_and_ips = remote_getaddrinfo(name.clone())?; // We could `unwrap` here, as this would have failed on the previous conversion.