scion_sdk_address_manager/
manager.rs

1// Copyright 2025 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//! IP address registry.
15
16use std::{
17    collections::BTreeMap,
18    net::IpAddr,
19    time::{self, Duration},
20};
21
22use ipnet::IpNet;
23use rand_chacha::ChaCha8Rng;
24use scion_proto::address::{EndhostAddr, IsdAsn};
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28use crate::allocator::{AddressAllocator, AddressAllocatorError, AllocatorCreationError};
29
30pub mod dto;
31
32const DEFAULT_HOLD_DURATION: Duration = Duration::from_secs(600);
33const MAX_ATTEMPTS: usize = 10;
34
35/// Address registration errors.
36#[derive(Debug, Error, PartialEq, Eq)]
37pub enum AddressRegistrationError {
38    /// Address already registered.
39    #[error("requested address {0} already registered")]
40    AddressAlreadyRegistered(EndhostAddr),
41    /// ISD-AS of the requested address not in range.
42    #[error("requested ISD-AS {0} not in allocation ISD-AS {1}")]
43    IaNotInAllocationRange(IsdAsn, IsdAsn),
44    /// Address allocation error.
45    #[error("requested address before start time")]
46    AddressAllocatorError(#[from] AddressAllocatorError),
47}
48
49/// AddressGrant is the result of a successful registration.
50#[derive(PartialEq, Eq, Debug, Clone)]
51pub struct AddressGrant {
52    /// Unique identifier of the allocation.
53    pub id: String,
54    /// ISD-AS.
55    pub isd_as: IsdAsn,
56    /// Allocated prefix.
57    pub prefix: ipnet::IpNet,
58}
59
60// Internal structure to keep track of grants
61#[derive(Debug, PartialEq, Serialize, Deserialize, Eq, Clone)]
62struct AddressGrantEntry {
63    /// The identity of the token that was used to retrieve this address grant.
64    id: String,
65    /// The endhost address that was allocated.
66    endhost_address: EndhostAddr,
67    // Time offset until the Grant will expire
68    on_hold_expiry: Option<Duration>,
69}
70
71/// Simple single ISD-AS address registry.
72#[derive(Debug, PartialEq, Eq, Clone)]
73pub struct AddressManager {
74    isd_as: IsdAsn,
75    hold_duration: time::Duration,
76    /// Map between ID and Grant
77    address_grants: BTreeMap<String, AddressGrantEntry>,
78    free_ips: AddressAllocator,
79    max_attempts: usize,
80    prefixes: Vec<IpNet>,
81}
82
83impl AddressManager {
84    /// Create a new address registry for the given ISD-AS and prefixes.
85    pub fn new(
86        isd_as: IsdAsn,
87        prefixes: Vec<IpNet>,
88        rng: ChaCha8Rng,
89    ) -> Result<Self, AllocatorCreationError> {
90        let free_ips = AddressAllocator::new(prefixes.clone(), rng)?;
91        Ok(Self {
92            isd_as,
93            hold_duration: DEFAULT_HOLD_DURATION,
94            address_grants: Default::default(),
95            max_attempts: MAX_ATTEMPTS,
96            free_ips,
97            prefixes,
98        })
99    }
100
101    /// Set the hold duration for address grants.
102    pub fn with_hold_duration(mut self, hold_duration: time::Duration) -> Self {
103        self.hold_duration = hold_duration;
104        self
105    }
106
107    /// Set the maximum number of attempts to allocate an address.
108    pub fn with_max_attempts(mut self, max_attempts: usize) -> Self {
109        self.max_attempts = max_attempts;
110        self
111    }
112
113    /// Checks if the given ISD-AS matches the registry's ISD-AS.
114    pub fn match_isd_as(&self, isd_as: IsdAsn) -> bool {
115        if isd_as.isd().is_wildcard() && isd_as.asn().is_wildcard() {
116            true
117        } else if isd_as.isd().is_wildcard() {
118            isd_as.asn() == self.isd_as.asn()
119        } else if isd_as.asn().is_wildcard() {
120            isd_as.isd() == self.isd_as.isd()
121        } else {
122            isd_as == self.isd_as
123        }
124    }
125
126    /// Registers a new Address, will remove current grant if `id` is reused
127    ///
128    /// ### Parameters
129    /// - `id`: Unique identifier of the allocation. One `id` can only have one Grant.
130    /// - `isd_asn`: The IsdAsn associated with this address allocation.
131    /// - `addr`: The requested IP address. Use [std::net::Ipv4Addr::UNSPECIFIED] or
132    ///   [std::net::Ipv6Addr::UNSPECIFIED] to request any available address.
133    pub fn register(
134        &mut self,
135        id: String,
136        mut isd_asn: IsdAsn,
137        addr: IpAddr,
138    ) -> Result<AddressGrant, AddressRegistrationError> {
139        if isd_asn.asn().is_wildcard() {
140            isd_asn.set_asn(self.isd_as.asn());
141        }
142        if isd_asn.isd().is_wildcard() {
143            isd_asn.set_isd(self.isd_as.isd());
144        }
145
146        if isd_asn != self.isd_as {
147            return Err(AddressRegistrationError::IaNotInAllocationRange(
148                isd_asn,
149                self.isd_as,
150            ));
151        }
152
153        let endhost_address = match (addr.is_unspecified(), self.address_grants.get(&id)) {
154            // Reuse Existing
155            (true, Some(existing))
156                // Only if they are of the same type
157                if existing.endhost_address.local_address().is_ipv4() == addr.is_ipv4() =>
158            {
159                existing.endhost_address
160            }
161            (false, Some(existing)) if existing.endhost_address.local_address() == addr => {
162                existing.endhost_address
163            }
164            // Try allocate new,
165            // if there was a different, existing alloc it will be removed further down
166            (..) => {
167                let ip = self.free_ips.allocate(addr)?;
168                EndhostAddr::new(isd_asn, ip)
169            }
170        };
171
172        let grant = AddressGrantEntry {
173            id: id.clone(),
174            on_hold_expiry: None,
175            endhost_address,
176        };
177
178        // Insert/Overwrite existing entry
179        if let Some(removed) = self.address_grants.insert(id, grant.clone()) {
180            if removed.endhost_address != endhost_address {
181                if let Err(e) = self.free_ips.free(removed.endhost_address.local_address()) {
182                    tracing::error!(
183                        error = %e,
184                        "Allocator did not contain IP from removed entry - this should not happen"
185                    )
186                };
187            };
188        }
189
190        Ok(AddressGrant {
191            id: grant.id,
192            isd_as: grant.endhost_address.isd_asn(),
193            prefix: grant.endhost_address.local_address().into(),
194        })
195    }
196
197    /// Puts a grant on hold for the default hold period
198    ///
199    /// After this period has passed, the address can be freed by the cleanup job.
200    pub fn put_on_hold(&mut self, id: String, duration_since_start: Duration) -> bool {
201        match self.address_grants.get_mut(&id) {
202            Some(grant) => {
203                grant.on_hold_expiry = Some(duration_since_start + self.hold_duration);
204
205                true
206            }
207            None => false,
208        }
209    }
210
211    /// Immediately frees an address
212    ///
213    /// Returns `true` if address existed and was freed, otherwise `false`
214    pub fn free(&mut self, id: &String) -> bool {
215        match self.address_grants.remove(id) {
216            Some(removed) => {
217                if let Err(e) = self.free_ips.free(removed.endhost_address.local_address()) {
218                    tracing::warn!(
219                        error = %e,
220                        "Address allocator did not contain an existing address grant - this should never happen"
221                    );
222                };
223
224                true
225            }
226            None => false,
227        }
228    }
229
230    /// Get this address registries isd-asn
231    pub fn isd_asn(&self) -> IsdAsn {
232        self.isd_as
233    }
234
235    /// Get this address registries prefixes
236    pub fn prefixes(&self) -> &[IpNet] {
237        &self.prefixes
238    }
239
240    /// Get the minimal count of free addresses of this registry
241    ///
242    /// Call [Self::clean_expired] before this to get the actual count
243    pub fn min_free_addresses(&self) -> u128 {
244        self.free_ips.free_v4() + self.free_ips.free_v6()
245    }
246
247    /// Checks all grants and removes expired grants
248    ///
249    /// Should be called periodically
250    ///
251    /// Returns (grant_count_before, grant_count_after)
252    pub fn clean_expired(&mut self, duration_since_start: Duration) -> (usize, usize) {
253        let start_grant_count = self.address_grants.len();
254
255        self.address_grants.retain(|_, grant| {
256            let Some(on_hold_until) = grant.on_hold_expiry else {
257                return true; // Skip not on hold grants
258            };
259
260            // If hold period has passed, clean
261            if duration_since_start > on_hold_until {
262                // Free IP alloc
263                self.free_ips
264                    .free(grant.endhost_address.local_address())
265                    .unwrap();
266
267                // Remove entry from grants
268                return false;
269            }
270
271            true
272        });
273
274        let end_grant_count = self.address_grants.len();
275        (start_grant_count, end_grant_count)
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use std::{
282        net::{Ipv4Addr, Ipv6Addr},
283        str::FromStr,
284    };
285
286    use rand::SeedableRng;
287    use rand_chacha::ChaCha8Rng;
288
289    use super::*;
290
291    fn duration(offset: u64) -> Duration {
292        Duration::from_secs(offset)
293    }
294
295    fn get_registry() -> AddressManager {
296        AddressManager::new(
297            IsdAsn::from_str("1-ff00:0:110").unwrap(),
298            vec![
299                IpNet::from_str("192.168.0.0/24").unwrap(),
300                IpNet::from_str("2001:db8::/64").unwrap(),
301            ],
302            ChaCha8Rng::seed_from_u64(42),
303        )
304        .unwrap()
305    }
306
307    fn id() -> String {
308        "00000000-0000-0000-0000-000000000001".to_string()
309    }
310
311    fn get_request(ip: &str) -> (IsdAsn, IpAddr) {
312        (
313            IsdAsn::from_str("1-ff00:0:110").unwrap(),
314            IpAddr::from_str(ip).unwrap(),
315        )
316    }
317
318    fn register(
319        id: String,
320        request: (IsdAsn, IpAddr),
321    ) -> Result<AddressGrant, AddressRegistrationError> {
322        get_registry().register(id, request.0, request.1)
323    }
324
325    #[test]
326    fn should_fail_on_isd_as_mismatch() {
327        let result = register(
328            id(),
329            (
330                IsdAsn::from_str("1-ff00:0:111").unwrap(),
331                "192.168.0.0".parse().unwrap(),
332            ),
333        );
334        assert_eq!(
335            result,
336            Err(AddressRegistrationError::IaNotInAllocationRange(
337                IsdAsn::from_str("1-ff00:0:111").unwrap(),
338                IsdAsn::from_str("1-ff00:0:110").unwrap()
339            ))
340        );
341    }
342
343    #[test]
344    fn should_fail_if_ipv4_is_outside_range() {
345        let result = register(id(), get_request("192.168.1.0"));
346        assert_eq!(
347            result,
348            Err(AddressRegistrationError::AddressAllocatorError(
349                AddressAllocatorError::AddressNotInPrefix("192.168.1.0".parse().unwrap())
350            ))
351        );
352    }
353
354    #[test]
355    fn should_fail_if_ipv6_is_outside_range() {
356        let result = register(id(), get_request("2001:db9::"));
357        assert_eq!(
358            result,
359            Err(AddressRegistrationError::AddressAllocatorError(
360                AddressAllocatorError::AddressNotInPrefix("2001:db9::".parse().unwrap())
361            ))
362        );
363    }
364
365    #[test]
366    fn should_succeed_to_re_register_same_ip_and_fail_register_same_ip_with_different_id() {
367        let isd_as = IsdAsn::from_str("1-ff00:0:110").unwrap();
368        let mut registry = get_registry();
369        // v4
370        {
371            let v4 = "192.168.0.0".parse().unwrap();
372            registry.register(id(), isd_as, v4).expect("Should Succeed");
373
374            // Register with the same id succeeds.
375            registry.register(id(), isd_as, v4).expect("Should Succeed");
376
377            // Register same address, with a different id fails.
378            let other_id = "Other Id".to_string();
379            let result = registry.register(other_id, isd_as, v4);
380            assert_eq!(
381                result,
382                Err(AddressRegistrationError::AddressAllocatorError(
383                    AddressAllocatorError::AddressAlreadyAllocated(v4)
384                )),
385                "got {result:?}"
386            );
387        }
388
389        // v6
390        {
391            let mut registry = get_registry();
392            let v6 = "2001:db8::".parse().unwrap();
393            registry.register(id(), isd_as, v6).expect("Should Succeed");
394
395            // Register with the same id succeeds.
396            registry.register(id(), isd_as, v6).expect("Should Succeed");
397
398            // Register same address, with a different id fails.
399            let other_id = "Other Id".to_string();
400            let result = registry.register(other_id, isd_as, v6);
401            assert_eq!(
402                result,
403                Err(AddressRegistrationError::AddressAllocatorError(
404                    AddressAllocatorError::AddressAlreadyAllocated(v6)
405                ))
406            );
407        }
408    }
409
410    #[test]
411    fn should_fail_if_no_address_is_available() {
412        let isd_as = IsdAsn::from_str("1-ff00:0:110").unwrap();
413        let mut registry = AddressManager::new(
414            IsdAsn::from_str("1-ff00:0:110").unwrap(),
415            vec![],
416            ChaCha8Rng::seed_from_u64(42),
417        )
418        .unwrap();
419        // v4
420        let v4 = Ipv4Addr::UNSPECIFIED.into();
421        let result = registry.register(id(), isd_as, v4);
422        assert_eq!(
423            result,
424            Err(AddressRegistrationError::AddressAllocatorError(
425                AddressAllocatorError::NoAddressesAvailable
426            ))
427        );
428
429        // v6
430        let v6 = Ipv6Addr::UNSPECIFIED.into();
431        let result = registry.register(id(), isd_as, v6);
432        assert_eq!(
433            result,
434            Err(AddressRegistrationError::AddressAllocatorError(
435                AddressAllocatorError::NoAddressesAvailable
436            ))
437        );
438    }
439
440    #[test]
441    fn should_succeed_allocation_with_specific_ip() {
442        let isd_as = IsdAsn::from_str("1-ff00:0:110").unwrap();
443        let mut registry = get_registry();
444        // v4
445        let v4 = "192.168.0.0".parse().unwrap();
446        let result = registry.register(id(), isd_as, v4).expect("Should succeed");
447        assert_eq!(result.prefix.addr(), v4, "Expected specific assignment");
448
449        // v6
450        let v6 = "2001:db8::".parse().unwrap();
451        let result = registry.register(id(), isd_as, v6).expect("Should succeed");
452        assert_eq!(result.prefix.addr(), v6, "Expected specific assignment");
453    }
454
455    #[test]
456    fn should_succeed_allocation_with_wildcard() {
457        let mut registry = get_registry();
458        // v4
459        let result = registry.register(id(), IsdAsn::WILDCARD, Ipv4Addr::UNSPECIFIED.into());
460        assert!(result.is_ok());
461
462        // v6
463        let result = registry.register(id(), IsdAsn::WILDCARD, Ipv6Addr::UNSPECIFIED.into());
464        assert!(result.is_ok());
465    }
466
467    #[test]
468    fn should_clean_existing_grant_on_reallocation() {
469        let mut registry = get_registry();
470        let initial = "192.168.0.0".parse().unwrap();
471        let other = "2001:db8::".parse().unwrap();
472
473        assert!(
474            registry.free_ips.is_free(initial),
475            "Expected initial address to be free"
476        );
477
478        // Try it a few times
479        for _ in 0..10 {
480            let grant = registry
481                .register(id(), IsdAsn::WILDCARD, initial)
482                .expect("Should succeed");
483
484            assert_eq!(
485                grant.prefix.addr(),
486                initial,
487                "Expected assignment of given address"
488            );
489
490            assert!(
491                !registry.free_ips.is_free(initial),
492                "Expected initial address to not be free"
493            );
494
495            assert!(
496                registry.free_ips.is_free(other),
497                "Expected other address to be free"
498            );
499            // Reregister with different ip
500            registry
501                .register(id(), IsdAsn::WILDCARD, other)
502                .expect("Should succeed");
503
504            assert!(
505                registry.free_ips.is_free(initial),
506                "Expected initial address to have been freed"
507            );
508        }
509    }
510
511    #[test]
512    fn should_not_assign_on_hold_address() {
513        let mut registry = get_registry();
514        let initial = "192.168.0.0".parse().unwrap();
515
516        let _grant = registry
517            .register(id(), IsdAsn::WILDCARD, initial)
518            .expect("Should succeed");
519
520        registry.put_on_hold(id(), duration(0));
521
522        let other_id = "Other Id".to_string();
523        let result = registry.register(other_id.clone(), IsdAsn::WILDCARD, initial);
524        assert_eq!(
525            result,
526            Err(AddressRegistrationError::AddressAllocatorError(
527                AddressAllocatorError::AddressAlreadyAllocated(initial)
528            )),
529            "Should have given AddressAlreadyAllocated got {result:?}"
530        );
531    }
532
533    #[test]
534    fn should_clean_expired_grants() {
535        let mut registry = get_registry();
536        let initial = "192.168.0.0".parse().unwrap();
537        let grant = registry
538            .register(id(), IsdAsn::WILDCARD, initial)
539            .expect("Should succeed");
540
541        assert!(registry.put_on_hold(grant.id, duration(0)));
542
543        let (before, after) =
544            registry.clean_expired(registry.hold_duration + Duration::from_nanos(1));
545        assert_eq!(before, 1);
546        assert_eq!(after, 0, "Expected grant to have been cleaned");
547        assert!(
548            registry.free_ips.is_free(initial),
549            "Expected initial address to have been freed"
550        );
551    }
552
553    #[test]
554    fn should_keep_not_expired_grants() {
555        let mut registry = get_registry();
556
557        let initial = "192.168.0.0".parse().unwrap();
558        let grant = registry
559            .register(id(), IsdAsn::WILDCARD, initial)
560            .expect("Should succeed");
561
562        assert!(registry.put_on_hold(grant.id, duration(0)));
563
564        let (before, after) = registry.clean_expired(registry.hold_duration);
565        assert_eq!(before, 1);
566        assert_eq!(after, 1);
567    }
568}