mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(connlib): allow glob patterns for matching domain names (#5901)
Currently, `connlib` can only handle "simple" DNS wildcards where `*` matches any number of subdomains, including zero and `?` matches a single subdomain. With this PR, we expand `connlib'`s capabilities to allow for a much more complex matching of domains that more closely resembles glob patterns: - `**` matches any number of subdomains. This supersedes the previous `*` operator. - `*` matches a single subdomain. This supersedes the previous `?` operator. - `?` matches a single character. This wasn't possible before. - Additionally, any of these can be combined. Previously, only `*` or `?` was allowed and they were only accepted at the front of the domain name pattern. Resolves: #5056. --------- Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
This commit is contained in:
10
.github/workflows/_build_artifacts.yml
vendored
10
.github/workflows/_build_artifacts.yml
vendored
@@ -156,9 +156,9 @@ jobs:
|
||||
artifact: firezone-client-headless-linux
|
||||
image_name: client
|
||||
# mark:next-headless-version
|
||||
release_name: headless-client-1.1.8
|
||||
release_name: headless-client-1.2.0
|
||||
# mark:next-headless-version
|
||||
version: 1.1.8
|
||||
version: 1.2.0
|
||||
- package: firezone-relay
|
||||
artifact: firezone-relay
|
||||
image_name: relay
|
||||
@@ -166,9 +166,9 @@ jobs:
|
||||
artifact: firezone-gateway
|
||||
image_name: gateway
|
||||
# mark:next-gateway-version
|
||||
release_name: gateway-1.1.6
|
||||
release_name: gateway-1.2.0
|
||||
# mark:next-gateway-version
|
||||
version: 1.1.6
|
||||
version: 1.2.0
|
||||
- package: snownet-tests
|
||||
artifact: snownet-tests
|
||||
image_name: snownet-tests
|
||||
@@ -352,7 +352,7 @@ jobs:
|
||||
- name: relay
|
||||
- name: gateway
|
||||
# mark:next-gateway-version
|
||||
version: 1.1.6
|
||||
version: 1.2.0
|
||||
- name: client
|
||||
# mark:next-client-version
|
||||
version: 1.0.6
|
||||
|
||||
22
.github/workflows/_tauri.yml
vendored
22
.github/workflows/_tauri.yml
vendored
@@ -26,31 +26,31 @@ jobs:
|
||||
include:
|
||||
- runs-on: ubuntu-20.04
|
||||
# mark:next-gui-version
|
||||
binary-dest-path: firezone-client-gui-linux_1.1.13_x86_64
|
||||
binary-dest-path: firezone-client-gui-linux_1.2.0_x86_64
|
||||
rename-script: ../../scripts/build/tauri-rename-ubuntu.sh
|
||||
upload-script: ../../scripts/build/tauri-upload-ubuntu.sh
|
||||
# mark:next-gui-version
|
||||
syms-artifact: rust/gui-client/firezone-client-gui-linux_1.1.13_x86_64.dwp
|
||||
syms-artifact: rust/gui-client/firezone-client-gui-linux_1.2.0_x86_64.dwp
|
||||
# mark:next-gui-version
|
||||
pkg-artifact: rust/gui-client/firezone-client-gui-linux_1.1.13_x86_64.deb
|
||||
pkg-artifact: rust/gui-client/firezone-client-gui-linux_1.2.0_x86_64.deb
|
||||
- runs-on: ubuntu-20.04-arm
|
||||
# mark:next-gui-version
|
||||
binary-dest-path: firezone-client-gui-linux_1.1.13_aarch64
|
||||
binary-dest-path: firezone-client-gui-linux_1.2.0_aarch64
|
||||
rename-script: ../../scripts/build/tauri-rename-ubuntu.sh
|
||||
upload-script: ../../scripts/build/tauri-upload-ubuntu.sh
|
||||
# mark:next-gui-version
|
||||
syms-artifact: rust/gui-client/firezone-client-gui-linux_1.1.13_aarch64.dwp
|
||||
syms-artifact: rust/gui-client/firezone-client-gui-linux_1.2.0_aarch64.dwp
|
||||
# mark:next-gui-version
|
||||
pkg-artifact: rust/gui-client/firezone-client-gui-linux_1.1.13_aarch64.deb
|
||||
pkg-artifact: rust/gui-client/firezone-client-gui-linux_1.2.0_aarch64.deb
|
||||
- runs-on: windows-2019
|
||||
# mark:next-gui-version
|
||||
binary-dest-path: firezone-client-gui-windows_1.1.13_x86_64
|
||||
binary-dest-path: firezone-client-gui-windows_1.2.0_x86_64
|
||||
rename-script: ../../scripts/build/tauri-rename-windows.sh
|
||||
upload-script: ../../scripts/build/tauri-upload-windows.sh
|
||||
# mark:next-gui-version
|
||||
syms-artifact: rust/gui-client/firezone-client-gui-windows_1.1.13_x86_64.pdb
|
||||
syms-artifact: rust/gui-client/firezone-client-gui-windows_1.2.0_x86_64.pdb
|
||||
# mark:next-gui-version
|
||||
pkg-artifact: rust/gui-client/firezone-client-gui-windows_1.1.13_x86_64.msi
|
||||
pkg-artifact: rust/gui-client/firezone-client-gui-windows_1.2.0_x86_64.msi
|
||||
env:
|
||||
BINARY_DEST_PATH: ${{ matrix.binary-dest-path }}
|
||||
AZURE_KEY_VAULT_URI: ${{ secrets.AZURE_KEY_VAULT_URI }}
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
if: ${{ runner.os == 'Windows' }}
|
||||
shell: bash
|
||||
# mark:next-gui-version
|
||||
run: ../../scripts/build/sign.sh ../target/release/bundle/msi/Firezone_1.1.13_x64_en-US.msi
|
||||
run: ../../scripts/build/sign.sh ../target/release/bundle/msi/Firezone_1.2.0_x64_en-US.msi
|
||||
- name: Rename artifacts and compute SHA256
|
||||
shell: bash
|
||||
run: ${{ matrix.rename-script }}
|
||||
@@ -121,6 +121,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
# mark:next-gui-version
|
||||
TAG_NAME: gui-client-1.1.13
|
||||
TAG_NAME: gui-client-1.2.0
|
||||
shell: bash
|
||||
run: ${{ matrix.upload-script }}
|
||||
|
||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
@@ -46,13 +46,13 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
# mark:next-gateway-version
|
||||
- release_name: gateway-1.1.6
|
||||
- release_name: gateway-1.2.0
|
||||
config_name: release-drafter-gateway.yml
|
||||
# mark:next-headless-version
|
||||
- release_name: headless-client-1.1.8
|
||||
- release_name: headless-client-1.2.0
|
||||
config_name: release-drafter-headless-client.yml
|
||||
# mark:next-gui-version
|
||||
- release_name: gui-client-1.1.13
|
||||
- release_name: gui-client-1.2.0
|
||||
config_name: release-drafter-gui-client.yml
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@v6
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -40,11 +40,11 @@ jobs:
|
||||
if [[ "${{ github.event.release.name }}" =~ gateway* ]]; then
|
||||
ARTIFACT=gateway
|
||||
# mark:next-gateway-version
|
||||
VERSION="1.1.6"
|
||||
VERSION="1.2.0"
|
||||
elif [[ "${{ github.event.release.name }}" =~ headless* ]]; then
|
||||
ARTIFACT=client
|
||||
# mark:next-headless-version
|
||||
VERSION="1.1.8"
|
||||
VERSION="1.2.0"
|
||||
else
|
||||
echo "Release doesn't require publishing Docker images"
|
||||
exit 0
|
||||
|
||||
@@ -56,7 +56,7 @@ android {
|
||||
targetSdk = 35
|
||||
versionCode = (System.currentTimeMillis() / 1000 / 10).toInt()
|
||||
// mark:next-android-version
|
||||
versionName = "1.1.7"
|
||||
versionName = "1.2.0"
|
||||
multiDexEnabled = true
|
||||
testInstrumentationRunner = "dev.firezone.android.core.HiltTestRunner"
|
||||
}
|
||||
|
||||
94
rust/Cargo.lock
generated
94
rust/Cargo.lock
generated
@@ -878,6 +878,7 @@ dependencies = [
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim 0.11.0",
|
||||
"terminal_size",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -988,9 +989,15 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "condtype"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
|
||||
|
||||
[[package]]
|
||||
name = "connlib-client-android"
|
||||
version = "1.1.7"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"android_log-sys",
|
||||
"backoff",
|
||||
@@ -1016,7 +1023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "connlib-client-apple"
|
||||
version = "1.1.6"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"backoff",
|
||||
@@ -1516,6 +1523,31 @@ dependencies = [
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "divan"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0d567df2c9c2870a43f3f2bd65aaeb18dbce1c18f217c3e564b4fbaeb3ee56c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"clap",
|
||||
"condtype",
|
||||
"divan-macros",
|
||||
"libc",
|
||||
"regex-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "divan-macros"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27540baf49be0d484d8f0130d7d8da3011c32a44d4fc873368154f1510e574a2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dns-lookup"
|
||||
version = "2.0.4"
|
||||
@@ -1796,7 +1828,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "firezone-gateway"
|
||||
version = "1.1.6"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1832,7 +1864,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "firezone-gui-client"
|
||||
version = "1.1.13"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -1884,7 +1916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "firezone-headless-client"
|
||||
version = "1.1.8"
|
||||
version = "1.2.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"atomicwrites",
|
||||
@@ -1991,11 +2023,13 @@ dependencies = [
|
||||
"chrono",
|
||||
"connlib-shared",
|
||||
"derivative",
|
||||
"divan",
|
||||
"domain",
|
||||
"firezone-logging",
|
||||
"firezone-relay",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"glob",
|
||||
"hex",
|
||||
"ip-packet",
|
||||
"ip_network",
|
||||
@@ -2011,6 +2045,7 @@ dependencies = [
|
||||
"snownet",
|
||||
"socket-factory",
|
||||
"socket2",
|
||||
"test-case",
|
||||
"test-strategy",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -4893,6 +4928,12 @@ dependencies = [
|
||||
"regex-syntax 0.8.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.6.29"
|
||||
@@ -6157,6 +6198,49 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminal_size"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "test-case"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8"
|
||||
dependencies = [
|
||||
"test-case-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "test-case-core"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "test-case-macros"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.72",
|
||||
"test-case-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "test-strategy"
|
||||
version = "0.3.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "connlib-client-android"
|
||||
# mark:next-android-version
|
||||
version = "1.1.7"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "connlib-client-apple"
|
||||
# mark:next-apple-version
|
||||
version = "1.1.6"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -10,10 +10,12 @@ boringtun = { workspace = true }
|
||||
bytes = { version = "1.7", default-features = false, features = ["std"] }
|
||||
chrono = { workspace = true }
|
||||
connlib-shared = { workspace = true }
|
||||
divan = { version = "0.1.14", optional = true }
|
||||
domain = { workspace = true }
|
||||
firezone-logging = { workspace = true }
|
||||
futures = { version = "0.3", default-features = false, features = ["std", "async-await", "executor"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["std", "async-await", "async-await-macro"] }
|
||||
glob = "0.3.1"
|
||||
hex = "0.4.3"
|
||||
ip-packet = { workspace = true }
|
||||
ip_network = { version = "0.4", default-features = false }
|
||||
@@ -41,12 +43,19 @@ ip-packet = { workspace = true, features = ["proptest"] }
|
||||
proptest-state-machine = "0.3"
|
||||
rand = "0.8"
|
||||
serde_json = "1.0"
|
||||
test-case = "3.3.1"
|
||||
test-strategy = "0.3.1"
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[[bench]]
|
||||
name = "divan"
|
||||
harness = false
|
||||
required-features = ["divan"]
|
||||
|
||||
[features]
|
||||
proptest = ["dep:proptest"]
|
||||
divan = ["dep:divan"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
5
rust/connlib/tunnel/benches/divan.rs
Normal file
5
rust/connlib/tunnel/benches/divan.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
extern crate firezone_tunnel; // Ensure benchmarks aren't optimised out.
|
||||
|
||||
fn main() {
|
||||
divan::main()
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
use crate::client::IpProvider;
|
||||
use connlib_shared::messages::{DnsServer, ResourceId};
|
||||
use connlib_shared::DomainName;
|
||||
use domain::base::RelativeName;
|
||||
use domain::base::{
|
||||
iana::{Class, Rcode, Rtype},
|
||||
Message, MessageBuilder, ToName,
|
||||
@@ -10,6 +9,7 @@ use domain::rdata::AllRecordData;
|
||||
use ip_packet::IpPacket;
|
||||
use ip_packet::Packet as _;
|
||||
use itertools::Itertools;
|
||||
use pattern::{Candidate, Pattern};
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
|
||||
@@ -23,8 +23,8 @@ pub struct StubResolver {
|
||||
fqdn_to_ips: HashMap<DomainName, Vec<IpAddr>>,
|
||||
ips_to_fqdn: HashMap<IpAddr, (DomainName, ResourceId)>,
|
||||
ip_provider: IpProvider,
|
||||
/// All DNS resources we know about, indexed by their domain (could be wildcard domain like `*.mycompany.com`).
|
||||
dns_resources: HashMap<String, ResourceId>,
|
||||
/// All DNS resources we know about, indexed by the glob pattern they match against.
|
||||
dns_resources: HashMap<Pattern, ResourceId>,
|
||||
/// Fixed dns name that will be resolved to fixed ip addrs, similar to /etc/hosts
|
||||
known_hosts: KnownHosts,
|
||||
}
|
||||
@@ -112,8 +112,16 @@ impl StubResolver {
|
||||
Some((fqdn, self.fqdn_to_ips.get(fqdn).unwrap()))
|
||||
}
|
||||
|
||||
pub(crate) fn add_resource(&mut self, id: ResourceId, address: String) -> bool {
|
||||
let existing = self.dns_resources.insert(address, id);
|
||||
pub(crate) fn add_resource(&mut self, id: ResourceId, pattern: String) -> bool {
|
||||
let parsed_pattern = match Pattern::new(&pattern) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(%pattern, "Domain pattern is not valid: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let existing = self.dns_resources.insert(parsed_pattern, id);
|
||||
|
||||
existing.is_none()
|
||||
}
|
||||
@@ -157,8 +165,26 @@ impl StubResolver {
|
||||
ips
|
||||
}
|
||||
|
||||
fn match_resource(&self, domain_name: &DomainName) -> Option<ResourceId> {
|
||||
match_domain(domain_name, &self.dns_resources)
|
||||
/// Attempts to match the given domain against our list of possible patterns.
|
||||
///
|
||||
/// This performs a linear search and is thus O(N) and **must not** be called in the hot-path of packet routing.
|
||||
#[tracing::instrument(level = "trace", skip_all, fields(domain))]
|
||||
fn match_resource_linear(&self, domain_name: &DomainName) -> Option<ResourceId> {
|
||||
let name = Candidate::from_domain(domain_name);
|
||||
|
||||
for (pattern, id) in &self.dns_resources {
|
||||
if pattern.matches(&name) {
|
||||
tracing::trace!(%id, %pattern, "Matched domain");
|
||||
|
||||
return Some(*id);
|
||||
}
|
||||
|
||||
tracing::trace!(%pattern, %id, "No match");
|
||||
}
|
||||
|
||||
tracing::debug!("No resources matched");
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn resource_address_name_by_reservse_dns(
|
||||
@@ -225,7 +251,8 @@ impl StubResolver {
|
||||
return Some(ResolveStrategy::LocalResponse(packet));
|
||||
}
|
||||
|
||||
let maybe_resource = self.match_resource(&domain);
|
||||
// `match_resource` is `O(N)` which we deem fine for DNS queries.
|
||||
let maybe_resource = self.match_resource_linear(&domain);
|
||||
|
||||
let resource_records = match (qtype, maybe_resource) {
|
||||
(_, Some(resource)) if !self.knows_resource(&resource) => {
|
||||
@@ -304,68 +331,17 @@ fn build_dns_with_answer(
|
||||
}
|
||||
|
||||
pub fn is_subdomain(name: &DomainName, resource: &str) -> bool {
|
||||
let question_mark = RelativeName::<Vec<_>>::from_octets(b"\x01?".as_ref().into()).unwrap();
|
||||
let Ok(resource) = DomainName::vec_from_str(resource) else {
|
||||
return false;
|
||||
let pattern = match Pattern::new(resource) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!(%resource, "Unable to parse pattern: {e}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if resource.starts_with(&question_mark) {
|
||||
return resource
|
||||
.parent()
|
||||
.is_some_and(|r| r == name || name.parent().is_some_and(|n| r == n));
|
||||
}
|
||||
let candidate = Candidate::from_domain(name);
|
||||
|
||||
if resource.starts_with(&RelativeName::wildcard_vec()) {
|
||||
let Some(resource) = resource.parent() else {
|
||||
return false;
|
||||
};
|
||||
return name.iter_suffixes().any(|n| n == resource);
|
||||
}
|
||||
|
||||
name == &resource
|
||||
}
|
||||
|
||||
fn match_domain<T>(name: &DomainName, resources: &HashMap<String, T>) -> Option<T>
|
||||
where
|
||||
T: Copy,
|
||||
{
|
||||
// Safety: `?` is less than 254 bytes long.
|
||||
const QUESTION_MARK: RelativeName<&'static [u8]> =
|
||||
unsafe { RelativeName::from_octets_unchecked(b"\x01?") };
|
||||
// Safety: `*` is less than 254 bytes long.
|
||||
const WILDCARD: RelativeName<&'static [u8]> =
|
||||
unsafe { RelativeName::from_octets_unchecked(b"\x01*") };
|
||||
|
||||
// First, check for full match.
|
||||
if let Some(resource) = resources.get(&name.to_string()) {
|
||||
return Some(*resource);
|
||||
}
|
||||
|
||||
// Second, check for `?` matching this domain exactly.
|
||||
let qm_dot_domain = QUESTION_MARK.chain(name).ok()?.to_string();
|
||||
if let Some(resource) = resources.get(&qm_dot_domain) {
|
||||
return Some(*resource);
|
||||
}
|
||||
|
||||
// Third, check for `?` matching up to 1 parent.
|
||||
if let Some(parent) = name.parent() {
|
||||
let qm_dot_parent = QUESTION_MARK.chain(parent).ok()?.to_string();
|
||||
|
||||
if let Some(resource) = resources.get(&qm_dot_parent) {
|
||||
return Some(*resource);
|
||||
}
|
||||
}
|
||||
|
||||
// Last, check for any wildcard domains, starting with the most specific one.
|
||||
for suffix in name.iter_suffixes() {
|
||||
let wildcard_dot_suffix = WILDCARD.chain(suffix).ok()?.to_string();
|
||||
|
||||
if let Some(resource) = resources.get(&wildcard_dot_suffix) {
|
||||
return Some(*resource);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
pattern.matches(&candidate)
|
||||
}
|
||||
|
||||
fn reverse_dns_addr(name: &str) -> Option<IpAddr> {
|
||||
@@ -438,9 +414,78 @@ fn ips_to_fqdn_for_known_hosts(
|
||||
.collect()
|
||||
}
|
||||
|
||||
mod pattern {
|
||||
use super::*;
|
||||
use std::{convert::Infallible, fmt, str::FromStr};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Pattern {
|
||||
inner: glob::Pattern,
|
||||
original: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for Pattern {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.original.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
pub fn new(p: &str) -> Result<Self, glob::PatternError> {
|
||||
Ok(Self {
|
||||
inner: glob::Pattern::new(&p.replace('.', "/"))?,
|
||||
original: p.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Matches a [`Candidate`] against this [`Pattern`].
|
||||
///
|
||||
/// Matching only requires a reference, thus allowing users to test a [`Candidate`] against multiple [`Pattern`]s.
|
||||
pub fn matches(&self, domain: &Candidate) -> bool {
|
||||
let domain = domain.0.as_str();
|
||||
|
||||
if let Some(rem) = self.inner.as_str().strip_prefix("*/") {
|
||||
if domain == rem {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
self.inner.matches_with(
|
||||
domain,
|
||||
glob::MatchOptions {
|
||||
case_sensitive: false,
|
||||
require_literal_separator: true,
|
||||
require_literal_leading_dot: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate for matching against a domain [`Pattern`].
|
||||
///
|
||||
/// Creates a type-safe contract that replaces `.` with `/` in the domain which is requires for pattern matching.
|
||||
pub struct Candidate(String);
|
||||
|
||||
impl Candidate {
|
||||
pub fn from_domain(domain: &DomainName) -> Self {
|
||||
Self(domain.to_string().replace('.', "/"))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Candidate {
|
||||
type Err = Infallible;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Self(s.replace('.', "/")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr as _;
|
||||
use test_case::test_case;
|
||||
|
||||
#[test]
|
||||
fn reverse_dns_addr_works_v4() {
|
||||
@@ -496,66 +541,82 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_matching() {
|
||||
let resources = HashMap::from([("*.foo.com".to_string(), 0), ("*.com".to_string(), 1)]);
|
||||
fn pattern_displays_without_slash() {
|
||||
let pattern = Pattern::new("**.example.com").unwrap();
|
||||
|
||||
assert_eq!(match_domain(&domain("a.foo.com"), &resources), Some(0));
|
||||
assert_eq!(match_domain(&domain("foo.com"), &resources), Some(0));
|
||||
assert_eq!(match_domain(&domain("a.b.foo.com"), &resources), Some(0));
|
||||
assert_eq!(match_domain(&domain("oo.com"), &resources), Some(1));
|
||||
assert_eq!(match_domain(&domain("oo.xyz"), &resources), None);
|
||||
assert_eq!(pattern.to_string(), "**.example.com")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_matching() {
|
||||
let resources = HashMap::from([("?.bar.com".to_string(), 1)]);
|
||||
#[test_case("**.example.com", "example.com"; "double star matches root domain")]
|
||||
#[test_case("app.**.example.com", "app.bar.foo.example.com"; "double star matches multiple levels within domain")]
|
||||
#[test_case("**.example.com", "foo.example.com"; "double star matches one level")]
|
||||
#[test_case("**.example.com", "foo.bar.example.com"; "double star matches two levels")]
|
||||
#[test_case("*.example.com", "foo.example.com"; "single star matches one level")]
|
||||
#[test_case("*.example.com", "example.com"; "single star matches root domain")]
|
||||
#[test_case("foo.*.example.com", "foo.bar.example.com"; "single star matches one domain within domain")]
|
||||
#[test_case("app.*.*.example.com", "app.foo.bar.example.com"; "single star can appear on multiple levels")]
|
||||
#[test_case("app.f??.example.com", "app.foo.example.com"; "question mark matches one letter")]
|
||||
#[test_case("app.example.com", "app.example.com"; "matches literal domain")]
|
||||
#[test_case("*?*.example.com", "app.example.com"; "mix of * and ?")]
|
||||
#[test_case("app.**.web.**.example.com", "app.web.example.com"; "multiple double star within domain")]
|
||||
|
||||
assert_eq!(match_domain(&domain("a.bar.com"), &resources), Some(1));
|
||||
assert_eq!(match_domain(&domain("bar.com"), &resources), Some(1));
|
||||
assert_eq!(match_domain(&domain("a.b.bar.com"), &resources), None);
|
||||
fn domain_pattern_matches(pattern: &str, domain: &str) {
|
||||
let pattern = Pattern::new(pattern).unwrap();
|
||||
let candidate = Candidate::from_str(domain).unwrap();
|
||||
|
||||
let matches = pattern.matches(&candidate);
|
||||
|
||||
assert!(matches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_matching() {
|
||||
let resources = HashMap::from([("baz.com".to_string(), 2)]);
|
||||
#[test_case("app.*.example.com", "app.foo.bar.example.com"; "single star does not match two level")]
|
||||
#[test_case("app.*com", "app.foo.com"; "single star does not match dot")]
|
||||
#[test_case("app?com", "app.com"; "question mark does not match dot")]
|
||||
fn domain_pattern_does_not_match(pattern: &str, domain: &str) {
|
||||
let pattern = Pattern::new(pattern).unwrap();
|
||||
let candidate = Candidate::from_str(domain).unwrap();
|
||||
|
||||
assert_eq!(match_domain(&domain("baz.com"), &resources), Some(2));
|
||||
assert_eq!(match_domain(&domain("a.baz.com"), &resources), None);
|
||||
assert_eq!(match_domain(&domain("a.b.baz.com"), &resources), None);
|
||||
}
|
||||
let matches = pattern.matches(&candidate);
|
||||
|
||||
#[test]
|
||||
fn exact_subdomain_match() {
|
||||
assert!(is_subdomain(&domain("foo.com"), "foo.com"));
|
||||
assert!(!is_subdomain(&domain("a.foo.com"), "foo.com"));
|
||||
assert!(!is_subdomain(&domain("a.b.foo.com"), "foo.com"));
|
||||
assert!(!is_subdomain(&domain("foo.com"), "a.foo.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_subdomain_match() {
|
||||
assert!(is_subdomain(&domain("foo.com"), "*.foo.com"));
|
||||
assert!(is_subdomain(&domain("a.foo.com"), "*.foo.com"));
|
||||
assert!(is_subdomain(&domain("a.foo.com"), "*.a.foo.com"));
|
||||
assert!(is_subdomain(&domain("b.a.foo.com"), "*.a.foo.com"));
|
||||
assert!(is_subdomain(&domain("a.b.foo.com"), "*.foo.com"));
|
||||
assert!(!is_subdomain(&domain("afoo.com"), "*.foo.com"));
|
||||
assert!(!is_subdomain(&domain("b.afoo.com"), "*.foo.com"));
|
||||
assert!(!is_subdomain(&domain("bar.com"), "*.foo.com"));
|
||||
assert!(!is_subdomain(&domain("foo.com"), "*.a.foo.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_subdomain_match() {
|
||||
assert!(is_subdomain(&domain("foo.com"), "?.foo.com"));
|
||||
assert!(is_subdomain(&domain("a.foo.com"), "?.foo.com"));
|
||||
assert!(!is_subdomain(&domain("a.b.foo.com"), "?.foo.com"));
|
||||
assert!(!is_subdomain(&domain("bar.com"), "?.foo.com"));
|
||||
assert!(!is_subdomain(&domain("foo.com"), "?.a.foo.com"));
|
||||
assert!(!is_subdomain(&domain("afoo.com"), "?.foo.com"));
|
||||
}
|
||||
|
||||
fn domain(name: &str) -> DomainName {
|
||||
DomainName::vec_from_str(name).unwrap()
|
||||
assert!(!matches);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "divan")]
|
||||
mod benches {
|
||||
use super::*;
|
||||
use rand::{distributions::DistString, seq::IteratorRandom, Rng};
|
||||
|
||||
#[divan::bench(
|
||||
consts = [10, 100, 1_000, 10_000, 100_000]
|
||||
)]
|
||||
fn match_domain_linear<const NUM_RES: u128>(bencher: divan::Bencher) {
|
||||
bencher
|
||||
.with_inputs(|| {
|
||||
let mut resolver = StubResolver::new(HashMap::default());
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
for n in 0..NUM_RES {
|
||||
resolver.add_resource(ResourceId::from_u128(n), make_domain(&mut rng));
|
||||
}
|
||||
|
||||
let needle = resolver
|
||||
.dns_resources
|
||||
.keys()
|
||||
.choose(&mut rng)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let needle = DomainName::vec_from_str(&needle).unwrap();
|
||||
|
||||
(resolver, needle)
|
||||
})
|
||||
.bench_refs(|(resolver, needle)| resolver.match_resource_linear(needle).unwrap());
|
||||
}
|
||||
|
||||
fn make_domain(rng: &mut impl Rng) -> String {
|
||||
(0..rng.gen_range(2..5))
|
||||
.map(|_| rand::distributions::Alphanumeric.sample_string(rng, 3))
|
||||
.join(".")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,13 +674,7 @@ fn is_subdomain(name: &str, record: &str) -> bool {
|
||||
return false;
|
||||
};
|
||||
match first {
|
||||
"*" => name.ends_with(end) && name.strip_suffix(end).is_some_and(|n| n.ends_with('.')),
|
||||
"?" => {
|
||||
name.ends_with(end)
|
||||
&& name
|
||||
.strip_suffix(end)
|
||||
.is_some_and(|n| n.ends_with('.') && n.matches('.').count() == 1)
|
||||
}
|
||||
"**" => name.ends_with(end) && name.strip_suffix(end).is_some_and(|n| n.ends_with('.')),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ pub(crate) fn stub_portal() -> impl Strategy<Value = StubPortal> {
|
||||
prop_oneof![
|
||||
non_wildcard_dns_resource(any_site(sites.clone())),
|
||||
star_wildcard_dns_resource(any_site(sites.clone())),
|
||||
question_mark_wildcard_dns_resource(any_site(sites.clone())),
|
||||
double_star_wildcard_dns_resource(any_site(sites.clone())),
|
||||
],
|
||||
1..5,
|
||||
);
|
||||
@@ -188,11 +188,11 @@ fn star_wildcard_dns_resource(
|
||||
})
|
||||
}
|
||||
|
||||
fn question_mark_wildcard_dns_resource(
|
||||
fn double_star_wildcard_dns_resource(
|
||||
site: impl Strategy<Value = Site>,
|
||||
) -> impl Strategy<Value = ResourceDescriptionDns> {
|
||||
dns_resource(site.prop_map(|s| vec![s])).prop_map(|r| ResourceDescriptionDns {
|
||||
address: format!("?.{}", r.address),
|
||||
address: format!("**.{}", r.address),
|
||||
..r
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,17 +211,14 @@ impl StubPortal {
|
||||
.map(|resource| {
|
||||
let address = resource.address.clone();
|
||||
|
||||
match address.chars().next().unwrap() {
|
||||
'*' => subdomain_records(
|
||||
address.trim_start_matches("*.").to_owned(),
|
||||
domain_name(1..3),
|
||||
)
|
||||
.boxed(),
|
||||
'?' => subdomain_records(
|
||||
address.trim_start_matches("?.").to_owned(),
|
||||
domain_label(),
|
||||
)
|
||||
.boxed(),
|
||||
// Only generate simple wildcard domains for these tests.
|
||||
// The matching logic is extensively unit-tested so we don't need to cover all cases here.
|
||||
// What we do want to cover is multiple domains pointing to the same resource.
|
||||
// For example, `*.example.com` and `app.example.com`.
|
||||
match address.split_once('.') {
|
||||
Some(("*" | "**", base)) => {
|
||||
subdomain_records(base.to_owned(), domain_label()).boxed()
|
||||
}
|
||||
_ => resolved_ips()
|
||||
.prop_map(move |resolved_ips| {
|
||||
HashMap::from([(address.parse().unwrap(), resolved_ips)])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "firezone-gateway"
|
||||
# mark:next-gateway-version
|
||||
version = "1.1.6"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "firezone-gui-client"
|
||||
# mark:next-gui-version
|
||||
version = "1.1.13"
|
||||
version = "1.2.0"
|
||||
description = "Firezone"
|
||||
edition = "2021"
|
||||
default-run = "firezone-gui-client"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "firezone-headless-client"
|
||||
# mark:next-headless-version
|
||||
version = "1.1.8"
|
||||
version = "1.2.0"
|
||||
edition = "2021"
|
||||
authors = ["Firezone, Inc."]
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -20,11 +20,11 @@ current-gui-version = 1.1.12
|
||||
current-headless-version = 1.1.7
|
||||
|
||||
# Tracks the next version to release for each platform
|
||||
next-apple-version = 1.1.6
|
||||
next-android-version = 1.1.7
|
||||
next-gateway-version = 1.1.6
|
||||
next-gui-version = 1.1.13
|
||||
next-headless-version = 1.1.8
|
||||
next-apple-version = 1.2.0
|
||||
next-android-version = 1.2.0
|
||||
next-gateway-version = 1.2.0
|
||||
next-gui-version = 1.2.0
|
||||
next-headless-version = 1.2.0
|
||||
|
||||
# macOS uses a slightly different sed syntax
|
||||
ifeq ($(shell uname),Darwin)
|
||||
|
||||
@@ -585,7 +585,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/debug";
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = "-lconnlib";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).debug.network-extension";
|
||||
@@ -627,7 +627,7 @@
|
||||
);
|
||||
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-ios/release";
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
OTHER_LDFLAGS = "-lconnlib";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -671,7 +671,7 @@
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-darwin/debug";
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64e]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-darwin/debug";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/debug";
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
OTHER_LDFLAGS = "-lconnlib";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).debug.network-extension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -714,7 +714,7 @@
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-darwin/release";
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64e]" = "$(CONNLIB_TARGET_DIR)/aarch64-apple-darwin/release";
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(CONNLIB_TARGET_DIR)/x86_64-apple-darwin/release";
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
OTHER_LDFLAGS = "-lconnlib";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited).network-extension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -908,7 +908,7 @@
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
OTHER_LDFLAGS = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -959,7 +959,7 @@
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MARKETING_VERSION = 1.1.6;
|
||||
MARKETING_VERSION = 1.2.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)";
|
||||
PRODUCT_MODULE_NAME = "$(PRODUCT_NAME:c99extidentifier)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -10,8 +10,11 @@ export default function Android() {
|
||||
title="Android"
|
||||
>
|
||||
{/*
|
||||
<Entry version="1.1.7" date={new Date(todo)}>
|
||||
<Entry version="1.2.0" date={new Date(todo)}>
|
||||
<ul className="list-disc space-y-2 pl-4 mb-4">
|
||||
<ChangeItem pull="5901">
|
||||
Implements glob-like matching of domains for DNS resources.
|
||||
</ChangeItem>
|
||||
</ul>
|
||||
</Entry>
|
||||
*/}
|
||||
|
||||
@@ -9,8 +9,11 @@ export default function Apple() {
|
||||
href="https://apps.apple.com/us/app/firezone/id6443661826"
|
||||
title="macOS / iOS"
|
||||
>
|
||||
{/* <Entry version="1.1.5" date={new Date(TODO)}>
|
||||
{/* <Entry version="1.2.0" date={new Date(TODO)}>
|
||||
<ul className="list-disc space-y-2 pl-4 mb-4">
|
||||
<ChangeItem pull="5901">
|
||||
Implements glob-like matching of domains for DNS resources.
|
||||
</ChangeItem>
|
||||
</ul>
|
||||
</Entry> */}
|
||||
<Entry version="1.1.5" date={new Date("2024-08-13")}>
|
||||
|
||||
@@ -14,16 +14,10 @@ export default function GUI({ title }: { title: string }) {
|
||||
<Entries href={href} arches={arches} title={title}>
|
||||
{/* When you cut a release, remove any solved issues from the "known issues" lists over in `client-apps`. This cannot be done when the issue's PR merges. */}
|
||||
{/*
|
||||
<Entry version="1.1.12" date={new Date("Invalid date")}>
|
||||
<Entry version="1.2.0" date={new Date("Invalid date")}>
|
||||
<ul className="list-disc space-y-2 pl-4 mb-4">
|
||||
<ChangeItem pull="6226">
|
||||
Fixes a bug where clearing the log files would delete the current files, preventing logs from being written.
|
||||
</ChangeItem>
|
||||
<ChangeItem pull="6276">
|
||||
Fixes a bug where relayed connections failed to establish after an idle period.
|
||||
</ChangeItem>
|
||||
<ChangeItem pull="6277">
|
||||
Fixes a bug where restrictive NATs caused connectivity problems.
|
||||
<ChangeItem pull="5901">
|
||||
Implements glob-like matching of domains for DNS resources.
|
||||
</ChangeItem>
|
||||
<ChangeItem enable={title === "Windows"} pull="6280">
|
||||
Fixes a bug where the "Clear Logs" button did not clear the IPC service logs.
|
||||
|
||||
@@ -10,13 +10,10 @@ export default function Gateway() {
|
||||
return (
|
||||
<Entries href={href} arches={arches} title="Gateway">
|
||||
{/*
|
||||
<Entry version="1.1.5" date={new Date("2024-XX-XX")}>
|
||||
<Entry version="1.2.0" date={new Date("2024-XX-XX")}>
|
||||
<ul className="list-disc space-y-2 pl-4 mb-4">
|
||||
<ChangeItem pull="6276">
|
||||
Fixes a bug where relayed connections failed to establish after an idle period.
|
||||
</ChangeItem>
|
||||
<ChangeItem pull="6277">
|
||||
Fixes a bug where restrictive NATs caused connectivity problems.
|
||||
<ChangeItem pull="5901">
|
||||
Implements glob-like matching of domains for DNS resources.
|
||||
</ChangeItem>
|
||||
</ul>
|
||||
</Entry>
|
||||
|
||||
@@ -10,8 +10,11 @@ export default function Headless() {
|
||||
return (
|
||||
<Entries href={href} arches={arches} title="Linux headless">
|
||||
{/*
|
||||
<Entry version="1.1.8" date={new Date(todo)}>
|
||||
<Entry version="1.2.0" date={new Date(todo)}>
|
||||
<ul className="list-disc space-y-2 pl-4 mb-4">
|
||||
<ChangeItem pull="5901">
|
||||
Implements glob-like matching of domains for DNS resources.
|
||||
</ChangeItem>
|
||||
</ul>
|
||||
</Entry>
|
||||
*/}
|
||||
|
||||
Reference in New Issue
Block a user