lizenzserver API gedöns
Dieser Commit ist enthalten in:
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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">
|
</button>
|
||||||
<i class="bi bi-plus"></i> Neuer Client
|
</h2>
|
||||||
</button>
|
<div id="rateLimits" class="accordion-collapse collapse" data-bs-parent="#technicalSettings">
|
||||||
</div>
|
<div class="accordion-body">
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<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 %}
|
||||||
151
v2_adminpanel/templates/license_sessions.html
Normale Datei
151
v2_adminpanel/templates/license_sessions.html
Normale Datei
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -206,4 +213,185 @@ 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"
|
||||||
|
)
|
||||||
@@ -40,4 +40,35 @@ class VersionCheckResponse(BaseModel):
|
|||||||
update_available: bool
|
update_available: bool
|
||||||
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
|
||||||
In neuem Issue referenzieren
Einen Benutzer sperren