Skip to main content

snap_control/
pg_wap.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//! PathGuard WAP SNAP extension.
15
16use std::{net::IpAddr, time::Instant};
17
18use anyhow::Context as _;
19
20use crate::{
21    api::http::model::{PgWapSessionManager, Session},
22    pg_wap::{auth::AuthService, session_manager::WapSessionManager},
23};
24
25mod auth;
26pub mod session_manager;
27
28pub use auth::AuthInfo;
29
30/// Handles WAP control plane interactions
31#[derive(Clone)]
32pub struct WapControl {
33    auth_service: AuthService,
34    session_manager: WapSessionManager,
35    data_plane_port: u16,
36    ap_id: String,
37}
38
39impl WapControl {
40    /// Creates a new control service.
41    pub fn new(
42        session_manager: WapSessionManager,
43        auth_duration: std::time::Duration,
44        ap_id: String,
45        data_plane_port: u16,
46    ) -> Self {
47        let auth_service = AuthService::new(auth_duration);
48
49        Self {
50            session_manager,
51            auth_service,
52            ap_id,
53            data_plane_port,
54        }
55    }
56
57    fn ap_id(&self) -> &str {
58        &self.ap_id
59    }
60}
61
62impl PgWapSessionManager for WapControl {
63    fn new_session(
64        &self,
65        client_ip: IpAddr,
66        target_domains: &[&str],
67    ) -> Result<Session, anyhow::Error> {
68        let now = Instant::now();
69        let auth_info = self
70            .auth_service
71            .authenticate(now, client_ip, target_domains);
72        self.session_manager
73            .add_session_authentication(auth_info.clone());
74
75        let valid_until = chrono::Utc::now()
76            + chrono::Duration::from_std(auth_info.valid_until.saturating_duration_since(now))
77                .context("auth service returned out of bounds duration")?;
78        tracing::info!(%client_ip, %valid_until, ?target_domains, "Granted IP access");
79
80        Ok(Session {
81            ip: auth_info.ip,
82            ap_id: self.ap_id().to_string(),
83            data_plane_port: self.data_plane_port,
84            target_domains: auth_info.targets,
85            valid_until,
86        })
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use std::{net::IpAddr, time::Duration};
93
94    use super::*;
95
96    fn control() -> WapControl {
97        WapControl::new(
98            WapSessionManager::new(),
99            Duration::from_secs(60),
100            "test-wap".to_string(),
101            8443,
102        )
103    }
104
105    #[test]
106    fn new_session_authenticates_for_requested_target_domains() {
107        let control = control();
108        let client_ip = IpAddr::from([10, 0, 0, 1]);
109
110        let session = control
111            .new_session(client_ip, &["a.example.com", "b.example.com"])
112            .expect("new_session");
113
114        // The response echoes exactly the requested target domains.
115        assert_eq!(
116            session.target_domains,
117            vec!["a.example.com".to_string(), "b.example.com".to_string()]
118        );
119        assert_eq!(session.ip, client_ip);
120        assert_eq!(session.data_plane_port, 8443);
121
122        // The session manager records an authentication covering exactly those domains.
123        let authed = control
124            .session_manager
125            .authenticated_sessions_for_ip(client_ip);
126        assert_eq!(authed.len(), 1);
127        assert_eq!(
128            authed[0].targets,
129            vec!["a.example.com".to_string(), "b.example.com".to_string()]
130        );
131    }
132
133    #[test]
134    fn authentication_is_scoped_to_supplied_domains() {
135        let control = control();
136        let client_ip = IpAddr::from([10, 0, 0, 2]);
137
138        control
139            .new_session(client_ip, &["a.example.com"])
140            .expect("new_session");
141
142        let authed = control
143            .session_manager
144            .authenticated_sessions_for_ip(client_ip);
145        assert_eq!(authed.len(), 1);
146        assert!(authed[0].targets.contains(&"a.example.com".to_string()));
147        assert!(
148            !authed[0]
149                .targets
150                .contains(&"not-requested.example.com".to_string())
151        );
152    }
153}