XGW.Peer (xgw v0.1.0)

Copy Markdown View Source

One remote WebRTC client: runs ICE, DTLS, and SRTP, then gives your app plaintext media and data-channel events.

Mental model

Think of a Peer as the server-side half of a browser RTCPeerConnection. Your application (SFU, MCU, or signaling server) still owns signaling: it negotiates SDP offer/answer, then calls apply_descriptions/4 here. After that, XGW owns the wire protocols until the Peer closes.

Lifecycle in short:

apply SDP -> ICE connects -> DTLS connects -> SRTP keys ready -> media I/O

Outbound media (after {:xgw, :dtls_connected, peer_ref})

  • {:ok, handle} = XGW.Peer.outbound_handle(peer_ref) then XGW.Peer.SendHandle.send_rtp(handle, ssrc, packet) (hot path; no GenServer)
  • :ok = XGW.Peer.send_rtp(peer_ref, ssrc, %XMediaLib.Rtp{} | binary())

  • :ok = XGW.Peer.send_rtp_async(peer_ref, ssrc, packet) (cast; still shares ETS encrypt)
  • :ok = XGW.Peer.send_rtcp(peer_ref, %XMediaLib.Rtcp{} | binary())

The ssrc argument to send_rtp/3 overrides %Rtp{}.ssrc when they differ (Echo v1 passes the inbound %Rtp{} unchanged so both match).

Handler callbacks

  • {:xgw, :ice_connected, peer_ref}
  • {:xgw, :local_candidate, peer_ref, %{candidate: line, mid: mid}} (full mode srflx trickle)
  • {:xgw, :local_candidates_complete, peer_ref} (full mode; post-apply gather finished)
  • {:xgw, :dtls_connected, peer_ref}
  • {:xgw, :sctp_connected, peer_ref} (when offer/answer include SCTP)
  • {:xgw, :sctp_disconnected, peer_ref} (SCTP association went down)
  • {:xgw, :datachannel, peer_ref, %{ref: ref, label: label, id: id}} (optional :reliability, :order, :param from DCEP when present)
  • {:xgw, :datachannel_closed, peer_ref, channel_ref}
  • {:xgw, :datachannel_message, peer_ref, channel_ref, binary()}
  • {:xgw, :rtp, peer_ref, %XMediaLib.Rtp{}, meta}
  • {:xgw, :rtcp, peer_ref, rtcp, meta}
  • {:xgw, :closed, peer_ref, reason} (:normal, :fingerprint_mismatch, :consent_failed)

After DTLS, reply on a data channel with XGW.Peer.send_datachannel/3. Production SCTP and DTLS app-data run on a linked XGW.Peer.Datachannel child so ICE/RTP on this GenServer are not head-of-line blocked by DC egress.

ICE modes and gather

Embed host (and optionally srflx) a=candidate: lines from XGW.Listener.gather_candidates/2 or host_candidate_lines/1 in the local answer before apply_descriptions/4. Post-answer srflx arrives via {:xgw, :local_candidate, ...} when stun_servers is configured.

Set ice_lite: false for the full ICE agent (XGW.ICE.Full). Full mode parses local host candidates from the answer, stays in :gathering until srflx, then :checking / :connected after nomination. Query locals with local_candidates/1 and phase with ice_phase/1 (:completed for lite).

Gather options (:advertised_ip, :advertised_ips, :include_loopback, :relay_only) belong on XGW.Listener.start_link/1, not the Peer.

Internal helpers (GenServer stays here):

Peer.Gather  -- local candidate verify + post-apply srflx/relay gather
Peer.Turn    -- TURN allocations, unwrap, channel bind, relay encode
Peer.Sctp    -- SCTP start + event fan-out

Media callback meta map:

  • :from - {ip, port} of the remote UDP endpoint (ICE-selected pair)
  • :mid - BUNDLE m-line id from offer SSRC map, or nil when unknown
  • :arrival_us - monotonic microseconds when the Listener demuxed the packet (wire side). Peer.deliver/4 fills this only when missing (tests / direct inject). Conference TWCC uses it so Peer and Forwarder mailbox delay are not reported as network delay.

RFCs

End-to-end peer stack: RFC 8445 (ICE), RFC 8489 (STUN), RFC 8656 (TURN), RFC 9147 / RFC 6347 (DTLS), RFC 5764 (DTLS-SRTP), RFC 3711 (SRTP), RFC 8261 / RFC 8831 (SCTP data channels over DTLS), RFC 7675 (consent).

Summary

Types

Trickle ICE candidate: %XMediaLib.SDP.Candidate{} or %{candidate: line} map.

Classified ingress packet kind from XGW.Demux.

Peer GenServer pid.

