Add interactive web server administration
Debian Trixie package bundle / packages (push) Successful in 21m32s
Debian Trixie package bundle / packages (push) Successful in 21m32s
This commit is contained in:
@@ -370,3 +370,47 @@ msgid "Compact lists"
|
||||
msgstr "Kompakte Listen"
|
||||
msgid "Display settings saved."
|
||||
msgstr "Darstellungseinstellungen gespeichert."
|
||||
msgid "User accounts"
|
||||
msgstr "Benutzerkonten"
|
||||
msgid "Account name"
|
||||
msgstr "Kontoname"
|
||||
msgid "Add user"
|
||||
msgstr "Benutzer hinzufügen"
|
||||
msgid "New password"
|
||||
msgstr "Neues Passwort"
|
||||
msgid "Confirm password"
|
||||
msgstr "Passwort bestätigen"
|
||||
msgid "Change password"
|
||||
msgstr "Passwort ändern"
|
||||
msgid "Quota"
|
||||
msgstr "Speicherlimit"
|
||||
msgid "Set quota"
|
||||
msgstr "Speicherlimit setzen"
|
||||
msgid "Services and agents"
|
||||
msgstr "Dienste und Agents"
|
||||
msgid "Agent"
|
||||
msgstr "Agent"
|
||||
msgid "Priority"
|
||||
msgstr "Priorität"
|
||||
msgid "Disabled"
|
||||
msgstr "Deaktiviert"
|
||||
msgid "Check configuration"
|
||||
msgstr "Konfiguration prüfen"
|
||||
msgid "Apply configuration"
|
||||
msgstr "Konfiguration anwenden"
|
||||
msgid "Agent settings are read from the configuration files; apply them after editing."
|
||||
msgstr "Agent-Einstellungen werden aus den Konfigurationsdateien gelesen; wenden Sie sie nach dem Bearbeiten an."
|
||||
msgid "LDAP offline cache"
|
||||
msgstr "LDAP-Offline-Cache"
|
||||
msgid "Extend all caches by hours"
|
||||
msgstr "Alle Caches um Stunden verlängern"
|
||||
msgid "Extend caches"
|
||||
msgstr "Caches verlängern"
|
||||
msgid "No LDAP cache information available."
|
||||
msgstr "Keine LDAP-Cache-Informationen verfügbar."
|
||||
msgid "Administration action failed."
|
||||
msgstr "Administrationsaktion fehlgeschlagen."
|
||||
msgid "Passwords do not match."
|
||||
msgstr "Die Passwörter stimmen nicht überein."
|
||||
msgid "Apply the validated configuration files to the running server?"
|
||||
msgstr "Die geprüften Konfigurationsdateien auf den laufenden Server anwenden?"
|
||||
|
||||
@@ -1726,3 +1726,47 @@ msgid "Compact lists"
|
||||
msgstr "Listes compactes"
|
||||
msgid "Display settings saved."
|
||||
msgstr "Paramètres d’affichage enregistrés."
|
||||
msgid "User accounts"
|
||||
msgstr "Comptes utilisateurs"
|
||||
msgid "Account name"
|
||||
msgstr "Nom du compte"
|
||||
msgid "Add user"
|
||||
msgstr "Ajouter un utilisateur"
|
||||
msgid "New password"
|
||||
msgstr "Nouveau mot de passe"
|
||||
msgid "Confirm password"
|
||||
msgstr "Confirmer le mot de passe"
|
||||
msgid "Change password"
|
||||
msgstr "Modifier le mot de passe"
|
||||
msgid "Quota"
|
||||
msgstr "Quota"
|
||||
msgid "Set quota"
|
||||
msgstr "Définir le quota"
|
||||
msgid "Services and agents"
|
||||
msgstr "Services et agents"
|
||||
msgid "Agent"
|
||||
msgstr "Agent"
|
||||
msgid "Priority"
|
||||
msgstr "Priorité"
|
||||
msgid "Disabled"
|
||||
msgstr "Désactivé"
|
||||
msgid "Check configuration"
|
||||
msgstr "Vérifier la configuration"
|
||||
msgid "Apply configuration"
|
||||
msgstr "Appliquer la configuration"
|
||||
msgid "Agent settings are read from the configuration files; apply them after editing."
|
||||
msgstr "Les réglages des agents sont lus depuis les fichiers de configuration ; appliquez-les après modification."
|
||||
msgid "LDAP offline cache"
|
||||
msgstr "Cache LDAP hors ligne"
|
||||
msgid "Extend all caches by hours"
|
||||
msgstr "Prolonger tous les caches en heures"
|
||||
msgid "Extend caches"
|
||||
msgstr "Prolonger les caches"
|
||||
msgid "No LDAP cache information available."
|
||||
msgstr "Aucune information de cache LDAP disponible."
|
||||
msgid "Administration action failed."
|
||||
msgstr "L’action d’administration a échoué."
|
||||
msgid "Passwords do not match."
|
||||
msgstr "Les mots de passe ne correspondent pas."
|
||||
msgid "Apply the validated configuration files to the running server?"
|
||||
msgstr "Appliquer les fichiers de configuration validés au serveur en cours d’exécution ?"
|
||||
|
||||
@@ -1211,3 +1211,47 @@ msgid "Compact lists"
|
||||
msgstr "Listas compactas"
|
||||
msgid "Display settings saved."
|
||||
msgstr "Configurações de exibição salvas."
|
||||
msgid "User accounts"
|
||||
msgstr "Contas de usuário"
|
||||
msgid "Account name"
|
||||
msgstr "Nome da conta"
|
||||
msgid "Add user"
|
||||
msgstr "Adicionar usuário"
|
||||
msgid "New password"
|
||||
msgstr "Nova senha"
|
||||
msgid "Confirm password"
|
||||
msgstr "Confirmar senha"
|
||||
msgid "Change password"
|
||||
msgstr "Alterar senha"
|
||||
msgid "Quota"
|
||||
msgstr "Cota"
|
||||
msgid "Set quota"
|
||||
msgstr "Definir cota"
|
||||
msgid "Services and agents"
|
||||
msgstr "Serviços e agentes"
|
||||
msgid "Agent"
|
||||
msgstr "Agente"
|
||||
msgid "Priority"
|
||||
msgstr "Prioridade"
|
||||
msgid "Disabled"
|
||||
msgstr "Desativado"
|
||||
msgid "Check configuration"
|
||||
msgstr "Verificar configuração"
|
||||
msgid "Apply configuration"
|
||||
msgstr "Aplicar configuração"
|
||||
msgid "Agent settings are read from the configuration files; apply them after editing."
|
||||
msgstr "As configurações dos agentes são lidas dos arquivos de configuração; aplique-as após editar."
|
||||
msgid "LDAP offline cache"
|
||||
msgstr "Cache LDAP offline"
|
||||
msgid "Extend all caches by hours"
|
||||
msgstr "Estender todos os caches por horas"
|
||||
msgid "Extend caches"
|
||||
msgstr "Estender caches"
|
||||
msgid "No LDAP cache information available."
|
||||
msgstr "Nenhuma informação de cache LDAP disponível."
|
||||
msgid "Administration action failed."
|
||||
msgstr "A ação administrativa falhou."
|
||||
msgid "Passwords do not match."
|
||||
msgstr "As senhas não coincidem."
|
||||
msgid "Apply the validated configuration files to the running server?"
|
||||
msgstr "Aplicar os arquivos de configuração validados ao servidor em execução?"
|
||||
|
||||
+36
-5
@@ -568,12 +568,34 @@ ClearSecret(char *secret)
|
||||
*cursor++ = '\0';
|
||||
}
|
||||
|
||||
static char *
|
||||
ReadPasswordLine(void)
|
||||
{
|
||||
char *line = NULL;
|
||||
size_t allocated = 0;
|
||||
ssize_t length;
|
||||
|
||||
length = getline(&line, &allocated, stdin);
|
||||
if (length < 0 || length > 4098) {
|
||||
if (line) {
|
||||
ClearSecret(line);
|
||||
free(line);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
while (length > 0 && (line[length - 1] == '\n' ||
|
||||
line[length - 1] == '\r'))
|
||||
line[--length] = '\0';
|
||||
return line;
|
||||
}
|
||||
|
||||
static int
|
||||
SetUserPassword(const char *username)
|
||||
SetUserPassword(const char *username, int read_stdin)
|
||||
{
|
||||
char *first_prompt;
|
||||
char *second_prompt;
|
||||
char *first = NULL;
|
||||
char *second = NULL;
|
||||
int result = 1;
|
||||
|
||||
if (!ValidCommandName(username) || InitializeAuth() != 0)
|
||||
@@ -582,16 +604,21 @@ SetUserPassword(const char *username)
|
||||
fprintf(stderr, "bongo-admin: user %s does not exist\n", username);
|
||||
return 1;
|
||||
}
|
||||
first_prompt = getpass("New password: ");
|
||||
first_prompt = read_stdin ? ReadPasswordLine() : getpass("New password: ");
|
||||
if (!first_prompt)
|
||||
goto done;
|
||||
first = strdup(first_prompt);
|
||||
ClearSecret(first_prompt);
|
||||
if (read_stdin)
|
||||
free(first_prompt);
|
||||
if (!first)
|
||||
goto done;
|
||||
second_prompt = getpass("Confirm password: ");
|
||||
second_prompt = read_stdin ? ReadPasswordLine() :
|
||||
getpass("Confirm password: ");
|
||||
if (!second_prompt)
|
||||
goto done;
|
||||
if (read_stdin)
|
||||
second = second_prompt;
|
||||
if (strlen(first) < 12) {
|
||||
fprintf(stderr, "bongo-admin: password must contain at least 12 characters\n");
|
||||
} else if (strcmp(first, second_prompt) != 0) {
|
||||
@@ -608,6 +635,8 @@ SetUserPassword(const char *username)
|
||||
}
|
||||
ClearSecret(second_prompt);
|
||||
done:
|
||||
if (second)
|
||||
free(second);
|
||||
if (first) {
|
||||
ClearSecret(first);
|
||||
free(first);
|
||||
@@ -971,6 +1000,8 @@ main(int argc, char **argv)
|
||||
return AccessConfiguration(argv[2], 0);
|
||||
if (argc == 3 && !strcmp(argv[1], "__config-replace"))
|
||||
return AccessConfiguration(argv[2], 1);
|
||||
if (argc == 4 && !strcmp(argv[1], "__user-password-stdin"))
|
||||
return SetUserPassword(argv[3], 1);
|
||||
if (argc == 3 && !strcmp(argv[1], "diagnose") &&
|
||||
!strcmp(argv[2], "store"))
|
||||
return DiagnoseStore();
|
||||
@@ -996,7 +1027,7 @@ main(int argc, char **argv)
|
||||
return ListUsers();
|
||||
if (argc == 4 && !strcmp(argv[1], "user") &&
|
||||
(!strcmp(argv[2], "password") || !strcmp(argv[2], "passwd")))
|
||||
return SetUserPassword(argv[3]);
|
||||
return SetUserPassword(argv[3], 0);
|
||||
if ((argc == 4 || argc == 5) && !strcmp(argv[1], "user") &&
|
||||
!strcmp(argv[2], "quota"))
|
||||
return UserQuota(argv[3], argc == 5 ? argv[4] : NULL);
|
||||
@@ -1011,7 +1042,7 @@ main(int argc, char **argv)
|
||||
return ListUsers();
|
||||
if (argc == 3 && (!strcmp(argv[1], "user-passwd") ||
|
||||
!strcmp(argv[1], "up")))
|
||||
return SetUserPassword(argv[2]);
|
||||
return SetUserPassword(argv[2], 0);
|
||||
if (argc == 3 && !strcmp(argv[1], "agent") &&
|
||||
!strcmp(argv[2], "list"))
|
||||
return ShowAgents(NULL);
|
||||
|
||||
+47
-1
@@ -77,6 +77,9 @@ from bongo_web.external_accounts import (create_account as create_external_accou
|
||||
list_accounts as list_external_accounts,
|
||||
list_identities as list_external_identities)
|
||||
from bongo_web.filters import (delete_filter, list_filters, save_filter)
|
||||
from bongo_web.admin import (ADMIN_COMMAND, AdminCommandError,
|
||||
perform as perform_admin,
|
||||
snapshot as admin_snapshot)
|
||||
from bongo_web.security import (SECOND_FACTOR_VALID, ExpiringSecrets,
|
||||
disable_totp, enable_totp,
|
||||
hosted_login_username,
|
||||
@@ -182,7 +185,15 @@ UI_MESSAGES = ("Email address", "Password", "Sign in", "Sign out", "Mail",
|
||||
"This account is not a server administrator.",
|
||||
"Server status could not be loaded.", "Account", "Host",
|
||||
"Domains", "Version",
|
||||
"Use the settings gear for personal settings and the task indicator for pending server work.")
|
||||
"User accounts", "Account name", "Add user", "New password",
|
||||
"Confirm password", "Change password", "Quota", "Set quota",
|
||||
"Services and agents", "Agent", "Priority", "Enabled",
|
||||
"Disabled", "Check configuration", "Apply configuration",
|
||||
"Agent settings are read from the configuration files; apply them after editing.",
|
||||
"LDAP offline cache", "Extend all caches by hours",
|
||||
"Extend caches", "No LDAP cache information available.",
|
||||
"Administration action failed.", "Passwords do not match.",
|
||||
"Apply the validated configuration files to the running server?")
|
||||
SUPPORTED_LANGUAGES = ("en", "de", "fr", "pt_BR")
|
||||
ASSET_PATH = re.compile(r"^/assets/(css|img)/([A-Za-z0-9._-]+)$")
|
||||
ASSET_TYPES = {".css": "text/css; charset=utf-8", ".gif": "image/gif",
|
||||
@@ -219,6 +230,9 @@ class BongoWebServer(ThreadingHTTPServer):
|
||||
raise ValueError("administrators must be a non-empty string array")
|
||||
self.administrators = {item.strip().casefold()
|
||||
for item in administrators}
|
||||
self.admin_command = str(config.get("admin_command", ADMIN_COMMAND))
|
||||
if not self.admin_command.startswith("/"):
|
||||
raise ValueError("admin_command must be an absolute path")
|
||||
if initialize:
|
||||
self.initialize_application()
|
||||
|
||||
@@ -657,11 +671,13 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
{"error": "administrator role required"})
|
||||
else:
|
||||
base = self.server.config.get("public_base_url", "")
|
||||
details = admin_snapshot(self.server.admin_command)
|
||||
self._json(HTTPStatus.OK, {
|
||||
"user": session["user"],
|
||||
"host": urlsplit(base).hostname or socket.getfqdn(),
|
||||
"domains": self.server.config.get("login_domains", []),
|
||||
"version": "@BONGO_V_STR@",
|
||||
**details,
|
||||
}, {"Vary": "Cookie"})
|
||||
elif path.startswith("/api/mail/"):
|
||||
self._mail_api(path)
|
||||
@@ -846,6 +862,9 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
path = self._path()
|
||||
if path == "/api/admin/action":
|
||||
self._admin_action()
|
||||
return
|
||||
if path == "/api/tasks/state":
|
||||
self._task_state()
|
||||
return
|
||||
@@ -1013,6 +1032,33 @@ class BongoWebHandler(BaseHTTPRequestHandler):
|
||||
token, lifetime, self._secure_cookies()),
|
||||
"Vary": "Cookie"})
|
||||
|
||||
def _admin_action(self):
|
||||
session = self._security_session()
|
||||
if session is None:
|
||||
return
|
||||
if session["user"].casefold() not in self.server.administrators:
|
||||
self._json(HTTPStatus.FORBIDDEN,
|
||||
{"error": "administrator role required"})
|
||||
return
|
||||
try:
|
||||
data = self._request_data()
|
||||
action = str(data.get("action", ""))
|
||||
result = perform_admin(action, data, self.server.admin_command)
|
||||
except (TypeError, ValueError, OverflowError, UnicodeError,
|
||||
json.JSONDecodeError):
|
||||
self._json(HTTPStatus.BAD_REQUEST,
|
||||
{"error": "invalid administration action"})
|
||||
return
|
||||
except AdminCommandError as error:
|
||||
logging.warning("Administration action failed: user=%s action=%s: %s",
|
||||
session["user"], action, error)
|
||||
self._json(HTTPStatus.CONFLICT, {"error": str(error)})
|
||||
return
|
||||
logging.info("Administration action completed: user=%s action=%s",
|
||||
session["user"], action)
|
||||
self._json(HTTPStatus.OK, {"message": result or "OK"},
|
||||
{"Vary": "Cookie"})
|
||||
|
||||
def _security_session(self):
|
||||
session = self._session()
|
||||
if session is None:
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
# /****************************************************************************
|
||||
# * <Novell-copyright>
|
||||
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
|
||||
# *
|
||||
# * This program is free software; you can redistribute it and/or
|
||||
# * modify it under the terms of version 2 of the GNU General Public License
|
||||
# * as published by the Free Software Foundation.
|
||||
# *
|
||||
# * This program is distributed in the hope that it will be useful,
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# * GNU General Public License for more details.
|
||||
# *
|
||||
# * You should have received a copy of the GNU General Public License
|
||||
# * along with this program; if not, contact Novell, Inc.
|
||||
# *
|
||||
# * To contact Novell about this file by physical or electronic mail, you
|
||||
# * may find current contact information at www.novell.com.
|
||||
# * </Novell-copyright>
|
||||
# ****************************************************************************/
|
||||
|
||||
"""Restricted bridge between web administration and ``bongo-admin``.
|
||||
|
||||
The web service never invokes a shell. Every supported operation is selected
|
||||
from a fixed command table and secrets are supplied through stdin rather than
|
||||
the process list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
ADMIN_COMMAND = "/usr/bin/bongo-admin"
|
||||
ACCOUNT = re.compile(r"^[^\s/\\]{1,255}$")
|
||||
QUOTA = re.compile(r"^(?:unlimited|none|[0-9]+(?:[KMGTPE](?:i?[Bb])?)?)$",
|
||||
re.IGNORECASE)
|
||||
AGENT = re.compile(r"^(.*?)\s+(enabled|disabled) \(priority (-?[0-9]+)\)$")
|
||||
|
||||
|
||||
class AdminCommandError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _command(program, *arguments, input_text=None, timeout=15):
|
||||
if not isinstance(program, str) or not program.startswith("/"):
|
||||
raise AdminCommandError("invalid administration command")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[program, *arguments], input=input_text, capture_output=True,
|
||||
text=True, encoding="utf-8", errors="replace", timeout=timeout,
|
||||
check=False)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
raise AdminCommandError("administration command unavailable") from error
|
||||
output = completed.stdout.strip()
|
||||
error = completed.stderr.strip()
|
||||
if completed.returncode != 0:
|
||||
raise AdminCommandError(error or output or "administration command failed")
|
||||
return output
|
||||
|
||||
|
||||
def _account(value):
|
||||
value = str(value).strip()
|
||||
if not ACCOUNT.fullmatch(value):
|
||||
raise ValueError("invalid account name")
|
||||
return value
|
||||
|
||||
|
||||
def _key_values(output):
|
||||
result = {}
|
||||
for line in output.splitlines():
|
||||
name, separator, value = line.partition(":")
|
||||
if separator:
|
||||
result[name.strip().casefold().replace(" ", "_")] = value.strip()
|
||||
return result
|
||||
|
||||
|
||||
def snapshot(program=ADMIN_COMMAND):
|
||||
"""Return independently collected status so one failed panel stays usable."""
|
||||
result = {"users": [], "agents": [], "server": {}, "ldap_cache": "",
|
||||
"errors": []}
|
||||
commands = (("users", ("user", "list")),
|
||||
("agents", ("agent", "list")),
|
||||
("server", ("server", "info")),
|
||||
("ldap_cache", ("ldap-cache", "status")))
|
||||
for name, arguments in commands:
|
||||
try:
|
||||
output = _command(program, *arguments)
|
||||
if name == "users":
|
||||
result[name] = [line.strip() for line in output.splitlines()
|
||||
if line.strip()]
|
||||
elif name == "agents":
|
||||
agents = []
|
||||
for line in output.splitlines():
|
||||
match = AGENT.fullmatch(line.strip())
|
||||
if match:
|
||||
agents.append({"name": match.group(1).strip(),
|
||||
"enabled": match.group(2) == "enabled",
|
||||
"priority": int(match.group(3))})
|
||||
result[name] = agents
|
||||
elif name == "server":
|
||||
result[name] = _key_values(output)
|
||||
else:
|
||||
result[name] = output
|
||||
except AdminCommandError as error:
|
||||
result["errors"].append({"section": name, "message": str(error)})
|
||||
return result
|
||||
|
||||
|
||||
def perform(action, values, program=ADMIN_COMMAND):
|
||||
"""Perform one explicitly supported administrative mutation."""
|
||||
if action == "user_add":
|
||||
return _command(program, "user", "add", _account(values.get("user", "")))
|
||||
if action == "user_password":
|
||||
user = _account(values.get("user", ""))
|
||||
password = str(values.get("password", ""))
|
||||
if not 12 <= len(password) <= 4096 or "\n" in password or "\r" in password:
|
||||
raise ValueError("invalid password")
|
||||
return _command(program, "__user-password-stdin", user,
|
||||
input_text=password + "\n" + password + "\n")
|
||||
if action == "user_quota":
|
||||
user = _account(values.get("user", ""))
|
||||
quota = str(values.get("quota", "")).strip()
|
||||
if not QUOTA.fullmatch(quota):
|
||||
raise ValueError("invalid quota")
|
||||
return _command(program, "user", "quota", user, quota)
|
||||
if action == "ldap_extend_all":
|
||||
try:
|
||||
hours = int(values.get("hours", 48))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("invalid cache lifetime") from error
|
||||
if not 1 <= hours <= 744:
|
||||
raise ValueError("invalid cache lifetime")
|
||||
return _command(program, "ldap-cache", "extend-all", str(hours))
|
||||
if action == "config_check":
|
||||
return _command(program, "config", "check", timeout=30)
|
||||
if action == "config_sync":
|
||||
return _command(program, "config", "sync", timeout=30)
|
||||
raise ValueError("unsupported administration action")
|
||||
@@ -189,8 +189,12 @@ def read_conversation(user, store_cookie, conversation_id,
|
||||
client = None
|
||||
try:
|
||||
client = client_factory(user, store_cookie)
|
||||
# CONVERSATION is a streamed NMAP command. Consume its terminating
|
||||
# response before issuing READ commands on the same connection;
|
||||
# otherwise READ sees the preceding command's final ``1000 OK``.
|
||||
items = list(client.Conversation(conversation_id))
|
||||
output = []
|
||||
for item in client.Conversation(conversation_id):
|
||||
for item in items:
|
||||
if int(item.type) != 0x0002:
|
||||
continue
|
||||
content = _message_content(client.Read(item.uid), sanitizer)
|
||||
|
||||
@@ -94,6 +94,24 @@ header { grid-column: 1 / -1; display: flex; align-items: center; min-height: 68
|
||||
.recovery-codes li { margin: .25rem 0; break-inside: avoid; }
|
||||
.recovery-actions { display: flex; align-items: center; gap: .7rem; }
|
||||
.security-status { font-weight: bold; }
|
||||
.admin-view > h2 { color: #1d3980; }
|
||||
#admin-summary { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .25rem .8rem; max-width: 50rem; }
|
||||
#admin-summary dt { font-weight: bold; }
|
||||
#admin-summary dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.admin-result { padding: .55rem .7rem; white-space: pre-wrap; border: 1px solid #86a579; background: #eff8ec; }
|
||||
.admin-result.error { border-color: #c47b7b; background: #fff0f0; }
|
||||
#admin-panels { display: grid; grid-template-columns: repeat(2, minmax(18rem, 1fr)); gap: 1rem; }
|
||||
.admin-panel { min-width: 0; padding: .8rem; border: 1px solid #afbdd2; background: #f7f9fc; }
|
||||
.admin-panel h3 { margin: 0 0 .7rem; color: #1d3980; border-bottom: 1px solid #ccd5e0; }
|
||||
.admin-panel:first-child { grid-row: span 2; }
|
||||
.admin-user-list { max-height: 12rem; margin: 0 0 .8rem; padding: .4rem .4rem .4rem 1.8rem; overflow: auto; border: 1px solid #ccd5e0; background: #fff; }
|
||||
.admin-form { display: grid; grid-template-columns: repeat(2, minmax(10rem, 1fr)) auto; gap: .5rem; align-items: end; margin-top: .65rem; }
|
||||
.admin-form label { display: flex; flex-direction: column; gap: .2rem; }
|
||||
.admin-form button { min-height: 2rem; }
|
||||
.admin-agent-table { width: 100%; border-collapse: collapse; background: #fff; }
|
||||
.admin-agent-table th, .admin-agent-table td { padding: .3rem .45rem; text-align: left; border: 1px solid #ccd5e0; }
|
||||
.admin-actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .7rem; }
|
||||
#admin-ldap-cache { max-width: 100%; max-height: 14rem; overflow: auto; padding: .5rem; border: 1px solid #ccd5e0; background: #fff; }
|
||||
.quiet { margin-left: 1rem; padding: .25rem .6rem; color: #e4eaf4; border: 1px solid #7a9ac9; background: transparent; }
|
||||
#section-sidebar { align-self: start; }
|
||||
#sidebar-nav { min-width: 120px; margin: 12px 0 0 12px; padding: .4rem 0 1rem; background: #fff; border: 1px solid #5f85bf; border-width: 1px 0 1px 1px; }
|
||||
@@ -156,4 +174,7 @@ select { padding: .25rem; border: 1px solid #7f8da5; background: #fff; }
|
||||
.organizer-form .wide { grid-column: 1; }
|
||||
.totp-enrollment { grid-template-columns: 1fr; }
|
||||
.recovery-codes { columns: 1; }
|
||||
#admin-panels { grid-template-columns: 1fr; }
|
||||
.admin-panel:first-child { grid-row: auto; }
|
||||
.admin-form { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
+78
-1
@@ -106,12 +106,14 @@ async function loadAdminStatus() {
|
||||
const response = await fetch("/api/admin/status", {credentials: "same-origin"});
|
||||
const status = document.querySelector("#admin-status");
|
||||
const summary = document.querySelector("#admin-summary");
|
||||
const panels = document.querySelector("#admin-panels");
|
||||
if (response.status === 403) {
|
||||
status.textContent = tr("This account is not a server administrator.");
|
||||
summary.replaceChildren();
|
||||
panels.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (!response.ok) { status.textContent = tr("Server status could not be loaded."); return; }
|
||||
if (!response.ok) { status.textContent = tr("Server status could not be loaded."); panels.hidden = true; return; }
|
||||
const result = await response.json();
|
||||
status.textContent = tr("Signed in as server administrator.");
|
||||
const entries = [[tr("Account"), result.user], [tr("Host"), result.host || ""],
|
||||
@@ -121,6 +123,54 @@ async function loadAdminStatus() {
|
||||
const definition = document.createElement("dd"); definition.textContent = value;
|
||||
return [term, definition];
|
||||
}));
|
||||
const users = result.users || [];
|
||||
document.querySelector("#admin-user-list").replaceChildren(...users.map(user => {
|
||||
const item = document.createElement("li"); item.textContent = user; return item;
|
||||
}));
|
||||
document.querySelectorAll("#admin-user-password select, #admin-user-quota select").forEach(select => {
|
||||
const selected = select.value;
|
||||
select.replaceChildren(...users.map(user => {
|
||||
const option = document.createElement("option"); option.value = user; option.textContent = user; return option;
|
||||
}));
|
||||
if (users.includes(selected)) select.value = selected;
|
||||
});
|
||||
document.querySelector("#admin-agent-list").replaceChildren(...(result.agents || []).map(agent => {
|
||||
const row = document.createElement("tr");
|
||||
for (const value of [agent.name, agent.enabled ? tr("Enabled") : tr("Disabled"), agent.priority]) {
|
||||
const cell = document.createElement("td"); cell.textContent = String(value); row.append(cell);
|
||||
}
|
||||
return row;
|
||||
}));
|
||||
document.querySelector("#admin-ldap-cache").textContent = result.ldap_cache || tr("No LDAP cache information available.");
|
||||
if ((result.errors || []).length) {
|
||||
showAdminResult(result.errors.map(error => `${error.section}: ${error.message}`).join("\n"), true);
|
||||
}
|
||||
panels.hidden = false;
|
||||
}
|
||||
|
||||
function showAdminResult(message, failed = false) {
|
||||
const result = document.querySelector("#admin-result");
|
||||
result.textContent = message;
|
||||
result.classList.toggle("error", failed);
|
||||
result.hidden = !message;
|
||||
}
|
||||
|
||||
async function adminAction(action, values = {}) {
|
||||
showAdminResult("");
|
||||
const response = await fetch("/api/admin/action", {
|
||||
method: "POST", credentials: "same-origin",
|
||||
headers: {"Content-Type": "application/json", "X-Bongo-CSRF": session.csrf_token},
|
||||
body: JSON.stringify({action, ...values})
|
||||
});
|
||||
let result = {};
|
||||
try { result = await response.json(); } catch (_) { result = {}; }
|
||||
if (!response.ok) {
|
||||
showAdminResult(result.error || tr("Administration action failed."), true);
|
||||
return false;
|
||||
}
|
||||
showAdminResult(result.message || tr("Saved."));
|
||||
await loadAdminStatus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function organizerInputValue(value) {
|
||||
@@ -1063,6 +1113,33 @@ document.querySelector("#contacts-refresh").addEventListener("click", loadContac
|
||||
document.querySelector("#contact-form").addEventListener("submit", saveContact);
|
||||
document.querySelector("#contact-delete").addEventListener("click", deleteContact);
|
||||
document.querySelector("#contact-cancel").addEventListener("click", () => { document.querySelector("#contact-form").hidden = true; });
|
||||
document.querySelector("#admin-user-add").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
if (await adminAction("user_add", {user: form.user.value})) form.reset();
|
||||
});
|
||||
document.querySelector("#admin-user-password").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
if (form.password.value !== form.confirmation.value) {
|
||||
showAdminResult(tr("Passwords do not match."), true); return;
|
||||
}
|
||||
if (await adminAction("user_password", {user: form.user.value, password: form.password.value})) form.reset();
|
||||
});
|
||||
document.querySelector("#admin-user-quota").addEventListener("submit", event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
adminAction("user_quota", {user: form.user.value, quota: form.quota.value});
|
||||
});
|
||||
document.querySelector("#admin-ldap-extend").addEventListener("submit", event => {
|
||||
event.preventDefault();
|
||||
adminAction("ldap_extend_all", {hours: event.currentTarget.hours.value});
|
||||
});
|
||||
document.querySelector("#admin-config-check").addEventListener("click", () => adminAction("config_check"));
|
||||
document.querySelector("#admin-config-sync").addEventListener("click", () => {
|
||||
if (window.confirm(tr("Apply the validated configuration files to the running server?"))) adminAction("config_sync");
|
||||
});
|
||||
document.querySelector("#admin-refresh").addEventListener("click", loadAdminStatus);
|
||||
document.querySelector("#message-back").addEventListener("click", () => { openConversation = null; render(); });
|
||||
document.querySelector("#load-remote-images").addEventListener("click", () => renderConversation(true));
|
||||
document.querySelector("#compose-new").addEventListener("click", () => {
|
||||
|
||||
@@ -171,7 +171,46 @@
|
||||
<h2 data-i18n="Server administration">Server administration</h2>
|
||||
<p id="admin-status" role="status"></p>
|
||||
<dl id="admin-summary"></dl>
|
||||
<p data-i18n="Use the settings gear for personal settings and the task indicator for pending server work.">Use the settings gear for personal settings and the task indicator for pending server work.</p>
|
||||
<p id="admin-result" class="admin-result" role="status" hidden></p>
|
||||
<div id="admin-panels" hidden>
|
||||
<section class="admin-panel">
|
||||
<h3 data-i18n="User accounts">User accounts</h3>
|
||||
<ul id="admin-user-list" class="admin-user-list"></ul>
|
||||
<form id="admin-user-add" class="admin-form">
|
||||
<label><span data-i18n="Account name">Account name</span><input name="user" required maxlength="255" autocomplete="off"></label>
|
||||
<button type="submit" data-i18n="Add user">Add user</button>
|
||||
</form>
|
||||
<form id="admin-user-password" class="admin-form">
|
||||
<label><span data-i18n="Account">Account</span><select name="user" required></select></label>
|
||||
<label><span data-i18n="New password">New password</span><input name="password" type="password" required minlength="12" maxlength="4096" autocomplete="new-password"></label>
|
||||
<label><span data-i18n="Confirm password">Confirm password</span><input name="confirmation" type="password" required minlength="12" maxlength="4096" autocomplete="new-password"></label>
|
||||
<button type="submit" data-i18n="Change password">Change password</button>
|
||||
</form>
|
||||
<form id="admin-user-quota" class="admin-form">
|
||||
<label><span data-i18n="Account">Account</span><select name="user" required></select></label>
|
||||
<label><span data-i18n="Quota">Quota</span><input name="quota" required value="10G" maxlength="32" placeholder="10G / unlimited"></label>
|
||||
<button type="submit" data-i18n="Set quota">Set quota</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="admin-panel">
|
||||
<h3 data-i18n="Services and agents">Services and agents</h3>
|
||||
<table class="admin-agent-table"><thead><tr><th data-i18n="Agent">Agent</th><th data-i18n="Status">Status</th><th data-i18n="Priority">Priority</th></tr></thead><tbody id="admin-agent-list"></tbody></table>
|
||||
<div class="admin-actions">
|
||||
<button id="admin-config-check" type="button" data-i18n="Check configuration">Check configuration</button>
|
||||
<button id="admin-config-sync" type="button" data-i18n="Apply configuration">Apply configuration</button>
|
||||
<button id="admin-refresh" type="button" data-i18n="Refresh">Refresh</button>
|
||||
</div>
|
||||
<p class="muted" data-i18n="Agent settings are read from the configuration files; apply them after editing.">Agent settings are read from the configuration files; apply them after editing.</p>
|
||||
</section>
|
||||
<section class="admin-panel">
|
||||
<h3 data-i18n="LDAP offline cache">LDAP offline cache</h3>
|
||||
<pre id="admin-ldap-cache"></pre>
|
||||
<form id="admin-ldap-extend" class="admin-form">
|
||||
<label><span data-i18n="Extend all caches by hours">Extend all caches by hours</span><input name="hours" type="number" min="1" max="744" value="48" required></label>
|
||||
<button type="submit" data-i18n="Extend caches">Extend caches</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<section id="settings-view" class="settings-view" hidden>
|
||||
<h2 data-i18n="Personal settings">Personal settings</h2>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# /****************************************************************************
|
||||
# * <Novell-copyright>
|
||||
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
|
||||
# *
|
||||
# * This program is free software; you can redistribute it and/or
|
||||
# * modify it under the terms of version 2 of the GNU General Public License
|
||||
# * as published by the Free Software Foundation.
|
||||
# *
|
||||
# * This program is distributed in the hope that it will be useful,
|
||||
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# * GNU General Public License for more details.
|
||||
# *
|
||||
# * You should have received a copy of the GNU General Public License
|
||||
# * along with this program; if not, contact Novell, Inc.
|
||||
# *
|
||||
# * To contact Novell about this file by physical or electronic mail, you
|
||||
# * may find current contact information at www.novell.com.
|
||||
# * </Novell-copyright>
|
||||
# ****************************************************************************/
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from bongo_web import admin
|
||||
|
||||
|
||||
class AdminBridgeTests(unittest.TestCase):
|
||||
@mock.patch("bongo_web.admin.subprocess.run")
|
||||
def test_password_is_passed_only_through_stdin(self, run):
|
||||
run.return_value = mock.Mock(returncode=0, stdout="changed\n", stderr="")
|
||||
result = admin.perform("user_password", {
|
||||
"user": "alice", "password": "correct horse battery staple"})
|
||||
self.assertEqual(result, "changed")
|
||||
arguments = run.call_args.args[0]
|
||||
self.assertNotIn("correct horse battery staple", arguments)
|
||||
self.assertEqual(run.call_args.kwargs["input"],
|
||||
"correct horse battery staple\ncorrect horse battery staple\n")
|
||||
|
||||
def test_rejects_shell_metacharacter_whitespace_and_bad_quota(self):
|
||||
with self.assertRaises(ValueError):
|
||||
admin.perform("user_add", {"user": "bad user"})
|
||||
with self.assertRaises(ValueError):
|
||||
admin.perform("user_quota", {"user": "alice", "quota": "1G;id"})
|
||||
|
||||
@mock.patch("bongo_web.admin._command")
|
||||
def test_snapshot_parses_users_agents_and_server(self, command):
|
||||
command.side_effect = ["admin\nalice", "smtp enabled (priority 10)",
|
||||
"Server: mail.test\nManager: running (pid 1)",
|
||||
"0 cached user(s)"]
|
||||
result = admin.snapshot()
|
||||
self.assertEqual(result["users"], ["admin", "alice"])
|
||||
self.assertEqual(result["agents"][0]["name"], "smtp")
|
||||
self.assertTrue(result["agents"][0]["enabled"])
|
||||
self.assertEqual(result["server"]["server"], "mail.test")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,6 +28,7 @@ from bongo_web.mail_store import list_conversations, list_folders, read_conversa
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
self.conversation_consumed = False
|
||||
|
||||
def Collections(self, path):
|
||||
self.assert_path = path
|
||||
@@ -47,9 +48,15 @@ class FakeStore:
|
||||
return result
|
||||
|
||||
def Conversation(self, uid, props=()):
|
||||
return [SimpleNamespace(uid="b" * 32, flags=2, type=2)]
|
||||
item = SimpleNamespace(uid="b" * 32, flags=2, type=2)
|
||||
def stream():
|
||||
yield item
|
||||
self.conversation_consumed = True
|
||||
return stream()
|
||||
|
||||
def Read(self, uid):
|
||||
if not self.conversation_consumed:
|
||||
raise RuntimeError("nested Store command before iterator completion")
|
||||
return (b"From: Example User <user@example.test>\r\n"
|
||||
b"To: recipient@example.test\r\nSubject: Hello\r\n"
|
||||
b"Content-Type: text/plain; charset=utf-8\r\n\r\nBody")
|
||||
|
||||
Reference in New Issue
Block a user