Initial commit: NetworkCity — network traffic as a neon city
A native macOS network monitor that renders live traffic as a top-down neon city: your Mac is downtown, remote orgs are glowing districts, and traffic is cars of light driving the roads. Architecture: - NetworkCityCore: swappable data layer behind a ConnectionSource protocol (nettop-backed today), plus pure/tested logic — traffic diffing, org classification (reverse-DNS + CIDR), traffic classes, hub-and-spoke expansion policy, label anti-overlap, and latency→distance layout. - NetworkCityApp: SwiftUI + SpriteKit city — auto-expanding districts, protocol-coloured cars, click-to-inspect panel, and latency-as-distance (districts glide to their measured RTT). - nettop-probe: CLI proof of the data layer. 44 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
/// The seam between *where the bytes come from* and *the city that renders them*.
|
||||
///
|
||||
/// Phase 1 backs this with `NettopSource`. Phase 2 can swap in a libpcap-based
|
||||
/// source — anything that can emit `ConnectionSnapshot`s — and the renderer is
|
||||
/// none the wiser.
|
||||
public protocol ConnectionSource: Sendable {
|
||||
/// Emits a snapshot roughly every `interval` seconds until the stream is
|
||||
/// cancelled (e.g. the consumer breaks out of its `for await` loop).
|
||||
func snapshots(every interval: TimeInterval) -> AsyncStream<ConnectionSnapshot>
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Foundation
|
||||
|
||||
/// A grouping of destinations under one real-world owner. `key` is the stable
|
||||
/// grouping id (a registrable domain, or a bare IP when unresolved); `name` is
|
||||
/// what the district is labelled.
|
||||
public struct Org: Sendable, Hashable {
|
||||
public let key: String
|
||||
public let name: String
|
||||
public let resolvedHost: String?
|
||||
|
||||
public init(key: String, name: String, resolvedHost: String?) {
|
||||
self.key = key
|
||||
self.name = name
|
||||
self.resolvedHost = resolvedHost
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a reverse-DNS hostname to an `Org`. Pure and deterministic so it can be
|
||||
/// unit-tested without touching the network.
|
||||
public enum OrgClassifier {
|
||||
|
||||
private struct Rule { let key: String; let name: String; let domains: Set<String> }
|
||||
|
||||
/// Well-known owners whose traffic spreads across many registrable domains.
|
||||
/// Anything matched here collapses to a single district.
|
||||
private static let rules: [Rule] = [
|
||||
Rule(key: "apple", name: "Apple",
|
||||
domains: ["apple.com", "icloud.com", "aaplimg.com", "mzstatic.com", "cdn-apple.com", "apple-dns.net"]),
|
||||
Rule(key: "google", name: "Google",
|
||||
domains: ["google.com", "googleusercontent.com", "gstatic.com", "googleapis.com",
|
||||
"1e100.net", "ggpht.com", "youtube.com", "ytimg.com", "google-analytics.com"]),
|
||||
Rule(key: "amazon", name: "Amazon / AWS",
|
||||
domains: ["amazonaws.com", "amazon.com", "cloudfront.net", "aws.dev", "awsglobalaccelerator.com"]),
|
||||
Rule(key: "microsoft", name: "Microsoft",
|
||||
domains: ["microsoft.com", "windows.com", "windowsupdate.com", "azure.com",
|
||||
"azureedge.net", "office.com", "live.com", "msftncsi.com"]),
|
||||
Rule(key: "meta", name: "Meta",
|
||||
domains: ["facebook.com", "fbcdn.net", "instagram.com", "whatsapp.net", "fb.com"]),
|
||||
Rule(key: "cloudflare", name: "Cloudflare",
|
||||
domains: ["cloudflare.com", "cloudflare-dns.com", "cf-dns.com"]),
|
||||
Rule(key: "akamai", name: "Akamai",
|
||||
domains: ["akamai.net", "akamaiedge.net", "akamaitechnologies.com", "akadns.net"]),
|
||||
Rule(key: "fastly", name: "Fastly",
|
||||
domains: ["fastly.net", "fastlylb.net"]),
|
||||
Rule(key: "github", name: "GitHub",
|
||||
domains: ["github.com", "githubusercontent.com", "githubassets.com"]),
|
||||
Rule(key: "spotify", name: "Spotify",
|
||||
domains: ["spotify.com", "scdn.co", "spotifycdn.com"]),
|
||||
Rule(key: "netflix", name: "Netflix",
|
||||
domains: ["netflix.com", "nflxvideo.net", "nflxso.net", "nflximg.net"]),
|
||||
Rule(key: "telegram", name: "Telegram",
|
||||
domains: ["telegram.org", "t.me", "telegram.me"]),
|
||||
Rule(key: "anthropic", name: "Anthropic",
|
||||
domains: ["anthropic.com", "claude.ai"]),
|
||||
]
|
||||
|
||||
/// Registrable suffixes that span two labels, so the registrable domain is
|
||||
/// the last *three* labels (e.g. `bbc.co.uk`, not `co.uk`).
|
||||
private static let multiPartSuffixes: Set<String> = [
|
||||
"co.uk", "org.uk", "gov.uk", "ac.uk", "co.jp", "co.nz", "co.in",
|
||||
"com.au", "net.au", "org.au", "com.br", "com.cn", "com.mx", "co.kr", "co.za",
|
||||
]
|
||||
|
||||
public static func classify(ip: String, ptr: String?) -> Org {
|
||||
// 1. A PTR with a real registrable domain is the most accurate signal.
|
||||
if let ptr, let reg = registrableDomain(ptr) {
|
||||
if let rule = rules.first(where: { $0.domains.contains(reg) }) {
|
||||
return Org(key: rule.key, name: rule.name, resolvedHost: ptr)
|
||||
}
|
||||
return Org(key: reg, name: prettify(reg), resolvedHost: ptr)
|
||||
}
|
||||
// 2. No usable PTR — fall back to the curated CIDR table (Apple's 17/8,
|
||||
// Telegram, private LAN, …).
|
||||
if let match = IPRanges.match(ip) {
|
||||
return Org(key: match.key, name: match.name, resolvedHost: ptr)
|
||||
}
|
||||
// 3. Unknown: the IP is its own (unlabelled) district.
|
||||
return Org(key: ip, name: ip, resolvedHost: ptr)
|
||||
}
|
||||
|
||||
/// eTLD+1 with a small built-in multi-part-suffix table. `a.b.example.co.uk`
|
||||
/// -> `example.co.uk`; `cdn.gstatic.com` -> `gstatic.com`.
|
||||
public static func registrableDomain(_ host: String) -> String? {
|
||||
let trimmed = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let labels = trimmed.split(separator: ".").map(String.init)
|
||||
guard labels.count >= 2 else { return nil }
|
||||
let lastTwo = labels.suffix(2).joined(separator: ".")
|
||||
if labels.count >= 3 && multiPartSuffixes.contains(lastTwo) {
|
||||
return labels.suffix(3).joined(separator: ".")
|
||||
}
|
||||
return lastTwo
|
||||
}
|
||||
|
||||
/// `example.co.uk` -> `Example`.
|
||||
public static func prettify(_ registrable: String) -> String {
|
||||
guard let sld = registrable.split(separator: ".").first else { return registrable }
|
||||
return sld.prefix(1).uppercased() + sld.dropFirst()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
/// Decides which districts auto-expand into hub-and-spoke each tick.
|
||||
///
|
||||
/// Rules: a district expands only if it has at least two endpoints (a single-IP
|
||||
/// org has nothing to fan out) and ranks among the busiest `topK` above
|
||||
/// `floorBytes`. Once chosen it stays expanded for `ttlTicks` ticks, so a burst
|
||||
/// leaves a smooth tail instead of flickering collapsed the moment it idles.
|
||||
///
|
||||
/// Pure and deterministic given its input — no SpriteKit, fully unit-testable.
|
||||
public final class ExpansionPolicy {
|
||||
public struct District: Sendable {
|
||||
public let key: String
|
||||
public let bytes: UInt64
|
||||
public let endpointCount: Int
|
||||
public init(key: String, bytes: UInt64, endpointCount: Int) {
|
||||
self.key = key
|
||||
self.bytes = bytes
|
||||
self.endpointCount = endpointCount
|
||||
}
|
||||
}
|
||||
|
||||
private let topK: Int
|
||||
private let ttlTicks: Int
|
||||
private let floorBytes: UInt64
|
||||
private var ttl: [String: Int] = [:]
|
||||
|
||||
public init(topK: Int = 4, ttlTicks: Int = 4, floorBytes: UInt64 = 8 * 1024) {
|
||||
self.topK = topK
|
||||
self.ttlTicks = ttlTicks
|
||||
self.floorBytes = floorBytes
|
||||
}
|
||||
|
||||
/// Advance one tick over the full set of currently-known districts (include
|
||||
/// idle ones so their tail can decay). Returns the set of keys to render
|
||||
/// expanded.
|
||||
public func update(_ districts: [District]) -> Set<String> {
|
||||
// Decay every existing timer.
|
||||
for key in ttl.keys { ttl[key] = max(0, (ttl[key] ?? 0) - 1) }
|
||||
|
||||
// Refresh the busiest multi-endpoint districts.
|
||||
var winners = 0
|
||||
for d in districts.sorted(by: { $0.bytes > $1.bytes }) {
|
||||
guard winners < topK else { break }
|
||||
if d.bytes >= floorBytes && d.endpointCount >= 2 {
|
||||
ttl[d.key] = ttlTicks
|
||||
winners += 1
|
||||
}
|
||||
}
|
||||
|
||||
// Expanded = live timer AND still has something to fan out.
|
||||
let endpointCounts = Dictionary(districts.map { ($0.key, $0.endpointCount) },
|
||||
uniquingKeysWith: { a, _ in a })
|
||||
var expanded = Set<String>()
|
||||
for (key, remaining) in ttl where remaining > 0 {
|
||||
if (endpointCounts[key] ?? 0) >= 2 { expanded.insert(key) }
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// A curated CIDR → owner table, used as a fallback when a host has no PTR
|
||||
/// record. Many large operators (Apple's 17.0.0.0/8 famously, Telegram, the
|
||||
/// cloud edges) publish no reverse DNS, so this recovers the obvious ones.
|
||||
/// IPv4 only — the no-PTR giants we care about are reachable on v4.
|
||||
public enum IPRanges {
|
||||
|
||||
private struct Block { let net: UInt32; let mask: UInt32; let key: String; let name: String }
|
||||
|
||||
private static let blocks: [Block] = [
|
||||
("17.0.0.0/8", "apple", "Apple"),
|
||||
("149.154.160.0/20", "telegram", "Telegram"),
|
||||
("91.108.0.0/16", "telegram", "Telegram"),
|
||||
("13.64.0.0/11", "microsoft", "Microsoft"),
|
||||
("157.240.0.0/16", "meta", "Meta"),
|
||||
("31.13.24.0/21", "meta", "Meta"),
|
||||
("129.134.0.0/16", "meta", "Meta"),
|
||||
("1.1.1.0/24", "cloudflare", "Cloudflare"),
|
||||
("104.16.0.0/13", "cloudflare", "Cloudflare"),
|
||||
// Private / link-local space collapses into one neighbourhood.
|
||||
("10.0.0.0/8", "lan", "Local Network"),
|
||||
("172.16.0.0/12", "lan", "Local Network"),
|
||||
("192.168.0.0/16", "lan", "Local Network"),
|
||||
("169.254.0.0/16", "lan", "Local Network"),
|
||||
].compactMap { parse($0.0, key: $0.1, name: $0.2) }
|
||||
|
||||
public static func match(_ ip: String) -> (key: String, name: String)? {
|
||||
guard let value = ipv4ToUInt32(ip) else { return nil }
|
||||
for b in blocks where (value & b.mask) == b.net {
|
||||
return (b.key, b.name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Parsing
|
||||
|
||||
private static func parse(_ cidr: String, key: String, name: String) -> Block? {
|
||||
let parts = cidr.split(separator: "/")
|
||||
guard parts.count == 2, let bits = UInt32(parts[1]), bits <= 32,
|
||||
let net = ipv4ToUInt32(String(parts[0])) else { return nil }
|
||||
let mask: UInt32 = bits == 0 ? 0 : ~UInt32(0) << (32 - bits)
|
||||
return Block(net: net & mask, mask: mask, key: key, name: name)
|
||||
}
|
||||
|
||||
static func ipv4ToUInt32(_ ip: String) -> UInt32? {
|
||||
let octets = ip.split(separator: ".")
|
||||
guard octets.count == 4 else { return nil }
|
||||
var result: UInt32 = 0
|
||||
for octet in octets {
|
||||
guard let n = UInt32(octet), n <= 255 else { return nil }
|
||||
result = (result << 8) | n
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Maps a measured round-trip time (ms) to a radial distance from downtown.
|
||||
/// A log curve so the busy near range (1–50ms LAN/CDN) spreads out instead of
|
||||
/// bunching at the center, while the long tail (transcontinental) saturates.
|
||||
public enum LatencyLayout {
|
||||
public static let minRadius: CGFloat = 150
|
||||
public static let maxRadius: CGFloat = 560
|
||||
public static let rttCeiling: Double = 250 // ms mapped to the outer edge
|
||||
|
||||
public static func radius(forRTT rtt: Double) -> CGFloat {
|
||||
let clamped = max(0, min(rtt, rttCeiling))
|
||||
let norm = log10(1 + clamped) / log10(1 + rttCeiling)
|
||||
return minRadius + CGFloat(norm) * (maxRadius - minRadius)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Measures round-trip time to an endpoint with an unprivileged TCP connect
|
||||
/// (time from start to `.ready` ≈ one handshake RTT). Results are cached.
|
||||
///
|
||||
/// We probe a port we've already seen traffic on, so we're not knocking on
|
||||
/// closed doors — and the connection carries no data, just the handshake.
|
||||
public actor LatencyProber {
|
||||
private var cache: [String: Double] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func cached(_ host: String) -> Double? { cache[host] }
|
||||
|
||||
/// Measure (or refresh) RTT in milliseconds. Returns nil if unreachable.
|
||||
public func measure(host: String, port: UInt16) async -> Double? {
|
||||
let rtt = await Self.tcpHandshakeRTT(host: host, port: port)
|
||||
if let rtt { cache[host] = rtt }
|
||||
return rtt
|
||||
}
|
||||
|
||||
private static func tcpHandshakeRTT(host: String, port: UInt16) async -> Double? {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return nil }
|
||||
|
||||
return await withCheckedContinuation { (cont: CheckedContinuation<Double?, Never>) in
|
||||
let conn = NWConnection(host: NWEndpoint.Host(host), port: nwPort, using: .tcp)
|
||||
let queue = DispatchQueue(label: "latency.probe")
|
||||
let start = DispatchTime.now()
|
||||
let state = ProbeState(conn: conn, cont: cont)
|
||||
|
||||
conn.stateUpdateHandler = { st in
|
||||
switch st {
|
||||
case .ready:
|
||||
let ns = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds
|
||||
state.finish(Double(ns) / 1_000_000)
|
||||
case .failed, .cancelled:
|
||||
state.finish(nil)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + 2) { state.finish(nil) } // timeout
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes the continuation exactly once, whichever callback fires first.
|
||||
private final class ProbeState: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
private let conn: NWConnection
|
||||
private let cont: CheckedContinuation<Double?, Never>
|
||||
|
||||
init(conn: NWConnection, cont: CheckedContinuation<Double?, Never>) {
|
||||
self.conn = conn
|
||||
self.cont = cont
|
||||
}
|
||||
|
||||
func finish(_ value: Double?) {
|
||||
lock.lock()
|
||||
if done { lock.unlock(); return }
|
||||
done = true
|
||||
lock.unlock()
|
||||
conn.cancel()
|
||||
cont.resume(returning: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// Places district labels so their boxes don't overlap. Each district starts at
|
||||
/// a hash-derived seed; if its (padded) label box collides with an already-placed
|
||||
/// one, we spiral outward — rotating and pushing away from downtown — until we
|
||||
/// find clear space. Existing placements never move, so the map stays stable as
|
||||
/// new districts appear.
|
||||
///
|
||||
/// Pure and deterministic given its inputs; no SpriteKit, fully unit-testable.
|
||||
public enum LayoutSolver {
|
||||
|
||||
/// - Parameters:
|
||||
/// - baseAngle/baseRadius: the hash-derived seed in polar coords.
|
||||
/// - localRect: the label box *relative to the node center* (labels sit
|
||||
/// below the dot, so this is usually offset downward).
|
||||
/// - existing: already-placed label boxes in world space.
|
||||
/// - pad: minimum gap to keep between boxes.
|
||||
/// - Returns: a world-space center for the new district.
|
||||
public static func placeNonOverlapping(
|
||||
baseAngle: Double,
|
||||
baseRadius: Double,
|
||||
localRect: CGRect,
|
||||
existing: [CGRect],
|
||||
pad: CGFloat = 16,
|
||||
maxAttempts: Int = 400
|
||||
) -> CGPoint {
|
||||
for attempt in 0..<maxAttempts {
|
||||
let angle = baseAngle + Double(attempt) * 0.45 // rotate as we go
|
||||
let radius = baseRadius + Double(attempt) * 7 // and push outward
|
||||
let center = CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
|
||||
let box = localRect.offsetBy(dx: center.x, dy: center.y).insetBy(dx: -pad, dy: -pad)
|
||||
if !existing.contains(where: { $0.intersects(box) }) {
|
||||
return center
|
||||
}
|
||||
}
|
||||
// Give up gracefully at the seed (extremely crowded map).
|
||||
return CGPoint(x: cos(baseAngle) * baseRadius, y: sin(baseAngle) * baseRadius)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import Foundation
|
||||
|
||||
/// Transport flavour as reported by `nettop` (the trailing 4/6 is the IP version).
|
||||
public enum NetProtocol: String, Sendable, Hashable {
|
||||
case tcp4, tcp6, udp4, udp6, quic4, quic6, other
|
||||
|
||||
public init(_ raw: String) {
|
||||
self = NetProtocol(rawValue: raw) ?? .other
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of a connection. `port` is nil for a wildcard (`*`) — i.e. a listener.
|
||||
public struct Endpoint: Sendable, Hashable {
|
||||
public let host: String
|
||||
public let port: Int?
|
||||
|
||||
public init(host: String, port: Int?) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
}
|
||||
|
||||
public var isWildcard: Bool { host == "*" || host.isEmpty }
|
||||
|
||||
public var display: String {
|
||||
guard let port else { return host }
|
||||
return "\(host):\(port)"
|
||||
}
|
||||
}
|
||||
|
||||
/// A single connection sampled from `nettop`. Byte counts are cumulative
|
||||
/// (lifetime of the connection), so traffic *rates* come from diffing snapshots.
|
||||
public struct Connection: Sendable, Hashable {
|
||||
public let proto: NetProtocol
|
||||
public let local: Endpoint
|
||||
public let remote: Endpoint
|
||||
public let bytesIn: UInt64
|
||||
public let bytesOut: UInt64
|
||||
public let processName: String
|
||||
public let pid: Int32
|
||||
|
||||
public init(
|
||||
proto: NetProtocol,
|
||||
local: Endpoint,
|
||||
remote: Endpoint,
|
||||
bytesIn: UInt64,
|
||||
bytesOut: UInt64,
|
||||
processName: String,
|
||||
pid: Int32
|
||||
) {
|
||||
self.proto = proto
|
||||
self.local = local
|
||||
self.remote = remote
|
||||
self.bytesIn = bytesIn
|
||||
self.bytesOut = bytesOut
|
||||
self.processName = processName
|
||||
self.pid = pid
|
||||
}
|
||||
|
||||
/// Stable identity for diffing across snapshots (ignores byte counts).
|
||||
public var key: String {
|
||||
"\(proto.rawValue) \(local.display)<->\(remote.display)"
|
||||
}
|
||||
|
||||
/// True when the remote is a real off-box destination — the only kind that
|
||||
/// becomes a "building" in the city. Filters listeners, loopback, link-local.
|
||||
public var isExternal: Bool {
|
||||
let h = remote.host
|
||||
if remote.isWildcard { return false }
|
||||
if h == "::1" || h.hasPrefix("127.") { return false } // loopback
|
||||
if h.hasPrefix("fe80") || h == "::" { return false } // link-local / unspecified
|
||||
return true
|
||||
}
|
||||
|
||||
/// True when the remote is on the local network (a "neighbourhood" district).
|
||||
public var isPrivateLAN: Bool {
|
||||
let h = remote.host
|
||||
return h.hasPrefix("10.")
|
||||
|| h.hasPrefix("192.168.")
|
||||
|| h.hasPrefix("169.254.")
|
||||
|| (h.hasPrefix("172.") && (16...31).contains(Int(h.split(separator: ".").dropFirst().first ?? "") ?? -1))
|
||||
}
|
||||
}
|
||||
|
||||
/// All connections observed at one instant.
|
||||
public struct ConnectionSnapshot: Sendable {
|
||||
public let timestamp: Date
|
||||
public let connections: [Connection]
|
||||
|
||||
public init(timestamp: Date, connections: [Connection]) {
|
||||
self.timestamp = timestamp
|
||||
self.connections = connections
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure, side-effect-free parsing of `nettop` CSV output. Kept separate from the
|
||||
/// process-spawning so it can be unit-tested against captured fixtures.
|
||||
///
|
||||
/// Expected invocation: `nettop -L 1 -x -n -J time,bytes_in,bytes_out`
|
||||
/// which yields lines like:
|
||||
///
|
||||
/// time,,bytes_in,bytes_out,
|
||||
/// 23:49:56.574,apsd.571,15671580,11326466,
|
||||
/// 23:49:56.573,tcp4 192.168.10.194:57359<->17.57.144.184:5223,15671580,11326466,
|
||||
///
|
||||
/// Process rows carry `Name.PID` in column 2; the connection sub-rows that follow
|
||||
/// inherit that process until the next process row.
|
||||
public enum NettopParser {
|
||||
|
||||
public static func parseConnections(_ raw: String) -> [Connection] {
|
||||
var result: [Connection] = []
|
||||
var currentProcess = "unknown"
|
||||
var currentPID: Int32 = -1
|
||||
|
||||
for rawLine in raw.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||
// Don't omit empty subsequences: byte columns can legitimately be empty.
|
||||
let fields = rawLine.split(separator: ",", omittingEmptySubsequences: false)
|
||||
.map(String.init)
|
||||
guard fields.count >= 2 else { continue }
|
||||
|
||||
let key = fields[1]
|
||||
if key.isEmpty || fields[0] == "time" { continue } // header / blank
|
||||
|
||||
if key.contains("<->") {
|
||||
if let conn = parseConnectionRow(
|
||||
key: key,
|
||||
bytesIn: fields.count > 2 ? fields[2] : "",
|
||||
bytesOut: fields.count > 3 ? fields[3] : "",
|
||||
processName: currentProcess,
|
||||
pid: currentPID
|
||||
) {
|
||||
result.append(conn)
|
||||
}
|
||||
} else if let (name, pid) = parseProcessKey(key) {
|
||||
currentProcess = name
|
||||
currentPID = pid
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// `mDNSResponder.647` -> ("mDNSResponder", 647). The suffix after the final
|
||||
/// dot must be all digits, otherwise it's not a process header.
|
||||
static func parseProcessKey(_ key: String) -> (String, Int32)? {
|
||||
guard let dot = key.lastIndex(of: ".") else { return nil }
|
||||
let pidPart = key[key.index(after: dot)...]
|
||||
guard !pidPart.isEmpty, pidPart.allSatisfy(\.isNumber),
|
||||
let pid = Int32(pidPart) else { return nil }
|
||||
return (String(key[key.startIndex..<dot]), pid)
|
||||
}
|
||||
|
||||
/// `tcp4 192.168.10.194:57359<->17.57.144.184:5223` -> Connection
|
||||
static func parseConnectionRow(
|
||||
key: String,
|
||||
bytesIn: String,
|
||||
bytesOut: String,
|
||||
processName: String,
|
||||
pid: Int32
|
||||
) -> Connection? {
|
||||
guard let space = key.firstIndex(of: " ") else { return nil }
|
||||
let protoStr = String(key[key.startIndex..<space])
|
||||
let tuple = String(key[key.index(after: space)...])
|
||||
|
||||
guard let arrow = tuple.range(of: "<->") else { return nil }
|
||||
let localStr = String(tuple[tuple.startIndex..<arrow.lowerBound])
|
||||
let remoteStr = String(tuple[arrow.upperBound...])
|
||||
|
||||
let isV6 = protoStr.hasSuffix("6")
|
||||
return Connection(
|
||||
proto: NetProtocol(protoStr),
|
||||
local: parseEndpoint(localStr, isV6: isV6),
|
||||
remote: parseEndpoint(remoteStr, isV6: isV6),
|
||||
bytesIn: UInt64(bytesIn.trimmingCharacters(in: .whitespaces)) ?? 0,
|
||||
bytesOut: UInt64(bytesOut.trimmingCharacters(in: .whitespaces)) ?? 0,
|
||||
processName: processName,
|
||||
pid: pid
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Endpoints
|
||||
|
||||
/// IPv4 separates host/port with `:` (`1.2.3.4:443`); IPv6 uses `.`
|
||||
/// (`::1.8021`) and may carry a `%zone` suffix on the host we strip off.
|
||||
static func parseEndpoint(_ s: String, isV6: Bool) -> Endpoint {
|
||||
let sep: Character = isV6 ? "." : ":"
|
||||
guard let idx = s.lastIndex(of: sep) else {
|
||||
return Endpoint(host: cleanHost(s), port: nil)
|
||||
}
|
||||
let host = cleanHost(String(s[s.startIndex..<idx]))
|
||||
let portStr = String(s[s.index(after: idx)...])
|
||||
let port = portStr == "*" ? nil : Int(portStr)
|
||||
return Endpoint(host: host, port: port)
|
||||
}
|
||||
|
||||
/// Strips an IPv6 scope id: `fe80::1%utun4` -> `fe80::1`.
|
||||
static func cleanHost(_ h: String) -> String {
|
||||
if let pct = h.firstIndex(of: "%") {
|
||||
return String(h[h.startIndex..<pct])
|
||||
}
|
||||
return h
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
/// A `ConnectionSource` backed by repeatedly shelling out to `/usr/bin/nettop`.
|
||||
///
|
||||
/// Each tick runs a fresh one-shot `nettop` (`-L 1`). Because byte counts are
|
||||
/// lifetime-cumulative and stable across invocations, the consumer diffs
|
||||
/// successive snapshots to recover per-interval traffic. No elevated privileges
|
||||
/// required — that's the whole point of starting here.
|
||||
public final class NettopSource: ConnectionSource {
|
||||
|
||||
private let nettopPath: String
|
||||
|
||||
public init(nettopPath: String = "/usr/bin/nettop") {
|
||||
self.nettopPath = nettopPath
|
||||
}
|
||||
|
||||
public func snapshots(every interval: TimeInterval) -> AsyncStream<ConnectionSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task { [nettopPath] in
|
||||
while !Task.isCancelled {
|
||||
if let output = try? Self.runNettop(at: nettopPath) {
|
||||
let conns = NettopParser.parseConnections(output)
|
||||
continuation.yield(ConnectionSnapshot(timestamp: Date(), connections: conns))
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
||||
} catch {
|
||||
break // cancelled
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one-shot nettop and returns its stdout. Reads the pipe to EOF *before*
|
||||
/// waiting on exit so large output can't deadlock a full pipe buffer.
|
||||
static func runNettop(at path: String) throws -> String {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
// -L 1 : one sample then exit -x : raw bytes (no unit suffixes)
|
||||
// -n : numeric (skip DNS; we enrich ourselves)
|
||||
// -J : pick only the columns we parse
|
||||
process.arguments = ["-L", "1", "-x", "-n", "-J", "time,bytes_in,bytes_out"]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
try process.run()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
/// Resolves a pid to its real executable name via `proc_pidpath`, because
|
||||
/// nettop's name column is unreliable (it reported `claude` as `2.1.177`).
|
||||
/// Results are cached; drive from a single thread/actor.
|
||||
public final class ProcessNamer {
|
||||
private var cache: [Int32: String] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func name(for pid: Int32) -> String? {
|
||||
guard pid > 0 else { return nil }
|
||||
if let hit = cache[pid] { return hit }
|
||||
|
||||
var buffer = [CChar](repeating: 0, count: 4096) // PROC_PIDPATHINFO_MAXSIZE
|
||||
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
let path = String(cString: buffer)
|
||||
// `/Applications/Google Chrome.app/.../Google Chrome Helper` -> last component
|
||||
let name = (path as NSString).lastPathComponent
|
||||
cache[pid] = name
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
/// Best-effort reverse DNS (IP -> PTR hostname). Blocking — call it off the main
|
||||
/// thread. Returns nil when there's no PTR record (we don't want a numeric echo).
|
||||
public enum ReverseDNS {
|
||||
|
||||
public static func resolve(_ ip: String) -> String? {
|
||||
var storage = sockaddr_storage()
|
||||
var length: socklen_t = 0
|
||||
|
||||
if ip.contains(":") {
|
||||
var sa = sockaddr_in6()
|
||||
sa.sin6_family = sa_family_t(AF_INET6)
|
||||
sa.sin6_len = UInt8(MemoryLayout<sockaddr_in6>.size)
|
||||
guard inet_pton(AF_INET6, ip, &sa.sin6_addr) == 1 else { return nil }
|
||||
withUnsafeBytes(of: &sa) { src in
|
||||
withUnsafeMutableBytes(of: &storage) { dst in
|
||||
dst.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[0..<MemoryLayout<sockaddr_in6>.size]))
|
||||
}
|
||||
}
|
||||
length = socklen_t(MemoryLayout<sockaddr_in6>.size)
|
||||
} else {
|
||||
var sa = sockaddr_in()
|
||||
sa.sin_family = sa_family_t(AF_INET)
|
||||
sa.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
guard inet_pton(AF_INET, ip, &sa.sin_addr) == 1 else { return nil }
|
||||
withUnsafeBytes(of: &sa) { src in
|
||||
withUnsafeMutableBytes(of: &storage) { dst in
|
||||
dst.copyMemory(from: UnsafeRawBufferPointer(rebasing: src[0..<MemoryLayout<sockaddr_in>.size]))
|
||||
}
|
||||
}
|
||||
length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
}
|
||||
|
||||
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = withUnsafePointer(to: &storage) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
|
||||
getnameinfo(sa, length, &host, socklen_t(host.count), nil, 0, NI_NAMEREQD)
|
||||
}
|
||||
}
|
||||
guard result == 0 else { return nil }
|
||||
let name = String(cString: host)
|
||||
return name.isEmpty ? nil : name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// A coarse traffic category derived from protocol + port, used to colour the
|
||||
/// cars of light. Direction (in/out) is conveyed by motion, so colour is free
|
||||
/// to mean "what kind of traffic is this".
|
||||
public enum TrafficClass: String, Sendable, CaseIterable {
|
||||
case dns
|
||||
case https
|
||||
case http
|
||||
case quic
|
||||
case other
|
||||
|
||||
/// Human label for the legend.
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .dns: return "DNS"
|
||||
case .https: return "HTTPS"
|
||||
case .http: return "HTTP"
|
||||
case .quic: return "QUIC"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
|
||||
public static func classify(proto: NetProtocol, port: Int?) -> TrafficClass {
|
||||
// nettop labels QUIC explicitly; trust that first.
|
||||
if proto == .quic4 || proto == .quic6 { return .quic }
|
||||
|
||||
let isUDP = (proto == .udp4 || proto == .udp6)
|
||||
switch port {
|
||||
case 53, 5353:
|
||||
return .dns
|
||||
case 443:
|
||||
return isUDP ? .quic : .https // UDP/443 is almost always QUIC
|
||||
case 80, 8080:
|
||||
return .http
|
||||
default:
|
||||
return .other
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
/// One connection's traffic over the interval between two snapshots.
|
||||
public struct TrafficDelta: Sendable {
|
||||
public let connection: Connection
|
||||
public let bytesInDelta: UInt64
|
||||
public let bytesOutDelta: UInt64
|
||||
public let interval: TimeInterval
|
||||
|
||||
public var inBytesPerSec: Double { interval > 0 ? Double(bytesInDelta) / interval : 0 }
|
||||
public var outBytesPerSec: Double { interval > 0 ? Double(bytesOutDelta) / interval : 0 }
|
||||
}
|
||||
|
||||
/// Turns the stream of cumulative-byte snapshots into per-interval deltas by
|
||||
/// remembering the previous reading for each connection key. Shared by the CLI
|
||||
/// probe and the SpriteKit city so there's one source of truth for "traffic".
|
||||
///
|
||||
/// Not thread-safe by design — drive it from a single actor/thread.
|
||||
public final class TrafficDiffer {
|
||||
private var previous: [String: (UInt64, UInt64)] = [:]
|
||||
private var lastTimestamp: Date?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func ingest(_ snapshot: ConnectionSnapshot) -> [TrafficDelta] {
|
||||
let interval = lastTimestamp.map { snapshot.timestamp.timeIntervalSince($0) } ?? 0
|
||||
lastTimestamp = snapshot.timestamp
|
||||
|
||||
var next: [String: (UInt64, UInt64)] = [:]
|
||||
var deltas: [TrafficDelta] = []
|
||||
|
||||
for c in snapshot.connections {
|
||||
next[c.key] = (c.bytesIn, c.bytesOut)
|
||||
guard interval > 0, let (pIn, pOut) = previous[c.key] else { continue }
|
||||
// A drop in the counter means the tuple was reused by a new connection;
|
||||
// treat as zero rather than underflow.
|
||||
let dIn = c.bytesIn >= pIn ? c.bytesIn - pIn : 0
|
||||
let dOut = c.bytesOut >= pOut ? c.bytesOut - pOut : 0
|
||||
if dIn + dOut > 0 {
|
||||
deltas.append(TrafficDelta(
|
||||
connection: c, bytesInDelta: dIn, bytesOutDelta: dOut, interval: interval
|
||||
))
|
||||
}
|
||||
}
|
||||
previous = next
|
||||
return deltas
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user