Updated API to accept public IP, Change the agent download to give full config options, remove Uvicorn - change to use Waitress for Windows, Guvicorn linux
This commit is contained in:
+2
-1
@@ -2,4 +2,5 @@
|
|||||||
database.db
|
database.db
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.db
|
*.db
|
||||||
*.db.old
|
*.db.old
|
||||||
|
config.ini
|
||||||
+2
-1
@@ -21,7 +21,8 @@ class Log(db.Model):
|
|||||||
event_type = db.Column(db.String(20), nullable=False)
|
event_type = db.Column(db.String(20), nullable=False)
|
||||||
user_name = db.Column(db.String(50), nullable=False)
|
user_name = db.Column(db.String(50), nullable=False)
|
||||||
computer_name = db.Column(db.String(50), nullable=False)
|
computer_name = db.Column(db.String(50), nullable=False)
|
||||||
ip_address = db.Column(db.String(15), nullable=False)
|
local_ip = db.Column(db.String(45), nullable=True) # Increased size to support IPv6, made nullable
|
||||||
|
public_ip = db.Column(db.String(45), nullable=True) # New field for public IP, nullable
|
||||||
timestamp = db.Column(db.DateTime, nullable=False, default=get_current_time_with_timezone)
|
timestamp = db.Column(db.DateTime, nullable=False, default=get_current_time_with_timezone)
|
||||||
retry = db.Column(db.Integer, default=0, nullable=False)
|
retry = db.Column(db.Integer, default=0, nullable=False)
|
||||||
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=True)
|
company_id = db.Column(db.Integer, db.ForeignKey('app_auth_companies.id'), nullable=True)
|
||||||
|
|||||||
+5
-2
@@ -144,7 +144,7 @@ def log_event():
|
|||||||
Log.event_type == data['EventType'],
|
Log.event_type == data['EventType'],
|
||||||
Log.user_name == data['UserName'],
|
Log.user_name == data['UserName'],
|
||||||
Log.computer_name == data['ComputerName'],
|
Log.computer_name == data['ComputerName'],
|
||||||
Log.ip_address == data['IPAddress'],
|
Log.local_ip == data.get('LocalIP'),
|
||||||
Log.timestamp == timestamp
|
Log.timestamp == timestamp
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
@@ -158,7 +158,8 @@ def log_event():
|
|||||||
event_type=data['EventType'],
|
event_type=data['EventType'],
|
||||||
user_name=data['UserName'],
|
user_name=data['UserName'],
|
||||||
computer_name=data['ComputerName'],
|
computer_name=data['ComputerName'],
|
||||||
ip_address=data['IPAddress'],
|
local_ip=data.get('LocalIP'),
|
||||||
|
public_ip=data.get('PublicIP'),
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
retry=is_retry,
|
retry=is_retry,
|
||||||
company_id=g.company_id, # Add the company ID from the API key
|
company_id=g.company_id, # Add the company ID from the API key
|
||||||
@@ -180,6 +181,8 @@ def log_event():
|
|||||||
'event_type': data.get('EventType') if 'data' in locals() else None,
|
'event_type': data.get('EventType') if 'data' in locals() else None,
|
||||||
'user_name': data.get('UserName') if 'data' in locals() else None,
|
'user_name': data.get('UserName') if 'data' in locals() else None,
|
||||||
'computer_name': data.get('ComputerName') if 'data' in locals() else None,
|
'computer_name': data.get('ComputerName') if 'data' in locals() else None,
|
||||||
|
'local_ip': data.get('LocalIP') if 'data' in locals() else None,
|
||||||
|
'public_ip': data.get('PublicIP') if 'data' in locals() else None,
|
||||||
'retry_attempt': data.get('retry', 0) if 'data' in locals() else None,
|
'retry_attempt': data.get('retry', 0) if 'data' in locals() else None,
|
||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
from flask import Flask, session, request, send_from_directory, render_template
|
from flask import Flask, session, request, send_from_directory, render_template
|
||||||
from asgiref.wsgi import WsgiToAsgi
|
|
||||||
from extensions import db, bcrypt, login_manager, get_env_var
|
from extensions import db, bcrypt, login_manager, get_env_var
|
||||||
from flask_migrate import Migrate
|
|
||||||
from auth.models import User, Settings, ApiKey
|
|
||||||
import ssl
|
|
||||||
import logging
|
|
||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
from auth import auth_bp
|
from auth import auth_bp
|
||||||
from api import api_bp
|
from api import api_bp
|
||||||
@@ -13,7 +8,10 @@ from datetime import datetime, timezone, timedelta
|
|||||||
import pytz
|
import pytz
|
||||||
import configparser
|
import configparser
|
||||||
import os
|
import os
|
||||||
import uvicorn
|
import sys
|
||||||
|
import platform
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
import argparse
|
import argparse
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
@@ -22,24 +20,43 @@ from flask_compress import Compress
|
|||||||
from utils.security_headers import setup_security_headers
|
from utils.security_headers import setup_security_headers
|
||||||
from utils.rate_limiter import apply_rate_limits
|
from utils.rate_limiter import apply_rate_limits
|
||||||
from utils.db_logging import setup_database_logging
|
from utils.db_logging import setup_database_logging
|
||||||
|
from auth.models import User, Settings, ApiKey
|
||||||
# Removed SQLite encryption import
|
# Removed SQLite encryption import
|
||||||
|
|
||||||
# Load configuration from ini file
|
# Set up logging first
|
||||||
config = configparser.ConfigParser()
|
|
||||||
config_file = os.path.join(os.path.dirname(__file__), 'config.ini')
|
|
||||||
config.read(config_file)
|
|
||||||
|
|
||||||
# Set up logging
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG if config.getboolean('app', 'APP_DEBUG', fallback=True) else logging.INFO,
|
level=logging.INFO, # Default level, will be updated after config is loaded
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = Flask(__name__)
|
# Configuration setup with automatic config.ini management
|
||||||
|
from utils.config_manager import initialize_config
|
||||||
|
|
||||||
# Add Migrate after app initialization
|
config_file = os.path.join(os.path.dirname(__file__), 'config.ini')
|
||||||
migrate = Migrate(app, db)
|
|
||||||
|
# Initialize configuration with automatic creation/updating
|
||||||
|
try:
|
||||||
|
config = initialize_config(config_file, preserve_existing=True)
|
||||||
|
logger.info("Configuration initialized successfully")
|
||||||
|
|
||||||
|
# Update logging level based on config
|
||||||
|
debug_mode = config.getboolean('app', 'APP_DEBUG', fallback=False)
|
||||||
|
if debug_mode:
|
||||||
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
|
logger.info("Debug mode enabled")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize configuration: {e}")
|
||||||
|
# Fall back to basic ConfigParser if config manager fails
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
config.read(config_file)
|
||||||
|
else:
|
||||||
|
logger.error(f"Configuration file {config_file} not found and could not be created")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Configure WSGI middleware for reverse proxy support (Traefik)
|
# Configure WSGI middleware for reverse proxy support (Traefik)
|
||||||
proxy_count = config.getint('proxy', 'PROXY_COUNT', fallback=1)
|
proxy_count = config.getint('proxy', 'PROXY_COUNT', fallback=1)
|
||||||
@@ -99,11 +116,11 @@ if config.getboolean('cache', 'ENABLE_COMPRESSION', fallback=True):
|
|||||||
# Enable CSRF protection
|
# Enable CSRF protection
|
||||||
csrf = CSRFProtect(app)
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
# Configure static files with caching
|
# Configure static files with caching and proper MIME types
|
||||||
@app.route('/favicon.ico')
|
@app.route('/favicon.ico')
|
||||||
def favicon():
|
def favicon():
|
||||||
response = send_from_directory(os.path.join(app.root_path, 'static', 'img'),
|
response = send_from_directory(os.path.join(app.root_path, 'static', 'img'),
|
||||||
'favicon.ico', mimetype='image/ico')
|
'favicon.ico', mimetype='image/x-icon')
|
||||||
|
|
||||||
# Add cache headers manually instead of using cache_timeout
|
# Add cache headers manually instead of using cache_timeout
|
||||||
max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
|
max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
|
||||||
@@ -112,6 +129,62 @@ def favicon():
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
# Add explicit static file serving with proper MIME types
|
||||||
|
@app.route('/static/<path:filename>')
|
||||||
|
def static_files(filename):
|
||||||
|
"""Serve static files with proper MIME types"""
|
||||||
|
from flask import send_from_directory
|
||||||
|
import mimetypes
|
||||||
|
|
||||||
|
# Ensure proper MIME type detection
|
||||||
|
if filename.endswith('.css'):
|
||||||
|
mimetype = 'text/css'
|
||||||
|
elif filename.endswith('.js'):
|
||||||
|
mimetype = 'application/javascript'
|
||||||
|
elif filename.endswith('.png'):
|
||||||
|
mimetype = 'image/png'
|
||||||
|
elif filename.endswith('.jpg') or filename.endswith('.jpeg'):
|
||||||
|
mimetype = 'image/jpeg'
|
||||||
|
elif filename.endswith('.gif'):
|
||||||
|
mimetype = 'image/gif'
|
||||||
|
elif filename.endswith('.ico'):
|
||||||
|
mimetype = 'image/x-icon'
|
||||||
|
elif filename.endswith('.woff'):
|
||||||
|
mimetype = 'font/woff'
|
||||||
|
elif filename.endswith('.woff2'):
|
||||||
|
mimetype = 'font/woff2'
|
||||||
|
elif filename.endswith('.ttf'):
|
||||||
|
mimetype = 'font/ttf'
|
||||||
|
else:
|
||||||
|
# Use mimetypes module for other files
|
||||||
|
mimetype, _ = mimetypes.guess_type(filename)
|
||||||
|
if not mimetype:
|
||||||
|
mimetype = 'application/octet-stream'
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = send_from_directory(app.static_folder, filename, mimetype=mimetype)
|
||||||
|
|
||||||
|
# Add cache headers based on file type
|
||||||
|
if filename.endswith(('.png', '.jpg', '.jpeg', '.gif', '.ico', '.woff', '.woff2', '.ttf')):
|
||||||
|
max_age = config.getint('cache', 'IMAGE_MAX_AGE', fallback=604800)
|
||||||
|
elif filename.endswith(('.js', '.css')):
|
||||||
|
max_age = config.getint('cache', 'JS_CSS_MAX_AGE', fallback=43200)
|
||||||
|
else:
|
||||||
|
max_age = config.getint('cache', 'STATIC_MAX_AGE', fallback=86400)
|
||||||
|
|
||||||
|
response.headers['Cache-Control'] = f'public, max-age={max_age}'
|
||||||
|
response.headers['Expires'] = (datetime.now(timezone.utc) + timedelta(seconds=max_age)).strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||||
|
|
||||||
|
# Add ETag for efficient caching
|
||||||
|
response.add_etag()
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
# Return 404 for missing static files instead of redirecting to HTML pages
|
||||||
|
from flask import abort
|
||||||
|
abort(404)
|
||||||
|
|
||||||
# Setup security headers
|
# Setup security headers
|
||||||
setup_security_headers(app, config)
|
setup_security_headers(app, config)
|
||||||
|
|
||||||
@@ -177,8 +250,6 @@ db.init_app(app)
|
|||||||
bcrypt.init_app(app)
|
bcrypt.init_app(app)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
|
|
||||||
wsg = WsgiToAsgi(app)
|
|
||||||
|
|
||||||
# Register blueprints
|
# Register blueprints
|
||||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||||
app.register_blueprint(api_bp, url_prefix='/api')
|
app.register_blueprint(api_bp, url_prefix='/api')
|
||||||
@@ -190,6 +261,12 @@ apply_rate_limits(app, config)
|
|||||||
def handle_http_exception(exc:HTTPException):
|
def handle_http_exception(exc:HTTPException):
|
||||||
"""Use the code and description from an HTTPException to inform the user of an error"""
|
"""Use the code and description from an HTTPException to inform the user of an error"""
|
||||||
logger.debug('HTTP error %s - %s', exc.code, exc.description)
|
logger.debug('HTTP error %s - %s', exc.code, exc.description)
|
||||||
|
|
||||||
|
# For static file 404s, return proper 404 response instead of HTML error page
|
||||||
|
if request.path.startswith('/static/') and exc.code == 404:
|
||||||
|
from flask import Response
|
||||||
|
return Response(f"Static file not found: {request.path}", status=404, mimetype='text/plain')
|
||||||
|
|
||||||
return render_template("error.html", status_code=exc.code, description=exc.description)
|
return render_template("error.html", status_code=exc.code, description=exc.description)
|
||||||
|
|
||||||
def handle_uncaught_exception(exc:Exception):
|
def handle_uncaught_exception(exc:Exception):
|
||||||
@@ -204,7 +281,7 @@ app.register_error_handler(Exception, handle_uncaught_exception)
|
|||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
return User.query.get(int(user_id))
|
return db.session.get(User, int(user_id))
|
||||||
|
|
||||||
def init_app():
|
def init_app():
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@@ -241,7 +318,8 @@ def init_app():
|
|||||||
# Create initial API key for admin
|
# Create initial API key for admin
|
||||||
api_key = ApiKey(
|
api_key = ApiKey(
|
||||||
key=ApiKey.generate_key(),
|
key=ApiKey.generate_key(),
|
||||||
description="Initial Admin API Key"
|
description="Initial Admin API Key",
|
||||||
|
user_id=admin.id
|
||||||
)
|
)
|
||||||
db.session.add(api_key)
|
db.session.add(api_key)
|
||||||
|
|
||||||
@@ -273,96 +351,183 @@ def format_datetime(value):
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
csrf.exempt(api_bp)
|
csrf.exempt(api_bp)
|
||||||
|
|
||||||
|
def get_best_server():
|
||||||
|
"""Determine the best server for the current OS"""
|
||||||
|
system = platform.system().lower()
|
||||||
|
|
||||||
|
if system == 'windows':
|
||||||
|
return 'waitress'
|
||||||
|
elif system in ['linux', 'darwin']: # Linux or macOS
|
||||||
|
return 'gunicorn'
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown OS: {system}, defaulting to waitress")
|
||||||
|
return 'waitress'
|
||||||
|
|
||||||
|
def run_with_waitress():
|
||||||
|
"""Run the application with Waitress (Windows-compatible production server)"""
|
||||||
|
try:
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
# Read configuration
|
||||||
|
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
||||||
|
port = config.getint('server', 'PORT', fallback=8000)
|
||||||
|
threads = config.get('server', 'WORKERS', fallback='4') # Waitress uses threads instead of processes
|
||||||
|
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback=None)
|
||||||
|
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback=None)
|
||||||
|
|
||||||
|
logger.info(f"Starting Waitress server on {host}:{port} with {threads} threads")
|
||||||
|
|
||||||
|
# Waitress configuration - optimized for performance
|
||||||
|
serve_kwargs = {
|
||||||
|
'host': host,
|
||||||
|
'port': port,
|
||||||
|
'threads': int(threads),
|
||||||
|
'connection_limit': 1000,
|
||||||
|
'cleanup_interval': 30,
|
||||||
|
'channel_timeout': 120,
|
||||||
|
'log_socket_errors': True,
|
||||||
|
# Valid Waitress performance optimizations
|
||||||
|
'recv_bytes': 65536, # Increase receive buffer
|
||||||
|
'send_bytes': 65536, # Increase send buffer
|
||||||
|
'max_request_header_size': 262144, # 256KB header limit
|
||||||
|
'max_request_body_size': 1073741824, # 1GB body limit
|
||||||
|
'expose_tracebacks': False, # Don't expose tracebacks in production
|
||||||
|
}
|
||||||
|
|
||||||
|
# Note: Waitress doesn't handle SSL directly - use reverse proxy for SSL
|
||||||
|
if ssl_certfile and ssl_keyfile and os.path.exists(ssl_certfile) and os.path.exists(ssl_keyfile):
|
||||||
|
logger.info("SSL certificates found - but Waitress doesn't handle SSL directly")
|
||||||
|
logger.info("For SSL support, use a reverse proxy (nginx, traefik, etc.)")
|
||||||
|
logger.info("Starting Waitress without SSL on HTTP")
|
||||||
|
else:
|
||||||
|
logger.info("No SSL certificates configured - starting with HTTP")
|
||||||
|
|
||||||
|
# Start Waitress server (HTTP only - SSL handled by reverse proxy)
|
||||||
|
serve(app, **serve_kwargs)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("Waitress not installed. Install with: pip install waitress")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Waitress: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def run_with_gunicorn():
|
||||||
|
"""Run the application with Gunicorn (Linux/macOS production server)"""
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Read configuration
|
||||||
|
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
||||||
|
port = config.getint('server', 'PORT', fallback=8000)
|
||||||
|
workers = config.get('server', 'WORKERS', fallback='4')
|
||||||
|
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback=None)
|
||||||
|
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback=None)
|
||||||
|
|
||||||
|
logger.info(f"Starting Gunicorn server on {host}:{port} with {workers} workers")
|
||||||
|
logger.info(f"SSL: {'Enabled' if ssl_certfile and ssl_keyfile else 'Disabled'}")
|
||||||
|
|
||||||
|
# Build Gunicorn command
|
||||||
|
cmd = [
|
||||||
|
'gunicorn',
|
||||||
|
'--bind', f'{host}:{port}',
|
||||||
|
'--workers', str(workers),
|
||||||
|
'--worker-class', 'sync',
|
||||||
|
'--timeout', '120',
|
||||||
|
'--keepalive', '5',
|
||||||
|
'--max-requests', '1000',
|
||||||
|
'--max-requests-jitter', '50',
|
||||||
|
'--preload',
|
||||||
|
'app:app'
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add SSL configuration if available
|
||||||
|
if ssl_certfile and ssl_keyfile and os.path.exists(ssl_certfile) and os.path.exists(ssl_keyfile):
|
||||||
|
cmd.extend(['--certfile', ssl_certfile, '--keyfile', ssl_keyfile])
|
||||||
|
logger.info("SSL enabled with certificates")
|
||||||
|
elif ssl_certfile and ssl_keyfile:
|
||||||
|
logger.warning(f"SSL certificates not found: {ssl_certfile}, {ssl_keyfile}")
|
||||||
|
|
||||||
|
# Run Gunicorn
|
||||||
|
subprocess.run(cmd)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.error("Gunicorn not available on this system")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("Gunicorn command not found. Install with: pip install gunicorn")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start Gunicorn: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
def run_app():
|
def run_app():
|
||||||
"""Start the application with Uvicorn using config settings"""
|
"""Start the application with the best server for this OS"""
|
||||||
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
best_server = get_best_server()
|
||||||
port = config.getint('server', 'PORT', fallback=8000)
|
|
||||||
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
|
|
||||||
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
|
|
||||||
|
|
||||||
# Get new configuration settings
|
logger.info(f"Detected OS: {platform.system()}")
|
||||||
development_mode = config.getboolean('server', 'DEVELOPMENT_MODE', fallback=False)
|
logger.info(f"Using server: {best_server}")
|
||||||
watch_files = config.getboolean('server', 'WATCH_FILES', fallback=False)
|
|
||||||
workers_setting = config.get('server', 'WORKERS', fallback='1')
|
|
||||||
worker_lifetime = config.getint('server', 'WORKER_LIFETIME', fallback=86400)
|
|
||||||
graceful_shutdown = config.getboolean('server', 'GRACEFUL_SHUTDOWN', fallback=True)
|
|
||||||
shutdown_timeout = config.getint('server', 'SHUTDOWN_TIMEOUT', fallback=30)
|
|
||||||
|
|
||||||
# Parse workers setting - could be "auto" or a number
|
if best_server == 'waitress':
|
||||||
workers = None
|
success = run_with_waitress()
|
||||||
if workers_setting.lower() == 'auto':
|
elif best_server == 'gunicorn':
|
||||||
import multiprocessing
|
success = run_with_gunicorn()
|
||||||
workers = multiprocessing.cpu_count()
|
|
||||||
else:
|
else:
|
||||||
try:
|
logger.error("No suitable server found")
|
||||||
workers = int(workers_setting)
|
success = False
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Invalid WORKERS setting '{workers_setting}', defaulting to 1")
|
|
||||||
workers = 1
|
|
||||||
|
|
||||||
# Only enable file watching in development mode
|
if not success:
|
||||||
reload_enabled = development_mode and watch_files
|
logger.error("Failed to start preferred server, falling back to Flask dev server")
|
||||||
|
# Fallback to Flask development server
|
||||||
# Use debug log level in development mode
|
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
||||||
log_level = "debug" if development_mode else "info"
|
port = config.getint('server', 'PORT', fallback=8000)
|
||||||
|
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
|
||||||
logger.info(f"Starting application on {host}:{port} with SSL")
|
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
|
||||||
logger.info(f"SSL certificate: {ssl_certfile}")
|
|
||||||
logger.info(f"SSL key: {ssl_keyfile}")
|
ssl_context = None
|
||||||
logger.info(f"Development mode: {development_mode}")
|
if ssl_certfile and ssl_keyfile and os.path.exists(ssl_certfile) and os.path.exists(ssl_keyfile):
|
||||||
logger.info(f"File watching: {reload_enabled}")
|
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
logger.info(f"Workers: {workers}")
|
ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
|
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
# Get max requests per worker before graceful restart
|
|
||||||
# Setting to None disables the worker auto-restart feature
|
app.run(debug=app.config['APP_DEBUG'], ssl_context=ssl_context, host=host, port=port)
|
||||||
limit_max_requests = None
|
|
||||||
if worker_lifetime > 0:
|
|
||||||
# If worker_lifetime is set (> 0), we'll use a reasonable request limit
|
|
||||||
# Default to around 10,000 requests per worker before restart
|
|
||||||
limit_max_requests = 10000
|
|
||||||
|
|
||||||
# Get trusted proxy configuration for Uvicorn
|
|
||||||
trusted_proxies_config = config.get('proxy', 'TRUSTED_PROXIES', fallback='').strip()
|
|
||||||
if trusted_proxies_config:
|
|
||||||
forwarded_allow_ips = trusted_proxies_config.replace(',', ' ')
|
|
||||||
logger.info(f"Uvicorn forwarded_allow_ips: {forwarded_allow_ips}")
|
|
||||||
else:
|
|
||||||
forwarded_allow_ips = '*'
|
|
||||||
logger.info("Uvicorn allowing all IPs for forwarded headers")
|
|
||||||
|
|
||||||
uvicorn.run(
|
|
||||||
"app:wsg",
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
reload=reload_enabled,
|
|
||||||
workers=workers,
|
|
||||||
log_level=log_level,
|
|
||||||
ssl_certfile=ssl_certfile,
|
|
||||||
ssl_keyfile=ssl_keyfile,
|
|
||||||
proxy_headers=True,
|
|
||||||
forwarded_allow_ips=forwarded_allow_ips,
|
|
||||||
timeout_keep_alive=65, # Keep-alive timeout to detect hanging connections
|
|
||||||
limit_max_requests=limit_max_requests, # Fixed: Only restart workers after this many requests
|
|
||||||
timeout_graceful_shutdown=shutdown_timeout
|
|
||||||
)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser(description='Domain Logons Monitoring Application')
|
parser = argparse.ArgumentParser(description='Domain Logons Monitoring Application')
|
||||||
parser.add_argument('--legacy', action='store_true', help='Use legacy Flask server instead of Uvicorn')
|
parser.add_argument('--legacy', action='store_true', help='Use legacy Flask development server')
|
||||||
|
parser.add_argument('--waitress', action='store_true', help='Force use of Waitress server (Windows)')
|
||||||
|
parser.add_argument('--gunicorn', action='store_true', help='Force use of Gunicorn server (Linux/macOS)')
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.legacy:
|
if args.legacy:
|
||||||
# Legacy Flask server mode
|
# Legacy Flask development server mode
|
||||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
||||||
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
|
|
||||||
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
|
|
||||||
ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
|
|
||||||
ssl_context.verify_mode = ssl.CERT_NONE # Accept self-signed certificates
|
|
||||||
|
|
||||||
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
host = config.get('server', 'HOST', fallback='0.0.0.0')
|
||||||
port = config.getint('server', 'PORT', fallback=8000)
|
port = config.getint('server', 'PORT', fallback=8000)
|
||||||
|
ssl_certfile = config.get('server', 'SSL_CERTFILE', fallback='certs/cert.pem')
|
||||||
|
ssl_keyfile = config.get('server', 'SSL_KEYFILE', fallback='certs/key.pem')
|
||||||
|
|
||||||
|
ssl_context = None
|
||||||
|
if ssl_certfile and ssl_keyfile and os.path.exists(ssl_certfile) and os.path.exists(ssl_keyfile):
|
||||||
|
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ssl_context.load_cert_chain(ssl_certfile, ssl_keyfile)
|
||||||
|
ssl_context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
logger.info("Starting Flask development server")
|
||||||
app.run(debug=app.config['APP_DEBUG'], ssl_context=ssl_context, host=host, port=port)
|
app.run(debug=app.config['APP_DEBUG'], ssl_context=ssl_context, host=host, port=port)
|
||||||
|
elif args.waitress:
|
||||||
|
# Force Waitress server
|
||||||
|
logger.info("Force using Waitress server")
|
||||||
|
if not run_with_waitress():
|
||||||
|
logger.error("Failed to start Waitress, no fallback available")
|
||||||
|
elif args.gunicorn:
|
||||||
|
# Force Gunicorn server
|
||||||
|
logger.info("Force using Gunicorn server")
|
||||||
|
if not run_with_gunicorn():
|
||||||
|
logger.error("Failed to start Gunicorn, no fallback available")
|
||||||
else:
|
else:
|
||||||
# Default to Uvicorn server
|
# Auto-detect best server for OS
|
||||||
run_app()
|
run_app()
|
||||||
+10
-4
@@ -6,12 +6,11 @@ from .models import User, Settings, AllowedDomain
|
|||||||
from extensions import db
|
from extensions import db
|
||||||
import re
|
import re
|
||||||
|
|
||||||
def validate_password_strength(form, field):
|
def validate_password_requirements(password):
|
||||||
"""Validate password based on current settings"""
|
"""Standalone password validation function that returns a list of error messages"""
|
||||||
password = field.data
|
|
||||||
settings = Settings.query.first()
|
settings = Settings.query.first()
|
||||||
if not settings:
|
if not settings:
|
||||||
return # No settings found, allow any password
|
return [] # No settings found, allow any password
|
||||||
|
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -34,6 +33,13 @@ def validate_password_strength(form, field):
|
|||||||
if not any(char in safe_chars for char in password):
|
if not any(char in safe_chars for char in password):
|
||||||
errors.append(f'Password must contain at least one special character from: {safe_chars}')
|
errors.append(f'Password must contain at least one special character from: {safe_chars}')
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def validate_password_strength(form, field):
|
||||||
|
"""WTForms validator that uses the standalone validation function"""
|
||||||
|
password = field.data
|
||||||
|
errors = validate_password_requirements(password)
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
raise ValidationError(' '.join(errors))
|
raise ValidationError(' '.join(errors))
|
||||||
|
|
||||||
|
|||||||
+175
-112
@@ -17,6 +17,15 @@ import zipfile
|
|||||||
import tempfile
|
import tempfile
|
||||||
import configparser
|
import configparser
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import pyotp
|
||||||
|
import qrcode
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
import configparser
|
||||||
|
import zipfile
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
from utils.toolbox import get_current_timestamp
|
from utils.toolbox import get_current_timestamp
|
||||||
|
|
||||||
print(get_current_timestamp())
|
print(get_current_timestamp())
|
||||||
@@ -512,18 +521,17 @@ def manage_users():
|
|||||||
flash('A user with that username or email already exists.', 'danger')
|
flash('A user with that username or email already exists.', 'danger')
|
||||||
else:
|
else:
|
||||||
# Validate password strength
|
# Validate password strength
|
||||||
try:
|
from .forms import validate_password_requirements
|
||||||
from .forms import validate_password_strength
|
password_errors = validate_password_requirements(password)
|
||||||
validate_password_strength(password)
|
if password_errors:
|
||||||
except ValidationError as e:
|
logger.warning(f'Password validation failed for user creation: {"; ".join(password_errors)}', extra={
|
||||||
logger.warning(f'Password validation failed for user creation: {str(e)}', extra={
|
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'email': email,
|
||||||
'role': role,
|
'role': role,
|
||||||
'remote_addr': request.remote_addr,
|
'remote_addr': request.remote_addr,
|
||||||
'current_user_id': current_user.id
|
'current_user_id': current_user.id
|
||||||
})
|
})
|
||||||
flash(f'Password validation failed: {str(e)}', 'danger')
|
flash(f'Password validation failed: {"; ".join(password_errors)}', 'danger')
|
||||||
return redirect(url_for('auth.manage_users'))
|
return redirect(url_for('auth.manage_users'))
|
||||||
|
|
||||||
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
hashed_password = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||||
@@ -947,17 +955,16 @@ def reset_user_password(user_id):
|
|||||||
new_password = request.form.get('new_password')
|
new_password = request.form.get('new_password')
|
||||||
if new_password:
|
if new_password:
|
||||||
# Validate password strength
|
# Validate password strength
|
||||||
try:
|
from .forms import validate_password_requirements
|
||||||
from .forms import validate_password_strength
|
password_errors = validate_password_requirements(new_password)
|
||||||
validate_password_strength(new_password)
|
if password_errors:
|
||||||
except ValidationError as e:
|
logger.warning(f'Password validation failed for password reset: {"; ".join(password_errors)}', extra={
|
||||||
logger.warning(f'Password validation failed for password reset: {str(e)}', extra={
|
|
||||||
'target_user_id': user_id,
|
'target_user_id': user_id,
|
||||||
'target_username': user.username,
|
'target_username': user.username,
|
||||||
'current_user_id': current_user.id,
|
'current_user_id': current_user.id,
|
||||||
'remote_addr': request.remote_addr
|
'remote_addr': request.remote_addr
|
||||||
})
|
})
|
||||||
flash(f'Password validation failed: {str(e)}', 'danger')
|
flash(f'Password validation failed: {"; ".join(password_errors)}', 'danger')
|
||||||
return redirect(url_for('auth.manage_users'))
|
return redirect(url_for('auth.manage_users'))
|
||||||
|
|
||||||
user.password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
user.password = bcrypt.generate_password_hash(new_password).decode('utf-8')
|
||||||
@@ -1285,11 +1292,10 @@ def create_company_user(company_id):
|
|||||||
return redirect(url_for('auth.create_company_user', company_id=company_id))
|
return redirect(url_for('auth.create_company_user', company_id=company_id))
|
||||||
|
|
||||||
# Validate password strength
|
# Validate password strength
|
||||||
try:
|
from .forms import validate_password_requirements
|
||||||
from .forms import validate_password_strength
|
password_errors = validate_password_requirements(password)
|
||||||
validate_password_strength(password)
|
if password_errors:
|
||||||
except ValidationError as e:
|
logger.warning(f'Password validation failed for company user creation: {"; ".join(password_errors)}', extra={
|
||||||
logger.warning(f'Password validation failed for company user creation: {str(e)}', extra={
|
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'email': email,
|
||||||
'company_id': company_id,
|
'company_id': company_id,
|
||||||
@@ -1297,7 +1303,7 @@ def create_company_user(company_id):
|
|||||||
'current_user_id': current_user.id,
|
'current_user_id': current_user.id,
|
||||||
'remote_addr': request.remote_addr
|
'remote_addr': request.remote_addr
|
||||||
})
|
})
|
||||||
flash(f'Password validation failed: {str(e)}', 'danger')
|
flash(f'Password validation failed: {"; ".join(password_errors)}', 'danger')
|
||||||
return redirect(url_for('auth.create_company_user', company_id=company_id))
|
return redirect(url_for('auth.create_company_user', company_id=company_id))
|
||||||
|
|
||||||
# Create new user
|
# Create new user
|
||||||
@@ -1380,62 +1386,82 @@ def delete_company_api_key(company_id, key_id):
|
|||||||
@auth.route('/company/<int:company_id>/download_agent', methods=['GET', 'POST'])
|
@auth.route('/company/<int:company_id>/download_agent', methods=['GET', 'POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def download_agent(company_id):
|
def download_agent(company_id):
|
||||||
# Check if user has access to this company
|
try:
|
||||||
user_company = UserCompany.query.filter_by(user_id=current_user.id, company_id=company_id).first()
|
# Check if user has access to this company
|
||||||
if not user_company and current_user.role != 'Admin' and current_user.role != 'GlobalAdmin':
|
user_company = UserCompany.query.filter_by(user_id=current_user.id, company_id=company_id).first()
|
||||||
abort(403)
|
if not user_company and current_user.role != 'Admin' and current_user.role != 'GlobalAdmin':
|
||||||
|
abort(403)
|
||||||
company = Company.query.get_or_404(company_id)
|
|
||||||
api_keys = ApiKey.query.filter_by(company_id=company_id).all()
|
|
||||||
|
|
||||||
if request.method == 'POST':
|
|
||||||
api_key_id = request.form.get('api_key')
|
|
||||||
server_url = request.form.get('server_url')
|
|
||||||
install_dir = request.form.get('install_dir')
|
|
||||||
|
|
||||||
# Get the selected API key
|
company = Company.query.get_or_404(company_id)
|
||||||
selected_api_key = ApiKey.query.get(api_key_id)
|
api_keys = ApiKey.query.filter_by(company_id=company_id).all()
|
||||||
if not selected_api_key or selected_api_key.company_id != company_id:
|
|
||||||
flash('Invalid API key selected', 'danger')
|
|
||||||
return redirect(url_for('auth.download_agent', company_id=company_id))
|
|
||||||
|
|
||||||
# Create a ZIP file with pre-configured agent
|
# Get timezone from the application configuration
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
from flask import current_app
|
||||||
# Create config.ini file
|
app_timezone = current_app.config.get('TIMEZONE', 'UTC')
|
||||||
from flask import current_app
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
api_key_id = request.form.get('api_key')
|
||||||
|
server_url = request.form.get('server_url')
|
||||||
|
|
||||||
# Get timezone from the application configuration
|
# Get all form values for configuration
|
||||||
app_timezone = current_app.config.get('TIMEZONE', 'UTC')
|
debug_logs = 'debug_logs' in request.form
|
||||||
|
install_dir = request.form.get('install_dir', '').strip()
|
||||||
|
if not install_dir: # If empty or whitespace only, use default
|
||||||
|
install_dir = r"C:\ProgramData\UserSessionMon"
|
||||||
|
health_check_interval = request.form.get('health_check_interval', '30')
|
||||||
|
obtain_public_ip = 'obtain_public_ip' in request.form
|
||||||
|
public_ip_http_urls = request.form.get('public_ip_http_urls', 'https://ifconfig.me/ip,https://ipv4.icanhazip.com')
|
||||||
|
|
||||||
config = configparser.ConfigParser()
|
# Logging settings
|
||||||
config['API'] = {
|
session_log_rotation_size_mb = request.form.get('session_log_rotation_size_mb', '5')
|
||||||
'api_key': selected_api_key.key,
|
error_log_rotation_size_mb = request.form.get('error_log_rotation_size_mb', '5')
|
||||||
'server_url': server_url,
|
event_log_rotation_size_mb = request.form.get('event_log_rotation_size_mb', '5')
|
||||||
'debug_logs': 'false',
|
|
||||||
'timezone': app_timezone,
|
|
||||||
'install_dir': install_dir if install_dir else r"C:\ProgramData\UserSessionMon"
|
|
||||||
}
|
|
||||||
# Settings for Log retention for agent - it is in MB ( max 20 MB, 0 is No log)
|
|
||||||
config['Logging'] = {
|
|
||||||
'session_log_rotation_size_mb': 5,
|
|
||||||
'error_log_rotation_size_mb': 5,
|
|
||||||
'event_log_rotation_size_mb': 5
|
|
||||||
}
|
|
||||||
|
|
||||||
config_path = os.path.join(tmp_dir, 'config.ini')
|
# Get the selected API key
|
||||||
with open(config_path, 'w') as f:
|
selected_api_key = ApiKey.query.get(api_key_id)
|
||||||
config.write(f)
|
if not selected_api_key or selected_api_key.company_id != company_id:
|
||||||
|
flash('Invalid API key selected', 'danger')
|
||||||
|
return redirect(url_for('auth.download_agent', company_id=company_id))
|
||||||
|
|
||||||
|
# Create a ZIP file with pre-configured agent
|
||||||
|
# Create temporary directory and files
|
||||||
|
tmp_dir = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
# Create config.ini file
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config['API'] = {
|
||||||
|
'api_key': selected_api_key.key,
|
||||||
|
'server_url': server_url,
|
||||||
|
'debug_logs': str(debug_logs).lower(),
|
||||||
|
'timezone': app_timezone,
|
||||||
|
'install_dir': install_dir,
|
||||||
|
'health_check_interval': health_check_interval,
|
||||||
|
'health_check_path': '/api/health',
|
||||||
|
'obtain_public_ip': str(obtain_public_ip).lower(),
|
||||||
|
'public_ip_http_urls': public_ip_http_urls
|
||||||
|
}
|
||||||
|
|
||||||
# Path to the pre-compiled agent executable
|
# Settings for Log retention for agent - it is in MB ( max 20 MB, 0 is No log)
|
||||||
agent_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
config['Logging'] = {
|
||||||
'windows_agent', 'winagentUSM.exe')
|
'session_log_rotation_size_mb': session_log_rotation_size_mb,
|
||||||
|
'error_log_rotation_size_mb': error_log_rotation_size_mb,
|
||||||
install_dir_display = install_dir if install_dir else r"C:\ProgramData\UserSessionMon"
|
'event_log_rotation_size_mb': event_log_rotation_size_mb
|
||||||
# Create installation batch script
|
}
|
||||||
install_script_path = os.path.join(tmp_dir, 'install_service.bat')
|
|
||||||
with open(install_script_path, 'w') as f:
|
config_path = os.path.join(tmp_dir, 'config.ini')
|
||||||
# Get the current directory for the config path
|
with open(config_path, 'w') as f:
|
||||||
f.write(f"""@echo off
|
config.write(f)
|
||||||
|
|
||||||
|
# Path to the pre-compiled agent executable
|
||||||
|
agent_path = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
||||||
|
'windows_agent', 'winagentUSM.exe')
|
||||||
|
|
||||||
|
install_dir_display = install_dir if install_dir else r"C:\ProgramData\UserSessionMon"
|
||||||
|
# Create installation batch script
|
||||||
|
install_script_path = os.path.join(tmp_dir, 'install_service.bat')
|
||||||
|
with open(install_script_path, 'w') as f:
|
||||||
|
# Get the current directory for the config path
|
||||||
|
f.write(f"""@echo off
|
||||||
REM User Session Monitor Agent Installation Script
|
REM User Session Monitor Agent Installation Script
|
||||||
REM This script must be run as Administrator
|
REM This script must be run as Administrator
|
||||||
|
|
||||||
@@ -1485,11 +1511,11 @@ echo To uninstall the app run uninstaller script:
|
|||||||
echo {install_dir_display}\\uninstall.bat
|
echo {install_dir_display}\\uninstall.bat
|
||||||
pause
|
pause
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Create README file with instructions
|
# Create README file with instructions
|
||||||
readme_path = os.path.join(tmp_dir, 'README.txt')
|
readme_path = os.path.join(tmp_dir, 'README.txt')
|
||||||
with open(readme_path, 'w') as f:
|
with open(readme_path, 'w') as f:
|
||||||
f.write(f"""USER SESSION MONITOR AGENT INSTALLATION INSTRUCTIONS
|
f.write(f"""USER SESSION MONITOR AGENT INSTALLATION INSTRUCTIONS
|
||||||
|
|
||||||
AUTOMATIC INSTALLATION (RECOMMENDED):
|
AUTOMATIC INSTALLATION (RECOMMENDED):
|
||||||
1. Extract the contents of this ZIP file to a folder on your Windows computer.
|
1. Extract the contents of this ZIP file to a folder on your Windows computer.
|
||||||
@@ -1512,49 +1538,86 @@ Configuration:
|
|||||||
- Config file will be created at: {install_dir_display}\\config.ini
|
- Config file will be created at: {install_dir_display}\\config.ini
|
||||||
|
|
||||||
Service Management:
|
Service Management:
|
||||||
- Start service: sc start "User Session Monitor"
|
- Start service: sc start "UserSessionMonService"
|
||||||
- Stop service: sc stop "User Session Monitor"
|
- Stop service: sc stop "UserSessionMonService"
|
||||||
- Check status: sc query "User Session Monitor"
|
- Check status: sc query "UserSessionMonService"
|
||||||
- Uninstall service: winagentUSM.exe -service uninstall
|
- Uninstall service: winagentUSM.exe -service uninstall
|
||||||
|
|
||||||
If you need to change settings later, edit the config file or use the command line:
|
Configuration Settings:
|
||||||
- winagentUSM.exe --api-key <key>
|
- Debug Logs: {str(debug_logs).lower()}
|
||||||
- winagentUSM.exe --url <url>
|
- Health Check Interval: {health_check_interval} seconds
|
||||||
- winagentUSM.exe --debug true|false
|
- Health Check Path: /api/health
|
||||||
- winagentUSM.exe --timezone <timezone>
|
- Public IP Detection: {str(obtain_public_ip).lower()}
|
||||||
|
- Session Log Size: {session_log_rotation_size_mb} MB
|
||||||
|
- Error Log Size: {error_log_rotation_size_mb} MB
|
||||||
|
- Event Log Size: {event_log_rotation_size_mb} MB
|
||||||
|
|
||||||
|
If you need to change settings later, edit the config file at:
|
||||||
|
{install_dir_display}\\config.ini
|
||||||
""")
|
""")
|
||||||
|
|
||||||
# Create the ZIP file
|
|
||||||
zip_path = os.path.join(tmp_dir, f'{company.name.replace(" ", "_")}_agent.zip')
|
|
||||||
with zipfile.ZipFile(zip_path, 'w') as zip_file:
|
|
||||||
# If agent executable exists, add it
|
|
||||||
if os.path.exists(agent_path):
|
|
||||||
zip_file.write(agent_path, arcname='winagentUSM.exe')
|
|
||||||
else:
|
|
||||||
flash('Pre-compiled agent not found. Please contact administrator.', 'danger')
|
|
||||||
return redirect(url_for('auth.download_agent', company_id=company_id))
|
|
||||||
|
|
||||||
zip_file.write(config_path, arcname='config.ini')
|
# Create the ZIP file
|
||||||
zip_file.write(readme_path, arcname='README.txt')
|
zip_path = os.path.join(tmp_dir, f'{company.name.replace(" ", "_")}_agent.zip')
|
||||||
zip_file.write(install_script_path, arcname='install_service.bat')
|
with zipfile.ZipFile(zip_path, 'w') as zip_file:
|
||||||
|
# If agent executable exists, add it
|
||||||
# Send the ZIP file to the user
|
if os.path.exists(agent_path):
|
||||||
return send_file(
|
zip_file.write(agent_path, arcname='winagentUSM.exe')
|
||||||
zip_path,
|
else:
|
||||||
as_attachment=True,
|
flash('Pre-compiled agent not found. Please contact administrator.', 'danger')
|
||||||
download_name=f'{company.name.replace(" ", "_")}_agent.zip',
|
return redirect(url_for('auth.download_agent', company_id=company_id))
|
||||||
mimetype='application/zip'
|
|
||||||
)
|
zip_file.write(config_path, arcname='config.ini')
|
||||||
|
zip_file.write(readme_path, arcname='README.txt')
|
||||||
# Default server URL is the current request URL's base
|
zip_file.write(install_script_path, arcname='install_service.bat')
|
||||||
default_url = request.url_root.rstrip('/')
|
|
||||||
|
# Read the ZIP file contents into memory
|
||||||
return render_template(
|
with open(zip_path, 'rb') as f:
|
||||||
'auth/download_agent.html',
|
zip_data = f.read()
|
||||||
company=company,
|
|
||||||
api_keys=api_keys,
|
# Create a BytesIO object to serve the file
|
||||||
default_url=default_url
|
from io import BytesIO
|
||||||
)
|
zip_buffer = BytesIO(zip_data)
|
||||||
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
|
# Clean up temporary directory now that we have the data in memory
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
shutil.rmtree(tmp_dir)
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
# Log cleanup error but don't fail the download
|
||||||
|
logger.warning(f"Failed to cleanup temporary directory {tmp_dir}: {str(cleanup_error)}")
|
||||||
|
|
||||||
|
# Send the ZIP file to the user from memory
|
||||||
|
return send_file(
|
||||||
|
zip_buffer,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=f'{company.name.replace(" ", "_")}_agent.zip',
|
||||||
|
mimetype='application/zip'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up temporary directory if an error occurs
|
||||||
|
import shutil
|
||||||
|
try:
|
||||||
|
shutil.rmtree(tmp_dir)
|
||||||
|
except Exception:
|
||||||
|
pass # Ignore cleanup errors during exception handling
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# Default server URL is the current request URL's base
|
||||||
|
default_url = f"https://{request.host}"
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'auth/download_agent.html',
|
||||||
|
company=company,
|
||||||
|
api_keys=api_keys,
|
||||||
|
default_url=default_url,
|
||||||
|
app_timezone=app_timezone
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error in download_agent for company {company_id}: {str(e)}", exc_info=True)
|
||||||
|
flash('An error occurred while preparing the agent download. Please try again.', 'danger')
|
||||||
|
return redirect(url_for('auth.company_api_keys', company_id=company_id))
|
||||||
|
|
||||||
# User-Company Management Routes for manage_users page
|
# User-Company Management Routes for manage_users page
|
||||||
@auth.route('/admin/user/<int:user_id>/companies/add', methods=['POST'])
|
@auth.route('/admin/user/<int:user_id>/companies/add', methods=['POST'])
|
||||||
|
|||||||
+91
-142
@@ -1,164 +1,113 @@
|
|||||||
|
; Configuration file for User Monitor Application
|
||||||
|
; This file is auto-managed - existing values are preserved
|
||||||
|
; Generated/Updated by ConfigManager
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
SECRET_KEY = your_secret_key
|
; Application configuration
|
||||||
APP_DEBUG = true
|
; SECRET_KEY: Change this to a random secret key in production
|
||||||
TIMEZONE = Europe/London
|
; APP_DEBUG: Set to false in production
|
||||||
|
; TIMEZONE: Your local timezone for log display
|
||||||
|
secret_key = your_secret_key_change_this_in_production
|
||||||
|
app_debug = false
|
||||||
|
timezone = Europe/London
|
||||||
|
|
||||||
[server]
|
[server]
|
||||||
HOST = 0.0.0.0
|
|
||||||
PORT = 8000
|
|
||||||
SSL_CERTFILE = instance/certs/cert.pem
|
|
||||||
SSL_KEYFILE = instance/certs/key.pem
|
|
||||||
; Server configuration
|
; Server configuration
|
||||||
; DEVELOPMENT_MODE: When true, enables development features (default: false)
|
; HOST: IP address to bind to (0.0.0.0 for all interfaces)
|
||||||
DEVELOPMENT_MODE = true
|
; PORT: Port number to listen on
|
||||||
; Watch for file changes and reload automatically (development only, default: false)
|
; SSL_CERTFILE/SSL_KEYFILE: SSL certificate paths (for reverse proxy setups)
|
||||||
WATCH_FILES = true
|
; WORKERS: Number of threads (Waitress) or processes (Gunicorn)
|
||||||
; Number of worker processes for Uvicorn (default: 1)
|
; DEVELOPMENT_MODE: Enable development features (false in production)
|
||||||
; For production, set to 2-4 workers for most servers
|
host = 0.0.0.0
|
||||||
; "auto" uses CPU count but may be excessive for some systems
|
port = 8000
|
||||||
WORKERS = 2
|
ssl_certfile = instance/certs/cert.pem
|
||||||
; Maximum number of seconds a worker can live (helps with memory leaks)
|
ssl_keyfile = instance/certs/key.pem
|
||||||
WORKER_LIFETIME = 86400
|
development_mode = false
|
||||||
; Determines if server should stop gracefully or immediately on receiving SIGINT/SIGTERM
|
watch_files = false
|
||||||
GRACEFUL_SHUTDOWN = true
|
workers = 4
|
||||||
; Timeout in seconds for graceful shutdown (default: 30)
|
worker_lifetime = 86400
|
||||||
SHUTDOWN_TIMEOUT = 30
|
graceful_shutdown = true
|
||||||
|
shutdown_timeout = 30
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
; Current SQLite configuration
|
; Database configuration
|
||||||
SQLALCHEMY_DATABASE_URI = sqlite:///database.db
|
; SQLALCHEMY_DATABASE_URI: Database connection string
|
||||||
SQLALCHEMY_TRACK_MODIFICATIONS = false
|
; For SQLite (default): sqlite:///database.db
|
||||||
|
; For PostgreSQL: postgresql://user:pass@localhost:5432/dbname
|
||||||
; ====== DATABASE CONNECTION EXAMPLES ======
|
; For MySQL: mysql+pymysql://user:pass@localhost:3306/dbname
|
||||||
; Uncomment one of these examples and comment out the SQLite connection above to switch databases
|
; For MSSQL: mssql+pyodbc://user:pass@server/db?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
|
sqlalchemy_database_uri = sqlite:///database.db
|
||||||
; === PostgreSQL Example ===
|
sqlalchemy_track_modifications = false
|
||||||
; Setup:
|
|
||||||
; 1. Install PostgreSQL server
|
|
||||||
; 2. Create database and user with proper permissions
|
|
||||||
; 3. Install Python driver: pip install psycopg2-binary
|
|
||||||
;
|
|
||||||
; SQLALCHEMY_DATABASE_URI = postgresql://username:password@localhost:5432/database_name
|
|
||||||
; For SSL connection:
|
|
||||||
; SQLALCHEMY_DATABASE_URI = postgresql://username:password@localhost:5432/database_name?sslmode=require
|
|
||||||
|
|
||||||
; === MySQL/MariaDB Example ===
|
|
||||||
; Setup:
|
|
||||||
; 1. Install MySQL/MariaDB server
|
|
||||||
; 2. Create database and user with proper permissions
|
|
||||||
; 3. Install Python driver: pip install pymysql
|
|
||||||
;
|
|
||||||
; SQLALCHEMY_DATABASE_URI = mysql+pymysql://username:password@localhost:3306/database_name
|
|
||||||
; For SSL connection:
|
|
||||||
; SQLALCHEMY_DATABASE_URI = mysql+pymysql://username:password@localhost:3306/database_name?ssl_ca=/path/to/ca.pem
|
|
||||||
|
|
||||||
; === MSSQL Server Example ===
|
|
||||||
; Setup:
|
|
||||||
; 1. Install MSSQL Server
|
|
||||||
; 2. Create database and user
|
|
||||||
; 3. Install Python driver: pip install pyodbc
|
|
||||||
; 4. Install ODBC Driver for SQL Server:
|
|
||||||
; - On Ubuntu/Debian:
|
|
||||||
; sudo apt-get install -y unixodbc-dev
|
|
||||||
; sudo curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
|
||||||
; sudo curl https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
|
||||||
; sudo apt-get update
|
|
||||||
; sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 # Driver 18 (latest)
|
|
||||||
; # Or for older driver: sudo ACCEPT_EULA=Y apt-get install -y msodbcsql17
|
|
||||||
; - On RHEL/CentOS:
|
|
||||||
; sudo curl https://packages.microsoft.com/config/rhel/8/prod.repo > /etc/yum.repos.d/mssql-release.repo
|
|
||||||
; sudo ACCEPT_EULA=Y dnf install -y msodbcsql18 # Driver 18 (latest)
|
|
||||||
; # Or for older driver: sudo ACCEPT_EULA=Y dnf install -y msodbcsql17
|
|
||||||
; - On Windows:
|
|
||||||
; Download and install from https://go.microsoft.com/fwlink/?linkid=2249006 # Driver 18
|
|
||||||
; # Or for older driver: https://go.microsoft.com/fwlink/?linkid=2187217 # Driver 17
|
|
||||||
;
|
|
||||||
; # Using ODBC Driver 18 (recommended)
|
|
||||||
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name/database_name?driver=ODBC+Driver+18+for+SQL+Server
|
|
||||||
; # Using ODBC Driver 17
|
|
||||||
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name/database_name?driver=ODBC+Driver+17+for+SQL+Server
|
|
||||||
; # For named instance:
|
|
||||||
; SQLALCHEMY_DATABASE_URI = mssql+pyodbc://username:password@server_name\\instance_name/database_name?driver=ODBC+Driver+18+for+SQL+Server
|
|
||||||
|
|
||||||
[session]
|
[session]
|
||||||
SESSION_COOKIE_SECURE = true
|
; Session and cookie configuration
|
||||||
SESSION_COOKIE_HTTPONLY = true
|
; SESSION_COOKIE_SECURE: Only send cookies over HTTPS
|
||||||
SESSION_COOKIE_SAMESITE = Lax
|
; REMEMBER_COOKIE_DURATION: Remember me duration in seconds
|
||||||
REMEMBER_COOKIE_SECURE = true
|
session_cookie_secure = true
|
||||||
REMEMBER_COOKIE_HTTPONLY = true
|
session_cookie_httponly = true
|
||||||
REMEMBER_COOKIE_DURATION = 7200
|
session_cookie_samesite = Lax
|
||||||
PERMANENT_SESSION_LIFETIME = 7200
|
remember_cookie_secure = true
|
||||||
|
remember_cookie_httponly = true
|
||||||
|
remember_cookie_duration = 7200
|
||||||
|
permanent_session_lifetime = 7200
|
||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
STATIC_MAX_AGE = 86400
|
; Cache and compression settings
|
||||||
IMAGE_MAX_AGE = 604800
|
; MAX_AGE values are in seconds
|
||||||
JS_CSS_MAX_AGE = 43200
|
; COMPRESSION_LEVEL: 1-9 (higher = better compression, more CPU)
|
||||||
ENABLE_COMPRESSION = true
|
static_max_age = 86400
|
||||||
COMPRESSION_LEVEL = 6
|
image_max_age = 604800
|
||||||
COMPRESSION_MIN_SIZE = 500
|
js_css_max_age = 43200
|
||||||
|
enable_compression = true
|
||||||
|
compression_level = 6
|
||||||
|
compression_min_size = 500
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
; Security headers configuration
|
; Security headers configuration
|
||||||
CONTENT_SECURITY_POLICY = default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'
|
; CONTENT_SECURITY_POLICY: Controls allowed content sources
|
||||||
ENABLE_HSTS = true
|
; ENABLE_HSTS: HTTP Strict Transport Security (HTTPS only)
|
||||||
HSTS_MAX_AGE = 31536000
|
; HSTS_MAX_AGE: HSTS duration in seconds
|
||||||
ENABLE_SECURITY_HEADERS = true
|
content_security_policy = default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.datatables.net https://code.jquery.com; style-src 'self' 'unsafe-inline' https://cdn.datatables.net; img-src 'self' data:; font-src 'self' https://cdn.datatables.net; connect-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'
|
||||||
|
enable_hsts = true
|
||||||
|
hsts_max_age = 31536000
|
||||||
|
enable_security_headers = true
|
||||||
|
|
||||||
[rate_limiting]
|
[rate_limiting]
|
||||||
; Rate limiting configuration
|
; Rate limiting configuration
|
||||||
ENABLE_RATE_LIMITING = true
|
; REDIS_URL: Redis connection for distributed rate limiting (leave empty for in-memory)
|
||||||
; Redis connection for rate limiting (leave empty to use in-memory storage)
|
; LOGIN_LIMIT: Max login attempts per LOGIN_PERIOD seconds
|
||||||
; REDIS_URL = redis://localhost:6379/0
|
; API_LIMIT: Max API requests per API_PERIOD seconds
|
||||||
REDIS_URL =
|
; Leave REDIS_URL empty to use in-memory rate limiting
|
||||||
; Login endpoint limits
|
enable_rate_limiting = true
|
||||||
LOGIN_LIMIT = 10
|
redis_url =
|
||||||
LOGIN_PERIOD = 60
|
login_limit = 10
|
||||||
; Registration endpoint limits
|
login_period = 60
|
||||||
REGISTER_LIMIT = 5
|
register_limit = 5
|
||||||
REGISTER_PERIOD = 300
|
register_period = 300
|
||||||
; API endpoint limits
|
api_limit = 60
|
||||||
API_LIMIT = 60
|
api_period = 60
|
||||||
API_PERIOD = 60
|
|
||||||
|
|
||||||
[proxy]
|
[proxy]
|
||||||
; Reverse proxy configuration for Traefik
|
; Reverse proxy configuration
|
||||||
; Number of proxies between the client and your app (default: 1 for single proxy like Traefik)
|
; PROXY_COUNT: Number of proxies between client and app
|
||||||
PROXY_COUNT = 1
|
; TRUSTED_PROXIES: Comma-separated proxy IPs (empty = trust all)
|
||||||
; Whether to trust X-Forwarded-For header (required for Traefik)
|
; For Docker: 172.16.0.0/12,10.0.0.0/8,192.168.0.0/16
|
||||||
TRUST_X_FORWARDED_FOR = true
|
proxy_count = 1
|
||||||
; Whether to trust X-Forwarded-Proto header (for HTTPS detection)
|
trust_x_forwarded_for = true
|
||||||
TRUST_X_FORWARDED_PROTO = true
|
trust_x_forwarded_proto = true
|
||||||
; Whether to trust X-Forwarded-Host header
|
trust_x_forwarded_host = true
|
||||||
TRUST_X_FORWARDED_HOST = true
|
trust_x_forwarded_port = true
|
||||||
; Whether to trust X-Forwarded-Port header
|
trust_x_forwarded_prefix = false
|
||||||
TRUST_X_FORWARDED_PORT = true
|
trusted_proxies =
|
||||||
; Whether to trust X-Forwarded-Prefix header
|
|
||||||
TRUST_X_FORWARDED_PREFIX = false
|
|
||||||
; Trusted proxy IPs (leave empty to trust all, comma-separated for multiple)
|
|
||||||
; For production with Traefik, specify your Traefik container IP or Docker network CIDR
|
|
||||||
; Examples:
|
|
||||||
; TRUSTED_PROXIES = 172.16.0.0/12,10.0.0.0/8,192.168.0.0/16 # Docker default networks
|
|
||||||
; TRUSTED_PROXIES = 172.20.0.2,172.20.0.3 # Specific Traefik IPs
|
|
||||||
; TRUSTED_PROXIES = 172.18.0.0/16 # Custom Docker network
|
|
||||||
; For development/testing, leave empty to trust all proxies:
|
|
||||||
TRUSTED_PROXIES =
|
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
; Database logging configuration
|
; Database logging configuration
|
||||||
; Enable/disable database logging entirely
|
; DB_LOGGING_ENABLED: Enable/disable database logging
|
||||||
DB_LOGGING_ENABLED = true
|
; DB_LOGGING_FILTERED_LOGGERS: Comma-separated logger names to exclude
|
||||||
|
; DB_LOGGING_FILTERED_PATTERNS: Comma-separated patterns to exclude
|
||||||
; Loggers to exclude from database logging (comma-separated)
|
db_logging_enabled = true
|
||||||
; These loggers often create feedback loops or excessive noise
|
db_logging_filtered_loggers = watchfiles.main,watchfiles.watcher,watchdog,__mp_main__,__main__,app,waitress.queue
|
||||||
DB_LOGGING_FILTERED_LOGGERS = watchfiles.main,watchfiles.watcher,watchdog,uvicorn.access,__mp_main__,__main__,app
|
db_logging_filtered_patterns = database.db,instance/,file changed,reloading
|
||||||
|
filter_file_watcher_logs = true
|
||||||
; Message patterns to exclude from database logging (comma-separated)
|
db_logging_dedupe_interval = 1
|
||||||
; Messages containing these patterns will not be logged to database
|
|
||||||
DB_LOGGING_FILTERED_PATTERNS = database.db,instance/,file changed,reloading
|
|
||||||
|
|
||||||
; Enable filtering of file watcher logs (prevents feedback loops in debug mode)
|
|
||||||
FILTER_FILE_WATCHER_LOGS = true
|
|
||||||
|
|
||||||
; Minimum time between identical log entries (seconds) to prevent spam
|
|
||||||
DB_LOGGING_DEDUPE_INTERVAL = 1
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Replace ip_address with local_ip and add public_ip
|
||||||
|
|
||||||
|
Revision ID: 351386323a79
|
||||||
|
Revises: 4b74b8a01154
|
||||||
|
Create Date: 2025-05-28 03:54:33.409642
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '351386323a79'
|
||||||
|
down_revision = '4b74b8a01154'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('api_logs', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('local_ip', sa.String(length=45), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('public_ip', sa.String(length=45), nullable=True))
|
||||||
|
batch_op.drop_column('ip_address')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('api_logs', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('ip_address', sa.VARCHAR(length=15), nullable=False))
|
||||||
|
batch_op.drop_column('public_ip')
|
||||||
|
batch_op.drop_column('local_ip')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
+2
-12
@@ -1,5 +1,4 @@
|
|||||||
flask
|
flask
|
||||||
asgiref
|
|
||||||
flask_sqlalchemy
|
flask_sqlalchemy
|
||||||
flask_bcrypt
|
flask_bcrypt
|
||||||
flask_jwt_extended
|
flask_jwt_extended
|
||||||
@@ -13,16 +12,7 @@ flask-compress
|
|||||||
pillow
|
pillow
|
||||||
|
|
||||||
# serving the application
|
# serving the application
|
||||||
uvicorn[standard]
|
waitress # Production WSGI server for Windows (better than Gunicorn for Windows)
|
||||||
|
gunicorn # Production WSGI server (Linux/Unix only - won't work on Windows)
|
||||||
|
|
||||||
# not needed?
|
|
||||||
#flask_login
|
|
||||||
#flask_bootstrap
|
|
||||||
#httpx[http2]
|
|
||||||
|
|
||||||
# Production enhancements
|
|
||||||
redis # Optional: for distributed rate limiting
|
|
||||||
#psutil # System monitoring for health checks
|
#psutil # System monitoring for health checks
|
||||||
#gunicorn # Production WSGI server
|
|
||||||
#click # CLI tools (for database backups)
|
#click # CLI tools (for database backups)
|
||||||
|
|||||||
@@ -23,42 +23,143 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="POST" action="{{ url_for('auth.download_agent', company_id=company.id) }}">
|
<form method="POST" action="{{ url_for('auth.download_agent', company_id=company.id) }}">
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
|
||||||
<label for="api_key" class="form-label">Select API Key</label>
|
<div class="table-responsive">
|
||||||
<select class="form-select" id="api_key" name="api_key" required>
|
<table class="table table-bordered">
|
||||||
<option value="">Select API Key</option>
|
<thead class="table-light">
|
||||||
{% for api_key in api_keys %}
|
<tr>
|
||||||
<option value="{{ api_key.id }}">
|
<th style="width: 25%;">Setting</th>
|
||||||
{{ api_key.description }} - {{ api_key.key[:8] }}...{{ api_key.key[-8:] }}
|
<th style="width: 65%;">Value</th>
|
||||||
</option>
|
<th style="width: 10%;">Info</th>
|
||||||
{% endfor %}
|
</tr>
|
||||||
</select>
|
</thead>
|
||||||
{% if not api_keys %}
|
<tbody>
|
||||||
<div class="form-text text-warning">
|
<!-- API Settings -->
|
||||||
No API keys found. <a href="{{ url_for('auth.company_api_keys', company_id=company.id) }}">Create an API key</a> first.
|
<tr class="table-secondary">
|
||||||
</div>
|
<td colspan="3"><strong>API Settings</strong></td>
|
||||||
{% endif %}
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="api_key" class="form-label mb-0">API Key (Site)</label></td>
|
||||||
|
<td>
|
||||||
|
<select class="form-select" id="api_key" name="api_key" required>
|
||||||
|
<option value="">Select API Key</option>
|
||||||
|
{% for api_key in api_keys %}
|
||||||
|
<option value="{{ api_key.id }}" {% if loop.first %}selected{% endif %}>
|
||||||
|
{{ api_key.description }} - {{ api_key.key[:8] }}...{{ api_key.key[-8:] }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if not api_keys %}
|
||||||
|
<div class="form-text text-warning">
|
||||||
|
No API keys found. <a href="{{ url_for('auth.company_api_keys', company_id=company.id) }}">Create an API key</a> first.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="The API key used to authenticate with the server. Each site should have its own unique API key."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="server_url" class="form-label mb-0">Server URL</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="url" class="form-control" id="server_url" name="server_url" value="{{ default_url }}" required>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="The base URL where the agent will send login events and health checks. This should be the URL of this server."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="debug_logs" class="form-label mb-0">Debug Logs</label></td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="debug_logs" name="debug_logs" value="true">
|
||||||
|
<label class="form-check-label" for="debug_logs">Enable debug logging</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Enable detailed debug logging for troubleshooting. Should be disabled in production for better performance."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="install_dir" class="form-label mb-0">Installation Directory</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control" id="install_dir" name="install_dir" placeholder="C:\ProgramData\UserSessionMon">
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Directory where the agent will be installed and store its configuration and logs. Leave empty to use the default path."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="health_check_interval" class="form-label mb-0">Health Check Interval (seconds)</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control" id="health_check_interval" name="health_check_interval" value="30" min="10" max="3600">
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Interval in seconds between health check requests to the server. Recommended: 30-300 seconds."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="obtain_public_ip" class="form-label mb-0">Obtain Public IP</label></td>
|
||||||
|
<td>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="obtain_public_ip" name="obtain_public_ip" value="true" checked>
|
||||||
|
<label class="form-check-label" for="obtain_public_ip">Enable public IP detection</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Enable automatic detection of the public IP address for better location tracking and security monitoring."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="public_ip_http_urls" class="form-label mb-0">Public IP HTTP URLs</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="text" class="form-control" id="public_ip_http_urls" name="public_ip_http_urls" value="https://ifconfig.me/ip,https://ipv4.icanhazip.com" placeholder="https://ifconfig.me/ip,https://ipv4.icanhazip.com">
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Comma-separated list of HTTP URLs used to detect the public IP address. The agent will try these in order until one responds."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Logging Settings -->
|
||||||
|
<tr class="table-secondary">
|
||||||
|
<td colspan="3"><strong>Logging Settings (Max size, after witch it will be archived)</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="session_log_rotation_size_mb" class="form-label mb-0">Session Log Size (MB)</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control" id="session_log_rotation_size_mb" name="session_log_rotation_size_mb" value="5" min="0" max="100">
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Maximum size in MB for session log files before rotation. Set to 0 to disable session logging."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="error_log_rotation_size_mb" class="form-label mb-0">Error Log Size (MB)</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control" id="error_log_rotation_size_mb" name="error_log_rotation_size_mb" value="5" min="0" max="100">
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Maximum size in MB for error log files before rotation. Set to 0 to disable error logging."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><label for="event_log_rotation_size_mb" class="form-label mb-0">Event Log Size (MB)</label></td>
|
||||||
|
<td>
|
||||||
|
<input type="number" class="form-control" id="event_log_rotation_size_mb" name="event_log_rotation_size_mb" value="5" min="0" max="100">
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<i class="fas fa-info-circle text-info" data-bs-toggle="tooltip" data-bs-placement="left" title="Maximum size in MB for event log files before rotation. Set to 0 to disable event logging."></i>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="d-grid gap-2">
|
||||||
<label for="server_url" class="form-label">Server URL</label>
|
<button type="submit" class="btn btn-primary btn-lg" {% if not api_keys %}disabled{% endif %}>
|
||||||
<input type="url" class="form-control" id="server_url" name="server_url" value="{{ default_url }}" required>
|
<i class="fas fa-download"></i> Download Agent Package
|
||||||
<div class="form-text">
|
</button>
|
||||||
The URL where the agent will send login events. Usually the same URL as this website.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="install_dir" class="form-label">Installation Directory (Optional)</label>
|
|
||||||
<input type="text" class="form-control" id="install_dir" name="install_dir" placeholder="C:\ProgramData\UserSessionMon">
|
|
||||||
<div class="form-text">
|
|
||||||
Custom installation directory for the agent. Leave empty to use the default path.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary" {% if not api_keys %}disabled{% endif %}>
|
|
||||||
<i class="fas fa-download"></i> Download Agent
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,9 +172,7 @@
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Download the agent package using the form above.</li>
|
<li>Download the agent package using the form above.</li>
|
||||||
<li>Extract the ZIP file to a folder on your Windows computer.</li>
|
<li>Extract the ZIP file to a folder on your Windows computer.</li>
|
||||||
<li>Run the agent as administrator to install it:
|
<li>Right-click on "install_service.bat" and select "Run as administrator".</li>
|
||||||
<code>winagentUSM.exe --service install</code>
|
|
||||||
</li>
|
|
||||||
<li>The service will start automatically and begin monitoring login events.</li>
|
<li>The service will start automatically and begin monitoring login events.</li>
|
||||||
<li>Events will be sent to this server using the specified API key.</li>
|
<li>Events will be sent to this server using the specified API key.</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -85,4 +184,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
// Initialize Bootstrap tooltips
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||||
|
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||||
|
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='css/dataTables.bootstrap5.min.css') }}" rel="stylesheet">
|
||||||
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='css/buttons.bootstrap5.min.css') }}" rel="stylesheet">
|
||||||
<link href="{{ url_for('static', filename='css/buttons.dataTables.min.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='css/buttons.dataTables.min.css') }}" rel="stylesheet">
|
||||||
<link href="{{ url_for('static', filename='css/colVis.dataTables.min.css') }}" rel="stylesheet">
|
|
||||||
<!-- DateRangePicker CSS -->
|
<!-- DateRangePicker CSS -->
|
||||||
<link href="{{ url_for('static', filename='css/daterangepicker.css') }}" rel="stylesheet">
|
<link href="{{ url_for('static', filename='css/daterangepicker.css') }}" rel="stylesheet">
|
||||||
<!-- Custom DateRangePicker dark theme styles -->
|
<!-- Custom DateRangePicker dark theme styles -->
|
||||||
@@ -216,7 +215,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<th>Site</th>
|
<th>Site</th>
|
||||||
<th>Computer Name</th>
|
<th>Computer Name</th>
|
||||||
<th>IP Address</th>
|
<th>Local IP</th>
|
||||||
|
<th>Public IP</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -228,11 +228,12 @@
|
|||||||
<td>{{ log.event_type }}</td>
|
<td>{{ log.event_type }}</td>
|
||||||
<td>{{ log.user_name }}</td>
|
<td>{{ log.user_name }}</td>
|
||||||
{% if current_user.is_global_admin() and not selected_company_id %}
|
{% if current_user.is_global_admin() and not selected_company_id %}
|
||||||
<td>{{ log.company.name if log.company else 'N/A' }}</td>
|
<td>{{ log.company.name if log.company else '' }}</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>{{ log.api_key.description if log.api_key else 'N/A' }}</td>
|
<td>{{ log.api_key.description if log.api_key else '' }}</td>
|
||||||
<td>{{ log.computer_name }}</td>
|
<td>{{ log.computer_name }}</td>
|
||||||
<td>{{ log.ip_address }}</td>
|
<td>{{ log.local_ip or '' }}</td>
|
||||||
|
<td>{{ log.public_ip or '' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -316,7 +317,7 @@
|
|||||||
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
|
'<"row"<"col-sm-12 col-md-5"i><"col-sm-12 col-md-7"p>>',
|
||||||
columnDefs: [
|
columnDefs: [
|
||||||
{
|
{
|
||||||
targets: [5, 6], // Computer Name and IP Address columns are now at indices 5 and 6
|
targets: [5, 6, 7], // Computer Name, Local IP, and Public IP columns are now at indices 5, 6, and 7
|
||||||
visible: false, // Hide by default
|
visible: false, // Hide by default
|
||||||
searchable: true // Still allow searching in these columns
|
searchable: true // Still allow searching in these columns
|
||||||
}
|
}
|
||||||
@@ -363,15 +364,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Column names for the visibility controls
|
// Column names for the visibility controls
|
||||||
var columnNames = [
|
var columnNames = ['Timestamp', 'Event Type', 'User Name'];
|
||||||
'Timestamp',
|
{% if current_user.is_global_admin() and not selected_company_id %}
|
||||||
'Event Type',
|
columnNames.push('Company');
|
||||||
'User Name',
|
{% endif %}
|
||||||
{% if current_user.is_global_admin() and not selected_company_id %}'Company',{% endif %}
|
columnNames.push('Site', 'Computer Name', 'Local IP', 'Public IP');
|
||||||
'Site',
|
|
||||||
'Computer Name',
|
|
||||||
'IP Address'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Load saved column visibility from localStorage
|
// Load saved column visibility from localStorage
|
||||||
function loadColumnVisibility() {
|
function loadColumnVisibility() {
|
||||||
@@ -383,10 +380,10 @@
|
|||||||
console.log('Error parsing saved column visibility:', e);
|
console.log('Error parsing saved column visibility:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default visibility - hide Computer Name (5) and IP Address (6)
|
// Default visibility - hide Computer Name (5), Local IP (6), and Public IP (7)
|
||||||
var defaultVisibility = {};
|
var defaultVisibility = {};
|
||||||
columnNames.forEach(function(name, index) {
|
columnNames.forEach(function(name, index) {
|
||||||
defaultVisibility[index] = index !== 5 && index !== 6;
|
defaultVisibility[index] = index !== 5 && index !== 6 && index !== 7;
|
||||||
});
|
});
|
||||||
return defaultVisibility;
|
return defaultVisibility;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,351 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Configuration Manager - Handles config.ini creation and updates
|
||||||
|
Preserves existing values while adding missing sections/options
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import configparser
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ConfigManager:
|
||||||
|
"""Manages configuration file creation and updates"""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str = 'config.ini'):
|
||||||
|
self.config_path = config_path
|
||||||
|
self.config = configparser.ConfigParser(allow_no_value=True)
|
||||||
|
|
||||||
|
def get_default_config(self) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Define the default configuration structure and values"""
|
||||||
|
return {
|
||||||
|
'app': {
|
||||||
|
'SECRET_KEY': 'your_secret_key_change_this_in_production',
|
||||||
|
'APP_DEBUG': 'false',
|
||||||
|
'TIMEZONE': 'Europe/London',
|
||||||
|
'; Application configuration': None,
|
||||||
|
'; SECRET_KEY: Change this to a random secret key in production': None,
|
||||||
|
'; APP_DEBUG: Set to false in production': None,
|
||||||
|
'; TIMEZONE: Your local timezone for log display': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'server': {
|
||||||
|
'HOST': '0.0.0.0',
|
||||||
|
'PORT': '8000',
|
||||||
|
'SSL_CERTFILE': 'instance/certs/cert.pem',
|
||||||
|
'SSL_KEYFILE': 'instance/certs/key.pem',
|
||||||
|
'DEVELOPMENT_MODE': 'false',
|
||||||
|
'WATCH_FILES': 'false',
|
||||||
|
'WORKERS': '4',
|
||||||
|
'WORKER_LIFETIME': '86400',
|
||||||
|
'GRACEFUL_SHUTDOWN': 'true',
|
||||||
|
'SHUTDOWN_TIMEOUT': '30',
|
||||||
|
'; Server configuration': None,
|
||||||
|
'; HOST: IP address to bind to (0.0.0.0 for all interfaces)': None,
|
||||||
|
'; PORT: Port number to listen on': None,
|
||||||
|
'; SSL_CERTFILE/SSL_KEYFILE: SSL certificate paths (for reverse proxy setups)': None,
|
||||||
|
'; WORKERS: Number of threads (Waitress) or processes (Gunicorn)': None,
|
||||||
|
'; DEVELOPMENT_MODE: Enable development features (false in production)': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'database': {
|
||||||
|
'SQLALCHEMY_DATABASE_URI': 'sqlite:///database.db',
|
||||||
|
'SQLALCHEMY_TRACK_MODIFICATIONS': 'false',
|
||||||
|
'; Database configuration': None,
|
||||||
|
'; SQLALCHEMY_DATABASE_URI: Database connection string': None,
|
||||||
|
'; For SQLite (default): sqlite:///database.db': None,
|
||||||
|
'; For PostgreSQL: postgresql://user:pass@localhost:5432/dbname': None,
|
||||||
|
'; For MySQL: mysql+pymysql://user:pass@localhost:3306/dbname': None,
|
||||||
|
'; For MSSQL: mssql+pyodbc://user:pass@server/db?driver=ODBC+Driver+18+for+SQL+Server': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'session': {
|
||||||
|
'SESSION_COOKIE_SECURE': 'true',
|
||||||
|
'SESSION_COOKIE_HTTPONLY': 'true',
|
||||||
|
'SESSION_COOKIE_SAMESITE': 'Lax',
|
||||||
|
'REMEMBER_COOKIE_SECURE': 'true',
|
||||||
|
'REMEMBER_COOKIE_HTTPONLY': 'true',
|
||||||
|
'REMEMBER_COOKIE_DURATION': '7200',
|
||||||
|
'PERMANENT_SESSION_LIFETIME': '7200',
|
||||||
|
'; Session and cookie configuration': None,
|
||||||
|
'; SESSION_COOKIE_SECURE: Only send cookies over HTTPS': None,
|
||||||
|
'; REMEMBER_COOKIE_DURATION: Remember me duration in seconds': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'cache': {
|
||||||
|
'STATIC_MAX_AGE': '86400',
|
||||||
|
'IMAGE_MAX_AGE': '604800',
|
||||||
|
'JS_CSS_MAX_AGE': '43200',
|
||||||
|
'ENABLE_COMPRESSION': 'true',
|
||||||
|
'COMPRESSION_LEVEL': '6',
|
||||||
|
'COMPRESSION_MIN_SIZE': '500',
|
||||||
|
'; Cache and compression settings': None,
|
||||||
|
'; MAX_AGE values are in seconds': None,
|
||||||
|
'; COMPRESSION_LEVEL: 1-9 (higher = better compression, more CPU)': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'security': {
|
||||||
|
'CONTENT_SECURITY_POLICY': "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.datatables.net https://code.jquery.com; style-src 'self' 'unsafe-inline' https://cdn.datatables.net; img-src 'self' data:; font-src 'self' https://cdn.datatables.net; connect-src 'self'; frame-ancestors 'self'; form-action 'self'; base-uri 'self'",
|
||||||
|
'ENABLE_HSTS': 'true',
|
||||||
|
'HSTS_MAX_AGE': '31536000',
|
||||||
|
'ENABLE_SECURITY_HEADERS': 'true',
|
||||||
|
'; Security headers configuration': None,
|
||||||
|
'; CONTENT_SECURITY_POLICY: Controls allowed content sources': None,
|
||||||
|
'; ENABLE_HSTS: HTTP Strict Transport Security (HTTPS only)': None,
|
||||||
|
'; HSTS_MAX_AGE: HSTS duration in seconds': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'rate_limiting': {
|
||||||
|
'ENABLE_RATE_LIMITING': 'true',
|
||||||
|
'REDIS_URL': '',
|
||||||
|
'LOGIN_LIMIT': '10',
|
||||||
|
'LOGIN_PERIOD': '60',
|
||||||
|
'REGISTER_LIMIT': '5',
|
||||||
|
'REGISTER_PERIOD': '300',
|
||||||
|
'API_LIMIT': '60',
|
||||||
|
'API_PERIOD': '60',
|
||||||
|
'; Rate limiting configuration': None,
|
||||||
|
'; REDIS_URL: Redis connection for distributed rate limiting (leave empty for in-memory)': None,
|
||||||
|
'; LOGIN_LIMIT: Max login attempts per LOGIN_PERIOD seconds': None,
|
||||||
|
'; API_LIMIT: Max API requests per API_PERIOD seconds': None,
|
||||||
|
'; Leave REDIS_URL empty to use in-memory rate limiting': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'proxy': {
|
||||||
|
'PROXY_COUNT': '1',
|
||||||
|
'TRUST_X_FORWARDED_FOR': 'true',
|
||||||
|
'TRUST_X_FORWARDED_PROTO': 'true',
|
||||||
|
'TRUST_X_FORWARDED_HOST': 'true',
|
||||||
|
'TRUST_X_FORWARDED_PORT': 'true',
|
||||||
|
'TRUST_X_FORWARDED_PREFIX': 'false',
|
||||||
|
'TRUSTED_PROXIES': '',
|
||||||
|
'; Reverse proxy configuration': None,
|
||||||
|
'; PROXY_COUNT: Number of proxies between client and app': None,
|
||||||
|
'; TRUSTED_PROXIES: Comma-separated proxy IPs (empty = trust all)': None,
|
||||||
|
'; For Docker: 172.16.0.0/12,10.0.0.0/8,192.168.0.0/16': None,
|
||||||
|
},
|
||||||
|
|
||||||
|
'logging': {
|
||||||
|
'DB_LOGGING_ENABLED': 'true',
|
||||||
|
'DB_LOGGING_FILTERED_LOGGERS': 'watchfiles.main,watchfiles.watcher,watchdog,uvicorn.access,__mp_main__,__main__,app',
|
||||||
|
'DB_LOGGING_FILTERED_PATTERNS': 'database.db,instance/,file changed,reloading',
|
||||||
|
'FILTER_FILE_WATCHER_LOGS': 'true',
|
||||||
|
'DB_LOGGING_DEDUPE_INTERVAL': '1',
|
||||||
|
'; Database logging configuration': None,
|
||||||
|
'; DB_LOGGING_ENABLED: Enable/disable database logging': None,
|
||||||
|
'; DB_LOGGING_FILTERED_LOGGERS: Comma-separated logger names to exclude': None,
|
||||||
|
'; DB_LOGGING_FILTERED_PATTERNS: Comma-separated patterns to exclude': None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_existing_config(self) -> bool:
|
||||||
|
"""Load existing configuration file if it exists"""
|
||||||
|
if os.path.exists(self.config_path):
|
||||||
|
try:
|
||||||
|
self.config.read(self.config_path)
|
||||||
|
logger.info(f"Loaded existing configuration from {self.config_path}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading existing config file: {e}")
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def merge_config(self, preserve_existing: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Merge default configuration with existing configuration
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preserve_existing: If True, preserve existing values; if False, update to defaults
|
||||||
|
"""
|
||||||
|
default_config = self.get_default_config()
|
||||||
|
changes_made = False
|
||||||
|
|
||||||
|
for section_name, section_data in default_config.items():
|
||||||
|
# Add section if it doesn't exist
|
||||||
|
if not self.config.has_section(section_name):
|
||||||
|
self.config.add_section(section_name)
|
||||||
|
changes_made = True
|
||||||
|
logger.info(f"Added new section: [{section_name}]")
|
||||||
|
|
||||||
|
# Add missing options to existing sections
|
||||||
|
for option_key, option_value in section_data.items():
|
||||||
|
if option_key.startswith(';'):
|
||||||
|
# This is a comment - always add/update
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not self.config.has_option(section_name, option_key):
|
||||||
|
# Missing option - add it
|
||||||
|
if option_value is not None:
|
||||||
|
self.config.set(section_name, option_key, str(option_value))
|
||||||
|
changes_made = True
|
||||||
|
logger.info(f"Added missing option: [{section_name}] {option_key}")
|
||||||
|
elif not preserve_existing and option_value is not None:
|
||||||
|
# Update existing option to default (only if preserve_existing=False)
|
||||||
|
current_value = self.config.get(section_name, option_key)
|
||||||
|
if current_value != str(option_value):
|
||||||
|
logger.info(f"Would update [{section_name}] {option_key}: {current_value} -> {option_value}")
|
||||||
|
# Uncomment next line to actually update existing values
|
||||||
|
# self.config.set(section_name, option_key, str(option_value))
|
||||||
|
# changes_made = True
|
||||||
|
|
||||||
|
return changes_made
|
||||||
|
|
||||||
|
def remove_obsolete_options(self) -> bool:
|
||||||
|
"""Remove configuration options that are no longer needed"""
|
||||||
|
# Define obsolete options that should be removed
|
||||||
|
obsolete_options = {
|
||||||
|
'server': ['UVICORN_WORKERS', 'ASYNC_MODE'], # Old Uvicorn settings
|
||||||
|
'security': ['FEATURE_POLICY'], # Replaced by Permissions-Policy
|
||||||
|
'rate_limiting': ['OLD_RATE_LIMIT_SETTING'], # Example obsolete setting
|
||||||
|
}
|
||||||
|
|
||||||
|
changes_made = False
|
||||||
|
|
||||||
|
for section_name, option_list in obsolete_options.items():
|
||||||
|
if self.config.has_section(section_name):
|
||||||
|
for option_key in option_list:
|
||||||
|
if self.config.has_option(section_name, option_key):
|
||||||
|
self.config.remove_option(section_name, option_key)
|
||||||
|
changes_made = True
|
||||||
|
logger.info(f"Removed obsolete option: [{section_name}] {option_key}")
|
||||||
|
|
||||||
|
return changes_made
|
||||||
|
|
||||||
|
def save_config(self) -> bool:
|
||||||
|
"""Save the configuration to file with proper formatting"""
|
||||||
|
try:
|
||||||
|
# Create backup of existing config
|
||||||
|
if os.path.exists(self.config_path):
|
||||||
|
backup_path = f"{self.config_path}.backup"
|
||||||
|
import shutil
|
||||||
|
shutil.copy2(self.config_path, backup_path)
|
||||||
|
logger.info(f"Created backup: {backup_path}")
|
||||||
|
|
||||||
|
# Write the configuration with proper formatting
|
||||||
|
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||||
|
# Write header comment
|
||||||
|
f.write("; Configuration file for User Monitor Application\n")
|
||||||
|
f.write("; This file is auto-managed - existing values are preserved\n")
|
||||||
|
f.write("; Generated/Updated by ConfigManager\n\n")
|
||||||
|
|
||||||
|
# Write sections with comments
|
||||||
|
default_config = self.get_default_config()
|
||||||
|
|
||||||
|
for section_name in default_config.keys():
|
||||||
|
if self.config.has_section(section_name):
|
||||||
|
f.write(f"[{section_name}]\n")
|
||||||
|
|
||||||
|
# Write comments first
|
||||||
|
for key, value in default_config[section_name].items():
|
||||||
|
if key.startswith(';') and value is None:
|
||||||
|
f.write(f"{key}\n")
|
||||||
|
|
||||||
|
# Write actual options
|
||||||
|
for option_key in self.config.options(section_name):
|
||||||
|
option_value = self.config.get(section_name, option_key)
|
||||||
|
f.write(f"{option_key} = {option_value}\n")
|
||||||
|
|
||||||
|
f.write("\n") # Empty line between sections
|
||||||
|
|
||||||
|
logger.info(f"Configuration saved to {self.config_path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving configuration: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_config_exists(self, preserve_existing: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Main method to ensure configuration file exists and is up-to-date
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preserve_existing: If True, preserve existing values; if False, reset to defaults
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if config was created/updated successfully
|
||||||
|
"""
|
||||||
|
config_existed = self.load_existing_config()
|
||||||
|
|
||||||
|
if not config_existed:
|
||||||
|
logger.info("No configuration file found, creating new one with defaults")
|
||||||
|
else:
|
||||||
|
logger.info("Existing configuration found, checking for updates needed")
|
||||||
|
|
||||||
|
# Merge configurations
|
||||||
|
merge_changes = self.merge_config(preserve_existing)
|
||||||
|
|
||||||
|
# Remove obsolete options
|
||||||
|
removal_changes = self.remove_obsolete_options()
|
||||||
|
|
||||||
|
# Save if changes were made or if config didn't exist
|
||||||
|
if not config_existed or merge_changes or removal_changes:
|
||||||
|
success = self.save_config()
|
||||||
|
if success:
|
||||||
|
if not config_existed:
|
||||||
|
logger.info("✅ New configuration file created successfully")
|
||||||
|
else:
|
||||||
|
logger.info("✅ Configuration file updated successfully")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.error("❌ Failed to save configuration file")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.info("✅ Configuration file is up-to-date, no changes needed")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def initialize_config(config_path: str = 'config.ini', preserve_existing: bool = True) -> configparser.ConfigParser:
|
||||||
|
"""
|
||||||
|
Initialize configuration file and return configured ConfigParser instance
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_path: Path to the configuration file
|
||||||
|
preserve_existing: Whether to preserve existing configuration values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ConfigParser instance with loaded configuration
|
||||||
|
"""
|
||||||
|
manager = ConfigManager(config_path)
|
||||||
|
|
||||||
|
if manager.ensure_config_exists(preserve_existing):
|
||||||
|
# Reload the config after ensuring it exists
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(config_path)
|
||||||
|
return config
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Failed to initialize configuration file")
|
||||||
|
|
||||||
|
# Example usage and testing
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Test the configuration manager
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Use a temporary file for testing
|
||||||
|
test_config_path = os.path.join(tempfile.gettempdir(), 'test_config.ini')
|
||||||
|
|
||||||
|
print("=== Testing Configuration Manager ===")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Test 1: Create new config
|
||||||
|
print("\n1. Testing new configuration creation...")
|
||||||
|
config = initialize_config(test_config_path, preserve_existing=True)
|
||||||
|
print(f"✅ New config created with {len(config.sections())} sections")
|
||||||
|
|
||||||
|
# Test 2: Load existing config and add missing options
|
||||||
|
print("\n2. Testing existing configuration update...")
|
||||||
|
config = initialize_config(test_config_path, preserve_existing=True)
|
||||||
|
print("✅ Existing config loaded and updated")
|
||||||
|
|
||||||
|
# Test 3: Show some values
|
||||||
|
print("\n3. Sample configuration values:")
|
||||||
|
print(f" Database URI: {config.get('database', 'SQLALCHEMY_DATABASE_URI')}")
|
||||||
|
print(f" Server Port: {config.get('server', 'PORT')}")
|
||||||
|
print(f" Debug Mode: {config.get('app', 'APP_DEBUG')}")
|
||||||
|
|
||||||
|
print(f"\n✅ Configuration file created at: {test_config_path}")
|
||||||
|
print("You can examine this file to see the output format")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Test failed: {e}")
|
||||||
@@ -52,7 +52,7 @@ def add_security_headers(response):
|
|||||||
|
|
||||||
# Permissions Policy (formerly Feature-Policy)
|
# Permissions Policy (formerly Feature-Policy)
|
||||||
response.headers['Permissions-Policy'] = (
|
response.headers['Permissions-Policy'] = (
|
||||||
'camera=(), microphone=(), geolocation=(), interest-cohort=()'
|
'camera=(), microphone=(), geolocation=(), payment=(), fullscreen=(self)'
|
||||||
)
|
)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user