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
54 changes: 25 additions & 29 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,43 +69,39 @@ to try an unreleased engine.
belongs to the app's code signature, so a build signed differently
(ad-hoc vs. a team certificate) is a new app to macOS and is asked
again.
- **ARP table needs a properly signed app.** On macOS 27 the kernel
hides the ARP table from the `sysctl(NET_RT_FLAGS, RTF_LLINFO)` dump
(it returns 0 bytes, with no error and nothing logged) unless the
process chain is trusted. Measured with a test app and the bundled
`ns` (October 2026):
- **The app reads the ARP table for the engine.** On macOS 27 the
kernel hides the ARP table from the `sysctl(NET_RT_FLAGS,
RTF_LLINFO)` dump (it returns 0 bytes, with no error and nothing
logged) unless the process chain is trusted. Measured with a test app
and the bundled `ns` (October 2026):
- The app's **own process** can always read the table, even when it
is ad-hoc signed.
- **Children of an ad-hoc signed app** (Xcode's "Sign to Run
Locally", which is what `CODE_SIGN_IDENTITY = "-"` gives) always
get an empty table. That holds for every child: `ns` with any
signature, Apple's own `/usr/sbin/arp`, children started through
`/bin/sh` or `/usr/bin/env`, and children that disclaim
responsibility. This is why the app shows no MAC addresses today.
responsibility. An Apple Development certificate (free personal
team) does not help either.
- **Children of a Developer ID signed, notarized app** that
LaunchServices started can read it. With Postman as the parent,
the unchanged bundled `ns -s … --resolve --json` reported 37 hosts,
every one with a MAC address and manufacturer, read directly and
through `/bin/sh`.
- A non-Apple binary between the responsible app and `ns` hides the
table again, even a Developer ID one (that is why `zig build run`
fails). Keep spawning `ns` directly from the app.
- The reader needs a reverse-DNS code identifier (`ns` keeps
`io.github.criseda.netscanner`; `ns`, `ns-<hash>` and the linker's
default all fail).
- Local Network permission is needed for scanning, but it does not
affect this: the ad-hoc app had it and still got an empty table.

So no engine change or workaround is needed: the Developer ID build
the Homebrew cask needs anyway should get MAC addresses as is. Still
to check once a certificate is available: that a Developer ID build
actually shows MACs (with and without notarization), . An Apple Development
certificate (free personal team) is **not** enough: a build signed
with one, launched from Finder, still got no MAC addresses (October
2026). If a Developer ID build still fails, fall back to
reading the table in the app's own process through a small C library
from the engine (`dump_neighbors` in `src/c/neighbors.c`), which
works even ad-hoc.
LaunchServices started can read it; a non-Apple binary between the
app and `ns` hides it again.

So discovery scans run `ns` with `--neighbor-table=parent` (engine
v1.7.0 and later; see "Neighbor table from the parent" in NetScanner's
`docs/README.md`). Each time `ns` needs the table it prints
`{"type":"neighbors_request","id":N}` and waits up to 2 seconds for
one line on stdin. `EngineRun` answers from the stdout handler (off
the main actor) with `NeighborTable.read()`, a pure Swift port of the
engine's `macos_dump` in `src/c/neighbors.c`: the raw table, every
entry `stale`, no filtering. Everything an entry means (which hosts
it proves, the confirming pings, the manufacturer lookup) stays in
the engine. If the read fails the reply carries an `error` and `ns`
reads the table itself. This works with any signature, so MAC
addresses, manufacturers and ARP-found quiet hosts show in ad-hoc
Debug builds too. Keep `ns` spawned directly by the app, keep its
stdin a pipe the app owns, and keep `ns` signed with a reverse-DNS
identifier (`io.github.criseda.netscanner`) as before.
- **Script sandboxing.** `ENABLE_USER_SCRIPT_SANDBOXING` is on. Build
phases must declare their inputs and outputs, and tools that write
temporary files next to outputs (codesign) fail inside it, which is
Expand Down
10 changes: 5 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ First version of the native macOS app:
finds only this Mac while the permission prompt is pending. Scans wait
for the answer, and if access is denied, Discovery explains how to
turn it on.
- Bundles NetScanner v1.6.0 as a universal binary.

Known issue: on macOS 27 the engine cannot read the ARP table unless
the app is Developer ID signed, so MAC addresses and manufacturers stay
empty in ad-hoc and Apple Development builds.
- Shows MAC addresses, manufacturers and quiet hosts found in the ARP
table however the app is signed: macOS 27 hides that table from the
engine but not from the app, so the app reads it and hands it to the
engine (`--neighbor-table=parent`).
- Bundles NetScanner v1.7.0 as a universal binary.
6 changes: 3 additions & 3 deletions Config/Version.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ CURRENT_PROJECT_VERSION = 1
// The bundled ns engine: its NetScanner release tag, and the SHA-256 of
// that release's macos-arm64.zip and macos-x86_64.zip. fetch-engine.sh
// refuses a download that does not match.
NETSCANNER_ENGINE_VERSION = v1.6.0
NETSCANNER_ENGINE_SHA256_ARM64 = a74a68164be864834c254f83ea80db47dd7b7e9e60e385bc3095804f8164c893
NETSCANNER_ENGINE_SHA256_X86_64 = 400e27a3a6d1230ae586b5831880e35336cdd63aec74dfca02448aa3c52d2a57
NETSCANNER_ENGINE_VERSION = v1.7.0
NETSCANNER_ENGINE_SHA256_ARM64 = b7209515de3bf364fb6b6d0b76ea17ddee21440e33ba0a537bb6b7b107d8e744
NETSCANNER_ENGINE_SHA256_X86_64 = 7b05effed2f091a50b6c0592b7589af6a9816ec9673242dc49bc0fcc9d5f27f6
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ public enum EngineEvent: Hashable, Sendable {
case portScanFinished(openPorts: [UInt16], elapsed: Duration)
/// An input error. The engine exits with status 1 right after.
case failure(message: String)
/// The engine waits for this app to send it the ARP table
/// (`--neighbor-table=parent`). ``ScanEngine`` answers it, so it
/// never reaches the scan's event stream.
case neighborsRequest(id: Int)
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public struct EngineEventDecoder: Sendable {
/// keeps one forgiving decode path instead of one type per event.
private struct RawEvent: Decodable {
let type: String
let id: Int?
let ip: String?
let source: String?
let hostname: String?
Expand All @@ -44,7 +45,7 @@ private struct RawEvent: Decodable {
let message: String?

enum CodingKeys: String, CodingKey {
case type, ip, source, hostname, mac, vendor, first, last, port, service, iana
case type, id, ip, source, hostname, mac, vendor, first, last, port, service, iana
case description, category, phase, done, total, hosts, message
case openPorts = "open_ports"
case elapsedMs = "elapsed_ms"
Expand Down Expand Up @@ -91,6 +92,10 @@ private struct RawEvent: Decodable {
case "error":
return .failure(message: message ?? String(localized: "The scan engine reported an error.", bundle: .module))

case "neighbors_request":
guard let id else { return nil }
return .neighborsRequest(id: id)

default:
return nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,16 @@ import Synchronization
/// lost and the exit status is known when deciding success or failure.
/// The pipe handlers and the termination handler run on Foundation's
/// background queues; all shared state sits behind one mutex.
///
/// Requests for the ARP table (`neighbors_request`) are answered on
/// stdin from the stdout handler, so never on the main actor; the engine
/// waits for the answer, so nothing else is held up meanwhile.
final class EngineRun: Sendable {
typealias Continuation = AsyncThrowingStream<[EngineEvent], any Error>.Continuation

private let process: Process
private let input = Pipe()
private let readNeighborTable: ScanEngine.NeighborTableReader
private let continuation: Continuation
private let state = Mutex(State())
private let decoder = EngineEventDecoder()
Expand All @@ -32,10 +38,14 @@ final class EngineRun: Sendable {
var finished = false
}

init(executableURL: URL, arguments: [String], continuation: Continuation) {
init(
executableURL: URL, arguments: [String],
readNeighborTable: @escaping ScanEngine.NeighborTableReader, continuation: Continuation
) {
process = Process()
process.executableURL = executableURL
process.arguments = arguments
self.readNeighborTable = readNeighborTable
self.continuation = continuation
}

Expand All @@ -44,7 +54,10 @@ final class EngineRun: Sendable {
let stderr = Pipe()
process.standardOutput = stdout
process.standardError = stderr
process.standardInput = FileHandle.nullDevice
process.standardInput = input
// Writing to an engine that already exited must fail with EPIPE,
// not kill the app with SIGPIPE.
_ = fcntl(input.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1)

stdout.fileHandleForReading.readabilityHandler = { [self] handle in
readStdout(handle)
Expand All @@ -61,6 +74,7 @@ final class EngineRun: Sendable {
} catch {
stdout.fileHandleForReading.readabilityHandler = nil
stderr.fileHandleForReading.readabilityHandler = nil
try? input.fileHandleForWriting.close()
state.withLock { $0.finished = true }
continuation.finish(
throwing: EngineError.launchFailed(reason: error.localizedDescription))
Expand All @@ -81,6 +95,8 @@ final class EngineRun: Sendable {
handle.readabilityHandler = nil
let lastLine = state.withLock { $0.splitter.finish() }
if let lastLine { deliver([lastLine]) }
// Requests come on stdout, so none can follow.
try? input.fileHandleForWriting.close()
update { $0.stdoutClosed = true }
return
}
Expand Down Expand Up @@ -111,6 +127,8 @@ final class EngineRun: Sendable {
switch decoder.decode(line) {
case .failure(let message)?:
state.withLock { $0.rejection = $0.rejection ?? message }
case .neighborsRequest(let id)?:
answerNeighborsRequest(id)
case let event?:
events.append(event)
case nil:
Expand All @@ -120,6 +138,15 @@ final class EngineRun: Sendable {
if !events.isEmpty { continuation.yield(events) }
}

/// Sends the ARP table, or why it could not be read. A failed write
/// means the engine is gone or stopped listening; it then reads the
/// table itself, so there is nothing to report.
private func answerNeighborsRequest(_ id: Int) {
let table = Result { () throws(NeighborTableError) in try readNeighborTable() }
let reply = NeighborsReply(id: id, table: table)
try? input.fileHandleForWriting.write(contentsOf: reply.line)
}

// MARK: - Completion

/// Applies a change, then finishes the stream if that was the last of
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

/// The app's answer to the engine's `neighbors_request`: one line on the
/// engine's stdin carrying the ARP table, or why it could not be read
/// (the engine then reads the table itself).
///
/// macOS keeps no reachability state in the table, so every entry is
/// sent as `stale`, which is what the engine expects there.
struct NeighborsReply: Encodable, Sendable {
private let type = "neighbors"
let id: Int
let entries: [Entry]?
let error: String?

struct Entry: Encodable, Sendable {
let ip: String
let mac: String
let state = "stale"
}

init(id: Int, table: Result<[NeighborEntry], NeighborTableError>) {
self.id = id
switch table {
case .success(let table):
entries = table.map { Entry(ip: $0.ip.description, mac: $0.macAddress) }
error = nil
case .failure(let failure):
entries = nil
error = failure.message
}
}

/// The reply as one newline-terminated JSON line.
var line: Data {
let encoder = JSONEncoder()
// Sorted keys make the line predictable; the engine does not care.
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
// Encoding plain strings and numbers cannot fail; an empty line
// would only make the engine read the table itself.
var line = (try? encoder.encode(self)) ?? Data()
line.append(UInt8(ascii: "\n"))
return line
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,23 @@ import Foundation
///
/// The engine is the single source of truth for scanning: this type only
/// launches it with `--json`, turns its output into typed events as they
/// arrive, and stops it when the consumer stops listening.
/// arrive, answers its requests for the ARP table (which macOS may hide
/// from it but not from the app), and stops it when the consumer stops
/// listening.
public struct ScanEngine: Sendable {
/// Reads the ARP table for the engine (`--neighbor-table=parent`).
public typealias NeighborTableReader = @Sendable () throws(NeighborTableError) -> [NeighborEntry]

public let executableURL: URL
let readNeighborTable: NeighborTableReader

public init(executableURL: URL) {
/// `readNeighborTable` answers the engine's requests for the ARP
/// table; it is replaceable so tests need no real network.
public init(
executableURL: URL, readNeighborTable: @escaping NeighborTableReader = NeighborTable.read
) {
self.executableURL = executableURL
self.readNeighborTable = readNeighborTable
}

/// Runs a scan and streams its events.
Expand All @@ -29,7 +40,7 @@ public struct ScanEngine: Sendable {
}
let run = EngineRun(
executableURL: executableURL, arguments: request.arguments,
continuation: continuation)
readNeighborTable: readNeighborTable, continuation: continuation)
continuation.onTermination = { termination in
if case .cancelled = termination { run.cancel() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ public enum ScanRequest: Hashable, Sendable {
var arguments = ["-s", options.subnet.description]
if options.method == .ping { arguments.append("--ping") }
if options.identifyDevices { arguments.append("--resolve") }
return arguments + ["--json"]
// The app reads the ARP table for the engine (see
// `NeighborTable`): macOS may hide it from `ns`.
return arguments + ["--json", "--neighbor-table=parent"]

case .portScan(let options):
var arguments = ["-p", options.host.description, options.ports.engineArgument]
Expand Down
Loading
Loading