dstar_gateway_server/reflector/authorizer.rs
1//! `ClientAuthorizer` trait for incoming client authorization.
2//!
3//! The reflector delegates the accept/reject decision for every new
4//! client link to a pluggable [`ClientAuthorizer`]. Implementors can
5//! consult bans, quotas, whitelists, or any other policy before
6//! letting a client into the fan-out pool.
7//!
8//! The default [`AllowAllAuthorizer`] accepts every request with
9//! read-write access and is intended for tests and local bring-up.
10
11use std::net::SocketAddr;
12
13use dstar_gateway_core::session::server::ClientRejectedReason;
14use dstar_gateway_core::types::{Callsign, Module, ProtocolKind};
15
16/// Decision boundary for accepting / rejecting a new client link.
17///
18/// The reflector calls [`Self::authorize`] once per inbound LINK
19/// attempt. Returning `Ok(AccessPolicy)` admits the client with the
20/// given access; returning `Err(RejectReason)` rejects the link and
21/// causes the reflector to send the protocol-appropriate NAK.
22pub trait ClientAuthorizer: Send + Sync + 'static {
23 /// Called when a new client attempts to link.
24 ///
25 /// # Errors
26 ///
27 /// Returns a [`RejectReason`] describing why the link was
28 /// refused. The reflector converts that reason into the correct
29 /// wire-level NAK for the client's protocol.
30 fn authorize(&self, request: &LinkAttempt) -> Result<AccessPolicy, RejectReason>;
31}
32
33/// One link attempt observed by the reflector.
34///
35/// Carries the structured inputs an authorizer typically needs —
36/// protocol, callsign, peer address, and requested module — without
37/// leaking any wire-level details.
38#[non_exhaustive]
39#[derive(Debug, Clone)]
40pub struct LinkAttempt {
41 /// Protocol the link came in on.
42 pub protocol: ProtocolKind,
43 /// Linking client's callsign.
44 pub callsign: Callsign,
45 /// Client's source address.
46 pub peer: SocketAddr,
47 /// Module the client wants to link to.
48 pub module: Module,
49}
50
51/// Access policy granted to an accepted client.
52///
53/// The reflector uses this value to gate whether inbound voice from
54/// the client is forwarded to other members of the module.
55#[non_exhaustive]
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AccessPolicy {
58 /// Full RX + TX.
59 ReadWrite,
60 /// Listen-only — client receives streams but transmissions are dropped.
61 ReadOnly,
62}
63
64/// Why a link attempt was rejected.
65///
66/// This enum is intentionally kept protocol-agnostic; the reflector's
67/// per-protocol endpoints translate each variant into the correct
68/// wire-level NAK (or silent drop, for protocols without one).
69#[non_exhaustive]
70#[derive(Debug, Clone)]
71pub enum RejectReason {
72 /// Reflector at capacity.
73 Busy,
74 /// Callsign or IP banlisted.
75 Banned {
76 /// Human-readable reason.
77 reason: String,
78 },
79 /// Module is not configured on this reflector.
80 UnknownModule,
81 /// Per-module max client count exceeded.
82 MaxClients,
83 /// Custom rejection.
84 Custom {
85 /// Numeric code (NOT a protocol code — internal).
86 code: u8,
87 /// Human-readable message.
88 message: String,
89 },
90}
91
92impl RejectReason {
93 /// Convert this reject reason into the core-level
94 /// [`ClientRejectedReason`] surfaced on `ServerEvent`.
95 ///
96 /// Variants map one-to-one and preserve any carried strings.
97 #[must_use]
98 pub fn into_core_reason(self) -> ClientRejectedReason {
99 match self {
100 Self::Busy => ClientRejectedReason::Busy,
101 Self::Banned { reason } => ClientRejectedReason::Banned { reason },
102 Self::UnknownModule => ClientRejectedReason::UnknownModule,
103 Self::MaxClients => ClientRejectedReason::MaxClients,
104 Self::Custom { code, message } => ClientRejectedReason::Custom { code, message },
105 }
106 }
107}
108
109/// Authorizer that accepts every link with [`AccessPolicy::ReadWrite`].
110///
111/// Intended for tests and local bring-up. Production deployments
112/// should plug in a policy-aware authorizer.
113#[derive(Debug, Default, Clone, Copy)]
114pub struct AllowAllAuthorizer;
115
116impl ClientAuthorizer for AllowAllAuthorizer {
117 fn authorize(&self, _request: &LinkAttempt) -> Result<AccessPolicy, RejectReason> {
118 Ok(AccessPolicy::ReadWrite)
119 }
120}
121
122/// Authorizer that rejects every link with [`RejectReason::Banned`].
123///
124/// Intended for tests and negative-path bring-up — verifies the
125/// shell honors an authorizer rejection (no handle created, NAK on
126/// the wire, [`ClientRejected`] event emitted).
127///
128/// [`ClientRejected`]: dstar_gateway_core::ServerEvent::ClientRejected
129#[derive(Debug, Default, Clone, Copy)]
130pub struct DenyAllAuthorizer;
131
132impl ClientAuthorizer for DenyAllAuthorizer {
133 fn authorize(&self, _request: &LinkAttempt) -> Result<AccessPolicy, RejectReason> {
134 Err(RejectReason::Banned {
135 reason: "deny-all authorizer".to_string(),
136 })
137 }
138}
139
140/// Authorizer that accepts every link with [`AccessPolicy::ReadOnly`].
141///
142/// Intended for tests that need to exercise the read-only voice
143/// drop path on the shell. Production deployments should NOT use
144/// this — it provides no capacity check, no banlist, and no per-peer
145/// policy.
146#[derive(Debug, Default, Clone, Copy)]
147pub struct ReadOnlyAuthorizer;
148
149impl ClientAuthorizer for ReadOnlyAuthorizer {
150 fn authorize(&self, _request: &LinkAttempt) -> Result<AccessPolicy, RejectReason> {
151 Ok(AccessPolicy::ReadOnly)
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::{
158 AccessPolicy, AllowAllAuthorizer, Callsign, ClientAuthorizer, LinkAttempt, Module,
159 ProtocolKind,
160 };
161 use std::net::{IpAddr, Ipv4Addr, SocketAddr};
162
163 const PEER: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 30001);
164
165 fn sample_request() -> LinkAttempt {
166 LinkAttempt {
167 protocol: ProtocolKind::DExtra,
168 callsign: Callsign::from_wire_bytes(*b"W1AW "),
169 peer: PEER,
170 module: Module::C,
171 }
172 }
173
174 #[test]
175 fn allow_all_accepts_dextra_request() {
176 let auth = AllowAllAuthorizer;
177 let decision = auth.authorize(&sample_request());
178 assert!(matches!(decision, Ok(AccessPolicy::ReadWrite)));
179 }
180
181 #[test]
182 fn access_policy_variants_distinct() {
183 assert_ne!(AccessPolicy::ReadWrite, AccessPolicy::ReadOnly);
184 }
185
186 #[test]
187 fn link_attempt_preserves_fields() {
188 let req = sample_request();
189 assert_eq!(req.protocol, ProtocolKind::DExtra);
190 assert_eq!(req.callsign, Callsign::from_wire_bytes(*b"W1AW "));
191 assert_eq!(req.module, Module::C);
192 }
193}