Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ its heading and collects entries. The date and the link go on with the tag.
`--version` and gives up after 3 seconds. A program still running at that
point was left behind. It is now stopped.

### Security

- **DNS lookups no longer go to Cloudflare behind your back.** When your own
DNS servers could not answer a lookup, including when a name simply had no
records of the type asked for, NetsCLI asked Cloudflare's public resolver
(1.1.1.1) the same question. That sent names you looked up to a third
party, unencrypted and without saying so, and the privacy page said the
opposite. It affected `dns` and every command that turns a host name into
an address, in the CLI, the terminal UI, the desktop app and the MCP
server. Lookups now go only to the DNS servers your computer is set up to
use. Some home routers refuse record types other than A and AAAA, and `dns`
now reports that refusal instead of quietly asking someone else.
`NETSCLI_DNS_FALLBACK` no longer does anything. The privacy page now says
what 0.3.4 and earlier did.

## [0.3.4] - 2026-10-04

### Added
Expand Down
5 changes: 3 additions & 2 deletions apps/netscli-gui/src/types/generated/DnsRecord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ name?: string,
*/
ttl_seconds?: number,
/**
* Resolver path used for this answer, for example "system" or
* "public_fallback".
* Resolver that answered. Always "system". Results saved by 0.3.4 and
* earlier may say "public_fallback", from a public DNS fallback that
* has since been removed.
*/
resolver_source?: string, };
43 changes: 10 additions & 33 deletions crates/netscli-core/src/dns/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::time::Duration;
use tokio::time::timeout;

use super::records::{normalize_value, ALL_RECORD_TYPES};
use super::resolver::{fallback_resolver, shared_resolver};
use super::resolver::shared_resolver;
use super::types::DnsRecord;
use crate::error::{Error, Result};

Expand All @@ -13,7 +13,7 @@ pub async fn lookup_record_timeout(
record_type: RecordType,
timeout_ms: u64,
) -> Result<Vec<DnsRecord>> {
let (response, resolver_source) = lookup_with_fallback(host, record_type, timeout_ms).await?;
let response = lookup_system(host, record_type, timeout_ms).await?;

// hickory 0.26 dropped `Lookup::record_iter()` in favor of explicit
// `.answers()` / `.authorities()` / `.additionals()` slice accessors.
Expand All @@ -28,7 +28,7 @@ pub async fn lookup_record_timeout(
value: normalize_value(&record.data.to_string()),
name: Some(normalize_value(&record.name.to_string())),
ttl_seconds: Some(record.ttl),
resolver_source: Some(resolver_source.to_string()),
resolver_source: Some("system".to_string()),
});
}
Ok(records)
Expand Down Expand Up @@ -74,7 +74,7 @@ pub async fn resolve_a(host: &str) -> Result<Vec<String>> {
}