Connection phase: :ice_wait, :dtls_wait, :media, or :closed.

Remote offer or local answer as SDP binary or %XMediaLib.SDP{}.

Functions

Adds a remote ICE candidate (Trickle ICE, RFC 8838).

Applies negotiated remote offer and local answer (produced upstream by XSDP).

Returns a specification to start this module under a supervisor.

Tears down ICE/DTLS and stops the peer process.

Test helper: install SRTP keys and enter media phase (skips live DTLS).

Test helper: force ICE connected with optional selected from endpoint.

Delivers a demuxed packet from the Listener (STUN, DTLS, RTP, RTCP, ChannelData).

Returns the ICE agent phase.

Signals ICE restart with a new remote offer (RFC 8445 section 9.1.1.1).

Returns local ICE candidates stored in the full ICE agent.

Opens a local data channel (DCEP) after the SCTP association is connected.

Returns a hot-path send handle when UDP media is published in ETS.

Returns the Peer's media phase (:ice_wait, :dtls_wait, :media, :closed).

Allocates TURN relay candidates once and keeps the allocations on the peer.

Sends a DataChannel user message after SCTP is up.

Queues a DataChannel user message without waiting for SCTP/DTLS flush.

Sends plaintext RTCP toward the peer (SRTCP-encrypted on the wire).

Sends plaintext RTP toward the peer (SRTP-encrypted on the wire).

Queues plaintext RTP toward the peer without waiting for encrypt/send.

Replaces the process that receives Peer notifications (:rtp, lifecycle, ...).

Starts a peer under XGW.PeerSupervisor.

Starts a peer as a child of XGW.PeerSupervisor.

Types

candidate()

@type candidate() :: map()

Trickle ICE candidate: %XMediaLib.SDP.Candidate{} or %{candidate: line} map.

packet_kind()

@type packet_kind() :: XGW.Demux.kind()

Classified ingress packet kind from XGW.Demux.

peer_ref()

@type peer_ref() :: pid()

Peer GenServer pid.

phase()

@type phase() :: :ice_wait | :dtls_wait | :media | :closed

Connection phase: :ice_wait, :dtls_wait, :media, or :closed.

sdp()

@type sdp() :: binary() | struct()

Remote offer or local answer as SDP binary or %XMediaLib.SDP{}.

Functions

add_remote_candidate(peer, candidate)

@spec add_remote_candidate(peer_ref(), candidate() | XMediaLib.SDP.Candidate.t()) ::
  :ok | {:error, term()}

Adds a remote ICE candidate (Trickle ICE, RFC 8838).

Accepts %Candidate{} or a map with :candidate (line or struct), optional :mid, :mline_index, and :end_of_candidates.

apply_descriptions(peer, remote_offer, local_answer, opts \\ [])

@spec apply_descriptions(peer_ref(), sdp(), sdp(), keyword()) ::
  :ok | {:error, term()}

Applies negotiated remote offer and local answer (produced upstream by XSDP).

Initializes ICE-lite credentials and transport config from %XMediaLib.SDP{} or SDP binaries. Validates local fingerprint against the listener certificate when a listener is attached.

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

close(peer)

@spec close(peer_ref()) :: :ok | {:error, term()}

Tears down ICE/DTLS and stops the peer process.

connect_dtls(peer, keys, from \\ nil)

@spec connect_dtls(
  peer_ref(),
  XGW.SRTPSession.keys(),
  {:inet.ip_address(), :inet.port_number()} | nil
) ::
  :ok

Test helper: install SRTP keys and enter media phase (skips live DTLS).

connect_ice(peer, from \\ nil)

@spec connect_ice(peer_ref(), {:inet.ip_address(), :inet.port_number()} | nil) :: :ok

Test helper: force ICE connected with optional selected from endpoint.

deliver(peer, kind, data, meta)

@spec deliver(peer_ref(), packet_kind(), binary(), map()) :: :ok

Delivers a demuxed packet from the Listener (STUN, DTLS, RTP, RTCP, ChannelData).

Fills :arrival_us on meta when missing. The Listener stamps this at demux time; this fallback covers tests and any inject path that skips the Listener.

ice_phase(peer)

@spec ice_phase(peer_ref()) :: XGW.ICE.ice_phase()

Returns the ICE agent phase.

Lite peers report :completed (binding-only path). Full peers report the ICE state machine phase from XGW.ICE.Full (for example :gathering).

ice_restart(peer, offer)

@spec ice_restart(peer_ref(), sdp()) :: :ok | {:error, term()}

Signals ICE restart with a new remote offer (RFC 8445 section 9.1.1.1).

Full ufrag/pwd rotation and checklist reset: Full.ice_restart/1 resets state; remote credentials come from the restart offer. Local ufrag/pwd unchanged in v1.

