#!/usr/bin/env python3 """Offline, path-relocated installer contract tests. All external commands mocked. Do not equate these tests with genuine Postfix/PostSRSd integration. """ from pathlib import Path import os, subprocess, tempfile, re, sys, shutil, signal, types SOURCE = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] def putexe(file, content): file.parent.mkdir(parents=True, exist_ok=True) file.write_text('#!/usr/bin/env bash\n' + content) file.chmod(0o755) def setup(root): root.mkdir(parents=True, exist_ok=True) repo = root / 'repo' repo.mkdir() # Copy only public SRS source files needed by the test. Never copy the # complete mailserver repository, .git, credentials, local conf/srs.conf, # or unrelated applications into an executable test fixture. if not SOURCE.is_dir(): raise RuntimeError(f'SOURCE must be a repository directory: {SOURCE}') required = [ 'install-update-srs.sh', 'helpers/srs-common.sh', 'helpers/srs-dns-check.py', 'helpers/srs-virtual-alias.py', 'files/srs/srs-alias-decision.py', 'files/srs/srs-forward.py', 'files/srs/srs-reverse-map.py', 'files/srs/srs-reverse-policy.py', ] templates = SOURCE/'files/srs/templates' required += [str(f.relative_to(SOURCE)) for f in templates.iterdir() if f.is_file() and not f.is_symlink()] for rel in required: src = SOURCE/rel if not src.is_file() or src.is_symlink(): raise RuntimeError(f'Missing or symlinked source file: {src}') dst = repo/rel dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) for path in (repo/'install-update-srs.sh', repo/'helpers/srs-common.sh', *list((repo/'files/srs').glob('*.py')), *list((repo/'files/srs/templates').glob('*'))): s = path.read_text() # Keep all managed install/config/queue paths in an unprivileged fixture tree. # The postconf absolute command must also be relocated. s = s.replace('/usr/sbin/postconf', str(root/'bin/postconf')) for prefix in ['/etc/', '/usr/local/', '/var/', '/run/lock/', '/root/srs-installer-backups']: new = str(root) + prefix s = s.replace(prefix, new) # prevent reading the real original root Postfix config in our fixtures if path == repo/'helpers/srs-common.sh': # The test runner uses a pipe for scripted confirmations; # allow that only inside the copied, isolated helper fixture. needle = 'if [[ -t 1 ]]; then' if s.count(needle) != 1: raise RuntimeError('Unexpected terminal gate in helper') s = s.replace(needle, 'if true; then', 1) path.write_text(s) (repo/'install-update-srs.sh').chmod(0o755) for p in ['etc/postfix','usr/local/sbin','run/lock','var/spool','var/lib','etc/systemd/system','flags']: (root/p).mkdir(parents=True, exist_ok=True) (root/'etc/postfix/main.cf').write_text('myhostname = mail.example.org\n') (root/'etc/postfix/master.cf').write_text('smtp inet n - y - - smtpd\nlocalhost:10025 inet n - y - - smtpd\n -o receive_override_options=no_unknown_recipient_checks\n') (repo/'conf').mkdir(exist_ok=True) # All expected lookup paths are extracted from the (relocated) reference source. alias = (repo/'files/srs/srs-alias-decision.py').read_text() for typ,name in set(re.findall(r'"(pgsql|btree):([^" ]+)"', alias)): f = Path(name + ('.db' if typ == 'btree' else '')) f.parent.mkdir(parents=True,exist_ok=True) f.touch() bin = root/'bin' bin.mkdir() putexe(bin/'postconf', ''' name="${@: -1}" # e.mx.oopen.de has two pre-existing unrelated warnings in main.cf. # They are emitted on every postconf call while the requested value is valid. echo "postconf: warning: ${SRS_TEST_ROOT}/etc/postfix/main.cf: unused parameter: policyd-spf_time_limit=3600" >&2 # The warning path matches the source main.cf in the relocated fixture. # Existing warnings must be distinguished from queried-param errors. if [[ -f "${SRS_TEST_ROOT}/flags/unknown_parameter_warning" ]]; then echo "postconf: warning: smtp_tls_cafile: unknown parameter" >&2 fi case "$name" in mail_owner) echo postfix;; myhostname) echo mail.example.org;; mydomain) echo example.org;; myorigin) echo /etc/mailname;; mydestination) echo 'mail.example.org localhost';; relay_domains) echo '\u0024mydestination';; virtual_alias_maps) echo 'pgsql:some-alias';; virtual_alias_domains) echo 'btree:some-domain';; virtual_mailbox_maps) echo 'pgsql:some-mailbox';; virtual_mailbox_domains) echo 'pgsql:some-domain';; smtp_bind_address) echo 192.0.2.22;; smtp_bind_address6) echo 2001:db8::22;; smtp_dns_support_level) echo dnssec;; smtp_host_lookup) echo dns;; smtp_tls_CAfile) echo /etc/ssl/certs/ca-certificates.crt;; smtp_tls_mandatory_protocols|smtp_tls_protocols) echo '>=TLSv1.2';; smtp_tls_security_level) echo dane;; *) echo "unknown $name" >&2; exit 1;; esac ''') putexe(bin/'id', ''' if [[ "$1" == -u && "$2" == postfix ]]; then echo 123; exit 0; fi if [[ "$1" == -g && "$2" == postfix ]]; then echo 123; exit 0; fi exec /usr/bin/id "$@" ''') putexe(bin/'systemctl', ''' if [[ "$1" == is-active ]]; then exit 3; fi exit 0 ''') putexe(bin/'git', ''' if [[ "$1" == clone ]]; then mkdir -p "${@: -1}"; exit 0 fi exec /usr/bin/git "$@" ''') putexe(bin/'cmake', ''' if [[ "$1" == --version ]]; then echo 'cmake version 3.31.6'; exit 0; fi if [[ "$1" == -S ]]; then for ((i=1; i<=$#; i++)); do arg="${!i}" if [[ "$arg" == -B ]]; then n=$((i+1)); build="${!n}"; fi if [[ "$arg" == -DCMAKE_INSTALL_PREFIX=* ]]; then prefix="${arg#*=}"; fi done mkdir -p "$build"; printf '%s\\n' "$prefix" > "$build/prefix"; exit 0 fi if [[ "$1" == --build ]]; then exit 0; fi if [[ "$1" == --install ]]; then prefix="$(cat "$2/prefix")" mkdir -p "${DESTDIR}${prefix}/sbin" printf '#!/bin/sh\\nexit 0\\n' > "${DESTDIR}${prefix}/sbin/postsrsd" chmod +x "${DESTDIR}${prefix}/sbin/postsrsd" exit 0 fi exit 9 ''') putexe(bin/'ctest','exit 0\n') putexe(bin/'dig','exit 0\n') putexe(bin/'postmap',''' case "$1" in btree:*) dest="${1#btree:}"; touch "${dest}.db";; *) exit 1;; esac if [[ -f "${SRS_TEST_ROOT}/flags/fail_postmap" ]]; then exit 1; fi exit 0 ''') putexe(bin/'postfix',''' if [[ "$*" == *check* ]]; then mkdir -p "${SRS_TEST_ROOT}/var/spool/postfix-srs-delivery/private" "${SRS_TEST_ROOT}/var/lib/postfix-srs-delivery" if [[ -f "${SRS_TEST_ROOT}/flags/fail_postfix" ]]; then echo 'forced postfix check error' >&2; exit 1; fi fi exit 0 ''') (repo/'helpers/srs-dns-check.py').write_text('print(" Simulated authoritative DNS: OK")\n') return repo def run(root, repo, *, fail=None, version=None): if fail: (root/'flags'/('fail_'+fail)).touch() argv = [str(repo/'install-update-srs.sh'), '--install'] if version: argv.extend(['--version', version]) env = os.environ.copy() env.update({'PATH':str(root/'bin') + ':' + env['PATH'], 'SRS_TEST_ROOT':str(root), 'LC_ALL':'C'}) # No real PTY is needed: the terminal requirement is disabled only in the # relocated helper copy, never in the actual repository. # File-backed capture prevents subprocess pipe hangs from descendant # processes retaining output fds. A timeout kills the entire fixture group. with tempfile.TemporaryFile(mode='w+t') as out: proc = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=out, stderr=subprocess.STDOUT, text=True, env=env, start_new_session=True) try: proc.communicate(input='srs.example.org\nyes\nyes\n', timeout=20) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait(timeout=5) raise RuntimeError('Mock installer timed out') out.seek(0) return types.SimpleNamespace(returncode=proc.returncode, stdout=out.read()) def assert_ok(condition, msg): if not condition: raise AssertionError(msg) with tempfile.TemporaryDirectory(prefix='oopen-srs-test-') as tmp: base=Path(tmp) os.chmod(base, 0o2700) # Test SGID inheritance without granting group access. root=base/'initial' repo=setup(root) result=run(root,repo) print('initial install', result.returncode) if result.returncode: print(result.stdout[-2500:]) assert_ok(result.returncode == 0,'initial installation failed') assert_ok((root/'usr/local/postsrsd').is_symlink(), 'missing symlink') assert_ok((root/'usr/local/sbin/srs-forward.py').exists(), 'missing srs forward') assert_ok((root/'etc/postsrsd-srs/secret').stat().st_mode & 0o777 == 0o600, 'secret mode') assert_ok('smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt' in (root/'etc/postfix-srs/delivery/main.cf').read_text(),'rendered TLS CA') print(" secret dir mode:", oct((root/"etc/postsrsd-srs").stat().st_mode & 0o777), "uid", (root/"etc/postsrsd-srs").stat().st_uid) print(" stat check:", subprocess.check_output(["stat", "-c", "%u:%g:%a", str(root/"etc/postsrsd-srs")], text=True).strip()) print(' initial install: state, secret, config, symlink, CAfile verified') original_secret=(root/'etc/postsrsd-srs/secret').read_bytes() original_forward=(root/'usr/local/sbin/srs-forward.py').read_bytes() original_link=os.readlink(root/'usr/local/postsrsd') result=run(root,repo) print('maintenance update', result.returncode) if result.returncode: print(result.stdout[-2500:]) assert_ok(result.returncode == 0,'maintenance update failed') assert_ok((root/'etc/postsrsd-srs/secret').read_bytes()==original_secret,'secret changed') assert_ok(os.readlink(root/'usr/local/postsrsd')==original_link,'link changed') print(' maintenance update: original secret and binary retained') # Force late failure, after Postfix configuration and all units have been deployed. source=repo/'files/srs/srs-forward.py' source.write_text(source.read_text()+'\n# injected test change\n') result=run(root,repo,fail='postfix') print('failed maintenance rollback', result.returncode) if result.returncode==0: print(result.stdout[-2500:]) assert_ok(result.returncode != 0,'injected failure not detected') assert_ok((root/'usr/local/sbin/srs-forward.py').read_bytes()==original_forward,'rollback did not restore file') assert_ok((root/'etc/postsrsd-srs/secret').read_bytes()==original_secret,'rollback changed secret') assert_ok(os.readlink(root/'usr/local/postsrsd')==original_link,'rollback changed symlink') print(' failure rollback: file restored; secret and symlink preserved') (root/'flags/fail_postfix').unlink() result=run(root,repo,version='2.4.2') print('version upgrade', result.returncode) if result.returncode: print(result.stdout[-2500:]) assert_ok(result.returncode == 0,'version upgrade failed') assert_ok((root/'usr/local/postsrsd-2.4.1').exists(),'prior prefix missing') assert_ok(os.readlink(root/'usr/local/postsrsd')=='postsrsd-2.4.2','new link incorrect') print(' version upgrade: previous prefix retained; link updated') (root/'flags/fail_postfix').touch() result=run(root,repo,version='2.4.3') print('failed version upgrade rollback', result.returncode) assert_ok(result.returncode != 0,'injected failure not detected') assert_ok(os.readlink(root/'usr/local/postsrsd')=='postsrsd-2.4.2','previous version link not restored') assert_ok(not (root/'usr/local/postsrsd-2.4.3').exists(),'failed version prefix not removed') print(' failed version upgrade: symlink restored, new prefix removed') # Active SRS configuration must prohibit any maintenance update before writes. (root/'flags/fail_postfix').unlink() active_marker = '# BEGIN OOPEN-SRS-MANAGED' existing_main=(root/'etc/postfix/main.cf') original_main=existing_main.read_text() existing_main.write_text(original_main + active_marker + '\n') before=(root/'usr/local/sbin/srs-forward.py').read_bytes() result=run(root,repo,version='2.4.2') print('active integration guard', result.returncode) assert_ok(result.returncode != 0,'active SRS was not refused') assert_ok((root/'usr/local/sbin/srs-forward.py').read_bytes() == before, 'active check changed scripts') existing_main.write_text(original_main) print(' active integration guard: blocked before file changes') # Independent initial installation with late failure. New SRS files must be removed. root=base/'failed-first' repo=setup(root) result=run(root,repo,fail='postfix') print('failed first install', result.returncode) assert_ok(result.returncode != 0,'injected first-install failure not detected') assert_ok(not (root/'usr/local/postsrsd').exists(), 'first-install rollback left symlink') assert_ok(not (root/'usr/local/postsrsd-2.4.1').exists(), 'first-install rollback left prefix') assert_ok(not (root/'etc/postsrsd-srs/secret').exists(), 'first-install rollback left secret') assert_ok(not (root/'etc/postfix-srs/delivery/main.cf').exists(), 'first-install rollback left config') assert_ok((root/'var/spool/postfix-srs-delivery/private').is_dir(), 'test Postfix runtime queue unexpectedly deleted') print(' failed first install: tracked files removed; Postfix runtime directory safely preserved') # Fail at postmap, before systemd and Postfix checks. root=base/'failed-map' repo=setup(root) result=run(root,repo,fail='postmap') print('failed postmap rollback', result.returncode) assert_ok(result.returncode != 0, 'injected postmap failure not detected') assert_ok(not (root/'etc/postfix-srs/delivery/transport.db').exists(),'failed postmap index remains') assert_ok(not (root/'etc/postsrsd-srs/secret').exists(),'failed postmap secret remains') assert_ok(not (root/'usr/local/postsrsd').exists(),'failed postmap link remains') print(' failed postmap: generated index and secret removed') # Unexpected warnings must never be silently swallowed. root=base/'unexpected-warning' repo=setup(root) (root/'flags/unknown_parameter_warning').touch() result=run(root, repo) print('unexpected postconf warning', result.returncode) assert_ok(result.returncode != 0, 'unexpected postconf warning accepted') assert_ok(not (root/'usr/local/postsrsd').exists(), 'unexpected warning allowed installation') print(' unrelated existing warnings tolerated, queried-parameter warning rejected') # A template referencing an unknown Postfix parameter must fail closed. root=base/'bad-template-parameter' repo=setup(root) template=root/'repo/files/srs/templates/delivery-main.cf.in' contents=template.read_text() assert_ok('smtp_tls_CAfile = @SMTP_TLS_CAFILE@' in contents, 'missing expected template parameter') template.write_text(contents.replace('smtp_tls_CAfile = @SMTP_TLS_CAFILE@', 'smtp_tls_cafile = @SMTP_TLS_CAFILE@')) result=run(root, repo) print('invalid template parameter', result.returncode) assert_ok(result.returncode != 0, 'unknown template Postfix parameter accepted') assert_ok(not (root/'usr/local/postsrsd').exists(), 'invalid template allowed installation') print(' unknown requested Postfix parameter rejected') print('ALL OFFLINE MOCK CONTRACT TESTS PASSED')