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}