lizenzserver API gedöns

Dieser Commit ist enthalten in:
2025-06-22 00:53:05 +02:00
Ursprung 6d1577c989
Commit b420452551
8 geänderte Dateien mit 914 neuen und 195 gelöschten Zeilen

Datei anzeigen

@@ -1,14 +1,16 @@
# TODO: Lizenzserver Konfiguration Implementation # Lizenzserver Konfiguration Implementation
## Overview ## Overview
Implement client configuration management and single-session enforcement for Account Forger software. ✅ COMPLETED: Implemented client configuration management and single-session enforcement for Account Forger software.
## Current State ## Implementation Status (2025-06-21)
- License activation works (consumes device slots) License activation works (consumes device slots)
- Basic verification exists at `/api/license/verify` Basic verification exists at `/api/license/verify`
- No heartbeat system ✅ Heartbeat system implemented (30-second intervals)
- No single-session enforcement ✅ Single-session enforcement implemented
- Admin panel has UI for "Lizenzserver Konfiguration" but backend is missing Admin panel has full UI and backend for "Lizenzserver Konfiguration"
✅ Session management and monitoring
✅ Automatic cleanup of expired sessions
## Requirements ## Requirements
1. **Single Session Enforcement**: Only one device can run the software at a time (even if activated on multiple devices) 1. **Single Session Enforcement**: Only one device can run the software at a time (even if activated on multiple devices)
@@ -16,9 +18,9 @@ Implement client configuration management and single-session enforcement for Acc
3. **Version Management**: Control minimum supported version and update notifications 3. **Version Management**: Control minimum supported version and update notifications
4. **Client Configuration**: Manage Account Forger settings from admin panel 4. **Client Configuration**: Manage Account Forger settings from admin panel
## Implementation Tasks ## Completed Features
### 1. Database Schema ### 1. Database Schema
#### Admin Panel Database #### Admin Panel Database
```sql ```sql
@@ -63,9 +65,9 @@ CREATE TABLE session_history (
); );
``` ```
### 2. License Server Endpoints ### 2. License Server Endpoints
#### New endpoints needed in `/v2_lizenzserver/app/api/license.py`: #### Implemented endpoints in `/v2_lizenzserver/app/api/license.py`:
1. **POST /api/license/session/start** 1. **POST /api/license/session/start**
- Input: license_key, machine_id, hardware_hash, version - Input: license_key, machine_id, hardware_hash, version
@@ -86,9 +88,9 @@ CREATE TABLE session_history (
4. **Background job**: Clean up sessions older than 60 seconds without heartbeat 4. **Background job**: Clean up sessions older than 60 seconds without heartbeat
### 3. Admin Panel Implementation ### 3. Admin Panel Implementation
#### Routes needed in `/v2_adminpanel/routes/admin_routes.py`: #### Implemented routes in `/v2_adminpanel/routes/admin_routes.py`:
1. **GET /lizenzserver/config** 1. **GET /lizenzserver/config**
- Show current client configuration - Show current client configuration
@@ -105,9 +107,9 @@ CREATE TABLE session_history (
4. **POST /lizenzserver/sessions/{session_id}/terminate** 4. **POST /lizenzserver/sessions/{session_id}/terminate**
- Force close a session (admin only: rac00n, w@rh@mm3r) - Force close a session (admin only: rac00n, w@rh@mm3r)
5. **GET /lizenzserver/config/client/new** (currently 404) 5. **GET /lizenzserver/config/client/new**
- This is what's missing and causing the error - Shows client configuration page
- Should handle creating initial client config - Handles initial client config and updates
### 4. Security ### 4. Security
@@ -152,18 +154,51 @@ CREATE TABLE session_history (
- View session history (last 24h) - View session history (last 24h)
- Manage client configuration - Manage client configuration
## Implementation Order ## Implementation Completed
1. Create database tables 1. Created database tables (client_configs, license_sessions, session_history)
2. Implement session management in license server 2. Implemented session management in license server
3. Add heartbeat endpoint 3. ✅ Added heartbeat endpoint
4. Create admin panel routes for configuration 4. Created admin panel routes for configuration
5. Implement session viewing/management 5. Implemented session viewing/management with terminate capability
6. Add background cleanup job 6. ✅ Added background cleanup job (runs every 60 seconds)
7. Test with Account Forger client 7. ⏳ Ready for testing with Account Forger client
## Notes ## Implementation Notes
- YAGNI: One global config for all Account Forger instances - YAGNI: One global config for all Account Forger instances
- No per-customer settings - No per-customer settings
- No grace period for session reclaim - No grace period for session reclaim
- Generic error messages (no "who's using it" info) - Generic error messages (no "who's using it" info)
- ✅ Version format: 1.0.0
- ✅ Session tokens: UUID format
- ✅ Background cleanup: Every 60 seconds
- ✅ API Key: Single global key stored in client_configs
## UI Improvements (2025-06-21)
### Single-Page Administration
- ✅ Merged all configuration into the main administration page
- ✅ Removed separate "Account Forger Konfiguration" page
- ✅ Removed "Neuer Client" button (not needed with single global config)
### Account Forger Configuration Section
- ✅ Inline version management (current and minimum version)
- ✅ API key display with copy-to-clipboard functionality
- ✅ Removed download_url and whats_new fields (handled elsewhere)
- ✅ Direct save without page navigation
### Live Session Monitor
- ✅ Real-time session count with badge
- ✅ Mini table showing last 5 active sessions
- ✅ Auto-refresh every 30 seconds via AJAX
- ✅ "Alle anzeigen" link to full session management page
### Technical Settings
- ✅ Feature flags in collapsible accordion
- ✅ Rate limits in collapsible accordion
- ✅ Clean separation between daily operations and technical settings
### Database Schema Updates
- ✅ Removed download_url column from client_configs
- ✅ Removed whats_new column from client_configs
- ✅ Simplified to only essential configuration fields

Datei anzeigen

@@ -591,3 +591,52 @@ BEGIN
WHERE device_type IS NULL OR device_type = 'unknown'; WHERE device_type IS NULL OR device_type = 'unknown';
END IF; END IF;
END $$; END $$;
-- Client configuration table for Account Forger
CREATE TABLE IF NOT EXISTS client_configs (
id SERIAL PRIMARY KEY,
client_name VARCHAR(100) NOT NULL DEFAULT 'Account Forger',
api_key VARCHAR(255) NOT NULL,
heartbeat_interval INTEGER DEFAULT 30, -- seconds
session_timeout INTEGER DEFAULT 60, -- seconds (2x heartbeat)
current_version VARCHAR(20) NOT NULL,
minimum_version VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- License sessions for single-session enforcement
CREATE TABLE IF NOT EXISTS license_sessions (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id VARCHAR(255) NOT NULL,
ip_address INET,
client_version VARCHAR(20),
session_token VARCHAR(255) UNIQUE NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_heartbeat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(license_id) -- Only one active session per license
);
-- Session history for debugging
CREATE TABLE IF NOT EXISTS session_history (
id SERIAL PRIMARY KEY,
license_id INTEGER REFERENCES licenses(id) ON DELETE CASCADE,
hardware_id VARCHAR(255) NOT NULL,
ip_address INET,
client_version VARCHAR(20),
started_at TIMESTAMP,
ended_at TIMESTAMP,
end_reason VARCHAR(50) -- 'normal', 'timeout', 'forced', 'replaced'
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_license_sessions_license_id ON license_sessions(license_id);
CREATE INDEX IF NOT EXISTS idx_license_sessions_last_heartbeat ON license_sessions(last_heartbeat);
CREATE INDEX IF NOT EXISTS idx_session_history_license_id ON session_history(license_id);
CREATE INDEX IF NOT EXISTS idx_session_history_ended_at ON session_history(ended_at);
-- Insert default client configuration if not exists
INSERT INTO client_configs (client_name, api_key, current_version, minimum_version)
VALUES ('Account Forger', 'AF-' || gen_random_uuid()::text, '1.0.0', '1.0.0')
ON CONFLICT DO NOTHING;

Datei anzeigen

@@ -931,6 +931,31 @@ def license_config():
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
# Get client configuration
cur.execute("""
SELECT id, client_name, api_key, heartbeat_interval, session_timeout,
current_version, minimum_version, download_url, whats_new,
created_at, updated_at
FROM client_configs
WHERE client_name = 'Account Forger'
""")
client_config = cur.fetchone()
# Get active sessions
cur.execute("""
SELECT ls.id, ls.session_token, l.license_key, c.name as customer_name,
ls.hardware_id, ls.ip_address, ls.client_version,
ls.started_at AT TIME ZONE 'Europe/Berlin' as started_at,
ls.last_heartbeat AT TIME ZONE 'Europe/Berlin' as last_heartbeat,
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ls.last_heartbeat)) as seconds_since_heartbeat
FROM license_sessions ls
JOIN licenses l ON ls.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
ORDER BY ls.last_heartbeat DESC
LIMIT 5
""")
active_sessions = cur.fetchall()
# Get feature flags # Get feature flags
cur.execute(""" cur.execute("""
SELECT * FROM feature_flags SELECT * FROM feature_flags
@@ -938,14 +963,6 @@ def license_config():
""") """)
feature_flags = cur.fetchall() feature_flags = cur.fetchall()
# Get API clients
cur.execute("""
SELECT id, client_name, api_key, is_active, created_at
FROM api_clients
ORDER BY created_at DESC
""")
api_clients = cur.fetchall()
# Get rate limits # Get rate limits
cur.execute(""" cur.execute("""
SELECT * FROM api_rate_limits SELECT * FROM api_rate_limits
@@ -954,8 +971,9 @@ def license_config():
rate_limits = cur.fetchall() rate_limits = cur.fetchall()
return render_template('license_config.html', return render_template('license_config.html',
client_config=client_config,
active_sessions=active_sessions,
feature_flags=feature_flags, feature_flags=feature_flags,
api_clients=api_clients,
rate_limits=rate_limits rate_limits=rate_limits
) )
@@ -1006,6 +1024,174 @@ def update_feature_flag(flag_id):
return redirect(url_for('admin.license_config')) return redirect(url_for('admin.license_config'))
@admin_bp.route("/lizenzserver/config/update", methods=["POST"])
@login_required
def update_client_config():
"""Update client configuration"""
if session.get('username') not in ['rac00n', 'w@rh@mm3r']:
flash('Zugriff verweigert', 'error')
return redirect(url_for('admin.dashboard'))
try:
conn = get_connection()
cur = conn.cursor()
# Update configuration
cur.execute("""
UPDATE client_configs
SET current_version = %s,
minimum_version = %s,
download_url = %s,
whats_new = %s,
heartbeat_interval = %s,
session_timeout = %s,
updated_at = CURRENT_TIMESTAMP
WHERE client_name = 'Account Forger'
""", (
request.form.get('current_version'),
request.form.get('minimum_version'),
'', # download_url - no longer used
'', # whats_new - no longer used
30, # heartbeat_interval - fixed
60 # session_timeout - fixed
))
conn.commit()
flash('Client-Konfiguration wurde aktualisiert', 'success')
# Log action
log_action(
username=session.get('username'),
action='UPDATE',
entity_type='client_config',
entity_id=1,
new_values={
'current_version': request.form.get('current_version'),
'minimum_version': request.form.get('minimum_version')
}
)
except Exception as e:
flash(f'Fehler beim Aktualisieren: {str(e)}', 'error')
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
return redirect(url_for('admin.license_config'))
@admin_bp.route("/lizenzserver/sessions")
@login_required
def license_sessions():
"""Show active license sessions"""
try:
conn = get_connection()
cur = conn.cursor()
# Get active sessions
cur.execute("""
SELECT ls.id, ls.session_token, l.license_key, c.name as customer_name,
ls.hardware_id, ls.ip_address, ls.client_version,
ls.started_at AT TIME ZONE 'Europe/Berlin' as started_at,
ls.last_heartbeat AT TIME ZONE 'Europe/Berlin' as last_heartbeat,
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ls.last_heartbeat)) as seconds_since_heartbeat
FROM license_sessions ls
JOIN licenses l ON ls.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
ORDER BY ls.last_heartbeat DESC
""")
sessions = cur.fetchall()
# Get session history (last 24h)
cur.execute("""
SELECT sh.id, l.license_key, c.name as customer_name,
sh.hardware_id, sh.ip_address, sh.client_version,
sh.started_at AT TIME ZONE 'Europe/Berlin' as started_at,
sh.ended_at AT TIME ZONE 'Europe/Berlin' as ended_at,
sh.end_reason,
EXTRACT(EPOCH FROM (sh.ended_at - sh.started_at)) as duration_seconds
FROM session_history sh
JOIN licenses l ON sh.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
WHERE sh.ended_at > CURRENT_TIMESTAMP - INTERVAL '24 hours'
ORDER BY sh.ended_at DESC
LIMIT 100
""")
history = cur.fetchall()
return render_template('license_sessions.html',
active_sessions=sessions,
session_history=history)
except Exception as e:
flash(f'Fehler beim Laden der Sessions: {str(e)}', 'error')
return render_template('license_sessions.html')
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
@admin_bp.route("/lizenzserver/sessions/<int:session_id>/terminate", methods=["POST"])
@login_required
def terminate_session(session_id):
"""Force terminate a session"""
if session.get('username') not in ['rac00n', 'w@rh@mm3r']:
flash('Zugriff verweigert', 'error')
return redirect(url_for('admin.license_sessions'))
try:
conn = get_connection()
cur = conn.cursor()
# Get session info
cur.execute("""
SELECT license_id, hardware_id, ip_address, client_version, started_at
FROM license_sessions
WHERE id = %s
""", (session_id,))
session_info = cur.fetchone()
if session_info:
# Log to history
cur.execute("""
INSERT INTO session_history
(license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, 'forced')
""", session_info)
# Delete session
cur.execute("DELETE FROM license_sessions WHERE id = %s", (session_id,))
conn.commit()
flash('Session wurde beendet', 'success')
# Log action
log_action(
username=session.get('username'),
action='TERMINATE_SESSION',
entity_type='license_session',
entity_id=session_id,
additional_info={'hardware_id': session_info[1]}
)
else:
flash('Session nicht gefunden', 'error')
except Exception as e:
flash(f'Fehler beim Beenden der Session: {str(e)}', 'error')
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
return redirect(url_for('admin.license_sessions'))
@admin_bp.route("/api/admin/lizenzserver/live-stats") @admin_bp.route("/api/admin/lizenzserver/live-stats")
@login_required @login_required
def license_live_stats(): def license_live_stats():
@@ -1024,27 +1210,36 @@ def license_live_stats():
""") """)
stats = cur.fetchone() stats = cur.fetchone()
# Get latest validations # Get active sessions count
cur.execute(""" cur.execute("""
SELECT l.license_key, lh.hardware_id, lh.ip_address, lh.timestamp SELECT COUNT(*) FROM license_sessions
FROM license_heartbeats lh """)
JOIN licenses l ON lh.license_id = l.id active_count = cur.fetchone()[0]
ORDER BY lh.timestamp DESC
# Get latest sessions
cur.execute("""
SELECT ls.id, l.license_key, c.name as customer_name,
ls.client_version, ls.last_heartbeat AT TIME ZONE 'Europe/Berlin' as last_heartbeat,
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - ls.last_heartbeat)) as seconds_since
FROM license_sessions ls
JOIN licenses l ON ls.license_id = l.id
LEFT JOIN customers c ON l.customer_id = c.id
ORDER BY ls.last_heartbeat DESC
LIMIT 5 LIMIT 5
""") """)
latest_validations = cur.fetchall() latest_sessions = cur.fetchall()
return jsonify({ return jsonify({
'active_licenses': stats[0] or 0, 'active_licenses': active_count,
'validations_per_minute': stats[1] or 0, 'validations_per_minute': stats[1] or 0,
'active_devices': stats[2] or 0, 'active_devices': stats[2] or 0,
'latest_validations': [ 'latest_sessions': [
{ {
'license_key': v[0][:8] + '...', 'customer_name': s[2],
'hardware_id': v[1][:8] + '...', 'version': s[3],
'ip_address': v[2] or 'Unknown', 'last_heartbeat': s[4].strftime('%H:%M:%S'),
'timestamp': v[3].strftime('%H:%M:%S') 'seconds_since': int(s[5])
} for v in latest_validations } for s in latest_sessions
] ]
}) })

Datei anzeigen

@@ -5,6 +5,7 @@ import logging
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
import config import config
from utils.backup import create_backup from utils.backup import create_backup
from utils.db_utils import get_connection
def scheduled_backup(): def scheduled_backup():
@@ -13,6 +14,56 @@ def scheduled_backup():
create_backup(backup_type="scheduled", created_by="scheduler") create_backup(backup_type="scheduled", created_by="scheduler")
def cleanup_expired_sessions():
"""Clean up expired license sessions"""
try:
conn = get_connection()
cur = conn.cursor()
# Get client config for timeout value
cur.execute("""
SELECT session_timeout
FROM client_configs
WHERE client_name = 'Account Forger'
""")
result = cur.fetchone()
timeout_seconds = result[0] if result else 60
# Find expired sessions
cur.execute("""
SELECT id, license_id, hardware_id, ip_address, client_version, started_at
FROM license_sessions
WHERE last_heartbeat < CURRENT_TIMESTAMP - INTERVAL '%s seconds'
""", (timeout_seconds,))
expired_sessions = cur.fetchall()
if expired_sessions:
logging.info(f"Found {len(expired_sessions)} expired sessions to clean up")
for session in expired_sessions:
# Log to history
cur.execute("""
INSERT INTO session_history
(license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason)
VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, 'timeout')
""", (session[1], session[2], session[3], session[4], session[5]))
# Delete session
cur.execute("DELETE FROM license_sessions WHERE id = %s", (session[0],))
conn.commit()
logging.info(f"Cleaned up {len(expired_sessions)} expired sessions")
cur.close()
conn.close()
except Exception as e:
logging.error(f"Error cleaning up sessions: {str(e)}")
if 'conn' in locals():
conn.rollback()
def init_scheduler(): def init_scheduler():
"""Initialize and configure the scheduler""" """Initialize and configure the scheduler"""
scheduler = BackgroundScheduler() scheduler = BackgroundScheduler()
@@ -27,7 +78,17 @@ def init_scheduler():
replace_existing=True replace_existing=True
) )
# Configure session cleanup job - runs every 60 seconds
scheduler.add_job(
cleanup_expired_sessions,
'interval',
seconds=60,
id='session_cleanup',
replace_existing=True
)
scheduler.start() scheduler.start()
logging.info(f"Scheduler started. Daily backup scheduled at {config.SCHEDULER_CONFIG['backup_hour']:02d}:{config.SCHEDULER_CONFIG['backup_minute']:02d}") logging.info(f"Scheduler started. Daily backup scheduled at {config.SCHEDULER_CONFIG['backup_hour']:02d}:{config.SCHEDULER_CONFIG['backup_minute']:02d}")
logging.info("Session cleanup job scheduled to run every 60 seconds")
return scheduler return scheduler

Datei anzeigen

@@ -1,22 +1,114 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Lizenzserver Konfiguration{% endblock %} {% block title %}Administration{% endblock %}
{% block content %} {% block content %}
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<h1 class="h3">Lizenzserver Konfiguration</h1> <h1 class="h3">Administration</h1>
</div> </div>
</div> </div>
<!-- Feature Flags --> <!-- Account Forger Configuration -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header bg-primary text-white">
<h5 class="mb-0">Feature Flags</h5> <h5 class="mb-0">Account Forger Konfiguration</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="post" action="{{ url_for('admin.update_client_config') }}" class="row g-3">
<div class="col-md-6">
<label class="form-label">Aktuelle Version</label>
<input type="text" class="form-control" name="current_version"
value="{{ client_config[5] if client_config else '1.0.0' }}"
pattern="^\d+\.\d+\.\d+$" required>
</div>
<div class="col-md-6">
<label class="form-label">Minimum Version</label>
<input type="text" class="form-control" name="minimum_version"
value="{{ client_config[6] if client_config else '1.0.0' }}"
pattern="^\d+\.\d+\.\d+$" required>
</div>
<div class="col-12">
<label class="form-label">API Key</label>
<div class="input-group">
<input type="text" class="form-control" value="{{ client_config[2] if client_config else 'Nicht konfiguriert' }}" readonly>
{% if client_config %}
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('{{ client_config[2] }}')">
<i class="bi bi-clipboard"></i> Kopieren
</button>
{% endif %}
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header bg-info text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0">Aktive Sitzungen</h5>
<div>
<span class="badge bg-white text-dark" id="sessionCount">{{ active_sessions|length if active_sessions else 0 }}</span>
<a href="{{ url_for('admin.license_sessions') }}" class="btn btn-sm btn-light ms-2">Alle anzeigen</a>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Kunde</th>
<th>Version</th>
<th>Letztes Heartbeat</th>
<th>Status</th>
</tr>
</thead>
<tbody id="sessionTableBody">
{% if active_sessions %}
{% for session in active_sessions[:5] %}
<tr>
<td>{{ session[3] or 'Unbekannt' }}</td>
<td>{{ session[6] }}</td>
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
<td>
{% if session[9] < 90 %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-warning">Timeout</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Technical Settings (collapsible) -->
<div class="accordion mb-4" id="technicalSettings">
<!-- Feature Flags -->
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#featureFlags">
Feature Flags
</button>
</h2>
<div id="featureFlags" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
<div class="accordion-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
@@ -59,66 +151,15 @@
</div> </div>
</div> </div>
<!-- API Clients --> <!-- Rate Limits -->
<div class="col-md-6"> <div class="accordion-item">
<div class="card"> <h2 class="accordion-header">
<div class="card-header d-flex justify-content-between align-items-center"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rateLimits">
<h5 class="mb-0">API Clients</h5> Rate Limits
<button class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#newApiClientModal">
<i class="bi bi-plus"></i> Neuer Client
</button> </button>
</div> </h2>
<div class="card-body"> <div id="rateLimits" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
<div class="table-responsive"> <div class="accordion-body">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>API Key</th>
<th>Status</th>
<th>Erstellt</th>
</tr>
</thead>
<tbody>
{% for client in api_clients %}
<tr>
<td>{{ client[1] }}</td>
<td>
<code>{{ client[2][:12] }}...</code>
<button class="btn btn-sm btn-link" onclick="copyToClipboard('{{ client[2] }}')">
<i class="bi bi-clipboard"></i>
</button>
</td>
<td>
{% if client[3] %}
<span class="badge bg-success">Aktiv</span>
{% else %}
<span class="badge bg-danger">Inaktiv</span>
{% endif %}
</td>
<td>{{ client[4].strftime('%d.%m.%Y') if client[4] else '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="4" class="text-center text-muted">Keine API Clients vorhanden</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Rate Limits -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Rate Limits</h5>
</div>
<div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
<table class="table"> <table class="table">
<thead> <thead>
@@ -128,7 +169,6 @@
<th>Requests/Stunde</th> <th>Requests/Stunde</th>
<th>Requests/Tag</th> <th>Requests/Tag</th>
<th>Burst Size</th> <th>Burst Size</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -139,15 +179,10 @@
<td>{{ limit[3] }}</td> <td>{{ limit[3] }}</td>
<td>{{ limit[4] }}</td> <td>{{ limit[4] }}</td>
<td>{{ limit[5] }}</td> <td>{{ limit[5] }}</td>
<td>
<button class="btn btn-sm btn-warning" onclick="editRateLimit('{{ limit[0] }}', {{ limit[2] }}, {{ limit[3] }}, {{ limit[4] }})">
Bearbeiten
</button>
</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="6" class="text-center text-muted">Keine Rate Limits konfiguriert</td> <td colspan="5" class="text-center text-muted">Keine Rate Limits konfiguriert</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -157,83 +192,57 @@
</div> </div>
</div> </div>
</div> </div>
<!-- New API Client Modal -->
<div class="modal fade" id="newApiClientModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="/lizenzserver/config/api-client">
<div class="modal-header">
<h5 class="modal-title">Neuer API Client</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Beschreibung</label>
<textarea name="description" class="form-control" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Erstellen</button>
</div>
</form>
</div>
</div>
</div>
<!-- Edit Rate Limit Modal -->
<div class="modal fade" id="editRateLimitModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="editRateLimitForm" method="post">
<div class="modal-header">
<h5 class="modal-title">Rate Limit bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Requests pro Minute</label>
<input type="number" name="requests_per_minute" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Requests pro Stunde</label>
<input type="number" name="requests_per_hour" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Requests pro Tag</label>
<input type="number" name="requests_per_day" class="form-control" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
function copyToClipboard(text) { function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function() { navigator.clipboard.writeText(text).then(function() {
alert('API Key wurde in die Zwischenablage kopiert!'); // Show success message instead of alert
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<i class="bi bi-check"></i> Kopiert!';
button.classList.remove('btn-outline-secondary');
button.classList.add('btn-success');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('btn-success');
button.classList.add('btn-outline-secondary');
}, 2000);
}); });
} }
function editRateLimit(id, rpm, rph, rpd) { // Auto-refresh sessions every 30 seconds
const form = document.getElementById('editRateLimitForm'); function refreshSessions() {
form.action = `/lizenzserver/config/rate-limit/${id}`; fetch('{{ url_for("admin.license_live_stats") }}')
form.requests_per_minute.value = rpm; .then(response => response.json())
form.requests_per_hour.value = rph; .then(data => {
form.requests_per_day.value = rpd; document.getElementById('sessionCount').textContent = data.active_licenses || 0;
new bootstrap.Modal(document.getElementById('editRateLimitModal')).show();
// Update session table
const tbody = document.getElementById('sessionTableBody');
if (data.latest_sessions && data.latest_sessions.length > 0) {
tbody.innerHTML = data.latest_sessions.map(session => `
<tr>
<td>${session.customer_name || 'Unbekannt'}</td>
<td>${session.version}</td>
<td>${session.last_heartbeat}</td>
<td>
${session.seconds_since < 90
? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-warning">Timeout</span>'}
</td>
</tr>
`).join('');
} else {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted">Keine aktiven Sitzungen</td></tr>';
}
})
.catch(error => console.error('Error refreshing sessions:', error));
} }
// Refresh sessions every 30 seconds
setInterval(refreshSessions, 30000);
</script> </script>
{% endblock %} {% endblock %}

