Skip to main content

snap_control/pg_wap/crpc/
handler.rs

1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The handler that serves the WAP control API.
16
17use std::{net::IpAddr, time::SystemTime};
18
19use crate::pg_wap::{
20    crpc::model::{
21        AuthorizeTargetsError, AuthorizeTargetsRequest, AuthorizeTargetsResponse,
22        ControlServiceAPIHandler,
23    },
24    grants::{AuthorizeError, GrantManager},
25};
26
27/// Serves the `anapaya.wap.v1.WapControl` service of one WAP.
28///
29/// A grant is only valid on the WAP that handed it out, so every response carries the identity of
30/// this WAP next to the grant: the id the client addresses it with in the SNI, and the port its
31/// data plane listens on.
32pub struct WapControlHandler {
33    /// Hands out the grants this handler reports.
34    grant: GrantManager,
35    /// The id of this WAP.
36    wap_id: String,
37    /// The port the data plane of this WAP listens on.
38    data_plane_port: u16,
39}
40
41impl WapControlHandler {
42    /// Creates a handler that grants on `grant` and reports this WAP as `wap_id`.
43    ///
44    /// `data_plane_port` is the port the WAP TCP proxy listens on, so take it from the address the
45    /// proxy binds.
46    pub fn new(grant: GrantManager, wap_id: String, data_plane_port: u16) -> Self {
47        Self {
48            grant,
49            wap_id,
50            data_plane_port,
51        }
52    }
53}
54
55impl ControlServiceAPIHandler for WapControlHandler {
56    fn authorize_targets(
57        &self,
58        client_ip: IpAddr,
59        request: AuthorizeTargetsRequest,
60        now: SystemTime,
61    ) -> Result<AuthorizeTargetsResponse, AuthorizeTargetsError> {
62        let expiry_time = self
63            .grant
64            .authorize(client_ip, request.targets, now)
65            .map_err(|err| {
66                let message = err.to_string();
67
68                match err {
69                    // The request is over a limit on its own, so it never fits. The client has to
70                    // ask for less.
71                    AuthorizeError::TooManySegmentsInRequest { .. }
72                    | AuthorizeError::TooManyTargetsInRequest { .. }
73                    | AuthorizeError::TooManySegmentsForTarget { .. }
74                    | AuthorizeError::TooManyTargetsForIp { .. } => {
75                        AuthorizeTargetsError::InvalidRequest(message)
76                    }
77                    // Grants that are in use hold the room the request needs. The same request
78                    // fits once they are gone.
79                    AuthorizeError::TargetGrantLimitReached { .. }
80                    | AuthorizeError::IpTargetLimitReached { .. } => {
81                        AuthorizeTargetsError::LimitReached(message)
82                    }
83                }
84            })?;
85
86        Ok(AuthorizeTargetsResponse {
87            client_ip,
88            wap_id: self.wap_id.clone(),
89            data_plane_port: self.data_plane_port,
90            expiry_time,
91        })
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use std::{
98        collections::BTreeMap,
99        net::Ipv4Addr,
100        time::{Duration, UNIX_EPOCH},
101    };
102
103    use super::*;
104    use crate::pg_wap::{
105        crpc::model::AuthSegments, grants::GrantManagerConfig, sni::CustomerDomain,
106    };
107
108    const GRANT_DURATION: Duration = Duration::from_secs(100);
109    const WAP_ID: &str = "wap-1";
110    const DATA_PLANE_PORT: u16 = 8443;
111
112    /// A handler over a grant manager that grants for [`GRANT_DURATION`], with `max_targets` per
113    /// request.
114    fn handler(max_targets: usize) -> WapControlHandler {
115        let grant = GrantManager::new(GrantManagerConfig {
116            grant_duration: GRANT_DURATION,
117            max_targets_per_request: max_targets,
118            ..GrantManagerConfig::default()
119        })
120        .expect("a valid GrantManagerConfig");
121
122        WapControlHandler::new(grant, WAP_ID.to_owned(), DATA_PLANE_PORT)
123    }
124
125    /// A request for `count` targets, each without private segments.
126    fn request(count: usize) -> AuthorizeTargetsRequest {
127        AuthorizeTargetsRequest {
128            targets: (0..count)
129                .map(|i| {
130                    (
131                        CustomerDomain::new(format!("target{i}.example.com"))
132                            .expect("a valid domain"),
133                        AuthSegments::default(),
134                    )
135                })
136                .collect::<BTreeMap<_, _>>(),
137        }
138    }
139
140    #[test]
141    fn response_carries_the_wap_and_the_granted_expiry() {
142        let client_ip = Ipv4Addr::new(192, 0, 2, 1).into();
143        let now = UNIX_EPOCH + Duration::from_secs(1_000);
144
145        let response = handler(10)
146            .authorize_targets(client_ip, request(1), now)
147            .expect("the grant fits");
148
149        assert_eq!(response.client_ip, client_ip);
150        assert_eq!(response.wap_id, WAP_ID);
151        assert_eq!(response.data_plane_port, DATA_PLANE_PORT);
152        assert_eq!(response.expiry_time, now + GRANT_DURATION);
153    }
154
155    #[test]
156    fn a_request_over_a_limit_is_invalid() {
157        let err = handler(1)
158            .authorize_targets(
159                Ipv4Addr::new(192, 0, 2, 1).into(),
160                request(2),
161                SystemTime::now(),
162            )
163            .expect_err("two targets do not fit");
164
165        assert!(matches!(err, AuthorizeTargetsError::InvalidRequest(_)));
166    }
167}