diff --git a/include/connio.h b/include/connio.h index b82697c..1a426a0 100644 --- a/include/connio.h +++ b/include/connio.h @@ -311,7 +311,7 @@ int ConnReadToConnUntilEOS(Connection *Src, Connection *Dest); int ConnWrite(Connection *Conn, const void *Source, int Count); int ConnWriteF(Connection *Conn, const char *Format, ...) XPL_PRINTF(2, 3); -int ConnWriteVF(Connection *c, const char *format, va_list ap); +int ConnWriteVF(Connection *c, const char *format, va_list ap) XPL_PRINTF(2, 0); int ConnWriteFile(Connection *Conn, FILE *Source); int ConnWriteFromFile(Connection *Conn, FILE *Source, int Count); #define ConnWriteStr(conn, mesg) ConnWrite(conn, mesg, strlen(mesg)) diff --git a/src/agents/director/bongodirector.py b/src/agents/director/bongodirector.py index 6d4169e..e87a4b3 100755 --- a/src/agents/director/bongodirector.py +++ b/src/agents/director/bongodirector.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import logging, os, pwd, sys diff --git a/src/apps/admin/bongo-admin.py b/src/apps/admin/bongo-admin.py index ebde4b1..d16a3c9 100755 --- a/src/apps/admin/bongo-admin.py +++ b/src/apps/admin/bongo-admin.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import logging, os, pwd, sys import getpass diff --git a/src/apps/admin/bongo-config-ui.py.in b/src/apps/admin/bongo-config-ui.py.in index 1a64164..d51cfc3 100644 --- a/src/apps/admin/bongo-config-ui.py.in +++ b/src/apps/admin/bongo-config-ui.py.in @@ -1,4 +1,4 @@ -#!@Python3_EXECUTABLE@ +#!/usr/bin/env python3 # /**************************************************************************** # * diff --git a/src/apps/config/bongo-setup.py.in b/src/apps/config/bongo-setup.py.in index 24b55d7..6abbfaa 100644 --- a/src/apps/config/bongo-setup.py.in +++ b/src/apps/config/bongo-setup.py.in @@ -1,4 +1,4 @@ -#!@Python3_EXECUTABLE@ +#!/usr/bin/env python3 # /**************************************************************************** # * diff --git a/src/apps/config/tests/test_storage.py b/src/apps/config/tests/test_storage.py index c961ff7..3eacb5f 100644 --- a/src/apps/config/tests/test_storage.py +++ b/src/apps/config/tests/test_storage.py @@ -134,8 +134,20 @@ class ConfigurationStorageTest(unittest.TestCase): def test_scanner_probe_fails_closed_on_protocol_mismatch(self): with mock.patch("socket.create_connection", - return_value=FakeSocket(b"unexpected\n")): - self.assertFalse(probe_clamav().available) + return_value=FakeSocket(b"unexpected\n")), \ + mock.patch("shutil.which", return_value="/usr/bin/clamd"): + result = probe_clamav() + self.assertFalse(result.available) + self.assertTrue(result.installed) + + def test_scanner_probe_reports_installed_but_unreachable(self): + with mock.patch("socket.create_connection", + side_effect=ConnectionRefusedError("refused")), \ + mock.patch("shutil.which", return_value="/usr/bin/spamd"): + result = probe_spamd() + self.assertFalse(result.available) + self.assertTrue(result.installed) + self.assertEqual(result.endpoint, "127.0.0.1:783") if __name__ == "__main__": diff --git a/src/apps/config/tests/test_tui.py b/src/apps/config/tests/test_tui.py new file mode 100644 index 0000000..d8ae5da --- /dev/null +++ b/src/apps/config/tests/test_tui.py @@ -0,0 +1,64 @@ +# /**************************************************************************** +# * +# * 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. +# * +# ****************************************************************************/ + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +SOURCE = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(SOURCE / "src" / "libs" / "python")) + +from bongo.configuration.probes import ProbeResult # noqa: E402 +from bongo.configuration.tui import ( # noqa: E402 + _choice_layout, _scanner_status, +) + + +class ConfigurationTuiTest(unittest.TestCase): + def test_single_line_prompt_preserves_original_choice_row(self): + self.assertEqual(_choice_layout(24, "Question?", 2, 0), (5, 0, 17)) + + def test_multiline_summary_places_choices_after_prompt(self): + prompt = "Host: mail.example.test\nDomains: example.test\n\nInstall now?" + choice_row, first, visible = _choice_layout(24, prompt, 2, 0) + self.assertEqual(choice_row, 8) + self.assertEqual(first, 0) + self.assertGreaterEqual(visible, 2) + + def test_small_terminal_clamps_choice_rows_and_scrolls(self): + prompt = "\n".join(f"summary {number}" for number in range(20)) + choice_row, first, visible = _choice_layout(12, prompt, 8, 7) + self.assertEqual(choice_row, 9) + self.assertEqual(visible, 1) + self.assertEqual(first, 7) + + def test_scanner_status_distinguishes_unavailable_service(self): + result = ProbeResult(False, "127.0.0.1:3310", "refused", True) + self.assertEqual(_scanner_status(False, result), + "disabled (service unavailable)") + self.assertEqual(_scanner_status(True, result), + "configured (currently unreachable)") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/apps/queuetool/bongo-queuetool b/src/apps/queuetool/bongo-queuetool index 28aa930..fab5da0 100755 --- a/src/apps/queuetool/bongo-queuetool +++ b/src/apps/queuetool/bongo-queuetool @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import logging, os, pwd, sys diff --git a/src/apps/storetool/bongo-storetool.py b/src/apps/storetool/bongo-storetool.py index d9e74ca..941177c 100755 --- a/src/apps/storetool/bongo-storetool.py +++ b/src/apps/storetool/bongo-storetool.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import logging, os, pwd, sys diff --git a/src/libs/msgapi/auth-backends/CMakeLists.txt b/src/libs/msgapi/auth-backends/CMakeLists.txt index 6ad03e9..da38cb7 100644 --- a/src/libs/msgapi/auth-backends/CMakeLists.txt +++ b/src/libs/msgapi/auth-backends/CMakeLists.txt @@ -23,6 +23,8 @@ add_library(authsqlite3 MODULE target_link_libraries(authsqlite3 PRIVATE bongoauthstore bongoauthsecurity + bongomsgapi + bongoutil bongoxpl LibGcrypt::LibGcrypt OpenLDAP::LDAP) @@ -55,4 +57,9 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(auth-ldap-test PRIVATE OpenLDAP::LDAP) add_test(NAME auth-ldap COMMAND auth-ldap-test) + + add_executable(auth-module-load-test tests/module-load-test.c) + target_link_libraries(auth-module-load-test PRIVATE ${CMAKE_DL_LIBS}) + add_test(NAME auth-module-load + COMMAND auth-module-load-test $) endif() diff --git a/src/libs/msgapi/auth-backends/tests/module-load-test.c b/src/libs/msgapi/auth-backends/tests/module-load-test.c new file mode 100644 index 0000000..d1d924d --- /dev/null +++ b/src/libs/msgapi/auth-backends/tests/module-load-test.c @@ -0,0 +1,47 @@ +/**************************************************************************** + * + * 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. + * + ****************************************************************************/ + +#include +#include + +int +main(int argc, char **argv) +{ + void *module; + const char *error; + + if (argc != 2) { + fprintf(stderr, "usage: %s AUTH_MODULE\n", argv[0]); + return 2; + } + + dlerror(); + module = dlopen(argv[1], RTLD_NOW | RTLD_GLOBAL); + if (!module) { + error = dlerror(); + fprintf(stderr, "unable to load %s: %s\n", argv[1], + error ? error : "unknown dynamic loader error"); + return 1; + } + + dlclose(module); + return 0; +} diff --git a/src/libs/msgapi/msgauth.c b/src/libs/msgapi/msgauth.c index 432a233..1b33913 100644 --- a/src/libs/msgapi/msgauth.c +++ b/src/libs/msgapi/msgauth.c @@ -26,11 +26,15 @@ MsgAuthLoadBackend(const char *name, const char *file) AuthPlugin_InterfaceVersion *version_func; AuthPlugin_Init *init_func; MsgAuthAPIFunction *func; + const char *load_error; snprintf(path, XPL_MAX_PATH, "%s/bongo-auth/%s", XPL_DEFAULT_LIB_DIR, file); + dlerror(); auth = XplLoadDLL(path); if (! auth) { - Log(LOG_FATAL, "Unable to load auth backend '%s'", file); + load_error = dlerror(); + Log(LOG_FATAL, "Unable to load auth backend '%s': %s", + path, load_error ? load_error : "unknown dynamic loader error"); return 1; } diff --git a/src/libs/python/bongo/configuration/probes.py b/src/libs/python/bongo/configuration/probes.py index d253fe7..4de2fb1 100644 --- a/src/libs/python/bongo/configuration/probes.py +++ b/src/libs/python/bongo/configuration/probes.py @@ -23,6 +23,7 @@ from __future__ import annotations +import shutil import socket from dataclasses import dataclass @@ -32,6 +33,7 @@ class ProbeResult: available: bool endpoint: str detail: str + installed: bool = False def _receive(connection: socket.socket, maximum: int = 4096) -> bytes: @@ -49,31 +51,33 @@ def _receive(connection: socket.socket, maximum: int = 4096) -> bytes: def probe_clamav(host: str = "127.0.0.1", port: int = 3310, timeout: float = 1.5) -> ProbeResult: endpoint = f"{host}:{port}" + installed = shutil.which("clamd") is not None try: with socket.create_connection((host, port), timeout=timeout) as connection: connection.settimeout(timeout) connection.sendall(b"zPING\0") response = _receive(connection) except OSError as error: - return ProbeResult(False, endpoint, str(error)) + return ProbeResult(False, endpoint, str(error), installed) if response.rstrip(b"\0\r\n") == b"PONG": - return ProbeResult(True, endpoint, "clamd replied PONG") + return ProbeResult(True, endpoint, "clamd replied PONG", installed) return ProbeResult(False, endpoint, - f"unexpected clamd response {response[:80]!r}") + f"unexpected clamd response {response[:80]!r}", installed) def probe_spamd(host: str = "127.0.0.1", port: int = 783, timeout: float = 1.5) -> ProbeResult: endpoint = f"{host}:{port}" + installed = shutil.which("spamd") is not None try: with socket.create_connection((host, port), timeout=timeout) as connection: connection.settimeout(timeout) connection.sendall(b"PING SPAMC/1.5\r\n\r\n") response = _receive(connection) except OSError as error: - return ProbeResult(False, endpoint, str(error)) + return ProbeResult(False, endpoint, str(error), installed) first_line = response.splitlines()[0] if response else b"" if first_line.startswith(b"SPAMD/") and b" 0 PONG" in first_line: - return ProbeResult(True, endpoint, "spamd replied PONG") + return ProbeResult(True, endpoint, "spamd replied PONG", installed) return ProbeResult(False, endpoint, - f"unexpected spamd response {first_line[:80]!r}") + f"unexpected spamd response {first_line[:80]!r}", installed) diff --git a/src/libs/python/bongo/configuration/tui.py b/src/libs/python/bongo/configuration/tui.py index c6a2715..3ae9fb7 100644 --- a/src/libs/python/bongo/configuration/tui.py +++ b/src/libs/python/bongo/configuration/tui.py @@ -37,7 +37,7 @@ from .model import ( apply_scenario, errors, ) -from .probes import probe_clamav, probe_spamd +from .probes import ProbeResult, probe_clamav, probe_spamd from .storage import ConfigurationError, ConfigurationStore, ValidationError @@ -50,6 +50,43 @@ def _short(value: Any, maximum: int = 58) -> str: return rendered if len(rendered) <= maximum else rendered[:maximum - 1] + "…" +def _choice_layout(height: int, prompt: str, choice_count: int, + selected: int) -> tuple[int, int, int]: + """Return the first choice row, scroll offset and visible row count.""" + prompt_lines = str(prompt).splitlines() or [""] + choice_row = min(4 + len(prompt_lines), height - 3) + visible = max(1, height - choice_row - 2) + first = max(0, selected - visible + 1) + first = min(first, max(0, choice_count - visible)) + return choice_row, first, visible + + +def _scanner_choice(dialog: "Dialog", title: str, service: str, + result: ProbeResult, example: str) -> bool: + if result.available: + return dialog.confirm( + title, f"{result.detail}. Enable Bongo {service} scanning?", True) + if result.installed: + return dialog.confirm( + title, + f"The scanner is installed, but {result.endpoint} is not reachable: " + f"{result.detail}.\n\nConfigure Bongo for this endpoint anyway? " + f"Start the service and keep its TCP listener on loopback only. " + f"See {example}.", + False) + return False + + +def _scanner_status(enabled: bool, result: ProbeResult) -> str: + if enabled and result.available: + return "enabled" + if enabled: + return "configured (currently unreachable)" + if result.installed: + return "disabled (service unavailable)" + return "disabled (not installed)" + + class Dialog: def __init__(self, screen): self.screen = screen @@ -144,16 +181,19 @@ class Dialog: def choose(self, title: str, prompt: str, choices: list[tuple[str, str]], selected: int = 0) -> int: + if not choices: + raise ValueError("at least one choice is required") while True: self._frame(title, (prompt,), "↑/↓ select • Enter accepts • Esc cancels") height, width = self.screen.getmaxyx() - first = max(0, min(selected - (height - 9), - len(choices) - (height - 7))) + choice_row, first, visible = _choice_layout( + height, prompt, len(choices), selected) for offset, (_value, label) in enumerate( - choices[first:first + height - 7]): + choices[first:first + visible]): index = first + offset attribute = curses.A_REVERSE if index == selected else curses.A_NORMAL - self.screen.addnstr(5 + offset, 4, label, width - 8, attribute) + self.screen.addnstr(choice_row + offset, 4, label, + width - 8, attribute) self.screen.refresh() key = self.screen.getch() if key in (10, 13): @@ -307,17 +347,25 @@ def setup_wizard(screen, store: ConfigurationStore): ("Checking local clamd and spamd without modifying them…",)) clam = probe_clamav() spam = probe_spamd() - antivirus = clam.available and dialog.confirm( - "ClamAV", f"{clam.detail}. Enable Bongo antivirus scanning?", True) - antispam = spam.available and dialog.confirm( - "SpamAssassin", f"{spam.detail}. Enable Bongo spam scanning?", True) + antivirus = _scanner_choice( + dialog, "ClamAV", "antivirus", clam, + "/usr/share/bongo/examples/antispam/clamd.conf") + antispam = _scanner_choice( + dialog, "SpamAssassin", "spam", spam, + "/usr/share/bongo/examples/antispam/spamd.options") if not clam.available or not spam.available: missing = [] if not clam.available: - missing.append(f"ClamAV disabled: {clam.detail}") + state = "configured" if antivirus else "disabled" + missing.append( + f"ClamAV {state}: {clam.endpoint} is unavailable: {clam.detail}") if not spam.available: - missing.append(f"SpamAssassin disabled: {spam.detail}") - missing.append("Installed examples show safe loopback-only scanner settings.") + state = "configured" if antispam else "disabled" + missing.append( + f"SpamAssassin {state}: {spam.endpoint} is unavailable: {spam.detail}") + missing.append( + "Examples in /usr/share/bongo/examples/antispam show safe " + "loopback-only TCP settings.") dialog.message("Optional scanners", missing) password = dialog.input( @@ -372,8 +420,8 @@ def setup_wizard(screen, store: ConfigurationStore): f"Mail proxy: {'enabled' if mail_proxy else 'disabled'}", f"Internal relay: {'enabled' if internal else 'disabled'}", f"LDAP: {'enabled' if ldap else 'disabled'}", - f"SpamAssassin: {'enabled' if antispam else 'disabled'}", - f"ClamAV: {'enabled' if antivirus else 'disabled'}", + f"SpamAssassin: {_scanner_status(antispam, spam)}", + f"ClamAV: {_scanner_status(antivirus, clam)}", ] if not dialog.confirm("Apply setup", "\n".join(summary) + "\n\nInstall now?", True): raise Cancelled()