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}