Add ClientSlot/UpstreamPortRange, ProxyUseCases with in-memory store and RPTO queue, plus peer_id packet helper — library only, not wired to runtime.pull/1/head
parent
6210aa4c87
commit
c361f593c3
@ -0,0 +1,10 @@
|
||||
"""Proxy application layer (Phase 3)."""
|
||||
|
||||
from .packet_helpers import peer_id_from_packet
|
||||
from .use_cases import ProxySlotError, ProxyUseCases
|
||||
|
||||
__all__ = [
|
||||
"ProxySlotError",
|
||||
"ProxyUseCases",
|
||||
"peer_id_from_packet",
|
||||
]
|
||||
@ -0,0 +1,47 @@
|
||||
"""Extract peer_id from Homebrew packets (legacy ``adn_proxy.application.packet_commands``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Homebrew command prefixes (wire vocabulary; no I/O)
|
||||
_DMRD = b"DMRD"
|
||||
_DMRA = b"DMRA"
|
||||
_MSTC = b"MSTC"
|
||||
_MSTN = b"MSTN"
|
||||
_MSTP = b"MSTP"
|
||||
_RPTA = b"RPTA"
|
||||
_RPTCL = b"RPTCL"
|
||||
_RPTK = b"RPTK"
|
||||
_RPTL = b"RPTL"
|
||||
_RPTC = b"RPTC"
|
||||
_RPTO = b"RPTO"
|
||||
_RPTP = b"RPTP"
|
||||
|
||||
|
||||
def peer_id_from_packet(data: bytes, *, from_master: bool) -> bytes | None:
|
||||
"""Return 4-byte peer_id from packet payload, or None if not applicable."""
|
||||
if len(data) < 8:
|
||||
return None
|
||||
command = data[:4]
|
||||
if from_master:
|
||||
if command == _DMRD and len(data) >= 15:
|
||||
return data[11:15]
|
||||
if command == _RPTA and len(data) >= 10:
|
||||
return data[6:10]
|
||||
if command == _MSTN and len(data) >= 10:
|
||||
return data[6:10]
|
||||
if command == _MSTP and len(data) >= 11:
|
||||
return data[7:11]
|
||||
if command == _MSTC and len(data) >= 9:
|
||||
return data[5:9]
|
||||
return None
|
||||
if command == _DMRD and len(data) >= 15:
|
||||
return data[11:15]
|
||||
if command in (_DMRA, _RPTL, _RPTK, _RPTO) and len(data) >= 8:
|
||||
return data[4:8]
|
||||
if command == _RPTC:
|
||||
if len(data) >= 5 and data[:5] == _RPTCL:
|
||||
return data[5:9] if len(data) >= 9 else None
|
||||
return data[4:8] if len(data) >= 8 else None
|
||||
if command == _RPTP and len(data) >= 11:
|
||||
return data[7:11]
|
||||
return None
|
||||
@ -0,0 +1,104 @@
|
||||
"""ProxyService use cases: client registration, upstream routing, pending RPTO (Phase 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Sequence
|
||||
|
||||
from adn_server.application.ports import PendingRptoQueue, ProxySlotStore
|
||||
from adn_server.domain.errors import DomainError
|
||||
from adn_server.domain.proxy import ClientEndpoint, ClientSlot, PendingRpto, UpstreamPortRange
|
||||
from adn_server.domain.result import Fail, Result, Success
|
||||
from adn_server.domain.value_objects import int_id
|
||||
|
||||
|
||||
class ProxySlotError(DomainError):
|
||||
"""Proxy session allocation or lookup failure."""
|
||||
|
||||
|
||||
class ProxyUseCases:
|
||||
"""Register hotspot clients, map them to upstream ports, queue RPTO for the master."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
slot_store: ProxySlotStore,
|
||||
port_range: UpstreamPortRange,
|
||||
rpto_queue: PendingRptoQueue,
|
||||
*,
|
||||
black_list: Sequence[int] = (),
|
||||
rng: random.Random | None = None,
|
||||
) -> None:
|
||||
self._slots = slot_store
|
||||
self._port_range = port_range
|
||||
self._rpto_queue = rpto_queue
|
||||
self._black_list = frozenset(black_list)
|
||||
self._rng = rng or random.Random()
|
||||
|
||||
def attach_client(
|
||||
self,
|
||||
peer_id: bytes,
|
||||
host: str,
|
||||
port: int,
|
||||
) -> Result[ClientSlot, ProxySlotError]:
|
||||
"""Bind or refresh a hotspot session (legacy ``peer_track`` on client packet)."""
|
||||
if len(peer_id) != 4:
|
||||
return Fail(ProxySlotError("peer_id must be 4 bytes"))
|
||||
existing = self._slots.get_by_peer(peer_id)
|
||||
if existing is not None:
|
||||
updated = existing.with_client(host, port)
|
||||
self._slots.update_client(peer_id, host, port)
|
||||
return Success(updated)
|
||||
if int_id(peer_id) in self._black_list:
|
||||
return Fail(ProxySlotError("peer is blacklisted"))
|
||||
upstream_port = self._pick_upstream_port()
|
||||
if upstream_port is None:
|
||||
return Fail(ProxySlotError("no upstream ports available"))
|
||||
slot = ClientSlot(
|
||||
peer_id=peer_id,
|
||||
client=ClientEndpoint(host=host, port=port),
|
||||
upstream_port=upstream_port,
|
||||
)
|
||||
self._slots.bind(slot)
|
||||
return Success(slot)
|
||||
|
||||
def detach_client(self, peer_id: bytes) -> ClientSlot | None:
|
||||
"""Release session and upstream port (legacy ``reaper`` without I/O side effects)."""
|
||||
return self._slots.unbind(peer_id)
|
||||
|
||||
def resolve_upstream(self, peer_id: bytes) -> int | None:
|
||||
"""Upstream port for forwarding client → master."""
|
||||
slot = self._slots.get_by_peer(peer_id)
|
||||
return slot.upstream_port if slot else None
|
||||
|
||||
def resolve_client(self, upstream_port: int) -> ClientEndpoint | None:
|
||||
"""Client endpoint for forwarding master → hotspot."""
|
||||
slot = self._slots.get_by_upstream(upstream_port)
|
||||
return slot.client if slot else None
|
||||
|
||||
def schedule_rpto(self, peer_id: bytes, payload: bytes) -> bool:
|
||||
"""Queue RPTO body for a connected peer (self-service / login options)."""
|
||||
slot = self._slots.get_by_peer(peer_id)
|
||||
if slot is None:
|
||||
return False
|
||||
self._rpto_queue.enqueue(peer_id, payload)
|
||||
return True
|
||||
|
||||
def next_pending_rpto(self) -> PendingRpto | None:
|
||||
"""Dequeue one pending RPTO with its upstream port (for master send loop)."""
|
||||
item = self._rpto_queue.dequeue()
|
||||
if item is None:
|
||||
return None
|
||||
peer_id, payload = item
|
||||
slot = self._slots.get_by_peer(peer_id)
|
||||
if slot is None:
|
||||
return None
|
||||
return PendingRpto(peer_id=peer_id, payload=payload, upstream_port=slot.upstream_port)
|
||||
|
||||
def list_slots(self) -> tuple[ClientSlot, ...]:
|
||||
return self._slots.list_slots()
|
||||
|
||||
def _pick_upstream_port(self) -> int | None:
|
||||
free = self._slots.free_upstream_ports()
|
||||
if not free:
|
||||
return None
|
||||
return self._rng.choice(free)
|
||||
@ -0,0 +1,68 @@
|
||||
"""Hotspot proxy domain: client sessions and upstream port bindings (Phase 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClientEndpoint:
|
||||
"""Repeater UDP endpoint (legacy ``shost`` / ``sport``)."""
|
||||
|
||||
host: str
|
||||
port: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpstreamPortRange:
|
||||
"""Fan-in UDP ports on the peer server (``PORT`` .. ``PORT+GENERATOR-1``)."""
|
||||
|
||||
port_start: int
|
||||
port_count: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.port_count < 1:
|
||||
raise ValueError("port_count must be >= 1")
|
||||
if self.port_start < 1:
|
||||
raise ValueError("port_start must be >= 1")
|
||||
|
||||
@property
|
||||
def port_end(self) -> int:
|
||||
return self.port_start + self.port_count - 1
|
||||
|
||||
def ports(self) -> tuple[int, ...]:
|
||||
return tuple(range(self.port_start, self.port_end + 1))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpstreamBinding:
|
||||
"""Maps one fan-in listen port to the internal master HBP endpoint."""
|
||||
|
||||
listen_port: int
|
||||
master_host: str
|
||||
system_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ClientSlot:
|
||||
"""Active hotspot session (legacy ``peer_track`` entry)."""
|
||||
|
||||
peer_id: bytes
|
||||
client: ClientEndpoint
|
||||
upstream_port: int
|
||||
|
||||
def with_client(self, host: str, port: int) -> ClientSlot:
|
||||
return ClientSlot(
|
||||
peer_id=self.peer_id,
|
||||
client=ClientEndpoint(host=host, port=port),
|
||||
upstream_port=self.upstream_port,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PendingRpto:
|
||||
"""Options payload queued for delivery to the master on a peer upstream port."""
|
||||
|
||||
peer_id: bytes
|
||||
payload: bytes
|
||||
upstream_port: int
|
||||
@ -0,0 +1,9 @@
|
||||
"""Proxy infrastructure adapters (Phase 3)."""
|
||||
|
||||
from .rpto_queue import InMemoryPendingRptoQueue
|
||||
from .slot_store import InMemoryProxySlotStore
|
||||
|
||||
__all__ = [
|
||||
"InMemoryPendingRptoQueue",
|
||||
"InMemoryProxySlotStore",
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
"""In-memory pending RPTO queue (Phase 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
|
||||
from adn_server.application.ports import PendingRptoQueue
|
||||
|
||||
|
||||
class InMemoryPendingRptoQueue(PendingRptoQueue):
|
||||
"""FIFO queue of RPTO payloads keyed by peer_id."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: deque[tuple[bytes, bytes]] = deque()
|
||||
|
||||
def enqueue(self, peer_id: bytes, payload: bytes) -> None:
|
||||
self._items.append((peer_id, payload))
|
||||
|
||||
def dequeue(self) -> tuple[bytes, bytes] | None:
|
||||
if not self._items:
|
||||
return None
|
||||
return self._items.popleft()
|
||||
@ -0,0 +1,51 @@
|
||||
"""In-memory proxy slot store (Phase 3; not wired to UDP yet)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from adn_server.application.ports import ProxySlotStore
|
||||
from adn_server.domain.proxy import ClientSlot, UpstreamPortRange
|
||||
|
||||
|
||||
class InMemoryProxySlotStore(ProxySlotStore):
|
||||
"""Track ``ClientSlot`` by peer_id and upstream port occupancy."""
|
||||
|
||||
def __init__(self, port_range: UpstreamPortRange) -> None:
|
||||
self._port_range = port_range
|
||||
self._by_peer: dict[bytes, ClientSlot] = {}
|
||||
self._by_upstream: dict[int, bytes] = {}
|
||||
|
||||
def bind(self, slot: ClientSlot) -> None:
|
||||
if slot.upstream_port in self._by_upstream:
|
||||
raise ValueError(f"upstream port {slot.upstream_port} already bound")
|
||||
if slot.peer_id in self._by_peer:
|
||||
raise ValueError("peer_id already bound")
|
||||
self._by_peer[slot.peer_id] = slot
|
||||
self._by_upstream[slot.upstream_port] = slot.peer_id
|
||||
|
||||
def update_client(self, peer_id: bytes, host: str, port: int) -> None:
|
||||
slot = self._by_peer.get(peer_id)
|
||||
if slot is None:
|
||||
raise KeyError(peer_id)
|
||||
self._by_peer[peer_id] = slot.with_client(host, port)
|
||||
|
||||
def unbind(self, peer_id: bytes) -> ClientSlot | None:
|
||||
slot = self._by_peer.pop(peer_id, None)
|
||||
if slot is None:
|
||||
return None
|
||||
self._by_upstream.pop(slot.upstream_port, None)
|
||||
return slot
|
||||
|
||||
def get_by_peer(self, peer_id: bytes) -> ClientSlot | None:
|
||||
return self._by_peer.get(peer_id)
|
||||
|
||||
def get_by_upstream(self, upstream_port: int) -> ClientSlot | None:
|
||||
peer_id = self._by_upstream.get(upstream_port)
|
||||
if peer_id is None:
|
||||
return None
|
||||
return self._by_peer.get(peer_id)
|
||||
|
||||
def free_upstream_ports(self) -> tuple[int, ...]:
|
||||
return tuple(p for p in self._port_range.ports() if p not in self._by_upstream)
|
||||
|
||||
def list_slots(self) -> tuple[ClientSlot, ...]:
|
||||
return tuple(self._by_peer.values())
|
||||
@ -0,0 +1,100 @@
|
||||
"""Proxy use cases and packet helpers (Phase 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from adn_server.application.proxy import ProxyUseCases, peer_id_from_packet
|
||||
from adn_server.domain.proxy import ClientEndpoint, UpstreamPortRange
|
||||
from adn_server.domain.result import is_fail, is_ok
|
||||
from adn_server.domain.value_objects import bytes_4
|
||||
from adn_server.infrastructure.proxy import InMemoryPendingRptoQueue, InMemoryProxySlotStore
|
||||
|
||||
_PORT_RANGE = UpstreamPortRange(port_start=56400, port_count=4)
|
||||
_PEER_A = bytes_4(1234567)
|
||||
_PEER_B = bytes_4(7654321)
|
||||
|
||||
|
||||
def _service(*, black_list: tuple[int, ...] = (), seed: int = 1) -> ProxyUseCases:
|
||||
return ProxyUseCases(
|
||||
InMemoryProxySlotStore(_PORT_RANGE),
|
||||
_PORT_RANGE,
|
||||
InMemoryPendingRptoQueue(),
|
||||
black_list=black_list,
|
||||
rng=random.Random(seed),
|
||||
)
|
||||
|
||||
|
||||
def test_attach_allocates_upstream_port_and_refreshes_client() -> None:
|
||||
svc = _service()
|
||||
first = svc.attach_client(_PEER_A, "10.0.0.1", 62031)
|
||||
assert is_ok(first)
|
||||
slot = first.value
|
||||
assert slot.upstream_port in _PORT_RANGE.ports()
|
||||
assert slot.client.host == "10.0.0.1"
|
||||
|
||||
again = svc.attach_client(_PEER_A, "10.0.0.2", 62032)
|
||||
assert is_ok(again)
|
||||
assert again.value.upstream_port == slot.upstream_port
|
||||
assert again.value.client == ClientEndpoint(host="10.0.0.2", port=62032)
|
||||
|
||||
|
||||
def test_attach_rejects_blacklisted_peer() -> None:
|
||||
svc = _service(black_list=(1234567,))
|
||||
result = svc.attach_client(_PEER_A, "10.0.0.1", 62031)
|
||||
assert is_fail(result)
|
||||
|
||||
|
||||
def test_attach_fails_when_ports_exhausted() -> None:
|
||||
svc = _service()
|
||||
for n in range(4):
|
||||
peer = bytes_4(1000 + n)
|
||||
assert is_ok(svc.attach_client(peer, "10.0.0.1", 62031 + n))
|
||||
assert is_fail(svc.attach_client(bytes_4(9999), "10.0.0.9", 62099))
|
||||
|
||||
|
||||
def test_detach_frees_upstream_port() -> None:
|
||||
svc = _service()
|
||||
slot = svc.attach_client(_PEER_A, "10.0.0.1", 62031).value
|
||||
removed = svc.detach_client(_PEER_A)
|
||||
assert removed == slot
|
||||
assert svc.resolve_upstream(_PEER_A) is None
|
||||
assert is_ok(svc.attach_client(_PEER_B, "10.0.0.5", 62035))
|
||||
|
||||
|
||||
def test_resolve_client_by_upstream_port() -> None:
|
||||
svc = _service()
|
||||
slot = svc.attach_client(_PEER_A, "192.168.1.10", 12345).value
|
||||
client = svc.resolve_client(slot.upstream_port)
|
||||
assert client is not None
|
||||
assert client.host == "192.168.1.10"
|
||||
assert client.port == 12345
|
||||
|
||||
|
||||
def test_schedule_and_dequeue_rpto() -> None:
|
||||
svc = _service()
|
||||
svc.attach_client(_PEER_A, "10.0.0.1", 62031)
|
||||
payload = b"TS1=123;TS2=456;"
|
||||
assert svc.schedule_rpto(_PEER_A, payload) is True
|
||||
assert svc.schedule_rpto(bytes_4(999), payload) is False
|
||||
pending = svc.next_pending_rpto()
|
||||
assert pending is not None
|
||||
assert pending.peer_id == _PEER_A
|
||||
assert pending.payload == payload
|
||||
assert pending.upstream_port == svc.resolve_upstream(_PEER_A)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "from_master", "expected"),
|
||||
[
|
||||
(b"RPTL" + _PEER_A + b"\x00", False, _PEER_A),
|
||||
(b"DMRD" + b"\x00" * 7 + _PEER_A + b"\x00", False, _PEER_A),
|
||||
(b"DMRD" + b"\x00" * 7 + _PEER_B, True, _PEER_B),
|
||||
(b"RPTA" + b"\x00" * 2 + _PEER_A, True, _PEER_A),
|
||||
(b"RPTL\x00", False, None),
|
||||
],
|
||||
)
|
||||
def test_peer_id_from_packet(data: bytes, from_master: bool, expected: bytes | None) -> None:
|
||||
assert peer_id_from_packet(data, from_master=from_master) == expected
|
||||
Loading…
Reference in new issue