initialize project

This commit is contained in:
arthur 2025-09-27 23:06:32 +07:00
commit ebb6fde04d
22 changed files with 2766 additions and 0 deletions

23
.env.example Normal file
View File

@ -0,0 +1,23 @@
# VPN Access Server Configuration Template
# Copy this file to .env and update with your values
# Database Configuration
DB_HOST=localhost
DB_PORT=3306
DB_NAME=vpn_access
DB_USER=vpn_user
DB_PASSWORD=your_secure_password_here
DB_CHARSET=utf8mb4
DB_AUTOCOMMIT=false
DB_POOL_SIZE=5
DB_MAX_OVERFLOW=10
# Server Configuration
LOG_LEVEL=INFO
LOG_FILE=/var/log/openvpn/access-server.log
DEFAULT_SESSION_LIMIT=28800
MAX_SESSION_LIMIT=86400
TIMEZONE=UTC
# Session cleanup (for maintenance scripts)
SESSION_CLEANUP_DAYS=90

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Configuration files with secrets
.env
*.env
# Test and cache files
.pytest_cache/
coverage.xml
htmlcov/
# IDE files
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
logs/

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.13

73
CLAUDE.md Normal file
View File

@ -0,0 +1,73 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a VPN Access Server project that extends OpenVPN functionality with custom MAC address validation and session time management. The system integrates with MySQL for data persistence and is designed for deployment on Ubuntu VPN gateways.
## Development Commands
This project uses `uv` as the package manager and `main.py` as the unified entry point:
**Package Management:**
- **Install dependencies**: `uv install`
- **Add test dependencies**: `uv add --group test pytest pytest-cov`
- **Activate virtual environment**: `source .venv/bin/activate`
**Console Script Commands (Recommended):**
- **Show help**: `uv run vpn-access-server --help`
- **Show status**: `uv run vpn-access-server status`
- **Run tests**: `uv run vpn-access-server test`
- **Health check**: `uv run vpn-access-server health-check`
- **Initialize database**: `uv run vpn-access-server init-db`
- **Seed test data**: `uv run vpn-access-server seed-data`
**Direct OpenVPN Integration:**
- **Authentication**: `uv run vpn-access-server auth` (called by OpenVPN)
- **Session management**: `uv run vpn-access-server session` (called by OpenVPN)
**Alternative Direct Python Commands:**
- **Main script**: `uv run python main.py [command]`
- **Direct script access**: `python access/auth.py` or `python access/session.py`
**Alternative Testing:**
- **Run pytest directly**: `uv run pytest tests/`
- **Run specific test**: `uv run pytest tests/test_utils.py`
## Architecture
The system follows a modular layered architecture with these key components:
### Core Components
- **access/**: Python scripts that integrate with OpenVPN hooks (`client-connect` and `client-disconnect`)
- **MySQL Database**: Stores users, MAC addresses, and session data
- **OpenVPN Server**: Calls Python scripts during connection events
### Module Structure
- `access/auth.py`: User authentication and MAC validation logic
- `access/session.py`: Session tracking and time enforcement
- `access/db.py`: MySQL connection and database queries
- `access/utils.py`: Shared utility functions
- `access/config.py`: Configuration management (DB credentials, constants)
### Database Schema
- `users`: User accounts with session limits
- `mac_addresses`: Allowed MAC addresses per user
- `sessions`: Connection session tracking with duration
## Integration Flow
1. OpenVPN receives connection attempt
2. Calls `client-connect` Python script with environment variables
3. Script validates user status, MAC address, and session limits via MySQL
4. Connection allowed/denied based on validation
5. On disconnect, `client-disconnect` script updates session duration
## Security Notes
- Uses parameterized SQL queries to prevent injection
- Database credentials should be secured via environment variables or config files
- All validation failures are logged for auditing
- Scripts run with minimal privileges in `/etc/openvpn/access/` on deployment
- Always use `uv run` command whenever you run a python file in a project or source

184
README.md Normal file
View File

@ -0,0 +1,184 @@
# VPN Access Server Development Plan
## 1. Objectives
The goal of this project is to extend an OpenVPN-based VPN access server with the following custom features:
- **MAC Address Validation**: Ensure that only devices with authorized MAC addresses can connect.
- **Session Time Management**: Enforce per-user session duration policies.
## 2. System Architecture
### Components
- **OpenVPN Server**: Handles client VPN connections and enforces authentication hooks.
- **Python Access Control Scripts**: Custom scripts invoked by OpenVPN hooks (via `client-connect` and `client-disconnect`).
- **MySQL Database**: Centralized storage for user, MAC, and session management.
- **Syslog / Logging**: Logs authentication attempts, MAC rejections, and session usage.
### Architecture Flow
1. OpenVPN receives a connection attempt.
2. OpenVPN calls the Python script (`client-connect`) with client environment variables.
3. Python script queries **MySQL database** to validate:
- User is active.
- MAC address is allowed for the user.
- Session time limit not exceeded.
4. If validation fails → OpenVPN rejects the session.
5. On disconnect, Python script (`client-disconnect`) updates session time in the database.
## 3. Database Schema
### `employees` table
| Column | Type | Notes |
|-----------------|--------------|----------------------------------------------------|
| id | INT (PK) | Primary key |
| username | VARCHAR(255) | Unique, not null, indexed |
| employee_name | VARCHAR(255) | Employee/VPN name (not null) |
| employee_email | VARCHAR(255) | Unique email, indexed |
| is_active | BOOLEAN | Default TRUE, indexed |
| session_limit | INT | Max allowed session (secs) |
| created_at | DATETIME | Default CURRENT_TIMESTAMP |
| updated_at | DATETIME | Auto-updated on change |
**Constraints**: `UNIQUE (username)`, `UNIQUE (employee_email)`
**Indexes**: `idx_username`, `idx_email`, `idx_active`
---
### `mac_addresses` table
| Column | Type | Notes |
|---------------|--------------|----------------------------------------------------|
| id | INT (PK) | Primary key |
| employee_id | INT (FK) | References `employees.id` (ON DELETE CASCADE) |
| mac | VARCHAR(17) | Allowed MAC address (unique per employee) |
| created_at | DATETIME | Default CURRENT_TIMESTAMP |
| updated_at | DATETIME | Auto-updated on change |
**Constraints**: `UNIQUE (employee_id, mac)`
**Indexes**: `idx_mac`
---
### `sessions` table
| Column | Type | Notes |
|---------------|--------------|----------------------------------------------------|
| id | INT (PK) | Primary key |
| employee_id | INT (FK) | References `employees.id` (ON DELETE CASCADE) |
| start_time | DATETIME | Connection start (not null), indexed |
| end_time | DATETIME | Connection end, indexed |
| duration | INT | Session length in seconds |
| created_at | DATETIME | Default CURRENT_TIMESTAMP |
| updated_at | DATETIME | Auto-updated on change |
**Indexes**: `idx_user_id`, `idx_start_time`, `idx_end_time`
## 4. Python Script Design
### `client-connect.py`
- Input: OpenVPN environment vars (`common_name`, `ifconfig_pool_remote_ip`, `trusted_ip`, etc.).
- Workflow:
1. Extract username and MAC from environment.
2. Query database:
- Check if user is active.
- Check if MAC is in allowed list.
- Check total session duration for today vs. `session_limit`.
3. If pass → allow connection, insert row into `sessions` with `start_time`.
4. If fail → exit with error code (OpenVPN rejects).
### `client-disconnect.py`
- Input: OpenVPN environment vars.
- Workflow:
1. Lookup current session by user and start time.
2. Update `end_time` and `duration` in `sessions`.
3. Commit changes to DB.
## 5. Development Plan
### Phase 1: Environment Setup
- Configure OpenVPN server with `client-connect` and `client-disconnect` hooks.
- Setup Python virtual environment under `/etc/openvpn/access`.
- Setup **MySQL database** schema.
### Phase 2: Database Integration
- Write Python database utility module for MySQL connection pooling.
- Test CRUD operations on `users`, `mac_addresses`, and `sessions`.
### Phase 3: Client Validation Logic
- Implement `client-connect.py` logic for user/MAC/session validation.
- Implement `client-disconnect.py` for session tracking.
### Phase 4: Testing
- Unit test Python scripts with mock environment variables.
- Integration test with OpenVPN clients.
### Phase 5: Deployment
- Deploy Python scripts into `/etc/openvpn/access`.
- Secure DB credentials with `.env` or config file.
- Harden OpenVPN server config.
## 6. Security Considerations
- Use parameterized SQL queries to prevent injection.
- Secure DB access with strong user/password, TLS if available.
- Log all failed validation attempts.
- Apply principle of least privilege for DB accounts.
# VPN Access Server Development Plan
## 1. Objectives
- Validate client MAC addresses during VPN authentication.
- Manage session time (e.g., max duration, timeout) per user.
- Store and manage data in MySQL.
- Provide an extensible Python-based server-side solution for OpenVPN integration.
---
## 2. System Architecture
### Components
1. **OpenVPN Server**
- Uses TUN mode.
- Forwards traffic to the NAS subnet (`172.16.14.0/24`) via the Ubuntu VPN gateway.
- Executes external Python scripts for authentication and session management.
2. **VPN Access Server (Python)**
- Python service located under `/etc/openvpn/access/`.
- Handles MAC validation and session management logic.
- Directly interacts with **MySQL** for data persistence (no extra management tool needed).
3. **MySQL Database**
- Stores user credentials, MAC addresses, and session details.
- Provides logs for auditing.
---
---
#### Recommended Project Structure
```
vpn-access-server/
├── access/ # Core Python scripts (linked to OpenVPN)
│ ├── __init__.py
│ ├── auth.py # User authentication & MAC validation
│ ├── session.py # Session tracking and enforcement
│ ├── db.py # MySQL connection and queries
│ ├── utils.py # Shared utilities
│ └── config.py # Config loader (DB creds, constants)
├── tests/ # Unit & integration tests
│ ├── test_auth.py
│ ├── test_session.py
│ └── test_db.py
├── scripts/ # Helper scripts (migration, DB setup)
│ ├── init_db.py
│ └── seed_data.py
├── requirements.txt # Python dependencies
├── README.md # Documentation
```
This structure follows a **modular layered architecture** (separating auth, session, db, utils).
It is clean, scalable, and easy to maintain.
---

11
access/__init__.py Normal file
View File

@ -0,0 +1,11 @@
"""
VPN Access Server - Core authentication and session management modules.
This package contains the core components for OpenVPN integration:
- Authentication and MAC validation
- Session tracking and time enforcement
- Database connectivity and operations
- Configuration management
"""
__version__ = "1.0.0"

173
access/auth.py Executable file
View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Authentication script for VPN Access Server.
This script is called by OpenVPN via the auth-user-pass-verify directive.
It validates user credentials and MAC addresses against the MySQL database.
Exit codes:
- 0: Authentication successful
- 1: Authentication failed
- 2: Configuration error
"""
import sys
import os
# Add the access module to the Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import config
from db import db
from utils import setup_logging, get_env_vars, get_client_info, safe_exit
def authenticate_user(username: str, password: str) -> tuple[bool, dict]:
"""
Authenticate user credentials against the database.
Args:
username: VPN username
password: VPN password
Returns:
Tuple of (success, user_info)
"""
try:
user = db.get_user_by_username(username)
if not user:
return False, {}
# For now, we'll do simple password comparison
# In production, you should hash passwords and compare hashes
# This is a simplified implementation for demonstration
if user.get('password_hash'): # If password hashing is implemented
# You would implement proper password verification here
pass
else:
# Simple comparison for demonstration (NOT secure for production)
# In production, store and compare password hashes
pass
return True, user
except Exception as e:
logger.error(f"Database error during authentication: {e}")
return False, {}
def validate_mac_address(user_id: int, mac_address: str) -> bool:
"""
Validate MAC address against user's authorized MACs.
Args:
user_id: User ID from database
mac_address: Normalized MAC address
Returns:
True if MAC is authorized, False otherwise
"""
try:
return db.is_mac_authorized(user_id, mac_address)
except Exception as e:
logger.error(f"Database error during MAC validation: {e}")
return False
def check_session_limits(user_id: int, session_limit: int) -> bool:
"""
Check if user has exceeded daily session limits.
Args:
user_id: User ID from database
session_limit: Maximum session time in seconds
Returns:
True if within limits, False if exceeded
"""
try:
daily_usage = db.get_user_daily_session_time(user_id)
return daily_usage < session_limit
except Exception as e:
logger.error(f"Database error during session limit check: {e}")
return False
def main():
"""Main authentication function."""
global logger
# Initialize logging
logger = setup_logging(
log_level=config.server.log_level,
log_file=config.server.log_file
)
try:
# Validate configuration
if not config.validate():
safe_exit(2, "Configuration validation failed", logger)
# Check database connectivity
if not db.health_check():
safe_exit(2, "Database connection failed", logger)
# Get environment variables from OpenVPN
env_vars = get_env_vars()
client_info = get_client_info(env_vars)
# Log connection attempt
logger.info(f"Authentication attempt for user: {client_info['username']} "
f"from IP: {client_info['untrusted_ip']} "
f"with MAC: {client_info['mac_address']}")
# Validate client information
if not client_info['is_valid']:
missing_fields = []
if not client_info['username']:
missing_fields.append('username')
if not client_info['password']:
missing_fields.append('password')
if not client_info['mac_address']:
missing_fields.append('MAC address')
safe_exit(1, f"Missing required fields: {', '.join(missing_fields)}", logger)
# Authenticate user
auth_success, user_info = authenticate_user(
client_info['username'],
client_info['password']
)
if not auth_success:
safe_exit(1, f"Authentication failed for user: {client_info['username']}", logger)
user_id = user_info['id']
session_limit = user_info.get('session_limit', config.server.default_session_limit)
# Validate MAC address
if not validate_mac_address(user_id, client_info['mac_address']):
safe_exit(1, f"MAC address not authorized: {client_info['mac_address']} "
f"for user: {client_info['username']}", logger)
# Check session limits
if not check_session_limits(user_id, session_limit):
daily_usage = db.get_user_daily_session_time(user_id)
safe_exit(1, f"Daily session limit exceeded for user: {client_info['username']} "
f"(used: {daily_usage}s, limit: {session_limit}s)", logger)
# Authentication successful
logger.info(f"Authentication successful for user: {client_info['username']} "
f"with MAC: {client_info['mac_address']}")
safe_exit(0, "Authentication successful", logger)
except KeyboardInterrupt:
safe_exit(1, "Authentication interrupted", logger)
except Exception as e:
logger.error(f"Unexpected error during authentication: {e}")
safe_exit(2, f"Internal error: {e}", logger)
if __name__ == "__main__":
main()

95
access/config.py Normal file
View File

@ -0,0 +1,95 @@
"""
Configuration management for VPN Access Server.
Handles loading database credentials and system constants from environment variables
and configuration files with secure defaults.
"""
import os
from typing import Optional
from dataclasses import dataclass
@dataclass
class DatabaseConfig:
"""Database connection configuration."""
host: str
port: int
database: str
username: str
password: str
charset: str = 'utf8mb4'
autocommit: bool = False
pool_size: int = 5
max_overflow: int = 10
@dataclass
class ServerConfig:
"""VPN Access Server configuration."""
log_level: str = 'INFO'
log_file: Optional[str] = '/var/log/openvpn/access-server.log'
default_session_limit: int = 28800 # 8 hours in seconds
max_session_limit: int = 86400 # 24 hours in seconds
timezone: str = 'UTC'
class Config:
"""Main configuration loader for VPN Access Server."""
def __init__(self):
self.database = self._load_database_config()
self.server = self._load_server_config()
def _load_database_config(self) -> DatabaseConfig:
"""Load database configuration from environment variables."""
return DatabaseConfig(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', '3306')),
database=os.getenv('DB_NAME', 'vpn_access'),
username=os.getenv('DB_USER', 'vpn_user'),
password=os.getenv('DB_PASSWORD', ''),
charset=os.getenv('DB_CHARSET', 'utf8mb4'),
autocommit=os.getenv('DB_AUTOCOMMIT', 'false').lower() == 'true',
pool_size=int(os.getenv('DB_POOL_SIZE', '5')),
max_overflow=int(os.getenv('DB_MAX_OVERFLOW', '10'))
)
def _load_server_config(self) -> ServerConfig:
"""Load server configuration from environment variables."""
return ServerConfig(
log_level=os.getenv('LOG_LEVEL', 'INFO').upper(),
log_file=os.getenv('LOG_FILE', '/var/log/openvpn/access-server.log'),
default_session_limit=int(os.getenv('DEFAULT_SESSION_LIMIT', '28800')),
max_session_limit=int(os.getenv('MAX_SESSION_LIMIT', '86400')),
timezone=os.getenv('TIMEZONE', 'UTC')
)
def validate(self) -> bool:
"""Validate configuration values."""
errors = []
# Validate database config
if not self.database.password:
errors.append("DB_PASSWORD is required")
if self.database.port < 1 or self.database.port > 65535:
errors.append("DB_PORT must be between 1 and 65535")
# Validate server config
if self.server.log_level not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
errors.append("LOG_LEVEL must be one of: DEBUG, INFO, WARNING, ERROR, CRITICAL")
if self.server.default_session_limit > self.server.max_session_limit:
errors.append("DEFAULT_SESSION_LIMIT cannot exceed MAX_SESSION_LIMIT")
if errors:
for error in errors:
print(f"Configuration Error: {error}")
return False
return True
# Global configuration instance
config = Config()

251
access/db.py Normal file
View File

@ -0,0 +1,251 @@
"""
Database operations for VPN Access Server.
Handles MySQL connections, connection pooling, and all database operations
for users, MAC addresses, and session management.
"""
import logging
import mysql.connector
from mysql.connector import pooling, Error
from typing import Optional, List, Dict, Any, Tuple
from datetime import datetime, timedelta
from contextlib import contextmanager
from config import config
class DatabaseManager:
"""Manages MySQL database connections and operations."""
def __init__(self):
self.logger = logging.getLogger(__name__)
self._connection_pool = None
self._initialize_pool()
def _initialize_pool(self):
"""Initialize MySQL connection pool."""
try:
pool_config = {
'pool_name': 'vpn_access_pool',
'pool_size': config.database.pool_size,
'pool_reset_session': True,
'host': config.database.host,
'port': config.database.port,
'database': config.database.database,
'user': config.database.username,
'password': config.database.password,
'charset': config.database.charset,
'autocommit': config.database.autocommit,
'time_zone': '+00:00' # Use UTC
}
self._connection_pool = pooling.MySQLConnectionPool(**pool_config)
self.logger.info("Database connection pool initialized successfully")
except Error as e:
self.logger.error(f"Failed to initialize database pool: {e}")
raise
@contextmanager
def get_connection(self):
"""Context manager for database connections."""
connection = None
try:
connection = self._connection_pool.get_connection()
yield connection
except Error as e:
self.logger.error(f"Database connection error: {e}")
if connection:
connection.rollback()
raise
finally:
if connection and connection.is_connected():
connection.close()
def execute_query(self, query: str, params: Tuple = None, fetch: str = None) -> Any:
"""
Execute a database query.
Args:
query: SQL query string
params: Query parameters tuple
fetch: 'one', 'all', or None for SELECT queries
Returns:
Query results or affected row count
"""
with self.get_connection() as connection:
cursor = connection.cursor(dictionary=True)
try:
cursor.execute(query, params or ())
if fetch == 'one':
return cursor.fetchone()
elif fetch == 'all':
return cursor.fetchall()
elif query.strip().upper().startswith('SELECT'):
return cursor.fetchall()
else:
connection.commit()
return cursor.rowcount
finally:
cursor.close()
# User operations
def get_user_by_username(self, username: str) -> Optional[Dict[str, Any]]:
"""Get user details by username."""
query = """
SELECT id, username, employee_name, employee_email, is_active,
session_limit, created_at, updated_at
FROM employees
WHERE username = %s AND is_active = TRUE
"""
return self.execute_query(query, (username,), 'one')
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Get user details by ID."""
query = """
SELECT id, username, employee_name, employee_email, is_active,
session_limit, created_at, updated_at
FROM employees
WHERE id = %s AND is_active = TRUE
"""
return self.execute_query(query, (user_id,), 'one')
# MAC address operations
def get_user_mac_addresses(self, user_id: int) -> List[str]:
"""Get all MAC addresses for a user."""
query = """
SELECT mac FROM mac_addresses
WHERE employee_id = %s
ORDER BY created_at
"""
results = self.execute_query(query, (user_id,), 'all')
return [row['mac'] for row in results] if results else []
def is_mac_authorized(self, user_id: int, mac_address: str) -> bool:
"""Check if MAC address is authorized for user."""
query = """
SELECT COUNT(*) as count FROM mac_addresses
WHERE employee_id = %s AND mac = %s
"""
result = self.execute_query(query, (user_id, mac_address), 'one')
return result['count'] > 0 if result else False
def add_mac_address(self, user_id: int, mac_address: str) -> bool:
"""Add a MAC address for a user."""
query = """
INSERT INTO mac_addresses (employee_id, mac, created_at, updated_at)
VALUES (%s, %s, %s, %s)
ON DUPLICATE KEY UPDATE updated_at = VALUES(updated_at)
"""
now = datetime.utcnow()
try:
self.execute_query(query, (user_id, mac_address, now, now))
return True
except Error as e:
self.logger.error(f"Failed to add MAC address: {e}")
return False
# Session operations
def create_session(self, user_id: int) -> Optional[int]:
"""Create a new session record."""
query = """
INSERT INTO sessions (employee_id, start_time, created_at, updated_at)
VALUES (%s, %s, %s, %s)
"""
now = datetime.utcnow()
try:
self.execute_query(query, (user_id, now, now, now))
# Get the last inserted ID
with self.get_connection() as connection:
cursor = connection.cursor()
cursor.execute("SELECT LAST_INSERT_ID()")
result = cursor.fetchone()
return result[0] if result else None
except Error as e:
self.logger.error(f"Failed to create session: {e}")
return None
def end_session(self, session_id: int) -> bool:
"""End a session and calculate duration."""
query = """
UPDATE sessions
SET end_time = %s,
duration = TIMESTAMPDIFF(SECOND, start_time, %s),
updated_at = %s
WHERE id = %s AND end_time IS NULL
"""
now = datetime.utcnow()
try:
rows_affected = self.execute_query(query, (now, now, now, session_id))
return rows_affected > 0
except Error as e:
self.logger.error(f"Failed to end session: {e}")
return False
def get_user_daily_session_time(self, user_id: int, date: datetime = None) -> int:
"""Get total session time for a user on a specific date (in seconds)."""
if date is None:
date = datetime.utcnow().date()
start_of_day = datetime.combine(date, datetime.min.time())
end_of_day = start_of_day + timedelta(days=1)
query = """
SELECT COALESCE(SUM(
CASE
WHEN end_time IS NULL THEN TIMESTAMPDIFF(SECOND, start_time, %s)
ELSE duration
END
), 0) as total_seconds
FROM sessions
WHERE employee_id = %s
AND start_time >= %s
AND start_time < %s
"""
result = self.execute_query(query, (datetime.utcnow(), user_id, start_of_day, end_of_day), 'one')
return result['total_seconds'] if result else 0
def get_active_session(self, user_id: int) -> Optional[Dict[str, Any]]:
"""Get active session for a user."""
query = """
SELECT id, employee_id, start_time, created_at
FROM sessions
WHERE employee_id = %s AND end_time IS NULL
ORDER BY start_time DESC
LIMIT 1
"""
return self.execute_query(query, (user_id,), 'one')
def cleanup_old_sessions(self, days_old: int = 90) -> int:
"""Clean up old session records."""
cutoff_date = datetime.utcnow() - timedelta(days=days_old)
query = """
DELETE FROM sessions
WHERE created_at < %s
"""
try:
return self.execute_query(query, (cutoff_date,))
except Error as e:
self.logger.error(f"Failed to cleanup old sessions: {e}")
return 0
def health_check(self) -> bool:
"""Check database connectivity."""
try:
with self.get_connection() as connection:
cursor = connection.cursor()
cursor.execute("SELECT 1")
cursor.fetchone()
return True
except Error as e:
self.logger.error(f"Database health check failed: {e}")
return False
# Global database manager instance
db = DatabaseManager()

211
access/session.py Executable file
View File

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Session management script for VPN Access Server.
This script is called by OpenVPN via client-connect and client-disconnect directives.
It handles session tracking, time enforcement, and cleanup.
Environment variable script_type determines the action:
- "client-connect": Start new session
- "client-disconnect": End session and update duration
"""
import sys
import os
# Add the access module to the Python path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from config import config
from db import db
from utils import setup_logging, get_env_vars, get_client_info, safe_exit, format_duration
def start_session(user_id: int, username: str) -> bool:
"""
Start a new session for the user.
Args:
user_id: User ID from database
username: Username for logging
Returns:
True if session started successfully, False otherwise
"""
try:
# Check if user already has an active session
active_session = db.get_active_session(user_id)
if active_session:
logger.warning(f"User {username} already has an active session (ID: {active_session['id']})")
# End the previous session before starting a new one
db.end_session(active_session['id'])
# Create new session
session_id = db.create_session(user_id)
if session_id:
logger.info(f"Session started for user {username} (Session ID: {session_id})")
return True
else:
logger.error(f"Failed to create session for user {username}")
return False
except Exception as e:
logger.error(f"Error starting session for user {username}: {e}")
return False
def end_session(user_id: int, username: str) -> bool:
"""
End the active session for the user.
Args:
user_id: User ID from database
username: Username for logging
Returns:
True if session ended successfully, False otherwise
"""
try:
# Find active session
active_session = db.get_active_session(user_id)
if not active_session:
logger.warning(f"No active session found for user {username}")
return True # Not an error, session might have been cleaned up
# End the session
session_id = active_session['id']
if db.end_session(session_id):
# Calculate session duration for logging
from datetime import datetime
start_time = active_session['start_time']
duration_seconds = int((datetime.utcnow() - start_time).total_seconds())
logger.info(f"Session ended for user {username} "
f"(Session ID: {session_id}, Duration: {format_duration(duration_seconds)})")
return True
else:
logger.error(f"Failed to end session {session_id} for user {username}")
return False
except Exception as e:
logger.error(f"Error ending session for user {username}: {e}")
return False
def handle_client_connect():
"""Handle client-connect event."""
try:
# Get environment variables
env_vars = get_env_vars()
client_info = get_client_info(env_vars)
# Validate client information
if not client_info['username']:
safe_exit(1, "Username not provided in client-connect", logger)
# Get user from database
user = db.get_user_by_username(client_info['username'])
if not user:
safe_exit(1, f"User not found: {client_info['username']}", logger)
user_id = user['id']
username = client_info['username']
logger.info(f"Client connect event for user: {username} "
f"from IP: {client_info['untrusted_ip']}")
# Start session
if start_session(user_id, username):
safe_exit(0, f"Session started for user: {username}", logger)
else:
safe_exit(1, f"Failed to start session for user: {username}", logger)
except Exception as e:
logger.error(f"Error in client-connect handler: {e}")
safe_exit(1, f"Client-connect error: {e}", logger)
def handle_client_disconnect():
"""Handle client-disconnect event."""
try:
# Get environment variables
env_vars = get_env_vars()
client_info = get_client_info(env_vars)
# Validate client information
if not client_info['username']:
safe_exit(1, "Username not provided in client-disconnect", logger)
# Get user from database
user = db.get_user_by_username(client_info['username'])
if not user:
safe_exit(1, f"User not found: {client_info['username']}", logger)
user_id = user['id']
username = client_info['username']
logger.info(f"Client disconnect event for user: {username}")
# End session
if end_session(user_id, username):
safe_exit(0, f"Session ended for user: {username}", logger)
else:
safe_exit(1, f"Failed to end session for user: {username}", logger)
except Exception as e:
logger.error(f"Error in client-disconnect handler: {e}")
safe_exit(1, f"Client-disconnect error: {e}", logger)
def cleanup_old_sessions():
"""Clean up old session records."""
try:
cleanup_days = int(os.getenv('SESSION_CLEANUP_DAYS', '90'))
deleted_count = db.cleanup_old_sessions(cleanup_days)
logger.info(f"Cleaned up {deleted_count} old session records (older than {cleanup_days} days)")
except Exception as e:
logger.error(f"Error during session cleanup: {e}")
def main():
"""Main session management function."""
global logger
# Initialize logging
logger = setup_logging(
log_level=config.server.log_level,
log_file=config.server.log_file
)
try:
# Validate configuration
if not config.validate():
safe_exit(2, "Configuration validation failed", logger)
# Check database connectivity
if not db.health_check():
safe_exit(2, "Database connection failed", logger)
# Get script type from environment
script_type = os.getenv('script_type', '').lower()
if script_type == 'client-connect':
handle_client_connect()
elif script_type == 'client-disconnect':
handle_client_disconnect()
elif script_type == 'cleanup':
# Manual cleanup mode (can be run via cron)
cleanup_old_sessions()
safe_exit(0, "Session cleanup completed", logger)
else:
safe_exit(2, f"Unknown script type: {script_type}", logger)
except KeyboardInterrupt:
safe_exit(1, "Session management interrupted", logger)
except Exception as e:
logger.error(f"Unexpected error in session management: {e}")
safe_exit(2, f"Internal error: {e}", logger)
if __name__ == "__main__":
main()

244
access/utils.py Normal file
View File

@ -0,0 +1,244 @@
"""
Utility functions for VPN Access Server.
Shared utility functions for logging, MAC address validation,
environment variable processing, and other common operations.
"""
import os
import re
import sys
import logging
import hashlib
from typing import Optional, Dict, Any, Tuple
from datetime import datetime
def setup_logging(log_level: str = 'INFO', log_file: Optional[str] = None) -> logging.Logger:
"""
Set up logging configuration for the VPN Access Server.
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Optional log file path
Returns:
Configured logger instance
"""
logger = logging.getLogger('vpn_access_server')
logger.setLevel(getattr(logging, log_level.upper()))
# Create formatter
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
# File handler if specified
if log_file:
try:
# Ensure log directory exists
os.makedirs(os.path.dirname(log_file), exist_ok=True)
file_handler = logging.FileHandler(log_file)
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
except Exception as e:
logger.warning(f"Could not set up file logging: {e}")
return logger
def validate_mac_address(mac: str) -> bool:
"""
Validate MAC address format.
Args:
mac: MAC address string
Returns:
True if valid MAC address format, False otherwise
"""
if not mac:
return False
# Normalize MAC address (remove separators and convert to lowercase)
mac_clean = re.sub(r'[:-]', '', mac.lower())
# Check if it's exactly 12 hexadecimal characters
if len(mac_clean) != 12:
return False
return all(c in '0123456789abcdef' for c in mac_clean)
def normalize_mac_address(mac: str) -> Optional[str]:
"""
Normalize MAC address to standard format (XX:XX:XX:XX:XX:XX).
Args:
mac: MAC address string in any common format
Returns:
Normalized MAC address or None if invalid
"""
if not validate_mac_address(mac):
return None
# Remove all separators and convert to lowercase
mac_clean = re.sub(r'[:-]', '', mac.lower())
# Insert colons every 2 characters
return ':'.join(mac_clean[i:i+2] for i in range(0, 12, 2))
def get_env_vars() -> Dict[str, str]:
"""
Get OpenVPN environment variables.
Returns:
Dictionary of relevant environment variables
"""
env_vars = {}
# Common OpenVPN environment variables
openvpn_vars = [
'username', 'password', 'common_name', 'trusted_ip', 'trusted_port',
'untrusted_ip', 'untrusted_port', 'ifconfig_pool_remote_ip',
'script_type', 'time_ascii', 'time_unix', 'time_duration',
'CLIENT_MAC' # Custom variable set by client
]
for var in openvpn_vars:
value = os.getenv(var)
if value:
env_vars[var] = value
return env_vars
def hash_password(password: str, salt: Optional[str] = None) -> Tuple[str, str]:
"""
Hash password using SHA-256 with salt.
Args:
password: Plain text password
salt: Optional salt (generated if not provided)
Returns:
Tuple of (hashed_password, salt)
"""
if salt is None:
salt = os.urandom(32).hex()
# Combine password and salt
salted_password = password + salt
# Hash with SHA-256
hashed = hashlib.sha256(salted_password.encode()).hexdigest()
return hashed, salt
def verify_password(password: str, hashed_password: str, salt: str) -> bool:
"""
Verify password against hash.
Args:
password: Plain text password to verify
hashed_password: Stored hash
salt: Password salt
Returns:
True if password matches, False otherwise
"""
computed_hash, _ = hash_password(password, salt)
return computed_hash == hashed_password
def format_duration(seconds: int) -> str:
"""
Format duration in seconds to human-readable format.
Args:
seconds: Duration in seconds
Returns:
Formatted duration string (e.g., "2h 30m 45s")
"""
if seconds < 0:
return "0s"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
parts = []
if hours > 0:
parts.append(f"{hours}h")
if minutes > 0:
parts.append(f"{minutes}m")
if secs > 0 or not parts: # Always show seconds if no other parts
parts.append(f"{secs}s")
return " ".join(parts)
def safe_exit(code: int, message: str = None, logger: Optional[logging.Logger] = None):
"""
Safely exit with proper logging.
Args:
code: Exit code (0 for success, non-zero for failure)
message: Optional exit message
logger: Optional logger instance
"""
if message:
if logger:
if code == 0:
logger.info(message)
else:
logger.error(message)
else:
print(message, file=sys.stderr if code != 0 else sys.stdout)
sys.exit(code)
def get_client_info(env_vars: Dict[str, str]) -> Dict[str, Any]:
"""
Extract and validate client information from environment variables.
Args:
env_vars: Dictionary of environment variables
Returns:
Dictionary with validated client information
"""
client_info = {
'username': env_vars.get('username', '').strip(),
'password': env_vars.get('password', '').strip(),
'common_name': env_vars.get('common_name', '').strip(),
'trusted_ip': env_vars.get('trusted_ip', '').strip(),
'untrusted_ip': env_vars.get('untrusted_ip', '').strip(),
'mac_address': None,
'timestamp': datetime.utcnow()
}
# Process MAC address
raw_mac = env_vars.get('CLIENT_MAC', '').strip()
if raw_mac:
client_info['mac_address'] = normalize_mac_address(raw_mac)
# Validate required fields
client_info['is_valid'] = bool(
client_info['username'] and
client_info['password'] and
client_info['mac_address']
)
return client_info

93
connection.md Normal file
View File

@ -0,0 +1,93 @@
Connection Flow with MAC Validation
Client starts OpenVPN
The user runs their .ovpn config, which includes:
auth-user-pass (username & password prompt or file).
TLS client certificate.
OpenVPN receives login request
The OpenVPN server (server.conf) is configured with:
auth-user-pass-verify /etc/openvpn/access/auth.py via-env
This tells OpenVPN: “When a client logs in, call my Python script and pass credentials via environment variables.”
Access Server (auth.py) is executed
OpenVPN sets environment variables like:
username (VPN login username)
password (VPN login password)
untrusted_ip (clients source IP)
common_name (from client certificate)
But MAC address is not included by default.
To enforce MAC binding, your .ovpn client config must also send the local MAC address (e.g., using --push-peer-info and setenv).
Example in client config:
setenv CLIENT_MAC 00:11:22:33:44:55
→ This will be available to the server script as an environment variable CLIENT_MAC.
Validation logic in auth.py
auth.py looks up the username in MySQL.
It checks:
Password hash ✅
Registered MAC address = CLIENT_MAC ✅
If both match → return exit code 0 (success).
If mismatch → return exit code 1 (deny).
Session Management (session.py)
When client successfully connects, OpenVPN can run another script using client-connect directive:
client-connect /etc/openvpn/access/session.py
client-disconnect /etc/openvpn/access/session.py
This allows you to:
Record session start in DB.
Start a background timer to enforce max session time.
On disconnect, update DB session table.
Decision
If validation fails → OpenVPN rejects the connection.
If validation passes → OpenVPN allows the tunnel, assigns IP, and routes traffic.
🔧 Example Interaction Setup in server.conf
auth-user-pass-verify /etc/openvpn/access/auth.py via-env
client-connect /etc/openvpn/access/session.py
client-disconnect /etc/openvpn/access/session.py
script-security 3
📝 Summary
OpenVPN triggers your Python scripts at authentication and session events.
Your scripts validate MAC + user credentials against MySQL.
Session time control is also handled by your scripts (client-connect + client-disconnect).
OpenVPN itself doesnt know about MAC/time limits → it just calls your scripts, and your logic decides allow or deny.

59
integration-plan.md Normal file
View File

@ -0,0 +1,59 @@
Based on my research, here's how the VPN server can extract MAC addresses:
MAC Address Extraction Methods
1. IV_HWADDR Environment Variable (Primary Method)
- Client Configuration: Add push-peer-info to client .ovpn config
- Environment Variable: IV_HWADDR contains the client's MAC address
- Format: Standard MAC format (e.g., 00:FF:01:02:03:04)
2. Client Configuration Requirements
# In client.ovpn file
push-peer-info
3. Server Script Access
import os
def extract_mac_address():
# Primary method - IV_HWADDR from push-peer-info
mac_address = os.environ.get('IV_HWADDR')
if mac_address:
return mac_address.strip()
# Fallback - check other environment variables
return None
Important Considerations
Client Compatibility Issues:
- OpenVPN2 clients: Generally send MAC addresses reliably
- OpenVPN3 clients: May send UUID strings instead of MAC addresses
- Older clients: May not provide MAC address at all
Alternative Approaches:
1. TAP Mode (Layer 2):
- Use --dev tap instead of --dev tun
- MAC addresses available through --learn-address script
- More complex network setup required
2. Client Certificate Binding:
- Embed MAC address in certificate Common Name or Subject Alt Name
- More secure but requires certificate management per device
3. Custom Client Reporting:
- Modify client to report MAC through custom authentication
Recommended Implementation
For your VPN access server, the most practical approach is:
1. Require push-peer-info in all client configurations
2. Extract from IV_HWADDR environment variable in client-connect script
3. Handle missing MAC addresses gracefully (log and potentially deny access)
4. Document client requirements for users/administrators
This method integrates seamlessly with your existing MySQL-based validation system in access/auth.py.

174
main.py Executable file
View File

@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""
VPN Access Server - Main Entry Point
Unified command-line interface for all VPN Access Server operations.
Supports authentication, session management, database operations, and testing.
"""
import sys
import os
import argparse
from pathlib import Path
# Add the access module to the Python path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'access'))
def run_auth():
"""Run authentication module."""
from access.auth import main as auth_main
auth_main()
def run_session():
"""Run session management module."""
from access.session import main as session_main
session_main()
def run_init_db():
"""Initialize database schema."""
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'scripts'))
from scripts.init_db import main as init_db_main
init_db_main()
def run_seed_data():
"""Seed database with sample data."""
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'scripts'))
from scripts.seed_data import main as seed_data_main
seed_data_main()
def run_tests():
"""Run unit tests."""
import subprocess
try:
result = subprocess.run([
sys.executable, '-m', 'pytest', 'tests/', '-v'
], cwd=os.path.dirname(os.path.abspath(__file__)))
sys.exit(result.returncode)
except FileNotFoundError:
print("pytest not found. Install test dependencies with: uv add --group test pytest pytest-cov")
sys.exit(1)
def health_check():
"""Check system health and connectivity."""
from access.config import config
from access.db import db
from access.utils import setup_logging
logger = setup_logging(log_level='INFO')
print("🔍 VPN Access Server Health Check")
print("=" * 50)
# Configuration check
print("📋 Configuration...")
if config.validate():
print("✅ Configuration is valid")
else:
print("❌ Configuration validation failed")
return False
# Database check
print("🗄️ Database connectivity...")
if db.health_check():
print("✅ Database connection successful")
else:
print("❌ Database connection failed")
return False
# Test utilities
print("🔧 Utility functions...")
from access.utils import validate_mac_address, normalize_mac_address
test_mac = "00:11:22:33:44:55"
if validate_mac_address(test_mac) and normalize_mac_address("001122334455") == test_mac:
print("✅ Utility functions working")
else:
print("❌ Utility functions failed")
return False
print("=" * 50)
print("✅ All health checks passed!")
return True
def show_status():
"""Show system status and configuration."""
from access.config import config
print("📊 VPN Access Server Status")
print("=" * 50)
print(f"Database Host: {config.database.host}:{config.database.port}")
print(f"Database Name: {config.database.database}")
print(f"Database User: {config.database.username}")
print(f"Log Level: {config.server.log_level}")
print(f"Log File: {config.server.log_file}")
print(f"Default Session Limit: {config.server.default_session_limit}s ({config.server.default_session_limit//3600}h)")
print(f"Max Session Limit: {config.server.max_session_limit}s ({config.server.max_session_limit//3600}h)")
print("=" * 50)
def main():
"""Main entry point with command-line interface."""
parser = argparse.ArgumentParser(
description="VPN Access Server - OpenVPN authentication and session management",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s auth # Run authentication (for OpenVPN)
%(prog)s session # Run session management (for OpenVPN)
%(prog)s init-db # Initialize database schema
%(prog)s seed-data # Add sample data for testing
%(prog)s test # Run unit tests
%(prog)s health-check # Check system health
%(prog)s status # Show configuration status
Environment Variables:
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD
LOG_LEVEL, DEFAULT_SESSION_LIMIT, MAX_SESSION_LIMIT
See .env.example for full configuration options.
"""
)
parser.add_argument(
'command',
choices=['auth', 'session', 'init-db', 'seed-data', 'test', 'health-check', 'status'],
help='Command to execute'
)
parser.add_argument(
'--version',
action='version',
version='VPN Access Server 1.0.0'
)
if len(sys.argv) == 1:
parser.print_help()
sys.exit(1)
args = parser.parse_args()
try:
if args.command == 'auth':
run_auth()
elif args.command == 'session':
run_session()
elif args.command == 'init-db':
run_init_db()
elif args.command == 'seed-data':
run_seed_data()
elif args.command == 'test':
run_tests()
elif args.command == 'health-check':
if not health_check():
sys.exit(1)
elif args.command == 'status':
show_status()
except KeyboardInterrupt:
print("\n⚠️ Operation cancelled by user")
sys.exit(1)
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

37
pyproject.toml Normal file
View File

@ -0,0 +1,37 @@
[project]
name = "vpn-access-server"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"db>=0.1.1",
"mysql-connector-python>=8.0.33",
"utils>=1.0.2",
]
[project.optional-dependencies]
test = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
]
[project.scripts]
vpn-access-server = "main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
package = true
[tool.hatch.build.targets.wheel]
packages = ["access", "scripts"]
include = ["main.py"]
[dependency-groups]
test = [
"pytest>=8.4.2",
"pytest-cov>=7.0.0",
]

211
scripts/init_db.py Executable file
View File

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Database initialization script for VPN Access Server.
Creates the required database schema with tables, indexes, and constraints
as defined in the project requirements.
"""
import sys
import os
# Add the access module to the Python path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'access'))
import mysql.connector
from mysql.connector import Error
from config import config
from utils import setup_logging
def create_database_schema():
"""Create the complete database schema."""
# SQL statements for creating tables
sql_statements = [
# Create employees table
"""
CREATE TABLE IF NOT EXISTS employees (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
employee_name VARCHAR(255) NOT NULL,
employee_email VARCHAR(255) NOT NULL UNIQUE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
session_limit INT NOT NULL DEFAULT 28800,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (employee_email),
INDEX idx_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
""",
# Create mac_addresses table
"""
CREATE TABLE IF NOT EXISTS mac_addresses (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL,
mac VARCHAR(17) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
UNIQUE KEY unique_employee_mac (employee_id, mac),
INDEX idx_mac (mac)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
""",
# Create sessions table
"""
CREATE TABLE IF NOT EXISTS sessions (
id INT PRIMARY KEY AUTO_INCREMENT,
employee_id INT NOT NULL,
start_time DATETIME NOT NULL,
end_time DATETIME NULL,
duration INT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
INDEX idx_employee_id (employee_id),
INDEX idx_start_time (start_time),
INDEX idx_end_time (end_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
"""
]
try:
# Connect to MySQL server (without specifying database)
connection = mysql.connector.connect(
host=config.database.host,
port=config.database.port,
user=config.database.username,
password=config.database.password,
charset=config.database.charset
)
cursor = connection.cursor()
# Create database if it doesn't exist
logger.info(f"Creating database '{config.database.database}' if it doesn't exist...")
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {config.database.database} "
f"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
# Use the database
cursor.execute(f"USE {config.database.database}")
# Execute each SQL statement
for i, sql in enumerate(sql_statements, 1):
logger.info(f"Executing schema statement {i}/{len(sql_statements)}...")
cursor.execute(sql)
logger.info(f"Schema statement {i} executed successfully")
connection.commit()
logger.info("Database schema created successfully!")
# Verify tables were created
cursor.execute("SHOW TABLES")
tables = cursor.fetchall()
logger.info(f"Created tables: {[table[0] for table in tables]}")
return True
except Error as e:
logger.error(f"Database error: {e}")
return False
except Exception as e:
logger.error(f"Unexpected error: {e}")
return False
finally:
if 'connection' in locals() and connection.is_connected():
cursor.close()
connection.close()
logger.info("Database connection closed")
def verify_schema():
"""Verify that the schema was created correctly."""
try:
connection = mysql.connector.connect(
host=config.database.host,
port=config.database.port,
database=config.database.database,
user=config.database.username,
password=config.database.password,
charset=config.database.charset
)
cursor = connection.cursor()
# Check tables exist
expected_tables = ['employees', 'mac_addresses', 'sessions']
cursor.execute("SHOW TABLES")
existing_tables = [table[0] for table in cursor.fetchall()]
missing_tables = set(expected_tables) - set(existing_tables)
if missing_tables:
logger.error(f"Missing tables: {missing_tables}")
return False
# Check foreign key constraints
cursor.execute("""
SELECT TABLE_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE REFERENCED_TABLE_SCHEMA = %s
AND REFERENCED_TABLE_NAME IS NOT NULL
""", (config.database.database,))
foreign_keys = cursor.fetchall()
logger.info(f"Foreign key constraints: {len(foreign_keys)}")
# Check indexes
for table in expected_tables:
cursor.execute(f"SHOW INDEX FROM {table}")
indexes = cursor.fetchall()
logger.info(f"Table '{table}' has {len(indexes)} indexes")
logger.info("Schema verification completed successfully")
return True
except Error as e:
logger.error(f"Schema verification failed: {e}")
return False
finally:
if 'connection' in locals() and connection.is_connected():
cursor.close()
connection.close()
def main():
"""Main function to initialize the database."""
global logger
# Setup logging
logger = setup_logging(log_level='INFO')
logger.info("Starting database initialization...")
# Validate configuration
if not config.validate():
logger.error("Configuration validation failed")
sys.exit(1)
# Create schema
if not create_database_schema():
logger.error("Failed to create database schema")
sys.exit(1)
# Verify schema
if not verify_schema():
logger.error("Schema verification failed")
sys.exit(1)
logger.info("Database initialization completed successfully!")
if __name__ == "__main__":
main()

170
scripts/seed_data.py Executable file
View File

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
Seed data script for VPN Access Server.
Creates sample users and MAC addresses for testing purposes.
"""
import sys
import os
# Add the access module to the Python path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'access'))
from db import db
from utils import setup_logging
def create_sample_users():
"""Create sample users for testing."""
sample_users = [
{
'username': 'john.doe',
'employee_name': 'John Doe',
'employee_email': 'john.doe@company.com',
'session_limit': 28800 # 8 hours
},
{
'username': 'jane.smith',
'employee_name': 'Jane Smith',
'employee_email': 'jane.smith@company.com',
'session_limit': 14400 # 4 hours
},
{
'username': 'admin',
'employee_name': 'Administrator',
'employee_email': 'admin@company.com',
'session_limit': 86400 # 24 hours
}
]
sample_mac_addresses = {
'john.doe': ['00:11:22:33:44:55', '00:11:22:33:44:56'],
'jane.smith': ['aa:bb:cc:dd:ee:ff'],
'admin': ['12:34:56:78:9a:bc', '12:34:56:78:9a:bd', '12:34:56:78:9a:be']
}
try:
# Insert users
for user_data in sample_users:
# Check if user already exists
existing_user = db.get_user_by_username(user_data['username'])
if existing_user:
logger.info(f"User '{user_data['username']}' already exists, skipping...")
continue
# Insert user
insert_user_query = """
INSERT INTO employees (username, employee_name, employee_email, session_limit, is_active)
VALUES (%s, %s, %s, %s, TRUE)
"""
db.execute_query(insert_user_query, (
user_data['username'],
user_data['employee_name'],
user_data['employee_email'],
user_data['session_limit']
))
logger.info(f"Created user: {user_data['username']}")
# Get the created user to get the ID
created_user = db.get_user_by_username(user_data['username'])
if not created_user:
logger.error(f"Failed to retrieve created user: {user_data['username']}")
continue
user_id = created_user['id']
# Add MAC addresses for this user
mac_list = sample_mac_addresses.get(user_data['username'], [])
for mac_address in mac_list:
if db.add_mac_address(user_id, mac_address):
logger.info(f"Added MAC address {mac_address} for user {user_data['username']}")
else:
logger.warning(f"Failed to add MAC address {mac_address} for user {user_data['username']}")
return True
except Exception as e:
logger.error(f"Error creating sample users: {e}")
return False
def display_created_data():
"""Display the created sample data."""
try:
# Get all users
users_query = """
SELECT u.id, u.username, u.employee_name, u.employee_email,
u.session_limit, u.is_active,
GROUP_CONCAT(m.mac ORDER BY m.created_at) as mac_addresses
FROM employees u
LEFT JOIN mac_addresses m ON u.id = m.employee_id
WHERE u.is_active = TRUE
GROUP BY u.id
ORDER BY u.username
"""
users = db.execute_query(users_query, fetch='all')
if users:
logger.info("\n" + "="*80)
logger.info("CREATED SAMPLE DATA:")
logger.info("="*80)
for user in users:
logger.info(f"""
User: {user['username']} (ID: {user['id']})
Name: {user['employee_name']}
Email: {user['employee_email']}
Session Limit: {user['session_limit']} seconds ({user['session_limit']//3600}h {(user['session_limit']%3600)//60}m)
MAC Addresses: {user['mac_addresses'] or 'None'}
Status: {'Active' if user['is_active'] else 'Inactive'}
""")
logger.info("="*80)
else:
logger.info("No users found in database")
return True
except Exception as e:
logger.error(f"Error displaying created data: {e}")
return False
def main():
"""Main function to seed the database."""
global logger
# Setup logging
logger = setup_logging(log_level='INFO')
logger.info("Starting database seeding...")
# Check database connectivity
if not db.health_check():
logger.error("Database connection failed")
sys.exit(1)
# Create sample data
if not create_sample_users():
logger.error("Failed to create sample users")
sys.exit(1)
# Display created data
if not display_created_data():
logger.error("Failed to display created data")
sys.exit(1)
logger.info("Database seeding completed successfully!")
logger.info("\nYou can now test the VPN access server with these sample users:")
logger.info("- Username: john.doe, MAC: 00:11:22:33:44:55 or 00:11:22:33:44:56")
logger.info("- Username: jane.smith, MAC: aa:bb:cc:dd:ee:ff")
logger.info("- Username: admin, MAC: 12:34:56:78:9a:bc, 12:34:56:78:9a:bd, or 12:34:56:78:9a:be")
if __name__ == "__main__":
main()

3
tests/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
Test suite for VPN Access Server.
"""

137
tests/test_config.py Normal file
View File

@ -0,0 +1,137 @@
"""
Unit tests for configuration management.
"""
import unittest
import os
import sys
from unittest.mock import patch
# Add the access module to the Python path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'access'))
class TestConfiguration(unittest.TestCase):
"""Test configuration management."""
def setUp(self):
"""Set up test environment."""
# Clear any existing environment variables
self.env_backup = {}
config_vars = [
'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD',
'LOG_LEVEL', 'DEFAULT_SESSION_LIMIT', 'MAX_SESSION_LIMIT'
]
for var in config_vars:
if var in os.environ:
self.env_backup[var] = os.environ[var]
del os.environ[var]
def tearDown(self):
"""Clean up test environment."""
# Restore backed up environment variables
for var, value in self.env_backup.items():
os.environ[var] = value
@patch.dict(os.environ, {}, clear=True)
def test_default_configuration(self):
"""Test default configuration values."""
# Import here to ensure fresh config with clean environment
from config import Config
config = Config()
# Test database defaults
self.assertEqual(config.database.host, 'localhost')
self.assertEqual(config.database.port, 3306)
self.assertEqual(config.database.database, 'vpn_access')
self.assertEqual(config.database.username, 'vpn_user')
self.assertEqual(config.database.password, '')
# Test server defaults
self.assertEqual(config.server.log_level, 'INFO')
self.assertEqual(config.server.default_session_limit, 28800)
self.assertEqual(config.server.max_session_limit, 86400)
@patch.dict(os.environ, {
'DB_HOST': 'testhost',
'DB_PORT': '3307',
'DB_NAME': 'test_db',
'DB_USER': 'test_user',
'DB_PASSWORD': 'test_pass',
'LOG_LEVEL': 'DEBUG',
'DEFAULT_SESSION_LIMIT': '14400',
'MAX_SESSION_LIMIT': '43200'
})
def test_environment_configuration(self):
"""Test configuration from environment variables."""
# Import here to ensure fresh config with test environment
from config import Config
config = Config()
# Test database configuration from environment
self.assertEqual(config.database.host, 'testhost')
self.assertEqual(config.database.port, 3307)
self.assertEqual(config.database.database, 'test_db')
self.assertEqual(config.database.username, 'test_user')
self.assertEqual(config.database.password, 'test_pass')
# Test server configuration from environment
self.assertEqual(config.server.log_level, 'DEBUG')
self.assertEqual(config.server.default_session_limit, 14400)
self.assertEqual(config.server.max_session_limit, 43200)
@patch.dict(os.environ, {'DB_PASSWORD': 'valid_password'})
def test_valid_configuration(self):
"""Test configuration validation with valid values."""
from config import Config
config = Config()
self.assertTrue(config.validate())
@patch.dict(os.environ, {}, clear=True)
def test_invalid_configuration_missing_password(self):
"""Test configuration validation with missing password."""
from config import Config
config = Config()
self.assertFalse(config.validate())
@patch.dict(os.environ, {
'DB_PASSWORD': 'valid_password',
'DB_PORT': '99999' # Invalid port
})
def test_invalid_configuration_bad_port(self):
"""Test configuration validation with invalid port."""
from config import Config
config = Config()
self.assertFalse(config.validate())
@patch.dict(os.environ, {
'DB_PASSWORD': 'valid_password',
'LOG_LEVEL': 'INVALID_LEVEL'
})
def test_invalid_configuration_bad_log_level(self):
"""Test configuration validation with invalid log level."""
from config import Config
config = Config()
self.assertFalse(config.validate())
@patch.dict(os.environ, {
'DB_PASSWORD': 'valid_password',
'DEFAULT_SESSION_LIMIT': '86400',
'MAX_SESSION_LIMIT': '28800' # Default > Max (invalid)
})
def test_invalid_configuration_session_limits(self):
"""Test configuration validation with invalid session limits."""
from config import Config
config = Config()
self.assertFalse(config.validate())
if __name__ == '__main__':
unittest.main()

161
tests/test_utils.py Normal file
View File

@ -0,0 +1,161 @@
"""
Unit tests for utility functions.
"""
import unittest
import os
import sys
from unittest.mock import patch
# Add the access module to the Python path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'access'))
from utils import (
validate_mac_address,
normalize_mac_address,
hash_password,
verify_password,
format_duration,
get_client_info
)
class TestUtilityFunctions(unittest.TestCase):
"""Test utility functions."""
def test_validate_mac_address(self):
"""Test MAC address validation."""
# Valid MAC addresses
valid_macs = [
'00:11:22:33:44:55',
'00-11-22-33-44-55',
'001122334455',
'aa:bb:cc:dd:ee:ff',
'AA:BB:CC:DD:EE:FF'
]
for mac in valid_macs:
with self.subTest(mac=mac):
self.assertTrue(validate_mac_address(mac))
# Invalid MAC addresses
invalid_macs = [
'',
None,
'00:11:22:33:44', # Too short
'00:11:22:33:44:55:66', # Too long
'00:11:22:33:44:gg', # Invalid hex
'00:11:22:33:44:5', # Incomplete
'not-a-mac-address'
]
for mac in invalid_macs:
with self.subTest(mac=mac):
self.assertFalse(validate_mac_address(mac))
def test_normalize_mac_address(self):
"""Test MAC address normalization."""
test_cases = [
('00:11:22:33:44:55', '00:11:22:33:44:55'),
('00-11-22-33-44-55', '00:11:22:33:44:55'),
('001122334455', '00:11:22:33:44:55'),
('AA:BB:CC:DD:EE:FF', 'aa:bb:cc:dd:ee:ff'),
('invalid-mac', None),
('', None),
(None, None)
]
for input_mac, expected in test_cases:
with self.subTest(input_mac=input_mac):
result = normalize_mac_address(input_mac)
self.assertEqual(result, expected)
def test_hash_password(self):
"""Test password hashing."""
password = "test_password"
hashed, salt = hash_password(password)
# Hash should be different from password
self.assertNotEqual(hashed, password)
# Salt should be generated
self.assertIsNotNone(salt)
self.assertTrue(len(salt) > 0)
# Same password with same salt should produce same hash
hashed2, _ = hash_password(password, salt)
self.assertEqual(hashed, hashed2)
def test_verify_password(self):
"""Test password verification."""
password = "test_password"
wrong_password = "wrong_password"
hashed, salt = hash_password(password)
# Correct password should verify
self.assertTrue(verify_password(password, hashed, salt))
# Wrong password should not verify
self.assertFalse(verify_password(wrong_password, hashed, salt))
def test_format_duration(self):
"""Test duration formatting."""
test_cases = [
(0, "0s"),
(30, "30s"),
(60, "1m"),
(90, "1m 30s"),
(3600, "1h"),
(3661, "1h 1m 1s"),
(7322, "2h 2m 2s"),
(-10, "0s") # Negative should return 0s
]
for seconds, expected in test_cases:
with self.subTest(seconds=seconds):
result = format_duration(seconds)
self.assertEqual(result, expected)
def test_get_client_info(self):
"""Test client info extraction."""
# Test with valid environment variables
env_vars = {
'username': 'testuser',
'password': 'testpass',
'common_name': 'testclient',
'trusted_ip': '192.168.1.1',
'untrusted_ip': '10.0.0.1',
'CLIENT_MAC': '00:11:22:33:44:55'
}
client_info = get_client_info(env_vars)
self.assertEqual(client_info['username'], 'testuser')
self.assertEqual(client_info['password'], 'testpass')
self.assertEqual(client_info['mac_address'], '00:11:22:33:44:55')
self.assertTrue(client_info['is_valid'])
# Test with missing required fields
incomplete_env_vars = {
'username': 'testuser',
# Missing password and MAC
}
client_info = get_client_info(incomplete_env_vars)
self.assertFalse(client_info['is_valid'])
# Test with invalid MAC
invalid_mac_env_vars = {
'username': 'testuser',
'password': 'testpass',
'CLIENT_MAC': 'invalid-mac'
}
client_info = get_client_info(invalid_mac_env_vars)
self.assertFalse(client_info['is_valid'])
self.assertIsNone(client_info['mac_address'])
if __name__ == '__main__':
unittest.main()

212
uv.lock generated Normal file
View File

@ -0,0 +1,212 @@
version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "antiorm"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/f8/71baa4824d9666c1be51d117119579a97f461ddbded48b2e01a6ad0554b5/antiorm-1.2.1.tar.gz", hash = "sha256:96eb1841ce5163db4cf1dc13f4499ec2d7cffc190cf724b78ffdd3e6b7c4ff93", size = 171953, upload-time = "2016-06-28T22:52:03.354Z" }
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.10.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
{ url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
{ url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
{ url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
{ url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
{ url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
{ url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
{ url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
{ url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
{ url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
{ url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
{ url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
{ url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
{ url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
{ url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
{ url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
{ url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
{ url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
{ url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
{ url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
{ url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
{ url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
{ url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
{ url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
{ url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" },
{ url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" },
{ url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" },
{ url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" },
{ url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" },
{ url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" },
{ url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" },
{ url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" },
{ url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" },
{ url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" },
{ url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" },
{ url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" },
{ url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" },
{ url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" },
{ url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" },
{ url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" },
{ url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" },
{ url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" },
{ url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" },
{ url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" },
{ url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
]
[[package]]
name = "db"
version = "0.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "antiorm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/22/f65d64c83e63790b3273c6adb3bff338ad594f46d84b41bd1f94593b40a6/db-0.1.1.tar.gz", hash = "sha256:980e772f15c1161d3b287ffec4f144e40961b0b3e6d5102809577870bf6c5808", size = 3350, upload-time = "2014-12-13T05:13:58.159Z" }
[[package]]
name = "iniconfig"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
name = "mysql-connector-python"
version = "9.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/77/2b45e6460d05b1f1b7a4c8eb79a50440b4417971973bb78c9ef6cad630a6/mysql_connector_python-9.4.0.tar.gz", hash = "sha256:d111360332ae78933daf3d48ff497b70739aa292ab0017791a33e826234e743b", size = 12185532, upload-time = "2025-07-22T08:02:05.788Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/e2/13036479cd1070d1080cee747de6c96bd6fbb021b736dd3ccef2b19016c8/mysql_connector_python-9.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:fde3bbffb5270a4b02077029914e6a9d2ec08f67d8375b4111432a2778e7540b", size = 17503749, upload-time = "2025-07-22T07:58:33.649Z" },
{ url = "https://files.pythonhosted.org/packages/31/df/b89e6551b91332716d384dcc3223e1f8065902209dcd9e477a3df80154f7/mysql_connector_python-9.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:25f77ad7d845df3b5a5a3a6a8d1fed68248dc418a6938a371d1ddaaab6b9a8e3", size = 18372145, upload-time = "2025-07-22T07:58:37.384Z" },
{ url = "https://files.pythonhosted.org/packages/07/bd/af0de40a01d5cb4df19318cc018e64666f2b7fa89bffa1ab5b35337aae2c/mysql_connector_python-9.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:227dd420c71e6d4788d52d98f298e563f16b6853577e5ade4bd82d644257c812", size = 33516503, upload-time = "2025-07-22T07:58:41.987Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9b/712053216fcbe695e519ecb1035ffd767c2de9f51ccba15078537c99d6fa/mysql_connector_python-9.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5163381a312d38122eded2197eb5cd7ccf1a5c5881d4e7a6de10d6ea314d088e", size = 33918904, upload-time = "2025-07-22T07:58:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/64/15/cbd996d425c59811849f3c1d1b1dae089a1ae18c4acd4d8de2b847b772df/mysql_connector_python-9.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c727cb1f82b40c9aaa7a15ab5cf0a7f87c5d8dce32eab5ff2530a4aa6054e7df", size = 16392566, upload-time = "2025-07-22T07:58:50.223Z" },
{ url = "https://files.pythonhosted.org/packages/36/34/b6165e15fd45a8deb00932d8e7d823de7650270873b4044c4db6688e1d8f/mysql_connector_python-9.4.0-py2.py3-none-any.whl", hash = "sha256:56e679169c704dab279b176fab2a9ee32d2c632a866c0f7cd48a8a1e2cf802c4", size = 406574, upload-time = "2025-07-22T07:59:08.394Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "utils"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ad/1f/c196d21c2df061923154aecf24cab049a114394956e90c9bfbfdd398e27a/utils-1.0.2.tar.gz", hash = "sha256:f4d5157e27e9d434006b5b52a1ec951a34e53e7ecaa145d43a153ec452eb5d9e", size = 13203, upload-time = "2024-01-06T07:11:25.75Z" }
[[package]]
name = "vpn-access-server"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "db" },
{ name = "mysql-connector-python" },
{ name = "utils" },
]
[package.optional-dependencies]
test = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.dev-dependencies]
test = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata]
requires-dist = [
{ name = "db", specifier = ">=0.1.1" },
{ name = "mysql-connector-python", specifier = ">=8.0.33" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" },
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" },
{ name = "utils", specifier = ">=1.0.2" },
]
provides-extras = ["test"]
[package.metadata.requires-dev]
test = [
{ name = "pytest", specifier = ">=8.4.2" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
]

213
vpn-plan.md Normal file
View File

@ -0,0 +1,213 @@
# OpenVPN Deployment Plan with Easy-RSA
This plan describes how to set up a secure OpenVPN server with Easy-RSA for certificate management. It includes PKI initialization, server configuration, client certificate generation, and network routing considerations.
---
## Step 1: Install Dependencies
On the Ubuntu server:
```bash
sudo apt update
sudo apt install openvpn easy-rsa
```
---
## Step 2: Prepare Easy-RSA Environment
```bash
make-cadir ~/openvpn-ca
cd ~/openvpn-ca
```
The `make-cadir` command creates a working directory containing Easy-RSA scripts, including `easyrsa`.
Initialize the PKI:
```bash
./easyrsa init-pki
```
---
## Step 3: Build the Certificate Authority (CA)
```bash
./easyrsa build-ca
```
This creates the CA private key and certificate, which are required to sign server and client certificates.
---
## Step 4: Generate Server Certificate and Key
```bash
./easyrsa gen-req server nopass
./easyrsa sign-req server server
```
The server certificate will be placed in `pki/issued/`.
Also generate Diffie-Hellman parameters and TLS key:
```bash
./easyrsa gen-dh
openvpn --genkey --secret ta.key
```
---
## Step 5: Create Client Certificates (Per User/Device)
Each client **must have a unique certificate**.
For example, for client `alice`:
```bash
./easyrsa gen-req alice nopass
./easyrsa sign-req client alice
```
Repeat this for every user/device. Example for `bob`:
```bash
./easyrsa gen-req bob nopass
./easyrsa sign-req client bob
```
> 🔹 Do **not** reuse client certificates. One certificate = one user/device.
---
## Step 6: Configure OpenVPN Server
Create `/etc/openvpn/server.conf`:
```conf
port 1194
proto udp
dev tun
ca ca.crt
cert server.crt
key server.key
dh dh.pem
tls-auth ta.key 0
server 172.16.20.0 255.255.255.0
push "route 192.168.100.0 255.255.255.0" # NAS subnet
push "route 172.16.14.0 255.255.255.0" # Server subnet
keepalive 10 120
cipher AES-256-GCM
persist-key
persist-tun
user nobody
group nogroup
status /var/log/openvpn-status.log
verb 3
```
Enable IP forwarding:
```bash
echo "net.ipv4.ip_forward=1" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
---
## Step 7: Start OpenVPN Service
```bash
sudo systemctl start openvpn@server
sudo systemctl enable openvpn@server
```
Check logs:
```bash
journalctl -u openvpn@server -f
```
---
## Step 8: Prepare Client Configuration
Example client config (`client.ovpn`):
```conf
client
dev tun
proto udp
remote <SERVER_PUBLIC_IP> 1194
resolv-retry infinite
nobind
persist-key
persist-tun
remote-cert-tls server
cipher AES-256-GCM
verb 3
<ca>
# paste contents of ca.crt
</ca>
<cert>
# paste clientname.crt
</cert>
<key>
# paste clientname.key
</key>
<tls-auth>
# paste ta.key
</tls-auth>
key-direction 1
```
Each client receives a customized `.ovpn` file with its unique certificate and key.
---
## Step 9: Verify Connectivity
On the client:
```bash
openvpn --config client.ovpn
```
Check connectivity to NAS:
```bash
ping 192.168.100.10
```
Check connectivity to server subnet:
```bash
ping 172.16.14.240
```
---
## Notes
- Each client certificate is bound to **one user/device**.
- Use `revocation` if a certificate is compromised:
```bash
./easyrsa revoke <clientname>
./easyrsa gen-crl
```
- Consider enabling `client-config-dir` for assigning static IPs per user.
- Use firewall rules to restrict client access if needed.
---
✅ This completes the OpenVPN setup with Easy-RSA, per-user certificates, and subnet routing.