Skip to content

ACL Engine - Implementation Guide

This document explains the ACL (Access Control List) engine implementation in RustSocks for fine-grained access control based on users, groups, IP addresses, domains, and ports.

Core Structures

// src/acl/types.rs

use ipnet::IpNet;
use std::net::IpAddr;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Action {
    Allow,
    Block,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Protocol {
    Tcp,
    Udp,
    Both,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclRule {
    pub action: Action,
    pub description: String,
    pub destinations: Vec<DestinationMatcher>,
    pub ports: Vec<PortMatcher>,
    pub protocols: Vec<Protocol>,
    pub priority: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum DestinationMatcher {
    Ip(IpAddr),
    Cidr(IpNet),
    Domain(String),
    DomainWildcard(String), // Format: "*.example.com"
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum PortMatcher {
    Single(u16),
    Range(u16, u16),
    Multiple(Vec<u16>),
    Any, // "*"
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserAcl {
    pub username: String,
    pub groups: Vec<String>,
    pub rules: Vec<AclRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupAcl {
    pub name: String,
    pub rules: Vec<AclRule>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AclConfig {
    pub global: GlobalAclConfig,
    pub users: Vec<UserAcl>,
    pub groups: Vec<GroupAcl>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GlobalAclConfig {
    pub default_policy: Action,
}

#[derive(Debug, Clone, PartialEq)]
pub enum AclDecision {
    Allow,
    Block,
}

Matching Logic

The ACL engine supports multiple matching strategies:

  • IP exact match: Match specific IPv4 or IPv6 address
  • CIDR ranges: Match address blocks (e.g., 10.0.0.0/8)
  • Domain exact match: Case-insensitive domain matching
  • Wildcard domains: Patterns like *.example.com, api.*.com
  • Port ranges: 8000-9000, single ports 443, or any port *
  • Protocol filtering: TCP, UDP, or both

Example ACL configuration (config/acl.toml):

[global]
default_policy = "block"  # Block by default (whitelist approach)

[[users]]
username = "alice"
groups = ["developers"]

  [[users.rules]]
  action = "block"
  description = "Block admin panel"
  destinations = ["admin.company.com"]
  ports = ["*"]
  protocols = ["tcp"]
  priority = 1000

  [[users.rules]]
  action = "allow"
  description = "Allow HTTPS anywhere"
  destinations = ["0.0.0.0/0"]
  ports = ["443"]
  protocols = ["tcp"]
  priority = 100

[[groups]]
name = "developers"

  [[groups.rules]]
  action = "allow"
  description = "Dev servers"
  destinations = ["*.dev.company.com"]
  ports = ["*"]
  protocols = ["both"]
  priority = 50

ACL Engine

The ACL engine evaluates rules in priority order with BLOCK rules taking precedence:

// src/acl/engine.rs

use super::types::*;
use std::sync::Arc;
use tokio::sync::RwLock;

pub struct AclEngine {
    config: Arc<RwLock<AclConfig>>,
}

impl AclEngine {
    pub fn new(config: AclConfig) -> Self {
        Self {
            config: Arc::new(RwLock::new(config)),
        }
    }

    /// Evaluate ACL for a connection attempt
    /// Returns (Decision, matched_rule_description)
    pub async fn evaluate(
        &self,
        user: &str,
        dest: &Address,
        port: u16,
        protocol: &Protocol,
    ) -> (AclDecision, Option<String>) {
        let config = self.config.read().await;

        // 1. Collect all rules for this user (user rules + group rules)
        let mut all_rules = Vec::new();

        if let Some(user_acl) = config.users.iter().find(|u| u.username == user) {
            // Add user's direct rules
            all_rules.extend(user_acl.rules.iter().cloned());

            // Add rules from user's groups
            for group_name in &user_acl.groups {
                if let Some(group) = config.groups.iter().find(|g| g.name == group_name) {
                    all_rules.extend(group.rules.iter().cloned());
                }
            }
        }

        // 2. Sort rules by priority
        // BLOCK rules are evaluated before ALLOW rules (security first)
        all_rules.sort_by(|a, b| {
            match (&a.action, &b.action) {
                (Action::Block, Action::Allow) => std::cmp::Ordering::Less,
                (Action::Allow, Action::Block) => std::cmp::Ordering::Greater,
                _ => b.priority.cmp(&a.priority),
            }
        });

        // 3. Evaluate rules in order until first match
        for rule in &all_rules {
            if rule.matches(dest, port, protocol) {
                let decision = match rule.action {
                    Action::Allow => AclDecision::Allow,
                    Action::Block => AclDecision::Block,
                };
                return (decision, Some(rule.description.clone()));
            }
        }

        // 4. No rule matched - apply default policy
        let decision = match config.global.default_policy {
            Action::Allow => AclDecision::Allow,
            Action::Block => AclDecision::Block,
        };

        (decision, None)
    }

    /// Hot reload ACL configuration
    pub async fn reload(&self, new_config: AclConfig) -> Result<(), String> {
        // Validate config first
        new_config.validate()?;

        // Atomic swap
        let mut config = self.config.write().await;
        *config = new_config;

        Ok(())
    }

    /// Get current config
    pub async fn get_config(&self) -> AclConfig {
        self.config.read().await.clone()
    }
}

impl AclConfig {
    /// Validate configuration
    pub fn validate(&self) -> Result<(), String> {
        // Check for duplicate users
        let mut seen_users = std::collections::HashSet::new();
        for user in &self.users {
            if !seen_users.insert(&user.username) {
                return Err(format!("Duplicate user: {}", user.username));
            }
        }

        // Check for duplicate groups
        let mut seen_groups = std::collections::HashSet::new();
        for group in &self.groups {
            if !seen_groups.insert(&group.name) {
                return Err(format!("Duplicate group: {}", group.name));
            }
        }

        // Check that user groups exist
        for user in &self.users {
            for group_name in &user.groups {
                if !self.groups.iter().any(|g| &g.name == group_name) {
                    return Err(format!(
                        "User '{}' references non-existent group '{}'",
                        user.username, group_name
                    ));
                }
            }
        }

        Ok(())
    }
}

Hot Reload Mechanism

The ACL engine supports zero-downtime configuration reloading via file watching:

// src/acl/watcher.rs

use super::engine::AclEngine;
use notify::{Watcher, RecursiveMode, Event, EventKind};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;

pub struct AclWatcher {
    acl_engine: Arc<AclEngine>,
    config_path: PathBuf,
}

impl AclWatcher {
    pub fn new(acl_engine: Arc<AclEngine>, config_path: PathBuf) -> Self {
        Self { acl_engine, config_path }
    }

    pub async fn start(self) -> Result<(), Box<dyn std::error::Error>> {
        let (tx, mut rx) = mpsc::channel(10);
        let config_path = self.config_path.clone();

        // Create file watcher
        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
            if let Ok(event) = res {
                if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
                    let _ = tx.blocking_send(());
                }
            }
        })?;

        watcher.watch(&config_path, RecursiveMode::NonRecursive)?;

        // Event loop
        tokio::spawn(async move {
            let _watcher = watcher;

            while rx.recv().await.is_some() {
                // Small delay to ensure file is fully written
                tokio::time::sleep(std::time::Duration::from_millis(100)).await;

                match load_acl_config_sync(&config_path) {
                    Ok(new_config) => {
                        match self.acl_engine.reload(new_config).await {
                            Ok(_) => {
                                // Reload successful
                            }
                            Err(e) => {
                                // Keep previous config on validation error
                                eprintln!("Failed to reload ACL config: {}", e);
                            }
                        }
                    }
                    Err(e) => {
                        eprintln!("Failed to load ACL config: {}", e);
                    }
                }
            }
        });

        Ok(())
    }
}

Integration with Connection Handler

// Example from src/server/handler.rs

use crate::acl::engine::AclEngine;
use crate::acl::types::{AclDecision, Protocol};
use std::sync::Arc;

pub async fn handle_socks_connection(
    user: &str,
    dest_addr: &Address,
    dest_port: u16,
    protocol: &Protocol,
    acl_engine: Arc<AclEngine>,
) -> Result<()> {
    // Evaluate ACL
    let (decision, matched_rule) = acl_engine.evaluate(
        user,
        dest_addr,
        dest_port,
        protocol,
    ).await;

    match decision {
        AclDecision::Block => {
            // Send SOCKS5 error response
            send_socks5_error(&mut stream, ErrorCode::ConnectionNotAllowed).await?;
            return Ok(());
        }
        AclDecision::Allow => {
            // Proceed with connection
        }
    }

    Ok(())
}

REST API Endpoints

The ACL engine provides REST endpoints for management:

# Get all users
curl http://127.0.0.1:9090/api/acl/users

# Get all groups
curl http://127.0.0.1:9090/api/acl/groups

# Get user details
curl http://127.0.0.1:9090/api/acl/users/alice

# Get group details
curl http://127.0.0.1:9090/api/acl/groups/developers

# Reload ACL config
curl -X POST http://127.0.0.1:9090/api/admin/reload-acl

Performance Characteristics

Evaluation Performance: - Depends on rule count, matchers, and destination mix - Typically fast enough to stay well below network latency - Hot reload does not block rule evaluation (read-locked config)

Configuration Best Practices

  1. Use default_policy = "block" (whitelist approach)
  2. More secure than blacklist
  3. Explicit allow rules are easier to audit

  4. Set rule priorities

  5. BLOCK rules implicitly higher priority than ALLOW
  6. Use numeric priorities for same-action rules
  7. Higher number = evaluated first

  8. Use group inheritance

  9. Create groups for common rule sets
  10. Users inherit group rules
  11. Easier to manage permissions

  12. Specific before general

  13. Put specific BLOCK rules before general ALLOW rules
  14. Example: Block admin.example.com before allowing *.example.com

  15. Watch your ACL file

    [acl]
    enabled = true
    config_file = "config/acl.toml"
    watch = true  # Enable hot reload
    

Monitoring & Observability

ACL configuration and decisions can be inspected via the API:

GET /api/acl/groups   # Groups and their rules
GET /api/acl/users    # Users and their rules
GET /api/acl/global   # Default policy
POST /api/acl/test    # Evaluate a rule decision

Summary

The RustSocks ACL engine provides:

Granular control - per-user and per-group rules ✅ Security first - BLOCK rules have priority ✅ Zero-downtime - hot reload support ✅ High performance - microsecond evaluation ✅ Flexible matching - IP, CIDR, domains, wildcards, ports ✅ Auditable - every decision is logged ✅ Production-ready - comprehensive validation

The implementation leverages Rust's type system for safety and Tokio for async operations, making it both fast and maintainable.