Shared/Models)The vocabulary types shared by both processes. All are value types and Sendable. These are
the foundation; build them first (milestone M1) and keep them dependency-free (Foundation only).
FlowKey is canonical: it identifies a connection regardless of packet direction, so
the flow table sees both directions as one flow./// Familia de direcciones IP del paquete.
public enum IPVersion: UInt8, Sendable, Codable {
case v4 = 4
case v6 = 6
}
/// Número de protocolo de la capa de transporte (campo `protocol`/`next header`).
public enum IPProtocolNumber: UInt8, Sendable, Codable {
case tcp = 6
case udp = 17
case icmp = 1
case icmpv6 = 58
case other = 255 // cualquier otro; el valor real se guarda aparte en `PacketMeta.rawProtocol`
}
/// Sentido del paquete relativo al dispositivo.
public enum Direction: UInt8, Sendable, Codable {
case outbound = 0 // del dispositivo hacia internet
case inbound = 1 // de internet hacia el dispositivo
}
/// Estado de inspección TLS de un flujo.
public enum TLSInspectionStatus: UInt8, Sendable, Codable {
case plaintext = 0 // no cifrado (p. ej. HTTP)
case encrypted = 1 // cifrado y no inspeccionado (inspección off o no es 443)
case inspected = 2 // cifrado y descifrado con consentimiento vía CA local
case notInspectable = 3 // rechazó nuestra CA (pinning): se relayea intacto
}
/// Dirección IP + puerto. La IP se guarda en su forma binaria (4 o 16 bytes).
/// `Comparable` da el orden total estable que usa la canonicalización de `FlowKey`
/// (por dirección y luego por puerto).
public struct IPEndpoint: Sendable, Hashable, Codable, Comparable, CustomStringConvertible {
public let address: IPAddress // wrapper sobre los bytes crudos (ver abajo)
public let port: UInt16
public init(address: IPAddress, port: UInt16)
}
/// IP cruda; evita depender de `Network.IPv4Address` en el modelo base. `Comparable` ordena
/// primero por familia y luego lexicográficamente por los bytes.
public struct IPAddress: Sendable, Hashable, Codable, Comparable, CustomStringConvertible {
public let version: IPVersion
public let bytes: [UInt8] // 4 para v4, 16 para v6
public init(version: IPVersion, bytes: [UInt8])
public var description: String { get } // texto canónico (dotted/colon-hex)
}
/// Identidad canónica de un flujo (5-tupla), independiente del sentido del paquete.
///
/// La canonicalización ordena los dos endpoints de forma estable (`endpointA <= endpointB`)
/// para que un paquete outbound y su respuesta inbound produzcan la MISMA clave. Como la clave
/// es canónica, NO almacena nada dependiente del sentido; `direction(ofPacketFrom:localAddress:)`
/// reconstruye el sentido a partir de la dirección local del dispositivo.
public struct FlowKey: Sendable, Hashable, Codable {
public let proto: IPProtocolNumber
public let endpointA: IPEndpoint // menor según orden canónico
public let endpointB: IPEndpoint // mayor según orden canónico
/// Construye la clave canónica a partir del par (origen, destino) tal como vienen en el paquete.
public init(proto: IPProtocolNumber, source: IPEndpoint, destination: IPEndpoint)
/// Reconstruye el sentido de un paquete a partir de su endpoint origen y de la dirección IP
/// local del dispositivo: `.outbound` si el origen es la IP local, `.inbound` si no.
/// Requiere `localAddress` porque la clave canónica, por sí sola, no distingue cuál de los dos
/// endpoints es el dispositivo. Precondición: `source` es uno de los dos endpoints del flujo.
public func direction(ofPacketFrom source: IPEndpoint, localAddress: IPAddress) -> Direction
}
These are produced by the parser and consumed immediately; they are not persisted as-is.
public struct IPHeader: Sendable {
public let version: IPVersion
public let source: IPAddress
public let destination: IPAddress
public let proto: IPProtocolNumber
public let rawProtocol: UInt8 // valor real del campo, incluso si `proto == .other`
public let payloadRange: Range<Int> // rango del payload L4 dentro del buffer original
public let totalLength: Int
}
public struct TCPHeader: Sendable {
public let sourcePort: UInt16
public let destinationPort: UInt16
public let sequence: UInt32
public let acknowledgment: UInt32
public let flags: TCPFlags
public let windowSize: UInt16
public let dataOffsetBytes: Int // longitud de cabecera TCP (incluye opciones)
public let payloadRange: Range<Int>
}
public struct UDPHeader: Sendable {
public let sourcePort: UInt16
public let destinationPort: UInt16
public let length: UInt16
public let payloadRange: Range<Int>
}
public struct TCPFlags: OptionSet, Sendable {
public let rawValue: UInt8
public init(rawValue: UInt8)
public static let fin = TCPFlags(rawValue: 1 << 0)
public static let syn = TCPFlags(rawValue: 1 << 1)
public static let rst = TCPFlags(rawValue: 1 << 2)
public static let psh = TCPFlags(rawValue: 1 << 3)
public static let ack = TCPFlags(rawValue: 1 << 4)
public static let urg = TCPFlags(rawValue: 1 << 5)
}
/// Metadato compacto de un paquete: lo que viaja por el ring buffer y a la tabla `packets`.
/// Layout de ancho fijo — ver ipc.md para la representación empaquetada.
public struct PacketMeta: Sendable, Hashable, Codable {
public let timestamp: UInt64 // nanosegundos monotónicos desde un origen inyectado
public let flowKey: FlowKey
public let direction: Direction
public let length: UInt32 // bytes del paquete IP
public let tcpFlags: TCPFlags // 0 si no es TCP
public let capture: CaptureLocation? // dónde quedaron sus bytes, o nil si no se capturó
}
/// Dónde quedaron los bytes de un paquete. Es la pareja completa a propósito: el writer rota de
/// fichero por tamaño y cada uno reinicia sus offsets tras la cabecera global, así que un offset
/// suelto no identifica unos bytes — apunta a una posición de un fichero desconocido.
public struct CaptureLocation: Sendable, Hashable, Codable {
public let fileSequence: UInt32 // el número del nombre del `.pcap` (ver pcap.md)
public let recordOffset: UInt64 // offset de la cabecera del registro; nunca 0
}
/// Estado agregado de un flujo: fila de la tabla `flows`.
public struct FlowRecord: Sendable, Hashable, Codable, Identifiable {
public let id: Int64 // rowid asignado por el store
public let key: FlowKey
public let firstSeen: UInt64
public let lastSeen: UInt64
public var bytesOut: UInt64
public var bytesIn: UInt64
public var packetCount: UInt64
public var tlsStatus: TLSInspectionStatus
public var sni: String? // hostname del ClientHello, si se vio
public var resolvedName: ResolvedFlowName? // el nombre que el DNS daba a la dirección remota
public var serverTLS: ServerTLSAnswer? // lo que el servidor contestó al ClientHello, si se leyó
public var serverCertificates: ServerCertificateReading? // su cadena de certificados (TLS ≤ 1.2), o que no mandó
public var clientTLS: ClientTLSOffer? // lo que el cliente ofreció en su ClientHello, si se leyó
public var quic: QUICVersionReading? // la versión de QUIC de su cabecera larga, y de qué extremo
public var streamOpening: StreamOpening? // con qué empezó su stream TCP: TLS, HTTP en claro o ninguna
public var name: FlowName? { get } // el nombre con su origen: el SNI, y si no el resuelto
public var certificateVisibility: ServerCertificateVisibility { get } // qué se sabe del certificado, y por qué no
}
/// Lo que el servidor contestó al ClientHello de un flujo (`relay-and-tls.md` § *What the server
/// chose*). Una negativa también es una respuesta; lo que no dice nada del servidor es `nil`.
public enum ServerTLSAnswer: Sendable, Hashable, Codable {
case negotiated(NegotiatedTLS) // versión, suite y si salió de un HelloRetryRequest
case refused(alert: UInt8) // el código de la alerta, tal cual
}
/// Lo que el cliente ofreció en su ClientHello (`relay-and-tls.md` § *What the client offered*).
public struct ClientTLSOffer: Sendable, Hashable, Codable {
public let versions: OfferedTLSVersions
public let applicationProtocols: [String] // ALPN, en el orden del cliente; sin GREASE
public let omittedApplicationProtocols: Int // los que mandó y no están en la lista
public let hasEncryptedClientHello: Bool // lo leído es el ClientHello exterior
}
public enum OfferedTLSVersions: Sendable, Hashable, Codable {
case listed([TLSProtocolVersion]) // `supported_versions`: la lista exacta, sin GREASE
case upTo(TLSProtocolVersion) // sin la extensión: `legacy_version`, un techo
}
/// El nombre que un flujo recibió al crearse de las respuestas de DNS vistas por el túnel.
public struct ResolvedFlowName: Sendable, Hashable, Codable {
public let name: String // el nombre por el que se preguntó, normalizado
public let otherNames: [String] // los demás nombres vivos de esa dirección, el más reciente primero
}
public enum FlowNameOrigin: Sendable, Hashable { case sni, dns }
/// El nombre de un flujo con su origen.
public struct FlowName: Sendable, Hashable {
public let text: String
public let origin: FlowNameOrigin
public let otherCandidates: [String] // solo los trae un nombre deducido del DNS
public init?(sni: String?, resolved: ResolvedFlowName?)
}
sni is what the connection announced in its ClientHello. resolvedName is an inference:
the name the device had last asked for when it was given the address the flow goes to
(packet-parsing.md § Names from DNS). They are separate fields and separate
columns, and a resolved name is never written into sni: a report has to be able to say which of the
two it is asserting.
A ClientHello is a ClientHello whatever carries it: on a TCP flow the relay reads it from the
stream, and on a QUIC flow the pipeline reads it out of the client’s Initial packets
(packet-parsing.md § Above L4: naming a QUIC flow). Both write sni and
clientTLS, with the same reader and the same rules.
FlowName is what a reader uses when it needs a name per flow — an allowlist, a release diff, a
report. Its rules:
FlowName’s initialiser,
so every reader of a flow’s name gets it.otherCandidates. When the SNI wins they are dropped: the connection
has said which one it was.nil), and is reported as unnamed rather than left out.serverTLS and tlsStatus are independent. tlsStatus says what TunnelVision did with the flow
(left it encrypted, inspected it, found it not inspectable); serverTLS says what the server
chose: read from a ServerHello that travels in the clear, or — for a flow the tunnel terminated —
reported by the connection the tunnel opened to the real server. NegotiatedTLS.source says which,
and it is not implied by tlsStatus: a notInspectable flow can carry an upstream reading, taken
before its client rejected the local CA. The two are answers to different ClientHellos (the app’s and
the tunnel’s), so a reader that cites a version cites its source with it. A flow is encrypted with a
negotiated TLS 1.3, and setting one never changes the other. nil means there was no reading — the
flow was not TLS over TCP/443, the stream could not be read, the upstream connection never completed
its handshake, or the answer has not arrived — and is never to be read as “no TLS”.
clientTLS is read from the ClientHello the device sent, so it describes the app whatever
TunnelVision then did with the flow. That is the asymmetry with serverTLS: on a flow the tunnel
terminated, the server’s answer is an answer to the tunnel’s ClientHello
(TLSAnswerSource.upstreamConnection), and the offer is the only TLS fact left that comes from the
app. A reader that wants to know whether the app would have accepted an old version reads the offer,
not the negotiated version.
encrypted only when its QUIC start was seenquic is the version read from a QUIC long header of the flow, with the end it came from
(packet-parsing.md § Above L4: the QUIC long header). It is what moves a
UDP flow from plaintext, the status every UDP flow is born with, to encrypted — and only when
the version is one known to protect its packets (QUICVersion.hasKnownPacketProtection). A flow
can therefore carry a quic reading and still be plaintext: an unknown version is recorded, not
vouched for. And plaintext with quic == nil on UDP/443 means no QUIC start was recognised, not
that cleartext was observed.
TLSInspectionStatus on TCP is born from the port: 443 is encrypted, every other port is
plaintext. By itself it cannot tell an HTTP request to port 80 from TLS to port 5223.
streamOpening is what was recognised in the first bytes the device sent on the connection, on
any port (relay-and-tls.md § How a stream opened):
public enum StreamOpening: String, Sendable, Hashable, Codable, CaseIterable {
case tlsHandshake // a TLS handshake record carrying a ClientHello
case httpRequest // a readable HTTP request line: HTTP in the clear
case unrecognised // neither
}
unrecognised is not “cleartext”. A protocol that is neither TLS nor HTTP may be encrypted
in its own way (SSH, Noise) or not; nothing is claimed about it.nil is not unrecognised. No reading means the flow is not TCP, the device never sent
enough bytes to decide, or the connection was already open when the tunnel started.tlsHandshake raises a plaintext flow to encrypted, the same way a known QUIC version
does for UDP: that status came from the port and an observation contradicts it. It never
overwrites an inspection outcome, and no opening ever lowers a status — a 443 that does not
speak TLS stays encrypted-by-port, because not recognising it is not seeing it in the clear.
Whoever needs to know whether a flow was encrypted reads the observation
(audit.md § Whether a flow was encrypted), not the status.OfferedTLSVersions is an enum because the two shapes are different claims. .listed is exhaustive:
a version that is not in it was not offered. .upTo is only a maximum: the ClientHello does not say
how far below it the client would go, and a reader must not treat it as a list of one. nil means
the offer was not read — the flow was not TLS over TCP/443, or its ClientHello could not be walked
to the end — and is never an empty offer.
serverCertificates is what was read behind the ServerHello of a TLS ≤ 1.2 flow
(relay-and-tls.md § The certificate chain of TLS ≤ 1.2): the chain the
server presented — a prefix of it, with isComplete saying whether it is all of it — or the fact
that the handshake carried none and why. It is nil for every other flow, and nil does not say
why. certificateVisibility does, and it is what a report reads: encryptedInHandshake for TLS
1.3 is a property of the protocol and has to be stated as one, not shown as a gap in the tool;
replacedByInspection says the certificate the app saw was the local CA’s; notRead is the only
case that means the tool did not get there. It is derived from serverTLS and the reading, never
stored.
What is recorded of a certificate is its subject, its issuer and its expiry, and nothing is
validated. The two names are text chosen by the server: they arrive escaped (RFC 4514) and
bounded, and one that was cut says so (CertificateName.isTruncated).
FlowKey canonicalization: (src,dst) and (dst,src) yield equal keys;
direction(ofPacketFrom:localAddress:) is correct for both endpoints and independent of the
order the key was built in.IPAddress.description for v4 and v6 (including :: compression edge cases).Codable round-trip for every persisted/transported type.TCPFlags set semantics.