diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ac4dec..46731563 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,13 @@ keeps a bare heading and collects entries. ### Added +- **DNS lookups can ask a server you name.** `netscli dns --server `, + `/dns ... --server ` in the terminal UI, a Server field in the desktop + app's DNS tool and a `server` parameter on the MCP server's `dns_lookup` + send the lookup to that DNS server instead of the system's. It replaces + the public fallback this release removes with something you choose. The + MCP server holds the named server to the same target policy as a scan. + - **The terminal UI honours `NO_COLOR`, and the CLI honours `TERM=dumb` and `CLICOLOR_FORCE`.** The CLI's tables already went plain for `NO_COLOR` and for output that is not a terminal. The terminal UI ignored `NO_COLOR` and @@ -413,6 +420,13 @@ keeps a bare heading and collects entries. once, a very long device table could drop devices that were there. - The example in the `netscli-core` README compiles. +- **Scans on macOS no longer start at the edge of the open-file limit.** + Each probe in flight holds a socket, and the default of 256 at once equals + macOS's default limit on open files, so a busy scan could fail connects for + lack of a file handle and report them as filtered. NetsCLI now raises its + own soft limit, up to what the system allows, before a scan starts. Linux + gets the same, where the default limit is usually 1024. + - **The Windows one-line installer no longer closes PowerShell when it fails.** Run as `iwr ... | iex`, the installer's `exit` ended the PowerShell session itself, so the window closed and took the error message diff --git a/Cargo.lock b/Cargo.lock index 377d1faa..d9c0b67f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3201,6 +3201,7 @@ dependencies = [ "ipconfig", "ipnet", "ipnetwork", + "libc", "mac_address", "mdns-sd", "once_cell", diff --git a/README.md b/README.md index 861d2041..6d464d86 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ winget install netscli-gui # desktop app cargo install netscli ``` +On Windows the desktop app is also in the Microsoft Store: + +Get it from Microsoft + Desktop installers for Windows, macOS and Linux are attached to every [release](https://github.com/fstubner/netscli/releases/latest). diff --git a/SECURITY.md b/SECURITY.md index 82b7fafc..e4b5e3d1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -64,13 +64,14 @@ signed by `.github/workflows/release.yml`. How to check both is in the - **Sigstore signatures.** Each asset also has a `.sig` and a `.pem`, made by cosign's keyless signing under the release workflow's GitHub identity. A passing `cosign verify-blob` shows the file was built and signed by this - repository's release workflow and has not changed since. The install - scripts do not check it, so that step is yours to take. + repository's release workflow and has not changed since. `install.sh` + checks it when `cosign` is installed and refuses a binary that fails. - **Windows.** The `netscli` executables (standard and `-pcap`), the `.msi` and the desktop app inside it carry an Authenticode signature from a Certum certificate, timestamped so it keeps verifying after the certificate expires. The executables and the `.msi` have been signed since - 0.3.3, and the app inside the `.msi` since 0.3.4. + 0.3.3, and the app inside the `.msi` since 0.3.4. `install.ps1` refuses a + `netscli.exe` whose signature is not valid or not from that certificate. - **macOS.** The `.dmg` is ad-hoc signed and not notarized, so macOS asks you to approve the first launch. - **In-app updates.** The desktop app reads `latest.json` from the latest @@ -83,9 +84,12 @@ signed by `.github/workflows/release.yml`. How to check both is in the and restart**, and **Release Notifications** in Settings turns the check off. Installs that a package manager owns (Scoop, the AUR and `.deb`) show a link to the release instead of updating themselves. -- **Not signed.** The `.mcpb` bundles and `latest.json` have no checksum or - signature file of their own. The binary inside a bundle is checked against - its published checksum when the bundle is built. +- **MCP bundles.** The `.mcpb` bundles have a `.sha256`, `.sig` and `.pem` + like every other asset, signed by the publishing workflow + (`publish.yml`) rather than the release workflow. +- **Not signed as a file.** `latest.json` has no signature file of its own. + Every update it lists is signed, and the signature names the version it + was made for, which the app requires. ## How far the supply chain has been reviewed diff --git a/apps/netscli-cli/src/args.rs b/apps/netscli-cli/src/args.rs index 51903015..66342b7a 100644 --- a/apps/netscli-cli/src/args.rs +++ b/apps/netscli-cli/src/args.rs @@ -154,6 +154,10 @@ pub enum Commands { #[arg(long)] record: Option, + /// Ask this DNS server (an IP address) instead of the ones the system uses + #[arg(long, value_name = "IP")] + server: Option, + #[command(flatten)] format: ListOutput, }, diff --git a/apps/netscli-cli/src/cli_dispatch.rs b/apps/netscli-cli/src/cli_dispatch.rs index 2d9d6bd3..bf7b7e72 100644 --- a/apps/netscli-cli/src/cli_dispatch.rs +++ b/apps/netscli-cli/src/cli_dispatch.rs @@ -85,9 +85,10 @@ pub(crate) async fn run_command(command: &Commands, ctx: CommandContext<'_>) -> Commands::Dns { host, record, + server, format, } => { - dns::run_lookup(ctx, host, record, *format).await?; + dns::run_lookup(ctx, host, record, *server, *format).await?; } Commands::Reverse { ip, format } => { dns::run_reverse(ctx, ip, format.json, format.yaml).await?; diff --git a/apps/netscli-cli/src/cli_dispatch/dns.rs b/apps/netscli-cli/src/cli_dispatch/dns.rs index fce1328f..81fe8fed 100644 --- a/apps/netscli-cli/src/cli_dispatch/dns.rs +++ b/apps/netscli-cli/src/cli_dispatch/dns.rs @@ -9,10 +9,11 @@ pub(super) async fn run_lookup( ctx: CommandContext<'_>, host: &str, record: &Option, + server: Option, flags: ListOutput, ) -> Result<()> { let format = list_output_format(flags)?; - let records = commands::run_dns(ctx.ops, ctx.db, host, record.clone()).await?; + let records = commands::run_dns(ctx.ops, ctx.db, host, record.clone(), server).await?; match format { OutputFormat::Json | OutputFormat::Yaml | OutputFormat::Csv | OutputFormat::Markdown => { print_structured(format, &records)?; diff --git a/apps/netscli-cli/src/commands.rs b/apps/netscli-cli/src/commands.rs index 8fe3143f..4d10e830 100644 --- a/apps/netscli-cli/src/commands.rs +++ b/apps/netscli-cli/src/commands.rs @@ -160,8 +160,9 @@ pub async fn run_dns( db: Option<&Database>, host: &str, record: Option, + server: Option, ) -> Result> { - let records = ops.dns_lookup(host, record).await?; + let records = ops.dns_lookup_via(host, record, server).await?; if let Some(db) = db { db_add_scan_history_safe(db, "dns", 0, &records).await; } diff --git a/apps/netscli-cli/src/tui/command_catalog.rs b/apps/netscli-cli/src/tui/command_catalog.rs index 56620c89..7d20e73a 100644 --- a/apps/netscli-cli/src/tui/command_catalog.rs +++ b/apps/netscli-cli/src/tui/command_catalog.rs @@ -46,7 +46,7 @@ pub(super) const COMMAND_DEFS: &[CommandDef] = &[ CommandDef { cmd: "/dns", desc: "DNS lookup", - args: " [--record |ALL]", + args: " [--record |ALL] [--server ]", }, CommandDef { cmd: "/reverse", @@ -130,7 +130,7 @@ pub(super) const COMMAND_DEFS: &[CommandDef] = &[ CommandDef { cmd: "/dns", desc: "DNS lookup", - args: " [--record |ALL]", + args: " [--record |ALL] [--server ]", }, CommandDef { cmd: "/reverse", diff --git a/apps/netscli-cli/src/tui/events/dns.rs b/apps/netscli-cli/src/tui/events/dns.rs index 878cb1ad..b4d5e12f 100644 --- a/apps/netscli-cli/src/tui/events/dns.rs +++ b/apps/netscli-cli/src/tui/events/dns.rs @@ -14,6 +14,7 @@ pub(super) async fn handle_lookup( // or a positional second token all work regardless of order. let mut host: Option = None; let mut record: Option = None; + let mut server: Option = None; let mut i = 1; let mut parse_err: Option = None; while i < parts.len() { @@ -32,6 +33,28 @@ pub(super) async fn handle_lookup( break; } } + } else if tok == "--server" || tok.starts_with("--server=") { + let value = match tok.strip_prefix("--server=") { + Some(value) => { + i += 1; + Some(value) + } + None => { + i += 2; + parts.get(i - 1).copied() + } + }; + match value.map(str::parse::) { + Some(Ok(ip)) => server = Some(ip), + Some(Err(_)) => { + parse_err = Some("--server takes an IP address".to_string()); + break; + } + None => { + parse_err = Some("--server requires a value".to_string()); + break; + } + } } else if tok.starts_with('-') { parse_err = Some(format!("unknown flag: {tok}")); break; @@ -50,10 +73,10 @@ pub(super) async fn handle_lookup( if let Some(err) = parse_err { out.push(Formatter::format_error(&format!( - "{err}. Usage: /dns [--record |ALL]" + "{err}. Usage: /dns [--record |ALL] [--server ]" ))); } else if let Some(host) = host { - match commands::run_dns(ops, db, &host, record.clone()).await { + match commands::run_dns(ops, db, &host, record.clone(), server).await { Ok(records) => { out.extend(Formatter::format_dns_results( &host, @@ -65,7 +88,7 @@ pub(super) async fn handle_lookup( } } else { out.push(Formatter::format_error( - "Usage: /dns [--record |ALL]", + "Usage: /dns [--record |ALL] [--server ]", )); } out diff --git a/apps/netscli-gui/src-tauri/src/commands/operations/lookup.rs b/apps/netscli-gui/src-tauri/src/commands/operations/lookup.rs index bd859349..9f356f01 100644 --- a/apps/netscli-gui/src-tauri/src/commands/operations/lookup.rs +++ b/apps/netscli-gui/src-tauri/src/commands/operations/lookup.rs @@ -81,12 +81,22 @@ pub(crate) async fn dns_lookup( op_id: Option, host: String, record: Option, + server: Option, manager: tauri::State<'_, OperationManager>, ) -> JsonResult { + // Parsed before the operation starts, so a typo is an error and never a + // lookup that quietly goes to the system's servers instead. + let server = match server.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(raw) => Some( + raw.parse::() + .map_err(|_| format!("Server must be an IP address, not '{raw}'."))?, + ), + None => None, + }; run_json_operation(op_id, manager, None, move || async move { let ops = Ops::default(); let res = ops - .dns_lookup(&host, record) + .dns_lookup_via(&host, record, server) .await .map_err(|e| e.to_string())?; serde_json::to_value(res).map_err(|e| e.to_string()) diff --git a/apps/netscli-gui/src/components/shell/McpServerSection.tsx b/apps/netscli-gui/src/components/shell/McpServerSection.tsx index 89aea6bb..fc936a68 100644 --- a/apps/netscli-gui/src/components/shell/McpServerSection.tsx +++ b/apps/netscli-gui/src/components/shell/McpServerSection.tsx @@ -1,6 +1,7 @@ -import { Bot, Check, Copy, ExternalLink } from 'lucide-react'; +import { Bot } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; +import { openAllowedExternalUrl } from '../../services/externalLinks'; import { type CliDetection, detectNetscliCli } from '../../services/netscli'; /** Matches CommandStrip's badge duration, for the same reason: the pointer is @@ -41,7 +42,9 @@ function clientConfig(path: string): string { ); } -function CopyButton({ value, label }: { value: string; label: string }) { +/** A text button in the same style as Choose Folder and Reset above it. Its + * label turns to "Copied" for a moment, the way CommandStrip's badge does. */ +function CopyButton({ value, label, testId }: { value: string; label: string; testId: string }) { const [copied, setCopied] = useState(false); const timer = useRef | null>(null); @@ -66,18 +69,20 @@ function CopyButton({ value, label }: { value: string; label: string }) { return ( ); } +/** The MCP section, laid out as ordinary settings rows: a label and a one-line + * note on the left, text buttons on the right. It used to be a heading, a + * wrapped paragraph and a boxed code block, which read as a different dialog + * dropped into this one. */ export function McpServerSection() { const [detection, setDetection] = useState(null); @@ -106,46 +111,75 @@ export function McpServerSection() { {detection === null ? ( -
- Looking for the netscli command-line tool… +
+
+ Command-Line Tool + Looking for netscli… +
) : detection.path ? ( <> -
- Ready to connect - - Paste this into your MCP client's config to let an agent run network - scans. Found netscli {detection.version} at {detection.path} - +
+
+ Command-Line Tool + + netscli {detection.version} at {detection.path} + +
-
-
{clientConfig(detection.path)}
- +
+
+ Agent Config + Paste into your MCP client's config to let an agent run scans. +
+
+ +
+
+ Show config +
{clientConfig(detection.path)}
+
) : ( <> -
- Command-line tool not found - - The MCP server is part of the netscli command-line tool, which installs - separately from this app. Install it and reopen Settings to get a config - block for your agent. - +
+
+ Command-Line Tool + Not installed. The MCP server comes with it. +
+
+ +
-
-
{INSTALL_COMMAND[detection.os]}
- +
+
+ Install Command + + {INSTALL_COMMAND[detection.os]} + +
+
+ +
- - All install options - - )} diff --git a/apps/netscli-gui/src/services/netscli.ts b/apps/netscli-gui/src/services/netscli.ts index 991cf342..c38b8ffe 100644 --- a/apps/netscli-gui/src/services/netscli.ts +++ b/apps/netscli-gui/src/services/netscli.ts @@ -97,8 +97,9 @@ export async function dnsLookup( host: string, record?: string, op_id?: string, + server?: string, ): Promise { - return invoke('dns_lookup', { opId: op_id, host, record }); + return invoke('dns_lookup', { opId: op_id, host, record, server: server || undefined }); } export async function discoverMdns( diff --git a/apps/netscli-gui/src/styles/shell/settings-mcp.css b/apps/netscli-gui/src/styles/shell/settings-mcp.css index 75af861d..7ca25f71 100644 --- a/apps/netscli-gui/src/styles/shell/settings-mcp.css +++ b/apps/netscli-gui/src/styles/shell/settings-mcp.css @@ -1,77 +1,42 @@ /* The MCP section of the Settings dialog. * - * Its own file because settings.css is at the 300-line guard and this is a - * self-contained region, the same reason settings-controls.css and - * settings-number-field.css are separate. + * Its own file because settings.css is at the 300-line guard. The section + * uses the ordinary settings rows (`.settings-folder-row` and its text + * buttons), so this file only holds what those rows do not have: the install + * command in monospace, and the optional config preview. */ -/* ─── MCP server section ───────────────────────────────────────────────── - * - * Two shapes share one container: a multi-line JSON config block, and a - * single-line install command. Both are things to copy, so both get the same - * treatment -- a code surface with the copy control pinned to its top-right, - * clear of the text rather than over it. - * - * `align-items: start` so the button sits level with the block's first line - * instead of centring itself against six lines of JSON. */ -.settings-mcp-config { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: start; - gap: 8px; - margin-top: 2px; - padding: 8px 10px; - border: 1px solid var(--border-subtle); - background: var(--bg-base); -} - -/* `pre`, not `code`: the config is six lines and its indentation is the - thing that makes it readable as JSON. Wrapping is allowed so a long - Windows path cannot push a horizontal scrollbar into the dialog. */ -.settings-mcp-config pre { - margin: 0; - min-width: 0; - color: var(--text-primary); - font-family: var(--font-mono); - font-size: 11px; - line-height: 1.55; - white-space: pre-wrap; - overflow-wrap: anywhere; +.settings-mcp-command { + font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; } -.settings-mcp-config button { - width: 24px; - min-width: 24px; - height: 24px; -} - -.settings-mcp-link { - display: inline-flex; - align-items: center; - gap: 5px; - margin-top: 2px; +/* The config itself is one click away rather than always on screen. It is + six lines of JSON, and shown open it made this section twice the height of + any other. */ +.settings-mcp-details summary { + width: fit-content; color: var(--text-muted); font-size: 11px; - text-decoration: none; - justify-self: start; + cursor: pointer; } -.settings-mcp-link:hover, -.settings-mcp-link:focus-visible { - color: var(--mint); +.settings-mcp-details summary:hover, +.settings-mcp-details summary:focus-visible { + color: var(--text-primary); } -/* Let this section's copy wrap. - * - * Every other settings row pairs a control with a one-line note, so - * settings.css clips `small` with `white-space: nowrap` and an ellipsis. That - * is right there and wrong here: this section explains a concept instead of - * labelling a control, and its two sentences rendered as - * "... Install it and reopen Settings t…" -- the half that tells you what to - * do was the half that got cut. */ -.settings-mcp .settings-row-copy small { - white-space: normal; - overflow: visible; - text-overflow: clip; - line-height: 1.5; +/* `pre`, not `code`: the indentation is what makes it readable as JSON. + Wrapping is allowed so a long Windows path cannot push a horizontal + scrollbar into the dialog. */ +.settings-mcp-details pre { + margin: 6px 0 0; + padding: 8px 10px; + border: 1px solid var(--border-subtle); + background: var(--bg-input); + color: var(--text-secondary); + font-family: "Cascadia Code", "JetBrains Mono", Consolas, monospace; + font-size: 11px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; } diff --git a/apps/netscli-gui/src/tools/presentation/commands.ts b/apps/netscli-gui/src/tools/presentation/commands.ts index e00cc820..f05d20c1 100644 --- a/apps/netscli-gui/src/tools/presentation/commands.ts +++ b/apps/netscli-gui/src/tools/presentation/commands.ts @@ -63,7 +63,7 @@ export function buildCommand(tab: WorkspaceTab): string { } case 'dns': { const record = flag('--record', form.record === 'ALL' ? '' : form.record); - return `netscli dns ${arg(form.host) || ''}${record} --json`; + return `netscli dns ${arg(form.host) || ''}${record}${flag('--server', form.server)} --json`; } case 'reverse': return `netscli reverse ${arg(form.ip) || ''} --json`; diff --git a/apps/netscli-gui/src/tools/presentation/values.ts b/apps/netscli-gui/src/tools/presentation/values.ts index b01f6906..d35b0d8f 100644 --- a/apps/netscli-gui/src/tools/presentation/values.ts +++ b/apps/netscli-gui/src/tools/presentation/values.ts @@ -24,12 +24,25 @@ export function numberOrUndefined(value?: string): number | undefined { * `\r` and tab are in the trigger set because both can carry a value onto a * fresh line or cell where it would lead again. */ -/** A control character other than the tab, CR and LF a CSV cell can carry. - * Unicode Cc, U+0000-U+001F and U+007F-U+009F, which is Rust's `is_control`. */ +/** A character a CSV cell must not carry as is: a control character other + * than the tab, CR and LF a cell can carry, or a bidi or zero-width one that + * reorders or hides text. The same set as `is_unsafe_for_display` in + * netscli-core (crates/netscli-core/src/common/terminal.rs), which the CLI's + * CSV export uses; testdata/csv-escape.json holds both to it. */ function isDefusedControl(char: string): boolean { if (char === '\t' || char === '\r' || char === '\n') return false; const code = char.codePointAt(0) ?? 0; - return code <= 0x1f || (code >= 0x7f && code <= 0x9f); + return ( + code <= 0x1f || + (code >= 0x7f && code <= 0x9f) || + code === 0x061c || + (code >= 0x200b && code <= 0x200f) || + code === 0x2028 || + code === 0x2029 || + (code >= 0x202a && code <= 0x202e) || + (code >= 0x2060 && code <= 0x206f) || + code === 0xfeff + ); } /** A finite decimal number, as Rust's `str::parse::` accepts it. */ diff --git a/apps/netscli-gui/src/tools/registry.ts b/apps/netscli-gui/src/tools/registry.ts index b1799c6e..7a2945be 100644 --- a/apps/netscli-gui/src/tools/registry.ts +++ b/apps/netscli-gui/src/tools/registry.ts @@ -114,6 +114,9 @@ export const TOOL_CONFIG: Record = { compact: true, options: ['ALL', 'A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SRV', 'PTR', 'SOA', 'CAA'], }, + // Optional. Empty asks the system's own DNS servers, which is the + // only place a lookup goes unless you name another here. + { key: 'server', label: 'Server', placeholder: 'system', compact: true }, ], }, reverse: { @@ -193,7 +196,7 @@ export const DEFAULT_FORM: Record> = { ping: { host: '127.0.0.1', count: '4' }, trace: { host: '1.1.1.1', max_hops: '30', resolve: 'Off' }, discover: { subnet: '' }, - dns: { host: 'netscli.com', record: 'ALL' }, + dns: { host: 'netscli.com', record: 'ALL', server: '' }, reverse: { ip: '127.0.0.1' }, inspect: { host: '127.0.0.1', ports: DEFAULT_PORTS }, sweep: { subnet: '', ports: DEFAULT_PORTS }, diff --git a/apps/netscli-gui/src/types/generated/DnsRecord.ts b/apps/netscli-gui/src/types/generated/DnsRecord.ts index 26f61b80..145a79c9 100644 --- a/apps/netscli-gui/src/types/generated/DnsRecord.ts +++ b/apps/netscli-gui/src/types/generated/DnsRecord.ts @@ -19,8 +19,9 @@ name?: string, */ ttl_seconds?: number, /** - * 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 that answered: "system" for the computer's own DNS + * servers, or "server" for one the user named. 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, }; diff --git a/apps/netscli-gui/src/workspace/toolExecution.ts b/apps/netscli-gui/src/workspace/toolExecution.ts index 0ae26bef..7b4c3385 100644 --- a/apps/netscli-gui/src/workspace/toolExecution.ts +++ b/apps/netscli-gui/src/workspace/toolExecution.ts @@ -163,11 +163,12 @@ function serviceTypes(value: string | undefined): string[] | undefined { async function executeDns(tab: WorkspaceTab, opId: string, isCurrent: () => boolean): Promise { const host = tab.form.host.trim(); const record = tab.form.record?.trim().toUpperCase(); + const server = tab.form.server?.trim() || undefined; if (record && record !== 'ALL') { return { kind: 'dns', - data: await netscli.dnsLookup(host, record, opId), + data: await netscli.dnsLookup(host, record, opId, server), }; } @@ -181,7 +182,7 @@ async function executeDns(tab: WorkspaceTab, opId: string, isCurrent: () => bool for (const recordType of DNS_ALL_RECORDS) { if (!isCurrent()) throw new Error('Operation cancelled'); try { - data.push(...await netscli.dnsLookup(host, recordType, opId)); + data.push(...await netscli.dnsLookup(host, recordType, opId, server)); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/cancelled/i.test(message)) throw error; diff --git a/crates/netscli-core/Cargo.toml b/crates/netscli-core/Cargo.toml index f1dd9099..c0486ff0 100644 --- a/crates/netscli-core/Cargo.toml +++ b/crates/netscli-core/Cargo.toml @@ -47,6 +47,7 @@ mdns-sd = { version = "0.21", optional = true } ts-rs = { version = "12.0.1", optional = true } [target.'cfg(unix)'.dependencies] +libc = "0.2" pnet_sys = "0.35" pnet_datalink = "0.35" diff --git a/crates/netscli-core/src/common.rs b/crates/netscli-core/src/common.rs index c6e3c5ec..3cf1f7f6 100644 --- a/crates/netscli-core/src/common.rs +++ b/crates/netscli-core/src/common.rs @@ -1,5 +1,7 @@ mod connect; mod constants; +#[cfg(unix)] +mod fd_limit; mod network; mod ports; // Windows-only. On Unix `exec` searches PATH alone, so there is nothing to @@ -10,6 +12,12 @@ mod system_tools; mod terminal; pub(crate) use connect::connect as tcp_connect; + +/// See `fd_limit`. Nothing to do where there is no `RLIMIT_NOFILE`. +pub(crate) fn raise_open_file_limit() { + #[cfg(unix)] + fd_limit::raise_open_file_limit(); +} pub use constants::{ DEFAULT_CONCURRENCY, DEFAULT_DNS_TIMEOUT_MS, DEFAULT_PING_TIMEOUT_MS, DEFAULT_PORTS, DEFAULT_SCAN_TIMEOUT_MS, DEFAULT_SUBNET, MAX_MDNS_TIMEOUT_MS, MAX_PING_COUNT, diff --git a/crates/netscli-core/src/common/fd_limit.rs b/crates/netscli-core/src/common/fd_limit.rs new file mode 100644 index 00000000..5851e52a --- /dev/null +++ b/crates/netscli-core/src/common/fd_limit.rs @@ -0,0 +1,62 @@ +//! Raise the soft open-file limit before a scan fans out. +//! +//! Every probe in flight holds a socket. The default concurrency is 256, and +//! macOS's default soft limit on open files is also 256, so a scan at the +//! default concurrency there runs out of descriptors once the process's own +//! files are counted. Connects then fail with EMFILE, which reads as a +//! filtered port or an error rather than as the limit it is. The hard limit +//! is normally far higher, and raising the soft limit up to it needs no +//! privileges. + +use std::sync::Once; + +/// Enough for the 1024-wide concurrency ceiling plus the process's own files, +/// and no higher than macOS accepts for a soft limit (`OPEN_MAX`). +const WANTED: libc::rlim_t = 10_240; + +/// Raise the soft `RLIMIT_NOFILE` to [`WANTED`], or to the hard limit if that +/// is lower. Runs once per process; failure leaves the limit as it was. +pub(crate) fn raise_open_file_limit() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: both calls only read or write the `rlimit` passed to them. + unsafe { + if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 { + return; + } + let wanted = WANTED.min(limit.rlim_max); + if limit.rlim_cur >= wanted { + return; + } + limit.rlim_cur = wanted; + libc::setrlimit(libc::RLIMIT_NOFILE, &limit); + } + }); +} + +#[cfg(test)] +mod tests { + #[test] + fn the_soft_limit_covers_a_full_concurrency_scan() { + super::raise_open_file_limit(); + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: as above. + assert_eq!( + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) }, + 0 + ); + assert!( + limit.rlim_cur >= super::WANTED.min(limit.rlim_max), + "soft limit {} below {}", + limit.rlim_cur, + super::WANTED.min(limit.rlim_max) + ); + } +} diff --git a/crates/netscli-core/src/dns.rs b/crates/netscli-core/src/dns.rs index de69af45..fbd2a4b0 100644 --- a/crates/netscli-core/src/dns.rs +++ b/crates/netscli-core/src/dns.rs @@ -5,8 +5,8 @@ mod reverse; mod types; pub use lookup::{ - lookup_all_records_timeout, lookup_record_timeout, resolve_a, resolve_a_timeout, resolve_aaaa, - resolve_aaaa_timeout, + lookup_all_records_timeout, lookup_all_records_via, lookup_record_timeout, lookup_record_via, + resolve_a, resolve_a_timeout, resolve_aaaa, resolve_aaaa_timeout, }; pub use records::parse_record_type; pub use reverse::{reverse_lookup_best_effort_timeout, reverse_lookup_timeout}; diff --git a/crates/netscli-core/src/dns/lookup.rs b/crates/netscli-core/src/dns/lookup.rs index e3989a1d..0328d3f3 100644 --- a/crates/netscli-core/src/dns/lookup.rs +++ b/crates/netscli-core/src/dns/lookup.rs @@ -1,10 +1,12 @@ use hickory_resolver::lookup::Lookup; use hickory_resolver::proto::rr::{RData, RecordType}; +use hickory_resolver::TokioResolver; +use std::net::IpAddr; use std::time::Duration; use tokio::time::timeout; use super::records::{normalize_value, ALL_RECORD_TYPES}; -use super::resolver::shared_resolver; +use super::resolver::{resolver_for_server, shared_resolver}; use super::types::DnsRecord; use crate::error::{Error, Result}; @@ -13,7 +15,32 @@ pub async fn lookup_record_timeout( record_type: RecordType, timeout_ms: u64, ) -> Result> { - let response = lookup_system(host, record_type, timeout_ms).await?; + lookup_record_via(host, record_type, timeout_ms, None).await +} + +/// [`lookup_record_timeout`], asking `server` instead of the system's DNS +/// servers when one is given. +pub async fn lookup_record_via( + host: &str, + record_type: RecordType, + timeout_ms: u64, + server: Option, +) -> Result> { + let owned = server.map(resolver_for_server).transpose()?; + lookup_record_with(host, record_type, timeout_ms, owned.as_ref()).await +} + +async fn lookup_record_with( + host: &str, + record_type: RecordType, + timeout_ms: u64, + server: Option<&TokioResolver>, +) -> Result> { + let source = if server.is_some() { "server" } else { "system" }; + let response = match server { + Some(resolver) => lookup_on(resolver, host, record_type, timeout_ms).await?, + None => lookup_system(host, record_type, timeout_ms).await?, + }; // hickory 0.26 dropped `Lookup::record_iter()` in favor of explicit // `.answers()` / `.authorities()` / `.additionals()` slice accessors. @@ -28,7 +55,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("system".to_string()), + resolver_source: Some(source.to_string()), }); } Ok(records) @@ -49,11 +76,23 @@ pub async fn lookup_record_timeout( /// `Ok(records)` — only a total failure with zero records surfaces the /// last error. pub async fn lookup_all_records_timeout(host: &str, timeout_ms: u64) -> Result> { + lookup_all_records_via(host, timeout_ms, None).await +} + +/// [`lookup_all_records_timeout`], asking `server` instead of the system's +/// DNS servers when one is given. +pub async fn lookup_all_records_via( + host: &str, + timeout_ms: u64, + server: Option, +) -> Result> { + // One resolver for all ten record types, not one per type. + let owned = server.map(resolver_for_server).transpose()?; let mut records = Vec::new(); let mut last_err: Option = None; for record_type in ALL_RECORD_TYPES { - match lookup_record_timeout(host, *record_type, timeout_ms).await { + match lookup_record_with(host, *record_type, timeout_ms, owned.as_ref()).await { Ok(mut found) => records.append(&mut found), Err(e) => last_err = Some(e), } @@ -107,7 +146,15 @@ pub async fn resolve_aaaa_timeout(host: &str, timeout_ms: u64) -> Result Result { - let resolver = shared_resolver()?; + lookup_on(shared_resolver()?, host, record_type, timeout_ms).await +} + +async fn lookup_on( + resolver: &TokioResolver, + host: &str, + record_type: RecordType, + timeout_ms: u64, +) -> Result { match timeout( Duration::from_millis(timeout_ms), resolver.lookup(host, record_type), diff --git a/crates/netscli-core/src/dns/resolver.rs b/crates/netscli-core/src/dns/resolver.rs index ed4f40dd..3c70b093 100644 --- a/crates/netscli-core/src/dns/resolver.rs +++ b/crates/netscli-core/src/dns/resolver.rs @@ -1,4 +1,7 @@ +use hickory_resolver::config::{NameServerConfig, ResolverConfig}; +use hickory_resolver::net::runtime::TokioRuntimeProvider; use hickory_resolver::TokioResolver; +use std::net::IpAddr; use std::sync::OnceLock; use crate::error::{Error, Result}; @@ -33,22 +36,43 @@ pub(super) fn shared_resolver() -> Result<&'static TokioResolver> { } } +/// A resolver that asks only `server`, for `dns --server` and its +/// equivalents. The user named it, so this is a lookup going where they +/// asked, not a fallback: nothing reaches it unless a caller passes it. +pub(super) fn resolver_for_server(server: IpAddr) -> Result { + TokioResolver::builder_with_config( + ResolverConfig::from_name_servers(vec![NameServerConfig::udp_and_tcp(server)]), + TokioRuntimeProvider::default(), + ) + .build() + .map_err(|e| Error::dns(format!("failed to set up a resolver for {server}: {e}"))) +} + #[cfg(test)] mod tests { - /// 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. + /// Lookups must only ever reach the system's DNS servers, or a server + /// the user named. A 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 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(), + // hickory's public presets, anywhere. + let presets = [ ["CLOUD", "FLARE"].concat(), ["GOO", "GLE"].concat(), ["QUA", "D9"].concat(), ]; + // Public resolvers' addresses, outside test files, which use some of + // them as ordinary example targets. + let addresses = [ + ["1.1.1", ".1"].concat(), + ["1.0.0", ".1"].concat(), + ["8.8.8", ".8"].concat(), + ["8.8.4", ".4"].concat(), + ["9.9.9", ".9"].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() { @@ -58,7 +82,11 @@ mod tests { 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 { + let is_test_file = path.file_name().is_some_and(|name| name == "tests.rs"); + let banned = presets + .iter() + .chain(addresses.iter().filter(|_| !is_test_file)); + for word in banned { if text.contains(word.as_str()) { hits.push(format!("{} mentions {word}", path.display())); } diff --git a/crates/netscli-core/src/dns/reverse.rs b/crates/netscli-core/src/dns/reverse.rs index 68bf18c3..8689a2d8 100644 --- a/crates/netscli-core/src/dns/reverse.rs +++ b/crates/netscli-core/src/dns/reverse.rs @@ -78,7 +78,7 @@ pub async fn reverse_lookup_best_effort_timeout(ip: IpAddr, timeout_ms: u64) -> /// handle `None`. pub(crate) fn normalize_hostname(name: String) -> Option { let name = name.trim().trim_end_matches('.').trim(); - if name.is_empty() || name.chars().any(char::is_control) { + if name.is_empty() || name.chars().any(crate::common::is_unsafe_for_display) { None } else { Some(name.to_string()) diff --git a/crates/netscli-core/src/dns/types.rs b/crates/netscli-core/src/dns/types.rs index 41d3e8f5..4f7305ae 100644 --- a/crates/netscli-core/src/dns/types.rs +++ b/crates/netscli-core/src/dns/types.rs @@ -16,9 +16,10 @@ pub struct DnsRecord { #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "ts", ts(optional))] pub ttl_seconds: Option, - /// 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 that answered: "system" for the computer's own DNS + /// servers, or "server" for one the user named. 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, diff --git a/crates/netscli-core/src/ops/host.rs b/crates/netscli-core/src/ops/host.rs index 96cc7210..940f31e7 100644 --- a/crates/netscli-core/src/ops/host.rs +++ b/crates/netscli-core/src/ops/host.rs @@ -65,10 +65,21 @@ impl Ops { &self, host: &str, record: Option, + ) -> Result> { + self.dns_lookup_via(host, record, None).await + } + + /// [`Ops::dns_lookup`], asking `server` instead of the system's DNS servers + /// when one is given. + pub async fn dns_lookup_via( + &self, + host: &str, + record: Option, + server: Option, ) -> Result> { let record = record.map(|r| r.trim().to_uppercase()); if record.as_deref().is_none() || matches!(record.as_deref(), Some("ALL" | "ANY")) { - return crate::dns::lookup_all_records_timeout(host, self.cfg.dns_timeout_ms).await; + return crate::dns::lookup_all_records_via(host, self.cfg.dns_timeout_ms, server).await; } let record = record.unwrap_or_else(|| "A".to_string()); @@ -78,7 +89,7 @@ impl Ops { ))); }; - crate::dns::lookup_record_timeout(host, parsed, self.cfg.dns_timeout_ms).await + crate::dns::lookup_record_via(host, parsed, self.cfg.dns_timeout_ms, server).await } } diff --git a/crates/netscli-core/src/ping.rs b/crates/netscli-core/src/ping.rs index 41759342..f3c18ec1 100644 --- a/crates/netscli-core/src/ping.rs +++ b/crates/netscli-core/src/ping.rs @@ -78,6 +78,7 @@ pub struct PingScanner { impl PingScanner { pub fn new(concurrency: usize) -> Self { + crate::common::raise_open_file_limit(); let concurrency = concurrency.clamp(1, crate::MAX_CONCURRENCY); Self { semaphore: Arc::new(Semaphore::new(concurrency)), diff --git a/crates/netscli-core/src/scan/tcp.rs b/crates/netscli-core/src/scan/tcp.rs index c4af6c1a..a31fb190 100644 --- a/crates/netscli-core/src/scan/tcp.rs +++ b/crates/netscli-core/src/scan/tcp.rs @@ -36,6 +36,7 @@ pub struct PortScanProgress { impl PortScanner { pub fn new(concurrency: usize) -> Self { + crate::common::raise_open_file_limit(); let concurrency = concurrency.clamp(1, crate::MAX_CONCURRENCY); Self { semaphore: Arc::new(Semaphore::new(concurrency)), diff --git a/crates/netscli-core/src/scan/udp.rs b/crates/netscli-core/src/scan/udp.rs index a0d309f0..b52168d3 100644 --- a/crates/netscli-core/src/scan/udp.rs +++ b/crates/netscli-core/src/scan/udp.rs @@ -59,6 +59,7 @@ pub struct UdpScanner { impl UdpScanner { pub fn new(concurrency: usize) -> Self { + crate::common::raise_open_file_limit(); let concurrency = concurrency.clamp(1, crate::MAX_CONCURRENCY); Self { semaphore: Arc::new(Semaphore::new(concurrency)), diff --git a/crates/netscli-mcp/src/server/operations.rs b/crates/netscli-mcp/src/server/operations.rs index 0b6f3ac1..e750ea21 100644 --- a/crates/netscli-mcp/src/server/operations.rs +++ b/crates/netscli-mcp/src/server/operations.rs @@ -4,7 +4,7 @@ use super::schemas::{ clamp_concurrency, clamp_timeout_ms, normalize_ports, validate_subnet, DiscoverParams, DnsParams, PingHostParams, ScanParams, SweepParams, }; -use super::targets::ensure_host_allowed; +use super::targets::{ensure_host_allowed, ensure_ip_allowed}; // NOTE: Hostname/IP resolution is shared in netscli-core (`netscli_core::resolve_host_ip`). @@ -234,8 +234,21 @@ pub(super) async fn op_dns_lookup( ))); } } + // A named server is a destination the client chose, so it is held to + // the same target policy as a scan: local unless the operator allowed + // public targets. + let server = match p.server.as_deref() { + Some(raw) => { + let ip: std::net::IpAddr = raw.trim().parse().map_err(|_| { + RpcError::InvalidParams(format!("server must be an IP address: {raw}")) + })?; + ensure_ip_allowed(ip, raw)?; + Some(ip) + } + None => None, + }; let ops = netscli_core::Ops::default(); - ops.dns_lookup(&p.host, p.record_type) + ops.dns_lookup_via(&p.host, p.record_type, server) .await .map_err(|e| RpcError::ToolError(e.to_string())) } diff --git a/crates/netscli-mcp/src/server/schemas.rs b/crates/netscli-mcp/src/server/schemas.rs index 62984544..69afc99f 100644 --- a/crates/netscli-mcp/src/server/schemas.rs +++ b/crates/netscli-mcp/src/server/schemas.rs @@ -124,6 +124,8 @@ pub(super) struct DnsParams { pub(super) host: String, #[serde(rename = "type")] pub(super) record_type: Option, + /// A DNS server to ask instead of the system's, as an IP address. + pub(super) server: Option, } #[derive(Deserialize)] diff --git a/crates/netscli-mcp/src/server/tools.rs b/crates/netscli-mcp/src/server/tools.rs index bb5b0466..302d90a7 100644 --- a/crates/netscli-mcp/src/server/tools.rs +++ b/crates/netscli-mcp/src/server/tools.rs @@ -77,6 +77,10 @@ pub fn tools_list() -> serde_json::Value { "type": "string", "description": "Record type. Omit, or pass ALL, for every type.", "enum": ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "SRV", "PTR", "SOA", "CAA", "ALL", "ANY"] + }, + "server": { + "type": "string", + "description": "IP address of a DNS server to ask instead of the system's own. Held to the same target policy as a scan." } }, "required": ["host"] diff --git a/packaging/README.md b/packaging/README.md index 022104f8..d641c62b 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -4,7 +4,8 @@ Manifest templates, generated package metadata, and submission notes for getting NetsCLI into the major OS package managers. Most live package-manager updates are automated from -`.github/workflows/publish.yml` after a GitHub release is published. The +`.github/workflows/publish.yml`, which `publish-release.yml` starts once a +release's files are all in place and it has been published. The files in this directory are reference templates/snapshots and should stay accurate enough to review. The publish jobs download each release asset, re-hash it, and check the result against the uploaded `.sha256` sidecar @@ -35,7 +36,7 @@ trusting GitHub's binary storage: cosign verify-blob \ --certificate netscli-linux-x86_64.pem \ --signature netscli-linux-x86_64.sig \ - --certificate-identity-regexp 'https://github.com/fstubner/netscli/.github/workflows/release\.yml@.*' \ + --certificate-identity-regexp '^https://github\.com/fstubner/netscli/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ netscli-linux-x86_64 ``` @@ -102,9 +103,12 @@ System Settings, under Privacy & Security. ## Release-day checklist -1. Publish the GitHub release for `vX.Y.Z`. -2. Confirm `release.yml` uploads the expected CLI binaries, GUI - installers, `.sha256` sidecars, and sigstore `.sig`/`.pem` files. +1. Run `gh workflow run publish-release.yml -f tag=vX.Y.Z` from `main` + (the steps before it are in `docs/RELEASE.md`). It builds into the + draft, checks every file, and only then publishes. +2. Confirm the run's asset check passed, so the CLI binaries, GUI + installers, `.sha256` sidecars and sigstore `.sig`/`.pem` files are all + on the release. 3. Confirm `publish.yml` completes. After a transient registry failure, run just that registry again with `gh workflow run publish.yml -f tag=vX.Y.Z -f only=`. Re-running diff --git a/site/public/assets/microsoft-store-badge-dark.svg b/site/public/assets/microsoft-store-badge-dark.svg new file mode 100644 index 00000000..f3953abc --- /dev/null +++ b/site/public/assets/microsoft-store-badge-dark.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/public/assets/microsoft-store-badge-light.svg b/site/public/assets/microsoft-store-badge-light.svg new file mode 100644 index 00000000..16f4593a --- /dev/null +++ b/site/public/assets/microsoft-store-badge-light.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/src/components/Install.astro b/site/src/components/Install.astro index de7ff600..d510b903 100644 --- a/site/src/components/Install.astro +++ b/site/src/components/Install.astro @@ -85,6 +85,18 @@ const groups = [
{e.command ? (
{e.command}
+ ) : e.storeHref ? ( + /* Microsoft's badge files, unmodified, served from + this site so a visitor's browser does not call + Microsoft. Two images, one per theme: the light + badge on the dark page and the dark one on the + light page, each hidden in the other theme. */ + ) : (
@@ -296,6 +308,11 @@ html[data-theme='light'] #install .reco{ * in, while the package-manager rows above it check a hash. The green was pointing at the * wrong option. It reads as one route among several now, and keeps the * accent for hover and focus so it is still obviously a control. */ +.store-badge{display:inline-block;line-height:0;border-radius:4px} +.store-badge img{height:40px;width:auto} +.store-badge:focus-visible{outline:2px solid var(--ui-accent);outline-offset:2px} +html[data-theme='light'] .store-badge-for-dark, +html:not([data-theme='light']) .store-badge-for-light{display:none} a.install-download{ display:inline-flex; align-items:center; diff --git a/site/src/content/docs/docs/cli.md b/site/src/content/docs/docs/cli.md index 93faafa1..093d5209 100644 --- a/site/src/content/docs/docs/cli.md +++ b/site/src/content/docs/docs/cli.md @@ -192,7 +192,7 @@ The CLI exposes shared network operations plus command-line maintenance workflow | `sweep` | Discover hosts and scan selected ports across them. | | `ping` | Measure reachability and packet loss. | | `trace` | Show route hops to a host. | -| `dns` | Query DNS records. | +| `dns` | Query DNS records. `--server ` asks that DNS server instead of the system's. | | `reverse` | Reverse lookup an IP address. | | `mdns` | Discover local mDNS/DNS-SD service announcements. | | `interfaces` | List local network interfaces. | diff --git a/site/src/content/docs/docs/desktop.md b/site/src/content/docs/docs/desktop.md index 2dff0a7b..4bc63b47 100644 --- a/site/src/content/docs/docs/desktop.md +++ b/site/src/content/docs/docs/desktop.md @@ -34,7 +34,7 @@ Use the desktop app when: - You want to export JSON, CSV, or a reusable NetsCLI result bundle. - You prefer a desktop interface but still want to see the equivalent CLI command. -Use the CLI instead when you need setup, doctor, shell completions, manpages, service management, or scripts that run unattended. +Use the CLI instead when you need setup, doctor, shell completions, manpages, or scripts that run unattended. ## Shell layout diff --git a/site/src/content/docs/docs/install.md b/site/src/content/docs/docs/install.md index 245a6484..5329f586 100644 --- a/site/src/content/docs/docs/install.md +++ b/site/src/content/docs/docs/install.md @@ -35,6 +35,9 @@ The desktop app is distributed separately: winget install netscli-gui ``` +It is also in the [Microsoft Store](https://apps.microsoft.com/detail/xpfg556rr6b76z), +which installs the same signed `.msi`. + If a short name ever matches more than one package, use the full identifiers, `fstubner.netscli` and `fstubner.netscli.gui`. @@ -261,12 +264,12 @@ and the signature is tied to the exact run that built it. Each of those assets s cosign verify-blob \ --signature netscli-linux-x86_64.sig \ --certificate netscli-linux-x86_64.pem \ - --certificate-identity-regexp 'https://github.com/fstubner/netscli/.github/workflows/release\.yml@.*' \ + --certificate-identity-regexp '^https://github\.com/fstubner/netscli/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ netscli-linux-x86_64 ``` -Substitute the asset name you downloaded. The same command works for the desktop `.msi`, `.dmg`, `.deb` and `.AppImage`. It needs the [cosign +Substitute the asset name you downloaded. The same command works for the desktop `.msi`, `.dmg`, `.deb` and `.AppImage`. The identity is pinned to the release workflow on `main` (or a release tag, for releases before 0.3.1), so a signature made by that file on any other branch does not pass. The `.mcpb` bundles are signed by the publishing workflow instead, so for those replace `release\.yml` with `publish\.yml`. It needs the [cosign CLI](https://docs.sigstore.dev/cosign/system_config/installation/). A pass confirms the asset was built and signed by this repository's release workflow and has not been altered since. diff --git a/site/src/content/docs/docs/interface-coverage.md b/site/src/content/docs/docs/interface-coverage.md index 921da621..9ac18231 100644 --- a/site/src/content/docs/docs/interface-coverage.md +++ b/site/src/content/docs/docs/interface-coverage.md @@ -39,7 +39,6 @@ So a dash in the MCP column of the table below does not mean you need another in | Result bundles | ✓ | – | – | – | | First-run setup | – | ✓ | – | – | | Diagnostics | – | ✓ | – | – | -| MCP service management | – | ✓ | – | – | | Completions and man page | – | ✓ | – | – | ✓ available here · – not offered here @@ -75,7 +74,7 @@ So a dash in the MCP column of the table below does not mean you need another in Interactive network work, with tabs, filtering, row selection, a details pane, history, exports, and local status indicators. It exposes the shared operations where a table or a details pane earns its place. -Shell maintenance (setup, doctor, completions, man pages, MCP service management) stays in the CLI, because none of it benefits from a window. +Shell maintenance (setup, doctor, completions, man pages) stays in the CLI, because none of it benefits from a window. ## CLI (`netscli `) @@ -86,8 +85,8 @@ prompt: ones that return a list. - `netscli setup` for the first-run wizard, `netscli doctor` for a headless capability report. -- `netscli serve` to start the MCP server, and `netscli mcp-service` to - manage it as a system service where that is supported. +- `netscli serve` to start the MCP server. An MCP client starts it for you, + so it does not run as a system service. - `netscli completions` and `netscli man`. ## TUI (`netscli` with no command) diff --git a/site/src/content/docs/docs/mcp.md b/site/src/content/docs/docs/mcp.md index 43fd0ab9..2d63af6e 100644 --- a/site/src/content/docs/docs/mcp.md +++ b/site/src/content/docs/docs/mcp.md @@ -81,7 +81,7 @@ there is nothing to look at until a client connects. | `discover_network` | Discover reachable hosts on a subnet. | | `scan_ports` | Scan TCP ports on a host, or UDP services with `udp: true`. Returns open ports only unless `include_closed` is true. | | `ping_host` | Check reachability and latency. | -| `dns_lookup` | Query DNS records. | +| `dns_lookup` | Query DNS records. `server` asks a DNS server you name instead of the system's, held to the same target policy as a scan. | | `get_arp_table` | Read the local ARP neighbor cache. | | `inspect_host` | Build a host profile with reachability, DNS, MAC address and maker, an OS hint, and ports. | | `sweep_network` | Discover hosts and scan selected ports. | diff --git a/site/src/content/docs/docs/operations.md b/site/src/content/docs/docs/operations.md index 1de188c5..f1c118ef 100644 --- a/site/src/content/docs/docs/operations.md +++ b/site/src/content/docs/docs/operations.md @@ -170,7 +170,11 @@ 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. +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. To ask a different server, name it: + +```bash +netscli dns netscli.com --record MX --server 192.168.1.1 +``` Use `reverse` when you already have an IP address: diff --git a/site/src/content/docs/docs/result-model.md b/site/src/content/docs/docs/result-model.md index e3240250..8ec16254 100644 --- a/site/src/content/docs/docs/result-model.md +++ b/site/src/content/docs/docs/result-model.md @@ -98,7 +98,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` | 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. | +| `resolver_source` | `system` for the DNS servers your computer is set up to use, or `server` for one named with `--server`. 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. diff --git a/site/src/data/site-content/install.ts b/site/src/data/site-content/install.ts index 297594ba..746df3f9 100644 --- a/site/src/data/site-content/install.ts +++ b/site/src/data/site-content/install.ts @@ -9,6 +9,9 @@ export const installCopy: SectionCopy = { const RELEASE_DOWNLOAD = 'https://github.com/fstubner/netscli/releases/latest/download'; +/** The desktop app's Microsoft Store listing (product XPFG556RR6B76Z). */ +export const MICROSOFT_STORE_URL = 'https://apps.microsoft.com/detail/xpfg556rr6b76z'; + /** Shown on the direct .dmg rows and on the Homebrew cask. * * The cask too: Homebrew quarantines what a cask downloads, and 5.0 @@ -53,6 +56,10 @@ export const installByPlatform: Record = { label: 'Winget', command: 'winget install netscli-gui', }, + { + label: 'Microsoft Store', + storeHref: MICROSOFT_STORE_URL, + }, { // Two lines for the same reason as the CLI entry above. label: 'Scoop', diff --git a/site/src/data/site-content/privacy.ts b/site/src/data/site-content/privacy.ts index 822fda28..1298c0c9 100644 --- a/site/src/data/site-content/privacy.ts +++ b/site/src/data/site-content/privacy.ts @@ -25,7 +25,7 @@ export const privacyCopy: PrivacyCopy = {

What reaches the internet

    -
  • Scans go where you point them. 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.
  • +
  • Scans go where you point them. 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, or to one you name yourself.
  • Version 0.3.4 and earlier also asked Cloudflare's public DNS. 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 NETSCLI_DNS_FALLBACK=0 turns this off in those versions. From version 0.3.5 on, NetsCLI never does this.
  • The desktop app checks for new releases. 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.
  • The command line, terminal UI and MCP server make no connections of their own beyond the operations you run.
  • diff --git a/site/src/data/site-content/types.ts b/site/src/data/site-content/types.ts index 6a4ed6aa..1b302849 100644 --- a/site/src/data/site-content/types.ts +++ b/site/src/data/site-content/types.ts @@ -166,6 +166,9 @@ export interface InstallEntry { * than a copyable command, for the installer artifacts that have no * package-manager equivalent (.msi / .dmg / .deb / .AppImage). */ href?: string; + /** Store listing URL. Entries with a `storeHref` render Microsoft's own + * "Get it from Microsoft" badge, unmodified, linking to the listing. */ + storeHref?: string; /** Optional small hint, rendered under the label. Used to warn about * the unsigned installers before someone hits a Gatekeeper or * SmartScreen dialog with no explanation. */ diff --git a/testdata/csv-escape.json b/testdata/csv-escape.json index 3ab5ab8f..40b14697 100644 --- a/testdata/csv-escape.json +++ b/testdata/csv-escape.json @@ -96,6 +96,22 @@ { "in": "", "out": "" + }, + { + "in": "admin\u202egnp.exe", + "out": "admin.gnp.exe" + }, + { + "in": "pay\u200bpal", + "out": "pay.pal" + }, + { + "in": "\ufeffname", + "out": ".name" + }, + { + "in": "a\u2066b\u2069c", + "out": "a.b.c" } ] -} \ No newline at end of file +}