|
|
@@ -1,5 +1,7 @@
|
|
|
|
from __future__ import annotations
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import functools
|
|
|
|
|
|
|
|
import hashlib
|
|
|
|
import json
|
|
|
|
import json
|
|
|
|
import logging
|
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import os
|
|
|
@@ -21,9 +23,11 @@ from .config import (
|
|
|
|
AppConfig,
|
|
|
|
AppConfig,
|
|
|
|
ConfigRef,
|
|
|
|
ConfigRef,
|
|
|
|
ContainerConfig,
|
|
|
|
ContainerConfig,
|
|
|
|
|
|
|
|
VALID_LOG_LEVELS,
|
|
|
|
HttpCheckConfig,
|
|
|
|
HttpCheckConfig,
|
|
|
|
UptimeKumaMonitorConfig,
|
|
|
|
UptimeKumaMonitorConfig,
|
|
|
|
build_config_from_raw,
|
|
|
|
build_config_from_raw,
|
|
|
|
|
|
|
|
valid_hhmm,
|
|
|
|
write_config_raw,
|
|
|
|
write_config_raw,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
from .formatting import PACIFIC, format_timestamp
|
|
|
|
from .formatting import PACIFIC, format_timestamp
|
|
|
@@ -36,6 +40,9 @@ from .state import ContainerState, utcnow
|
|
|
|
log = logging.getLogger("inspector.dashboard")
|
|
|
|
log = logging.getLogger("inspector.dashboard")
|
|
|
|
|
|
|
|
|
|
|
|
_SESSION_MAX_AGE = 86400 # seconds a login cookie stays valid
|
|
|
|
_SESSION_MAX_AGE = 86400 # seconds a login cookie stays valid
|
|
|
|
|
|
|
|
_MAX_BODY_BYTES = 1024 * 1024 # largest request body accepted
|
|
|
|
|
|
|
|
_LOGIN_MAX_FAILURES = 5 # failed logins per client before it is throttled
|
|
|
|
|
|
|
|
_LOGIN_FAILURE_WINDOW = 300 # seconds a failed login counts toward the limit
|
|
|
|
_UNCHANGED_SECRET_PLACEHOLDER = "__UNCHANGED__" # UI sentinel: keep existing secret value
|
|
|
|
_UNCHANGED_SECRET_PLACEHOLDER = "__UNCHANGED__" # UI sentinel: keep existing secret value
|
|
|
|
|
|
|
|
|
|
|
|
# Config keys that require an Inspector container restart to take effect.
|
|
|
|
# Config keys that require an Inspector container restart to take effect.
|
|
|
@@ -84,7 +91,13 @@ def _color_map(
|
|
|
|
states: dict[str, ContainerState],
|
|
|
|
states: dict[str, ContainerState],
|
|
|
|
containers: dict[str, ContainerConfig],
|
|
|
|
containers: dict[str, ContainerConfig],
|
|
|
|
) -> dict[str, str]:
|
|
|
|
) -> dict[str, str]:
|
|
|
|
return {name: _resolve_color(containers[name], i) for i, name in enumerate(states)}
|
|
|
|
# list() snapshot + skip: the inspector loop adds/removes states from another
|
|
|
|
|
|
|
|
# thread, and a container removed in the UI keeps its state until the next check.
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
|
|
name: _resolve_color(containers[name], i)
|
|
|
|
|
|
|
|
for i, name in enumerate(list(states))
|
|
|
|
|
|
|
|
if name in containers
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _compute_phase(
|
|
|
|
def _compute_phase(
|
|
|
@@ -246,9 +259,31 @@ def _escape(value: object) -> str:
|
|
|
|
.replace("<", "<")
|
|
|
|
.replace("<", "<")
|
|
|
|
.replace(">", ">")
|
|
|
|
.replace(">", ">")
|
|
|
|
.replace('"', """)
|
|
|
|
.replace('"', """)
|
|
|
|
|
|
|
|
.replace("'", "'")
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _js_string_attr(text: str) -> str:
|
|
|
|
|
|
|
|
"""A JS string literal safe to put inside a double-quoted HTML attribute."""
|
|
|
|
|
|
|
|
return _escape(json.dumps(text))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _creds_match(username: str, password: str, cfg) -> bool:
|
|
|
|
|
|
|
|
"""Constant-time credential check. Never accepts an empty configured
|
|
|
|
|
|
|
|
password, and works for non-ASCII input (compare_digest on str doesn't)."""
|
|
|
|
|
|
|
|
if not cfg.password:
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
user_ok = secrets.compare_digest(username.encode("utf-8"), cfg.username.encode("utf-8"))
|
|
|
|
|
|
|
|
pass_ok = secrets.compare_digest(password.encode("utf-8"), cfg.password.encode("utf-8"))
|
|
|
|
|
|
|
|
return user_ok and pass_ok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cred_fingerprint(cfg) -> str:
|
|
|
|
|
|
|
|
"""Changes whenever the dashboard username or password changes, which
|
|
|
|
|
|
|
|
invalidates every session created with the old credentials."""
|
|
|
|
|
|
|
|
return hashlib.sha256(f"{cfg.username}\0{cfg.password}".encode("utf-8")).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_STATUS_BADGE_CLASS = {
|
|
|
|
_STATUS_BADGE_CLASS = {
|
|
|
|
HEALTHY: "healthy",
|
|
|
|
HEALTHY: "healthy",
|
|
|
|
UNHEALTHY: "unhealthy",
|
|
|
|
UNHEALTHY: "unhealthy",
|
|
|
@@ -1146,10 +1181,13 @@ def _render_event_item(ev: dict, container_colors: dict[str, str] | None) -> str
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_login_page(error: bool = False) -> bytes:
|
|
|
|
def _render_login_page(error: bool = False, throttled: bool = False) -> bytes:
|
|
|
|
error_html = (
|
|
|
|
if throttled:
|
|
|
|
'<div class="login-error">Incorrect username or password.</div>\n' if error else ""
|
|
|
|
error_html = '<div class="login-error">Too many failed sign-ins. Try again in a few minutes.</div>\n'
|
|
|
|
)
|
|
|
|
elif error:
|
|
|
|
|
|
|
|
error_html = '<div class="login-error">Incorrect username or password.</div>\n'
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
error_html = ""
|
|
|
|
head = (
|
|
|
|
head = (
|
|
|
|
"<!DOCTYPE html>\n"
|
|
|
|
"<!DOCTYPE html>\n"
|
|
|
|
'<html lang="en">\n<head>\n'
|
|
|
|
'<html lang="en">\n<head>\n'
|
|
|
@@ -1243,6 +1281,14 @@ def _form_float(form: dict, key: str, default: float = 0.0) -> float:
|
|
|
|
return default
|
|
|
|
return default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _int_in(form: dict, key: str, default: int, lo: int, hi: int) -> int:
|
|
|
|
|
|
|
|
"""Read an int form field and reject values outside lo..hi."""
|
|
|
|
|
|
|
|
value = _form_int(form, key, default)
|
|
|
|
|
|
|
|
if not lo <= value <= hi:
|
|
|
|
|
|
|
|
raise _SettingsError(f"{key} must be between {lo} and {hi} (got {value})")
|
|
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _set_or_drop(section: dict, key: str, value) -> None:
|
|
|
|
def _set_or_drop(section: dict, key: str, value) -> None:
|
|
|
|
"""Store value, or remove the key when it is blank/False so config.yml
|
|
|
|
"""Store value, or remove the key when it is blank/False so config.yml
|
|
|
|
doesn't fill up with empty defaults."""
|
|
|
|
doesn't fill up with empty defaults."""
|
|
|
@@ -1270,8 +1316,10 @@ def _apply_container_advanced(raw: dict, name: str, cfg: dict, form: dict) -> No
|
|
|
|
hc["enabled"] = hc_enabled
|
|
|
|
hc["enabled"] = hc_enabled
|
|
|
|
hc["url"] = hc_url
|
|
|
|
hc["url"] = hc_url
|
|
|
|
timeout = _form_float(form, "http_check_timeout_seconds", 5.0)
|
|
|
|
timeout = _form_float(form, "http_check_timeout_seconds", 5.0)
|
|
|
|
|
|
|
|
if not 0.5 <= timeout <= 120:
|
|
|
|
|
|
|
|
raise _SettingsError(f"HTTP check timeout must be between 0.5 and 120 seconds (got {timeout:g})")
|
|
|
|
hc["timeout_seconds"] = int(timeout) if timeout.is_integer() else timeout
|
|
|
|
hc["timeout_seconds"] = int(timeout) if timeout.is_integer() else timeout
|
|
|
|
hc["expected_status"] = _form_int(form, "http_check_expected_status", 200)
|
|
|
|
hc["expected_status"] = _int_in(form, "http_check_expected_status", 200, 100, 599)
|
|
|
|
cfg["http_check"] = hc
|
|
|
|
cfg["http_check"] = hc
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
cfg.pop("http_check", None)
|
|
|
|
cfg.pop("http_check", None)
|
|
|
@@ -1314,6 +1362,15 @@ def _apply_container_advanced(raw: dict, name: str, cfg: dict, form: dict) -> No
|
|
|
|
cfg.pop("portainer_stack_name", None)
|
|
|
|
cfg.pop("portainer_stack_name", None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_PORTAINER_ENV_NAME_RE = re.compile(r"^PORTAINER_[A-Z0-9_]+$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _token_env_allowed(name: str, saved: str = "") -> bool:
|
|
|
|
|
|
|
|
"""Only PORTAINER_* variables (or the name already in config.yml) may be read
|
|
|
|
|
|
|
|
as a token, so the dashboard can't be used to send other container secrets out."""
|
|
|
|
|
|
|
|
return bool(_PORTAINER_ENV_NAME_RE.match(name)) or (bool(saved) and name == saved)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_portainer_env(raw: dict, form: dict) -> None:
|
|
|
|
def _apply_portainer_env(raw: dict, form: dict) -> None:
|
|
|
|
"""Add, edit, rename or delete one integrations.portainer.environments entry."""
|
|
|
|
"""Add, edit, rename or delete one integrations.portainer.environments entry."""
|
|
|
|
pt = raw.setdefault("integrations", {}).setdefault("portainer", {})
|
|
|
|
pt = raw.setdefault("integrations", {}).setdefault("portainer", {})
|
|
|
@@ -1350,7 +1407,7 @@ def _apply_portainer_env(raw: dict, form: dict) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
env = envs.get(original) or {}
|
|
|
|
env = envs.get(original) or {}
|
|
|
|
env["base_url"] = base_url
|
|
|
|
env["base_url"] = base_url
|
|
|
|
env["endpoint_id"] = _form_int(form, "endpoint_id", 1)
|
|
|
|
env["endpoint_id"] = _int_in(form, "endpoint_id", 1, 1, 1_000_000)
|
|
|
|
env["verify_ssl"] = _form_bool(form, "verify_ssl")
|
|
|
|
env["verify_ssl"] = _form_bool(form, "verify_ssl")
|
|
|
|
if _form_str(form, "token_source", "env") == "inline":
|
|
|
|
if _form_str(form, "token_source", "env") == "inline":
|
|
|
|
_apply_secret(form, env, "api_token", "api_token")
|
|
|
|
_apply_secret(form, env, "api_token", "api_token")
|
|
|
@@ -1361,6 +1418,8 @@ def _apply_portainer_env(raw: dict, form: dict) -> None:
|
|
|
|
token_env = _form_str(form, "api_token_env", "").strip()
|
|
|
|
token_env = _form_str(form, "api_token_env", "").strip()
|
|
|
|
if not token_env:
|
|
|
|
if not token_env:
|
|
|
|
raise _SettingsError("enter the name of the environment variable holding the API token")
|
|
|
|
raise _SettingsError("enter the name of the environment variable holding the API token")
|
|
|
|
|
|
|
|
if not _token_env_allowed(token_env, env.get("api_token_env", "")):
|
|
|
|
|
|
|
|
raise _SettingsError("the environment variable name must start with PORTAINER_ (e.g. PORTAINER_API_TOKEN)")
|
|
|
|
env["api_token_env"] = token_env
|
|
|
|
env["api_token_env"] = token_env
|
|
|
|
env.pop("api_token", None)
|
|
|
|
env.pop("api_token", None)
|
|
|
|
|
|
|
|
|
|
|
@@ -1376,57 +1435,81 @@ def _apply_portainer_env(raw: dict, form: dict) -> None:
|
|
|
|
pt["environments"] = envs
|
|
|
|
pt["environments"] = envs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_SAVE_LOCK = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _serialized(fn):
|
|
|
|
|
|
|
|
"""Run config read-modify-write functions one at a time, so two saves at
|
|
|
|
|
|
|
|
the same moment can't overwrite each other's changes."""
|
|
|
|
|
|
|
|
@functools.wraps(fn)
|
|
|
|
|
|
|
|
def wrapper(*args, **kwargs):
|
|
|
|
|
|
|
|
with _SAVE_LOCK:
|
|
|
|
|
|
|
|
return fn(*args, **kwargs)
|
|
|
|
|
|
|
|
return wrapper
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@_serialized
|
|
|
|
def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None:
|
|
|
|
def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> None:
|
|
|
|
"""Validate the form, merge into the raw config, write atomically, swap ref."""
|
|
|
|
"""Validate the form, merge into the raw config, write atomically, swap ref."""
|
|
|
|
raw = config_ref.get_raw()
|
|
|
|
raw = config_ref.get_raw()
|
|
|
|
|
|
|
|
|
|
|
|
if section == "inspector":
|
|
|
|
if section == "inspector":
|
|
|
|
insp = raw.setdefault("inspector", {})
|
|
|
|
insp = raw.setdefault("inspector", {})
|
|
|
|
insp["interval_seconds"] = _form_int(form, "interval_seconds", 60)
|
|
|
|
insp["interval_seconds"] = _int_in(form, "interval_seconds", 60, 1, 86400)
|
|
|
|
insp["log_level"] = _form_str(form, "log_level", "INFO").upper()
|
|
|
|
level = _form_str(form, "log_level", "INFO").strip().upper()
|
|
|
|
|
|
|
|
if level not in VALID_LOG_LEVELS:
|
|
|
|
|
|
|
|
raise _SettingsError("log_level must be one of DEBUG, INFO, WARNING, ERROR, CRITICAL")
|
|
|
|
|
|
|
|
insp["log_level"] = level
|
|
|
|
insp["dry_run"] = _form_bool(form, "dry_run")
|
|
|
|
insp["dry_run"] = _form_bool(form, "dry_run")
|
|
|
|
insp["heartbeat"] = _form_bool(form, "heartbeat")
|
|
|
|
insp["heartbeat"] = _form_bool(form, "heartbeat")
|
|
|
|
insp["log_retention_days"] = _form_int(form, "log_retention_days", 30)
|
|
|
|
insp["log_retention_days"] = _int_in(form, "log_retention_days", 30, 1, 3650)
|
|
|
|
elif section == "gotify":
|
|
|
|
elif section == "gotify":
|
|
|
|
notif = raw.setdefault("notifications", {})
|
|
|
|
notif = raw.setdefault("notifications", {})
|
|
|
|
gotify = notif.setdefault("gotify", {})
|
|
|
|
gotify = notif.setdefault("gotify", {})
|
|
|
|
gotify["enabled"] = _form_bool(form, "enabled")
|
|
|
|
gotify["enabled"] = _form_bool(form, "enabled")
|
|
|
|
gotify["url"] = _form_str(form, "url", "")
|
|
|
|
gotify["url"] = _form_str(form, "url", "")
|
|
|
|
_apply_secret(form, gotify, "token", "token")
|
|
|
|
_apply_secret(form, gotify, "token", "token")
|
|
|
|
gotify["priority"] = _form_int(form, "priority", 5)
|
|
|
|
gotify["priority"] = _int_in(form, "priority", 5, 0, 10)
|
|
|
|
elif section == "pushover":
|
|
|
|
elif section == "pushover":
|
|
|
|
notif = raw.setdefault("notifications", {})
|
|
|
|
notif = raw.setdefault("notifications", {})
|
|
|
|
pushover = notif.setdefault("pushover", {})
|
|
|
|
pushover = notif.setdefault("pushover", {})
|
|
|
|
pushover["enabled"] = _form_bool(form, "enabled")
|
|
|
|
pushover["enabled"] = _form_bool(form, "enabled")
|
|
|
|
_apply_secret(form, pushover, "token", "token")
|
|
|
|
_apply_secret(form, pushover, "token", "token")
|
|
|
|
_apply_secret(form, pushover, "user_key", "user_key")
|
|
|
|
_apply_secret(form, pushover, "user_key", "user_key")
|
|
|
|
pushover["priority"] = _form_int(form, "priority", 5)
|
|
|
|
pushover["priority"] = _int_in(form, "priority", 5, 0, 10)
|
|
|
|
pushover["device"] = _form_str(form, "device", "")
|
|
|
|
pushover["device"] = _form_str(form, "device", "")
|
|
|
|
pushover["sound"] = _form_str(form, "sound", "")
|
|
|
|
pushover["sound"] = _form_str(form, "sound", "")
|
|
|
|
elif section == "dashboard":
|
|
|
|
elif section == "dashboard":
|
|
|
|
dash = raw.setdefault("dashboard", {})
|
|
|
|
dash = raw.setdefault("dashboard", {})
|
|
|
|
dash["enabled"] = _form_bool(form, "enabled")
|
|
|
|
dash["enabled"] = _form_bool(form, "enabled")
|
|
|
|
dash["username"] = _form_str(form, "username", "admin")
|
|
|
|
dash["username"] = _form_str(form, "username", "admin").strip()
|
|
|
|
|
|
|
|
if not dash["username"]:
|
|
|
|
|
|
|
|
raise _SettingsError("the dashboard username can't be empty")
|
|
|
|
_apply_secret(form, dash, "password", "password")
|
|
|
|
_apply_secret(form, dash, "password", "password")
|
|
|
|
|
|
|
|
if not dash.get("password"):
|
|
|
|
|
|
|
|
raise _SettingsError("the dashboard password can't be empty")
|
|
|
|
elif section == "database":
|
|
|
|
elif section == "database":
|
|
|
|
db = raw.setdefault("database", {})
|
|
|
|
db = raw.setdefault("database", {})
|
|
|
|
db["enabled"] = _form_bool(form, "enabled")
|
|
|
|
db["enabled"] = _form_bool(form, "enabled")
|
|
|
|
db["retention_days"] = _form_int(form, "retention_days", 30)
|
|
|
|
db["retention_days"] = _int_in(form, "retention_days", 30, 1, 3650)
|
|
|
|
elif section == "health_summary":
|
|
|
|
elif section == "health_summary":
|
|
|
|
hs = raw.setdefault("health_summary", {})
|
|
|
|
hs = raw.setdefault("health_summary", {})
|
|
|
|
hs["enabled"] = _form_bool(form, "enabled")
|
|
|
|
hs["enabled"] = _form_bool(form, "enabled")
|
|
|
|
hs["interval_hours"] = _form_int(form, "interval_hours", 24)
|
|
|
|
hs["interval_hours"] = _int_in(form, "interval_hours", 24, 1, 8760)
|
|
|
|
hs["priority"] = _form_int(form, "priority", 3)
|
|
|
|
hs["priority"] = _int_in(form, "priority", 3, 0, 10)
|
|
|
|
elif section == "maintenance":
|
|
|
|
elif section == "maintenance":
|
|
|
|
m = raw.setdefault("maintenance", {})
|
|
|
|
m = raw.setdefault("maintenance", {})
|
|
|
|
m["enabled"] = _form_bool(form, "enabled")
|
|
|
|
m["enabled"] = _form_bool(form, "enabled")
|
|
|
|
m["start"] = _form_str(form, "start", "00:00")
|
|
|
|
for key, default in (("start", "00:00"), ("end", "06:00")):
|
|
|
|
m["end"] = _form_str(form, "end", "06:00")
|
|
|
|
value = _form_str(form, key, default).strip()
|
|
|
|
m["max_hours"] = _form_int(form, "max_hours", 8)
|
|
|
|
if not valid_hhmm(value):
|
|
|
|
|
|
|
|
raise _SettingsError(f"{key} must be a 24-hour time like 02:30 (got {value!r})")
|
|
|
|
|
|
|
|
m[key] = value
|
|
|
|
|
|
|
|
m["max_hours"] = _int_in(form, "max_hours", 8, 1, 720)
|
|
|
|
elif section == "notification_mute":
|
|
|
|
elif section == "notification_mute":
|
|
|
|
nm = raw.setdefault("notification_mute", {})
|
|
|
|
nm = raw.setdefault("notification_mute", {})
|
|
|
|
nm["enabled"] = _form_bool(form, "enabled")
|
|
|
|
nm["enabled"] = _form_bool(form, "enabled")
|
|
|
|
nm["default_hours"] = _form_int(form, "default_hours", 12)
|
|
|
|
nm["default_hours"] = _int_in(form, "default_hours", 12, 1, 720)
|
|
|
|
elif section.startswith("container:"):
|
|
|
|
elif section.startswith("container:"):
|
|
|
|
name = section.split(":", 1)[1]
|
|
|
|
name = section.split(":", 1)[1]
|
|
|
|
containers = raw.get("containers") or {}
|
|
|
|
containers = raw.get("containers") or {}
|
|
|
@@ -1434,10 +1517,10 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
|
|
|
|
raise _SettingsError(f"unknown container {name!r}")
|
|
|
|
raise _SettingsError(f"unknown container {name!r}")
|
|
|
|
cfg = containers[name] or {}
|
|
|
|
cfg = containers[name] or {}
|
|
|
|
cfg["enabled"] = _form_bool(form, "enabled")
|
|
|
|
cfg["enabled"] = _form_bool(form, "enabled")
|
|
|
|
cfg["max_error_count"] = _form_int(form, "max_error_count", 5)
|
|
|
|
cfg["max_error_count"] = _int_in(form, "max_error_count", 5, 1, 1000)
|
|
|
|
cfg["cooldown_seconds"] = _form_int(form, "cooldown_seconds", 300)
|
|
|
|
cfg["cooldown_seconds"] = _int_in(form, "cooldown_seconds", 300, 0, 86400)
|
|
|
|
cfg["grace_period_seconds"] = _form_int(form, "grace_period_seconds", 120)
|
|
|
|
cfg["grace_period_seconds"] = _int_in(form, "grace_period_seconds", 120, 0, 86400)
|
|
|
|
cfg["max_restarts_per_hour"] = _form_int(form, "max_restarts_per_hour", 3)
|
|
|
|
cfg["max_restarts_per_hour"] = _int_in(form, "max_restarts_per_hour", 3, 1, 100)
|
|
|
|
_set_or_drop(cfg, "color", _form_str(form, "color", "").strip())
|
|
|
|
_set_or_drop(cfg, "color", _form_str(form, "color", "").strip())
|
|
|
|
if form.get("restart_on_present"): # only our own form carries this field
|
|
|
|
if form.get("restart_on_present"): # only our own form carries this field
|
|
|
|
_set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window"))
|
|
|
|
_set_or_drop(cfg, "maintenance_window", _form_bool(form, "maintenance_window"))
|
|
|
@@ -1467,8 +1550,8 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
|
|
|
|
if default_env and default_env not in (pt.get("environments") or {}):
|
|
|
|
if default_env and default_env not in (pt.get("environments") or {}):
|
|
|
|
raise _SettingsError(f"unknown Portainer environment {default_env!r}")
|
|
|
|
raise _SettingsError(f"unknown Portainer environment {default_env!r}")
|
|
|
|
_set_or_drop(pt, "default_environment", default_env)
|
|
|
|
_set_or_drop(pt, "default_environment", default_env)
|
|
|
|
pt["stack_redeploy_cooldown_seconds"] = _form_int(form, "stack_redeploy_cooldown_seconds", 300)
|
|
|
|
pt["stack_redeploy_cooldown_seconds"] = _int_in(form, "stack_redeploy_cooldown_seconds", 300, 0, 86400)
|
|
|
|
pt["post_redeploy_wait_seconds"] = _form_int(form, "post_redeploy_wait_seconds", 90)
|
|
|
|
pt["post_redeploy_wait_seconds"] = _int_in(form, "post_redeploy_wait_seconds", 90, 0, 3600)
|
|
|
|
elif section == "portainer_env":
|
|
|
|
elif section == "portainer_env":
|
|
|
|
_apply_portainer_env(raw, form)
|
|
|
|
_apply_portainer_env(raw, form)
|
|
|
|
else:
|
|
|
|
else:
|
|
|
@@ -1492,6 +1575,7 @@ def _apply_settings_update(config_ref: ConfigRef, section: str, form: dict) -> N
|
|
|
|
_CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$")
|
|
|
|
_CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,62}$")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@_serialized
|
|
|
|
def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
|
|
|
|
def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
|
|
|
|
"""Insert a new container under raw['containers']. Returns the new name."""
|
|
|
|
"""Insert a new container under raw['containers']. Returns the new name."""
|
|
|
|
name = _form_str(form, "name", "").strip()
|
|
|
|
name = _form_str(form, "name", "").strip()
|
|
|
@@ -1507,10 +1591,10 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
cfg = {
|
|
|
|
cfg = {
|
|
|
|
"enabled": _form_bool(form, "enabled"),
|
|
|
|
"enabled": _form_bool(form, "enabled"),
|
|
|
|
"max_error_count": _form_int(form, "max_error_count", 5),
|
|
|
|
"max_error_count": _int_in(form, "max_error_count", 5, 1, 1000),
|
|
|
|
"cooldown_seconds": _form_int(form, "cooldown_seconds", 300),
|
|
|
|
"cooldown_seconds": _int_in(form, "cooldown_seconds", 300, 0, 86400),
|
|
|
|
"grace_period_seconds": _form_int(form, "grace_period_seconds", 120),
|
|
|
|
"grace_period_seconds": _int_in(form, "grace_period_seconds", 120, 0, 86400),
|
|
|
|
"max_restarts_per_hour": _form_int(form, "max_restarts_per_hour", 3),
|
|
|
|
"max_restarts_per_hour": _int_in(form, "max_restarts_per_hour", 3, 1, 100),
|
|
|
|
"color": _form_str(form, "color", ""),
|
|
|
|
"color": _form_str(form, "color", ""),
|
|
|
|
"maintenance_window": _form_bool(form, "maintenance_window"),
|
|
|
|
"maintenance_window": _form_bool(form, "maintenance_window"),
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -1536,6 +1620,7 @@ def _apply_container_create(config_ref: ConfigRef, form: dict) -> str:
|
|
|
|
return name
|
|
|
|
return name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@_serialized
|
|
|
|
def _apply_container_delete(config_ref: ConfigRef, name: str) -> None:
|
|
|
|
def _apply_container_delete(config_ref: ConfigRef, name: str) -> None:
|
|
|
|
raw = config_ref.get_raw()
|
|
|
|
raw = config_ref.get_raw()
|
|
|
|
containers = raw.get("containers") or {}
|
|
|
|
containers = raw.get("containers") or {}
|
|
|
@@ -1583,7 +1668,7 @@ def _run_test_notification(channel: str, form: dict, app: AppConfig) -> dict:
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
resp = requests.post(
|
|
|
|
resp = requests.post(
|
|
|
|
f"{url}/message",
|
|
|
|
f"{url}/message",
|
|
|
|
params={"token": token},
|
|
|
|
headers={"X-Gotify-Key": token},
|
|
|
|
json={"title": title, "message": message, "priority": priority},
|
|
|
|
json={"title": title, "message": message, "priority": priority},
|
|
|
|
timeout=5.0,
|
|
|
|
timeout=5.0,
|
|
|
|
)
|
|
|
|
)
|
|
|
@@ -1665,6 +1750,9 @@ def _run_test_integration(kind: str, form: dict, app: AppConfig) -> dict:
|
|
|
|
return {"ok": False, "error": "API token required"}
|
|
|
|
return {"ok": False, "error": "API token required"}
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
var = _form_str(form, "api_token_env", "").strip()
|
|
|
|
var = _form_str(form, "api_token_env", "").strip()
|
|
|
|
|
|
|
|
saved = app.integrations.portainer.environments.get(_form_str(form, "env_original", ""))
|
|
|
|
|
|
|
|
if var and not _token_env_allowed(var, saved.api_token_env if saved else ""):
|
|
|
|
|
|
|
|
return {"ok": False, "error": "the environment variable name must start with PORTAINER_"}
|
|
|
|
token = os.environ.get(var, "") if var else ""
|
|
|
|
token = os.environ.get(var, "") if var else ""
|
|
|
|
if not token:
|
|
|
|
if not token:
|
|
|
|
return {"ok": False, "error": f"environment variable {var!r} is not set in the Inspector container"}
|
|
|
|
return {"ok": False, "error": f"environment variable {var!r} is not set in the Inspector container"}
|
|
|
@@ -2203,7 +2291,7 @@ def _portainer_env_form(env_name: str, env, csrf_token: str, *, suggest_name: st
|
|
|
|
if env is not None:
|
|
|
|
if env is not None:
|
|
|
|
delete = (
|
|
|
|
delete = (
|
|
|
|
'<button type="submit" name="delete" value="1" class="btn-danger" '
|
|
|
|
'<button type="submit" name="delete" value="1" class="btn-danger" '
|
|
|
|
f"onclick=\"return confirm('Delete Portainer environment {_escape(env_name)}?');\">Delete</button>"
|
|
|
|
f'onclick="return confirm({_js_string_attr(f"Delete Portainer environment {env_name}?")});">Delete</button>'
|
|
|
|
)
|
|
|
|
)
|
|
|
|
label = "environment" if env is not None else "new environment"
|
|
|
|
label = "environment" if env is not None else "new environment"
|
|
|
|
return (
|
|
|
|
return (
|
|
|
@@ -2440,7 +2528,7 @@ def _render_container_advanced(name: str, cfg, app: AppConfig) -> str:
|
|
|
|
+ '<div class="grid-2">'
|
|
|
|
+ '<div class="grid-2">'
|
|
|
|
+ _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number")
|
|
|
|
+ _settings_field("timeout_seconds", "http_check_timeout_seconds", timeout_str, kind="number")
|
|
|
|
+ _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number",
|
|
|
|
+ _settings_field("expected_status", "http_check_expected_status", str(hc.expected_status), kind="number",
|
|
|
|
hint="Exact HTTP code a healthy service returns (often 200; some apps return 401 or 302)")
|
|
|
|
hint="Exact HTTP code a healthy service returns (often 200; some apps return 401). Redirects are followed, so use the final page's code.")
|
|
|
|
+ "</div></div>"
|
|
|
|
+ "</div></div>"
|
|
|
|
'<div class="form-group"><h4>Uptime Kuma monitor</h4>'
|
|
|
|
'<div class="form-group"><h4>Uptime Kuma monitor</h4>'
|
|
|
|
+ _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch")
|
|
|
|
+ _settings_field("enabled", "uptime_kuma_enabled", "on" if ukm.enabled else "", kind="switch")
|
|
|
@@ -2580,7 +2668,7 @@ def _render_container_form(
|
|
|
|
delete_btn = (
|
|
|
|
delete_btn = (
|
|
|
|
f'<form method="post" action="/containers/{_escape(name)}/delete" '
|
|
|
|
f'<form method="post" action="/containers/{_escape(name)}/delete" '
|
|
|
|
'style="display:inline;" '
|
|
|
|
'style="display:inline;" '
|
|
|
|
f"onsubmit=\"return confirm('Delete container {_escape(name)} from config.yml?');\">"
|
|
|
|
f'onsubmit="return confirm({_js_string_attr(f"Delete container {name} from config.yml?")});">'
|
|
|
|
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
|
|
|
|
f'<input type="hidden" name="csrf" value="{_escape(csrf_token)}">'
|
|
|
|
'<button type="submit" class="btn-danger">Delete container</button>'
|
|
|
|
'<button type="submit" class="btn-danger">Delete container</button>'
|
|
|
|
"</form>"
|
|
|
|
"</form>"
|
|
|
@@ -2684,8 +2772,39 @@ class _DashboardHTTPServer(ThreadingHTTPServer):
|
|
|
|
self.states = states
|
|
|
|
self.states = states
|
|
|
|
self.maintenance = maintenance
|
|
|
|
self.maintenance = maintenance
|
|
|
|
self.notification_mute = notification_mute
|
|
|
|
self.notification_mute = notification_mute
|
|
|
|
# token -> (expiry_timestamp, csrf_token)
|
|
|
|
# token -> (expiry_timestamp, csrf_token, credential fingerprint)
|
|
|
|
self._sessions: dict[str, tuple[float, str]] = {}
|
|
|
|
self._sessions: dict[str, tuple[float, str, str]] = {}
|
|
|
|
|
|
|
|
# client address -> timestamps of recent failed logins
|
|
|
|
|
|
|
|
self._failed_logins: dict[str, list[float]] = {}
|
|
|
|
|
|
|
|
self._failed_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def login_throttled(self, client: str) -> bool:
|
|
|
|
|
|
|
|
cutoff = time.time() - _LOGIN_FAILURE_WINDOW
|
|
|
|
|
|
|
|
with self._failed_lock:
|
|
|
|
|
|
|
|
recent = [t for t in self._failed_logins.get(client, []) if t > cutoff]
|
|
|
|
|
|
|
|
if recent:
|
|
|
|
|
|
|
|
self._failed_logins[client] = recent
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
|
|
self._failed_logins.pop(client, None)
|
|
|
|
|
|
|
|
return len(recent) >= _LOGIN_MAX_FAILURES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def note_login_failure(self, client: str) -> None:
|
|
|
|
|
|
|
|
with self._failed_lock:
|
|
|
|
|
|
|
|
self._failed_logins.setdefault(client, []).append(time.time())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clear_login_failures(self, client: str) -> None:
|
|
|
|
|
|
|
|
with self._failed_lock:
|
|
|
|
|
|
|
|
self._failed_logins.pop(client, None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def new_session(self) -> str:
|
|
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
|
|
|
for tok, entry in list(self._sessions.items()): # drop expired sessions
|
|
|
|
|
|
|
|
if entry[0] <= now:
|
|
|
|
|
|
|
|
self._sessions.pop(tok, None)
|
|
|
|
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
|
|
|
|
|
|
fp = _cred_fingerprint(self.current_app().dashboard)
|
|
|
|
|
|
|
|
self._sessions[token] = (now + _SESSION_MAX_AGE, secrets.token_urlsafe(32), fp)
|
|
|
|
|
|
|
|
return token
|
|
|
|
|
|
|
|
|
|
|
|
def current_app(self) -> AppConfig:
|
|
|
|
def current_app(self) -> AppConfig:
|
|
|
|
return self.config_ref.get()
|
|
|
|
return self.config_ref.get()
|
|
|
@@ -2710,29 +2829,47 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
|
def _is_authenticated(self) -> bool:
|
|
|
|
def _is_authenticated(self) -> bool:
|
|
|
|
# Session cookie
|
|
|
|
# Session cookie
|
|
|
|
now = time.time()
|
|
|
|
now = time.time()
|
|
|
|
|
|
|
|
cfg = self.server.current_app().dashboard
|
|
|
|
token = self._session_token()
|
|
|
|
token = self._session_token()
|
|
|
|
if token:
|
|
|
|
if token:
|
|
|
|
entry = self.server._sessions.get(token)
|
|
|
|
entry = self.server._sessions.get(token)
|
|
|
|
if entry and now < entry[0]:
|
|
|
|
if entry and now < entry[0] and entry[2] == _cred_fingerprint(cfg):
|
|
|
|
return True
|
|
|
|
return True
|
|
|
|
self.server._sessions.pop(token, None)
|
|
|
|
self.server._sessions.pop(token, None)
|
|
|
|
|
|
|
|
|
|
|
|
# Basic Auth
|
|
|
|
# Basic Auth
|
|
|
|
header = self.headers.get("Authorization", "")
|
|
|
|
header = self.headers.get("Authorization", "")
|
|
|
|
if header.startswith("Basic "):
|
|
|
|
if header.startswith("Basic "):
|
|
|
|
|
|
|
|
client = self.client_address[0]
|
|
|
|
|
|
|
|
if self.server.login_throttled(client):
|
|
|
|
|
|
|
|
return False
|
|
|
|
try:
|
|
|
|
try:
|
|
|
|
decoded = b64decode(header[len("Basic "):]).decode("utf-8")
|
|
|
|
decoded = b64decode(header[len("Basic "):]).decode("utf-8")
|
|
|
|
username, _, password = decoded.partition(":")
|
|
|
|
username, _, password = decoded.partition(":")
|
|
|
|
except Exception:
|
|
|
|
except Exception:
|
|
|
|
username, password = "", ""
|
|
|
|
username, password = "", ""
|
|
|
|
cfg = self.server.current_app().dashboard
|
|
|
|
if _creds_match(username, password, cfg):
|
|
|
|
if secrets.compare_digest(username, cfg.username) and secrets.compare_digest(
|
|
|
|
self.server.clear_login_failures(client)
|
|
|
|
password, cfg.password
|
|
|
|
|
|
|
|
):
|
|
|
|
|
|
|
|
return True
|
|
|
|
return True
|
|
|
|
|
|
|
|
self.server.note_login_failure(client)
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_form(self) -> dict | None:
|
|
|
|
|
|
|
|
"""Parse the urlencoded request body, or send 400/413 and return None."""
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
|
|
length = -1
|
|
|
|
|
|
|
|
if length < 0:
|
|
|
|
|
|
|
|
self._send_bytes(HTTPStatus.BAD_REQUEST, "text/plain; charset=utf-8", b"bad Content-Length")
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
if length > _MAX_BODY_BYTES:
|
|
|
|
|
|
|
|
self._send_bytes(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "text/plain; charset=utf-8", b"request too large")
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
raw = self.rfile.read(length) if length > 0 else b""
|
|
|
|
|
|
|
|
return parse_qs(raw.decode("utf-8", errors="replace"))
|
|
|
|
|
|
|
|
|
|
|
|
def _csrf_token_for_session(self) -> str:
|
|
|
|
def _csrf_token_for_session(self) -> str:
|
|
|
|
"""Return the CSRF token bound to the current session, or '' if none."""
|
|
|
|
"""Return the CSRF token bound to the current session, or '' if none."""
|
|
|
|
token = self._session_token()
|
|
|
|
token = self._session_token()
|
|
|
@@ -2890,18 +3027,21 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
|
server = self.server
|
|
|
|
server = self.server
|
|
|
|
|
|
|
|
|
|
|
|
if path == "/login":
|
|
|
|
if path == "/login":
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
|
|
data = self._read_form()
|
|
|
|
raw = self.rfile.read(length) if length > 0 else b""
|
|
|
|
if data is None:
|
|
|
|
data = parse_qs(raw.decode("utf-8", errors="replace"))
|
|
|
|
return
|
|
|
|
|
|
|
|
client = self.client_address[0]
|
|
|
|
|
|
|
|
if server.login_throttled(client):
|
|
|
|
|
|
|
|
self._send_bytes(
|
|
|
|
|
|
|
|
HTTPStatus.TOO_MANY_REQUESTS, "text/html; charset=utf-8", _render_login_page(throttled=True)
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
return
|
|
|
|
username = data.get("username", [""])[0]
|
|
|
|
username = data.get("username", [""])[0]
|
|
|
|
password = data.get("password", [""])[0]
|
|
|
|
password = data.get("password", [""])[0]
|
|
|
|
cfg = server.current_app().dashboard
|
|
|
|
cfg = server.current_app().dashboard
|
|
|
|
if secrets.compare_digest(username, cfg.username) and secrets.compare_digest(
|
|
|
|
if _creds_match(username, password, cfg):
|
|
|
|
password, cfg.password
|
|
|
|
server.clear_login_failures(client)
|
|
|
|
):
|
|
|
|
token = server.new_session()
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
|
|
|
|
|
|
csrf = secrets.token_urlsafe(32)
|
|
|
|
|
|
|
|
server._sessions[token] = (time.time() + _SESSION_MAX_AGE, csrf)
|
|
|
|
|
|
|
|
self.send_response(HTTPStatus.SEE_OTHER)
|
|
|
|
self.send_response(HTTPStatus.SEE_OTHER)
|
|
|
|
self.send_header("Location", "/")
|
|
|
|
self.send_header("Location", "/")
|
|
|
|
self.send_header(
|
|
|
|
self.send_header(
|
|
|
@@ -2911,6 +3051,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
|
self.send_header("Content-Length", "0")
|
|
|
|
self.send_header("Content-Length", "0")
|
|
|
|
self.end_headers()
|
|
|
|
self.end_headers()
|
|
|
|
else:
|
|
|
|
else:
|
|
|
|
|
|
|
|
server.note_login_failure(client)
|
|
|
|
self._send_bytes(
|
|
|
|
self._send_bytes(
|
|
|
|
HTTPStatus.UNAUTHORIZED, "text/html; charset=utf-8", _render_login_page(error=True)
|
|
|
|
HTTPStatus.UNAUTHORIZED, "text/html; charset=utf-8", _render_login_page(error=True)
|
|
|
|
)
|
|
|
|
)
|
|
|
@@ -2919,9 +3060,9 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
|
if not self._check_auth():
|
|
|
|
if not self._check_auth():
|
|
|
|
return
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
|
|
form = self._read_form()
|
|
|
|
raw_body = self.rfile.read(length) if length > 0 else b""
|
|
|
|
if form is None:
|
|
|
|
form = parse_qs(raw_body.decode("utf-8", errors="replace"))
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Only cookie-authenticated requests need a CSRF token. Basic Auth
|
|
|
|
# Only cookie-authenticated requests need a CSRF token. Basic Auth
|
|
|
|
# clients prove identity on every request so they're not subject to
|
|
|
|
# clients prove identity on every request so they're not subject to
|
|
|
|