dstar_gateway_core/session/server/
event.rs

1//! Server-side session events.
2//!
3//! [`ServerEvent`] is the consumer-visible enum surfaced by the
4//! server-side session machine. The `P: Protocol` parameter is a
5//! phantom discriminator — every variant carries the same data
6//! regardless of protocol, mirroring the client-side `Event<P>`
7//! design.
8
9use std::convert::Infallible;
10use std::marker::PhantomData;
11use std::net::SocketAddr;
12
13use crate::header::DStarHeader;
14use crate::session::client::Protocol;
15use crate::types::{Callsign, Module, StreamId};
16use crate::voice::VoiceFrame;
17
18/// Reason a reflector rejected or evicted a client.
19///
20/// Stripped of any protocol-specific NAK code. Carries a
21/// human-readable reason so consumers (logs, metrics, tests) can
22/// explain the decision without needing to know which authorizer or
23/// health check fired.
24///
25/// This mirrors `dstar_gateway_server::RejectReason` at the event
26/// layer so the core can surface rejections without a cross-crate
27/// dependency on the server-side authorizer types.
28#[non_exhaustive]
29#[derive(Debug, Clone)]
30pub enum ClientRejectedReason {
31    /// Reflector at capacity.
32    Busy,
33    /// Callsign or IP is banlisted.
34    Banned {
35        /// Human-readable reason.
36        reason: String,
37    },
38    /// The requested module is not configured on this reflector.
39    UnknownModule,
40    /// Per-module max client count exceeded.
41    MaxClients,
42    /// Custom rejection.
43    Custom {
44        /// Numeric code (NOT a protocol code — internal).
45        code: u8,
46        /// Human-readable message.
47        message: String,
48    },
49}
50
51/// One event surfaced by the server-side session machine.
52///
53/// The `P: Protocol` parameter is a phantom — every variant carries
54/// the same data regardless of protocol. The phantom is for
55/// compile-time discrimination only, confined to a hidden
56/// [`ServerEvent::__Phantom`] variant that cannot be constructed
57/// (its payload is an uninhabited [`Infallible`]).
58#[non_exhaustive]
59#[derive(Debug, Clone)]
60pub enum ServerEvent<P: Protocol> {
61    /// A new client has linked to a module.
62    ClientLinked {
63        /// Client peer address.
64        peer: SocketAddr,
65        /// Client callsign.
66        callsign: Callsign,
67        /// Module the client linked to.
68        module: Module,
69    },
70    /// A client has unlinked.
71    ClientUnlinked {
72        /// Client peer address.
73        peer: SocketAddr,
74    },
75    /// A client started a voice stream.
76    ClientStreamStarted {
77        /// Client peer.
78        peer: SocketAddr,
79        /// Stream id.
80        stream_id: StreamId,
81        /// The header they sent.
82        header: DStarHeader,
83    },
84    /// A client sent a voice frame.
85    ClientStreamFrame {
86        /// Client peer.
87        peer: SocketAddr,
88        /// Stream id.
89        stream_id: StreamId,
90        /// Frame seq.
91        seq: u8,
92        /// Voice frame.
93        frame: VoiceFrame,
94    },
95    /// A client ended a voice stream.
96    ClientStreamEnded {
97        /// Client peer.
98        peer: SocketAddr,
99        /// Stream id.
100        stream_id: StreamId,
101    },
102    /// A link attempt was refused by the shell authorizer.
103    ///
104    /// Emitted before any handle is created — the rejected client is
105    /// *not* present in the pool. The reflector additionally enqueues
106    /// a protocol-appropriate NAK to the peer.
107    ClientRejected {
108        /// Client peer that was rejected.
109        peer: SocketAddr,
110        /// Why the authorizer refused the link.
111        reason: ClientRejectedReason,
112    },
113    /// Voice from a read-only client was dropped.
114    ///
115    /// Emitted when a client whose [`AccessPolicy`] is `ReadOnly`
116    /// sends a voice header / data / EOT packet. The reflector drops
117    /// the frame silently on the wire so the originator isn't told
118    /// the difference — this event lets consumers observe the drop
119    /// for metrics and audit purposes without exposing it on-air.
120    ///
121    /// [`AccessPolicy`]: https://docs.rs/dstar-gateway-server/latest/dstar_gateway_server/enum.AccessPolicy.html
122    VoiceFromReadOnlyDropped {
123        /// Client peer that attempted to transmit.
124        peer: SocketAddr,
125        /// Stream id of the dropped frame.
126        stream_id: StreamId,
127    },
128    /// A client was evicted by the reflector.
129    ///
130    /// Emitted when the shell removes a client for reasons unrelated
131    /// to a protocol-level UNLINK — e.g. the send-failure threshold
132    /// was exceeded or a health check fired. The peer entry has
133    /// already been removed from the pool by the time this event is
134    /// observed.
135    ClientEvicted {
136        /// Client peer that was evicted.
137        peer: SocketAddr,
138        /// Human-readable reason for eviction.
139        reason: String,
140    },
141
142    /// Hidden phantom variant that carries the `P` type parameter.
143    ///
144    /// This variant cannot be constructed because its payload is
145    /// [`Infallible`]. It exists only so the `ServerEvent<P>` type
146    /// is generic over `P` without embedding a `PhantomData` field in
147    /// every public variant.
148    #[doc(hidden)]
149    __Phantom {
150        /// Uninhabited — prevents construction of this variant.
151        #[doc(hidden)]
152        never: Infallible,
153        /// Phantom marker for `P`.
154        #[doc(hidden)]
155        _p: PhantomData<P>,
156    },
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{Callsign, ClientRejectedReason, Module, ServerEvent, StreamId};
162    use crate::session::client::{DExtra, DPlus, Dcs};
163    use std::net::{IpAddr, Ipv4Addr, SocketAddr};
164
165    const ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 30001);
166    const ADDR_DPLUS: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 20001);
167    const ADDR_DCS: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 30051);
168
169    #[expect(clippy::unwrap_used, reason = "const-validated: n is non-zero")]
170    const fn sid(n: u16) -> StreamId {
171        StreamId::new(n).unwrap()
172    }
173
174    #[test]
175    fn client_linked_constructs_dextra() {
176        let e: ServerEvent<DExtra> = ServerEvent::ClientLinked {
177            peer: ADDR,
178            callsign: Callsign::from_wire_bytes(*b"W1AW    "),
179            module: Module::C,
180        };
181        assert!(matches!(e, ServerEvent::ClientLinked { .. }));
182    }
183
184    #[test]
185    fn client_rejected_carries_reason() {
186        let e: ServerEvent<DExtra> = ServerEvent::ClientRejected {
187            peer: ADDR,
188            reason: ClientRejectedReason::Banned {
189                reason: "bad actor".to_string(),
190            },
191        };
192        assert!(
193            matches!(
194                &e,
195                ServerEvent::ClientRejected {
196                    peer,
197                    reason: ClientRejectedReason::Banned { .. },
198                } if *peer == ADDR
199            ),
200            "expected ClientRejected/Banned, got {e:?}"
201        );
202    }
203
204    #[test]
205    fn voice_from_readonly_dropped_carries_stream_id() {
206        let sid = sid(0xBEEF);
207        let e: ServerEvent<DExtra> = ServerEvent::VoiceFromReadOnlyDropped {
208            peer: ADDR,
209            stream_id: sid,
210        };
211        assert!(
212            matches!(e, ServerEvent::VoiceFromReadOnlyDropped { stream_id, .. } if stream_id == sid),
213            "expected VoiceFromReadOnlyDropped, got {e:?}"
214        );
215    }
216
217    #[test]
218    fn client_evicted_carries_reason_string() {
219        let e: ServerEvent<DExtra> = ServerEvent::ClientEvicted {
220            peer: ADDR,
221            reason: "too many send failures".to_string(),
222        };
223        assert!(
224            matches!(
225                &e,
226                ServerEvent::ClientEvicted { peer, reason }
227                    if *peer == ADDR && reason == "too many send failures"
228            ),
229            "expected ClientEvicted, got {e:?}"
230        );
231    }
232
233    #[test]
234    fn client_linked_constructs_dplus() {
235        let e: ServerEvent<DPlus> = ServerEvent::ClientLinked {
236            peer: ADDR_DPLUS,
237            callsign: Callsign::from_wire_bytes(*b"W1AW    "),
238            module: Module::C,
239        };
240        assert!(matches!(e, ServerEvent::ClientLinked { .. }));
241    }
242
243    #[test]
244    fn client_linked_constructs_dcs() {
245        let e: ServerEvent<Dcs> = ServerEvent::ClientLinked {
246            peer: ADDR_DCS,
247            callsign: Callsign::from_wire_bytes(*b"W1AW    "),
248            module: Module::C,
249        };
250        assert!(matches!(e, ServerEvent::ClientLinked { .. }));
251    }
252
253    #[test]
254    fn client_unlinked_carries_peer() {
255        let e: ServerEvent<DExtra> = ServerEvent::ClientUnlinked { peer: ADDR };
256        assert!(
257            matches!(e, ServerEvent::ClientUnlinked { peer } if peer == ADDR),
258            "expected ClientUnlinked, got {e:?}"
259        );
260    }
261
262    #[test]
263    fn client_stream_ended_carries_stream_id() {
264        let sid = sid(0xBEEF);
265        let e: ServerEvent<DExtra> = ServerEvent::ClientStreamEnded {
266            peer: ADDR,
267            stream_id: sid,
268        };
269        assert!(
270            matches!(e, ServerEvent::ClientStreamEnded { stream_id, .. } if stream_id == sid),
271            "expected ClientStreamEnded, got {e:?}"
272        );
273    }
274}