pub async fn resolve_a_timeout(host: &str, timeout_ms: u64) -> Result<Vec<String>> {
let (response, _) = lookup_with_fallback(host, RecordType::A, timeout_ms).await?;
let response = lookup_system(host, RecordType::A, timeout_ms).await?;
// hickory 0.26 returns the flat `Lookup` here (it used to be a typed
// `Ipv4Lookup` wrapper that yielded `&Ipv4Addr` directly). We now
// extract the IPv4 from each answer's RData::A variant.
Expand All @@ -93,7 +93,7 @@ pub async fn resolve_aaaa(host: &str) -> Result<Vec<String>> {
}

pub async fn resolve_aaaa_timeout(host: &str, timeout_ms: u64) -> Result<Vec<String>> {
let (response, _) = lookup_with_fallback(host, RecordType::AAAA, timeout_ms).await?;
let response = lookup_system(host, RecordType::AAAA, timeout_ms).await?;
Ok(response
.answers()
.iter()
Expand All @@ -104,41 +104,18 @@ pub async fn resolve_aaaa_timeout(host: &str, timeout_ms: u64) -> Result<Vec<Str
.collect())
}

async fn lookup_with_fallback(
host: &str,
record_type: RecordType,
timeout_ms: u64,
) -> Result<(Lookup, &'static str)> {
/// One lookup against the system resolver. See `resolver::shared_resolver` for
/// why there is no other.
async fn lookup_system(host: &str, record_type: RecordType, timeout_ms: u64) -> Result<Lookup> {
let resolver = shared_resolver()?;
match timeout(
Duration::from_millis(timeout_ms),
resolver.lookup(host, record_type),
)
.await
{
Ok(Ok(resp)) => Ok((resp, "system")),
Ok(Err(system_err)) => {
if !super::resolver::should_use_public_fallback(host) {
return Err(Error::dns(format!(
"{record_type} lookup failed: system resolver returned {system_err}; public fallback disabled"
)));
}
let fallback = fallback_resolver()?;
match timeout(
Duration::from_millis(timeout_ms),
fallback.lookup(host, record_type),
)
.await
{
Ok(Ok(resp)) => Ok((resp, "public_fallback")),
Ok(Err(fallback_err)) => Err(Error::dns(format!(
"{record_type} lookup failed: system resolver returned {system_err}; public fallback returned {fallback_err}"
))),
Err(_) => Err(Error::dns(format!(
"{record_type} lookup failed: system resolver returned {system_err}; public fallback timed out after {timeout_ms}ms"
))),
}
}
Ok(Ok(resp)) => Ok(resp),
Ok(Err(e)) => Err(Error::dns(format!("{record_type} lookup failed: {e}"))),
Err(_) => Err(Error::Timeout(timeout_ms)),
}
}
Expand Down
124 changes: 43 additions & 81 deletions crates/netscli-core/src/dns/resolver.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
use hickory_resolver::{
config::{ResolverConfig, CLOUDFLARE},
net::runtime::TokioRuntimeProvider,
TokioResolver,
};
use hickory_resolver::TokioResolver;
use std::sync::OnceLock;

use crate::error::{Error, Result};

const DNS_FALLBACK_ENV: &str = "NETSCLI_DNS_FALLBACK";

/// Shared resolver — parsing the system config (`/etc/resolv.conf` or the
/// The only resolver NetsCLI uses: the system's own DNS configuration.
///
/// There is deliberately no fallback. Until 0.3.5 a name the system resolver
/// could not answer (including a plain "no records" answer) was asked again of
/// Cloudflare's public resolver, which sent names the user looked up to a
/// third party without saying so. A lookup now goes only to the DNS servers
/// the system is configured to use.
///
/// Shared, because parsing the system config (`/etc/resolv.conf` or the
/// Windows registry) on every lookup is wasteful for high-volume scans like
/// a /24 with reverse DNS enabled.
pub(super) fn shared_resolver() -> Result<&'static TokioResolver> {
Expand All @@ -31,82 +33,42 @@ pub(super) fn shared_resolver() -> Result<&'static TokioResolver> {
}
}

/// Public fallback resolver used only after the system resolver returns an
/// error. Normal lookups should still respect the OS resolver first so local
/// split-DNS/VPN names keep working.
pub(super) fn fallback_resolver() -> Result<&'static TokioResolver> {
static RESOLVER: OnceLock<std::result::Result<TokioResolver, String>> = OnceLock::new();
let cached = RESOLVER.get_or_init(|| {
TokioResolver::builder_with_config(
ResolverConfig::udp_and_tcp(&CLOUDFLARE),
TokioRuntimeProvider::default(),
)
.build()
.map_err(|e| e.to_string())
});

match cached {
Ok(r) => Ok(r),
Err(e) => Err(Error::dns(format!(
"failed to create DNS fallback resolver: {e}"
))),
}
}

pub(super) fn should_use_public_fallback(host: &str) -> bool {
if std::env::var(DNS_FALLBACK_ENV)
.map(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no"
)
})
.unwrap_or(false)
{
return false;
}

!is_local_or_internal_name(host)
}

fn is_local_or_internal_name(host: &str) -> bool {
let name = host.trim().trim_end_matches('.').to_ascii_lowercase();
// Single-label names (no dot) are never public FQDNs — e.g. "nas" or
// "printer" resolved via mDNS/NetBIOS/local search domains — so treat
// them as local rather than leaking them to the public fallback.
!name.contains('.')
|| name == "localhost"
|| name.ends_with(".localhost")
|| name.ends_with(".local")
|| name.ends_with(".lan")
|| name.ends_with(".home")
|| name.ends_with(".home.arpa")
|| name.ends_with(".internal")
|| name.ends_with(".test")
}

