Add user management and a settings page
This commit is contained in:
+60
-10
@@ -1,6 +1,8 @@
|
||||
"""Command line interface for hbd package."""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from .config import load_config
|
||||
from .main import run as run_server
|
||||
@@ -14,26 +16,74 @@ def build_parser():
|
||||
description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config", dest="configfile", help="Config file path (YAML)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f", "--foreground", action="store_true", help="Run in foreground"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
# --- serve (default) ---
|
||||
serve_p = subparsers.add_parser("serve", help="Start the hbd server (default)")
|
||||
serve_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||
serve_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
|
||||
serve_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
serve_p.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
|
||||
help="Push service to use")
|
||||
serve_p.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
|
||||
|
||||
# Legacy top-level flags (no subcommand) — kept for backward compatibility
|
||||
parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
|
||||
parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
parser.add_argument(
|
||||
"-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use"
|
||||
parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
|
||||
help="Push service to use")
|
||||
parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
|
||||
|
||||
# --- passwd ---
|
||||
passwd_p = subparsers.add_parser(
|
||||
"passwd",
|
||||
help="Generate a password hash for use in the config file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-x", "--debug", action="count", default=0, help="Increase debug level"
|
||||
passwd_p.add_argument(
|
||||
"username",
|
||||
nargs="?",
|
||||
help="Username (informational only, for display)",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def cmd_passwd(args):
|
||||
"""Interactive password hash generator."""
|
||||
from .users import hash_password
|
||||
|
||||
username = args.username or ""
|
||||
prompt = f"New password for {username}: " if username else "New password: "
|
||||
while True:
|
||||
pw = getpass.getpass(prompt)
|
||||
if not pw:
|
||||
print("Password must not be empty.", file=sys.stderr)
|
||||
continue
|
||||
pw2 = getpass.getpass("Confirm password: ")
|
||||
if pw != pw2:
|
||||
print("Passwords do not match, try again.", file=sys.stderr)
|
||||
continue
|
||||
break
|
||||
|
||||
hashed = hash_password(pw)
|
||||
if username:
|
||||
print(f"\nAdd the following to your config under users: -> {username}:")
|
||||
else:
|
||||
print("\nPassword hash (paste into config file under the user's 'password' key):")
|
||||
print(f" password: {hashed}")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "passwd":
|
||||
cmd_passwd(args)
|
||||
return
|
||||
|
||||
# Default: run the server (supports both `hbd serve ...` and `hbd ...`)
|
||||
config = load_config(args.configfile)
|
||||
|
||||
# Apply CLI overrides
|
||||
|
||||
+62
-9
@@ -14,23 +14,27 @@ SERVER_DEFAULTS = {
|
||||
"hb_port": 50003, # Port to listen for heartbeats
|
||||
"hbd_port": 50004, # HTTP API port
|
||||
"hbd_host": "", # Bind address (empty = all interfaces)
|
||||
|
||||
|
||||
# Persistence
|
||||
"pickfile": "/tmp/hb.pick",
|
||||
|
||||
|
||||
# Logging
|
||||
"logfile": "/var/log/heartbeat.log",
|
||||
"logfmt": "text", # text or msg or json
|
||||
|
||||
|
||||
# Notification channels
|
||||
"notification_channels": {}, # Named channels with type and credentials
|
||||
"default_notification_channels": [], # Default channels if host doesn't specify
|
||||
|
||||
|
||||
# Monitoring settings
|
||||
"interval": 20, # Expected heartbeat interval (for server checks)
|
||||
"grace": 2, # Grace multiplier (interval * grace = timeout)
|
||||
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
|
||||
|
||||
|
||||
# User management
|
||||
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
|
||||
"default_owner": None, # Username that owns hosts with no explicit owner
|
||||
|
||||
# Host management
|
||||
"hosts": {}, # New unified host definitions (optional)
|
||||
"watchhosts": [], # Hosts to monitor and notify about (legacy)
|
||||
@@ -321,20 +325,69 @@ def get_channel_config(config, channel_name):
|
||||
|
||||
def get_notification_channels_config(config, hostname):
|
||||
"""Get list of notification channel configurations for a host.
|
||||
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
hostname: Host name
|
||||
|
||||
|
||||
Returns:
|
||||
List of (channel_name, channel_config) tuples
|
||||
"""
|
||||
channel_names = get_notification_channels_for_host(config, hostname)
|
||||
|
||||
|
||||
channels = []
|
||||
for channel_name in channel_names:
|
||||
channel_config = get_channel_config(config, channel_name)
|
||||
if channel_config and channel_config.get("type"):
|
||||
channels.append((channel_name, channel_config))
|
||||
|
||||
|
||||
return channels
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User / host-access helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_default_owner(config) -> str | None:
|
||||
"""Return the configured default_owner username, or the first admin user, or None."""
|
||||
explicit = config.get("default_owner")
|
||||
if explicit:
|
||||
return explicit
|
||||
# Fall back to first admin user found in config
|
||||
users_cfg = config.get("users", {})
|
||||
if isinstance(users_cfg, dict):
|
||||
for username, attrs in users_cfg.items():
|
||||
if isinstance(attrs, dict) and attrs.get("admin", False):
|
||||
return username
|
||||
return None
|
||||
|
||||
|
||||
def get_host_access(config, hostname) -> dict:
|
||||
"""Return the access dict for *hostname*: owner, managers, monitors.
|
||||
|
||||
Falls back to default_owner for hosts without an explicit owner.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"owner": str | None,
|
||||
"managers": list[str],
|
||||
"monitors": list[str],
|
||||
}
|
||||
"""
|
||||
host_cfg = get_host_config(config, hostname)
|
||||
|
||||
owner = host_cfg.get("owner") or get_default_owner(config)
|
||||
|
||||
managers = host_cfg.get("managers", [])
|
||||
if isinstance(managers, str):
|
||||
managers = [managers]
|
||||
|
||||
monitors = host_cfg.get("monitors", [])
|
||||
if isinstance(monitors, str):
|
||||
monitors = [monitors]
|
||||
|
||||
return {
|
||||
"owner": owner,
|
||||
"managers": list(managers),
|
||||
"monitors": list(monitors),
|
||||
}
|
||||
|
||||
+44
-2
@@ -297,6 +297,10 @@ class Host:
|
||||
self.plugin_retention = 100 # Keep last N samples per plugin
|
||||
# Alert state tracking: {metric_path: AlertState}
|
||||
self.alert_states = {}
|
||||
# User access control
|
||||
self.owner: str | None = None # username of owner
|
||||
self.managers: list = [] # usernames with manager role
|
||||
self.monitors: list = [] # usernames with monitor role
|
||||
|
||||
def statedict(self):
|
||||
d = {}
|
||||
@@ -412,7 +416,12 @@ class Host:
|
||||
ddict["alert_warning_acked"] = warning_acked
|
||||
ddict["alert_critical_unacked"] = critical_unacked
|
||||
ddict["alert_critical_acked"] = critical_acked
|
||||
|
||||
|
||||
# User access
|
||||
ddict["owner"] = getattr(self, "owner", None)
|
||||
ddict["managers"] = list(getattr(self, "managers", []))
|
||||
ddict["monitors"] = list(getattr(self, "monitors", []))
|
||||
|
||||
return ddict
|
||||
|
||||
def jsons(self):
|
||||
@@ -458,6 +467,13 @@ class Host:
|
||||
self.plugin_retention = 100
|
||||
if not hasattr(self, "alert_states"):
|
||||
self.alert_states = {}
|
||||
# User access fields (added in user-management feature)
|
||||
if not hasattr(self, "owner"):
|
||||
self.owner = None
|
||||
if not hasattr(self, "managers"):
|
||||
self.managers = []
|
||||
if not hasattr(self, "monitors"):
|
||||
self.monitors = []
|
||||
|
||||
pass
|
||||
|
||||
@@ -511,12 +527,38 @@ class Host:
|
||||
|
||||
def get_all_plugin_data(self):
|
||||
"""Get all plugin data for this host.
|
||||
|
||||
|
||||
Returns:
|
||||
Dict of {plugin_name: [(timestamp, data), ...]}
|
||||
"""
|
||||
return self.plugin_data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# User-role helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def apply_access(self, owner, managers, monitors):
|
||||
"""Set owner/managers/monitors on this host (called from config load)."""
|
||||
self.owner = owner
|
||||
self.managers = list(managers)
|
||||
self.monitors = list(monitors)
|
||||
|
||||
def is_owner(self, username: str) -> bool:
|
||||
return self.owner == username
|
||||
|
||||
def is_manager(self, username: str) -> bool:
|
||||
return username in self.managers or self.is_owner(username)
|
||||
|
||||
def is_monitor(self, username: str) -> bool:
|
||||
return username in self.monitors or self.is_manager(username)
|
||||
|
||||
def access_dict(self) -> dict:
|
||||
return {
|
||||
"owner": self.owner,
|
||||
"managers": list(self.managers),
|
||||
"monitors": list(self.monitors),
|
||||
}
|
||||
|
||||
hostfields_long = [
|
||||
"name",
|
||||
"IPv4.addr",
|
||||
|
||||
+450
-35
@@ -10,6 +10,8 @@ from aiohttp import web
|
||||
import jinja2
|
||||
from . import data
|
||||
from . import notify as notify_mod
|
||||
from . import settings as settings_mod
|
||||
from . import users as users_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +22,78 @@ def _render_template(html_str: str, **context) -> str:
|
||||
return tmpl.render(**context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SESSION_COOKIE = "hbd_session"
|
||||
|
||||
|
||||
def _get_token(request) -> str:
|
||||
"""Extract session token from Bearer header, X-Auth-Token header, or cookie."""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.lower().startswith("bearer "):
|
||||
return auth[7:].strip()
|
||||
header_token = request.headers.get("X-Auth-Token", "").strip()
|
||||
if header_token:
|
||||
return header_token
|
||||
return request.cookies.get(SESSION_COOKIE, "")
|
||||
|
||||
|
||||
def _current_user(request):
|
||||
"""Return the authenticated User, or None when auth is not enabled."""
|
||||
if not users_mod.users_enabled():
|
||||
return None # unauthenticated mode — all access allowed
|
||||
return users_mod.get_session_user(_get_token(request))
|
||||
|
||||
|
||||
def _require_auth(request):
|
||||
"""Return (user, None) or (None, error Response)."""
|
||||
if not users_mod.users_enabled():
|
||||
return None, None
|
||||
user = users_mod.get_session_user(_get_token(request))
|
||||
if user is None:
|
||||
return None, web.json_response({"error": "Unauthorized"}, status=401)
|
||||
return user, None
|
||||
|
||||
|
||||
def _require_auth_redirect(request):
|
||||
"""Like _require_auth but returns a redirect to /login for browser requests."""
|
||||
if not users_mod.users_enabled():
|
||||
return None, None
|
||||
user = users_mod.get_session_user(_get_token(request))
|
||||
if user is None:
|
||||
raise web.HTTPFound("/login")
|
||||
return user, None
|
||||
|
||||
|
||||
def _can_view_host(user, host) -> bool:
|
||||
"""Return True if *user* may see *host* (monitor or higher, or no auth)."""
|
||||
if user is None:
|
||||
return True
|
||||
if user.admin:
|
||||
return True
|
||||
return host.is_monitor(user.username)
|
||||
|
||||
|
||||
def _can_operate_host(user, host) -> bool:
|
||||
"""Manager-level: queue commands, DNS, upgrade."""
|
||||
if user is None:
|
||||
return True
|
||||
if user.admin:
|
||||
return True
|
||||
return host.is_manager(user.username)
|
||||
|
||||
|
||||
def _can_own_host(user, host) -> bool:
|
||||
"""Owner-level: drop host, transfer ownership."""
|
||||
if user is None:
|
||||
return True
|
||||
if user.admin:
|
||||
return True
|
||||
return host.is_owner(user.username)
|
||||
|
||||
|
||||
async def start(
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -37,7 +111,8 @@ async def start(
|
||||
"""
|
||||
get_now = get_now or (lambda: time.time())
|
||||
|
||||
async def index(request):
|
||||
async def old_index(request):
|
||||
_require_auth_redirect(request)
|
||||
res = []
|
||||
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
|
||||
res.append("<html>")
|
||||
@@ -62,7 +137,15 @@ async def start(
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
async def api_hosts(request):
|
||||
lst = [hbdclass.Host.hosts[h].jsons() for h in hbdclass.Host.hosts]
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hosts = [
|
||||
hbdclass.Host.hosts[h]
|
||||
for h in hbdclass.Host.hosts
|
||||
if _can_view_host(user, hbdclass.Host.hosts[h])
|
||||
]
|
||||
lst = [h.jsons() for h in hosts]
|
||||
return web.json_response(json.loads("[" + ",".join(lst) + "]"))
|
||||
|
||||
async def api_messages(request):
|
||||
@@ -70,6 +153,9 @@ async def start(
|
||||
return web.json_response(lst)
|
||||
|
||||
async def cmd(request):
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
qa = request.rel_url.query
|
||||
uname = qa.get("h")
|
||||
ucmd = qa.get("c")
|
||||
@@ -77,34 +163,50 @@ async def start(
|
||||
return web.Response(status=400, text="need h= and c= arguments")
|
||||
if uname not in hbdclass.Host.hosts:
|
||||
return web.Response(status=400, text=f"h={uname} not found")
|
||||
hbdclass.Host.hosts[uname].cmds.append(
|
||||
("CMD", {"cmd": urllib.parse.unquote(ucmd)})
|
||||
)
|
||||
host = hbdclass.Host.hosts[uname]
|
||||
if not _can_operate_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
host.cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)}))
|
||||
return web.Response(text=f"cmd {uname} queued")
|
||||
|
||||
async def drop(request):
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
qa = request.rel_url.query
|
||||
uname = qa.get("h")
|
||||
if not uname:
|
||||
return web.Response(status=400, text="need h= argument")
|
||||
if uname not in hbdclass.Host.hosts:
|
||||
return web.Response(status=400, text=f"h={uname} not found")
|
||||
host = hbdclass.Host.hosts[uname]
|
||||
if not _can_own_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
eventlog(uname, "INFO", "dropped")
|
||||
del hbdclass.Host.hosts[uname]
|
||||
return web.Response(text="Done")
|
||||
|
||||
async def register(request):
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
qa = request.rel_url.query
|
||||
uname = qa.get("h")
|
||||
if not uname:
|
||||
return web.Response(status=400, text="need h= argument")
|
||||
if uname not in hbdclass.Host.hosts:
|
||||
return web.Response(status=400, text=f"h={uname} not found")
|
||||
ll = hbdclass.Host.hosts[uname].registerDns()
|
||||
host = hbdclass.Host.hosts[uname]
|
||||
if not _can_operate_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
ll = host.registerDns()
|
||||
eventlog(uname, "INFO", ll)
|
||||
return web.Response(text=str(ll))
|
||||
|
||||
async def update(request):
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
qa = request.rel_url.query
|
||||
uname = urllib.parse.unquote(qa.get("h", ""))
|
||||
ucode = qa.get("c")
|
||||
@@ -118,16 +220,21 @@ async def start(
|
||||
names = [n for n in hbdclass.Host.hosts]
|
||||
out = []
|
||||
for n in names:
|
||||
err = None
|
||||
host = hbdclass.Host.hosts[n]
|
||||
if not _can_operate_host(user, host):
|
||||
out.append(f"update skipped for {n}: Forbidden")
|
||||
continue
|
||||
op_err = None
|
||||
try:
|
||||
r = {"csum": None, "code": ucode}
|
||||
hbdclass.Host.hosts[n].cmds.append(("UPD", r))
|
||||
host.cmds.append(("UPD", r))
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
out.append(f"update started for {n}: {err if err else 'OK'}")
|
||||
op_err = str(e)
|
||||
out.append(f"update started for {n}: {op_err if op_err else 'OK'}")
|
||||
return web.Response(text="\n".join(out))
|
||||
|
||||
async def live(request):
|
||||
current_user, _ = _require_auth_redirect(request)
|
||||
# render template from hbd/templates/live.html using Jinja2
|
||||
# Resolve templates directory relative to the hbd package
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
@@ -151,6 +258,8 @@ async def start(
|
||||
hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)
|
||||
],
|
||||
messages=data.msgs[-30:],
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="live",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
@@ -185,16 +294,18 @@ async def start(
|
||||
|
||||
async def api_host_plugins(request):
|
||||
"""Get all plugin data for a specific host."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response(
|
||||
{"error": f"Host '{hostname}' not found"},
|
||||
status=404
|
||||
)
|
||||
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
|
||||
# Get plugin data with most recent sample for each plugin
|
||||
plugins_summary = {}
|
||||
for plugin_name, samples in host.plugin_data.items():
|
||||
@@ -214,16 +325,18 @@ async def start(
|
||||
|
||||
async def api_host_plugin_detail(request):
|
||||
"""Get detailed data for a specific plugin on a host."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
plugin_name = request.match_info.get("plugin_name")
|
||||
|
||||
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response(
|
||||
{"error": f"Host '{hostname}' not found"},
|
||||
status=404
|
||||
)
|
||||
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
|
||||
# Get limit from query parameter
|
||||
limit = request.rel_url.query.get("limit", "10")
|
||||
@@ -259,15 +372,17 @@ async def start(
|
||||
|
||||
async def api_host_alerts(request):
|
||||
"""Get alert states for a specific host."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
|
||||
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response(
|
||||
{"error": f"Host '{hostname}' not found"},
|
||||
status=404
|
||||
)
|
||||
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
|
||||
# Get alert states
|
||||
alerts = []
|
||||
@@ -287,9 +402,14 @@ async def start(
|
||||
|
||||
async def api_all_alerts(request):
|
||||
"""Get all active alerts across all hosts."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
all_alerts = []
|
||||
|
||||
|
||||
for hostname, host in hbdclass.Host.hosts.items():
|
||||
if not _can_view_host(user, host):
|
||||
continue
|
||||
if threshold_checker:
|
||||
active_alerts = threshold_checker.get_active_alerts(host.alert_states)
|
||||
else:
|
||||
@@ -326,6 +446,9 @@ async def start(
|
||||
|
||||
async def api_acknowledge_alert(request):
|
||||
"""Acknowledge an alert to stop reminder notifications."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
@@ -350,7 +473,9 @@ async def start(
|
||||
)
|
||||
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
|
||||
if metric_path not in host.alert_states:
|
||||
return web.json_response(
|
||||
{"error": f"Alert '{metric_path}' not found for host '{hostname}'"},
|
||||
@@ -373,50 +498,338 @@ async def start(
|
||||
|
||||
async def plugins_page(request):
|
||||
"""Render the plugin metrics visualization page."""
|
||||
current_user, _ = _require_auth_redirect(request)
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
||||
|
||||
# Collect all hosts with plugin data
|
||||
|
||||
# Collect all hosts with plugin data (filtered by visibility)
|
||||
hosts_with_plugins = []
|
||||
for hostname in sorted(hbdclass.Host.hosts.keys()):
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(current_user, host):
|
||||
continue
|
||||
if host.plugin_data:
|
||||
hosts_with_plugins.append({
|
||||
"name": hostname,
|
||||
"plugins": list(host.plugin_data.keys()),
|
||||
})
|
||||
|
||||
|
||||
tmpl = env.get_template("plugins.html")
|
||||
body = tmpl.render(
|
||||
title="Plugin Metrics - Heartbeat",
|
||||
header="Plugin Metrics",
|
||||
hosts=hosts_with_plugins,
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="plugins",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
async def alerts_page(request):
|
||||
"""Render the alerts dashboard page."""
|
||||
current_user, _ = _require_auth_redirect(request)
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
||||
|
||||
|
||||
tmpl = env.get_template("alerts.html")
|
||||
body = tmpl.render(
|
||||
title="Alerts Dashboard - Heartbeat",
|
||||
header="Alerts Dashboard",
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="alerts",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Auth endpoints
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def api_login(request):
|
||||
"""POST /api/0/auth/login {username, password} -> {token}
|
||||
Also sets an hbd_session cookie for browser clients.
|
||||
"""
|
||||
if not users_mod.users_enabled():
|
||||
return web.json_response({"error": "Auth not configured"}, status=404)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
username = body.get("username", "")
|
||||
password = body.get("password", "")
|
||||
user = users_mod.authenticate(username, password)
|
||||
if user is None:
|
||||
return web.json_response({"error": "Invalid credentials"}, status=401)
|
||||
token = users_mod.create_session(username)
|
||||
resp = web.json_response({"token": token, "username": username})
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
max_age=users_mod.SESSION_TTL,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
return resp
|
||||
|
||||
async def login_page(request):
|
||||
"""GET /login — show login form; POST /login — process and redirect."""
|
||||
if not users_mod.users_enabled():
|
||||
raise web.HTTPFound("/")
|
||||
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
form = await request.post()
|
||||
username = form.get("username", "")
|
||||
password = form.get("password", "")
|
||||
user = users_mod.authenticate(username, password)
|
||||
if user:
|
||||
token = users_mod.create_session(username)
|
||||
redirect_to = request.rel_url.query.get("next", "/")
|
||||
resp = web.HTTPFound(redirect_to)
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
max_age=users_mod.SESSION_TTL,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
raise resp
|
||||
error = "Invalid username or password."
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Heartbeat — Login</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; background: #f5f5f5; display: flex;
|
||||
justify-content: center; align-items: center; height: 100vh; margin: 0; }}
|
||||
.box {{ background: #fff; padding: 2em 2.5em; border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.15); min-width: 300px; }}
|
||||
h2 {{ margin: 0 0 1.2em; color: #333; font-size: 1.4em; }}
|
||||
label {{ display: block; margin-bottom: .3em; font-size: .9em; color: #555; }}
|
||||
input {{ width: 100%; padding: .5em .7em; border: 1px solid #ccc;
|
||||
border-radius: 4px; font-size: 1em; box-sizing: border-box; }}
|
||||
button {{ margin-top: 1.2em; width: 100%; padding: .6em; background: #0066cc;
|
||||
color: #fff; border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }}
|
||||
button:hover {{ background: #0055aa; }}
|
||||
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
|
||||
.field {{ margin-bottom: .9em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>Heartbeat</h2>
|
||||
{'<p class="error">' + error + '</p>' if error else ''}
|
||||
<form method="post">
|
||||
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
||||
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
async def web_logout(request):
|
||||
"""GET /logout — clear session cookie and redirect to /login."""
|
||||
token = request.cookies.get(SESSION_COOKIE, "")
|
||||
users_mod.delete_session(token)
|
||||
resp = web.HTTPFound("/login")
|
||||
resp.del_cookie(SESSION_COOKIE)
|
||||
raise resp
|
||||
|
||||
async def api_logout(request):
|
||||
"""POST /api/0/auth/logout"""
|
||||
token = _get_token(request)
|
||||
users_mod.delete_session(token)
|
||||
resp = web.json_response({"success": True})
|
||||
resp.del_cookie(SESSION_COOKIE)
|
||||
return resp
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# User endpoints
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def api_user_avatar(request):
|
||||
"""GET /api/0/users/{username}/avatar — serve a local avatar file.
|
||||
|
||||
Only reachable when the user's avatar config value starts with '/'.
|
||||
Falls back to 404 for external URLs (the browser fetches those directly).
|
||||
"""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
username = request.match_info.get("username")
|
||||
target_user = users_mod.get_user(username)
|
||||
if target_user is None:
|
||||
return web.Response(status=404, text="User not found")
|
||||
if not target_user.avatar_is_local():
|
||||
return web.Response(status=404, text="No local avatar configured")
|
||||
path = target_user.avatar
|
||||
if not os.path.isfile(path):
|
||||
return web.Response(status=404, text="Avatar file not found")
|
||||
# Infer content-type from extension
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
mime = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
}.get(ext, "application/octet-stream")
|
||||
return web.FileResponse(path=path, headers={"Content-Type": mime})
|
||||
|
||||
async def api_users(request):
|
||||
"""GET /api/0/users — admin only."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
if users_mod.users_enabled() and (user is None or not user.admin):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
return web.json_response([u.to_dict() for u in users_mod.users.values()])
|
||||
|
||||
async def api_user_self(request):
|
||||
"""GET /api/0/users/me — own profile."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
if user is None:
|
||||
return web.json_response({"error": "Auth not configured"}, status=404)
|
||||
return web.json_response(user.to_dict())
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Host access endpoints
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def api_host_access_get(request):
|
||||
"""GET /api/0/hosts/{hostname}/access"""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
return web.json_response(host.access_dict())
|
||||
|
||||
async def api_host_access_put(request):
|
||||
"""PUT /api/0/hosts/{hostname}/access — owner or admin only.
|
||||
|
||||
Body: {owner?: str, managers?: [str], monitors?: [str]}
|
||||
"""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_own_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
if "owner" in body:
|
||||
host.owner = body["owner"] or None
|
||||
if "managers" in body:
|
||||
host.managers = list(body["managers"])
|
||||
if "monitors" in body:
|
||||
host.monitors = list(body["monitors"])
|
||||
|
||||
return web.json_response(host.access_dict())
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# User profile page
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def profile_page(request):
|
||||
"""GET /profile — current user's settings and host access summary."""
|
||||
current_user, _ = _require_auth_redirect(request)
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
||||
|
||||
# Build host access summary for this user
|
||||
owned, managed, monitored = [], [], []
|
||||
if current_user:
|
||||
for hostname, host in sorted(hbdclass.Host.hosts.items()):
|
||||
if host.is_owner(current_user.username):
|
||||
owned.append(hostname)
|
||||
elif host.is_manager(current_user.username):
|
||||
managed.append(hostname)
|
||||
elif host.is_monitor(current_user.username):
|
||||
monitored.append(hostname)
|
||||
|
||||
# Resolve notification channel configs for display
|
||||
notif_channels = []
|
||||
if current_user:
|
||||
for ch_name in (current_user.notification_channels or []):
|
||||
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
||||
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
|
||||
|
||||
tmpl = env.get_template("profile.html")
|
||||
body = tmpl.render(
|
||||
title="Profile - Heartbeat",
|
||||
header="My Profile",
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
owned_hosts=owned,
|
||||
managed_hosts=managed,
|
||||
monitored_hosts=monitored,
|
||||
notification_channels=notif_channels,
|
||||
active_page="profile",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Settings page (admin only)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def settings_page(request):
|
||||
"""GET /settings — read-only view of the current server configuration."""
|
||||
current_user, _ = _require_auth_redirect(request)
|
||||
if current_user and not current_user.admin:
|
||||
raise web.HTTPForbidden(reason="Admin access required")
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
||||
tmpl = env.get_template("settings.html")
|
||||
body = tmpl.render(
|
||||
title="Settings - Heartbeat",
|
||||
sections=settings_mod.get_settings_sections(config),
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="settings",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes(
|
||||
[
|
||||
web.get("/", index),
|
||||
web.get("/", live),
|
||||
web.get("/old", old_index),
|
||||
# Auth
|
||||
web.get("/login", login_page),
|
||||
web.post("/login", login_page),
|
||||
web.get("/logout", web_logout),
|
||||
web.post("/api/0/auth/login", api_login),
|
||||
web.post("/api/0/auth/logout", api_logout),
|
||||
# Users
|
||||
web.get("/api/0/users", api_users),
|
||||
web.get("/api/0/users/me", api_user_self),
|
||||
web.get("/api/0/users/{username}/avatar", api_user_avatar),
|
||||
# Hosts
|
||||
web.get("/api/0/hosts", api_hosts),
|
||||
web.get("/api/0/messages", api_messages),
|
||||
web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins),
|
||||
web.get("/api/0/hosts/{hostname}/plugins/{plugin_name}", api_host_plugin_detail),
|
||||
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
||||
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
||||
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
||||
web.get("/api/0/alerts", api_all_alerts),
|
||||
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
||||
web.get("/c", cmd),
|
||||
@@ -426,6 +839,8 @@ async def start(
|
||||
web.get("/live", live),
|
||||
web.get("/plugins", plugins_page),
|
||||
web.get("/alerts", alerts_page),
|
||||
web.get("/profile", profile_page),
|
||||
web.get("/settings", settings_page),
|
||||
web.get("/static/{path:.*}", static),
|
||||
web.get("/favicon.ico", favicon),
|
||||
]
|
||||
|
||||
+17
-2
@@ -14,7 +14,8 @@ from . import hbdclass
|
||||
|
||||
from . import ws as ws_mod
|
||||
from . import notify as notify_mod
|
||||
from . import data
|
||||
from . import data
|
||||
from . import users as users_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
msg_to_websockets = ws_mod.broadcast
|
||||
@@ -84,7 +85,16 @@ async def reload_configuration(config_obj, config_path, components):
|
||||
|
||||
# Update notify module
|
||||
notify_mod.reload_config(new_config)
|
||||
|
||||
|
||||
# Reload users
|
||||
users_mod.load_users(new_config)
|
||||
|
||||
# Re-apply host access from updated config to all known hosts
|
||||
from . import config as config_mod
|
||||
for hostname, host in hbdclass.Host.hosts.items():
|
||||
access = config_mod.get_host_access(new_config, hostname)
|
||||
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||
|
||||
# Reload threshold checker
|
||||
if 'threshold_checker' in components:
|
||||
components['threshold_checker'].reload(new_config)
|
||||
@@ -436,6 +446,10 @@ def load_pickled_hosts(config, hbdclass):
|
||||
hbdclass.Host.hosts[h].dyn = h in dyndnshosts
|
||||
hbdclass.Host.hosts[h].watched = h in watchhosts
|
||||
hbdclass.Host.hosts[h].fixup()
|
||||
access = config_mod.get_host_access(config, h)
|
||||
hbdclass.Host.hosts[h].apply_access(
|
||||
access["owner"], access["managers"], access["monitors"]
|
||||
)
|
||||
for h in drophosts:
|
||||
if h in hbdclass.Host.hosts:
|
||||
del hbdclass.Host.hosts[h]
|
||||
@@ -463,6 +477,7 @@ def run(config, config_path=None):
|
||||
load_pickled_hosts(config, hbdclass)
|
||||
|
||||
notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
|
||||
users_mod.load_users(config)
|
||||
eventlog(None, "INFO", f"hbd version {__version__} starting up")
|
||||
|
||||
if config_path:
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Settings descriptor: maps config keys to display metadata.
|
||||
|
||||
``get_settings_sections(config)`` returns an ordered list of sections, each
|
||||
containing a list of field descriptors. The template iterates this structure
|
||||
generically, so adding editability later is a matter of:
|
||||
|
||||
1. Setting ``"editable": True`` on a field.
|
||||
2. Adding the matching ``<input>``/``<select>`` in the template
|
||||
(guided by ``"type"``).
|
||||
3. Wiring a POST handler in http.py.
|
||||
|
||||
Field descriptor keys
|
||||
---------------------
|
||||
key str Config key (for future form POST matching)
|
||||
label str Human-readable label
|
||||
description str One-line help text shown below the value
|
||||
value any Sanitized display value (secrets replaced with "•••")
|
||||
type str One of: text | number | port | boolean | path | duration |
|
||||
list | secret | size | select
|
||||
editable bool Reserved for future use — currently always False
|
||||
sensitive bool True when the raw value must never be shown
|
||||
"""
|
||||
|
||||
# Credential field names that should always be masked.
|
||||
_SECRET_KEYS = frozenset({
|
||||
"password", "token", "user_key", "api_key", "secret",
|
||||
"smtp_password", "smtp_user",
|
||||
})
|
||||
|
||||
_CHANNEL_TYPE_LABELS = {
|
||||
"pushover": "Pushover",
|
||||
"email": "E-mail",
|
||||
"signal": "Signal",
|
||||
"mattermost": "Mattermost",
|
||||
}
|
||||
|
||||
|
||||
def _mask(value):
|
||||
"""Return a masked placeholder for sensitive values."""
|
||||
if not value:
|
||||
return ""
|
||||
return "•••"
|
||||
|
||||
|
||||
def _fmt_size(n):
|
||||
"""Format a byte count as a human-readable string."""
|
||||
try:
|
||||
n = int(n)
|
||||
except (TypeError, ValueError):
|
||||
return str(n)
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if n < 1024:
|
||||
return f"{n} {unit}"
|
||||
n //= 1024
|
||||
return f"{n} TB"
|
||||
|
||||
|
||||
def _fmt_duration(seconds):
|
||||
"""Format seconds into a human-readable duration string."""
|
||||
try:
|
||||
s = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return str(seconds)
|
||||
if s < 60:
|
||||
return f"{s}s"
|
||||
if s < 3600:
|
||||
m, sec = divmod(s, 60)
|
||||
return f"{m}m {sec}s" if sec else f"{m}m"
|
||||
h, rem = divmod(s, 3600)
|
||||
m = rem // 60
|
||||
return f"{h}h {m}m" if m else f"{h}h"
|
||||
|
||||
|
||||
def _sanitize_channel(name, cfg):
|
||||
"""Return a sanitized copy of a notification channel config."""
|
||||
result = {}
|
||||
for k, v in cfg.items():
|
||||
if k in _SECRET_KEYS:
|
||||
result[k] = _mask(v)
|
||||
elif isinstance(v, list):
|
||||
result[k] = v
|
||||
else:
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_settings_sections(config: dict) -> list:
|
||||
"""Return ordered list of setting sections for the settings page.
|
||||
|
||||
Each section:
|
||||
{
|
||||
"title": str,
|
||||
"description": str,
|
||||
"fields": [ field_descriptor, ... ]
|
||||
}
|
||||
|
||||
Each field_descriptor:
|
||||
{
|
||||
"key": str,
|
||||
"label": str,
|
||||
"description": str,
|
||||
"value": display_value,
|
||||
"raw": raw_config_value, # None for sensitive
|
||||
"type": str,
|
||||
"editable": bool,
|
||||
"sensitive": bool,
|
||||
}
|
||||
"""
|
||||
def field(key, label, ftype, description="", editable=False, sensitive=False):
|
||||
raw = config.get(key)
|
||||
if sensitive:
|
||||
display = _mask(raw)
|
||||
raw_out = None
|
||||
elif ftype == "size":
|
||||
display = _fmt_size(raw)
|
||||
raw_out = raw
|
||||
elif ftype == "duration":
|
||||
display = _fmt_duration(raw)
|
||||
raw_out = raw
|
||||
elif ftype == "boolean":
|
||||
display = bool(raw)
|
||||
raw_out = raw
|
||||
elif ftype == "list":
|
||||
val = raw or []
|
||||
display = list(val) if not isinstance(val, list) else val
|
||||
raw_out = display
|
||||
else:
|
||||
display = raw if raw is not None else ""
|
||||
raw_out = raw
|
||||
return {
|
||||
"key": key,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"value": display,
|
||||
"raw": raw_out,
|
||||
"type": ftype,
|
||||
"editable": editable,
|
||||
"sensitive": sensitive,
|
||||
}
|
||||
|
||||
# ---- Notification channels (complex, built separately) ----------------
|
||||
notif_channels = []
|
||||
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
|
||||
if not isinstance(ch_cfg, dict):
|
||||
continue
|
||||
ch_type = ch_cfg.get("type", "")
|
||||
fields = []
|
||||
for k, v in ch_cfg.items():
|
||||
if k == "type":
|
||||
continue
|
||||
sensitive = k in _SECRET_KEYS
|
||||
fields.append({
|
||||
"key": k,
|
||||
"label": k.replace("_", " ").title(),
|
||||
"value": _mask(v) if sensitive else (
|
||||
", ".join(v) if isinstance(v, list) else str(v)
|
||||
),
|
||||
"sensitive": sensitive,
|
||||
})
|
||||
notif_channels.append({
|
||||
"name": ch_name,
|
||||
"type": ch_type,
|
||||
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
|
||||
"fields": fields,
|
||||
})
|
||||
|
||||
# ---- Users (show metadata only, never password hashes) ----------------
|
||||
users_list = []
|
||||
for username, attrs in (config.get("users") or {}).items():
|
||||
if not isinstance(attrs, dict):
|
||||
continue
|
||||
users_list.append({
|
||||
"username": username,
|
||||
"full_name": attrs.get("full_name", ""),
|
||||
"admin": bool(attrs.get("admin", False)),
|
||||
"avatar": attrs.get("avatar", ""),
|
||||
"notification_channels": attrs.get("notification_channels", []),
|
||||
})
|
||||
|
||||
# ---- Hosts summary ----------------------------------------------------
|
||||
hosts_list = []
|
||||
for hname, hcfg in (config.get("hosts") or {}).items():
|
||||
if not isinstance(hcfg, dict):
|
||||
continue
|
||||
hosts_list.append({
|
||||
"name": hname,
|
||||
"watch": bool(hcfg.get("watch", False)),
|
||||
"dyndns": bool(hcfg.get("dyndns", False)),
|
||||
"owner": hcfg.get("owner", ""),
|
||||
"managers": hcfg.get("managers", []),
|
||||
"monitors": hcfg.get("monitors", []),
|
||||
"threshold_config": hcfg.get("threshold_config", ""),
|
||||
"notification_channels": hcfg.get("notification_channels", []),
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
"id": "network",
|
||||
"title": "Network",
|
||||
"description": "Ports and bind addresses for all server sockets.",
|
||||
"fields": [
|
||||
field("hb_port", "Heartbeat UDP port", "port",
|
||||
"UDP port the server listens on for heartbeat datagrams."),
|
||||
field("hbd_host", "HTTP bind address", "text",
|
||||
"Interface to bind the HTTP server to. Empty = all interfaces."),
|
||||
field("hbd_port", "HTTP API port", "port",
|
||||
"TCP port for the HTTP API and web UI."),
|
||||
field("ws_port", "WebSocket port", "port",
|
||||
"TCP port for the plain WebSocket server."),
|
||||
field("wss_port", "Secure WebSocket port", "port",
|
||||
"TCP port for WSS (TLS WebSocket). Leave empty to disable."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tls",
|
||||
"title": "TLS / WebSocket Security",
|
||||
"description": "Certificate paths used when wss_port is set.",
|
||||
"fields": [
|
||||
field("cert_path", "Certificate directory", "path",
|
||||
"Directory containing the TLS certificate and key files."),
|
||||
field("wss_pem", "Certificate file", "text",
|
||||
"Filename of the TLS certificate chain (PEM format)."),
|
||||
field("wss_key", "Key file", "text",
|
||||
"Filename of the TLS private key (PEM format)."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "monitoring",
|
||||
"title": "Monitoring",
|
||||
"description": "Heartbeat timing and alert re-notification behaviour.",
|
||||
"fields": [
|
||||
field("interval", "Heartbeat interval", "duration",
|
||||
"Expected time between heartbeat messages from each client."),
|
||||
field("grace", "Grace multiplier", "number",
|
||||
"A host is marked overdue after interval × grace seconds of silence."),
|
||||
field("threshold_renotify_interval", "Re-notify interval", "duration",
|
||||
"How often to re-send notifications for ongoing threshold alerts."),
|
||||
field("autosave_interval", "Autosave interval", "duration",
|
||||
"How often the server saves its state to disk."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "persistence",
|
||||
"title": "Persistence & Logging",
|
||||
"description": "State file and event log settings.",
|
||||
"fields": [
|
||||
field("pickfile", "State file", "path",
|
||||
"Path to the pickle file used to persist host state across restarts."),
|
||||
field("logfile", "Event log", "path",
|
||||
"Path to the event log file."),
|
||||
field("logfmt", "Log format", "select",
|
||||
"Format for event log entries: text, msg, or json."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "journal",
|
||||
"title": "Message Journal",
|
||||
"description": "All received heartbeat and plugin messages are journalled here.",
|
||||
"fields": [
|
||||
field("journal_enabled", "Enabled", "boolean",
|
||||
"Turn journalling on or off."),
|
||||
field("journal_dir", "Journal directory","path",
|
||||
"Directory where journal files are written."),
|
||||
field("journal_file", "Journal filename", "text",
|
||||
"Base filename for the journal (rotated copies get a numeric suffix)."),
|
||||
field("journal_max_size", "Max file size", "size",
|
||||
"Rotate the journal when it exceeds this size."),
|
||||
field("journal_max_backups", "Backup count", "number",
|
||||
"Number of rotated journal files to keep."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dns",
|
||||
"title": "Dynamic DNS",
|
||||
"description": "nsupdate-based DNS registration for dynamic hosts.",
|
||||
"fields": [
|
||||
field("nsupdate_bin", "nsupdate binary", "path",
|
||||
"Full path to the nsupdate executable."),
|
||||
field("dyndomains", "Dynamic domains", "list",
|
||||
"DNS zones managed by nsupdate for dynamic hosts."),
|
||||
field("drophosts", "Drop hosts", "list",
|
||||
"Hostnames to silently ignore — no state, no alerts."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "users",
|
||||
"title": "Users",
|
||||
"description": "Accounts defined in the config file. Password hashes are never shown.",
|
||||
"users": users_list,
|
||||
"fields": [
|
||||
field("default_owner", "Default owner", "text",
|
||||
"Username that owns hosts with no explicit owner. "
|
||||
"Falls back to the first admin user."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "channels",
|
||||
"title": "Notification Channels",
|
||||
"description": "Named notification providers. Credentials are masked.",
|
||||
"channels": notif_channels,
|
||||
"fields": [
|
||||
field("default_notification_channels", "Default channels", "list",
|
||||
"Channels used when a host does not specify its own."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "hosts",
|
||||
"title": "Hosts",
|
||||
"description": "Host definitions loaded from the config file.",
|
||||
"hosts": hosts_list,
|
||||
"fields": [],
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"title": "Runtime",
|
||||
"description": "Flags set at startup (require restart to change).",
|
||||
"fields": [
|
||||
field("foreground", "Foreground mode", "boolean",
|
||||
"Run in the foreground instead of daemonising."),
|
||||
field("verbose", "Verbose logging", "boolean",
|
||||
"Enable verbose log output."),
|
||||
field("debug", "Debug level", "number",
|
||||
"0 = off. Higher values increase log verbosity."),
|
||||
],
|
||||
},
|
||||
]
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 181 KiB |
@@ -139,4 +139,5 @@
|
||||
font-size: 9px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,30 +8,6 @@
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -327,11 +303,7 @@
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/live">Live Dashboard</a>
|
||||
<a href="/plugins">Plugin Metrics</a>
|
||||
<a href="/alerts" class="active">Alerts</a>
|
||||
</div>
|
||||
{% include 'nav.html' %}
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
|
||||
@@ -3,5 +3,59 @@
|
||||
<link rel="stylesheet" href="/static/style.css" type="text/css" />
|
||||
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
|
||||
<title>{{ title }}</title>
|
||||
<script src="{{ extra_scripts }}"></script>
|
||||
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
||||
<style>
|
||||
/* Navigation bar — shared across all pages */
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.nav-links { display: flex; align-items: center; }
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.nav a:hover { text-decoration: underline; }
|
||||
.nav a.active { color: #333; font-weight: bold; }
|
||||
.nav-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: #333;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
||||
.nav-avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-initials {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #0066cc;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75em;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -9,31 +9,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
@@ -419,11 +394,7 @@
|
||||
WS_Connect();
|
||||
</script>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/live" class="active">Live Dashboard</a>
|
||||
<a href="/plugins">Plugin Metrics</a>
|
||||
<a href="/alerts">Alerts</a>
|
||||
</div>
|
||||
{% include 'nav.html' %}
|
||||
|
||||
{% include 'menu.html' %}
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
||||
s<header>{{ header }}</header> -->
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<div class="nav">
|
||||
<div class="nav-links">
|
||||
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
||||
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Plugin Metrics</a>
|
||||
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
||||
{% if current_user and current_user.admin %}
|
||||
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if current_user %}
|
||||
<a href="/profile" class="nav-user{% if active_page == 'profile' %} active{% endif %}" title="{{ current_user.full_name or current_user.username }}">
|
||||
{% if current_user.avatar %}
|
||||
<img class="nav-avatar" src="{{ current_user.avatar_url }}" alt="{{ current_user.full_name or current_user.username }}">
|
||||
{% else %}
|
||||
<span class="nav-initials">{{ (current_user.full_name or current_user.username)[:1] | upper }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -9,31 +9,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #fff;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nav a {
|
||||
margin-right: 20px;
|
||||
text-decoration: none;
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nav a.active {
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -357,11 +332,7 @@
|
||||
</style>
|
||||
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/live">Live Dashboard</a>
|
||||
<a href="/plugins" class="active">Plugin Metrics</a>
|
||||
<a href="/alerts">Alerts</a>
|
||||
</div>
|
||||
{% include 'nav.html' %}
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ---- Profile card ---- */
|
||||
.profile-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.avatar-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.avatar-initials-large {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: #0066cc;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.profile-info { flex: 1; }
|
||||
|
||||
.profile-name {
|
||||
font-size: 1.4em;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.profile-username {
|
||||
font-size: 0.9em;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.badge-admin { background: #e8f0fe; color: #1a73e8; }
|
||||
.badge-user { background: #f1f3f4; color: #555; }
|
||||
|
||||
.profile-logout {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
display: inline-block;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
background: #f44336;
|
||||
color: #fff;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-logout:hover { background: #d32f2f; text-decoration: none; }
|
||||
|
||||
/* ---- Section cards ---- */
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin: 0 0 16px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ---- Settings rows ---- */
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.settings-row:last-child { border-bottom: none; }
|
||||
|
||||
.settings-label {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.settings-value { color: #222; }
|
||||
|
||||
.settings-empty { color: #aaa; font-style: italic; }
|
||||
|
||||
/* ---- Host lists ---- */
|
||||
.host-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.host-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.host-chip.owner { background: #e8f5e9; color: #2e7d32; }
|
||||
.host-chip.manager { background: #e3f2fd; color: #1565c0; }
|
||||
.host-chip.monitor { background: #f3e5f5; color: #6a1b9a; }
|
||||
|
||||
.host-chip-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
}
|
||||
.owner .host-chip-dot { background: #2e7d32; }
|
||||
.manager .host-chip-dot { background: #1565c0; }
|
||||
.monitor .host-chip-dot { background: #6a1b9a; }
|
||||
|
||||
.no-hosts {
|
||||
color: #aaa;
|
||||
font-size: 0.9em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---- Notification channels ---- */
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.channel-row:last-child { border-bottom: none; }
|
||||
|
||||
.channel-type {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
background: #f1f3f4;
|
||||
color: #555;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-name { color: #333; }
|
||||
</style>
|
||||
|
||||
<body>
|
||||
{% include 'nav.html' %}
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
<p class="subtitle">Your account settings and host access</p>
|
||||
|
||||
<!-- Profile card -->
|
||||
<div class="profile-card">
|
||||
{% if current_user and current_user.avatar %}
|
||||
<img class="avatar-large" src="{{ current_user.avatar_url }}" alt="">
|
||||
{% else %}
|
||||
<div class="avatar-initials-large">
|
||||
{{ ((current_user.full_name if current_user else '') or (current_user.username if current_user else '?'))[:1] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="profile-info">
|
||||
<div class="profile-name">{{ current_user.full_name if current_user and current_user.full_name else (current_user.username if current_user else '—') }}</div>
|
||||
<div class="profile-username">@{{ current_user.username if current_user else '—' }}</div>
|
||||
{% if current_user and current_user.admin %}
|
||||
<span class="badge badge-admin">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">User</span>
|
||||
{% endif %}
|
||||
<div class="profile-logout">
|
||||
<a href="/logout" class="btn-logout">Sign out</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account settings -->
|
||||
<div class="section">
|
||||
<h2>Account</h2>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Username</span>
|
||||
<span class="settings-value">{{ current_user.username if current_user else '—' }}</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Full name</span>
|
||||
{% if current_user and current_user.full_name %}
|
||||
<span class="settings-value">{{ current_user.full_name }}</span>
|
||||
{% else %}
|
||||
<span class="settings-empty">Not set</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Role</span>
|
||||
<span class="settings-value">{{ 'Administrator' if current_user and current_user.admin else 'User' }}</span>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Avatar</span>
|
||||
{% if current_user and current_user.avatar %}
|
||||
<span class="settings-value" style="word-break:break-all;">{{ current_user.avatar }}</span>
|
||||
{% else %}
|
||||
<span class="settings-empty">Not set (initials used)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notification channels -->
|
||||
<div class="section">
|
||||
<h2>Notification Channels</h2>
|
||||
{% if notification_channels %}
|
||||
{% for ch in notification_channels %}
|
||||
<div class="channel-row">
|
||||
<span class="channel-type">{{ ch.type }}</span>
|
||||
<span class="channel-name">{{ ch.name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="no-hosts">No personal notification channels configured.</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Host access -->
|
||||
<div class="section">
|
||||
<h2>Host Access</h2>
|
||||
|
||||
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
|
||||
<span class="settings-label" style="padding-top: 2px;">Owner</span>
|
||||
<div class="host-grid">
|
||||
{% if owned_hosts %}
|
||||
{% for h in owned_hosts %}
|
||||
<span class="host-chip owner"><span class="host-chip-dot"></span>{{ h }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="no-hosts">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
|
||||
<span class="settings-label" style="padding-top: 2px;">Manager</span>
|
||||
<div class="host-grid">
|
||||
{% if managed_hosts %}
|
||||
{% for h in managed_hosts %}
|
||||
<span class="host-chip manager"><span class="host-chip-dot"></span>{{ h }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="no-hosts">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row" style="align-items: flex-start; padding-bottom: 4px;">
|
||||
<span class="settings-label" style="padding-top: 2px;">Monitor</span>
|
||||
<div class="host-grid">
|
||||
{% if monitored_hosts %}
|
||||
{% for h in monitored_hosts %}
|
||||
<span class="host-chip monitor"><span class="host-chip-dot"></span>{{ h }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="no-hosts">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 { color: #333; margin-bottom: 4px; font-size: 1.5em; }
|
||||
.subtitle { color: #666; margin-bottom: 24px; font-size: 0.9em; }
|
||||
|
||||
/* ---- Sidebar + content layout ---- */
|
||||
.settings-layout {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.settings-sidebar {
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-size: 0.85em;
|
||||
color: #444;
|
||||
margin-bottom: 2px;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.sidebar-nav a:hover { background: #e8eaf6; color: #1a237e; }
|
||||
.sidebar-nav a.active { background: #e3f2fd; color: #0066cc; font-weight: 600; }
|
||||
|
||||
.settings-main { flex: 1; min-width: 0; }
|
||||
|
||||
/* ---- Section card ---- */
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 14px 20px 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.95em;
|
||||
font-weight: 700;
|
||||
color: #222;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 3px;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
font-size: 0.82em;
|
||||
color: #888;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ---- Field rows ---- */
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
gap: 16px;
|
||||
}
|
||||
.field-row:last-child { border-bottom: none; }
|
||||
|
||||
.field-label {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.88em;
|
||||
font-weight: 500;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.field-body { flex: 1; min-width: 0; }
|
||||
|
||||
.field-value {
|
||||
font-size: 0.9em;
|
||||
color: #222;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field-desc {
|
||||
font-size: 0.78em;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- Value type renderers ---- */
|
||||
.val-boolean {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.val-boolean.on { background: #e8f5e9; color: #2e7d32; }
|
||||
.val-boolean.off { background: #fce4ec; color: #c62828; }
|
||||
|
||||
.val-masked {
|
||||
font-family: monospace;
|
||||
color: #bbb;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.val-list { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.val-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
background: #e8eaf6;
|
||||
color: #283593;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.val-empty { color: #ccc; font-style: italic; font-size: 0.88em; }
|
||||
|
||||
/* ---- Users table ---- */
|
||||
.mini-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.mini-table th {
|
||||
background: #f5f5f5;
|
||||
padding: 7px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: 0.82em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
.mini-table td {
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
color: #333;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mini-table tbody tr:last-child td { border-bottom: none; }
|
||||
.mini-table tbody tr:hover { background: #fafafa; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-admin { background: #e8f0fe; color: #1a73e8; }
|
||||
.badge-user { background: #f1f3f4; color: #666; }
|
||||
|
||||
/* ---- Notification channels ---- */
|
||||
.channel-card {
|
||||
border: 1px solid #e8eaf6;
|
||||
border-radius: 6px;
|
||||
margin: 12px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
background: #f8f9ff;
|
||||
border-bottom: 1px solid #e8eaf6;
|
||||
}
|
||||
|
||||
.channel-name-text { font-weight: 600; font-size: 0.9em; color: #222; }
|
||||
|
||||
.ch-type-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
background: #e8eaf6;
|
||||
color: #3949ab;
|
||||
}
|
||||
|
||||
.channel-fields { padding: 6px 0; }
|
||||
|
||||
.channel-field {
|
||||
display: flex;
|
||||
padding: 5px 14px;
|
||||
font-size: 0.85em;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
gap: 12px;
|
||||
}
|
||||
.channel-field:last-child { border-bottom: none; }
|
||||
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
|
||||
.channel-field-value { color: #333; word-break: break-all; }
|
||||
|
||||
/* ---- Hosts table ---- */
|
||||
.host-bool { text-align: center; }
|
||||
.dot-yes { color: #2e7d32; font-size: 1.1em; }
|
||||
.dot-no { color: #ddd; font-size: 1.1em; }
|
||||
</style>
|
||||
|
||||
<body>
|
||||
{% include 'nav.html' %}
|
||||
|
||||
<div class="container">
|
||||
<h1>Settings</h1>
|
||||
<p class="subtitle">Current server configuration — read from the config file at startup.</p>
|
||||
|
||||
<div class="settings-layout">
|
||||
|
||||
<!-- Sidebar navigation -->
|
||||
<nav class="settings-sidebar">
|
||||
<div class="sidebar-nav" id="sidebar-nav">
|
||||
{% for section in sections %}
|
||||
<a href="#{{ section.id }}">{{ section.title }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="settings-main">
|
||||
{% for section in sections %}
|
||||
<div class="section" id="{{ section.id }}">
|
||||
<div class="section-header">
|
||||
<p class="section-title">{{ section.title }}</p>
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
{# ---- Standard field rows ---- #}
|
||||
{% for f in section.fields %}
|
||||
<div class="field-row">
|
||||
<div class="field-label">{{ f.label }}</div>
|
||||
<div class="field-body">
|
||||
{% if f.sensitive %}
|
||||
<div class="field-value"><span class="val-masked">••••••••</span></div>
|
||||
{% elif f.type == "boolean" %}
|
||||
<div class="field-value">
|
||||
<span class="val-boolean {{ 'on' if f.value else 'off' }}">
|
||||
{{ 'Enabled' if f.value else 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
{% elif f.type == "list" %}
|
||||
<div class="field-value">
|
||||
{% if f.value %}
|
||||
<span class="val-list">
|
||||
{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="val-empty">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif f.value is none or f.value == "" %}
|
||||
<div class="field-value"><span class="val-empty">Not set</span></div>
|
||||
{% else %}
|
||||
<div class="field-value">{{ f.value }}</div>
|
||||
{% endif %}
|
||||
{% if f.description %}
|
||||
<div class="field-desc">{{ f.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# ---- Users section ---- #}
|
||||
{% if section.id == "users" and section.users %}
|
||||
<div style="padding: 0 0 4px;">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Role</th>
|
||||
<th>Avatar</th>
|
||||
<th>Channels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in section.users %}
|
||||
<tr>
|
||||
<td><strong>{{ u.username }}</strong></td>
|
||||
<td>{{ u.full_name or '—' }}</td>
|
||||
<td>
|
||||
{% if u.admin %}
|
||||
<span class="badge badge-admin">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge badge-user">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.8em; color:#888;">
|
||||
{% if u.avatar %}{{ u.avatar }}{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.notification_channels %}
|
||||
<span class="val-list">
|
||||
{% for ch in u.notification_channels %}
|
||||
<span class="val-tag">{{ ch }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ---- Notification channels section ---- #}
|
||||
{% if section.id == "channels" %}
|
||||
{% for ch in section.channels %}
|
||||
<div class="channel-card">
|
||||
<div class="channel-header">
|
||||
<span class="channel-name-text">{{ ch.name }}</span>
|
||||
<span class="ch-type-badge">{{ ch.type_label }}</span>
|
||||
</div>
|
||||
<div class="channel-fields">
|
||||
{% for cf in ch.fields %}
|
||||
<div class="channel-field">
|
||||
<span class="channel-field-label">{{ cf.label }}</span>
|
||||
<span class="channel-field-value">
|
||||
{% if cf.sensitive %}
|
||||
<span class="val-masked">••••••••</span>
|
||||
{% elif cf.value is iterable and cf.value is not string %}
|
||||
{{ cf.value | join(', ') }}
|
||||
{% else %}
|
||||
{{ cf.value }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not section.channels %}
|
||||
<div class="field-row"><span class="val-empty">No notification channels configured.</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- Hosts section ---- #}
|
||||
{% if section.id == "hosts" %}
|
||||
{% if section.hosts %}
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th>Watch</th>
|
||||
<th>DynDNS</th>
|
||||
<th>Owner</th>
|
||||
<th>Threshold config</th>
|
||||
<th>Channels</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in section.hosts %}
|
||||
<tr>
|
||||
<td><strong>{{ h.name }}</strong></td>
|
||||
<td class="host-bool">
|
||||
<span class="{{ 'dot-yes' if h.watch else 'dot-no' }}">●</span>
|
||||
</td>
|
||||
<td class="host-bool">
|
||||
<span class="{{ 'dot-yes' if h.dyndns else 'dot-no' }}">●</span>
|
||||
</td>
|
||||
<td>{{ h.owner or '—' }}</td>
|
||||
<td>{{ h.threshold_config or '—' }}</td>
|
||||
<td>
|
||||
{% if h.notification_channels %}
|
||||
<span class="val-list">
|
||||
{% for ch in h.notification_channels %}
|
||||
<span class="val-tag">{{ ch }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="field-row"><span class="val-empty">No hosts defined in config.</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</div>{# /section #}
|
||||
{% endfor %}
|
||||
</div>{# /settings-main #}
|
||||
</div>{# /settings-layout #}
|
||||
</div>{# /container #}
|
||||
|
||||
<script>
|
||||
// Highlight sidebar link for the section currently in view
|
||||
const sections = document.querySelectorAll('.section');
|
||||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const id = entry.target.id;
|
||||
navLinks.forEach(a => {
|
||||
a.classList.toggle('active', a.getAttribute('href') === '#' + id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.25 });
|
||||
|
||||
sections.forEach(s => observer.observe(s));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -297,6 +297,9 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
# Use new config function to check dyndns
|
||||
dyndnshosts = config_mod.get_dyndnshosts(cfg)
|
||||
host.dyn = uname in dyndnshosts
|
||||
# Apply user-access settings from config
|
||||
access = config_mod.get_host_access(cfg, uname)
|
||||
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||
if verbose:
|
||||
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
|
||||
newh = True
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""User management: loading, authentication, and session tracking.
|
||||
|
||||
Users are defined in the config file under the ``users`` key:
|
||||
|
||||
users:
|
||||
alice:
|
||||
full_name: Alice Smith
|
||||
avatar: /path/to/avatar.png # file path, URL, or base64 data URI
|
||||
password: pbkdf2:sha256:... # generated with: hbd passwd
|
||||
admin: true # optional server-level admin
|
||||
notification_channels: [pushover_standard]
|
||||
|
||||
Roles are assigned per-host:
|
||||
|
||||
hosts:
|
||||
webserver01:
|
||||
owner: alice
|
||||
managers: [bob]
|
||||
monitors: [carol]
|
||||
|
||||
If no users are defined the server runs in unauthenticated mode (backwards
|
||||
compatible). When users are defined every API call must carry a valid session
|
||||
token in an ``Authorization: Bearer <token>`` or ``X-Auth-Token`` header,
|
||||
obtained via ``POST /api/0/auth/login``.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Session lifetime in seconds (24 hours).
|
||||
SESSION_TTL = 86400
|
||||
|
||||
# Global session store: token -> {"username": str, "expires": float, "created": float}
|
||||
_sessions: dict = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class User:
|
||||
def __init__(
|
||||
self,
|
||||
username: str,
|
||||
full_name: str = "",
|
||||
avatar: str = "",
|
||||
password_hash: str = "",
|
||||
admin: bool = False,
|
||||
notification_channels: list | None = None,
|
||||
):
|
||||
self.username = username
|
||||
self.full_name = full_name
|
||||
self.avatar = avatar
|
||||
self.password_hash = password_hash
|
||||
self.admin = admin
|
||||
self.notification_channels: list = notification_channels or []
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
if not self.password_hash:
|
||||
return False
|
||||
return _verify_password(password, self.password_hash)
|
||||
|
||||
def avatar_is_local(self) -> bool:
|
||||
"""Return True when the avatar is a local filesystem path (starts with '/')."""
|
||||
return bool(self.avatar and self.avatar.startswith("/"))
|
||||
|
||||
def avatar_url(self) -> str:
|
||||
"""Return the URL to use as an <img src>.
|
||||
|
||||
Local file paths are served via the /api/0/users/{username}/avatar
|
||||
endpoint. External URLs and data URIs are returned as-is.
|
||||
"""
|
||||
if self.avatar_is_local():
|
||||
return f"/api/0/users/{self.username}/avatar"
|
||||
return self.avatar
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"username": self.username,
|
||||
"full_name": self.full_name,
|
||||
"avatar": self.avatar,
|
||||
"avatar_url": self.avatar_url(),
|
||||
"admin": self.admin,
|
||||
"notification_channels": self.notification_channels,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (PBKDF2-HMAC-SHA256, stdlib only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return a storable hash for *password*.
|
||||
|
||||
Format: ``pbkdf2:sha256:<iterations>:<salt>:<hex-digest>``
|
||||
|
||||
Use this to generate the ``password`` value in the config file::
|
||||
|
||||
python -c "from hbd.server.users import hash_password; print(hash_password('secret'))"
|
||||
|
||||
Or via the CLI::
|
||||
|
||||
hbd passwd
|
||||
"""
|
||||
salt = secrets.token_hex(16)
|
||||
iterations = 260_000
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
|
||||
)
|
||||
return f"pbkdf2:sha256:{iterations}:{salt}:{dk.hex()}"
|
||||
|
||||
|
||||
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||
"""Return True if *password* matches *stored_hash*."""
|
||||
try:
|
||||
parts = stored_hash.split(":")
|
||||
if len(parts) != 5 or parts[0] != "pbkdf2" or parts[1] != "sha256":
|
||||
return False
|
||||
_, _, iterations_str, salt, expected_hex = parts
|
||||
iterations = int(iterations_str)
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
|
||||
)
|
||||
return hmac.compare_digest(dk.hex(), expected_hex)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global user registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# username -> User
|
||||
users: dict = {}
|
||||
|
||||
|
||||
def load_users(config: dict) -> dict:
|
||||
"""Populate the global user registry from *config*.
|
||||
|
||||
Called once at startup and again on SIGHUP config reload.
|
||||
Returns the new ``users`` dict.
|
||||
"""
|
||||
global users
|
||||
users_cfg = config.get("users", {})
|
||||
if not isinstance(users_cfg, dict):
|
||||
users = {}
|
||||
return users
|
||||
|
||||
result: dict = {}
|
||||
for username, attrs in users_cfg.items():
|
||||
if not isinstance(attrs, dict):
|
||||
logger.warning("Skipping user %r: expected a mapping", username)
|
||||
continue
|
||||
result[username] = User(
|
||||
username=username,
|
||||
full_name=attrs.get("full_name", ""),
|
||||
avatar=attrs.get("avatar", ""),
|
||||
password_hash=attrs.get("password", ""),
|
||||
admin=bool(attrs.get("admin", False)),
|
||||
notification_channels=attrs.get("notification_channels", []),
|
||||
)
|
||||
|
||||
users = result
|
||||
logger.info("Loaded %d user(s) from config", len(users))
|
||||
return users
|
||||
|
||||
|
||||
def users_enabled() -> bool:
|
||||
"""Return True if at least one user is configured (auth-required mode)."""
|
||||
return bool(users)
|
||||
|
||||
|
||||
def get_user(username: str) -> "User | None":
|
||||
return users.get(username)
|
||||
|
||||
|
||||
def authenticate(username: str, password: str) -> "User | None":
|
||||
"""Return the User if credentials are valid, else None."""
|
||||
user = users.get(username)
|
||||
if user and user.check_password(password):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_session(username: str) -> str:
|
||||
"""Create a new session for *username* and return the opaque token."""
|
||||
_purge_expired_sessions()
|
||||
token = secrets.token_hex(32)
|
||||
_sessions[token] = {
|
||||
"username": username,
|
||||
"expires": time.time() + SESSION_TTL,
|
||||
"created": time.time(),
|
||||
}
|
||||
return token
|
||||
|
||||
|
||||
def get_session_user(token: str) -> "User | None":
|
||||
"""Return the User for a valid *token*, or None if missing/expired."""
|
||||
if not token:
|
||||
return None
|
||||
session = _sessions.get(token)
|
||||
if not session:
|
||||
return None
|
||||
if session["expires"] < time.time():
|
||||
del _sessions[token]
|
||||
return None
|
||||
return get_user(session["username"])
|
||||
|
||||
|
||||
def delete_session(token: str) -> None:
|
||||
"""Invalidate *token* (logout)."""
|
||||
_sessions.pop(token, None)
|
||||
|
||||
|
||||
def _purge_expired_sessions() -> None:
|
||||
now = time.time()
|
||||
expired = [t for t, s in list(_sessions.items()) if s["expires"] < now]
|
||||
for t in expired:
|
||||
del _sessions[t]
|
||||
Reference in New Issue
Block a user