local_candidates(peer)

@spec local_candidates(peer_ref()) :: [XMediaLib.SDP.Candidate.t()]

Returns local ICE candidates stored in the full ICE agent.

Lite peers return []; use XGW.Listener.host_candidate/1 for lite host lines.

open_datachannel(peer, label, opts \\ [])

@spec open_datachannel(peer_ref(), binary(), keyword()) ::
  {:ok, map()} | {:error, term()}

Opens a local data channel (DCEP) after the SCTP association is connected.

Synthesizes {:xgw, :datachannel, peer, info} to the handler so Session / Backend register the label the same way as a remote open. Returns {:error, :not_connected} when SCTP is missing or not connected.

outbound_handle(peer)

@spec outbound_handle(peer_ref()) ::
  {:ok, XGW.Peer.SendHandle.t()} | {:error, :not_ready | :use_genserver}

Returns a hot-path send handle when UDP media is published in ETS.

Prefer XGW.Peer.SendHandle.send_rtp/3 for Echo/SFU forwarding. Returns {:error, :not_ready} before media, or {:error, :use_genserver} for ICE-TCP.

phase(peer)

@spec phase(peer_ref()) :: phase()

Returns the Peer's media phase (:ice_wait, :dtls_wait, :media, :closed).

prefetch_relay_candidates(peer)

@spec prefetch_relay_candidates(peer_ref()) ::
  {:ok, [String.t()]} | {:error, :no_relay_candidates | term()}

Allocates TURN relay candidates once and keeps the allocations on the peer.

Used by relay-only answer builders so apply_descriptions/3 can seed those relay locals without a second Allocate on the same 5-tuple.

send_datachannel(peer, channel_ref, data, type \\ :string)

@spec send_datachannel(peer_ref(), reference(), binary(), :string | :binary) ::
  :ok | {:error, term()}

Sends a DataChannel user message after SCTP is up.

Routes to XGW.Peer.Datachannel when that child is published; otherwise the Peer GenServer (tests / pre-attach). type is :string (PPI 51, default) or :binary (PPI 53).

send_datachannel_async(peer, channel_ref, data, type \\ :string)

@spec send_datachannel_async(peer_ref(), reference(), binary(), :string | :binary) ::
  :ok

Queues a DataChannel user message without waiting for SCTP/DTLS flush.

Prefer XGW.Peer.SendHandle.send_datachannel/3 when a :dc_passthrough row is published (benches). Production Forwarder fallback casts the per-Peer XGW.Peer.Datachannel process (batched drain) when published; otherwise the Peer GenServer. type defaults to :string (PPI 51).

send_rtcp(peer, packet)

@spec send_rtcp(peer_ref(), binary() | XMediaLib.Rtcp.t()) :: :ok | {:error, term()}

Sends plaintext RTCP toward the peer (SRTCP-encrypted on the wire).

Same phase and error semantics as send_rtp/3.

send_rtp(peer, ssrc, packet)

@spec send_rtp(peer_ref(), non_neg_integer(), binary() | XMediaLib.Rtp.t()) ::
  :ok | {:error, term()}

Sends plaintext RTP toward the peer (SRTP-encrypted on the wire).

Requires :media phase. On UDP, uses the same ETS encrypt path as SendHandle.send_rtp/3. Returns {:error, :not_connected}, {:error, :no_selected_pair}, {:error, :invalid_packet}, or an SRTP/UDP error.

send_rtp_async(peer, ssrc, packet)

@spec send_rtp_async(peer_ref(), non_neg_integer(), binary() | XMediaLib.Rtp.t()) ::
  :ok

Queues plaintext RTP toward the peer without waiting for encrypt/send.

Prefer outbound_handle/1 + SendHandle.send_rtp/3 when a handle is available; this cast remains a fallback (ICE-TCP, missing handle).

set_handler(peer, handler)

@spec set_handler(peer_ref(), pid()) :: :ok | {:error, term()}

Replaces the process that receives Peer notifications (:rtp, lifecycle, ...).

Used when a Session installs a media forwarder as handler: after start, or when tests retarget delivery. handler must be a live pid.

start_link(opts)

@spec start_link(keyword()) :: {:ok, peer_ref()} | {:error, term()}

Starts a peer under XGW.PeerSupervisor.

Required: :handler (pid). Optional: :listener, :ice_lite (default true; false selects XGW.ICE.Full), :consent_interval_ms (default 5000), :consent_timeout_ms (default 30000), :cert_dir (unused here; listener owns certs).

start_supervised(opts)

@spec start_supervised(keyword()) :: {:ok, peer_ref()} | {:error, term()}

Starts a peer as a child of XGW.PeerSupervisor.