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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ keeps a bare heading and collects entries.

### Added

- **DNS lookups can ask a server you name.** `netscli dns --server <ip>`,
`/dns ... --server <ip>` 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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ winget install netscli-gui # desktop app
cargo install netscli
```

On Windows the desktop app is also in the Microsoft Store:

<a href="https://apps.microsoft.com/detail/xpfg556rr6b76z"><img src="site/public/assets/microsoft-store-badge-dark.svg" alt="Get it from Microsoft" height="40" /></a>

Desktop installers for Windows, macOS and Linux are attached to every
[release](https://github.com/fstubner/netscli/releases/latest).

Expand Down
16 changes: 10 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions apps/netscli-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ pub enum Commands {
#[arg(long)]
record: Option<String>,

/// Ask this DNS server (an IP address) instead of the ones the system uses
#[arg(long, value_name = "IP")]
server: Option<std::net::IpAddr>,

#[command(flatten)]
format: ListOutput,
},
Expand Down
3 changes: 2 additions & 1 deletion apps/netscli-cli/src/cli_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
Expand Down
3 changes: 2 additions & 1 deletion apps/netscli-cli/src/cli_dispatch/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ pub(super) async fn run_lookup(
ctx: CommandContext<'_>,
host: &str,
record: &Option<String>,
server: Option<std::net::IpAddr>,
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)?;
Expand Down
3 changes: 2 additions & 1 deletion apps/netscli-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ pub async fn run_dns(
db: Option<&Database>,
host: &str,
record: Option<String>,
server: Option<std::net::IpAddr>,
) -> Result<Vec<netscli_core::dns::DnsRecord>> {
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;
}
Expand Down
4 changes: 2 additions & 2 deletions apps/netscli-cli/src/tui/command_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub(super) const COMMAND_DEFS: &[CommandDef] = &[
CommandDef {
cmd: "/dns",
desc: "DNS lookup",
args: "<host> [--record <type>|ALL]",
args: "<host> [--record <type>|ALL] [--server <ip>]",
},
CommandDef {
cmd: "/reverse",
Expand Down Expand Up @@ -130,7 +130,7 @@ pub(super) const COMMAND_DEFS: &[CommandDef] = &[
CommandDef {
cmd: "/dns",
desc: "DNS lookup",
args: "<host> [--record <type>|ALL]",
args: "<host> [--record <type>|ALL] [--server <ip>]",
},
CommandDef {
cmd: "/reverse",
Expand Down
29 changes: 26 additions & 3 deletions apps/netscli-cli/src/tui/events/dns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub(super) async fn handle_lookup(
// or a positional second token all work regardless of order.
let mut host: Option<String> = None;
let mut record: Option<String> = None;
let mut server: Option<std::net::IpAddr> = None;
let mut i = 1;
let mut parse_err: Option<String> = None;
while i < parts.len() {
Expand All @@ -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::<std::net::IpAddr>) {
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;
Expand All @@ -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 <host> [--record <type>|ALL]"
"{err}. Usage: /dns <host> [--record <type>|ALL] [--server <ip>]"
)));
} 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,
Expand All @@ -65,7 +88,7 @@ pub(super) async fn handle_lookup(
}
} else {
out.push(Formatter::format_error(
"Usage: /dns <host> [--record <type>|ALL]",
"Usage: /dns <host> [--record <type>|ALL] [--server <ip>]",
));
}
out
Expand Down
12 changes: 11 additions & 1 deletion apps/netscli-gui/src-tauri/src/commands/operations/lookup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,22 @@ pub(crate) async fn dns_lookup(
op_id: Option<String>,
host: String,
record: Option<String>,
server: Option<String>,
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::<std::net::IpAddr>()
.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())
Expand Down
106 changes: 70 additions & 36 deletions apps/netscli-gui/src/components/shell/McpServerSection.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | null>(null);

Expand All @@ -66,18 +69,20 @@ function CopyButton({ value, label }: { value: string; label: string }) {

return (
<button
aria-label={copied ? 'Copied' : label}
className={copied ? 'copied' : undefined}
data-copied={copied ? 'true' : undefined}
data-testid="settings-mcp-copy"
data-testid={testId}
type="button"
onClick={() => void handleClick()}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? 'Copied' : label}
</button>
);
}

/** 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<CliDetection | null>(null);

Expand Down Expand Up @@ -106,46 +111,75 @@ export function McpServerSection() {
</span>

{detection === null ? (
<div className="settings-row-copy">
<small>Looking for the netscli command-line tool…</small>
<div className="settings-folder-row">
<div className="settings-row-copy">
<span>Command-Line Tool</span>
<small>Looking for netscli…</small>
</div>
</div>
) : detection.path ? (
<>
<div className="settings-row-copy">
<span>Ready to connect</span>
<small>
Paste this into your MCP client&apos;s config to let an agent run network
scans. Found netscli {detection.version} at {detection.path}
</small>
<div className="settings-folder-row">
<div className="settings-row-copy">
<span>Command-Line Tool</span>
<small title={detection.path}>
netscli {detection.version} at {detection.path}
</small>
</div>
</div>
<div className="settings-mcp-config">
<pre data-testid="settings-mcp-config">{clientConfig(detection.path)}</pre>
<CopyButton label="Copy MCP config" value={clientConfig(detection.path)} />
<div className="settings-folder-row">
<div className="settings-row-copy">
<span>Agent Config</span>
<small>Paste into your MCP client&apos;s config to let an agent run scans.</small>
</div>
<div className="settings-folder-actions">
<CopyButton
label="Copy Config"
testId="settings-mcp-copy"
value={clientConfig(detection.path)}
/>
</div>
</div>
<details className="settings-mcp-details">
<summary>Show config</summary>
<pre data-testid="settings-mcp-config">{clientConfig(detection.path)}</pre>
</details>
</>
) : (
<>
<div className="settings-row-copy">
<span>Command-line tool not found</span>
<small>
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.
</small>
<div className="settings-folder-row">
<div className="settings-row-copy">
<span>Command-Line Tool</span>
<small>Not installed. The MCP server comes with it.</small>
</div>
<div className="settings-folder-actions">
<button
type="button"
onClick={() =>
openAllowedExternalUrl(INSTALL_DOCS).catch((error: unknown) =>
console.error('Opening the link failed', error),
)
}
>
Install Options
</button>
</div>
</div>
<div className="settings-mcp-config">
<pre data-testid="settings-mcp-install">{INSTALL_COMMAND[detection.os]}</pre>
<CopyButton label="Copy install command" value={INSTALL_COMMAND[detection.os]} />
<div className="settings-folder-row">
<div className="settings-row-copy">
<span>Install Command</span>
<small className="settings-mcp-command" data-testid="settings-mcp-install">
{INSTALL_COMMAND[detection.os]}
</small>
</div>
<div className="settings-folder-actions">
<CopyButton
label="Copy"
testId="settings-mcp-copy"
value={INSTALL_COMMAND[detection.os]}
/>
</div>
</div>
<a
className="settings-mcp-link"
href={INSTALL_DOCS}
rel="noreferrer"
target="_blank"
>
All install options
<ExternalLink size={11} />
</a>
</>
)}
</section>
Expand Down
3 changes: 2 additions & 1 deletion apps/netscli-gui/src/services/netscli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ export async function dnsLookup(
host: string,
record?: string,
op_id?: string,
server?: string,
): Promise<DnsRecord[]> {
return invoke<DnsRecord[]>('dns_lookup', { opId: op_id, host, record });
return invoke<DnsRecord[]>('dns_lookup', { opId: op_id, host, record, server: server || undefined });
}

export async function discoverMdns(
Expand Down
Loading
Loading