-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathadmin.py
More file actions
749 lines (631 loc) · 31.2 KB
/
Copy pathadmin.py
File metadata and controls
749 lines (631 loc) · 31.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
"""Admin UI: setup wizard, login, settings, user management, audit log.
Two route families under the same /admin prefix:
- HTML shell routes (render the layout.html shell the React SPA mounts into,
same convention as the existing dashboard routes in healthcheck.py).
- /admin/api/* JSON endpoints the SPA calls with same-origin fetch;
Flask-Login's session cookie carries auth, no token plumbing needed.
This blueprint is exempted from healthcheck.py's global read-only-method
enforcement (see enforce_read_only_methods) since it's the one place in the
app that legitimately needs POST/DELETE.
"""
import logging
import os
import re
import secrets
import sqlite3
import time
import requests
from flask import Blueprint, jsonify, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
import dbstore
import poller
import notifier
import public_ip_updater
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
# How long a password-verified-but-MFA-pending session state stays valid
# before the second factor must be re-entered from scratch (i.e. the whole
# password + code flow starts over). Bounds how long an abandoned or stolen
# post-password session cookie could be used to complete login later.
MFA_CHALLENGE_TTL_SECONDS = 5 * 60
def _read_app_version() -> str:
"""Read the app version from the repo-root VERSION file, once at import.
Falls back to "unknown" rather than raising - version display is a
cosmetic feature and must never break app startup if the file is
missing (e.g. an unusual deployment that doesn't copy it in).
"""
version_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "VERSION")
try:
with open(version_path, "r", encoding="utf-8") as f:
return f.read().strip() or "unknown"
except OSError:
return "unknown"
APP_VERSION = _read_app_version()
MASKED_SETTINGS = dbstore.SECRET_SETTINGS
# Settings that require a process restart to take effect even after being
# saved to the DB, because they're read once at Flask app / logging setup
# time rather than per-request (Flask-Limiter wiring, logging.basicConfig,
# the WSGI middleware stack, Flask's session config).
RESTART_REQUIRED_SETTINGS = {
"rate_limit_enabled", "rate_limit_per_ip", "rate_limit_global",
"rate_limit_storage_url", "rate_limit_headers_enabled", "log_level",
"trusted_proxy_count", "session_cookie_secure", "session_lifetime_minutes",
}
def _setting_field(name, meta=None, typed_value=None):
"""One settings-page field. `meta`/`typed_value` are passed in by
api_get_settings() from a pair of batched lookups; the per-setting
fallbacks below keep this usable for a single field (and in tests)."""
env_var, type_name, default, sentinel, group = dbstore.SETTINGS_REGISTRY[name]
if meta is None:
meta = dbstore.get_setting_meta(name)
if typed_value is None:
typed_value = dbstore.get_setting_typed(name)
secret = name in MASKED_SETTINGS
display_value = "********" if (secret and meta.get("value")) else typed_value
return {
"value": display_value,
"source": meta.get("source"),
"configured": meta.get("value") is not None and meta.get("value") != "",
"type": type_name,
"default": default,
"group": group,
"secret": secret,
"env_var": env_var,
"restart_required": name in RESTART_REQUIRED_SETTINGS,
}
def _setup_incomplete() -> bool:
return not dbstore.is_tailnet_configured() or not dbstore.is_auth_configured() or not dbstore.has_any_user()
def _needs_bootstrap() -> bool:
"""True only when no user exists yet - the sole case where /admin/setup
(and posting to it) must stay unauthenticated, since there's no session
to require. Once a user exists, connection repair goes through a normal
login + /admin/settings, not this wizard - see _gate_dashboard_ui's and
login_page's docstrings for why that distinction matters."""
return not dbstore.has_any_user()
def _validate_tailscale_credentials(tailnet_domain: str, auth_header: dict):
"""Trial call against the Tailscale devices API; raises on failure."""
url = f"https://api.tailscale.com/api/v2/tailnet/{tailnet_domain}/devices"
response = requests.get(url, headers=auth_header, timeout=10)
response.raise_for_status()
# ---------------------------------------------------------------------------
# HTML shell routes
# ---------------------------------------------------------------------------
@admin_bp.route("/", methods=["GET"])
def index():
if _needs_bootstrap():
return redirect(url_for("admin.setup_page"))
if not current_user.is_authenticated:
return redirect(url_for("admin.login_page"))
return redirect(url_for("admin.settings_page"))
@admin_bp.route("/setup", methods=["GET"])
def setup_page():
# Reachable unauthenticated only for genuine bootstrap (no user yet).
# Once a user exists, even with connection settings still incomplete,
# send them to log in first - see login_page's docstring. (login_page
# itself redirects an already-authenticated session on to settings.)
if not _needs_bootstrap() and not current_user.is_authenticated:
return redirect(url_for("admin.login_page"))
if not _setup_incomplete():
return redirect(url_for("admin.login_page"))
return render_template("admin_setup.html")
@admin_bp.route("/login", methods=["GET"])
def login_page():
# Only redirect to the (unauthenticated) bootstrap wizard when there's
# truly no user to log into - not just because connection settings
# happen to be unconfigured, which can happen again post-setup (env
# removed, a DB row cleared, etc.) and must not lock an existing admin
# out of logging in to repair it via /admin/settings.
if _needs_bootstrap():
return redirect(url_for("admin.setup_page"))
if current_user.is_authenticated:
return redirect(url_for("admin.settings_page"))
return render_template("admin_login.html")
@admin_bp.route("/settings", methods=["GET"])
@login_required
def settings_page():
return render_template("admin_settings.html")
@admin_bp.route("/public-ip", methods=["GET"])
@login_required
def public_ip_page():
return render_template("admin_public_ip.html")
@admin_bp.route("/profile", methods=["GET"])
@login_required
def profile_page():
return render_template("admin_profile.html")
@admin_bp.route("/users", methods=["GET"])
@login_required
def users_page():
return render_template("admin_users.html")
@admin_bp.route("/audit", methods=["GET"])
@login_required
def audit_page():
return render_template("admin_audit.html")
@admin_bp.route("/api-docs", methods=["GET"])
@login_required
def api_docs_page():
return render_template("admin_api_docs.html")
# ---------------------------------------------------------------------------
# JSON API
# ---------------------------------------------------------------------------
@admin_bp.route("/api/status", methods=["GET"])
def api_status():
return jsonify({
"tailnet_configured": dbstore.is_tailnet_configured(),
"auth_configured": dbstore.is_auth_configured(),
"has_users": dbstore.has_any_user(),
"authenticated": current_user.is_authenticated,
"version": APP_VERSION,
})
@admin_bp.route("/api/setup", methods=["POST"])
def api_setup():
if not _setup_incomplete():
return jsonify({"error": "Setup already complete"}), 403
# Genuine first-run (no user exists yet) is intentionally unauthenticated
# - there's no session to require. But once a user exists, connection
# settings can still be cleared later (env removed, DB row wiped, etc.),
# re-triggering _setup_incomplete() - at that point this must require
# login, or any unauthenticated caller could repoint a live instance at
# a tailnet/credentials of their choosing. Repairing it is what the
# login page already redirects to /admin/setup for; api_login() still
# works even with connection settings unconfigured (see its comment).
if dbstore.has_any_user() and not current_user.is_authenticated:
return jsonify({"error": "Log in to repair connection settings"}), 401
data = request.get_json(silent=True) or {}
response = {}
tailnet_needs_input = not dbstore.is_tailnet_configured()
auth_needs_input = not dbstore.is_auth_configured()
if tailnet_needs_input or auth_needs_input:
if tailnet_needs_input:
tailnet_domain = str(data.get("tailnet_domain", "")).strip()
if not tailnet_domain or tailnet_domain.lower() == "example.com":
return jsonify({"error": "A valid tailnet domain is required"}), 400
else:
# Tailnet domain is already configured (e.g. via env) - only
# auth is still missing, so reuse the effective value instead of
# requiring the wizard to resubmit it.
tailnet_domain = dbstore.get_setting("tailnet_domain")
auth_mode = str(data.get("auth_mode", "")).strip().lower()
if auth_mode == "oauth":
client_id = str(data.get("oauth_client_id", "")).strip()
client_secret = str(data.get("oauth_client_secret", "")).strip()
if not client_id or not client_secret:
return jsonify({"error": "OAuth client id and secret are required"}), 400
try:
token_resp = requests.post(
"https://api.tailscale.com/api/v2/oauth/token",
data={"client_id": client_id, "client_secret": client_secret},
timeout=10,
)
token_resp.raise_for_status()
access_token = token_resp.json()["access_token"]
_validate_tailscale_credentials(tailnet_domain, {"Authorization": f"Bearer {access_token}"})
except Exception as e:
logging.warning(f"Setup wizard: OAuth credential validation failed: {e}")
return jsonify({"error": "Could not authenticate to the Tailscale API with the provided OAuth credentials"}), 400
if tailnet_needs_input:
dbstore.set_setting("tailnet_domain", tailnet_domain, source="db", actor="setup")
dbstore.set_setting("oauth_client_id", client_id, source="db", actor="setup")
dbstore.set_setting("oauth_client_secret", client_secret, source="db", actor="setup")
elif auth_mode == "token":
auth_token = str(data.get("auth_token", "")).strip()
if not auth_token:
return jsonify({"error": "An API auth token is required"}), 400
try:
_validate_tailscale_credentials(tailnet_domain, {"Authorization": f"Bearer {auth_token}"})
except Exception as e:
logging.warning(f"Setup wizard: token credential validation failed: {e}")
return jsonify({"error": "Could not authenticate to the Tailscale API with the provided token"}), 400
if tailnet_needs_input:
dbstore.set_setting("tailnet_domain", tailnet_domain, source="db", actor="setup")
dbstore.set_setting("auth_token", auth_token, source="db", actor="setup")
else:
return jsonify({"error": "auth_mode must be 'token' or 'oauth'"}), 400
response["connection_configured"] = True
api_base_url = data.get("api_base_url")
if api_base_url is not None and dbstore.get_setting_meta("api_base_url").get("source") != "env":
dbstore.set_setting("api_base_url", str(api_base_url).strip().rstrip("/"), source="db", actor="setup")
tailnet_lock_enabled = data.get("tailnet_lock_enabled")
if tailnet_lock_enabled is not None and dbstore.get_setting_meta("tailnet_lock_enabled").get("source") != "env":
dbstore.set_setting(
"tailnet_lock_enabled", dbstore.encode_setting_value("tailnet_lock_enabled", tailnet_lock_enabled),
source="db", actor="setup",
)
if not dbstore.has_any_user():
username = str(data.get("username", "")).strip()
password = str(data.get("password", ""))
if not username or len(password) < 8:
return jsonify({"error": "A username and a password of at least 8 characters are required"}), 400
if dbstore.get_user_by_username(username):
return jsonify({"error": "Username already exists"}), 400
dbstore.create_user(username, password, actor="setup")
response["user_created"] = True
response["setup_complete"] = not _setup_incomplete()
if response.get("connection_configured"):
# Kick off an immediate poll so the dashboard has data right away.
try:
poller.run_poll_cycle()
except Exception as e: # pragma: no cover - best effort
logging.warning(f"Post-setup poll trigger failed: {e}")
return jsonify(response)
@admin_bp.route("/api/login", methods=["POST"])
def api_login():
if not dbstore.check_login_rate_limit(request.remote_addr):
return jsonify({"error": "Too many login attempts. Try again later."}), 429
# Login only needs a user to exist - it shouldn't be blocked just because
# the tailnet connection itself isn't configured yet (that's editable
# from /admin/settings once logged in).
if not dbstore.has_any_user():
return jsonify({"error": "Setup is not complete"}), 400
data = request.get_json(silent=True) or {}
username = str(data.get("username", "")).strip()
password = str(data.get("password", ""))
user_row = dbstore.verify_password(username, password)
if not user_row:
return jsonify({"error": "Invalid username or password"}), 401
if user_row.get("totp_enabled"):
# Don't establish the session yet - a second factor is still required.
# Only the pending user id goes in the (signed, httponly) session
# cookie, nothing that could itself grant access. The deadline
# bounds how long an abandoned/stolen post-password cookie could be
# used to complete the second factor later without re-entering the
# password - without it, a session cookie surviving the browser
# session would let that happen indefinitely.
session["mfa_pending_user_id"] = user_row["id"]
session["mfa_pending_deadline"] = time.time() + MFA_CHALLENGE_TTL_SECONDS
return jsonify({"ok": True, "mfa_required": True})
from auth import User
login_user(User.from_row(user_row))
dbstore.touch_last_login(user_row["id"])
return jsonify({"ok": True, "username": user_row["username"]})
@admin_bp.route("/api/login/mfa", methods=["POST"])
def api_login_mfa():
if not dbstore.check_login_rate_limit(request.remote_addr):
return jsonify({"error": "Too many login attempts. Try again later."}), 429
pending_id = session.get("mfa_pending_user_id")
deadline = session.get("mfa_pending_deadline")
if pending_id and (deadline is None or time.time() > deadline):
session.pop("mfa_pending_user_id", None)
session.pop("mfa_pending_deadline", None)
pending_id = None
user_row = dbstore.get_user_by_id(pending_id) if pending_id else None
if not user_row:
return jsonify({"error": "No pending sign-in awaiting a verification code"}), 400
data = request.get_json(silent=True) or {}
code = str(data.get("code", "")).strip()
recovery_code = str(data.get("recovery_code", "")).strip()
verified = False
if code:
verified = dbstore.verify_totp_code(user_row.get("totp_secret") or "", code)
if not verified and recovery_code:
verified = dbstore.verify_recovery_code(user_row["username"], recovery_code)
if not verified:
# Deliberately the same generic message shape as the password step -
# don't distinguish "wrong code" from "wrong recovery code" etc.
return jsonify({"error": "Invalid verification code"}), 401
session.pop("mfa_pending_user_id", None)
session.pop("mfa_pending_deadline", None)
from auth import User
login_user(User.from_row(user_row))
dbstore.touch_last_login(user_row["id"])
return jsonify({"ok": True, "username": user_row["username"]})
@admin_bp.route("/api/logout", methods=["POST"])
@login_required
def api_logout():
logout_user()
return jsonify({"ok": True})
# ---------------------------------------------------------------------------
# Profile: password change + TOTP MFA
# ---------------------------------------------------------------------------
@admin_bp.route("/api/profile", methods=["GET"])
@login_required
def api_profile():
return jsonify({
"username": current_user.username,
"mfa": dbstore.get_user_mfa_status(current_user.username),
})
@admin_bp.route("/api/profile/password", methods=["POST"])
@login_required
def api_profile_password():
data = request.get_json(silent=True) or {}
current_password = str(data.get("current_password", ""))
new_password = str(data.get("new_password", ""))
if len(new_password) < 8:
return jsonify({"error": "New password must be at least 8 characters"}), 400
if not dbstore.change_password(current_user.username, current_password, new_password):
return jsonify({"error": "Current password is incorrect"}), 401
return jsonify({"ok": True})
@admin_bp.route("/api/profile/mfa/enroll", methods=["POST"])
@login_required
def api_profile_mfa_enroll():
if dbstore.get_user_mfa_status(current_user.username)["enabled"]:
return jsonify({"error": "MFA is already enabled"}), 400
secret = dbstore.generate_totp_secret()
# Kept only in the signed session until confirmed - never written to the
# DB as "enabled" for an enrollment the user might abandon.
session["totp_pending_secret"] = secret
return jsonify({
"secret": secret,
"provisioning_uri": dbstore.totp_provisioning_uri(current_user.username, secret),
})
@admin_bp.route("/api/profile/mfa/confirm", methods=["POST"])
@login_required
def api_profile_mfa_confirm():
secret = session.get("totp_pending_secret")
if not secret:
return jsonify({"error": "No MFA enrollment in progress - start over"}), 400
data = request.get_json(silent=True) or {}
code = str(data.get("code", "")).strip()
recovery_codes = dbstore.confirm_totp_enable(current_user.username, secret, code, actor=current_user.username)
if recovery_codes is None:
return jsonify({"error": "Invalid verification code"}), 400
session.pop("totp_pending_secret", None)
# Recovery codes are returned exactly once here - only their hashes are
# ever persisted, the plaintext values cannot be retrieved again.
return jsonify({"ok": True, "recovery_codes": recovery_codes})
@admin_bp.route("/api/profile/mfa/disable", methods=["POST"])
@login_required
def api_profile_mfa_disable():
data = request.get_json(silent=True) or {}
code = str(data.get("code", "")).strip()
if not dbstore.disable_totp(current_user.username, code, actor=current_user.username):
return jsonify({"error": "A valid current verification code is required to disable MFA"}), 400
return jsonify({"ok": True})
@admin_bp.route("/api/settings", methods=["GET"])
@login_required
def api_get_settings():
# Two batched queries for the whole registry instead of two per setting.
names = list(dbstore.SETTINGS_REGISTRY)
metas = dbstore.get_settings_meta(names)
typed = dbstore.get_settings_typed(names)
settings = {name: _setting_field(name, metas[name], typed[name]) for name in names}
poll_status = dbstore.get_poll_status()
settings["_meta"] = {
"last_polled_at": dbstore.get_poll_meta(),
"poll_interval_seconds": poller.poll_interval_seconds(),
"last_poll_ok": poll_status.get("ok"),
"last_poll_error": poll_status.get("error"),
"last_poll_auth_error": bool(poll_status.get("auth_error")),
}
return jsonify(settings)
@admin_bp.route("/api/settings", methods=["POST"])
@login_required
def api_update_settings():
data = request.get_json(silent=True) or {}
# Validate the entire payload before writing anything, so a bad or
# env-locked field later in the payload can't leave earlier fields
# already persisted while the request as a whole reports failure -
# this is all-or-nothing from the caller's point of view.
encoded_values = {}
for name, raw_value in data.items():
if name not in dbstore.SETTINGS_REGISTRY:
return jsonify({"error": f"Unknown setting: {name}"}), 400
meta = dbstore.get_setting_meta(name)
if meta.get("source") == "env":
return jsonify({"error": f"{name} is set via an environment variable and cannot be changed here"}), 409
try:
encoded_values[name] = dbstore.validate_setting_value(name, raw_value)
except ValueError as e:
return jsonify({"error": str(e)}), 400
# One shared transaction for the whole batch - see set_settings_batch's
# docstring for why per-field transactions weren't actually atomic
# despite validating everything up front first.
dbstore.set_settings_batch(encoded_values, source="db", actor=current_user.username)
updated = list(encoded_values.keys())
restarts_needed = sorted(set(updated) & RESTART_REQUIRED_SETTINGS)
return jsonify({"ok": True, "updated": updated, "restart_required_for": restarts_needed})
@admin_bp.route("/api/settings/generate-token", methods=["POST"])
@login_required
def api_generate_token():
"""Generate a random token for use as a setting value (e.g. health_endpoint_token).
Purely a convenience generator - does NOT save anything. The caller
fills the returned value into a form field and it only takes effect
once they POST it to /admin/api/settings themselves.
"""
return jsonify({"token": secrets.token_urlsafe(32)})
@admin_bp.route("/api/notifications/test", methods=["POST"])
@login_required
def api_notifications_test():
"""Send a one-off test notification via notifier.test(), bypassing the
notification_events/tag gating. Accepts optional apprise_api_url/
apprise_notification_urls/apprise_bearer_token overrides in the request
body, so the settings page can test unsaved draft values directly
instead of forcing a save first."""
data = request.get_json(silent=True) or {}
cfg = dbstore.get_settings_typed(notifier.NOTIFICATION_SETTINGS)
for field in ("apprise_api_url", "apprise_notification_urls", "apprise_bearer_token"):
if field in data:
cfg[field] = str(data[field])
ok, error = notifier.test(cfg)
if not ok:
return jsonify({"ok": False, "error": error}), 400
return jsonify({"ok": True})
@admin_bp.route("/api/poll-now", methods=["POST"])
@login_required
def api_poll_now():
# Takes the same claim /health/cache/invalidate does. Without it, clicking
# "Poll now" ran a second full cycle concurrently with the background
# poller (or with another admin's click), so two cycles raced on the same
# upsert + audit-diff writes.
if not dbstore.try_claim_manual_poll():
return jsonify({
"ok": False,
"message": "A refresh is already in progress; try again shortly.",
"last_polled_at": dbstore.get_poll_meta(),
}), 202
try:
poller.run_poll_cycle()
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
dbstore.release_manual_poll_claim()
return jsonify({"ok": True, "last_polled_at": dbstore.get_poll_meta()})
def _validated_public_ip_mapping(data):
hostname = str(data.get("hostname", "")).strip().rstrip(".").lower()
posture_name = str(data.get("posture_name", "")).strip()
if posture_name and not posture_name.startswith("posture:"):
posture_name = f"posture:{posture_name}"
enabled = bool(data.get("enabled", True))
if not hostname or len(hostname) > 253 or not re.fullmatch(
r"(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", hostname
):
raise ValueError("A valid DynDNS hostname is required")
if not posture_name.startswith("posture:") or len(posture_name) <= len("posture:"):
raise ValueError("posture_name must start with posture: and include a name")
return hostname, posture_name, enabled
@admin_bp.route("/api/public-ip", methods=["GET"])
@login_required
def api_public_ip_status():
settings = dbstore.get_settings_typed((
"public_ip_updater_enabled", "public_ip_backup_retention_days",
"public_ip_errors_affect_health", "poll_interval_seconds",
))
mappings = dbstore.list_public_ip_mappings()
return jsonify({"settings": settings, "mappings": mappings})
@admin_bp.route("/api/public-ip/mappings", methods=["POST"])
@login_required
def api_create_public_ip_mapping():
lock = public_ip_updater.try_lock()
if lock is None:
return jsonify({"error": "A public-IP synchronization is in progress; try again shortly."}), 409
try:
hostname, posture_name, enabled = _validated_public_ip_mapping(request.get_json(silent=True) or {})
mapping = dbstore.create_public_ip_mapping(hostname, posture_name, enabled, actor=current_user.username)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except sqlite3.IntegrityError:
return jsonify({"error": "That posture rule already has a mapping"}), 409
finally:
public_ip_updater.release_lock(lock)
return jsonify({"ok": True, "mapping": mapping}), 201
@admin_bp.route("/api/public-ip/mappings/<int:mapping_id>", methods=["PUT"])
@login_required
def api_update_public_ip_mapping(mapping_id):
lock = public_ip_updater.try_lock()
if lock is None:
return jsonify({"error": "A public-IP synchronization is in progress; try again shortly."}), 409
try:
hostname, posture_name, enabled = _validated_public_ip_mapping(request.get_json(silent=True) or {})
mapping = dbstore.update_public_ip_mapping(
mapping_id, hostname, posture_name, enabled, actor=current_user.username
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except sqlite3.IntegrityError:
return jsonify({"error": "That posture rule already has a mapping"}), 409
finally:
public_ip_updater.release_lock(lock)
if mapping is None:
return jsonify({"error": "Mapping not found"}), 404
return jsonify({"ok": True, "mapping": mapping})
@admin_bp.route("/api/public-ip/mappings/<int:mapping_id>", methods=["DELETE"])
@login_required
def api_delete_public_ip_mapping(mapping_id):
lock = public_ip_updater.try_lock()
if lock is None:
return jsonify({"error": "A public-IP synchronization is in progress; try again shortly."}), 409
try:
if not dbstore.delete_public_ip_mapping(mapping_id, actor=current_user.username):
return jsonify({"error": "Mapping not found"}), 404
return jsonify({"ok": True})
finally:
public_ip_updater.release_lock(lock)
@admin_bp.route("/api/public-ip/sync", methods=["POST"])
@admin_bp.route("/api/public-ip/mappings/<int:mapping_id>/sync", methods=["POST"])
@login_required
def api_sync_public_ip(mapping_id=None):
result = poller.run_public_ip_sync(mapping_id=mapping_id, actor=current_user.username)
if result.get("busy"):
return jsonify(result), 202
if result.get("not_found"):
return jsonify(result), 404
if not result.get("ok"):
return jsonify(result), 502
return jsonify(result)
@admin_bp.route("/api/users", methods=["GET"])
@login_required
def api_list_users():
return jsonify({"users": dbstore.list_users()})
@admin_bp.route("/api/users", methods=["POST"])
@login_required
def api_create_user():
data = request.get_json(silent=True) or {}
username = str(data.get("username", "")).strip()
password = str(data.get("password", ""))
if not username or len(password) < 8:
return jsonify({"error": "A username and a password of at least 8 characters are required"}), 400
if dbstore.get_user_by_username(username):
return jsonify({"error": "Username already exists"}), 400
dbstore.create_user(username, password, actor=current_user.username)
return jsonify({"ok": True})
@admin_bp.route("/api/users/<string:username>", methods=["DELETE"])
@login_required
def api_delete_user(username):
result = dbstore.delete_user(username, actor=current_user.username)
if result == "not_found":
return jsonify({"error": "User not found"}), 404
if result == "last_user":
return jsonify({"error": "Cannot delete the last remaining user"}), 400
return jsonify({"ok": True})
@admin_bp.route("/api/audit", methods=["GET"])
@login_required
def api_audit_log():
try:
limit = max(1, min(500, int(request.args.get("limit", 100))))
offset = max(0, int(request.args.get("offset", 0)))
except ValueError:
return jsonify({"error": "limit/offset must be integers"}), 400
filters = {
"entity_type": request.args.get("entity_type") or None,
"entity_id": request.args.get("entity_id") or None,
"action": request.args.get("action") or None,
"actor": request.args.get("actor") or None,
"start": request.args.get("start") or None,
"end": request.args.get("end") or None,
"changed_field": request.args.get("changed_field") or None,
"changes_contains": (request.args.get("changes_contains") or "").strip() or None,
}
entries = dbstore.list_audit_log(limit=limit, offset=offset, **filters)
# `total` is what lets the UI paginate and say how much it isn't showing -
# without it a truncated result was indistinguishable from a complete one.
return jsonify({
"entries": entries,
"total": dbstore.count_audit_log(**filters),
"limit": limit,
"offset": offset,
})
@admin_bp.route("/api/audit/filters", methods=["GET"])
@login_required
def api_audit_filters():
"""Distinct values to populate the audit log filter UI (actors, entity ids,
and the field names present in changes blobs)."""
entity_type = request.args.get("entity_type") or None
return jsonify({
"actors": dbstore.list_audit_log_actors(),
"changed_fields": dbstore.list_audit_log_changed_fields(entity_type),
"entity_ids": dbstore.list_audit_log_entity_ids(entity_type),
"entity_types": ["device", "tailnet_key", "setting", "user", "public_ip_mapping"],
"actions": ["created", "updated", "removed"],
})
@admin_bp.route("/api/metrics-history", methods=["GET"])
@login_required
def api_metrics_history():
try:
hours = max(1, min(168, int(request.args.get("hours", 24))))
except ValueError:
return jsonify({"error": "hours must be an integer"}), 400
return jsonify({"entries": dbstore.get_metrics_history(hours=hours)})
@admin_bp.route("/api/debug/poller-log", methods=["GET"])
@login_required
def api_poller_log():
event_type = request.args.get("event_type") or None
try:
limit = max(1, min(500, int(request.args.get("limit", 200))))
except ValueError:
return jsonify({"error": "limit must be an integer"}), 400
entries = poller.get_poll_log(event_type=event_type, limit=limit)
return jsonify({
"entries": entries,
"event_types": poller.get_poll_log_event_types(),
"enabled": dbstore.get_setting_typed("debug_log_enabled"),
"last_polled_at": dbstore.get_poll_meta(),
"poll_interval_seconds": poller.poll_interval_seconds(),
})