Add interactive web server administration
Debian Trixie package bundle / packages (push) Successful in 21m32s

This commit is contained in:
Mario Fetka
2026-07-22 07:55:52 +02:00
parent 48ceeebe94
commit ffac9dd290
12 changed files with 566 additions and 10 deletions
+44
View File
@@ -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?"
+44
View File
@@ -1726,3 +1726,47 @@ msgid "Compact lists"
msgstr "Listes compactes"
msgid "Display settings saved."
msgstr "Paramètres daffichage 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 "Laction dadministration 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 dexécution ?"
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+140
View File
@@ -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")
+5 -1
View File
@@ -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)
+21
View File
@@ -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
View File
@@ -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", () => {
+40 -1
View File
@@ -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>
+59
View File
@@ -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()
+8 -1
View File
@@ -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")