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.
- cryptolabs_proxy/__init__.py +36 -0
- cryptolabs_proxy/auth.py +2274 -0
- cryptolabs_proxy/cli.py +567 -0
- cryptolabs_proxy/config.py +44 -0
- cryptolabs_proxy/exporter_manager.py +618 -0
- cryptolabs_proxy/services.py +215 -0
- cryptolabs_proxy/setup.py +615 -0
- cryptolabs_proxy/ssl.py +25 -0
- cryptolabs_proxy/templates/docker-compose.yml.j2 +53 -0
- cryptolabs_proxy/templates/nginx.conf.j2 +296 -0
- cryptolabs_proxy-1.1.1.dist-info/METADATA +320 -0
- cryptolabs_proxy-1.1.1.dist-info/RECORD +16 -0
- cryptolabs_proxy-1.1.1.dist-info/WHEEL +5 -0
- cryptolabs_proxy-1.1.1.dist-info/entry_points.txt +2 -0
- cryptolabs_proxy-1.1.1.dist-info/licenses/LICENSE +21 -0
- cryptolabs_proxy-1.1.1.dist-info/top_level.txt +1 -0
cryptolabs_proxy/auth.py
ADDED
|
@@ -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 & 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 → Account → 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 → Settings → 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 & 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' ? '●' : '●';
|
|
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 ? ' · ' : '') + 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 || '—') + '</td>';
|
|
1706
|
+
html += '<td style="text-align:right;"><button class="btn btn-danger btn-sm" data-acct="' + name.replace(/"/g, '"') + '">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
|