#[cfg(test)]
mod tests {
use super::is_local_or_internal_name;

/// Lookups must only ever reach the system's DNS servers. A second
/// resolver built from one of hickory's public presets is how the old
/// Cloudflare fallback worked, and nothing else would notice one coming
/// back: it changes no result, only where the question goes.
#[test]
fn local_names_skip_public_fallback() {
for host in [
"localhost",
"printer.local",
"router.lan",
"service.internal",
"fixture.test.",
"nas",
"printer",
"router.home.arpa",
] {
assert!(is_local_or_internal_name(host));
fn no_resolver_other_than_the_system_one() {
// Assembled at run time so this file does not match itself.
let banned = [
["Resolver", "Config"].concat(),
["builder_with", "_config"].concat(),
["CLOUD", "FLARE"].concat(),
["GOO", "GLE"].concat(),
["QUA", "D9"].concat(),
];
let mut dirs = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut hits = Vec::new();
while let Some(dir) = dirs.pop() {
for entry in std::fs::read_dir(&dir).expect("read src dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
dirs.push(path);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let text = std::fs::read_to_string(&path).expect("read source file");
for word in &banned {
if text.contains(word.as_str()) {
hits.push(format!("{} mentions {word}", path.display()));
}
}
}
}
}
}

#[test]
fn public_names_can_use_public_fallback() {
assert!(!is_local_or_internal_name("netscli.com"));
assert!(
hits.is_empty(),
"a non-system DNS resolver is back: {hits:?}"
);
}
}
5 changes: 3 additions & 2 deletions crates/netscli-core/src/dns/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ pub struct DnsRecord {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub ttl_seconds: Option<u32>,
/// Resolver path used for this answer, for example "system" or
/// "public_fallback".
/// Resolver that answered. Always "system". Results saved by 0.3.4 and
/// earlier may say "public_fallback", from a public DNS fallback that
/// has since been removed.
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "ts", ts(optional))]
pub resolver_source: Option<String>,
Expand Down
10 changes: 6 additions & 4 deletions site/src/content/docs/docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@ netscli scan 192.168.1.1 -p 22,80,443 --csv
```

```console
$ netscli dns netscli.com --record MX --csv
$ netscli dns netscli.com --record A --csv
record_type,value,name,ttl_seconds,resolver_source
MX,10 eforward1.registrar-servers.com,netscli.com,300,public_fallback
A,172.67.141.41,netscli.com,273,system
A,104.21.33.43,netscli.com,273,system

$ netscli dns netscli.com --record MX --md
$ netscli dns netscli.com --record A --md
| record_type | value | name | ttl_seconds | resolver_source |
| --- | --- | --- | --- | --- |
| MX | 10 eforward1.registrar-servers.com | netscli.com | 300 | public\_fallback |
| A | 104.21.33.43 | netscli.com | 273 | system |
| A | 172.67.141.41 | netscli.com | 273 | system |
```

- **Columns are the JSON field names**, in the same order, so a script can
Expand Down
2 changes: 2 additions & 0 deletions site/src/content/docs/docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ netscli dns netscli.com --record ALL --json

`ALL` asks for the supported record types. Some record families can fail while others succeed. Treat those as partial results unless every lookup fails.

Lookups go only to the DNS servers your computer is set up to use, the same ones `nslookup` asks. Some home routers refuse record types other than A and AAAA. When that happens, `dns` reports the refusal and does not ask anyone else.

Use `reverse` when you already have an IP address:

```bash
Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/docs/result-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ DNS records expose type and value first, then additive metadata when the resolve
| `value` | Display value for the record. |
| `ttl_seconds` | TTL when available. |
| `name` | Owner name when available. |
| `resolver_source` | Resolver source when NetsCLI can report it. |
| `resolver_source` | Always `system`. Lookups only use the DNS servers your computer is set up to use. Results saved by 0.3.4 and earlier can say `public_fallback`, from a public DNS fallback that has since been removed. |

