188 lines
7.0 KiB
Python
Executable File
188 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Drive the real curses setup wizard without installing anything."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import curses
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def source_root() -> Path:
|
|
return Path(os.environ.get(
|
|
"BONGO_SOURCE_ROOT", Path(__file__).resolve().parents[2])).resolve()
|
|
|
|
|
|
def temporary_root() -> Path:
|
|
root = Path(os.environ.get(
|
|
"BONGO_TEST_CONFIG_ROOT", "/tmp/bongo-setup-wizard-defaults"))
|
|
resolved = root.resolve()
|
|
if resolved == Path("/tmp") or Path("/tmp") not in resolved.parents:
|
|
raise SystemExit("BONGO_TEST_CONFIG_ROOT must be a child of /tmp")
|
|
return resolved
|
|
|
|
|
|
def run_wizard() -> None:
|
|
source = source_root()
|
|
root = temporary_root()
|
|
sys.path.insert(0, str(source / "src" / "libs" / "python"))
|
|
os.environ["BONGO_CONFIG_TEMPLATE_DIR"] = str(
|
|
source / "src" / "apps" / "config" / "config")
|
|
os.environ["BONGO_CONFIG_DOCUMENT_DIR"] = str(root / "etc/bongo/config.d")
|
|
os.environ["BONGO_ALIASES_CONFIG_DIR"] = str(root / "etc/bongo/aliases.d")
|
|
os.environ["BONGO_SSL_DIR"] = str(root / "etc/bongo/ssl.d")
|
|
os.environ["BONGO_DKIM_DIR"] = str(root / "etc/bongo/dkim.d")
|
|
os.environ["BONGO_DMARC_DIR"] = str(root / "etc/bongo/dmarc.d")
|
|
os.environ["BONGO_ACME_CONFIG_DIR"] = str(root / "etc/bongo/acme.d")
|
|
os.environ["BONGO_ACME_PROVIDER_CONFIG"] = str(
|
|
root / "etc/bongo/acme.d/providers")
|
|
|
|
from bongo.configuration import tui # pylint: disable=import-outside-toplevel
|
|
from bongo.configuration.model import errors # pylint: disable=import-outside-toplevel
|
|
from bongo.configuration.probes import ProbeResult # pylint: disable=import-outside-toplevel
|
|
from bongo.configuration.storage import ConfigurationStore # pylint: disable=import-outside-toplevel
|
|
from bongo.domain import domain_to_ascii # pylint: disable=import-outside-toplevel
|
|
|
|
def mail_dns(domains, _selector):
|
|
return {
|
|
domain_to_ascii(domain): {
|
|
"spf": [], "dkim": [], "dmarc": [], "errors": []
|
|
}
|
|
for domain in domains
|
|
}
|
|
|
|
tui.inspect_mail_dns = mail_dns
|
|
tui.probe_clamav = lambda: ProbeResult(
|
|
False, "127.0.0.1:3310", "not installed", False)
|
|
tui.probe_spamd = lambda: ProbeResult(
|
|
False, "127.0.0.1:783", "not installed", False)
|
|
|
|
store = ConfigurationStore()
|
|
scenario, configurations, plans = curses.wrapper(tui.setup_wizard, store)
|
|
issues = store.validate(configurations, check_files=False)
|
|
result = {
|
|
"hostname": scenario.hostname,
|
|
"bind_address": scenario.bind_address,
|
|
"domains": scenario.domains,
|
|
"tls_hostnames": scenario.tls_hostnames,
|
|
"web_mode": scenario.web_mode,
|
|
"web_public_url": scenario.web_public_url,
|
|
"web_proxy_networks": scenario.web_proxy_networks,
|
|
"imap_enabled": scenario.imap_enabled,
|
|
"pop3_enabled": scenario.pop3_enabled,
|
|
"sieve_enabled": scenario.sieve_enabled,
|
|
"submission_enabled": scenario.submission_enabled,
|
|
"internal_relay_enabled": scenario.internal_relay_enabled,
|
|
"ldap_enabled": scenario.ldap_enabled,
|
|
"antispam_enabled": scenario.antispam_enabled,
|
|
"antivirus_enabled": scenario.antivirus_enabled,
|
|
"acme_enabled": scenario.acme_enabled,
|
|
"scanner_plan_count": len(plans),
|
|
"validation_errors": [str(issue) for issue in errors(issues)],
|
|
}
|
|
print("BONGO_WIZARD_RESULT=" + json.dumps(result, sort_keys=True))
|
|
|
|
|
|
def drive_wizard(password: str) -> None:
|
|
try:
|
|
import pexpect
|
|
except ImportError as error:
|
|
raise SystemExit("setup-wizard-defaults.py requires pexpect") from error
|
|
|
|
child = pexpect.spawn(
|
|
sys.executable, [str(Path(__file__).resolve()), "--wizard"],
|
|
env={**os.environ, "TERM": "xterm-256color"},
|
|
encoding="utf-8", timeout=30)
|
|
child.setwinsize(24, 100)
|
|
transcript: list[str] = []
|
|
|
|
def answer(prompt: str, value: str = "", replace: bool = False) -> None:
|
|
child.expect(prompt)
|
|
transcript.append(child.before + child.after)
|
|
if replace:
|
|
child.send("\x7f" * 255)
|
|
child.send(value + "\r")
|
|
|
|
answer("Bongo development setup")
|
|
answer("Primary mail hostname", "mail.example.test", True)
|
|
answer("IPv4 address Bongo listens on", "192.0.2.10", True)
|
|
answer("Hosted domains, separated by commas", "example.test", True)
|
|
answer("DNS hostnames used by mail and Web clients")
|
|
answer("Enable automatic ACME certificate issuance")
|
|
answer("How is Bongo Web exposed")
|
|
answer("Public base URL")
|
|
answer("Trusted proxy IPv4 networks")
|
|
answer("behind HAProxy")
|
|
answer("Enable IMAP4/IMAP4rev1/rev2")
|
|
answer(r"POP3\?")
|
|
answer(r"filtering\?")
|
|
answer(r"submission\?")
|
|
answer(r"old clients\?")
|
|
answer("DKIM selector")
|
|
answer("Public outbound IPv4")
|
|
answer("missing SPF, DKIM, DMARC")
|
|
answer("Private key for bongo._domainkey.example.test")
|
|
answer("Enable the rate-limited port 26 relay")
|
|
answer("Use LDAP/Active Directory")
|
|
answer("Optional scanners")
|
|
answer("Initial admin password", password)
|
|
answer("Repeat the admin password", password)
|
|
answer("Install now")
|
|
child.expect(r"BONGO_WIZARD_RESULT=(.*)\r?\n")
|
|
result = json.loads(child.match.group(1))
|
|
child.expect(pexpect.EOF)
|
|
|
|
expected = {
|
|
"hostname": "mail.example.test",
|
|
"bind_address": "192.0.2.10",
|
|
"domains": ["example.test"],
|
|
"tls_hostnames": ["mail.example.test"],
|
|
"web_mode": "apache",
|
|
"web_public_url": "https://mail.example.test",
|
|
"web_proxy_networks": ["127.0.0.1/32"],
|
|
"imap_enabled": True,
|
|
"pop3_enabled": True,
|
|
"sieve_enabled": True,
|
|
"submission_enabled": True,
|
|
"internal_relay_enabled": False,
|
|
"ldap_enabled": False,
|
|
"antispam_enabled": False,
|
|
"antivirus_enabled": False,
|
|
"acme_enabled": False,
|
|
"scanner_plan_count": 0,
|
|
"validation_errors": [],
|
|
}
|
|
if result != expected:
|
|
raise SystemExit("wizard defaults differ:\nexpected="
|
|
+ json.dumps(expected, sort_keys=True)
|
|
+ "\nactual=" + json.dumps(result, sort_keys=True))
|
|
plain = re.sub(
|
|
r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))", "",
|
|
"".join(transcript))
|
|
if "WeYesdirect" in plain or "MaNo proxy" in plain:
|
|
raise SystemExit("detected an overlapped choice label")
|
|
print("PASS setup wizard defaults and narrow-terminal layout")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--wizard", action="store_true",
|
|
help=argparse.SUPPRESS)
|
|
arguments = parser.parse_args()
|
|
if arguments.wizard:
|
|
run_wizard()
|
|
return 0
|
|
password = os.environ.get("BONGO_TEST_PASSWORD", "")
|
|
if len(password) < 12:
|
|
raise SystemExit("set a disposable BONGO_TEST_PASSWORD (12+ characters)")
|
|
drive_wizard(password)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|