Commit ab6a7639 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: send CAMARA ipv4 port and private address to SRM



Both mappers put device.ipv4Address.publicAddress into target.device.ipv4 and
dropped publicPort and privateAddress, because the canonical target had nowhere
to put them -- srm/canonical-parameters-schema.md mapped the whole CAMARA
object onto one string.

CAMARA requires publicAddress plus at least one of privateAddress or publicPort
because "in general, mobile devices cannot be identified by their public IPv4
address alone". Under carrier NAT one public address is shared by thousands of
subscribers and the port is what selects one of them, so sending the address
alone asks SRM to resolve a device from information the spec calls
insufficient. CAMARA's own conformance example is {publicAddress, publicPort}.

target.device gains ipv4_port and ipv4_private alongside ipv4. Deliberately not
folded into `ports`: that field is CAMARA's separate devicePorts -- which ports
a capability acts on -- and a QoD CreateSession can carry both at once, so one
list would leave SRM unable to tell a subscriber-identifying port from a port
to shape. Same line ADR-0037 drew for Traffic Influence's traffic_filters.

QoD's build_session_info now reads the fields back. model_construct stays, but
as a genuine fallback: sessions created before this change hold only the bare
address, so their reconstruction can still fall short of CAMARA's rule.

Additive -- ipv4 keeps its type and meaning, so no schema_version bump.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 40b240d2
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -39,6 +39,8 @@ def build_location_query(
            device=NetworkCapabilityTargetDevice(
                phone_number=device.phoneNumber,
                ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None,
                ipv4_port=device.ipv4Address.publicPort if device.ipv4Address else None,
                ipv4_private=device.ipv4Address.privateAddress if device.ipv4Address else None,
                ipv6=device.ipv6Address,
                network_access_id=device.networkAccessIdentifier,
            )
+12 −8
Original line number Diff line number Diff line
@@ -59,6 +59,8 @@ def build_activate_command(
        device=NetworkCapabilityTargetDevice(
            phone_number=device.phoneNumber,
            ipv4=device.ipv4Address.publicAddress if device.ipv4Address else None,
            ipv4_port=device.ipv4Address.publicPort if device.ipv4Address else None,
            ipv4_private=device.ipv4Address.privateAddress if device.ipv4Address else None,
            ipv6=device.ipv6Address,
            network_access_id=device.networkAccessIdentifier,
            ports=_to_capability_ports(request.devicePorts),
@@ -96,15 +98,17 @@ def build_session_info(qod_session: QodSession, capability: SRMNetworkCapability

    ipv4_address = None
    if device_target.ipv4:
        # Only the bare address survives on SRM's side (build_activate_command never sends
        # privateAddress/publicPort), so this can't fully round-trip a CAMARA DeviceIpv4Addr.
        # model_construct bypasses DeviceIpv4Addr's "publicAddress + one of private/port"
        # validator, which exists to constrain client input, not this internal reconstruction.
        # Device.model_construct is required alongside it: pydantic revalidates nested model
        # fields against their own validators even when a pre-built instance is passed in, so
        # the bypass has to hold at every level enclosing the incomplete address.
        # Sessions created before ipv4_port/ipv4_private were sent still hold only the bare
        # address, so the reconstruction can still fall short of DeviceIpv4Addr's "publicAddress
        # + one of private/port" rule. model_construct bypasses that validator, which exists to
        # constrain client input, not this internal reconstruction. Device.model_construct is
        # required alongside it: pydantic revalidates nested model fields against their own
        # validators even when a pre-built instance is passed in, so the bypass has to hold at
        # every level enclosing a potentially incomplete address.
        ipv4_address = DeviceIpv4Addr.model_construct(
            publicAddress=device_target.ipv4, privateAddress=None, publicPort=None
            publicAddress=device_target.ipv4,
            privateAddress=device_target.ipv4_private,
            publicPort=device_target.ipv4_port,
        )

    device = Device.model_construct(
+6 −0
Original line number Diff line number Diff line
@@ -31,7 +31,13 @@ class NetworkCapabilityPorts(BaseModel):

class NetworkCapabilityTargetDevice(BaseModel):
    phone_number: str | None = None
    # `ipv4` is CAMARA's publicAddress; `ipv4_port`/`ipv4_private` are the other half of its
    # DeviceIpv4Addr. CAMARA requires publicAddress plus one of them because a NAT'd public
    # address alone identifies thousands of subscribers. Distinct from `ports` below, which is
    # CAMARA's separate devicePorts -- what to act on, not which device this is.
    ipv4: str | None = None
    ipv4_port: int | None = None
    ipv4_private: str | None = None
    ipv6: str | None = None
    network_access_id: str | None = None
    ports: NetworkCapabilityPorts | None = None
+22 −4
Original line number Diff line number Diff line
@@ -69,9 +69,11 @@ class TestBuildLocationQuery:
        assert device.ipv4 == "84.125.93.10"
        assert device.ipv6 == "2001:db8:85a3:8d3:1319:8a2e:370:7344"

    def test_only_the_public_ipv4_address_crosses(self) -> None:
        """The canonical target carries a single `ipv4` string, so privateAddress and
        publicPort have nowhere to go — the same lossy step QoD's mapper documents."""
    def test_the_whole_ipv4_address_crosses(self) -> None:
        """Under carrier NAT one public address is shared by thousands of subscribers, so
        CAMARA requires publicAddress plus privateAddress or publicPort -- the second half is
        what selects one device. Sending only the address asks SRM to identify a subscriber
        from information CAMARA says is insufficient."""
        request = RetrievalLocationRequest(
            device=Device(
                ipv4Address=DeviceIpv4Addr(
@@ -80,7 +82,23 @@ class TestBuildLocationQuery:
            )
        )

        assert _query(request).target.device.ipv4 == "84.125.93.10"  # type: ignore[attr-defined]
        device = _query(request).target.device  # type: ignore[attr-defined]

        assert device.ipv4 == "84.125.93.10"
        assert device.ipv4_port == 59765
        assert device.ipv4_private == "10.0.0.1"

    def test_ipv4_identification_is_not_folded_into_ports(self) -> None:
        """`ports` is CAMARA's separate devicePorts -- what a capability acts on. A NAT source
        port says which device this is. QoD can carry both at once, so merging them would
        leave SRM unable to tell them apart."""
        request = RetrievalLocationRequest(
            device=Device(
                ipv4Address=DeviceIpv4Addr(publicAddress="84.125.93.10", publicPort=59765)
            )
        )

        assert _query(request).target.device.ports is None  # type: ignore[attr-defined]

    @pytest.mark.parametrize("max_age", [-5, 0, 120])
    def test_max_age_is_forwarded_including_zero(self, max_age: int) -> None: