diff --git a/po/bongo-web/de.po b/po/bongo-web/de.po
index acdacf8..d29074d 100644
--- a/po/bongo-web/de.po
+++ b/po/bongo-web/de.po
@@ -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?"
diff --git a/po/bongo-web/fr.po b/po/bongo-web/fr.po
index e02096b..997cb16 100644
--- a/po/bongo-web/fr.po
+++ b/po/bongo-web/fr.po
@@ -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 ?"
diff --git a/po/bongo-web/pt_BR.po b/po/bongo-web/pt_BR.po
index 728cb69..c75ac0f 100644
--- a/po/bongo-web/pt_BR.po
+++ b/po/bongo-web/pt_BR.po
@@ -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?"
diff --git a/src/apps/admin/admin.c b/src/apps/admin/admin.c
index e449e62..0cddaf9 100644
--- a/src/apps/admin/admin.c
+++ b/src/apps/admin/admin.c
@@ -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);
diff --git a/src/www/bongo-web.py.in b/src/www/bongo-web.py.in
index 644b1d8..63beda7 100644
--- a/src/www/bongo-web.py.in
+++ b/src/www/bongo-web.py.in
@@ -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:
diff --git a/src/www/bongo_web/admin.py b/src/www/bongo_web/admin.py
new file mode 100644
index 0000000..4132eae
--- /dev/null
+++ b/src/www/bongo_web/admin.py
@@ -0,0 +1,140 @@
+# /****************************************************************************
+# *
+# * 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.
+# *
+# ****************************************************************************/
+
+"""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")
diff --git a/src/www/bongo_web/mail_store.py b/src/www/bongo_web/mail_store.py
index c481c8d..f72ef28 100644
--- a/src/www/bongo_web/mail_store.py
+++ b/src/www/bongo_web/mail_store.py
@@ -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)
diff --git a/src/www/static/app.css b/src/www/static/app.css
index a8442fe..dc48b4e 100644
--- a/src/www/static/app.css
+++ b/src/www/static/app.css
@@ -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; }
}
diff --git a/src/www/static/app.js b/src/www/static/app.js
index c37ef81..01fd30a 100644
--- a/src/www/static/app.js
+++ b/src/www/static/app.js
@@ -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", () => {
diff --git a/src/www/static/index.html b/src/www/static/index.html
index 8608aa2..7ae9724 100644
--- a/src/www/static/index.html
+++ b/src/www/static/index.html
@@ -171,7 +171,46 @@
Server administration
-
Use the settings gear for personal settings and the task indicator for pending server work.
+
+
+
+
User accounts
+
+
+
+
+
+
+
Services and agents
+
Agent
Status
Priority
+
+
+
+
+
+
Agent settings are read from the configuration files; apply them after editing.
+
+
+
LDAP offline cache
+
+
+
+
Personal settings
diff --git a/src/www/tests/test_admin.py b/src/www/tests/test_admin.py
new file mode 100644
index 0000000..a891a4a
--- /dev/null
+++ b/src/www/tests/test_admin.py
@@ -0,0 +1,59 @@
+# /****************************************************************************
+# *
+# * 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.
+# *
+# ****************************************************************************/
+
+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()
diff --git a/src/www/tests/test_mail_store.py b/src/www/tests/test_mail_store.py
index 992254f..7271753 100644
--- a/src/www/tests/test_mail_store.py
+++ b/src/www/tests/test_mail_store.py
@@ -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 \r\n"
b"To: recipient@example.test\r\nSubject: Hello\r\n"
b"Content-Type: text/plain; charset=utf-8\r\n\r\nBody")