Datei anzeigen

@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}Aktive Lizenzsitzungen{% endblock %}
{% block content %}
<div class="container-fluid">
<h1>Lizenzsitzungen</h1>
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Aktive Sitzungen</h5>
</div>
<div class="card-body">
{% if active_sessions %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Hardware ID</th>
<th>IP-Adresse</th>
<th>Version</th>
<th>Gestartet</th>
<th>Letztes Heartbeat</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
{% for session in active_sessions %}
<tr>
<td><code>{{ session[2][:8] }}...</code></td>
<td>{{ session[3] or 'Unbekannt' }}</td>
<td><code>{{ session[4][:12] }}...</code></td>
<td>{{ session[5] or 'Unbekannt' }}</td>
<td>{{ session[6] }}</td>
<td>{{ session[7].strftime('%H:%M:%S') }}</td>
<td>{{ session[8].strftime('%H:%M:%S') }}</td>
<td>
{% if session[9] < 90 %}
<span class="badge bg-success">Aktiv</span>
{% elif session[9] < 120 %}
<span class="badge bg-warning">Timeout bald</span>
{% else %}
<span class="badge bg-danger">Timeout</span>
{% endif %}
</td>
<td>
{% if session.get('username') in ['rac00n', 'w@rh@mm3r'] %}
<form method="POST" action="{{ url_for('admin.terminate_session', session_id=session[0]) }}"
style="display: inline;" onsubmit="return confirm('Sitzung wirklich beenden?');">
<button type="submit" class="btn btn-sm btn-danger">Beenden</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Keine aktiven Sitzungen vorhanden.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>Sitzungsverlauf (letzte 24 Stunden)</h5>
</div>
<div class="card-body">
{% if session_history %}
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Lizenzschlüssel</th>
<th>Kunde</th>
<th>Hardware ID</th>
<th>IP-Adresse</th>
<th>Version</th>
<th>Gestartet</th>
<th>Beendet</th>
<th>Dauer</th>
<th>Grund</th>
</tr>
</thead>
<tbody>
{% for hist in session_history %}
<tr>
<td><code>{{ hist[1][:8] }}...</code></td>
<td>{{ hist[2] or 'Unbekannt' }}</td>
<td><code>{{ hist[3][:12] }}...</code></td>
<td>{{ hist[4] or 'Unbekannt' }}</td>
<td>{{ hist[5] }}</td>
<td>{{ hist[6].strftime('%d.%m %H:%M') }}</td>
<td>{{ hist[7].strftime('%d.%m %H:%M') }}</td>
<td>
{% set duration = hist[9] %}
{% if duration < 60 %}
{{ duration|int }}s
{% elif duration < 3600 %}
{{ (duration / 60)|int }}m
{% else %}
{{ (duration / 3600)|round(1) }}h
{% endif %}
</td>
<td>
{% if hist[8] == 'normal' %}
<span class="badge bg-success">Normal</span>
{% elif hist[8] == 'timeout' %}
<span class="badge bg-warning">Timeout</span>
{% elif hist[8] == 'forced' %}
<span class="badge bg-danger">Erzwungen</span>
{% elif hist[8] == 'replaced' %}
<span class="badge bg-info">Ersetzt</span>
{% else %}
{{ hist[8] }}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">Keine Sitzungen in den letzten 24 Stunden.</p>
{% endif %}
</div>
</div>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin.license_config') }}" class="btn btn-secondary">Zurück zur Konfiguration</a>
</div>
</div>
<script>
// Auto-refresh every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
</script>
{% endblock %}

Datei anzeigen

@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Dict, Any from typing import Dict, Any
import uuid
from app.db.database import get_db from app.db.database import get_db
from app.models.models import License, Activation, Version from app.models.models import License, Activation, Version
@@ -9,7 +10,13 @@ from app.schemas.license import (
LicenseActivationRequest, LicenseActivationRequest,
LicenseActivationResponse, LicenseActivationResponse,
LicenseVerificationRequest, LicenseVerificationRequest,
LicenseVerificationResponse LicenseVerificationResponse,
SessionStartRequest,
SessionStartResponse,
SessionHeartbeatRequest,
SessionHeartbeatResponse,
SessionEndRequest,
SessionEndResponse
) )
from app.core.security import get_api_key from app.core.security import get_api_key
from app.core.config import settings from app.core.config import settings
@@ -207,3 +214,184 @@ async def get_license_info(
for a in activations for a in activations
] ]
} }
@router.post("/session/start", response_model=SessionStartResponse)
async def start_session(
request: SessionStartRequest,
db: Session = Depends(get_db),
api_key = Depends(get_api_key)
):
# Verify API key matches client config
from sqlalchemy import text
result = db.execute(text("SELECT api_key, current_version, minimum_version FROM client_configs WHERE client_name = 'Account Forger'")).first()
if not result or result.api_key != api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
# Check if version is supported
if request.version < result.minimum_version:
return SessionStartResponse(
success=False,
message=f"Version {request.version} is too old. Minimum required: {result.minimum_version}",
session_token=None,
requires_update=True,
update_url=None,
whats_new=None
)
# Verify license exists and is active
license = db.query(License).filter(
License.license_key == request.license_key,
License.is_active == True
).first()
if not license:
return SessionStartResponse(
success=False,
message="Invalid or inactive license key",
session_token=None
)
if license.expires_at and license.expires_at < datetime.utcnow():
return SessionStartResponse(
success=False,
message="License has expired",
session_token=None
)
# Check for existing active session
existing_session_result = db.execute(
text("SELECT session_token, hardware_id FROM license_sessions WHERE license_id = :license_id"),
{"license_id": license.id}
).first()
if existing_session_result:
if existing_session_result.hardware_id == request.hardware_id:
# Same device, update heartbeat
db.execute(
text("UPDATE license_sessions SET last_heartbeat = CURRENT_TIMESTAMP WHERE session_token = :token"),
{"token": existing_session_result.session_token}
)
db.commit()
return SessionStartResponse(
success=True,
message="Existing session resumed",
session_token=existing_session_result.session_token,
requires_update=request.version < result.current_version,
update_url=None,
whats_new=None
)
else:
return SessionStartResponse(
success=False,
message="Es ist nur eine Sitzung erlaubt, stelle sicher, dass nirgendwo sonst das Programm läuft",
session_token=None
)
# Create new session
session_token = str(uuid.uuid4())
db.execute(
text("""
INSERT INTO license_sessions (license_id, hardware_id, ip_address, client_version, session_token)
VALUES (:license_id, :hardware_id, :ip_address, :version, :token)
"""),
{
"license_id": license.id,
"hardware_id": request.hardware_id,
"ip_address": request.ip_address,
"version": request.version,
"token": session_token
}
)
db.commit()
return SessionStartResponse(
success=True,
message="Session started successfully",
session_token=session_token,
requires_update=request.version < result.current_version,
update_url=None,
whats_new=None
)
@router.post("/session/heartbeat", response_model=SessionHeartbeatResponse)
async def session_heartbeat(
request: SessionHeartbeatRequest,
db: Session = Depends(get_db),
api_key = Depends(get_api_key)
):
# Update heartbeat
result = db.execute(
text("""
UPDATE license_sessions
SET last_heartbeat = CURRENT_TIMESTAMP
WHERE session_token = :token
RETURNING id
"""),
{"token": request.session_token}
).first()
if not result:
return SessionHeartbeatResponse(
success=False,
message="Invalid or expired session"
)
db.commit()
return SessionHeartbeatResponse(
success=True,
message="Heartbeat received"
)
@router.post("/session/end", response_model=SessionEndResponse)
async def end_session(
request: SessionEndRequest,
db: Session = Depends(get_db),
api_key = Depends(get_api_key)
):
# Get session info before deleting
session_info = db.execute(
text("""
SELECT license_id, hardware_id, ip_address, client_version, started_at
FROM license_sessions
WHERE session_token = :token
"""),
{"token": request.session_token}
).first()
if not session_info:
return SessionEndResponse(
success=False,
message="Session not found"
)
# Log to session history
db.execute(
text("""
INSERT INTO session_history (license_id, hardware_id, ip_address, client_version, started_at, ended_at, end_reason)
VALUES (:license_id, :hardware_id, :ip_address, :version, :started, CURRENT_TIMESTAMP, 'normal')
"""),
{
"license_id": session_info.license_id,
"hardware_id": session_info.hardware_id,
"ip_address": session_info.ip_address,
"version": session_info.client_version,
"started": session_info.started_at
}
)
# Delete the session
db.execute(
text("DELETE FROM license_sessions WHERE session_token = :token"),
{"token": request.session_token}
)
db.commit()
return SessionEndResponse(
success=True,
message="Session ended"
)

Datei anzeigen

@@ -41,3 +41,34 @@ class VersionCheckResponse(BaseModel):
is_mandatory: bool is_mandatory: bool
download_url: Optional[str] = None download_url: Optional[str] = None
release_notes: Optional[str] = None release_notes: Optional[str] = None
class SessionStartRequest(BaseModel):
license_key: str
machine_id: str
hardware_id: str
hardware_hash: str
version: str
ip_address: Optional[str] = None
class SessionStartResponse(BaseModel):
success: bool
message: str
session_token: Optional[str] = None
requires_update: bool = False
update_url: Optional[str] = None
whats_new: Optional[str] = None
class SessionHeartbeatRequest(BaseModel):
session_token: str
license_key: str
class SessionHeartbeatResponse(BaseModel):
success: bool
message: str
class SessionEndRequest(BaseModel):
session_token: str
class SessionEndResponse(BaseModel):
success: bool
message: str