Fix setup authentication and scanner detection

This commit is contained in:
Mario Fetka
2026-07-18 11:22:01 +02:00
parent 1f1193a68c
commit 0e3194a37d
14 changed files with 216 additions and 30 deletions
+1 -1
View File
@@ -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))
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import logging, os, pwd, sys
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import logging, os, pwd, sys
import getpass
+1 -1
View File
@@ -1,4 +1,4 @@
#!@Python3_EXECUTABLE@
#!/usr/bin/env python3
# /****************************************************************************
# * <Novell-copyright>
+1 -1
View File
@@ -1,4 +1,4 @@
#!@Python3_EXECUTABLE@
#!/usr/bin/env python3
# /****************************************************************************
# * <Novell-copyright>
+14 -2
View File
@@ -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__":
+64
View File
@@ -0,0 +1,64 @@
# /****************************************************************************
# * <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>
# ****************************************************************************/
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()
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import logging, os, pwd, sys
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import logging, os, pwd, sys
@@ -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 $<TARGET_FILE:authsqlite3>)
endif()
@@ -0,0 +1,47 @@
/****************************************************************************
* <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>
****************************************************************************/
#include <dlfcn.h>
#include <stdio.h>
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;
}
+5 -1
View File
@@ -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;
}
+10 -6
View File
@@ -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)
+62 -14
View File
@@ -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()