cryptolabs-proxy 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2274 @@
1
+ """
2
+ CryptoLabs Proxy - Unified Authentication Module
3
+
4
+ Provides centralized authentication for all CryptoLabs services:
5
+ - IPMI Monitor
6
+ - DC Overview
7
+ - Grafana (via auth proxy)
8
+ - Prometheus (via auth proxy)
9
+
10
+ User Roles:
11
+ - admin: Full access to all features and user management
12
+ - readwrite: Can view and modify data, but cannot manage users
13
+ - readonly: Can only view data, no modifications
14
+ - anonymous: Public access (disabled by default)
15
+
16
+ Services can operate in two modes:
17
+ 1. Standalone: Use their own authentication
18
+ 2. Fleet Mode: Trust auth headers from cryptolabs-proxy
19
+ """
20
+
21
+ import os
22
+ import secrets
23
+ import hashlib
24
+ import hmac
25
+ import json
26
+ import time
27
+ import logging
28
+ import requests
29
+ from pathlib import Path
30
+ from datetime import datetime, timedelta
31
+ from functools import wraps
32
+ from typing import Optional, Tuple, List
33
+
34
+ from werkzeug.security import generate_password_hash, check_password_hash
35
+
36
+ # Module-level logger for auth operations
37
+ logger = logging.getLogger('cryptolabs_proxy.auth')
38
+
39
+ # =============================================================================
40
+ # CONFIGURATION
41
+ # =============================================================================
42
+
43
+ AUTH_SECRET_KEY = os.environ.get('AUTH_SECRET_KEY', secrets.token_hex(32))
44
+ AUTH_TOKEN_EXPIRY_HOURS = int(os.environ.get('AUTH_TOKEN_EXPIRY_HOURS', '24'))
45
+ AUTH_HEADER_USER = 'X-Fleet-Auth-User'
46
+ AUTH_HEADER_TOKEN = 'X-Fleet-Auth-Token'
47
+ AUTH_HEADER_ROLE = 'X-Fleet-Auth-Role'
48
+ AUTH_HEADER_TIMESTAMP = 'X-Fleet-Auth-Timestamp'
49
+
50
+ # Data directory for auth database
51
+ DATA_DIR = Path(os.environ.get('AUTH_DATA_DIR', '/data/auth'))
52
+
53
+ # DC Watchdog SSO Configuration
54
+ # API key (sk-ipmi-xxx) from CryptoLabs subscription - enables Auto-SSO
55
+ # This key is also used as the signing secret for SSO tokens (no separate secret needed!)
56
+ WATCHDOG_API_KEY = os.environ.get('WATCHDOG_API_KEY', '')
57
+ WATCHDOG_URL = os.environ.get('WATCHDOG_URL', 'https://watchdog.cryptolabs.co.za')
58
+ WATCHDOG_SIGNUP_URL = 'https://www.cryptolabs.co.za/dc-watchdog-signup/'
59
+
60
+ def get_watchdog_api_key() -> str:
61
+ """Get the DC Watchdog API key from environment or persistent storage."""
62
+ # First check persistent storage (for keys obtained via OAuth callback)
63
+ key_file = DATA_DIR / 'watchdog_api_key'
64
+ if key_file.exists():
65
+ try:
66
+ key = key_file.read_text().strip()
67
+ # Ensure file is readable by other containers sharing fleet-auth-data volume
68
+ # (dc-overview runs as dcuser, not root)
69
+ try:
70
+ current_mode = key_file.stat().st_mode & 0o777
71
+ if current_mode != 0o644:
72
+ os.chmod(key_file, 0o644)
73
+ except Exception:
74
+ pass
75
+ return key
76
+ except Exception:
77
+ pass
78
+
79
+ # Then check environment variable
80
+ if WATCHDOG_API_KEY:
81
+ return WATCHDOG_API_KEY
82
+
83
+ return ''
84
+
85
+
86
+ def is_watchdog_verified() -> bool:
87
+ """Check if DC Watchdog has been verified via SSO.
88
+
89
+ On fresh install, even if the API key is in env var (from setup config),
90
+ we require the user to complete SSO to verify the account. Only then is
91
+ DC Watchdog shown as "enabled" in Fleet Management.
92
+ """
93
+ verified_file = DATA_DIR / 'watchdog_verified'
94
+ return verified_file.exists()
95
+
96
+
97
+ def set_watchdog_verified() -> bool:
98
+ """Mark DC Watchdog as verified after SSO completion."""
99
+ try:
100
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
101
+ verified_file = DATA_DIR / 'watchdog_verified'
102
+ verified_file.write_text('1')
103
+ logger.info(f"DC Watchdog verified flag set at {verified_file}")
104
+ return True
105
+ except Exception as e:
106
+ logger.error(f"Failed to set DC Watchdog verified flag: {e}")
107
+ return False
108
+
109
+
110
+ def save_watchdog_api_key(api_key: str) -> bool:
111
+ """Save the DC Watchdog API key to persistent storage."""
112
+ try:
113
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
114
+ key_file = DATA_DIR / 'watchdog_api_key'
115
+ key_file.write_text(api_key)
116
+ # 0644 so other containers sharing fleet-auth-data volume can read it
117
+ # (dc-overview runs as dcuser, not root)
118
+ os.chmod(key_file, 0o644)
119
+ return True
120
+ except Exception:
121
+ return False
122
+
123
+ # Valid roles (ordered by privilege level)
124
+ VALID_ROLES = ['admin', 'readwrite', 'readonly']
125
+
126
+ # Role hierarchy for permission checks
127
+ ROLE_HIERARCHY = {
128
+ 'admin': 3,
129
+ 'readwrite': 2,
130
+ 'readonly': 1,
131
+ 'anonymous': 0
132
+ }
133
+
134
+
135
+ # =============================================================================
136
+ # SETTINGS MANAGEMENT
137
+ # =============================================================================
138
+
139
+ def get_settings_file() -> Path:
140
+ """Get path to settings file."""
141
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
142
+ return DATA_DIR / 'settings.json'
143
+
144
+
145
+ def load_settings() -> dict:
146
+ """Load settings from file."""
147
+ settings_file = get_settings_file()
148
+ defaults = {
149
+ 'allow_anonymous': False,
150
+ 'anonymous_role': 'readonly',
151
+ 'require_password_change': True,
152
+ 'session_timeout_hours': 24,
153
+ 'max_login_attempts': 5,
154
+ 'lockout_duration_minutes': 15,
155
+ }
156
+
157
+ if settings_file.exists():
158
+ try:
159
+ saved = json.loads(settings_file.read_text())
160
+ defaults.update(saved)
161
+ except Exception:
162
+ pass
163
+
164
+ return defaults
165
+
166
+
167
+ def save_settings(settings: dict):
168
+ """Save settings to file."""
169
+ settings_file = get_settings_file()
170
+ settings_file.write_text(json.dumps(settings, indent=2))
171
+
172
+
173
+ def get_setting(key: str, default=None):
174
+ """Get a single setting."""
175
+ settings = load_settings()
176
+ return settings.get(key, default)
177
+
178
+
179
+ def set_setting(key: str, value):
180
+ """Set a single setting."""
181
+ settings = load_settings()
182
+ settings[key] = value
183
+ save_settings(settings)
184
+
185
+
186
+ # =============================================================================
187
+ # TOKEN MANAGEMENT
188
+ # =============================================================================
189
+
190
+ def generate_auth_token(username: str, role: str = 'admin') -> Tuple[str, int]:
191
+ """
192
+ Generate a signed authentication token.
193
+
194
+ Returns:
195
+ Tuple of (token, expiry_timestamp)
196
+ """
197
+ expiry = int(time.time()) + (AUTH_TOKEN_EXPIRY_HOURS * 3600)
198
+
199
+ # Create payload
200
+ payload = f"{username}:{role}:{expiry}"
201
+
202
+ # Sign with HMAC-SHA256
203
+ signature = hmac.new(
204
+ AUTH_SECRET_KEY.encode(),
205
+ payload.encode(),
206
+ hashlib.sha256
207
+ ).hexdigest()
208
+
209
+ # Token format: payload.signature (base64 encoded)
210
+ import base64
211
+ token = base64.urlsafe_b64encode(
212
+ f"{payload}.{signature}".encode()
213
+ ).decode()
214
+
215
+ return token, expiry
216
+
217
+
218
+ def verify_auth_token(token: str) -> Optional[dict]:
219
+ """
220
+ Verify a signed authentication token.
221
+
222
+ Returns:
223
+ dict with username, role, expiry if valid, None otherwise
224
+ """
225
+ try:
226
+ import base64
227
+ decoded = base64.urlsafe_b64decode(token.encode()).decode()
228
+
229
+ parts = decoded.rsplit('.', 1)
230
+ if len(parts) != 2:
231
+ return None
232
+
233
+ payload, signature = parts
234
+
235
+ # Verify signature
236
+ expected_sig = hmac.new(
237
+ AUTH_SECRET_KEY.encode(),
238
+ payload.encode(),
239
+ hashlib.sha256
240
+ ).hexdigest()
241
+
242
+ if not hmac.compare_digest(signature, expected_sig):
243
+ return None
244
+
245
+ # Parse payload
246
+ username, role, expiry = payload.split(':')
247
+ expiry = int(expiry)
248
+
249
+ # Check expiry
250
+ if time.time() > expiry:
251
+ return None
252
+
253
+ return {
254
+ 'username': username,
255
+ 'role': role,
256
+ 'expiry': expiry
257
+ }
258
+
259
+ except Exception:
260
+ return None
261
+
262
+
263
+ def generate_proxy_headers(username: str, role: str = 'admin') -> dict:
264
+ """
265
+ Generate headers to be passed to backend services.
266
+
267
+ These headers allow backend services to trust the proxy's authentication.
268
+ """
269
+ token, expiry = generate_auth_token(username, role)
270
+ timestamp = str(int(time.time()))
271
+
272
+ return {
273
+ AUTH_HEADER_USER: username,
274
+ AUTH_HEADER_ROLE: role,
275
+ AUTH_HEADER_TOKEN: token,
276
+ AUTH_HEADER_TIMESTAMP: timestamp,
277
+ }
278
+
279
+
280
+ # =============================================================================
281
+ # USER MANAGEMENT
282
+ # =============================================================================
283
+
284
+ def get_users_file() -> Path:
285
+ """Get path to users file."""
286
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
287
+ return DATA_DIR / 'users.json'
288
+
289
+
290
+ def load_users() -> dict:
291
+ """Load users from file."""
292
+ users_file = get_users_file()
293
+ if users_file.exists():
294
+ try:
295
+ return json.loads(users_file.read_text())
296
+ except Exception:
297
+ pass
298
+ return {}
299
+
300
+
301
+ def save_users(users: dict):
302
+ """Save users to file."""
303
+ users_file = get_users_file()
304
+ users_file.write_text(json.dumps(users, indent=2))
305
+ os.chmod(users_file, 0o600)
306
+
307
+
308
+ def create_user(username: str, password: str, role: str = 'readonly',
309
+ enabled: bool = True, require_password_change: bool = False) -> bool:
310
+ """Create a new user."""
311
+ users = load_users()
312
+
313
+ if username in users:
314
+ return False
315
+
316
+ if role not in VALID_ROLES:
317
+ role = 'readonly'
318
+
319
+ users[username] = {
320
+ 'password_hash': generate_password_hash(password),
321
+ 'role': role,
322
+ 'enabled': enabled,
323
+ 'require_password_change': require_password_change,
324
+ 'created_at': datetime.utcnow().isoformat(),
325
+ 'last_login': None,
326
+ 'login_attempts': 0,
327
+ 'locked_until': None,
328
+ }
329
+
330
+ save_users(users)
331
+ return True
332
+
333
+
334
+ def get_user(username: str) -> Optional[dict]:
335
+ """Get user by username."""
336
+ users = load_users()
337
+ user = users.get(username)
338
+ if user:
339
+ return {'username': username, **user}
340
+ return None
341
+
342
+
343
+ def list_users() -> List[dict]:
344
+ """List all users (without password hashes)."""
345
+ users = load_users()
346
+ result = []
347
+ for username, data in users.items():
348
+ result.append({
349
+ 'username': username,
350
+ 'role': data.get('role', 'readonly'),
351
+ 'enabled': data.get('enabled', True),
352
+ 'created_at': data.get('created_at'),
353
+ 'last_login': data.get('last_login'),
354
+ 'require_password_change': data.get('require_password_change', False),
355
+ })
356
+ return result
357
+
358
+
359
+ def update_user(username: str, role: str = None, enabled: bool = None,
360
+ require_password_change: bool = None) -> bool:
361
+ """Update user properties."""
362
+ users = load_users()
363
+
364
+ if username not in users:
365
+ return False
366
+
367
+ if role is not None and role in VALID_ROLES:
368
+ users[username]['role'] = role
369
+
370
+ if enabled is not None:
371
+ users[username]['enabled'] = enabled
372
+
373
+ if require_password_change is not None:
374
+ users[username]['require_password_change'] = require_password_change
375
+
376
+ save_users(users)
377
+ return True
378
+
379
+
380
+ def delete_user(username: str) -> bool:
381
+ """Delete a user."""
382
+ users = load_users()
383
+
384
+ if username not in users:
385
+ return False
386
+
387
+ # Prevent deleting last admin
388
+ admins = [u for u, d in users.items() if d.get('role') == 'admin' and d.get('enabled', True)]
389
+ if len(admins) <= 1 and username in admins:
390
+ return False
391
+
392
+ del users[username]
393
+ save_users(users)
394
+ return True
395
+
396
+
397
+ def verify_user(username: str, password: str) -> Optional[dict]:
398
+ """Verify user credentials."""
399
+ users = load_users()
400
+
401
+ user = users.get(username)
402
+ if not user:
403
+ return None
404
+
405
+ # Check if user is enabled
406
+ if not user.get('enabled', True):
407
+ return None
408
+
409
+ # Check if locked out
410
+ locked_until = user.get('locked_until')
411
+ if locked_until:
412
+ if datetime.fromisoformat(locked_until) > datetime.utcnow():
413
+ return None
414
+ else:
415
+ # Lockout expired, reset
416
+ users[username]['locked_until'] = None
417
+ users[username]['login_attempts'] = 0
418
+
419
+ if check_password_hash(user['password_hash'], password):
420
+ # Successful login - update stats
421
+ users[username]['last_login'] = datetime.utcnow().isoformat()
422
+ users[username]['login_attempts'] = 0
423
+ users[username]['locked_until'] = None
424
+ save_users(users)
425
+
426
+ return {
427
+ 'username': username,
428
+ 'role': user.get('role', 'readonly'),
429
+ 'require_password_change': user.get('require_password_change', False),
430
+ }
431
+ else:
432
+ # Failed login - increment attempts
433
+ attempts = user.get('login_attempts', 0) + 1
434
+ users[username]['login_attempts'] = attempts
435
+
436
+ max_attempts = get_setting('max_login_attempts', 5)
437
+ if attempts >= max_attempts:
438
+ lockout_mins = get_setting('lockout_duration_minutes', 15)
439
+ users[username]['locked_until'] = (
440
+ datetime.utcnow() + timedelta(minutes=lockout_mins)
441
+ ).isoformat()
442
+
443
+ save_users(users)
444
+ return None
445
+
446
+
447
+ def change_password(username: str, old_password: str, new_password: str) -> bool:
448
+ """Change user password."""
449
+ users = load_users()
450
+
451
+ user = users.get(username)
452
+ if not user:
453
+ return False
454
+
455
+ if not check_password_hash(user['password_hash'], old_password):
456
+ return False
457
+
458
+ users[username]['password_hash'] = generate_password_hash(new_password)
459
+ users[username]['require_password_change'] = False
460
+ save_users(users)
461
+ return True
462
+
463
+
464
+ def admin_set_password(username: str, new_password: str) -> bool:
465
+ """Admin reset password (no old password required)."""
466
+ users = load_users()
467
+
468
+ if username not in users:
469
+ return False
470
+
471
+ users[username]['password_hash'] = generate_password_hash(new_password)
472
+ users[username]['require_password_change'] = True
473
+ save_users(users)
474
+ return True
475
+
476
+
477
+ def user_exists() -> bool:
478
+ """Check if any user exists."""
479
+ users = load_users()
480
+ return len(users) > 0
481
+
482
+
483
+ def get_admin_count() -> int:
484
+ """Count enabled admin users."""
485
+ users = load_users()
486
+ return len([u for u, d in users.items()
487
+ if d.get('role') == 'admin' and d.get('enabled', True)])
488
+
489
+
490
+ # =============================================================================
491
+ # PERMISSION HELPERS
492
+ # =============================================================================
493
+
494
+ def has_permission(user_role: str, required_role: str) -> bool:
495
+ """Check if user role has sufficient permissions."""
496
+ user_level = ROLE_HIERARCHY.get(user_role, 0)
497
+ required_level = ROLE_HIERARCHY.get(required_role, 0)
498
+ return user_level >= required_level
499
+
500
+
501
+ def can_admin(role: str) -> bool:
502
+ """Check if role can perform admin actions."""
503
+ return has_permission(role, 'admin')
504
+
505
+
506
+ def can_write(role: str) -> bool:
507
+ """Check if role can perform write actions."""
508
+ return has_permission(role, 'readwrite')
509
+
510
+
511
+ def can_read(role: str) -> bool:
512
+ """Check if role can perform read actions."""
513
+ return has_permission(role, 'readonly')
514
+
515
+
516
+ # =============================================================================
517
+ # BACKEND SERVICE INTEGRATION
518
+ # =============================================================================
519
+
520
+ def verify_proxy_auth(headers: dict) -> Optional[dict]:
521
+ """
522
+ Verify authentication headers from the proxy.
523
+
524
+ Called by backend services (IPMI Monitor, DC Overview) to verify
525
+ that a request came through an authenticated proxy session.
526
+
527
+ Args:
528
+ headers: Request headers dict
529
+
530
+ Returns:
531
+ dict with username, role if valid, None otherwise
532
+ """
533
+ token = headers.get(AUTH_HEADER_TOKEN)
534
+ username = headers.get(AUTH_HEADER_USER)
535
+ role = headers.get(AUTH_HEADER_ROLE)
536
+
537
+ if not token or not username:
538
+ return None
539
+
540
+ # Verify token
541
+ token_data = verify_auth_token(token)
542
+ if not token_data:
543
+ return None
544
+
545
+ # Verify username matches
546
+ if token_data['username'] != username:
547
+ return None
548
+
549
+ return {
550
+ 'username': username,
551
+ 'role': role or token_data.get('role', 'readonly'),
552
+ 'authenticated_via': 'fleet_proxy'
553
+ }
554
+
555
+
556
+ # =============================================================================
557
+ # FLASK INTEGRATION HELPERS
558
+ # =============================================================================
559
+
560
+ def create_flask_auth_app():
561
+ """
562
+ Create a Flask app for the proxy authentication API.
563
+
564
+ This runs alongside nginx to handle login/logout/session management.
565
+ """
566
+ from flask import Flask, request, jsonify, session, redirect, render_template_string
567
+
568
+ app = Flask(__name__)
569
+ app.secret_key = AUTH_SECRET_KEY
570
+
571
+ # Session cookie settings
572
+ app.config['SESSION_COOKIE_NAME'] = 'fleet_session'
573
+ app.config['SESSION_COOKIE_HTTPONLY'] = True
574
+ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
575
+ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=AUTH_TOKEN_EXPIRY_HOURS)
576
+
577
+ # =========================================================================
578
+ # TEMPLATES
579
+ # =========================================================================
580
+
581
+ BASE_STYLE = '''
582
+ :root {
583
+ --bg-primary: #0a0a0f;
584
+ --bg-secondary: #12121a;
585
+ --bg-card: #1a1a24;
586
+ --text-primary: #f0f0f0;
587
+ --text-secondary: #888;
588
+ --accent-cyan: #00d4ff;
589
+ --accent-green: #4ade80;
590
+ --accent-yellow: #fbbf24;
591
+ --accent-red: #ef4444;
592
+ --border-color: #2a2a3a;
593
+ }
594
+ * { margin: 0; padding: 0; box-sizing: border-box; }
595
+ body {
596
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
597
+ background: var(--bg-primary);
598
+ color: var(--text-primary);
599
+ min-height: 100vh;
600
+ }
601
+ .container { max-width: 800px; margin: 0 auto; padding: 20px; }
602
+ .card {
603
+ background: var(--bg-card);
604
+ border: 1px solid var(--border-color);
605
+ border-radius: 16px;
606
+ padding: 30px;
607
+ margin-bottom: 20px;
608
+ }
609
+ .card h2 { margin-bottom: 20px; }
610
+ .form-group { margin-bottom: 20px; }
611
+ .form-group label { display: block; margin-bottom: 8px; color: var(--text-secondary); }
612
+ .form-group input, .form-group select {
613
+ width: 100%;
614
+ padding: 12px 16px;
615
+ background: var(--bg-primary);
616
+ border: 1px solid var(--border-color);
617
+ border-radius: 8px;
618
+ color: var(--text-primary);
619
+ font-size: 1rem;
620
+ }
621
+ .form-group input:focus, .form-group select:focus {
622
+ outline: none;
623
+ border-color: var(--accent-cyan);
624
+ }
625
+ .btn {
626
+ padding: 12px 24px;
627
+ background: linear-gradient(135deg, var(--accent-cyan), #0099cc);
628
+ color: #000;
629
+ border: none;
630
+ border-radius: 8px;
631
+ font-size: 1rem;
632
+ font-weight: 600;
633
+ cursor: pointer;
634
+ transition: transform 0.2s;
635
+ text-decoration: none;
636
+ display: inline-block;
637
+ }
638
+ .btn:hover { transform: scale(1.02); }
639
+ .btn-secondary {
640
+ background: var(--bg-secondary);
641
+ color: var(--text-primary);
642
+ border: 1px solid var(--border-color);
643
+ }
644
+ .btn-danger { background: var(--accent-red); color: #fff; }
645
+ .btn-sm { padding: 8px 16px; font-size: 0.85rem; }
646
+ .error {
647
+ background: rgba(239, 68, 68, 0.1);
648
+ border: 1px solid var(--accent-red);
649
+ color: var(--accent-red);
650
+ padding: 12px;
651
+ border-radius: 8px;
652
+ margin-bottom: 20px;
653
+ }
654
+ .success {
655
+ background: rgba(74, 222, 128, 0.1);
656
+ border: 1px solid var(--accent-green);
657
+ color: var(--accent-green);
658
+ padding: 12px;
659
+ border-radius: 8px;
660
+ margin-bottom: 20px;
661
+ }
662
+ .setup-notice {
663
+ background: rgba(0, 212, 255, 0.1);
664
+ border: 1px solid var(--accent-cyan);
665
+ color: var(--accent-cyan);
666
+ padding: 12px;
667
+ border-radius: 8px;
668
+ margin-bottom: 20px;
669
+ text-align: center;
670
+ }
671
+ table { width: 100%; border-collapse: collapse; }
672
+ th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border-color); }
673
+ th { color: var(--text-secondary); font-weight: 500; }
674
+ .badge {
675
+ padding: 4px 10px;
676
+ border-radius: 12px;
677
+ font-size: 0.75rem;
678
+ font-weight: 500;
679
+ }
680
+ .badge-admin { background: var(--accent-cyan); color: #000; }
681
+ .badge-readwrite { background: var(--accent-green); color: #000; }
682
+ .badge-readonly { background: var(--accent-yellow); color: #000; }
683
+ .badge-disabled { background: var(--text-secondary); color: #000; }
684
+ .nav {
685
+ display: flex;
686
+ gap: 15px;
687
+ margin-bottom: 30px;
688
+ padding-bottom: 15px;
689
+ border-bottom: 1px solid var(--border-color);
690
+ }
691
+ .nav a {
692
+ color: var(--text-secondary);
693
+ text-decoration: none;
694
+ padding: 8px 16px;
695
+ border-radius: 8px;
696
+ }
697
+ .nav a:hover, .nav a.active {
698
+ color: var(--text-primary);
699
+ background: var(--bg-secondary);
700
+ }
701
+ .toggle {
702
+ position: relative;
703
+ display: inline-block;
704
+ width: 50px;
705
+ height: 26px;
706
+ }
707
+ .toggle input { opacity: 0; width: 0; height: 0; }
708
+ .toggle-slider {
709
+ position: absolute;
710
+ cursor: pointer;
711
+ top: 0; left: 0; right: 0; bottom: 0;
712
+ background: var(--bg-secondary);
713
+ border: 1px solid var(--border-color);
714
+ border-radius: 26px;
715
+ transition: 0.3s;
716
+ }
717
+ .toggle-slider:before {
718
+ position: absolute;
719
+ content: "";
720
+ height: 20px;
721
+ width: 20px;
722
+ left: 2px;
723
+ bottom: 2px;
724
+ background: var(--text-secondary);
725
+ border-radius: 50%;
726
+ transition: 0.3s;
727
+ }
728
+ .toggle input:checked + .toggle-slider { background: var(--accent-cyan); }
729
+ .toggle input:checked + .toggle-slider:before {
730
+ transform: translateX(24px);
731
+ background: #000;
732
+ }
733
+ '''
734
+
735
+ LOGIN_TEMPLATE = '''
736
+ <!DOCTYPE html>
737
+ <html lang="en">
738
+ <head>
739
+ <meta charset="UTF-8">
740
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
741
+ <title>Fleet Management | Login</title>
742
+ <style>''' + BASE_STYLE + '''
743
+ body {
744
+ display: flex;
745
+ align-items: center;
746
+ justify-content: center;
747
+ }
748
+ .login-container {
749
+ width: 100%;
750
+ max-width: 400px;
751
+ margin: 20px;
752
+ }
753
+ .logo { text-align: center; margin-bottom: 15px; }
754
+ .logo svg { width: 80px; height: 80px; filter: drop-shadow(0 0 10px rgba(79, 195, 247, 0.4)); }
755
+ h1 {
756
+ text-align: center;
757
+ background: linear-gradient(135deg, var(--accent-cyan), #00ff88);
758
+ -webkit-background-clip: text;
759
+ -webkit-text-fill-color: transparent;
760
+ margin-bottom: 10px;
761
+ }
762
+ .subtitle { text-align: center; color: var(--text-secondary); margin-bottom: 30px; }
763
+ </style>
764
+ </head>
765
+ <body>
766
+ <div class="login-container">
767
+ <div class="card">
768
+ <div class="logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M52 24c0-8.8-7.2-16-16-16-6.6 0-12.3 4-14.7 9.7C19.9 17.3 18.5 17 17 17c-5.5 0-10 4.5-10 10 0 .3 0 .7.1 1C4.1 29.4 2 32.5 2 36c0 5 4 9 9 9h7v-3H11c-3.3 0-6-2.7-6-6 0-2.6 1.7-4.9 4.1-5.7l1.4-.5-.2-1.5c0-.4-.1-.9-.1-1.3 0-3.9 3.1-7 7-7 1.3 0 2.5.4 3.5 1l1.5 1 .6-1.7C24.5 15.4 29.8 11 36 11c7.2 0 13 5.8 13 13v2h2c4.4 0 8 3.6 8 8s-3.6 8-8 8h-5v3h5c6.1 0 11-4.9 11-11 0-5.5-4.1-10.1-9.4-10.9L52 24z" fill="#4FC3F7"/><rect x="18" y="36" width="28" height="8" rx="2" fill="#4FC3F7"/><circle cx="23" cy="40" r="2" fill="#4CAF50"/><circle cx="29" cy="40" r="2" fill="#FFC107"/><rect x="38" y="38" width="5" height="4" rx="1" fill="#fff" opacity="0.5"/><rect x="18" y="46" width="28" height="8" rx="2" fill="#29B6F6"/><circle cx="23" cy="50" r="2" fill="#4CAF50"/><circle cx="29" cy="50" r="2" fill="#FFC107"/><rect x="38" y="48" width="5" height="4" rx="1" fill="#fff" opacity="0.5"/><rect x="30" y="54" width="4" height="6" fill="#29B6F6"/><rect x="24" y="58" width="16" height="4" rx="1" fill="#29B6F6"/></svg></div>
769
+ <h1>Fleet Management</h1>
770
+ <p class="subtitle">CryptoLabs Infrastructure Dashboard</p>
771
+
772
+ {% if first_run %}
773
+ <div class="setup-notice">
774
+ Welcome! Create your admin account to get started.
775
+ </div>
776
+ {% endif %}
777
+
778
+ {% if error %}
779
+ <div class="error">{{ error }}</div>
780
+ {% endif %}
781
+
782
+ <form method="POST">
783
+ <div class="form-group">
784
+ <label>Username</label>
785
+ <input type="text" name="username" value="{{ username or 'admin' }}" required autofocus>
786
+ </div>
787
+ <div class="form-group">
788
+ <label>{% if first_run %}Create Password{% else %}Password{% endif %}</label>
789
+ <input type="password" name="password" required>
790
+ </div>
791
+ {% if first_run %}
792
+ <div class="form-group">
793
+ <label>Confirm Password</label>
794
+ <input type="password" name="confirm_password" required>
795
+ </div>
796
+ {% endif %}
797
+ <button type="submit" class="btn" style="width: 100%;">
798
+ {% if first_run %}Create Account{% else %}Login{% endif %}
799
+ </button>
800
+ </form>
801
+ </div>
802
+ </div>
803
+ </body>
804
+ </html>
805
+ '''
806
+
807
+ USERS_TEMPLATE = '''
808
+ <!DOCTYPE html>
809
+ <html lang="en">
810
+ <head>
811
+ <meta charset="UTF-8">
812
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
813
+ <title>Fleet Management | Users</title>
814
+ <style>''' + BASE_STYLE + '''</style>
815
+ </head>
816
+ <body>
817
+ <div class="container">
818
+ <div class="nav">
819
+ <a href="/"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"><path d="M52 24c0-8.8-7.2-16-16-16-6.6 0-12.3 4-14.7 9.7C19.9 17.3 18.5 17 17 17c-5.5 0-10 4.5-10 10 0 .3 0 .7.1 1C4.1 29.4 2 32.5 2 36c0 5 4 9 9 9h7v-3H11c-3.3 0-6-2.7-6-6 0-2.6 1.7-4.9 4.1-5.7l1.4-.5-.2-1.5c0-.4-.1-.9-.1-1.3 0-3.9 3.1-7 7-7 1.3 0 2.5.4 3.5 1l1.5 1 .6-1.7C24.5 15.4 29.8 11 36 11c7.2 0 13 5.8 13 13v2h2c4.4 0 8 3.6 8 8s-3.6 8-8 8h-5v3h5c6.1 0 11-4.9 11-11 0-5.5-4.1-10.1-9.4-10.9L52 24z" fill="#4FC3F7"/><rect x="18" y="36" width="28" height="8" rx="2" fill="#4FC3F7"/><rect x="18" y="46" width="28" height="8" rx="2" fill="#29B6F6"/></svg> Dashboard</a>
820
+ <a href="/auth/users" class="active">👥 Users</a>
821
+ <a href="/auth/settings">⚙️ Settings</a>
822
+ <a href="/auth/logout">Logout</a>
823
+ </div>
824
+
825
+ <h1 style="margin-bottom: 30px;">User Management</h1>
826
+
827
+ {% if error %}
828
+ <div class="error">{{ error }}</div>
829
+ {% endif %}
830
+
831
+ {% if success %}
832
+ <div class="success">{{ success }}</div>
833
+ {% endif %}
834
+
835
+ <div class="card">
836
+ <h2>Add User</h2>
837
+ <form method="POST" action="/auth/users/create">
838
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 15px; align-items: end;">
839
+ <div class="form-group" style="margin: 0;">
840
+ <label>Username</label>
841
+ <input type="text" name="username" required>
842
+ </div>
843
+ <div class="form-group" style="margin: 0;">
844
+ <label>Password</label>
845
+ <input type="password" name="password" required>
846
+ </div>
847
+ <div class="form-group" style="margin: 0;">
848
+ <label>Role</label>
849
+ <select name="role">
850
+ <option value="readonly">Read Only</option>
851
+ <option value="readwrite">Read/Write</option>
852
+ <option value="admin">Admin</option>
853
+ </select>
854
+ </div>
855
+ <button type="submit" class="btn">Add User</button>
856
+ </div>
857
+ </form>
858
+ </div>
859
+
860
+ <div class="card">
861
+ <h2>Users ({{ users|length }})</h2>
862
+ <table>
863
+ <thead>
864
+ <tr>
865
+ <th>Username</th>
866
+ <th>Role</th>
867
+ <th>Status</th>
868
+ <th>Last Login</th>
869
+ <th>Actions</th>
870
+ </tr>
871
+ </thead>
872
+ <tbody>
873
+ {% for user in users %}
874
+ <tr>
875
+ <td><strong>{{ user.username }}</strong>
876
+ {% if user.require_password_change %}
877
+ <span style="color: var(--accent-yellow); font-size: 0.8rem;" title="Must change password on next login">🔑</span>
878
+ {% endif %}
879
+ </td>
880
+ <td>
881
+ {% if user.username != current_user %}
882
+ <form method="POST" action="/auth/users/{{ user.username }}/role" style="display: inline;">
883
+ <select name="role" onchange="this.form.submit()" style="background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 4px; padding: 2px 6px; font-size: 0.85rem; cursor: pointer;">
884
+ <option value="readonly" {{ 'selected' if user.role == 'readonly' }}>readonly</option>
885
+ <option value="readwrite" {{ 'selected' if user.role == 'readwrite' }}>readwrite</option>
886
+ <option value="admin" {{ 'selected' if user.role == 'admin' }}>admin</option>
887
+ </select>
888
+ </form>
889
+ {% else %}
890
+ <span class="badge badge-{{ user.role }}">{{ user.role }}</span>
891
+ {% endif %}
892
+ </td>
893
+ <td>
894
+ {% if user.enabled %}
895
+ <span style="color: var(--accent-green);">● Enabled</span>
896
+ {% else %}
897
+ <span style="color: var(--text-secondary);">○ Disabled</span>
898
+ {% endif %}
899
+ </td>
900
+ <td>{{ user.last_login[:10] if user.last_login else '—' }}</td>
901
+ <td>
902
+ {% if user.username != current_user %}
903
+ <button type="button" class="btn btn-secondary btn-sm" onclick="toggleResetForm('{{ user.username }}')" title="Reset Password">🔑 Reset</button>
904
+ <form method="POST" action="/auth/users/{{ user.username }}/toggle" style="display: inline;">
905
+ <button type="submit" class="btn btn-secondary btn-sm">
906
+ {{ 'Disable' if user.enabled else 'Enable' }}
907
+ </button>
908
+ </form>
909
+ <form method="POST" action="/auth/users/{{ user.username }}/delete" style="display: inline;"
910
+ onsubmit="return confirm('Delete user {{ user.username }}?')">
911
+ <button type="submit" class="btn btn-danger btn-sm">Delete</button>
912
+ </form>
913
+ {% else %}
914
+ <span style="color: var(--text-secondary);">(current user)</span>
915
+ {% endif %}
916
+ </td>
917
+ </tr>
918
+ <tr id="reset-row-{{ user.username }}" style="display: none;">
919
+ <td colspan="5" style="background: var(--bg-secondary); border-top: none;">
920
+ <form method="POST" action="/auth/users/{{ user.username }}/reset-password" style="display: flex; align-items: center; gap: 10px; padding: 5px 0;">
921
+ <label style="white-space: nowrap; font-size: 0.9rem;">New password for <strong>{{ user.username }}</strong>:</label>
922
+ <input type="password" name="new_password" required minlength="4" placeholder="Min 4 characters" style="flex: 1; max-width: 250px; padding: 6px 10px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-primary);">
923
+ <button type="submit" class="btn btn-sm">Set &amp; Force Change</button>
924
+ <button type="button" class="btn btn-secondary btn-sm" onclick="toggleResetForm('{{ user.username }}')">Cancel</button>
925
+ </form>
926
+ </td>
927
+ </tr>
928
+ {% endfor %}
929
+ </tbody>
930
+ </table>
931
+ </div>
932
+ </div>
933
+ <script>
934
+ function toggleResetForm(username) {
935
+ var row = document.getElementById('reset-row-' + username);
936
+ if (row.style.display === 'none') {
937
+ // Hide all other open reset forms first
938
+ document.querySelectorAll('[id^="reset-row-"]').forEach(function(r) { r.style.display = 'none'; });
939
+ row.style.display = 'table-row';
940
+ row.querySelector('input[type="password"]').focus();
941
+ } else {
942
+ row.style.display = 'none';
943
+ }
944
+ }
945
+ </script>
946
+ </body>
947
+ </html>
948
+ '''
949
+
950
+ SETTINGS_TEMPLATE = '''
951
+ <!DOCTYPE html>
952
+ <html lang="en">
953
+ <head>
954
+ <meta charset="UTF-8">
955
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
956
+ <title>Fleet Management | Settings</title>
957
+ <style>''' + BASE_STYLE + '''</style>
958
+ </head>
959
+ <body>
960
+ <div class="container">
961
+ <div class="nav">
962
+ <a href="/"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" style="width:16px;height:16px;vertical-align:middle;margin-right:4px;"><path d="M52 24c0-8.8-7.2-16-16-16-6.6 0-12.3 4-14.7 9.7C19.9 17.3 18.5 17 17 17c-5.5 0-10 4.5-10 10 0 .3 0 .7.1 1C4.1 29.4 2 32.5 2 36c0 5 4 9 9 9h7v-3H11c-3.3 0-6-2.7-6-6 0-2.6 1.7-4.9 4.1-5.7l1.4-.5-.2-1.5c0-.4-.1-.9-.1-1.3 0-3.9 3.1-7 7-7 1.3 0 2.5.4 3.5 1l1.5 1 .6-1.7C24.5 15.4 29.8 11 36 11c7.2 0 13 5.8 13 13v2h2c4.4 0 8 3.6 8 8s-3.6 8-8 8h-5v3h5c6.1 0 11-4.9 11-11 0-5.5-4.1-10.1-9.4-10.9L52 24z" fill="#4FC3F7"/><rect x="18" y="36" width="28" height="8" rx="2" fill="#4FC3F7"/><rect x="18" y="46" width="28" height="8" rx="2" fill="#29B6F6"/></svg> Dashboard</a>
963
+ <a href="/auth/users">👥 Users</a>
964
+ <a href="/auth/settings" class="active">⚙️ Settings</a>
965
+ <a href="/auth/logout">Logout</a>
966
+ </div>
967
+
968
+ <h1 style="margin-bottom: 30px;">Security Settings</h1>
969
+
970
+ {% if success %}
971
+ <div class="success">{{ success }}</div>
972
+ {% endif %}
973
+
974
+ <div class="card">
975
+ <h2>Access Control</h2>
976
+ <form method="POST">
977
+ <div class="form-group">
978
+ <div style="display: flex; justify-content: space-between; align-items: center;">
979
+ <div>
980
+ <strong>Allow Anonymous Access</strong>
981
+ <p style="color: var(--text-secondary); margin-top: 5px; font-size: 0.9rem;">
982
+ When enabled, unauthenticated users can access the dashboard with read-only permissions.
983
+ <br><strong style="color: var(--accent-yellow);">⚠️ Not recommended for production.</strong>
984
+ </p>
985
+ </div>
986
+ <label class="toggle">
987
+ <input type="checkbox" name="allow_anonymous" {{ 'checked' if settings.allow_anonymous }}>
988
+ <span class="toggle-slider"></span>
989
+ </label>
990
+ </div>
991
+ </div>
992
+
993
+ <div class="form-group">
994
+ <label>Anonymous User Role (if enabled)</label>
995
+ <select name="anonymous_role">
996
+ <option value="readonly" {{ 'selected' if settings.anonymous_role == 'readonly' }}>Read Only</option>
997
+ </select>
998
+ </div>
999
+
1000
+ <hr style="border: none; border-top: 1px solid var(--border-color); margin: 25px 0;">
1001
+
1002
+ <div class="form-group">
1003
+ <label>Session Timeout (hours)</label>
1004
+ <input type="number" name="session_timeout_hours" value="{{ settings.session_timeout_hours }}" min="1" max="720">
1005
+ </div>
1006
+
1007
+ <div class="form-group">
1008
+ <label>Max Login Attempts Before Lockout</label>
1009
+ <input type="number" name="max_login_attempts" value="{{ settings.max_login_attempts }}" min="3" max="20">
1010
+ </div>
1011
+
1012
+ <div class="form-group">
1013
+ <label>Lockout Duration (minutes)</label>
1014
+ <input type="number" name="lockout_duration_minutes" value="{{ settings.lockout_duration_minutes }}" min="5" max="60">
1015
+ </div>
1016
+
1017
+ <button type="submit" class="btn">Save Settings</button>
1018
+ </form>
1019
+ </div>
1020
+
1021
+ <div class="card">
1022
+ <h2>Role Permissions</h2>
1023
+ <table>
1024
+ <thead>
1025
+ <tr>
1026
+ <th>Role</th>
1027
+ <th>View Dashboard</th>
1028
+ <th>Modify Data</th>
1029
+ <th>Manage Users</th>
1030
+ <th>System Settings</th>
1031
+ </tr>
1032
+ </thead>
1033
+ <tbody>
1034
+ <tr>
1035
+ <td><span class="badge badge-admin">admin</span></td>
1036
+ <td>✅</td><td>✅</td><td>✅</td><td>✅</td>
1037
+ </tr>
1038
+ <tr>
1039
+ <td><span class="badge badge-readwrite">readwrite</span></td>
1040
+ <td>✅</td><td>✅</td><td>❌</td><td>❌</td>
1041
+ </tr>
1042
+ <tr>
1043
+ <td><span class="badge badge-readonly">readonly</span></td>
1044
+ <td>✅</td><td>❌</td><td>❌</td><td>❌</td>
1045
+ </tr>
1046
+ <tr>
1047
+ <td><span class="badge badge-disabled">anonymous</span></td>
1048
+ <td>✅ (if enabled)</td><td>❌</td><td>❌</td><td>❌</td>
1049
+ </tr>
1050
+ </tbody>
1051
+ </table>
1052
+ </div>
1053
+ </div>
1054
+ </body>
1055
+ </html>
1056
+ '''
1057
+
1058
+ CHANGE_PASSWORD_TEMPLATE = '''
1059
+ <!DOCTYPE html>
1060
+ <html lang="en">
1061
+ <head>
1062
+ <meta charset="UTF-8">
1063
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1064
+ <title>Fleet Management | Change Password</title>
1065
+ <style>''' + BASE_STYLE + '''
1066
+ body { display: flex; align-items: center; justify-content: center; }
1067
+ .card { max-width: 400px; margin: 20px; }
1068
+ </style>
1069
+ </head>
1070
+ <body>
1071
+ <div class="card">
1072
+ <h2>Change Password</h2>
1073
+ <p style="color: var(--text-secondary); margin-bottom: 20px;">
1074
+ You are required to change your password before continuing.
1075
+ </p>
1076
+
1077
+ {% if error %}
1078
+ <div class="error">{{ error }}</div>
1079
+ {% endif %}
1080
+
1081
+ <form method="POST">
1082
+ <div class="form-group">
1083
+ <label>Current Password</label>
1084
+ <input type="password" name="current_password" required autofocus>
1085
+ </div>
1086
+ <div class="form-group">
1087
+ <label>New Password</label>
1088
+ <input type="password" name="new_password" required>
1089
+ </div>
1090
+ <div class="form-group">
1091
+ <label>Confirm New Password</label>
1092
+ <input type="password" name="confirm_password" required>
1093
+ </div>
1094
+ <button type="submit" class="btn" style="width: 100%;">Change Password</button>
1095
+ </form>
1096
+ </div>
1097
+ </body>
1098
+ </html>
1099
+ '''
1100
+
1101
+ # =========================================================================
1102
+ # DECORATORS
1103
+ # =========================================================================
1104
+
1105
+ def login_required_decorator(f):
1106
+ @wraps(f)
1107
+ def decorated(*args, **kwargs):
1108
+ if not session.get('logged_in'):
1109
+ return redirect('/auth/login')
1110
+ return f(*args, **kwargs)
1111
+ return decorated
1112
+
1113
+ def admin_required_decorator(f):
1114
+ @wraps(f)
1115
+ def decorated(*args, **kwargs):
1116
+ if not session.get('logged_in'):
1117
+ return redirect('/auth/login')
1118
+ if session.get('role') != 'admin':
1119
+ return jsonify({'error': 'Admin access required'}), 403
1120
+ return f(*args, **kwargs)
1121
+ return decorated
1122
+
1123
+ # =========================================================================
1124
+ # ROUTES
1125
+ # =========================================================================
1126
+
1127
+ @app.route('/auth/login', methods=['GET', 'POST'])
1128
+ def login():
1129
+ error = None
1130
+ first_run = not user_exists()
1131
+
1132
+ if request.method == 'POST':
1133
+ username = request.form.get('username', '').strip()
1134
+ password = request.form.get('password', '')
1135
+
1136
+ if first_run:
1137
+ # First run - create account
1138
+ confirm_password = request.form.get('confirm_password', '')
1139
+
1140
+ if len(password) < 4:
1141
+ error = 'Password must be at least 4 characters'
1142
+ elif password != confirm_password:
1143
+ error = 'Passwords do not match'
1144
+ else:
1145
+ create_user(username, password, 'admin')
1146
+ session['logged_in'] = True
1147
+ session['username'] = username
1148
+ session['role'] = 'admin'
1149
+ session.permanent = True
1150
+
1151
+ token, _ = generate_auth_token(username, 'admin')
1152
+ session['auth_token'] = token
1153
+
1154
+ return redirect('/')
1155
+ else:
1156
+ # Normal login
1157
+ user = verify_user(username, password)
1158
+ if user:
1159
+ session['logged_in'] = True
1160
+ session['username'] = user['username']
1161
+ session['role'] = user['role']
1162
+ session['require_password_change'] = user.get('require_password_change', False)
1163
+ session.permanent = True
1164
+
1165
+ token, _ = generate_auth_token(user['username'], user['role'])
1166
+ session['auth_token'] = token
1167
+
1168
+ # Redirect to password change if required
1169
+ if user.get('require_password_change'):
1170
+ return redirect('/auth/change-password')
1171
+
1172
+ return redirect(request.args.get('next', '/'))
1173
+ else:
1174
+ error = 'Invalid username or password'
1175
+
1176
+ return render_template_string(LOGIN_TEMPLATE,
1177
+ error=error,
1178
+ first_run=first_run,
1179
+ username=request.form.get('username', ''))
1180
+
1181
+ @app.route('/auth/logout')
1182
+ def logout():
1183
+ session.clear()
1184
+ return redirect('/auth/login')
1185
+
1186
+ @app.route('/auth/change-password', methods=['GET', 'POST'])
1187
+ @login_required_decorator
1188
+ def change_password_route():
1189
+ error = None
1190
+
1191
+ if request.method == 'POST':
1192
+ current = request.form.get('current_password', '')
1193
+ new_pass = request.form.get('new_password', '')
1194
+ confirm = request.form.get('confirm_password', '')
1195
+
1196
+ if len(new_pass) < 4:
1197
+ error = 'Password must be at least 4 characters'
1198
+ elif new_pass != confirm:
1199
+ error = 'Passwords do not match'
1200
+ elif change_password(session['username'], current, new_pass):
1201
+ session['require_password_change'] = False
1202
+ return redirect('/')
1203
+ else:
1204
+ error = 'Current password is incorrect'
1205
+
1206
+ return render_template_string(CHANGE_PASSWORD_TEMPLATE, error=error)
1207
+
1208
+ @app.route('/auth/users')
1209
+ @admin_required_decorator
1210
+ def users_list():
1211
+ users = list_users()
1212
+ return render_template_string(USERS_TEMPLATE,
1213
+ users=users,
1214
+ current_user=session.get('username'),
1215
+ error=request.args.get('error'),
1216
+ success=request.args.get('success'))
1217
+
1218
+ @app.route('/auth/users/create', methods=['POST'])
1219
+ @admin_required_decorator
1220
+ def users_create():
1221
+ username = request.form.get('username', '').strip()
1222
+ password = request.form.get('password', '')
1223
+ role = request.form.get('role', 'readonly')
1224
+
1225
+ if not username or not password:
1226
+ return redirect('/auth/users?error=Username+and+password+required')
1227
+
1228
+ if create_user(username, password, role, require_password_change=True):
1229
+ return redirect(f'/auth/users?success=User+{username}+created')
1230
+ else:
1231
+ return redirect('/auth/users?error=User+already+exists')
1232
+
1233
+ @app.route('/auth/users/<username>/toggle', methods=['POST'])
1234
+ @admin_required_decorator
1235
+ def users_toggle(username):
1236
+ user = get_user(username)
1237
+ if user:
1238
+ update_user(username, enabled=not user.get('enabled', True))
1239
+ return redirect('/auth/users?success=User+updated')
1240
+ return redirect('/auth/users?error=User+not+found')
1241
+
1242
+ @app.route('/auth/users/<username>/delete', methods=['POST'])
1243
+ @admin_required_decorator
1244
+ def users_delete(username):
1245
+ if delete_user(username):
1246
+ return redirect('/auth/users?success=User+deleted')
1247
+ return redirect('/auth/users?error=Cannot+delete+user')
1248
+
1249
+ @app.route('/auth/users/<username>/reset-password', methods=['POST'])
1250
+ @admin_required_decorator
1251
+ def users_reset_password(username):
1252
+ new_password = request.form.get('new_password', '').strip()
1253
+ if not new_password or len(new_password) < 4:
1254
+ return redirect('/auth/users?error=Password+must+be+at+least+4+characters')
1255
+ if admin_set_password(username, new_password):
1256
+ return redirect(f'/auth/users?success=Password+reset+for+{username}.+They+must+change+it+on+next+login.')
1257
+ return redirect('/auth/users?error=User+not+found')
1258
+
1259
+ @app.route('/auth/users/<username>/role', methods=['POST'])
1260
+ @admin_required_decorator
1261
+ def users_change_role(username):
1262
+ new_role = request.form.get('role', '')
1263
+ if new_role not in VALID_ROLES:
1264
+ return redirect('/auth/users?error=Invalid+role')
1265
+ if username == session.get('username'):
1266
+ return redirect('/auth/users?error=Cannot+change+your+own+role')
1267
+ if update_user(username, role=new_role):
1268
+ return redirect(f'/auth/users?success=Role+updated+to+{new_role}+for+{username}')
1269
+ return redirect('/auth/users?error=User+not+found')
1270
+
1271
+ @app.route('/auth/settings', methods=['GET', 'POST'])
1272
+ @admin_required_decorator
1273
+ def settings_page():
1274
+ success = None
1275
+
1276
+ if request.method == 'POST':
1277
+ settings = load_settings()
1278
+ settings['allow_anonymous'] = 'allow_anonymous' in request.form
1279
+ settings['anonymous_role'] = request.form.get('anonymous_role', 'readonly')
1280
+ settings['session_timeout_hours'] = int(request.form.get('session_timeout_hours', 24))
1281
+ settings['max_login_attempts'] = int(request.form.get('max_login_attempts', 5))
1282
+ settings['lockout_duration_minutes'] = int(request.form.get('lockout_duration_minutes', 15))
1283
+ save_settings(settings)
1284
+ success = 'Settings saved successfully'
1285
+
1286
+ return render_template_string(SETTINGS_TEMPLATE,
1287
+ settings=load_settings(),
1288
+ success=success)
1289
+
1290
+ @app.route('/auth/check')
1291
+ def check_auth():
1292
+ """API endpoint to check authentication status."""
1293
+ if session.get('logged_in'):
1294
+ return jsonify({
1295
+ 'authenticated': True,
1296
+ 'username': session.get('username'),
1297
+ 'role': session.get('role'),
1298
+ })
1299
+
1300
+ # Check if anonymous access is allowed
1301
+ if get_setting('allow_anonymous', False):
1302
+ return jsonify({
1303
+ 'authenticated': True,
1304
+ 'username': 'anonymous',
1305
+ 'role': get_setting('anonymous_role', 'readonly'),
1306
+ })
1307
+
1308
+ return jsonify({'authenticated': False}), 401
1309
+
1310
+ @app.route('/auth/token')
1311
+ def get_token():
1312
+ """Get current auth token for service integration."""
1313
+ if not session.get('logged_in'):
1314
+ # Check anonymous access
1315
+ if get_setting('allow_anonymous', False):
1316
+ token, _ = generate_auth_token('anonymous', get_setting('anonymous_role', 'readonly'))
1317
+ return jsonify({
1318
+ 'token': token,
1319
+ 'username': 'anonymous',
1320
+ 'role': get_setting('anonymous_role', 'readonly'),
1321
+ })
1322
+ return jsonify({'error': 'Not authenticated'}), 401
1323
+
1324
+ token = session.get('auth_token')
1325
+ if not token or not verify_auth_token(token):
1326
+ token, _ = generate_auth_token(
1327
+ session.get('username'),
1328
+ session.get('role', 'readonly')
1329
+ )
1330
+ session['auth_token'] = token
1331
+
1332
+ return jsonify({
1333
+ 'token': token,
1334
+ 'username': session.get('username'),
1335
+ 'role': session.get('role'),
1336
+ })
1337
+
1338
+ def _get_exporter_mgmt_token() -> str:
1339
+ """Get the exporter management API token.
1340
+
1341
+ Checks multiple sources (in order):
1342
+ 1. Local file written by exporter_manager (Fleet Management UI deploy)
1343
+ 2. Running exporter container env var (fleet_manager.py / dc-overview deploy)
1344
+
1345
+ Result is cached for 60s to avoid repeated docker inspect calls.
1346
+ """
1347
+ import time as _time
1348
+ now = _time.time()
1349
+ if not hasattr(_get_exporter_mgmt_token, '_cached'):
1350
+ _get_exporter_mgmt_token._cached = ''
1351
+ _get_exporter_mgmt_token._ts = 0
1352
+
1353
+ # Return cache if fresh (60s TTL) - cache empty string too to avoid repeated docker inspect
1354
+ if (now - _get_exporter_mgmt_token._ts) < 60:
1355
+ return _get_exporter_mgmt_token._cached
1356
+
1357
+ token = ''
1358
+
1359
+ # Source 1: local file (written by exporter_manager._generate_mgmt_token)
1360
+ token_file = DATA_DIR / 'exporter-mgmt-token'
1361
+ try:
1362
+ if token_file.exists():
1363
+ token = token_file.read_text().strip()
1364
+ except Exception:
1365
+ pass
1366
+
1367
+ # Source 2: read MGMT_TOKEN from a running exporter container
1368
+ if not token:
1369
+ for container in ('vastai-exporter', 'runpod-exporter'):
1370
+ try:
1371
+ import subprocess as _sp
1372
+ result = _sp.run(
1373
+ ["docker", "inspect", "--format",
1374
+ "{{range .Config.Env}}{{println .}}{{end}}", container],
1375
+ capture_output=True, text=True, timeout=5,
1376
+ )
1377
+ if result.returncode == 0:
1378
+ for line in result.stdout.strip().split('\n'):
1379
+ if line.startswith('MGMT_TOKEN='):
1380
+ token = line.split('=', 1)[1]
1381
+ # Persist it so future lookups hit source 1
1382
+ try:
1383
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
1384
+ token_file.write_text(token)
1385
+ except Exception:
1386
+ pass
1387
+ break
1388
+ if token:
1389
+ break
1390
+ except Exception:
1391
+ pass
1392
+
1393
+ _get_exporter_mgmt_token._cached = token
1394
+ _get_exporter_mgmt_token._ts = now
1395
+ return token
1396
+
1397
+ @app.route('/auth/headers')
1398
+ def get_headers():
1399
+ """Get auth headers for nginx subrequest.
1400
+
1401
+ Also includes X-Mgmt-Token for exporter management API proxying.
1402
+ Nginx captures this via auth_request_set and forwards it to
1403
+ the exporter containers.
1404
+ """
1405
+ # Check for logged in user first
1406
+ if session.get('logged_in'):
1407
+ # Check if password change required
1408
+ if session.get('require_password_change'):
1409
+ return '', 401
1410
+
1411
+ username = session.get('username')
1412
+ role = session.get('role', 'readonly')
1413
+ token = session.get('auth_token')
1414
+
1415
+ if not token or not verify_auth_token(token):
1416
+ token, _ = generate_auth_token(username, role)
1417
+ session['auth_token'] = token
1418
+
1419
+ response = app.make_response('')
1420
+ response.status_code = 200
1421
+ response.headers[AUTH_HEADER_USER] = username
1422
+ response.headers[AUTH_HEADER_ROLE] = role
1423
+ response.headers[AUTH_HEADER_TOKEN] = token
1424
+ # Include exporter management token for nginx to forward
1425
+ mgmt_token = _get_exporter_mgmt_token()
1426
+ if mgmt_token:
1427
+ response.headers['X-Mgmt-Token'] = mgmt_token
1428
+ return response
1429
+
1430
+ # Check if anonymous access is allowed
1431
+ if get_setting('allow_anonymous', False):
1432
+ role = get_setting('anonymous_role', 'readonly')
1433
+ token, _ = generate_auth_token('anonymous', role)
1434
+
1435
+ response = app.make_response('')
1436
+ response.status_code = 200
1437
+ response.headers[AUTH_HEADER_USER] = 'anonymous'
1438
+ response.headers[AUTH_HEADER_ROLE] = role
1439
+ response.headers[AUTH_HEADER_TOKEN] = token
1440
+ return response
1441
+
1442
+ return '', 401
1443
+
1444
+ # API endpoints for programmatic access
1445
+ @app.route('/auth/api/users', methods=['GET'])
1446
+ @admin_required_decorator
1447
+ def api_users_list():
1448
+ return jsonify(list_users())
1449
+
1450
+ @app.route('/auth/api/users', methods=['POST'])
1451
+ @admin_required_decorator
1452
+ def api_users_create():
1453
+ data = request.json
1454
+ if create_user(
1455
+ data.get('username'),
1456
+ data.get('password'),
1457
+ data.get('role', 'readonly'),
1458
+ require_password_change=data.get('require_password_change', True)
1459
+ ):
1460
+ return jsonify({'success': True})
1461
+ return jsonify({'error': 'User already exists'}), 400
1462
+
1463
+ @app.route('/auth/api/settings', methods=['GET'])
1464
+ @admin_required_decorator
1465
+ def api_settings_get():
1466
+ return jsonify(load_settings())
1467
+
1468
+ @app.route('/auth/api/settings', methods=['POST'])
1469
+ @admin_required_decorator
1470
+ def api_settings_set():
1471
+ settings = load_settings()
1472
+ settings.update(request.json)
1473
+ save_settings(settings)
1474
+ return jsonify({'success': True})
1475
+
1476
+ # =========================================================================
1477
+ # EXPORTER MANAGEMENT (Vast.ai / RunPod)
1478
+ # =========================================================================
1479
+
1480
+ @app.route('/auth/api/exporters', methods=['GET'])
1481
+ @login_required_decorator
1482
+ def api_exporters_status():
1483
+ """Get status of all optional exporters."""
1484
+ from cryptolabs_proxy.exporter_manager import get_exporter_status
1485
+ return jsonify(get_exporter_status())
1486
+
1487
+ @app.route('/auth/api/exporters/<name>/enable', methods=['POST'])
1488
+ @admin_required_decorator
1489
+ def api_exporter_enable(name):
1490
+ """Enable an exporter (vastai or runpod)."""
1491
+ from cryptolabs_proxy.exporter_manager import enable_exporter
1492
+ data = request.json or {}
1493
+ api_key = data.get('api_key', '').strip()
1494
+ if not api_key:
1495
+ return jsonify({'success': False, 'error': 'API key is required'}), 400
1496
+ result = enable_exporter(name, api_key)
1497
+ status = 200 if result.get('success') else 500
1498
+ return jsonify(result), status
1499
+
1500
+ @app.route('/auth/api/exporters/<name>/disable', methods=['POST'])
1501
+ @admin_required_decorator
1502
+ def api_exporter_disable(name):
1503
+ """Disable an exporter (vastai or runpod)."""
1504
+ from cryptolabs_proxy.exporter_manager import disable_exporter
1505
+ result = disable_exporter(name)
1506
+ return jsonify(result)
1507
+
1508
+ @app.route('/auth/api/exporters/<name>/restart', methods=['POST'])
1509
+ @admin_required_decorator
1510
+ def api_exporter_restart(name):
1511
+ """Restart an exporter container."""
1512
+ from cryptolabs_proxy.exporter_manager import restart_exporter
1513
+ result = restart_exporter(name)
1514
+ status = 200 if result.get('success') else 500
1515
+ return jsonify(result), status
1516
+
1517
+ # =========================================================================
1518
+ # EXPORTER MANAGEMENT PAGES
1519
+ # =========================================================================
1520
+
1521
+ EXPORTER_PAGE_CONFIG = {
1522
+ 'vastai': {
1523
+ 'display_name': 'Vast.ai Exporter',
1524
+ 'api_prefix': '/vastai-api',
1525
+ 'metrics_path': '/vastai-metrics/',
1526
+ 'grafana_path': '/grafana/d/vast-dashboard/vast-dashboard?orgId=1',
1527
+ 'key_placeholder': 'Your Vast.ai API Key',
1528
+ 'key_help': 'Find your API key at console.vast.ai &rarr; Account &rarr; API Keys',
1529
+ 'logo_html': '<img src="https://vast.ai/favicon.ico" alt="Vast.ai" style="width:28px;height:28px;border-radius:6px;" onerror="this.onerror=null;this.textContent=\'💎\';">',
1530
+ },
1531
+ 'runpod': {
1532
+ 'display_name': 'RunPod Exporter',
1533
+ 'api_prefix': '/runpod-api',
1534
+ 'metrics_path': '/runpod-metrics/',
1535
+ 'grafana_path': '/grafana/d/runpod-dashboard/runpod-dashboard?orgId=1',
1536
+ 'key_placeholder': 'rpa_XXXXXXXXXXXXX',
1537
+ 'key_help': 'Find your API key at runpod.io &rarr; Settings &rarr; API Keys',
1538
+ 'logo_html': '<img src="https://cdn.prod.website-files.com/67d20fb9f56ff2ec6a7a657d/683cd0ee11462aef4a016ef6_runpod%20lowercase.webp" alt="RunPod" style="width:28px;height:28px;border-radius:6px;object-fit:contain;" onerror="this.onerror=null;this.textContent=\'🚀\';">',
1539
+ },
1540
+ }
1541
+
1542
+ EXPORTER_PAGE_TEMPLATE = '''
1543
+ <!DOCTYPE html>
1544
+ <html lang="en">
1545
+ <head>
1546
+ <meta charset="UTF-8">
1547
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1548
+ <title>Fleet Management | {{ config.display_name }}</title>
1549
+ <style>''' + BASE_STYLE + '''
1550
+ .top-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; }
1551
+ .top-bar a.home-btn { color: var(--accent-blue); text-decoration: none; border: 1px solid var(--accent-blue); padding: 6px 16px; border-radius: 6px; font-size: 0.9rem; transition: background 0.2s; }
1552
+ .top-bar a.home-btn:hover { background: rgba(0, 180, 216, 0.15); }
1553
+ .top-bar .sep { color: var(--border-color); }
1554
+ .top-bar .title { font-size: 1.2rem; font-weight: 600; display: flex; align-items: center; gap: 8px; }
1555
+ .status-badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.8rem; font-weight: 500; }
1556
+ .status-running { background: rgba(76,175,80,0.2); color: var(--accent-green); }
1557
+ .status-stopped { background: rgba(244,67,54,0.2); color: #f44336; }
1558
+ .actions-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px; }
1559
+ .key-table { width: 100%; border-collapse: collapse; }
1560
+ .key-table th, .key-table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border-color); }
1561
+ .key-table th { color: var(--text-secondary); font-size: 0.85rem; font-weight: 500; }
1562
+ .key-masked { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 0.85rem; color: var(--text-secondary); background: var(--bg-secondary); padding: 2px 8px; border-radius: 4px; }
1563
+ .key-meta { font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px; }
1564
+ .add-key-form { display: flex; gap: 10px; align-items: end; margin-top: 15px; }
1565
+ .add-key-form input { flex: 1; padding: 8px 12px; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); font-size: 0.9rem; }
1566
+ .add-key-form input::placeholder { color: var(--text-secondary); }
1567
+ #status-msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: 0.9rem; display: none; }
1568
+ .msg-success { background: rgba(76,175,80,0.15); color: var(--accent-green); }
1569
+ .msg-error { background: rgba(244,67,54,0.15); color: #f44336; }
1570
+ .msg-info { background: rgba(33,150,243,0.15); color: #2196f3; }
1571
+ .btn-restart { background: rgba(255,152,0,0.15); color: #ff9800; border: 1px solid rgba(255,152,0,0.3); }
1572
+ .btn-restart:hover { background: rgba(255,152,0,0.25); }
1573
+ </style>
1574
+ </head>
1575
+ <body>
1576
+ <div class="container">
1577
+ <div class="top-bar">
1578
+ <a href="/" class="home-btn">Home</a>
1579
+ <span class="sep">|</span>
1580
+ <span class="title">{{ config.logo_html|safe }} {{ config.display_name }}</span>
1581
+ <span id="status-badge" class="status-badge status-stopped">checking...</span>
1582
+ </div>
1583
+
1584
+ <div class="actions-row">
1585
+ <a href="{{ config.grafana_path }}" class="btn">Open Grafana Dashboard</a>
1586
+ <a href="{{ config.metrics_path }}" class="btn btn-secondary">Raw Metrics</a>
1587
+ </div>
1588
+
1589
+ <div class="card">
1590
+ <h2>Service Status</h2>
1591
+ <div id="service-status">Loading...</div>
1592
+ <div style="margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap;">
1593
+ <button id="btn-enable" class="btn" style="display:none;" onclick="showEnableForm()">Enable Service</button>
1594
+ <button id="btn-restart" class="btn btn-restart" style="display:none;" onclick="restartService()">Restart Container</button>
1595
+ <button id="btn-disable" class="btn btn-danger" style="display:none;" onclick="disableService()">Disable &amp; Remove</button>
1596
+ </div>
1597
+ <div id="enable-form" style="display:none; margin-top:15px;">
1598
+ <form onsubmit="event.preventDefault(); enableService();" class="add-key-form">
1599
+ <input type="password" id="enable-key" placeholder="{{ config.key_placeholder }}" autocomplete="new-password">
1600
+ <button type="submit" class="btn">Enable</button>
1601
+ <button type="button" class="btn btn-secondary" onclick="hideEnableForm()">Cancel</button>
1602
+ </form>
1603
+ <div style="font-size:0.8rem; color:var(--text-secondary); margin-top:6px;">{{ config.key_help|safe }}</div>
1604
+ </div>
1605
+ <div id="status-msg"></div>
1606
+ </div>
1607
+
1608
+ <div class="card" id="accounts-card" style="display:none;">
1609
+ <h2>API Key Accounts</h2>
1610
+ <p style="color:var(--text-secondary); font-size:0.9rem; margin-bottom:15px;">
1611
+ Manage API key accounts for this exporter. Multiple accounts can be monitored simultaneously.
1612
+ </p>
1613
+ <div id="accounts-list">Loading...</div>
1614
+ <form onsubmit="event.preventDefault(); addAccount();" class="add-key-form" style="margin-top:20px; padding-top:15px; border-top:1px solid var(--border-color);">
1615
+ <input type="text" id="new-account-name" placeholder="Account name" style="max-width:180px;" autocomplete="off">
1616
+ <input type="password" id="new-account-key" placeholder="{{ config.key_placeholder }}" autocomplete="new-password">
1617
+ <button type="submit" class="btn">Add Account</button>
1618
+ </form>
1619
+ </div>
1620
+ </div>
1621
+
1622
+ <script>
1623
+ const EXPORTER = '{{ exporter_name }}';
1624
+ const API_PREFIX = '{{ config.api_prefix }}';
1625
+
1626
+ async function loadStatus() {
1627
+ try {
1628
+ const resp = await fetch('/auth/api/exporters');
1629
+ if (!resp.ok) throw new Error('Failed');
1630
+ const data = await resp.json();
1631
+ const info = data[EXPORTER];
1632
+ if (!info) { document.getElementById('service-status').textContent = 'Unknown exporter'; return; }
1633
+
1634
+ const badge = document.getElementById('status-badge');
1635
+ const statusDiv = document.getElementById('service-status');
1636
+
1637
+ if (info.running) {
1638
+ badge.textContent = 'Running';
1639
+ badge.className = 'status-badge status-running';
1640
+ statusDiv.innerHTML = 'Container is running on port <strong>' + info.port + '</strong>';
1641
+ document.getElementById('btn-disable').style.display = '';
1642
+ document.getElementById('btn-restart').style.display = '';
1643
+ document.getElementById('btn-enable').style.display = 'none';
1644
+ loadAccounts();
1645
+ } else if (info.enabled) {
1646
+ badge.textContent = 'Enabled (not running)';
1647
+ badge.className = 'status-badge status-stopped';
1648
+ statusDiv.innerHTML = 'Service is enabled but the container is not running.';
1649
+ document.getElementById('btn-disable').style.display = '';
1650
+ document.getElementById('btn-restart').style.display = 'none';
1651
+ document.getElementById('btn-enable').style.display = '';
1652
+ } else {
1653
+ badge.textContent = 'Disabled';
1654
+ badge.className = 'status-badge status-stopped';
1655
+ document.getElementById('btn-restart').style.display = 'none';
1656
+ if (info.can_deploy === false) {
1657
+ statusDiv.innerHTML = '<span style="color:var(--accent-yellow);">' + (info.deploy_blocked_reason || 'Cannot deploy yet') + '</span>';
1658
+ document.getElementById('btn-disable').style.display = 'none';
1659
+ document.getElementById('btn-enable').style.display = 'none';
1660
+ } else {
1661
+ statusDiv.innerHTML = 'Service is not enabled. Click Enable to start.';
1662
+ document.getElementById('btn-disable').style.display = 'none';
1663
+ document.getElementById('btn-enable').style.display = '';
1664
+ }
1665
+ }
1666
+ } catch (e) {
1667
+ document.getElementById('service-status').textContent = 'Error loading status';
1668
+ }
1669
+ }
1670
+
1671
+ async function loadAccounts() {
1672
+ const card = document.getElementById('accounts-card');
1673
+ const listDiv = document.getElementById('accounts-list');
1674
+ try {
1675
+ const resp = await fetch(API_PREFIX + '/accounts');
1676
+ if (!resp.ok) { card.style.display = 'none'; return; }
1677
+ const data = await resp.json();
1678
+ card.style.display = '';
1679
+
1680
+ if (!data.accounts || data.accounts.length === 0) {
1681
+ listDiv.innerHTML = '<p style="color:var(--text-secondary);">No accounts configured.</p>';
1682
+ return;
1683
+ }
1684
+
1685
+ let html = '<table class="key-table"><thead><tr><th>Account</th><th>API Key</th><th>Status</th><th>Details</th><th></th></tr></thead><tbody>';
1686
+ for (const acc of data.accounts) {
1687
+ const name = acc.name || acc;
1688
+ const keyMasked = acc.key_masked || '***';
1689
+ const status = acc.status || 'unknown';
1690
+ const statusColor = status === 'connected' ? 'var(--accent-green)' : '#f44336';
1691
+ const statusIcon = status === 'connected' ? '&#9679;' : '&#9679;';
1692
+
1693
+ let details = '';
1694
+ if (acc.balance !== null && acc.balance !== undefined) {
1695
+ details += '$' + parseFloat(acc.balance).toFixed(2);
1696
+ }
1697
+ if (acc.machine_count !== null && acc.machine_count !== undefined) {
1698
+ details += (details ? ' &middot; ' : '') + acc.machine_count + ' machine' + (acc.machine_count !== 1 ? 's' : '');
1699
+ }
1700
+
1701
+ html += '<tr>';
1702
+ html += '<td><strong>' + name + '</strong></td>';
1703
+ html += '<td><code class="key-masked">' + keyMasked + '</code></td>';
1704
+ html += '<td><span style="color:' + statusColor + ';">' + statusIcon + ' ' + status + '</span></td>';
1705
+ html += '<td style="color:var(--text-secondary); font-size:0.85rem;">' + (details || '&mdash;') + '</td>';
1706
+ html += '<td style="text-align:right;"><button class="btn btn-danger btn-sm" data-acct="' + name.replace(/"/g, '&quot;') + '">Remove</button></td>';
1707
+ html += '</tr>';
1708
+ }
1709
+ html += '</tbody></table>';
1710
+ listDiv.innerHTML = html;
1711
+ // Attach remove handlers via event delegation
1712
+ listDiv.querySelectorAll('[data-acct]').forEach(function(btn) {
1713
+ btn.onclick = function() { removeAccount(btn.dataset.acct); };
1714
+ });
1715
+ } catch (e) {
1716
+ card.style.display = 'none';
1717
+ }
1718
+ }
1719
+
1720
+ async function addAccount() {
1721
+ const name = document.getElementById('new-account-name').value.trim();
1722
+ const key = document.getElementById('new-account-key').value.trim();
1723
+ if (!name || !key) { showMsg('Please enter both account name and API key', 'error'); return; }
1724
+
1725
+ showMsg('Adding account...', 'info');
1726
+ try {
1727
+ const resp = await fetch(API_PREFIX + '/accounts', {
1728
+ method: 'POST',
1729
+ headers: {'Content-Type': 'application/json'},
1730
+ body: JSON.stringify({name: name, key: key})
1731
+ });
1732
+ if (resp.ok) {
1733
+ showMsg('Account added successfully', 'success');
1734
+ document.getElementById('new-account-name').value = '';
1735
+ document.getElementById('new-account-key').value = '';
1736
+ loadAccounts();
1737
+ } else {
1738
+ const data = await resp.json().catch(() => ({}));
1739
+ showMsg(data.error || 'Failed to add account', 'error');
1740
+ }
1741
+ } catch (e) { showMsg('Request failed: ' + e.message, 'error'); }
1742
+ }
1743
+
1744
+ async function removeAccount(name) {
1745
+ if (!confirm('Remove account "' + name + '"? This will stop monitoring for this API key.')) return;
1746
+ showMsg('Removing account...', 'info');
1747
+ try {
1748
+ const resp = await fetch(API_PREFIX + '/accounts/' + encodeURIComponent(name), {
1749
+ method: 'DELETE'
1750
+ });
1751
+ if (resp.ok) {
1752
+ showMsg('Account "' + name + '" removed successfully', 'success');
1753
+ loadAccounts();
1754
+ } else {
1755
+ const data = await resp.json().catch(() => ({}));
1756
+ showMsg(data.error || 'Failed to remove account', 'error');
1757
+ }
1758
+ } catch (e) { showMsg('Request failed: ' + e.message, 'error'); }
1759
+ }
1760
+
1761
+ function showEnableForm() { document.getElementById('enable-form').style.display = ''; }
1762
+ function hideEnableForm() { document.getElementById('enable-form').style.display = 'none'; }
1763
+
1764
+ async function enableService() {
1765
+ const key = document.getElementById('enable-key').value.trim();
1766
+ if (!key) { showMsg('Please enter an API key', 'error'); return; }
1767
+ showMsg('Enabling service (starting container, configuring Prometheus, importing dashboard)...', 'info');
1768
+ try {
1769
+ const resp = await fetch('/auth/api/exporters/' + EXPORTER + '/enable', {
1770
+ method: 'POST',
1771
+ headers: {'Content-Type': 'application/json'},
1772
+ body: JSON.stringify({api_key: key})
1773
+ });
1774
+ const data = await resp.json();
1775
+ if (data.success) {
1776
+ showMsg('Service enabled! ' + (data.steps || []).join(' | '), 'success');
1777
+ hideEnableForm();
1778
+ setTimeout(loadStatus, 1500);
1779
+ } else { showMsg(data.error || 'Failed to enable', 'error'); }
1780
+ } catch (e) { showMsg('Request failed: ' + e.message, 'error'); }
1781
+ }
1782
+
1783
+ async function restartService() {
1784
+ if (!confirm('Restart the exporter container? Metrics will be briefly unavailable.')) return;
1785
+ showMsg('Restarting container...', 'info');
1786
+ try {
1787
+ const resp = await fetch('/auth/api/exporters/' + EXPORTER + '/restart', {
1788
+ method: 'POST',
1789
+ headers: {'Content-Type': 'application/json'}
1790
+ });
1791
+ const data = await resp.json();
1792
+ if (data.success) {
1793
+ showMsg(data.message || 'Container restarted successfully', 'success');
1794
+ setTimeout(loadStatus, 2000);
1795
+ } else { showMsg(data.error || 'Failed to restart', 'error'); }
1796
+ } catch (e) { showMsg('Request failed: ' + e.message, 'error'); }
1797
+ }
1798
+
1799
+ async function disableService() {
1800
+ if (!confirm('Disable this service? This will stop the container, remove the Prometheus target, and delete the Grafana dashboard.')) return;
1801
+ showMsg('Disabling service...', 'info');
1802
+ try {
1803
+ const resp = await fetch('/auth/api/exporters/' + EXPORTER + '/disable', {
1804
+ method: 'POST',
1805
+ headers: {'Content-Type': 'application/json'}
1806
+ });
1807
+ const data = await resp.json();
1808
+ if (data.success) {
1809
+ showMsg('Service disabled. ' + (data.steps || []).join(' | '), 'success');
1810
+ document.getElementById('accounts-card').style.display = 'none';
1811
+ setTimeout(loadStatus, 1500);
1812
+ } else { showMsg(data.error || 'Failed to disable', 'error'); }
1813
+ } catch (e) { showMsg('Request failed: ' + e.message, 'error'); }
1814
+ }
1815
+
1816
+ function showMsg(msg, type) {
1817
+ const el = document.getElementById('status-msg');
1818
+ el.style.display = 'block';
1819
+ el.className = 'msg-' + type;
1820
+ el.textContent = msg;
1821
+ }
1822
+
1823
+ loadStatus();
1824
+ </script>
1825
+ </body>
1826
+ </html>
1827
+ '''
1828
+
1829
+ @app.route('/vastai/')
1830
+ @login_required_decorator
1831
+ def vastai_management():
1832
+ """Vast.ai exporter management page."""
1833
+ return render_template_string(EXPORTER_PAGE_TEMPLATE,
1834
+ config=EXPORTER_PAGE_CONFIG['vastai'],
1835
+ exporter_name='vastai')
1836
+
1837
+ @app.route('/runpod/')
1838
+ @login_required_decorator
1839
+ def runpod_management():
1840
+ """RunPod exporter management page."""
1841
+ return render_template_string(EXPORTER_PAGE_TEMPLATE,
1842
+ config=EXPORTER_PAGE_CONFIG['runpod'],
1843
+ exporter_name='runpod')
1844
+
1845
+ # =========================================================================
1846
+ # DC WATCHDOG AUTO-SSO
1847
+ # =========================================================================
1848
+
1849
+ @app.route('/auth/watchdog/sso')
1850
+ def watchdog_sso():
1851
+ """Generate SSO URL for DC Watchdog - enables seamless one-click access.
1852
+
1853
+ IMPORTANT: WordPress validation ALWAYS happens!
1854
+ This is NOT skipping WordPress - it's just avoiding browser redirects.
1855
+
1856
+ Flow:
1857
+ 1. Fleet Management has API key (sk-ipmi-xxx) from client's .secrets.yaml
1858
+ 2. This endpoint generates a signed token containing the API key
1859
+ 3. DC Watchdog receives token and validates API key against WordPress
1860
+ 4. WordPress confirms: user_id, email, tier, subscription status
1861
+ 5. If valid, DC Watchdog creates session with WordPress-verified info
1862
+ 6. User lands on dashboard - no manual login needed!
1863
+
1864
+ The API key is the trust anchor - it was obtained from WordPress during
1865
+ initial signup at cryptolabs.co.za. DC Watchdog validates it every time.
1866
+
1867
+ If API key is not configured, falls back to WordPress browser SSO.
1868
+ """
1869
+ import base64
1870
+
1871
+ # Check if user is logged in to Fleet Management
1872
+ if not session.get('logged_in'):
1873
+ if not get_setting('allow_anonymous', False):
1874
+ return redirect('/auth/login?next=/auth/watchdog/sso')
1875
+
1876
+ username = session.get('username', 'anonymous')
1877
+ role = session.get('role', 'readonly')
1878
+
1879
+ # Get API key (from env or persistent storage)
1880
+ api_key = get_watchdog_api_key()
1881
+
1882
+ # If no API key configured, redirect to WordPress signup with callback
1883
+ # After signup, WordPress will redirect back with the API key
1884
+ if not api_key:
1885
+ # Build callback URL for this Fleet Management instance
1886
+ callback_url = request.host_url.rstrip('/') + '/auth/watchdog/callback'
1887
+ signup_url = f"{WATCHDOG_SIGNUP_URL}?redirect_uri={callback_url}&source=fleet_management"
1888
+ return redirect(signup_url)
1889
+
1890
+ # Generate signed SSO payload
1891
+ # NOTE: DC Watchdog will validate this API key against WordPress server-to-server
1892
+ # The token is just a signed transport - WordPress is the source of truth
1893
+ timestamp = int(time.time())
1894
+
1895
+ # Include this Fleet Management instance's URL so dc-watchdog
1896
+ # can show a "Home" button that navigates back here.
1897
+ fleet_home = request.host_url.rstrip('/') + '/'
1898
+
1899
+ sso_data = {
1900
+ 'username': username,
1901
+ 'role': role,
1902
+ 'api_key': api_key, # Will be validated against WordPress by DC Watchdog
1903
+ 'timestamp': timestamp,
1904
+ 'source': 'fleet_management',
1905
+ 'fleet_home_url': fleet_home,
1906
+ }
1907
+
1908
+ # Sign the payload using the API key itself as the secret
1909
+ # This eliminates the need for a separate shared secret per client!
1910
+ # DC Watchdog will verify using the same API key from the payload
1911
+ payload = base64.b64encode(json.dumps(sso_data).encode('utf-8')).decode('utf-8')
1912
+ signature = hmac.new(
1913
+ api_key.encode('utf-8'), # API key IS the secret
1914
+ payload.encode('utf-8'),
1915
+ hashlib.sha256
1916
+ ).hexdigest()
1917
+
1918
+ # Mark as verified - user is actively using DC Watchdog via SSO
1919
+ # This handles the case where API key came from setup but user
1920
+ # never went through the WordPress callback flow
1921
+ if not is_watchdog_verified():
1922
+ set_watchdog_verified()
1923
+ logger.info("DC Watchdog verified via direct SSO (API key from setup)")
1924
+
1925
+ # Build SSO redirect URL
1926
+ sso_url = f"{WATCHDOG_URL}/auth/sso?payload={payload}&signature={signature}"
1927
+
1928
+ return redirect(sso_url)
1929
+
1930
+ @app.route('/auth/watchdog/sso-url')
1931
+ def watchdog_sso_url():
1932
+ """API endpoint to get SSO URL without redirect (for JavaScript fetch).
1933
+
1934
+ Returns JSON with the SSO URL that can be used client-side.
1935
+ """
1936
+ import base64
1937
+
1938
+ # Check if user is logged in
1939
+ if not session.get('logged_in'):
1940
+ if not get_setting('allow_anonymous', False):
1941
+ return jsonify({'error': 'Not authenticated'}), 401
1942
+
1943
+ username = session.get('username', 'anonymous')
1944
+ role = session.get('role', 'readonly')
1945
+
1946
+ # Get API key (from env or persistent storage)
1947
+ api_key = get_watchdog_api_key()
1948
+
1949
+ # If no API key configured, return signup URL with callback
1950
+ if not api_key:
1951
+ callback_url = request.host_url.rstrip('/') + '/auth/watchdog/callback'
1952
+ signup_url = f"{WATCHDOG_SIGNUP_URL}?redirect_uri={callback_url}&source=fleet_management"
1953
+ return jsonify({
1954
+ 'sso_url': signup_url,
1955
+ 'auto_sso': False,
1956
+ 'needs_signup': True,
1957
+ 'message': 'API key not configured - redirect to signup'
1958
+ })
1959
+
1960
+ # Generate signed SSO payload
1961
+ timestamp = int(time.time())
1962
+
1963
+ fleet_home = request.host_url.rstrip('/') + '/'
1964
+
1965
+ sso_data = {
1966
+ 'username': username,
1967
+ 'role': role,
1968
+ 'api_key': api_key,
1969
+ 'timestamp': timestamp,
1970
+ 'source': 'fleet_management',
1971
+ 'fleet_home_url': fleet_home,
1972
+ }
1973
+
1974
+ # Sign using API key as the secret (same as /auth/watchdog/sso)
1975
+ payload = base64.b64encode(json.dumps(sso_data).encode('utf-8')).decode('utf-8')
1976
+ signature = hmac.new(
1977
+ api_key.encode('utf-8'), # API key IS the secret
1978
+ payload.encode('utf-8'),
1979
+ hashlib.sha256
1980
+ ).hexdigest()
1981
+
1982
+ sso_url = f"{WATCHDOG_URL}/auth/sso?payload={payload}&signature={signature}"
1983
+
1984
+ return jsonify({
1985
+ 'sso_url': sso_url,
1986
+ 'auto_sso': True,
1987
+ 'message': 'Auto-SSO enabled'
1988
+ })
1989
+
1990
+ @app.route('/auth/watchdog/callback')
1991
+ def watchdog_callback():
1992
+ """OAuth-style callback from WordPress after user signs up for DC Watchdog.
1993
+
1994
+ WordPress redirects here with:
1995
+ - api_key: The user's API key (sk-ipmi-xxx)
1996
+ - user_email: User's email
1997
+ - subscription: Subscription status (trial, active, etc.)
1998
+
1999
+ We store the API key and then redirect to the DC Watchdog dashboard.
2000
+ """
2001
+ api_key = request.args.get('api_key')
2002
+ user_email = request.args.get('user_email', '')
2003
+ subscription = request.args.get('subscription', 'trial')
2004
+
2005
+ if not api_key or not api_key.startswith('sk-ipmi-'):
2006
+ return render_template_string('''
2007
+ <!DOCTYPE html>
2008
+ <html>
2009
+ <head><title>Error</title></head>
2010
+ <body style="font-family: sans-serif; padding: 40px; text-align: center;">
2011
+ <h2>❌ Invalid API Key</h2>
2012
+ <p>No valid API key was provided. Please try again.</p>
2013
+ <a href="/">Return to Dashboard</a>
2014
+ </body>
2015
+ </html>
2016
+ '''), 400
2017
+
2018
+ # Save the API key persistently and mark as verified (SSO completed)
2019
+ if save_watchdog_api_key(api_key):
2020
+ set_watchdog_verified() # User completed SSO, now DC Watchdog is enabled
2021
+ # Now redirect to DC Watchdog with SSO
2022
+ return redirect('/auth/watchdog/sso')
2023
+ else:
2024
+ return render_template_string('''
2025
+ <!DOCTYPE html>
2026
+ <html>
2027
+ <head><title>Error</title></head>
2028
+ <body style="font-family: sans-serif; padding: 40px; text-align: center;">
2029
+ <h2>❌ Failed to Save API Key</h2>
2030
+ <p>Could not save the API key. Please try again.</p>
2031
+ <a href="/">Return to Dashboard</a>
2032
+ </body>
2033
+ </html>
2034
+ '''), 500
2035
+
2036
+ @app.route('/auth/watchdog/status')
2037
+ def watchdog_status():
2038
+ """Check DC Watchdog configuration and agent status.
2039
+
2040
+ Returns detailed status for UI rendering:
2041
+ - state: 'not_configured' | 'pending_agents' | 'agents_installed' | 'active' | 'error'
2042
+ - configured: bool - user has completed SSO verification
2043
+ - agents: dict with total, online, outdated counts
2044
+ - latest_version: string - latest agent version from watchdog server
2045
+ """
2046
+ api_key = get_watchdog_api_key()
2047
+ verified = is_watchdog_verified()
2048
+
2049
+ result = {
2050
+ 'configured': verified, # Only true after SSO completion
2051
+ 'has_api_key': bool(api_key),
2052
+ 'verified': verified,
2053
+ 'signup_url': WATCHDOG_SIGNUP_URL,
2054
+ 'state': 'not_configured',
2055
+ 'agents': {
2056
+ 'total': 0,
2057
+ 'online': 0,
2058
+ 'outdated': 0
2059
+ },
2060
+ 'sites': [], # Per-site breakdown for multi-site customers
2061
+ 'latest_version': None,
2062
+ 'dashboard_url': f'{WATCHDOG_URL}/dashboard'
2063
+ }
2064
+
2065
+ if not verified:
2066
+ # First-time: require user to enable DC Watchdog via "Link Account" + SSO.
2067
+ # Even if setup passed API key via env var, user must complete SSO to
2068
+ # verify account. After SSO, agents get worker tokens for encrypted comms.
2069
+ result['state'] = 'not_configured'
2070
+ result['message'] = 'Enable DC Watchdog to monitor server uptime'
2071
+ return jsonify(result)
2072
+
2073
+ if not api_key:
2074
+ # Verified but no key (shouldn't happen, but handle gracefully)
2075
+ result['state'] = 'not_configured'
2076
+ result['message'] = 'API key missing, please re-enable DC Watchdog'
2077
+ return jsonify(result)
2078
+
2079
+ # Try to get agent status from watchdog server
2080
+ try:
2081
+ # First get latest version (public endpoint)
2082
+ version_resp = requests.get(
2083
+ f'{WATCHDOG_URL}/api/latest-version',
2084
+ timeout=5
2085
+ )
2086
+ if version_resp.ok:
2087
+ version_data = version_resp.json()
2088
+ result['latest_version'] = version_data.get('version', 'unknown')
2089
+
2090
+ # Try to get agent count (requires API key)
2091
+ agents_resp = requests.get(
2092
+ f'{WATCHDOG_URL}/api/updates',
2093
+ params={'api_key': api_key},
2094
+ timeout=5
2095
+ )
2096
+ if agents_resp.ok:
2097
+ agents_data = agents_resp.json()
2098
+ # API returns: total (all registered), online (currently online), outdated, sites
2099
+ total_registered = agents_data.get('total_registered', agents_data.get('total', 0))
2100
+ online_count = agents_data.get('online', 0) # Default 0, not total_registered
2101
+ result['agents'] = {
2102
+ 'total': total_registered,
2103
+ 'online': online_count,
2104
+ 'outdated': agents_data.get('outdated', 0)
2105
+ }
2106
+ result['sites'] = agents_data.get('sites', [])
2107
+ result['latest_version'] = agents_data.get('latest_version', result['latest_version'])
2108
+ result['min_version'] = agents_data.get('min_version') # Lowest running agent version
2109
+ result['max_version'] = agents_data.get('max_version') # Highest running agent version
2110
+
2111
+ if result['agents']['total'] > 0:
2112
+ if online_count == 0:
2113
+ # Agents registered but none online - likely not deployed or all offline
2114
+ result['state'] = 'agents_offline'
2115
+ result['message'] = f"0/{total_registered} agents online"
2116
+ elif result['agents']['outdated'] > 0:
2117
+ result['state'] = 'active'
2118
+ result['message'] = f"{result['agents']['outdated']} agents need updates"
2119
+ else:
2120
+ result['state'] = 'active'
2121
+ result['message'] = f"{online_count}/{total_registered} agents online"
2122
+ else:
2123
+ # No agents reporting to watchdog yet - check if any are installed locally (e.g. via dc-overview setup)
2124
+ result['state'] = 'pending_agents'
2125
+ result['message'] = 'API key configured, deploy agents to start monitoring'
2126
+
2127
+ try:
2128
+ local_resp = requests.get(
2129
+ 'http://dc-overview:5001/api/watchdog-agents/status',
2130
+ timeout=3
2131
+ )
2132
+ if local_resp.ok:
2133
+ local_data = local_resp.json()
2134
+ result['local'] = {
2135
+ 'total_servers': local_data.get('total_servers', 0),
2136
+ 'installed': local_data.get('installed', 0),
2137
+ 'not_installed': local_data.get('not_installed', 0)
2138
+ }
2139
+ installed = local_data.get('installed', 0)
2140
+ if installed > 0:
2141
+ result['state'] = 'agents_installed' # Go agents deployed, waiting for heartbeats
2142
+ result['message'] = f"{installed} agents installed, waiting for heartbeats..."
2143
+ except Exception:
2144
+ pass # dc-overview not available
2145
+ elif agents_resp.status_code in (401, 403):
2146
+ # API key is invalid, expired, or subscription ended
2147
+ result['configured'] = False # Force re-link
2148
+ result['state'] = 'key_invalid'
2149
+ result['key_error'] = True
2150
+ if agents_resp.status_code == 401:
2151
+ result['message'] = 'API key invalid or expired. Please re-link your account.'
2152
+ else:
2153
+ result['message'] = 'Subscription expired or access denied. Please check your account.'
2154
+ else:
2155
+ # Other error - might be temporary
2156
+ result['state'] = 'pending_agents'
2157
+ result['message'] = 'Verifying API key with watchdog server...'
2158
+
2159
+ except requests.RequestException as e:
2160
+ result['state'] = 'pending_agents' if api_key else 'not_configured'
2161
+ result['message'] = 'Could not reach watchdog server'
2162
+
2163
+ return jsonify(result)
2164
+
2165
+ @app.route('/auth/watchdog/deploy-agents', methods=['POST'])
2166
+ @login_required_decorator
2167
+ def watchdog_deploy_agents():
2168
+ """Deploy DC Watchdog agents to all servers via dc-overview.
2169
+
2170
+ This forwards the request to dc-overview's /api/watchdog-agents/deploy-all
2171
+ endpoint which handles the SSH deployment to each server.
2172
+ """
2173
+ # Check if user has write access
2174
+ role = session.get('role', 'readonly')
2175
+ if role == 'readonly':
2176
+ return jsonify({
2177
+ 'success': False,
2178
+ 'error': 'Write access required to deploy agents'
2179
+ }), 403
2180
+
2181
+ # Forward to dc-overview
2182
+ try:
2183
+ # dc-overview container is accessible via internal network
2184
+ dc_overview_url = 'http://dc-overview:5001'
2185
+
2186
+ # Forward the deploy request with Fleet auth headers
2187
+ # dc-overview expects X-Fleet-Auth-* headers from trusted proxy
2188
+ resp = requests.post(
2189
+ f'{dc_overview_url}/api/watchdog-agents/deploy-all',
2190
+ timeout=120, # Allow time for SSH to multiple servers
2191
+ headers={
2192
+ 'X-Fleet-Auth-User': session.get('username', 'admin'),
2193
+ 'X-Fleet-Auth-Role': role,
2194
+ 'X-Fleet-Authenticated': 'true',
2195
+ 'Content-Type': 'application/json'
2196
+ }
2197
+ )
2198
+
2199
+ if resp.ok:
2200
+ return jsonify(resp.json())
2201
+ else:
2202
+ return jsonify({
2203
+ 'success': False,
2204
+ 'error': f'Deploy failed: {resp.text}'
2205
+ }), resp.status_code
2206
+
2207
+ except requests.exceptions.ConnectionError:
2208
+ return jsonify({
2209
+ 'success': False,
2210
+ 'error': 'Could not connect to dc-overview. Is Server Manager running?'
2211
+ }), 503
2212
+ except requests.exceptions.Timeout:
2213
+ return jsonify({
2214
+ 'success': False,
2215
+ 'error': 'Deployment timed out. Check server connectivity.'
2216
+ }), 504
2217
+ except Exception as e:
2218
+ return jsonify({
2219
+ 'success': False,
2220
+ 'error': str(e)
2221
+ }), 500
2222
+
2223
+ @app.route('/auth/watchdog/agents-status')
2224
+ @login_required_decorator
2225
+ def watchdog_agents_local_status():
2226
+ """Get local agent deployment status from dc-overview.
2227
+
2228
+ Returns how many agents are installed locally (via dc-overview)
2229
+ vs what watchdog server sees.
2230
+ """
2231
+ try:
2232
+ dc_overview_url = 'http://dc-overview:5001'
2233
+ resp = requests.get(
2234
+ f'{dc_overview_url}/api/watchdog-agents/status',
2235
+ timeout=5
2236
+ )
2237
+
2238
+ if resp.ok:
2239
+ return jsonify(resp.json())
2240
+ else:
2241
+ return jsonify({
2242
+ 'configured': False,
2243
+ 'error': 'Could not get status from dc-overview'
2244
+ }), resp.status_code
2245
+
2246
+ except requests.exceptions.ConnectionError:
2247
+ return jsonify({
2248
+ 'configured': False,
2249
+ 'error': 'dc-overview not running'
2250
+ }), 503
2251
+ except Exception as e:
2252
+ return jsonify({
2253
+ 'configured': False,
2254
+ 'error': str(e)
2255
+ }), 500
2256
+
2257
+ return app
2258
+
2259
+
2260
+ # =============================================================================
2261
+ # INITIALIZE DEFAULT USER
2262
+ # =============================================================================
2263
+
2264
+ def ensure_default_user():
2265
+ """Ensure at least one user exists."""
2266
+ if not user_exists():
2267
+ # Check for environment variables
2268
+ default_user = os.environ.get('FLEET_ADMIN_USER', 'admin')
2269
+ default_pass = os.environ.get('FLEET_ADMIN_PASS')
2270
+
2271
+ if default_pass:
2272
+ create_user(default_user, default_pass, 'admin')
2273
+ return True
2274
+ return False