When `ALL` records are requested, some record families can fail while others succeed. When at least one record is returned, interfaces present the lookup as partial results rather than a total failure.

Expand Down
18 changes: 12 additions & 6 deletions site/src/data/site-content/privacy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import type { PrivacyCopy } from './privacy-types';

// The privacy page (/privacy/). Every claim here was checked against the code
// on 2026-10-04: the desktop app's history limit and settings, its GitHub
// release check, the CLI's lack of outbound calls, and the site's analytics.
// Re-check before changing what any of them do.
// on 2026-10-07: the desktop app's history limit and settings, its GitHub
// release check, the CLI's history database, where DNS lookups go, and what
// the site loads. The 2026-10-04 check missed the public DNS fallback because
// it searched for HTTP clients and URLs, and the fallback was neither (see
// crates/netscli-core/src/dns/resolver.rs). Re-check before changing what any
// of them do, and check every way the code can reach the network, not only
// HTTP.
export const privacyCopy: PrivacyCopy = {
heading: 'Privacy',
leadHtml:
Expand All @@ -14,19 +18,21 @@ export const privacyCopy: PrivacyCopy = {
<p>Some of it is kept on your computer.</p>
<ul>
<li>The desktop app can keep your 20 most recent runs, with their results, so you can reopen them. Clearing history removes them.</li>
<li>From version 0.3.5 on, the command line and terminal UI keep a history of their results in <code>~/.netscli/netscli.db</code> only if you set <code>NETSCLI_HISTORY=1</code>. Earlier versions kept every result there without asking. Deleting the file removes that history.</li>
<li>The desktop app and terminal UI remember their settings.</li>
<li>Exports and packet captures are written only where you choose to save them.</li>
</ul>

<h2>What reaches the internet</h2>
<ul>
<li><strong>Scans go where you point them.</strong> A port scan, ping, trace or DNS lookup contacts the hosts and DNS servers you ask it to, the same as any network tool.</li>
<li><strong>The desktop app checks for new releases.</strong> It asks GitHub's API for the latest release, which shares your IP address with GitHub. You can turn the check off in Preferences. If you choose to install an update, it is downloaded from GitHub Releases.</li>
<li><strong>Scans go where you point them.</strong> A port scan, ping or trace contacts the hosts you ask it to. A DNS lookup, including the one a scan makes to turn a name into an address, goes only to the DNS servers your computer is set up to use.</li>
<li><strong>Version 0.3.4 and earlier also asked Cloudflare's public DNS.</strong> When your DNS servers could not answer a lookup, including when a name simply had no records of the type asked for, those versions asked Cloudflare's public resolver (1.1.1.1) the same question, unencrypted. Single-word names and names ending in .local, .lan, .home, .home.arpa, .internal or .test were never sent. Setting <code>NETSCLI_DNS_FALLBACK=0</code> turns this off in those versions. From version 0.3.5 on, NetsCLI never does this.</li>
<li><strong>The desktop app checks for new releases.</strong> Installs that can update themselves fetch one small file from GitHub's release downloads, and the rest ask GitHub's API for the latest release. Either way GitHub sees your IP address. You can turn the check off in Settings, under Release Notifications. If you choose to install an update, it is downloaded from GitHub Releases.</li>
<li><strong>The command line, terminal UI and MCP server</strong> make no connections of their own beyond the operations you run.</li>
</ul>

<h2>This website</h2>
<p>This site is hosted on GitHub Pages and uses Cloudflare Web Analytics, which counts page views without cookies and without identifying visitors. The landing page asks GitHub and crates.io for the star and download counts it shows, so your browser contacts both.</p>
<p>This site is hosted on GitHub Pages behind Cloudflare, which handles every request to it and so sees your IP address. It uses Cloudflare Web Analytics, which counts page views without cookies and without identifying visitors. The landing page asks GitHub and crates.io for the star and download counts it shows, and the changelog page asks GitHub for the release notes. Your browser contacts those services directly.</p>

<h2>Contact</h2>
<p>Questions about privacy can go to the <a href="https://github.com/fstubner/netscli/issues">issue tracker</a>.</p>
Expand Down
Loading