From c08fc277a997a99520913fb321d1c759eb61991b Mon Sep 17 00:00:00 2001 From: Chris Dail Date: Sat, 13 Jun 2026 08:43:32 -0700 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20NetworkCity=20=E2=80=94=20n?= =?UTF-8?q?etwork=20traffic=20as=20a=20neon=20city?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitignore | 4 + Package.swift | 31 ++ README.md | 92 +++++ Sources/NetworkCityApp/BuildingNode.swift | 211 ++++++++++ Sources/NetworkCityApp/CityScene.swift | 387 ++++++++++++++++++ Sources/NetworkCityApp/CityView.swift | 200 +++++++++ Sources/NetworkCityApp/Inspection.swift | 25 ++ Sources/NetworkCityApp/NetworkCityApp.swift | 27 ++ .../NetworkCityApp/TrafficController.swift | 242 +++++++++++ Sources/NetworkCityApp/Visuals.swift | 99 +++++ .../NetworkCityCore/ConnectionSource.swift | 12 + Sources/NetworkCityCore/Enrichment.swift | 99 +++++ Sources/NetworkCityCore/ExpansionPolicy.swift | 60 +++ Sources/NetworkCityCore/IPRanges.swift | 56 +++ Sources/NetworkCityCore/LatencyLayout.swift | 17 + Sources/NetworkCityCore/LatencyProber.swift | 69 ++++ Sources/NetworkCityCore/LayoutSolver.swift | 40 ++ Sources/NetworkCityCore/Models.swift | 93 +++++ Sources/NetworkCityCore/NettopParser.swift | 111 +++++ Sources/NetworkCityCore/NettopSource.swift | 57 +++ Sources/NetworkCityCore/ProcessNamer.swift | 26 ++ Sources/NetworkCityCore/ReverseDNS.swift | 46 +++ Sources/NetworkCityCore/TrafficClass.swift | 40 ++ Sources/NetworkCityCore/TrafficDiffer.swift | 48 +++ Sources/nettop-probe/main.swift | 45 ++ .../ExpansionPolicyTests.swift | 49 +++ .../NetworkCityCoreTests/IPRangesTests.swift | 42 ++ .../LatencyLayoutTests.swift | 35 ++ .../LayoutSolverTests.swift | 45 ++ .../NettopParserTests.swift | 67 +++ .../OrgClassifierTests.swift | 46 +++ .../TrafficClassTests.swift | 34 ++ .../TrafficDifferTests.swift | 44 ++ 33 files changed, 2499 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/NetworkCityApp/BuildingNode.swift create mode 100644 Sources/NetworkCityApp/CityScene.swift create mode 100644 Sources/NetworkCityApp/CityView.swift create mode 100644 Sources/NetworkCityApp/Inspection.swift create mode 100644 Sources/NetworkCityApp/NetworkCityApp.swift create mode 100644 Sources/NetworkCityApp/TrafficController.swift create mode 100644 Sources/NetworkCityApp/Visuals.swift create mode 100644 Sources/NetworkCityCore/ConnectionSource.swift create mode 100644 Sources/NetworkCityCore/Enrichment.swift create mode 100644 Sources/NetworkCityCore/ExpansionPolicy.swift create mode 100644 Sources/NetworkCityCore/IPRanges.swift create mode 100644 Sources/NetworkCityCore/LatencyLayout.swift create mode 100644 Sources/NetworkCityCore/LatencyProber.swift create mode 100644 Sources/NetworkCityCore/LayoutSolver.swift create mode 100644 Sources/NetworkCityCore/Models.swift create mode 100644 Sources/NetworkCityCore/NettopParser.swift create mode 100644 Sources/NetworkCityCore/NettopSource.swift create mode 100644 Sources/NetworkCityCore/ProcessNamer.swift create mode 100644 Sources/NetworkCityCore/ReverseDNS.swift create mode 100644 Sources/NetworkCityCore/TrafficClass.swift create mode 100644 Sources/NetworkCityCore/TrafficDiffer.swift create mode 100644 Sources/nettop-probe/main.swift create mode 100644 Tests/NetworkCityCoreTests/ExpansionPolicyTests.swift create mode 100644 Tests/NetworkCityCoreTests/IPRangesTests.swift create mode 100644 Tests/NetworkCityCoreTests/LatencyLayoutTests.swift create mode 100644 Tests/NetworkCityCoreTests/LayoutSolverTests.swift create mode 100644 Tests/NetworkCityCoreTests/NettopParserTests.swift create mode 100644 Tests/NetworkCityCoreTests/OrgClassifierTests.swift create mode 100644 Tests/NetworkCityCoreTests/TrafficClassTests.swift create mode 100644 Tests/NetworkCityCoreTests/TrafficDifferTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9fb9f36 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj/xcuserdata/ +.DS_Store diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..fcaa285 --- /dev/null +++ b/Package.swift @@ -0,0 +1,31 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "NetworkCity", + platforms: [.macOS(.v13)], + products: [ + // The swappable data layer. The SpriteKit "city" will depend on this + // and never know whether the bytes came from nettop or libpcap. + .library(name: "NetworkCityCore", targets: ["NetworkCityCore"]), + // Phase-1 proof of concept: prints live per-connection traffic rates. + .executable(name: "nettop-probe", targets: ["nettop-probe"]), + // Phase-3: the SpriteKit city — SwiftUI window, traffic as cars. + .executable(name: "NetworkCityApp", targets: ["NetworkCityApp"]), + ], + targets: [ + .target(name: "NetworkCityCore"), + .executableTarget( + name: "nettop-probe", + dependencies: ["NetworkCityCore"] + ), + .executableTarget( + name: "NetworkCityApp", + dependencies: ["NetworkCityCore"] + ), + .testTarget( + name: "NetworkCityCoreTests", + dependencies: ["NetworkCityCore"] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..54787a4 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# NetworkCity + +A macOS network monitor that renders live traffic as a top-down **city map** — +remote hosts become buildings, and your traffic becomes cars driving to them +(SimCity-style). This repo currently contains **Phase 1: the proven data layer**. + +## Architecture + +``` +ConnectionSource (protocol) ← the seam: "where bytes come from" + │ + ├─ NettopSource ← Phase 1: shells out to /usr/bin/nettop (no privileges) + └─ (libpcap source) ← Phase 2: real packets (needs privilege / helper tool) + │ + ▼ + ConnectionSnapshot ← list of Connection, with cumulative byte counts + │ + ▼ + SpriteKit "city" (Phase 3) ← buildings + cars; never knows the data source +``` + +The renderer depends only on `NetworkCityCore`, so the data backend can be +swapped without touching the visuals. + +## Layout + +- `Sources/NetworkCityCore/` — models, the `ConnectionSource` protocol, the + `nettop` parser (`NettopParser`, pure/testable) and poller (`NettopSource`). +- `Sources/nettop-probe/` — Phase-1 CLI proof of concept. Prints live KB/s. +- `Tests/` — parser tests pinned to real captured `nettop` output. + +## Try it + +```sh +swift test # parser tests against real fixtures +swift run nettop-probe # live traffic, busiest external flows as KB/s +``` + +## Key design facts (learned from real output) + +- **nettop format**: process rows are `Name.PID,bytes_in,bytes_out`; the + connection sub-rows beneath inherit that process. IPv4 ports use `:`, IPv6 + uses `.` and may carry a `%zone` suffix. +- **Rates by diffing**: byte counts are lifetime-cumulative, so we keep the last + snapshot and subtract. Robust to nettop quirks; works across fresh invocations. +- **Process names are unreliable** — nettop truncates/mangles them (it reported + `claude` as `2.1.177`). The **pid is trustworthy**; enrich the real name via + `proc_pidpath`/`ps` keyed on pid. (Phase-2 polish.) +- **`isExternal`** filters listeners, loopback, and link-local so only real + off-box destinations become buildings. + +## Roadmap + +1. ✅ Data layer + live probe (nettop, no privileges) +2. ✅ SpriteKit city — SwiftUI window, glowing buildings, cars of light + (`swift run NetworkCityApp`): cyan cars inbound = downloads, amber outbound + = uploads; deterministic building placement; drag to pan, scroll to zoom. +3. ✅ Enrichment — real process names (`proc_pidpath`), reverse-DNS + a curated + CIDR table so hosts group into named **districts** (Apple, Google, AWS, + Telegram, Local Network…). PTR is primary; the CIDR table catches the + no-PTR giants (all of `17.0.0.0/8` is Apple). Run with `NC_DEBUG=1` to log + each host→district resolution to stderr. +4. ✅ Hub-and-spoke — busy districts auto-expand into their endpoints + (`This Mac → OneDrive → its many servers`); cars route the full two-segment + path. Expansion is a pure, tested `ExpansionPolicy` (≥2 endpoints, top-K by + bytes, TTL tail to avoid flicker). A single client downloading from a CDN + pins to one IP, so fan-out is driven by apps that genuinely spread + connections (sync clients, browsers). +5. ✅ Traffic differentiation — cars coloured by protocol class (DNS gold, + HTTPS cyan, HTTP orange, QUIC violet, Other slate) since direction is already + read from motion. Behaviour reinforces it: DNS = tiny fast sparks, QUIC = + streaks, heavy flows = big slow "freight" comets. Classifier is a pure tested + `TrafficClass`; HUD shows a legend. +6. ✅ Label anti-overlap (`LayoutSolver`). +7. ✅ Click-to-inspect — click a district hub to open a live panel: total + down/up, every endpoint with its reverse-DNS name + per-endpoint rate + + protocol-colour dot, and the owning process(es). Click empty space or ✕ to + close. A pulsing ring marks the selection. Click vs drag is disambiguated by + movement distance. +8. ✅ Latency as distance — distance from downtown = measured round-trip time + (unprivileged TCP-connect probe, cached), not geography. GeoIP was rejected + because anycast makes it assert a physical fiction; latency is measured so it + can't lie. Districts glide to their true distance as probes complete; the + inspector shows per-endpoint ms. Pure `LatencyLayout` (log curve) is tested; + `LatencyProber` uses Network.framework. Our own pid is filtered out so we + don't render our own probes. +9. `MenuBarExtra` shell; optional libpcap source via privileged helper. + +### Env flags (opt-in diagnostics) +- `NC_DEBUG=1` — log host→district resolution, per-tick top districts, and fan-outs to stderr. +- `NC_DEMO=1` — offline demo: feeds a synthetic 8-endpoint "OneDrive" through the + real scene so the hub-and-spoke fan-out runs without nettop. diff --git a/Sources/NetworkCityApp/BuildingNode.swift b/Sources/NetworkCityApp/BuildingNode.swift new file mode 100644 index 0000000..3b0f9d8 --- /dev/null +++ b/Sources/NetworkCityApp/BuildingNode.swift @@ -0,0 +1,211 @@ +import SpriteKit + +/// A single endpoint (one server IP) belonging to an org — a small glowing +/// satellite that appears when its district fans out. +final class EndpointNode: SKNode { + private let glow: SKSpriteNode + private let core: SKShapeNode + + override init() { + glow = SKSpriteNode(texture: Textures.softCircle) + core = SKShapeNode(circleOfRadius: 2.5) + super.init() + glow.color = Palette.building + glow.colorBlendFactor = 1 + glow.blendMode = .add + glow.setScale(0.12) + glow.alpha = 0.7 + addChild(glow) + core.fillColor = Palette.building + core.strokeColor = .white + core.lineWidth = 0.4 + addChild(core) + } + required init?(coder: NSCoder) { fatalError() } + + func pulse() { + core.removeAction(forKey: "pulse") + core.run(.sequence([.scale(to: 1.8, duration: 0.12), .scale(to: 1.0, duration: 0.3)]), withKey: "pulse") + glow.removeAction(forKey: "flare") + glow.run(.sequence([.fadeAlpha(to: 1.0, duration: 0.12), .fadeAlpha(to: 0.7, duration: 0.4)]), withKey: "flare") + } +} + +/// A destination *organization* rendered as a glowing district hub. When busy it +/// fans out into its known endpoints (hub-and-spoke); when quiet it collapses +/// back to a single labelled hub. +final class BuildingNode: SKNode { + private let glow: SKSpriteNode + private let core: SKShapeNode + private let title: SKLabelNode + private let subtitle: SKLabelNode + private var cumulative: Double = 0 + + private let spokeLayer = SKNode() + private let endpointLayer = SKNode() + private var endpointNodes: [String: EndpointNode] = [:] + private var knownHosts: Set = [] + private(set) var isExpanded = false + + private let maxEndpoints = 20 + private let fanSpread = 75.0 * .pi / 180 // half-arc the spokes spread over + private let spokeRadius: CGFloat = 82 + + init(title titleText: String, subtitle subtitleText: String) { + glow = SKSpriteNode(texture: Textures.softCircle) + core = SKShapeNode(circleOfRadius: 5) + title = SKLabelNode(text: titleText) + subtitle = SKLabelNode(text: subtitleText) + super.init() + + spokeLayer.zPosition = -1 + addChild(spokeLayer) + addChild(endpointLayer) + + glow.color = Palette.building + glow.colorBlendFactor = 1 + glow.blendMode = .add + glow.setScale(0.30) + glow.alpha = 0.85 + addChild(glow) + + core.fillColor = Palette.building + core.strokeColor = .white + core.lineWidth = 0.5 + core.glowWidth = 1 + addChild(core) + + title.fontName = "Menlo-Bold" + title.fontSize = 13 + title.fontColor = .white + title.verticalAlignmentMode = .top + title.position = CGPoint(x: 0, y: -12) + addChild(title) + + subtitle.fontName = "Menlo" + subtitle.fontSize = 10 + subtitle.fontColor = Palette.building.withAlphaComponent(0.85) + subtitle.verticalAlignmentMode = .top + subtitle.position = CGPoint(x: 0, y: -27) + addChild(subtitle) + } + + required init?(coder: NSCoder) { fatalError() } + + func update(subtitle text: String) { + if subtitle.text != text { subtitle.text = text } + } + + var knownHostCount: Int { knownHosts.count } + + /// The label box (title + subtitle) relative to this node's center, used for + /// collision-aware placement so labels don't overlap their neighbours. + var labelFootprint: CGRect { + title.calculateAccumulatedFrame().union(subtitle.calculateAccumulatedFrame()) + } + + func note(activeHosts: [String]) { knownHosts.formUnion(activeHosts) } + + /// React to a tick of traffic: grow toward a size set by total volume, and + /// pulse so the eye catches the activity. + func receive(bytes: Double) { + cumulative += bytes + let target = (0.30 + CGFloat(log10(cumulative + 1)) * 0.09).clamped(0.30, 1.05) + glow.removeAction(forKey: "resize") + glow.run(.scale(to: target, duration: 0.6), withKey: "resize") + + core.removeAction(forKey: "pulse") + core.run(.sequence([ + .scale(to: 1.9, duration: 0.12), + .scale(to: 1.0, duration: 0.35), + ]), withKey: "pulse") + } + + // MARK: - Expansion + + /// The capped, stably-ordered list of endpoints we actually draw. + private var renderedHosts: [String] { knownHosts.sorted().prefix(maxEndpoints).map { $0 } } + + /// Local offset of an endpoint, fanned outward (away from downtown at origin). + private func offset(forIndex i: Int, count: Int, host: String) -> CGPoint { + let outward = atan2(position.y, position.x) + let angle: CGFloat + if count <= 1 { + angle = outward + } else { + let t = CGFloat(i) / CGFloat(count - 1) // 0…1 across the arc + angle = outward - fanSpread + 2 * fanSpread * t + } + let depth = spokeRadius + CGFloat(stableHash(host) % 4) * 11 + return CGPoint(x: cos(angle) * depth, y: sin(angle) * depth) + } + + /// Scene-space position of an endpoint (for routing cars along its spoke). + func worldPosition(forEndpoint host: String) -> CGPoint { + let hosts = renderedHosts + guard let i = hosts.firstIndex(of: host) else { return position } + let o = offset(forIndex: i, count: hosts.count, host: host) + return CGPoint(x: position.x + o.x, y: position.y + o.y) + } + + func setExpanded(_ on: Bool) { + if on { + if !isExpanded { + isExpanded = true + if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { + FileHandle.standardError.write(Data("[expand] \(title.text ?? "?") fanned out → \(knownHostCount) endpoints\n".utf8)) + } + endpointLayer.alpha = 0 + spokeLayer.alpha = 0 + endpointLayer.run(.fadeIn(withDuration: 0.4)) + spokeLayer.run(.fadeAlpha(to: 0.5, duration: 0.4)) + } + layoutEndpoints() + } else if isExpanded { + isExpanded = false + endpointLayer.run(.fadeOut(withDuration: 0.35)) + spokeLayer.run(.sequence([.fadeOut(withDuration: 0.35), .run { [weak self] in + self?.spokeLayer.removeAllChildren() + self?.endpointLayer.removeAllChildren() + self?.endpointNodes.removeAll() + }])) + } + } + + func pulseEndpoint(_ host: String) { + endpointNodes[host]?.pulse() + } + + private func layoutEndpoints() { + let hosts = renderedHosts + spokeLayer.removeAllChildren() + let spokes = CGMutablePath() + + for (i, host) in hosts.enumerated() { + let target = offset(forIndex: i, count: hosts.count, host: host) + let node = endpointNodes[host] ?? { + let n = EndpointNode() + n.position = .zero + n.alpha = 0 + n.run(.fadeIn(withDuration: 0.3)) + endpointLayer.addChild(n) + endpointNodes[host] = n + return n + }() + node.run(.move(to: target, duration: 0.3)) + spokes.move(to: .zero) + spokes.addLine(to: target) + } + + // drop endpoints that fell out of the capped set + for (host, node) in endpointNodes where !hosts.contains(host) { + node.removeFromParent() + endpointNodes[host] = nil + } + + let line = SKShapeNode(path: spokes) + line.strokeColor = Palette.building.withAlphaComponent(0.4) + line.lineWidth = 1 + spokeLayer.addChild(line) + } +} diff --git a/Sources/NetworkCityApp/CityScene.swift b/Sources/NetworkCityApp/CityScene.swift new file mode 100644 index 0000000..299232e --- /dev/null +++ b/Sources/NetworkCityApp/CityScene.swift @@ -0,0 +1,387 @@ +import SpriteKit +import NetworkCityCore + +/// Traffic of one class to/from one endpoint in a single tick. +struct ClassFlow { + let cls: TrafficClass + let inBytes: UInt64 + let outBytes: UInt64 +} + +/// One endpoint's traffic within a district for a single tick, split by class. +struct EndpointTraffic { + let host: String + let flows: [ClassFlow] + var inBytes: UInt64 { flows.reduce(0) { $0 + $1.inBytes } } + var outBytes: UInt64 { flows.reduce(0) { $0 + $1.outBytes } } +} + +/// One org's traffic for a single tick, ready to render as a district. +struct DistrictTraffic { + let key: String + let title: String + var subtitle: String + var inBytes: UInt64 + var outBytes: UInt64 + var endpoints: [EndpointTraffic] + var rttMs: Double? = nil // measured round-trip time → distance from downtown +} + +/// The city. Downtown (your Mac) glows at the origin; every org is a district +/// hub placed at a deterministic spot around it, connected by a road. Busy +/// districts fan out into their endpoints (hub-and-spoke); cars of light flow +/// the full path — cyan inbound for downloads, amber outbound for uploads. +final class CityScene: SKScene { + + private let cam = SKCameraNode() + private let worldRadius: CGFloat = 540 + private var buildings: [String: BuildingNode] = [:] + private var roads: [String: SKShapeNode] = [:] + private var latencyRadius: [String: CGFloat] = [:] + private var ringKey: String? + private let roadLayer = SKNode() + private let carLayer = SKNode() + private let buildingLayer = SKNode() + + // Auto-expand: an org's spokes stay open for a few ticks after it last + // ranked among the busiest, giving a smooth tail instead of flicker. + private let expansion = ExpansionPolicy() + + /// Number of districts currently on the map (for the HUD). + var buildingCount: Int { buildings.count } + + /// Called with a district key when a hub is clicked, or nil when empty space + /// is clicked (deselect). + var onSelect: ((String?) -> Void)? + private let selectionRing = SKShapeNode(circleOfRadius: 24) + private var dragDistance: CGFloat = 0 + + override func didMove(to view: SKView) { + backgroundColor = Palette.background + scaleMode = .resizeFill + anchorPoint = CGPoint(x: 0.5, y: 0.5) + + drawGrid() + addChild(roadLayer) + addChild(carLayer) + addChild(buildingLayer) + addDowntown() + + selectionRing.strokeColor = .white + selectionRing.lineWidth = 2 + selectionRing.glowWidth = 4 + selectionRing.alpha = 0 + selectionRing.zPosition = 40 + addChild(selectionRing) + + camera = cam + cam.setScale(1.25) + addChild(cam) + } + + // MARK: - Selection + + /// Rings the selected district (or fades the ring out when nil). + func highlight(key: String?) { + ringKey = key + selectionRing.removeAllActions() + if let key, let building = buildings[key] { + selectionRing.position = building.position + selectionRing.alpha = 1 + selectionRing.run(.repeatForever(.sequence([ + .scale(to: 1.18, duration: 0.7), .scale(to: 1.0, duration: 0.7), + ]))) + } else { + selectionRing.run(.fadeOut(withDuration: 0.2)) + } + } + + /// Nearest district hub within a (zoom-aware) radius of the point. + private func districtKey(at point: CGPoint) -> String? { + let radius = 30 * cam.xScale + var best: (key: String, dist: CGFloat)? + for (key, building) in buildings { + let d = hypot(point.x - building.position.x, point.y - building.position.y) + if d < radius, best == nil || d < best!.dist { best = (key, d) } + } + return best?.key + } + + // MARK: - Static scenery + + private func drawGrid() { + let grid = SKNode() + let step: CGFloat = 80 + let extent: CGFloat = 1400 + let path = CGMutablePath() + var x = -extent + while x <= extent { path.move(to: CGPoint(x: x, y: -extent)); path.addLine(to: CGPoint(x: x, y: extent)); x += step } + var y = -extent + while y <= extent { path.move(to: CGPoint(x: -extent, y: y)); path.addLine(to: CGPoint(x: extent, y: y)); y += step } + let line = SKShapeNode(path: path) + line.strokeColor = Palette.grid.withAlphaComponent(0.18) + line.lineWidth = 1 + grid.addChild(line) + grid.zPosition = -100 + addChild(grid) + } + + private func addDowntown() { + let glow = SKSpriteNode(texture: Textures.softCircle) + glow.color = Palette.hub + glow.colorBlendFactor = 1 + glow.blendMode = .add + glow.setScale(1.1) + glow.zPosition = -10 + glow.run(.repeatForever(.sequence([ + .fadeAlpha(to: 0.6, duration: 1.8), + .fadeAlpha(to: 1.0, duration: 1.8), + ]))) + addChild(glow) + + let core = SKShapeNode(circleOfRadius: 9) + core.fillColor = Palette.hub + core.strokeColor = .white + core.glowWidth = 2 + addChild(core) + + let label = SKLabelNode(text: "▸ this mac") + label.fontName = "Menlo-Bold" + label.fontSize = 13 + label.fontColor = Palette.hub + label.verticalAlignmentMode = .top + label.position = CGPoint(x: 0, y: -16) + addChild(label) + } + + // MARK: - Live data + + /// Render a tick of per-org traffic: ensure each district exists, decide + /// which ones are busy enough to fan out, then dispatch cars accordingly. + func apply(_ districts: [DistrictTraffic]) { + // 1. Ensure hubs exist and learn their endpoints. + for t in districts { + let hub = building(forKey: t.key, title: t.title, subtitle: t.subtitle) + hub.note(activeHosts: t.endpoints.map(\.host)) + hub.receive(bytes: Double(t.inBytes + t.outBytes)) + if let rtt = t.rttMs { repositionToLatency(key: t.key, rttMs: rtt) } + } + + // 2. Decide which districts fan out. Evaluate *every* known district + // (not just this tick's active ones) so a TTL tail keeps idle hubs + // open. A district needs ≥2 endpoints to have anything to expand. + let bytesByKey = Dictionary(districts.map { ($0.key, $0.inBytes + $0.outBytes) }, + uniquingKeysWith: +) + let entries = buildings.map { key, hub in + ExpansionPolicy.District(key: key, bytes: bytesByKey[key] ?? 0, endpointCount: hub.knownHostCount) + } + let expanded = expansion.update(entries) + + if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { + let top = districts.sorted { ($0.inBytes + $0.outBytes) > ($1.inBytes + $1.outBytes) }.prefix(3) + .map { "\($0.title)=\(($0.inBytes + $0.outBytes) / 1024)KB/ep\(buildings[$0.key]?.knownHostCount ?? 0)/\(expanded.contains($0.key) ? "EXP" : "—")" } + .joined(separator: " ") + var classKB: [TrafficClass: UInt64] = [:] + for d in districts { for e in d.endpoints { for f in e.flows { classKB[f.cls, default: 0] += f.inBytes + f.outBytes } } } + let mix = TrafficClass.allCases.compactMap { c in classKB[c].map { "\(c.rawValue):\($0 / 1024)KB" } }.joined(separator: " ") + FileHandle.standardError.write(Data("[tick] \(top) | \(mix)\n".utf8)) + } + + // 3. Apply expansion state to all hubs, then route this tick's cars. + for (key, hub) in buildings { hub.setExpanded(expanded.contains(key)) } + + for t in districts { + guard let hub = buildings[t.key] else { continue } + if expanded.contains(t.key) { + // Per endpoint, per class: cars run the full Mac→hub→endpoint path. + for e in t.endpoints { + let dest = hub.worldPosition(forEndpoint: e.host) + for f in e.flows { + dispatchSpokeCars(carCount(f.inBytes), hub: hub.position, endpoint: dest, inbound: true, cls: f.cls, bytes: f.inBytes) + dispatchSpokeCars(carCount(f.outBytes), hub: hub.position, endpoint: dest, inbound: false, cls: f.cls, bytes: f.outBytes) + } + if e.inBytes + e.outBytes > 0 { hub.pulseEndpoint(e.host) } + } + } else { + // Collapsed: aggregate every endpoint's flows by class onto the hub. + var byClass: [TrafficClass: (UInt64, UInt64)] = [:] + for e in t.endpoints { + for f in e.flows { + let prior = byClass[f.cls] ?? (0, 0) + byClass[f.cls] = (prior.0 + f.inBytes, prior.1 + f.outBytes) + } + } + for (cls, bytes) in byClass { + dispatchDirectCars(carCount(bytes.0), to: hub.position, inbound: true, cls: cls, bytes: bytes.0) + dispatchDirectCars(carCount(bytes.1), to: hub.position, inbound: false, cls: cls, bytes: bytes.1) + } + } + } + } + + private func carCount(_ bytes: UInt64) -> Int { + guard bytes > 0 else { return 0 } + return max(1, min(7, Int(Double(bytes) / 16_384))) // ~1 car / 16 KB, capped + } + + // MARK: - Buildings & roads + + private func building(forKey key: String, title: String, subtitle: String) -> BuildingNode { + if let existing = buildings[key] { + existing.update(subtitle: subtitle) + return existing + } + + let node = BuildingNode(title: title, subtitle: subtitle) + node.position = placement(for: key, footprint: node.labelFootprint) + node.alpha = 0 + node.run(.fadeIn(withDuration: 0.6)) + buildingLayer.addChild(node) + buildings[key] = node + + if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { + let f = node.labelFootprint + FileHandle.standardError.write(Data(String(format: "[place] %@ pos=(%.0f,%.0f) box=%.0fx%.0f\n", + title, node.position.x, node.position.y, f.width, f.height).utf8)) + } + + let path = CGMutablePath() + path.move(to: .zero) + path.addLine(to: node.position) + let road = SKShapeNode(path: path) + road.strokeColor = Palette.road.withAlphaComponent(0.55) + road.lineWidth = 1.5 + road.zPosition = -20 + roadLayer.addChild(road) + roads[key] = road + + return node + } + + /// Glide a hub to the radius implied by its measured RTT, keeping its angle. + /// The road (and selection ring, if attached) follow. + private func repositionToLatency(key: String, rttMs: Double) { + guard let building = buildings[key] else { return } + let target = LatencyLayout.radius(forRTT: rttMs) + if let current = latencyRadius[key], abs(current - target) < 18 { return } + latencyRadius[key] = target + + let angle = atan2(building.position.y, building.position.x) + let dest = CGPoint(x: cos(angle) * Double(target), y: sin(angle) * Double(target)) + building.run(.move(to: dest, duration: 0.8)) + + if let road = roads[key] { + let path = CGMutablePath() + path.move(to: .zero) + path.addLine(to: dest) + road.path = path + } + if ringKey == key { selectionRing.run(.move(to: dest, duration: 0.8)) } + } + + /// Deterministic radial seed (angle + ring from the key's hash), then spiral + /// out from there until the label box clears every existing district. + private func placement(for key: String, footprint: CGRect) -> CGPoint { + let h = stableHash(key) + let baseAngle = Double(h % 3600) / 3600 * 2 * .pi + let ring = Double((h >> 16) % 1000) / 1000 + let baseRadius = 165 + Double(ring) * Double(worldRadius - 165) + + let existing = buildings.values.map { + $0.labelFootprint.offsetBy(dx: $0.position.x, dy: $0.position.y) + } + return LayoutSolver.placeNonOverlapping( + baseAngle: baseAngle, baseRadius: baseRadius, + localRect: footprint, existing: existing, pad: 18 + ) + } + + // MARK: - Cars + + private func dispatchDirectCars(_ count: Int, to dest: CGPoint, inbound: Bool, cls: TrafficClass, bytes: UInt64) { + guard count > 0 else { return } + let style = carStyle(cls, bytes: bytes) + stagger(count) { [weak self] in + guard let self else { return } + let path = CGMutablePath() + path.move(to: inbound ? dest : .zero) + path.addLine(to: inbound ? .zero : dest) + self.runCar(along: path, start: inbound ? dest : .zero, distance: hypot(dest.x, dest.y), style: style) + } + } + + private func dispatchSpokeCars(_ count: Int, hub: CGPoint, endpoint: CGPoint, inbound: Bool, cls: TrafficClass, bytes: UInt64) { + guard count > 0 else { return } + let style = carStyle(cls, bytes: bytes) + stagger(count) { [weak self] in + guard let self else { return } + let path = CGMutablePath() + if inbound { + path.move(to: endpoint); path.addLine(to: hub); path.addLine(to: .zero) + } else { + path.move(to: .zero); path.addLine(to: hub); path.addLine(to: endpoint) + } + let dist = hypot(hub.x, hub.y) + hypot(endpoint.x - hub.x, endpoint.y - hub.y) + self.runCar(along: path, start: inbound ? endpoint : .zero, distance: dist, style: style) + } + } + + /// Spreads `count` spawns across most of the interval so cars stream rather + /// than appear all at once. + private func stagger(_ count: Int, _ spawn: @escaping () -> Void) { + guard count > 0 else { return } + for i in 0.. 1.05 // streaks orient along their path + let duration = Double((distance / style.speed).clamped(0.6, 2.8)) * Double.random(in: 0.85...1.15) + car.run(.sequence([ + .group([ + .follow(path, asOffset: false, orientToPath: orient, duration: duration), + .sequence([.fadeAlpha(to: 1.0, duration: duration * 0.15), + .wait(forDuration: duration * 0.55), + .fadeAlpha(to: 0.0, duration: duration * 0.3)]), + ]), + .removeFromParent(), + ])) + } + + // MARK: - Pan, zoom & click (macOS) + + override func scrollWheel(with event: NSEvent) { + let factor = 1 - event.scrollingDeltaY * 0.006 + cam.setScale((cam.xScale * factor).clamped(0.45, 3.2)) + } + + override func mouseDown(with event: NSEvent) { + dragDistance = 0 + } + + override func mouseDragged(with event: NSEvent) { + dragDistance += abs(event.deltaX) + abs(event.deltaY) + cam.position.x -= event.deltaX * cam.xScale + cam.position.y += event.deltaY * cam.xScale + } + + override func mouseUp(with event: NSEvent) { + // A near-stationary press is a click (select); a drag is a pan. + guard dragDistance < 6, let view else { return } + // Canonical, camera-correct window → view → scene conversion. + let viewPoint = view.convert(event.locationInWindow, from: nil) + onSelect?(districtKey(at: convertPoint(fromView: viewPoint))) + } +} diff --git a/Sources/NetworkCityApp/CityView.swift b/Sources/NetworkCityApp/CityView.swift new file mode 100644 index 0000000..176f6a2 --- /dev/null +++ b/Sources/NetworkCityApp/CityView.swift @@ -0,0 +1,200 @@ +import SwiftUI +import SpriteKit +import NetworkCityCore + +/// The SpriteKit city with a glassy stats HUD floated on top. +struct CityView: View { + @ObservedObject var controller: TrafficController + + var body: some View { + ZStack(alignment: .topLeading) { + SpriteView(scene: controller.scene, options: [.ignoresSiblingOrder]) + .ignoresSafeArea() + + hud + .padding(14) + + if let inspection = controller.selection { + inspector(inspection) + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.18), value: controller.selection?.id) + .onAppear { controller.start() } + } + + // MARK: - Inspector panel + + static func color(_ c: TrafficClass) -> Color { + switch c { + case .dns: return Color(red: 1.00, green: 0.82, blue: 0.25) + case .https: return Color(red: 0.22, green: 0.88, blue: 1.00) + case .http: return Color(red: 1.00, green: 0.48, blue: 0.24) + case .quic: return Color(red: 0.71, green: 0.48, blue: 1.00) + case .other: return Color(red: 0.55, green: 0.60, blue: 0.72) + } + } + + private func inspector(_ insp: OrgInspection) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(insp.title) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundStyle(.white) + Spacer(minLength: 12) + Button { controller.deselect() } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white.opacity(0.5)) + } + .buttonStyle(.plain) + } + + HStack(spacing: 16) { + Text("▼ \(fmt(insp.downKBs))").foregroundStyle(.cyan) + Text("▲ \(fmt(insp.upKBs))").foregroundStyle(.orange) + Spacer(minLength: 4) + Text("\(insp.endpointCount) pts").foregroundStyle(.white.opacity(0.6)) + } + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + + if !insp.processes.isEmpty { + Text(insp.processes.joined(separator: ", ")) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(2) + } + + Divider().overlay(.white.opacity(0.15)) + + Text("ENDPOINTS").font(.system(size: 9, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.4)) + + ScrollView { + VStack(alignment: .leading, spacing: 7) { + ForEach(insp.endpoints) { ep in endpointRow(ep) } + } + } + .frame(maxHeight: 320) + } + .padding(14) + .frame(width: 290) + .background(.black.opacity(0.62), in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.1))) + } + + private func endpointRow(_ ep: EndpointInfo) -> some View { + HStack(alignment: .top, spacing: 8) { + Circle().fill(Self.color(ep.topClass)).frame(width: 7, height: 7) + .shadow(color: Self.color(ep.topClass), radius: 3) + .padding(.top, 3) + VStack(alignment: .leading, spacing: 1) { + Text(ep.host) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.white.opacity(0.9)) + if let rdns = ep.rdns { + Text(rdns) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.white.opacity(0.45)) + .lineLimit(1).truncationMode(.middle) + } + if let rtt = ep.rttMs { + Text("\(Int(rtt)) ms") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.white.opacity(0.5)) + } + } + Spacer(minLength: 6) + VStack(alignment: .trailing, spacing: 1) { + if ep.downKBs >= 0.1 { Text("▼\(fmt(ep.downKBs))").foregroundStyle(.cyan.opacity(0.85)) } + if ep.upKBs >= 0.1 { Text("▲\(fmt(ep.upKBs))").foregroundStyle(.orange.opacity(0.85)) } + if ep.downKBs < 0.1 && ep.upKBs < 0.1 { Text("idle").foregroundStyle(.white.opacity(0.3)) } + } + .font(.system(size: 9, weight: .semibold, design: .monospaced)) + } + } + + private var hud: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Circle() + .fill(controller.live ? Color.green : Color.orange) + .frame(width: 8, height: 8) + Text("NETWORKCITY") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(.white.opacity(0.9)) + } + Divider().frame(width: 168).overlay(.white.opacity(0.15)) + stat("▼ down", controller.downKBs, .cyan) + stat("▲ up", controller.upKBs, .orange) + stat(label: "districts", value: "\(controller.districtCount)") + + Divider().frame(width: 168).overlay(.white.opacity(0.15)) + legend + + Text(controller.live ? "live · color = protocol · motion = direction" + : "warming up…") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.white.opacity(0.4)) + .padding(.top, 2) + } + .padding(12) + .background(.black.opacity(0.45), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(.white.opacity(0.08))) + } + + private static let legendItems: [(String, Color)] = [ + ("DNS", Color(red: 1.00, green: 0.82, blue: 0.25)), + ("HTTPS", Color(red: 0.22, green: 0.88, blue: 1.00)), + ("HTTP", Color(red: 1.00, green: 0.48, blue: 0.24)), + ("QUIC", Color(red: 0.71, green: 0.48, blue: 1.00)), + ("Other", Color(red: 0.55, green: 0.60, blue: 0.72)), + ] + + private var legend: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Self.legendItems, id: \.0) { name, color in + HStack(spacing: 8) { + Circle().fill(color).frame(width: 7, height: 7) + .shadow(color: color, radius: 3) + Text(name) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.white.opacity(0.8)) + } + } + } + .frame(width: 168, alignment: .leading) + } + + private func stat(_ label: String, _ kbs: Double, _ color: Color) -> some View { + HStack { + Text(label) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(color.opacity(0.9)) + Spacer(minLength: 16) + Text(format(kbs)) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundStyle(.white) + } + .frame(width: 168) + } + + private func stat(label: String, value: String) -> some View { + HStack { + Text(label).font(.system(size: 12, design: .monospaced)).foregroundStyle(.white.opacity(0.7)) + Spacer(minLength: 16) + Text(value).font(.system(size: 12, weight: .semibold, design: .monospaced)).foregroundStyle(.white) + } + .frame(width: 168) + } + + private func format(_ kbs: Double) -> String { + kbs >= 1024 ? String(format: "%.1f MB/s", kbs / 1024) : String(format: "%.0f KB/s", kbs) + } + + /// Compact rate for the dense inspector rows. + private func fmt(_ kbs: Double) -> String { + kbs >= 1024 ? String(format: "%.1fM", kbs / 1024) : String(format: "%.0fK", kbs) + } +} diff --git a/Sources/NetworkCityApp/Inspection.swift b/Sources/NetworkCityApp/Inspection.swift new file mode 100644 index 0000000..7422935 --- /dev/null +++ b/Sources/NetworkCityApp/Inspection.swift @@ -0,0 +1,25 @@ +import NetworkCityCore + +/// One endpoint's live detail for the inspector panel. +struct EndpointInfo: Identifiable { + var id: String { host } + let host: String + let rdns: String? + let downKBs: Double + let upKBs: Double + let topClass: TrafficClass + let rttMs: Double? +} + +/// A live snapshot of one district, shown when its hub is clicked. Rebuilt each +/// tick while selected so the panel updates in real time. +struct OrgInspection: Identifiable { + var id: String { key } + let key: String + let title: String + let downKBs: Double + let upKBs: Double + let endpointCount: Int + let endpoints: [EndpointInfo] + let processes: [String] +} diff --git a/Sources/NetworkCityApp/NetworkCityApp.swift b/Sources/NetworkCityApp/NetworkCityApp.swift new file mode 100644 index 0000000..a216c84 --- /dev/null +++ b/Sources/NetworkCityApp/NetworkCityApp.swift @@ -0,0 +1,27 @@ +import SwiftUI +import AppKit + +@main +struct NetworkCityApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate + @StateObject private var controller = TrafficController() + + var body: some Scene { + WindowGroup("NetworkCity") { + CityView(controller: controller) + .frame(minWidth: 900, minHeight: 620) + .background(.black) + } + .windowStyle(.hiddenTitleBar) + } +} + +/// Running as an SPM executable (no app bundle), so promote ourselves to a +/// regular foreground app and grab focus on launch. +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + func applicationShouldTerminateAfterLastWindowClosed(_ app: NSApplication) -> Bool { true } +} diff --git a/Sources/NetworkCityApp/TrafficController.swift b/Sources/NetworkCityApp/TrafficController.swift new file mode 100644 index 0000000..19f0d1f --- /dev/null +++ b/Sources/NetworkCityApp/TrafficController.swift @@ -0,0 +1,242 @@ +import SwiftUI +import NetworkCityCore + +/// Owns the data feed, enriches it (real process names + reverse-DNS org +/// grouping), and bridges per-district traffic to the scene on the main actor. +@MainActor +final class TrafficController: ObservableObject { + let scene = CityScene(size: CGSize(width: 1200, height: 800)) + + @Published var downKBs: Double = 0 + @Published var upKBs: Double = 0 + @Published var districtCount: Int = 0 + @Published var live = false + @Published var selection: OrgInspection? + + private let source: ConnectionSource = NettopSource() + private let differ = TrafficDiffer() + private let namer = ProcessNamer() + private let prober = LatencyProber() + private let ownPID = Int32(ProcessInfo.processInfo.processIdentifier) + + private var orgForHost: [String: Org] = [:] // resolved IP -> org + private var pending: Set = [] // resolutions in flight + private var rttForHost: [String: Double] = [:] // measured RTT (ms) + private var rttPending: Set = [] + private var portForHost: [String: UInt16] = [:] + private var task: Task? + + // Inspector state: enough per-org history to render the panel live. + private var selectedKey: String? + private var orgTitles: [String: String] = [:] + private var orgEndpoints: [String: Set] = [:] // all hosts ever seen per org + private var orgProcesses: [String: Set] = [:] + private var lastEndpointBytes: [String: [String: [TrafficClass: (UInt64, UInt64)]]] = [:] + private var lastInterval: TimeInterval = 2 + + func start() { + guard task == nil else { return } + scene.onSelect = { [weak self] key in + MainActor.assumeIsolated { self?.select(key) } + } + if ProcessInfo.processInfo.environment["NC_DEMO"] != nil { + startDemo() + return + } + task = Task { [weak self] in + guard let self else { return } + for await snap in source.snapshots(every: 2.0) { + handle(snap) + } + } + } + + /// Offline demo: feeds a synthetic multi-endpoint district through the real + /// scene path so the hub-and-spoke fan-out can be exercised without nettop. + private func startDemo() { + task = Task { [weak self] in + guard let self else { return } + var tick = 0 + while !Task.isCancelled { + tick += 1 + let classes: [TrafficClass] = [.https, .quic, .dns, .http, .other] + let endpoints = (0..<8).map { i -> EndpointTraffic in + let active = (i + tick) % 3 == 0 + let cls = classes[i % classes.count] + let heavy = (cls == .https || cls == .quic) && i % 4 == 0 + return EndpointTraffic(host: "20.190.\(i).\(10 + i)", flows: [ + ClassFlow(cls: cls, + inBytes: active ? (heavy ? 400_000 : 36_000) : 0, + outBytes: active ? 12_000 : 0), + ]) + } + let inB = endpoints.reduce(0) { $0 + $1.inBytes } + let outB = endpoints.reduce(0) { $0 + $1.outBytes } + scene.apply([DistrictTraffic(key: "onedrive", title: "OneDrive", subtitle: "OneDrive", + inBytes: inB, outBytes: outB, endpoints: endpoints)]) + + // Keep the inspector maps live so the demo district is clickable. + orgTitles["onedrive"] = "OneDrive" + orgEndpoints["onedrive", default: []].formUnion(endpoints.map(\.host)) + orgProcesses["onedrive", default: []].insert("OneDrive") + var bytesMap: [String: [TrafficClass: (UInt64, UInt64)]] = [:] + for e in endpoints { for f in e.flows { bytesMap[e.host, default: [:]][f.cls] = (f.inBytes, f.outBytes) } } + lastEndpointBytes["onedrive"] = bytesMap + if let key = selectedKey { selection = buildInspection(key) } + // Verification hook: auto-open the inspector in debug runs. + if tick == 3, ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { select("onedrive") } + + downKBs = Double(inB) / 1024 / 2 + upKBs = Double(outB) / 1024 / 2 + districtCount = scene.buildingCount + live = true + try? await Task.sleep(nanoseconds: 2_000_000_000) + } + } + } + + func stop() { task?.cancel(); task = nil } + + private func handle(_ snapshot: ConnectionSnapshot) { + // Exclude our own traffic so we don't render our latency probes. + let deltas = differ.ingest(snapshot).filter { $0.connection.isExternal && $0.connection.pid != ownPID } + + // Group resolved hosts by org; kick off resolution for the rest. A + // brand-new host shows up one tick late, which is fine — traffic is + // continuous, and this avoids spawning an IP building we'd have to merge. + var districts: [String: DistrictTraffic] = [:] + // orgKey -> host -> class -> (in, out) + var endpoints: [String: [String: [TrafficClass: (UInt64, UInt64)]]] = [:] + for d in deltas { + let host = d.connection.remote.host + guard let org = orgForHost[host] else { resolve(host); continue } + + let proc = namer.name(for: d.connection.pid) ?? d.connection.processName + var district = districts[org.key] + ?? DistrictTraffic(key: org.key, title: org.name, subtitle: proc, + inBytes: 0, outBytes: 0, endpoints: []) + district.inBytes += d.bytesInDelta + district.outBytes += d.bytesOutDelta + district.subtitle = proc + districts[org.key] = district + + let cls = TrafficClass.classify(proto: d.connection.proto, port: d.connection.remote.port) + var hosts = endpoints[org.key] ?? [:] + var classes = hosts[host] ?? [:] + let prior = classes[cls] ?? (0, 0) + classes[cls] = (prior.0 + d.bytesInDelta, prior.1 + d.bytesOutDelta) + hosts[host] = classes + endpoints[org.key] = hosts + + orgEndpoints[org.key, default: []].insert(host) + if let p = d.connection.remote.port, p > 0 { portForHost[host] = UInt16(p) } + probeIfNeeded(host) + } + let rendered = districts.map { key, d -> DistrictTraffic in + var d = d + d.endpoints = (endpoints[key] ?? [:]).map { host, classMap in + EndpointTraffic(host: host, flows: classMap.map { + ClassFlow(cls: $0.key, inBytes: $0.value.0, outBytes: $0.value.1) + }) + } + // Distance = closest measured edge of this org (the nearest server you're served from). + d.rttMs = (orgEndpoints[key] ?? []).compactMap { rttForHost[$0] }.min() + return d + } + scene.apply(rendered) + + // Retain per-org detail for the inspector panel. + lastInterval = deltas.first?.interval ?? 2 + lastEndpointBytes = endpoints + for d in rendered { + orgTitles[d.key] = d.title + orgProcesses[d.key, default: []].insert(d.subtitle) + } + if let key = selectedKey { selection = buildInspection(key) } + + // HUD totals reflect *all* external traffic, resolved or not, so the + // throughput numbers stay honest while districts are still resolving. + downKBs = deltas.reduce(0) { $0 + $1.inBytesPerSec } / 1024 + upKBs = deltas.reduce(0) { $0 + $1.outBytesPerSec } / 1024 + districtCount = scene.buildingCount + live = true + } + + // MARK: - Inspector + + func select(_ key: String?) { + selectedKey = key + scene.highlight(key: key) + selection = key.flatMap(buildInspection) + if let s = selection, ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { + FileHandle.standardError.write(Data("[inspect] \(s.title): \(s.endpointCount) pts ▼\(Int(s.downKBs)) ▲\(Int(s.upKBs)) KB/s top=\(s.endpoints.first?.host ?? "-")\n".utf8)) + } + } + + func deselect() { select(nil) } + + private func buildInspection(_ key: String) -> OrgInspection { + let hosts = orgEndpoints[key] ?? [] + var infos: [EndpointInfo] = [] + var totalDown = 0.0, totalUp = 0.0 + + for host in hosts { + let classMap = lastEndpointBytes[key]?[host] ?? [:] + var down = 0.0, up = 0.0 + var top: (cls: TrafficClass, bytes: UInt64)? + for (cls, bytes) in classMap { + down += Double(bytes.0) / lastInterval / 1024 + up += Double(bytes.1) / lastInterval / 1024 + let total = bytes.0 + bytes.1 + if top == nil || total > top!.bytes { top = (cls, total) } + } + totalDown += down + totalUp += up + infos.append(EndpointInfo(host: host, rdns: orgForHost[host]?.resolvedHost, + downKBs: down, upKBs: up, topClass: top?.cls ?? .other, + rttMs: rttForHost[host])) + } + infos.sort { ($0.downKBs + $0.upKBs) > ($1.downKBs + $1.upKBs) } + + return OrgInspection( + key: key, title: orgTitles[key] ?? key, + downKBs: totalDown, upKBs: totalUp, + endpointCount: hosts.count, endpoints: infos, + processes: (orgProcesses[key] ?? []).sorted() + ) + } + + /// Measure RTT to a newly-seen endpoint once (cached), then store it so the + /// next tick can glide its district to the right distance. + private func probeIfNeeded(_ host: String) { + guard rttForHost[host] == nil, !rttPending.contains(host), + let port = portForHost[host] else { return } + rttPending.insert(host) + Task { [prober] in + // Unreachable (firewalled) endpoints read as "far" rather than missing. + let rtt = await prober.measure(host: host, port: port) ?? LatencyLayout.rttCeiling + rttForHost[host] = rtt + rttPending.remove(host) + if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { + FileHandle.standardError.write(Data("[rtt] \(host):\(port) → \(Int(rtt))ms\n".utf8)) + } + } + } + + /// Resolve a host's org off the main thread, then store it back on the main + /// actor. Reverse DNS can block for seconds, so it runs detached. + private func resolve(_ host: String) { + guard orgForHost[host] == nil, pending.insert(host).inserted else { return } + Task { + let org = await Task.detached(priority: .utility) { + let ptr = ReverseDNS.resolve(host) + return OrgClassifier.classify(ip: host, ptr: ptr) + }.value + orgForHost[host] = org + pending.remove(host) + if ProcessInfo.processInfo.environment["NC_DEBUG"] != nil { + FileHandle.standardError.write(Data("[org] \(host) → \(org.name) (\(org.resolvedHost ?? "no-ptr"))\n".utf8)) + } + } + } +} diff --git a/Sources/NetworkCityApp/Visuals.swift b/Sources/NetworkCityApp/Visuals.swift new file mode 100644 index 0000000..5a6cd88 --- /dev/null +++ b/Sources/NetworkCityApp/Visuals.swift @@ -0,0 +1,99 @@ +import SpriteKit +import NetworkCityCore + +/// How a car of light looks for a given traffic class + magnitude. +struct CarStyle { + let color: SKColor + let scale: CGFloat + let elongation: CGFloat // 1 = round dot; >1 = streak along motion + let speed: CGFloat // points/sec +} + +/// Colour per traffic class — direction is read from motion, so hue is free to +/// mean protocol. +enum TrafficPalette { + static func color(_ c: TrafficClass) -> SKColor { + switch c { + case .dns: return SKColor(red: 1.00, green: 0.82, blue: 0.25, alpha: 1) // gold + case .https: return SKColor(red: 0.22, green: 0.88, blue: 1.00, alpha: 1) // cyan + case .http: return SKColor(red: 1.00, green: 0.48, blue: 0.24, alpha: 1) // orange + case .quic: return SKColor(red: 0.71, green: 0.48, blue: 1.00, alpha: 1) // violet + case .other: return SKColor(red: 0.55, green: 0.60, blue: 0.72, alpha: 1) // slate + } + } +} + +/// Translate a class + this-tick byte volume into a car's personality. Heavy +/// flows become "freight": bigger, slower, stretched into a glowing comet. +func carStyle(_ cls: TrafficClass, bytes: UInt64) -> CarStyle { + let heavy = bytes > 196_608 // ~96 KB/s over a 2s tick + var scale: CGFloat + var elongation: CGFloat = 1 + var speed: CGFloat = 230 + + switch cls { + case .dns: scale = 0.055; speed = 360 // tiny, fast sparks + case .https: scale = 0.085 + case .http: scale = 0.085 + case .quic: scale = 0.085; elongation = 2.4; speed = 285 // streaks + case .other: scale = 0.065 + } + + if heavy { + scale *= 1.7 + elongation = max(elongation, 3.0) // freight comet + speed *= 0.7 // slow and heavy + } + return CarStyle(color: TrafficPalette.color(cls), scale: scale, elongation: elongation, speed: speed) +} + +/// Neon-night palette. Bright, saturated colors on near-black so additive +/// blending reads as glow. +enum Palette { + static let background = SKColor(red: 0.039, green: 0.055, blue: 0.102, alpha: 1) // #0a0e1a + static let grid = SKColor(red: 0.13, green: 0.20, blue: 0.38, alpha: 1) + static let hub = SKColor(red: 1.00, green: 0.85, blue: 0.62, alpha: 1) // warm downtown + static let building = SKColor(red: 0.50, green: 0.82, blue: 0.78, alpha: 1) // teal + static let road = SKColor(red: 0.17, green: 0.26, blue: 0.46, alpha: 1) + static let download = SKColor(red: 0.22, green: 0.88, blue: 1.00, alpha: 1) // cyan, inbound + static let upload = SKColor(red: 1.00, green: 0.62, blue: 0.30, alpha: 1) // amber, outbound +} + +extension CGFloat { + func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { Swift.min(Swift.max(self, lo), hi) } +} + +enum Textures { + /// A white radial-gradient disc (opaque center → transparent edge). Tint it + /// per-node with `colorBlendFactor` + `.add` blend mode to get a glow. + // Immutable after creation and only read; safe to share across actors. + nonisolated(unsafe) static let softCircle: SKTexture = makeSoftCircle(diameter: 128) + + private static func makeSoftCircle(diameter: Int) -> SKTexture { + let space = CGColorSpaceCreateDeviceRGB() + let ctx = CGContext( + data: nil, width: diameter, height: diameter, + bitsPerComponent: 8, bytesPerRow: 0, space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + let colors = [ + SKColor.white.cgColor, + SKColor.white.withAlphaComponent(0).cgColor, + ] as CFArray + let gradient = CGGradient(colorsSpace: space, colors: colors, locations: [0, 1])! + let center = CGPoint(x: diameter / 2, y: diameter / 2) + ctx.drawRadialGradient( + gradient, startCenter: center, startRadius: 0, + endCenter: center, endRadius: CGFloat(diameter) / 2, options: [] + ) + return SKTexture(cgImage: ctx.makeImage()!) + } +} + +/// Deterministic FNV-1a hash so a given host always lands in the same place, +/// independent of Swift's per-process `Hasher` seed. +func stableHash(_ s: String) -> UInt64 { + var h: UInt64 = 1469598103934665603 + for byte in s.utf8 { h = (h ^ UInt64(byte)) &* 1099511628211 } + return h +} diff --git a/Sources/NetworkCityCore/ConnectionSource.swift b/Sources/NetworkCityCore/ConnectionSource.swift new file mode 100644 index 0000000..1f298c5 --- /dev/null +++ b/Sources/NetworkCityCore/ConnectionSource.swift @@ -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 +} diff --git a/Sources/NetworkCityCore/Enrichment.swift b/Sources/NetworkCityCore/Enrichment.swift new file mode 100644 index 0000000..1b24d26 --- /dev/null +++ b/Sources/NetworkCityCore/Enrichment.swift @@ -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 } + + /// 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 = [ + "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() + } +} diff --git a/Sources/NetworkCityCore/ExpansionPolicy.swift b/Sources/NetworkCityCore/ExpansionPolicy.swift new file mode 100644 index 0000000..00340aa --- /dev/null +++ b/Sources/NetworkCityCore/ExpansionPolicy.swift @@ -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 { + // 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() + for (key, remaining) in ttl where remaining > 0 { + if (endpointCounts[key] ?? 0) >= 2 { expanded.insert(key) } + } + return expanded + } +} diff --git a/Sources/NetworkCityCore/IPRanges.swift b/Sources/NetworkCityCore/IPRanges.swift new file mode 100644 index 0000000..2347eba --- /dev/null +++ b/Sources/NetworkCityCore/IPRanges.swift @@ -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 + } +} diff --git a/Sources/NetworkCityCore/LatencyLayout.swift b/Sources/NetworkCityCore/LatencyLayout.swift new file mode 100644 index 0000000..98a6a13 --- /dev/null +++ b/Sources/NetworkCityCore/LatencyLayout.swift @@ -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) + } +} diff --git a/Sources/NetworkCityCore/LatencyProber.swift b/Sources/NetworkCityCore/LatencyProber.swift new file mode 100644 index 0000000..4145cff --- /dev/null +++ b/Sources/NetworkCityCore/LatencyProber.swift @@ -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) 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 + + init(conn: NWConnection, cont: CheckedContinuation) { + 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) + } + } +} diff --git a/Sources/NetworkCityCore/LayoutSolver.swift b/Sources/NetworkCityCore/LayoutSolver.swift new file mode 100644 index 0000000..52c9509 --- /dev/null +++ b/Sources/NetworkCityCore/LayoutSolver.swift @@ -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..\(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 + } +} diff --git a/Sources/NetworkCityCore/NettopParser.swift b/Sources/NetworkCityCore/NettopParser.swift new file mode 100644 index 0000000..5b04c2e --- /dev/null +++ b/Sources/NetworkCityCore/NettopParser.swift @@ -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..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..") else { return nil } + let localStr = String(tuple[tuple.startIndex.. 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.. `fe80::1`. + static func cleanHost(_ h: String) -> String { + if let pct = h.firstIndex(of: "%") { + return String(h[h.startIndex.. AsyncStream { + 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) + } +} diff --git a/Sources/NetworkCityCore/ProcessNamer.swift b/Sources/NetworkCityCore/ProcessNamer.swift new file mode 100644 index 0000000..c6b1620 --- /dev/null +++ b/Sources/NetworkCityCore/ProcessNamer.swift @@ -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 + } +} diff --git a/Sources/NetworkCityCore/ReverseDNS.swift b/Sources/NetworkCityCore/ReverseDNS.swift new file mode 100644 index 0000000..84d5c61 --- /dev/null +++ b/Sources/NetworkCityCore/ReverseDNS.swift @@ -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.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...size])) + } + } + length = socklen_t(MemoryLayout.size) + } else { + var sa = sockaddr_in() + sa.sin_family = sa_family_t(AF_INET) + sa.sin_len = UInt8(MemoryLayout.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...size])) + } + } + length = socklen_t(MemoryLayout.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 + } +} diff --git a/Sources/NetworkCityCore/TrafficClass.swift b/Sources/NetworkCityCore/TrafficClass.swift new file mode 100644 index 0000000..fd1eea5 --- /dev/null +++ b/Sources/NetworkCityCore/TrafficClass.swift @@ -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 + } + } +} diff --git a/Sources/NetworkCityCore/TrafficDiffer.swift b/Sources/NetworkCityCore/TrafficDiffer.swift new file mode 100644 index 0000000..9786fee --- /dev/null +++ b/Sources/NetworkCityCore/TrafficDiffer.swift @@ -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 + } +} diff --git a/Sources/nettop-probe/main.swift b/Sources/nettop-probe/main.swift new file mode 100644 index 0000000..7a3a4ea --- /dev/null +++ b/Sources/nettop-probe/main.swift @@ -0,0 +1,45 @@ +import Foundation +import NetworkCityCore + +// Phase-1 proof of concept. Polls nettop, diffs successive snapshots, and prints +// the busiest external flows as live KB/s — i.e. the raw material that will +// become cars driving to buildings. + +setvbuf(stdout, nil, _IONBF, 0) // unbuffered: see output live even when piped + +let interval: TimeInterval = 2.0 +let source = NettopSource() +let differ = TrafficDiffer() +var warmedUp = false + +func pad(_ s: String, _ width: Int) -> String { + s.count >= width ? String(s.prefix(width)) : s + String(repeating: " ", count: width - s.count) +} + +print("📡 NetworkCity probe — sampling every \(Int(interval))s. Ctrl-C to stop.\n") + +for await snap in source.snapshots(every: interval) { + let deltas = differ.ingest(snap).filter { $0.connection.isExternal } + let external = snap.connections.filter(\.isExternal).count + + if !warmedUp { + warmedUp = true + print("… warming up (need two samples to compute rates) …\n") + continue + } + + let active = deltas.sorted { ($0.inBytesPerSec + $0.outBytesPerSec) > ($1.inBytesPerSec + $1.outBytesPerSec) } + let ts = DateFormatter.localizedString(from: snap.timestamp, dateStyle: .none, timeStyle: .medium) + + print("─── \(ts) \(active.count) active / \(external) external connections ───") + print("\(pad("PROCESS", 18)) \(pad("DESTINATION", 24)) ↓ KB/s ↑ KB/s") + if active.isEmpty { + print(" (quiet — no measurable traffic this interval)") + } + for d in active.prefix(15) { + let dest = d.connection.remote.display + print("\(pad(d.connection.processName, 18)) \(pad(dest, 24)) " + + "\(String(format: "%9.1f", d.inBytesPerSec / 1024)) \(String(format: "%9.1f", d.outBytesPerSec / 1024))") + } + print("") +} diff --git a/Tests/NetworkCityCoreTests/ExpansionPolicyTests.swift b/Tests/NetworkCityCoreTests/ExpansionPolicyTests.swift new file mode 100644 index 0000000..b0359e0 --- /dev/null +++ b/Tests/NetworkCityCoreTests/ExpansionPolicyTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import NetworkCityCore + +final class ExpansionPolicyTests: XCTestCase { + typealias D = ExpansionPolicy.District + + func testBusyMultiEndpointOrgExpands() { + let policy = ExpansionPolicy() + let expanded = policy.update([D(key: "onedrive", bytes: 500_000, endpointCount: 15)]) + XCTAssertTrue(expanded.contains("onedrive")) + } + + func testSingleEndpointNeverExpandsEvenIfHuge() { + // The exact case we saw live: Google at 126 MB but only one active IP. + let policy = ExpansionPolicy() + let expanded = policy.update([D(key: "google", bytes: 126_000_000, endpointCount: 1)]) + XCTAssertTrue(expanded.isEmpty) + } + + func testQuietOrgDoesNotExpand() { + let policy = ExpansionPolicy(floorBytes: 8 * 1024) + let expanded = policy.update([D(key: "apple", bytes: 100, endpointCount: 5)]) + XCTAssertTrue(expanded.isEmpty) + } + + func testOnlyTopKExpand() { + let policy = ExpansionPolicy(topK: 2) + let districts = (0..<5).map { D(key: "org\($0)", bytes: UInt64(1000 - $0) * 1024, endpointCount: 3) } + let expanded = policy.update(districts) + XCTAssertEqual(expanded, ["org0", "org1"]) // two busiest only + } + + func testHysteresisKeepsExpandedThenCollapses() { + let policy = ExpansionPolicy(ttlTicks: 3) + // Tick 1: busy -> expands. + XCTAssertTrue(policy.update([D(key: "x", bytes: 1_000_000, endpointCount: 4)]).contains("x")) + // Now idle, but still has endpoints: stays up for the TTL tail. + XCTAssertTrue(policy.update([D(key: "x", bytes: 0, endpointCount: 4)]).contains("x")) // ttl 2 + XCTAssertTrue(policy.update([D(key: "x", bytes: 0, endpointCount: 4)]).contains("x")) // ttl 1 + XCTAssertFalse(policy.update([D(key: "x", bytes: 0, endpointCount: 4)]).contains("x")) // ttl 0 -> collapsed + } + + func testExpiredTimerWithLostEndpointsStaysCollapsed() { + let policy = ExpansionPolicy(ttlTicks: 2) + _ = policy.update([D(key: "x", bytes: 1_000_000, endpointCount: 4)]) + // Endpoints drop to 1 while timer still alive -> nothing to fan out. + XCTAssertFalse(policy.update([D(key: "x", bytes: 0, endpointCount: 1)]).contains("x")) + } +} diff --git a/Tests/NetworkCityCoreTests/IPRangesTests.swift b/Tests/NetworkCityCoreTests/IPRangesTests.swift new file mode 100644 index 0000000..955e1d7 --- /dev/null +++ b/Tests/NetworkCityCoreTests/IPRangesTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import NetworkCityCore + +final class IPRangesTests: XCTestCase { + + func testAppleEightSlashEight() { + XCTAssertEqual(IPRanges.match("17.248.242.19")?.key, "apple") + XCTAssertEqual(IPRanges.match("17.42.251.69")?.name, "Apple") + } + + func testTelegram() { + XCTAssertEqual(IPRanges.match("149.154.175.54")?.key, "telegram") + } + + func testPrivateRangesAreLocalNetwork() { + XCTAssertEqual(IPRanges.match("192.168.0.1")?.name, "Local Network") + XCTAssertEqual(IPRanges.match("10.1.2.3")?.key, "lan") + XCTAssertEqual(IPRanges.match("172.20.0.5")?.key, "lan") + } + + func testUnknownIPReturnsNil() { + XCTAssertNil(IPRanges.match("8.8.8.8")) + } + + func testInvalidIPReturnsNil() { + XCTAssertNil(IPRanges.match("not.an.ip")) + XCTAssertNil(IPRanges.match("999.1.1.1")) + } + + func testClassifyUsesRangeFallbackWhenNoPTR() { + // Apple publishes no PTR for 17/8, so the range table must catch it. + let org = OrgClassifier.classify(ip: "17.248.242.19", ptr: nil) + XCTAssertEqual(org.key, "apple") + XCTAssertEqual(org.name, "Apple") + } + + func testPTRStillWinsOverRange() { + // A real registrable domain in the PTR should take precedence. + let org = OrgClassifier.classify(ip: "17.248.242.19", ptr: "host.example.com") + XCTAssertEqual(org.key, "example.com") + } +} diff --git a/Tests/NetworkCityCoreTests/LatencyLayoutTests.swift b/Tests/NetworkCityCoreTests/LatencyLayoutTests.swift new file mode 100644 index 0000000..819488f --- /dev/null +++ b/Tests/NetworkCityCoreTests/LatencyLayoutTests.swift @@ -0,0 +1,35 @@ +import XCTest +import CoreGraphics +@testable import NetworkCityCore + +final class LatencyLayoutTests: XCTestCase { + + func testZeroRTTSitsAtInnerRadius() { + XCTAssertEqual(LatencyLayout.radius(forRTT: 0), LatencyLayout.minRadius, accuracy: 0.001) + } + + func testCeilingSitsAtOuterEdge() { + XCTAssertEqual(LatencyLayout.radius(forRTT: 250), LatencyLayout.maxRadius, accuracy: 0.001) + } + + func testClampsBeyondCeiling() { + XCTAssertEqual(LatencyLayout.radius(forRTT: 5000), LatencyLayout.maxRadius, accuracy: 0.001) + } + + func testMonotonicAndWithinBounds() { + var last = LatencyLayout.radius(forRTT: 0) + for rtt in stride(from: 5.0, through: 250.0, by: 5) { + let r = LatencyLayout.radius(forRTT: rtt) + XCTAssertGreaterThan(r, last) // farther RTT -> farther out + XCTAssertLessThanOrEqual(r, LatencyLayout.maxRadius) + last = r + } + } + + func testNearRangeSpreadsOut() { + // The log curve should give the 1–50ms band real separation, not a clump. + let r10 = LatencyLayout.radius(forRTT: 10) + let r50 = LatencyLayout.radius(forRTT: 50) + XCTAssertGreaterThan(r50 - r10, 40) // meaningfully far apart on screen + } +} diff --git a/Tests/NetworkCityCoreTests/LayoutSolverTests.swift b/Tests/NetworkCityCoreTests/LayoutSolverTests.swift new file mode 100644 index 0000000..2a2a729 --- /dev/null +++ b/Tests/NetworkCityCoreTests/LayoutSolverTests.swift @@ -0,0 +1,45 @@ +import XCTest +import CoreGraphics +@testable import NetworkCityCore + +final class LayoutSolverTests: XCTestCase { + + // A label box centered under the node: ~160 wide, sitting below the dot. + private let label = CGRect(x: -80, y: -34, width: 160, height: 30) + + private func worldBox(at c: CGPoint) -> CGRect { label.offsetBy(dx: c.x, dy: c.y) } + + func testEmptyMapReturnsSeed() { + let p = LayoutSolver.placeNonOverlapping(baseAngle: 0, baseRadius: 200, localRect: label, existing: []) + XCTAssertEqual(p.x, 200, accuracy: 0.001) // attempt 0 == seed + XCTAssertEqual(p.y, 0, accuracy: 0.001) + } + + func testAvoidsOverlapWithExisting() { + // Pre-place a box exactly where the seed would land. + let seed = CGPoint(x: cos(0.0) * 200, y: sin(0.0) * 200) + let existing = [worldBox(at: seed).insetBy(dx: -16, dy: -16)] + + let p = LayoutSolver.placeNonOverlapping(baseAngle: 0, baseRadius: 200, localRect: label, existing: existing) + XCTAssertNotEqual(p, seed) // had to move + + // The chosen spot's padded box must clear the existing one. + let placed = worldBox(at: p).insetBy(dx: -16, dy: -16) + XCTAssertFalse(placed.intersects(existing[0])) + } + + func testResultClearsAllOfManyNeighbours() { + // Seed eight districts and confirm none of their boxes overlap. + var placed: [CGRect] = [] + for i in 0..<8 { + let angle = Double(i) / 8 * 2 * .pi + let c = LayoutSolver.placeNonOverlapping(baseAngle: angle, baseRadius: 180, localRect: label, existing: placed) + placed.append(worldBox(at: c)) + } + for a in 0..*:*,,, + 23:49:56.574395,apsd.571,15671580,11326466, + 23:49:56.573044,tcp4 192.168.10.194:57359<->17.57.144.184:5223,15671580,11326466, + 23:49:56.574397,trustd.609,10032,18469, + 23:49:56.569491,quic4 192.168.10.194:51698<->17.248.242.102:443,5017,9232, + 23:49:56.574410,mDNSResponder.647,974941537,77402609, + 23:49:56.569906,udp6 *.5353<->*.*,176848739,34857088, + 23:49:56.574316,tcp6 fe80::d08d:6eb7:1dcd:6466%utun4.1024<->fe80::9ed2:d4f7:aa69:9e47%utun4.1024,0,0, + """ + + func testParsesProcessAndConnectionRows() { + let conns = NettopParser.parseConnections(sample) + // 4 connection rows present (launchd listener, apsd, trustd, mDNS, utun6) + XCTAssertEqual(conns.count, 5) + } + + func testConnectionInheritsPrecedingProcess() { + let conns = NettopParser.parseConnections(sample) + let apsd = conns.first { $0.remote.host == "17.57.144.184" } + XCTAssertEqual(apsd?.processName, "apsd") + XCTAssertEqual(apsd?.pid, 571) + XCTAssertEqual(apsd?.proto, .tcp4) + XCTAssertEqual(apsd?.remote.port, 5223) + XCTAssertEqual(apsd?.bytesIn, 15671580) + XCTAssertEqual(apsd?.bytesOut, 11326466) + } + + func testIPv4EndpointParsing() { + let ep = NettopParser.parseEndpoint("192.168.10.194:57359", isV6: false) + XCTAssertEqual(ep.host, "192.168.10.194") + XCTAssertEqual(ep.port, 57359) + } + + func testIPv6EndpointParsingStripsZone() { + let ep = NettopParser.parseEndpoint("fe80::d08d:6eb7:1dcd:6466%utun4.1024", isV6: true) + XCTAssertEqual(ep.host, "fe80::d08d:6eb7:1dcd:6466") + XCTAssertEqual(ep.port, 1024) + } + + func testWildcardEndpointHasNoPort() { + XCTAssertNil(NettopParser.parseEndpoint("*:*", isV6: false).port) + XCTAssertNil(NettopParser.parseEndpoint("*.*", isV6: true).port) + } + + func testExternalClassification() { + let conns = NettopParser.parseConnections(sample) + let external = conns.filter(\.isExternal) + // Only apsd (17.57.x) and quic (17.248.x) are real off-box destinations. + // Loopback listener, mDNS wildcard, and fe80 link-local are excluded. + XCTAssertEqual(Set(external.map(\.remote.host)), ["17.57.144.184", "17.248.242.102"]) + } + + func testQuicProtocolRecognised() { + let conns = NettopParser.parseConnections(sample) + XCTAssertTrue(conns.contains { $0.proto == .quic4 }) + } +} diff --git a/Tests/NetworkCityCoreTests/OrgClassifierTests.swift b/Tests/NetworkCityCoreTests/OrgClassifierTests.swift new file mode 100644 index 0000000..3b1feaa --- /dev/null +++ b/Tests/NetworkCityCoreTests/OrgClassifierTests.swift @@ -0,0 +1,46 @@ +import XCTest +@testable import NetworkCityCore + +final class OrgClassifierTests: XCTestCase { + + func testRegistrableDomainSimple() { + XCTAssertEqual(OrgClassifier.registrableDomain("cdn.gstatic.com"), "gstatic.com") + XCTAssertEqual(OrgClassifier.registrableDomain("a.b.c.example.org"), "example.org") + } + + func testRegistrableDomainMultiPartSuffix() { + XCTAssertEqual(OrgClassifier.registrableDomain("www.bbc.co.uk"), "bbc.co.uk") + XCTAssertEqual(OrgClassifier.registrableDomain("shop.foo.com.au"), "foo.com.au") + } + + func testRegistrableDomainHandlesTrailingDot() { + XCTAssertEqual(OrgClassifier.registrableDomain("host.apple.com."), "apple.com") + } + + func testKnownOrgsCollapseToOneDistrict() { + // Different registrable domains, same owner -> same key. + let a = OrgClassifier.classify(ip: "1", ptr: "cdn.gstatic.com") + let b = OrgClassifier.classify(ip: "2", ptr: "lb.1e100.net") + XCTAssertEqual(a.key, "google") + XCTAssertEqual(b.key, "google") + XCTAssertEqual(a.name, "Google") + } + + func testAppleFamilyCollapses() { + XCTAssertEqual(OrgClassifier.classify(ip: "1", ptr: "x.icloud.com").key, "apple") + XCTAssertEqual(OrgClassifier.classify(ip: "2", ptr: "y.aaplimg.com").key, "apple") + } + + func testUnknownDomainGetsPrettyName() { + let org = OrgClassifier.classify(ip: "9.9.9.9", ptr: "resolver.quad9.net") + XCTAssertEqual(org.key, "quad9.net") + XCTAssertEqual(org.name, "Quad9") + } + + func testNoPTRFallsBackToIP() { + let org = OrgClassifier.classify(ip: "203.0.113.7", ptr: nil) + XCTAssertEqual(org.key, "203.0.113.7") + XCTAssertEqual(org.name, "203.0.113.7") + XCTAssertNil(org.resolvedHost) + } +} diff --git a/Tests/NetworkCityCoreTests/TrafficClassTests.swift b/Tests/NetworkCityCoreTests/TrafficClassTests.swift new file mode 100644 index 0000000..da4f8fe --- /dev/null +++ b/Tests/NetworkCityCoreTests/TrafficClassTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import NetworkCityCore + +final class TrafficClassTests: XCTestCase { + + func testQuicByProtocol() { + XCTAssertEqual(TrafficClass.classify(proto: .quic4, port: 443), .quic) + XCTAssertEqual(TrafficClass.classify(proto: .quic6, port: 12345), .quic) + } + + func testHTTPSOverTCP() { + XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 443), .https) + } + + func testUDP443IsQuic() { + XCTAssertEqual(TrafficClass.classify(proto: .udp4, port: 443), .quic) + } + + func testDNS() { + XCTAssertEqual(TrafficClass.classify(proto: .udp4, port: 53), .dns) + XCTAssertEqual(TrafficClass.classify(proto: .udp6, port: 5353), .dns) + XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 53), .dns) + } + + func testHTTP() { + XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 80), .http) + XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 8080), .http) + } + + func testOther() { + XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: 22), .other) + XCTAssertEqual(TrafficClass.classify(proto: .tcp4, port: nil), .other) + } +} diff --git a/Tests/NetworkCityCoreTests/TrafficDifferTests.swift b/Tests/NetworkCityCoreTests/TrafficDifferTests.swift new file mode 100644 index 0000000..6b98f12 --- /dev/null +++ b/Tests/NetworkCityCoreTests/TrafficDifferTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import NetworkCityCore + +final class TrafficDifferTests: XCTestCase { + + private func conn(_ host: String, in bIn: UInt64, out bOut: UInt64) -> Connection { + Connection( + proto: .tcp4, + local: Endpoint(host: "192.168.1.2", port: 5000), + remote: Endpoint(host: host, port: 443), + bytesIn: bIn, bytesOut: bOut, + processName: "test", pid: 1 + ) + } + + func testFirstSnapshotYieldsNoDeltas() { + let differ = TrafficDiffer() + let t0 = Date(timeIntervalSince1970: 1000) + let deltas = differ.ingest(ConnectionSnapshot(timestamp: t0, connections: [conn("1.1.1.1", in: 100, out: 50)])) + XCTAssertTrue(deltas.isEmpty) // need two readings to know a rate + } + + func testComputesDeltaAndRate() { + let differ = TrafficDiffer() + let t0 = Date(timeIntervalSince1970: 1000) + let t1 = Date(timeIntervalSince1970: 1002) // +2s + _ = differ.ingest(ConnectionSnapshot(timestamp: t0, connections: [conn("1.1.1.1", in: 100, out: 50)])) + let deltas = differ.ingest(ConnectionSnapshot(timestamp: t1, connections: [conn("1.1.1.1", in: 1124, out: 50)])) + + XCTAssertEqual(deltas.count, 1) + XCTAssertEqual(deltas[0].bytesInDelta, 1024) + XCTAssertEqual(deltas[0].bytesOutDelta, 0) + XCTAssertEqual(deltas[0].inBytesPerSec, 512, accuracy: 0.001) // 1024 / 2s + } + + func testCounterResetTreatedAsZero() { + let differ = TrafficDiffer() + let t0 = Date(timeIntervalSince1970: 1000) + let t1 = Date(timeIntervalSince1970: 1002) + _ = differ.ingest(ConnectionSnapshot(timestamp: t0, connections: [conn("1.1.1.1", in: 9999, out: 0)])) + let deltas = differ.ingest(ConnectionSnapshot(timestamp: t1, connections: [conn("1.1.1.1", in: 10, out: 0)])) + XCTAssertTrue(deltas.isEmpty) // reused tuple, counter dropped -> no phantom delta + } +}