Initial commit: NetworkCity — network traffic as a neon city
A native macOS network monitor that renders live traffic as a top-down neon city: your Mac is downtown, remote orgs are glowing districts, and traffic is cars of light driving the roads. Architecture: - NetworkCityCore: swappable data layer behind a ConnectionSource protocol (nettop-backed today), plus pure/tested logic — traffic diffing, org classification (reverse-DNS + CIDR), traffic classes, hub-and-spoke expansion policy, label anti-overlap, and latency→distance layout. - NetworkCityApp: SwiftUI + SpriteKit city — auto-expanding districts, protocol-coloured cars, click-to-inspect panel, and latency-as-distance (districts glide to their measured RTT). - nettop-probe: CLI proof of the data layer. 44 tests passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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"))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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..<placed.count {
|
||||
for b in (a + 1)..<placed.count {
|
||||
XCTAssertFalse(placed[a].intersects(placed[b]), "districts \(a) and \(b) overlap")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import XCTest
|
||||
@testable import NetworkCityCore
|
||||
|
||||
/// Fixtures are taken verbatim from real `nettop -L 1 -x -n -J time,bytes_in,bytes_out`
|
||||
/// output captured on macOS 26, so the parser is pinned to reality.
|
||||
final class NettopParserTests: XCTestCase {
|
||||
|
||||
let sample = """
|
||||
time,,bytes_in,bytes_out,
|
||||
23:49:56.574393,launchd.1,0,0,
|
||||
23:49:56.573243,tcp4 127.0.0.1:8021<->*:*,,,
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user