# magi

URL: https://sola.ysz.life/projects/magi

> Async port scanning, built to be correct

- Type: Personal project
- Period: Jun 2026 - Present
- Live: https://magi.ysz.life
- Source: https://github.com/lyfe691/magi
- Stack: Rust, tokio, Async, CLI, TCP/UDP, Networking, cargo-dist, GitHub Actions

magi is a command-line port scanner for TCP and UDP, written in Rust on tokio, with prebuilt releases for Linux, macOS and Windows and a small site at magi.ysz.life. The scanning core is a library with its own integration tests, and the command-line tool is a thin layer over it.

## A TCP and UDP port scanner in Rust

magi is a command-line port scanner I wrote in Rust. It does connect scanning: every probe is an ordinary TCP connection or UDP datagram made through the operating system's sockets, so it runs as a normal user on Linux, macOS and Windows. It reports a port only in a state it actually observed, and says so when it couldn't test one.

![A terminal: magi 45.33.32.156 --banner is typed and run, a counter climbs through 1024 probed ports, and the result lists 22/tcp ssh with the banner SSH-2.0-OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13 and 80/tcp http](https://sola.ysz.life/projects/magi/scan.webp)
*A real scan of scanme.nmap.org's first 1,024 ports.*
[Video](https://sola.ysz.life/projects/magi/scan.mp4)

### Scanning a host

You give magi a target and the ports to probe. Open ports are listed under their host with the usual service name, and while a longer scan runs, a counter shows how many ports have been probed and how many are open. The run above is against 45.33.32.156, scanme.nmap.org, a host the Nmap project keeps up for testing scanners, and it took about a second.

The target can take three forms:

| Target           | Example           | What magi does                                 |
| ---------------- | ----------------- | ---------------------------------------------- |
| A hostname       | `scanme.nmap.org` | Resolves it and scans every address it returns |
| An IP address    | `45.33.32.156`    | Scans that address, with no DNS lookup         |
| A subnet in CIDR | `192.168.1.0/24`  | Scans every host in it, up to a /16            |

The /16 limit is there so a mistyped prefix can't turn into millions of probes. Everything else is a flag:

| Flag                  | What it sets                                   | Default  |
| --------------------- | ---------------------------------------------- | -------- |
| `-p`, `--ports`       | Ports to probe, as a list, a range or a mix    | `1-1024` |
| `-c`, `--concurrency` | Probes in flight at once                       | 512      |
| `-t`, `--timeout`     | How long each probe waits for an answer, in ms | 1000     |
| `-r`, `--rate`        | Most probes started per second                 | No cap   |
| `-b`, `--banner`      | Read the first line each open service sends    | Off      |
| `-u`, `--udp`         | Probe UDP instead of TCP                       | TCP      |
| `-j`, `--json`        | Print the results as JSON                      | A table  |

`examples.sh`

```bash
magi 192.168.1.0/24 --ports 22,80,443      # a whole subnet, grouped by host
magi 10.0.0.5 --ports 1-65535 -c 1024      # every port, 1024 probes in flight
magi 192.168.1.1 --ports 53,123,161 --udp  # UDP instead of TCP
magi scanme.nmap.org -p 22,80 --json | jq  # JSON on stdout, progress on stderr
```

### Output for scripts

Everything meant for a person goes to stderr: the line that opens the scan, the counter and any warning. The counter only appears when stderr is a terminal. With `--json`, stdout carries a JSON array of the open ports with their address, protocol, state and banner, so the results pipe straight into jq or another program.

![A terminal: magi 45.33.32.156 -p 22,80,443 --banner --json piped into jq prints the scan line, then a coloured JSON array with two objects, port 22 with its SSH banner and port 80 with a null banner](https://sola.ysz.life/projects/magi/json.webp)
*JSON on stdout, piped into jq.*
[Video](https://sola.ysz.life/projects/magi/json.mp4)

### UDP

`--udp` probes UDP instead. UDP has no handshake, so magi sends an empty datagram and reads the verdict from how the host reacts:

| The host…                             | magi reports                    |
| ------------------------------------- | ------------------------------- |
| Replies                               | `open`                          |
| Answers with an ICMP port-unreachable | Closed, and leaves the port out |
| Stays silent until the timeout        | `open\|filtered`                |

Silence is ambiguous: an open service that ignores an empty datagram and a firewall that drops it look the same from outside, so magi doesn't pick one. In this run 53 and 161 came back closed and are left out, while 123 stayed silent for the whole second.

![A terminal: magi 45.33.32.156 -p 53,123,161 --udp runs, and after a second the result lists one port, 123/udp, marked open|filtered](https://sola.ysz.life/projects/magi/udp.webp)
*A UDP scan of three ports.*
[Video](https://sola.ysz.life/projects/magi/udp.mp4)

### Ports it couldn't test

A TCP probe can end four ways, and only two of them say anything about the port:

| The connection…                                       | magi counts the port as | Why                                       |
| ----------------------------------------------------- | ----------------------- | ----------------------------------------- |
| Completes                                             | Open                    | Something is listening                    |
| Is refused, or times out                              | Not open                | The host answered no, or nothing answered |
| Fails because the process ran out of file descriptors | Untested                | The probe never ran                       |
| Fails for any other reason                            | Untested                | Nothing reached the port                  |

Untested ports aren't folded into the closed count. The scan ends with a warning that says how many there were and why, and when the cause is running out of file descriptors it says to lower `--concurrency` or raise the open-file limit. In the clip below, scanme.nmap.org resolves to an IPv4 and an IPv6 address. On a network without IPv6 routing, the connections to the IPv6 address fail with "network unreachable" before anything is sent, so its three ports are reported as untested.

![A terminal: magi scanme.nmap.org -p 22,80,443 reports two hosts, lists 22/tcp ssh and 80/tcp http on 45.33.32.156, and ends with warning: 3 port(s) could not be tested (network or host unreachable); results may be incomplete.](https://sola.ysz.life/projects/magi/untestable.webp)
*The IPv6 half of the scan, reported as untested.*
[Video](https://sola.ysz.life/projects/magi/untestable.mp4)

### Installing it

Each release installs with one command that puts `magi` on your `PATH`:

| Platform | Builds        | Installer         |
| -------- | ------------- | ----------------- |
| Linux    | x86-64, arm64 | Shell script      |
| macOS    | x86-64, arm64 | Shell script      |
| Windows  | x86-64        | PowerShell script |

[magi.ysz.life](https://magi.ysz.life) leads with the command for the visitor's platform, with a menu to switch. With a stable Rust toolchain it also builds from a checkout, with `cargo install --path .`

![magi.ysz.life loading: the headline A port scanner built to be correct comes into focus over dotted waves, the waves bend as the pointer passes, and the install command switches from PowerShell to the curl installer when Linux / macOS is picked from its menu](https://sola.ysz.life/projects/magi/site.webp)
*The site switching the install command to Linux and macOS.*
[Video](https://sola.ysz.life/projects/magi/site.mp4)

### The site

The site is a single page with the install command, a terminal window with a scan in it, the features and a FAQ. The dots behind the headline bend around the pointer, and each feature card draws its figure in ASCII characters that fill in row by row as the card scrolls into view and keep changing after. It comes in a light and a dark theme.

![The magi site in the dark theme gliding down from the hero to the feature grid, where a lightning bolt and an eye drawn in orange ASCII characters fill in row by row above the cards Fast and async and TCP and UDP](https://sola.ysz.life/projects/magi/site-features.webp)
*The feature cards in the dark theme.*
[Video](https://sola.ysz.life/projects/magi/site-features.mp4)

### How it's built

magi is a small library under a thin binary. `main.rs` parses the arguments, expands the target and prints the results, and `scanner.rs` holds the scan itself, banner grabbing, UDP and the service names. It runs on tokio, with clap for the flags and serde for the JSON. GitHub Actions runs rustfmt, clippy and the tests on every push, and cargo-dist builds the release archives and the install scripts. The site is Next.js, translated with next-intl.

#### A fixed number of probes in flight

A /24 across ports 1 to 1024 is about 260,000 probes, and spawning a task for each up front would hold all of them in memory at once. The scan loop takes a semaphore permit before it spawns each probe instead, so once `--concurrency` probes are in flight it waits for one to finish. The permit moves into the task and is released when the probe ends, and the loop collects finished probes as it goes, so the number of live tasks stays bounded however large the scan is. Abridged from `scan()`:

`src/scanner.rs`

```rust
let permits = config.concurrency.clamp(1, MAX_CONCURRENCY);
let limiter = Arc::new(Semaphore::new(permits));
let mut tasks = JoinSet::new();
let mut summary = ScanSummary::default();
for &target in targets {
    for &port in ports {
        let permit = Arc::clone(&limiter)
            .acquire_owned()
            .await
            .expect("semaphore stays open for the duration of the scan");
        tasks.spawn(async move {
            let _permit = permit;
            probe(target, port, timeout_dur, grab_banner, protocol).await
        });
        while let Some(joined) = tasks.try_join_next() {
            collect(&mut summary, &mut progress, joined);
        }
    }
}
```

The optional rate cap is a tokio interval ticked before each spawn. The semaphore limits how many probes run at once and the interval limits how many start per second, and missed ticks are delayed, so a slow stretch never releases a burst of probes to catch up.

#### Telling closed from untested

The table under "Ports it couldn't test" is one function. It sorts the error from a failed connect, and anything that isn't a refusal or a timeout becomes one of two untested kinds:

`src/scanner.rs`

```rust
fn classify_connect_error(e: &io::Error) -> ProbeOutcome {
    match e.kind() {
        ErrorKind::ConnectionRefused | ErrorKind::TimedOut => ProbeOutcome::NotOpen,
        _ if is_fd_exhaustion(e) => ProbeOutcome::Untestable(Untestable::OutOfDescriptors),
        _ => ProbeOutcome::Untestable(Untestable::Other),
    }
}
```

Because the scanner is a library, the integration tests call the real `scan()` against sockets on loopback. One of them lowers the process's open-file limit to 128, scans 400 local ports with 300 probes in flight over TCP and then UDP, and checks that both results count untested ports and name the descriptor limit as the cause.

#### UDP without raw sockets

Seeing an ICMP port-unreachable normally takes a raw socket, and with it root. magi connects its UDP socket to the target before sending, which filters replies to that one peer and makes the operating system report the ICMP message as a `ConnectionRefused` error on the next receive. The closed verdict then depends on the OS passing those errors back, which Linux does reliably. macOS and Windows rate-limit ICMP, so on them some closed ports read as `open|filtered`.

#### Banners from untrusted hosts

With `--banner`, magi reads up to 512 bytes from each open port and keeps the first line. That text comes from a host you don't control and ends up in your terminal, so every control character is removed before it's printed. A raw escape sequence in a banner could otherwise clear the screen, retitle the window or overwrite earlier output, and a unit test passes an ANSI clear-screen sequence through to check that only printable text comes out.

![The magi site in the dark theme: the headline A port scanner built to be correct over orange dotted waves, the PowerShell install command with a copy button, and a terminal window with a scan of scanme.nmap.org](https://sola.ysz.life/projects/magi/site-dark.webp)
*The site in the dark theme.*

![The magi site on a phone in the dark theme: the headline, the install command and the terminal window with the scan](https://sola.ysz.life/projects/magi/site-dark-mobile.webp)
*The site on a phone.*
