initialize project
This commit is contained in:
commit
ebb6fde04d
23
.env.example
Normal file
23
.env.example
Normal 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
30
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.13
|
||||
73
CLAUDE.md
Normal file
73
CLAUDE.md
Normal 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
184
README.md
Normal 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
11
access/__init__.py
Normal 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
173
access/auth.py
Executable 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
95
access/config.py
Normal 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
251
access/db.py
Normal 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
211
access/session.py
Executable 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
244
access/utils.py
Normal 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
93
connection.md
Normal 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 (client’s 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 doesn’t know about MAC/time limits → it just calls your scripts, and your logic decides allow or deny.
|
||||
59
integration-plan.md
Normal file
59
integration-plan.md
Normal 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
174
main.py
Executable 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
37
pyproject.toml
Normal 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
211
scripts/init_db.py
Executable 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
170
scripts/seed_data.py
Executable 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
3
tests/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""
|
||||
Test suite for VPN Access Server.
|
||||
"""
|
||||
137
tests/test_config.py
Normal file
137
tests/test_config.py
Normal 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
161
tests/test_utils.py
Normal 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
212
uv.lock
generated
Normal 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
213
vpn-plan.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user