c60d98a13c
Co-authored-by: Martin Weinelt <hexa@darmstadt.ccc.de>
236 lines
7.0 KiB
Python
236 lines
7.0 KiB
Python
#!/usr/bin/env nix-shell
|
|
#!nix-shell -i python3 -p python3
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from pwd import getpwnam
|
|
|
|
EXIT_OK = 0
|
|
EXIT_ERROR = 1
|
|
|
|
GREEN = "32"
|
|
YELLOW = "33"
|
|
RED = "31"
|
|
BOLD = "1"
|
|
|
|
NO_COLOR = "NO_COLOR" in os.environ
|
|
|
|
|
|
def color(text, code):
|
|
if NO_COLOR:
|
|
return text
|
|
return f"\033[{code}m{text}\033[0m"
|
|
|
|
|
|
def check_user(sieve_root: Path):
|
|
owner = sieve_root.owner()
|
|
owner_uid = getpwnam(owner).pw_uid
|
|
|
|
if os.geteuid() == owner_uid:
|
|
return
|
|
|
|
try:
|
|
print(f"Trying to switch effective user id to {owner_uid} ({owner})")
|
|
os.seteuid(owner_uid)
|
|
return
|
|
except PermissionError:
|
|
print(
|
|
f"Failed switching to virtual mail user. Please run this script under it, for example by using `sudo -u {owner}`)"
|
|
)
|
|
sys.exit(1)
|
|
|
|
|
|
def doveadm_get_user_home(user: str) -> Path:
|
|
output = subprocess.check_output(
|
|
["doveadm", "user", "-f", "home", user], text=True, stderr=subprocess.DEVNULL
|
|
)
|
|
homedir = Path(output.strip())
|
|
return homedir
|
|
|
|
|
|
def move(src: Path, dst: Path, dry_run: bool = True) -> bool:
|
|
print(f'mv "{src}" "{dst}"')
|
|
if not dry_run:
|
|
try:
|
|
shutil.move(src, dst)
|
|
except OSError as exc:
|
|
print(f"Rename failed ({src=!s}, {dst=!s}): {exc}")
|
|
return False
|
|
return True
|
|
|
|
|
|
def symlink(target: Path, link: Path, dry_run: bool = True) -> bool:
|
|
print(f'ln --symbolic --relative "{target}" "{link}"')
|
|
if not dry_run:
|
|
try:
|
|
target_relative = target.relative_to(link.parent)
|
|
link.symlink_to(target_relative)
|
|
except (OSError, ValueError) as exc:
|
|
print(f"Symlinking failed ({target=!s}, {link=!s}): {exc}")
|
|
return False
|
|
return True
|
|
|
|
|
|
def main(sieve_root: Path, dry_run: bool = True):
|
|
print(
|
|
color(
|
|
f"\nFind accounts based on existing Sieve script directories in {sieve_root!s}",
|
|
BOLD,
|
|
)
|
|
)
|
|
|
|
skipped = 0
|
|
accounts = set()
|
|
for path in sieve_root.glob("*"):
|
|
if not path.is_dir():
|
|
print(f"- Not a directory ({path=!s}) (skipping)")
|
|
skipped += 1
|
|
continue
|
|
elif not set(path.glob("scripts/*.sieve")):
|
|
print(f"- No Sieve scripts in directory ({path=!s}) (skipping)")
|
|
skipped += 1
|
|
continue
|
|
|
|
account = path.name
|
|
accounts.add(account)
|
|
print(f"- Sieve directory found ({path=!s}, {account=})")
|
|
|
|
print(
|
|
color(
|
|
f"\nLookup home directory of accounts based on the remaining Sieve directories found in {sieve_root!s}",
|
|
BOLD,
|
|
)
|
|
)
|
|
|
|
lookup_failed = 0
|
|
homedirs = {}
|
|
for account in accounts:
|
|
try:
|
|
homedir = doveadm_get_user_home(account)
|
|
except subprocess.CalledProcessError as exc:
|
|
print(f"- Home directory lookup failed ({account=}): {exc}")
|
|
lookup_failed += 1
|
|
continue
|
|
print(f"- Home directory retrieved ({account=}, {homedir=!s})")
|
|
homedirs.update({account: homedir})
|
|
|
|
print(
|
|
color(
|
|
"\nEnumerate Sieve directories of accounts",
|
|
BOLD,
|
|
)
|
|
)
|
|
|
|
plan = {}
|
|
for account, homedir in homedirs.items():
|
|
sieve_src = sieve_root / account / "scripts"
|
|
sieve_dst = homedir / "sieve"
|
|
plan.update({sieve_src: sieve_dst})
|
|
|
|
active = sieve_root / account / "active.sieve"
|
|
link = homedir / ".dovecot.sieve"
|
|
# An account may have Sieve scripts but none enabled,
|
|
# e.g. an out-of-office auto-reply but currently in-office.
|
|
if not active.is_symlink():
|
|
print(
|
|
f"- Account has Sieve scripts but none enabled ({account=}, {active=!s})"
|
|
)
|
|
continue
|
|
active_relative = active.resolve().relative_to(sieve_src)
|
|
target = sieve_dst / active_relative
|
|
plan.update({target: link})
|
|
print(
|
|
f"- Account has Sieve scripts and one enabled ({account=}, {sieve_src=!s})"
|
|
)
|
|
|
|
print(color("\nThe following operations will be executed:", BOLD))
|
|
moved = 0
|
|
moves_failed = 0
|
|
for src, dst in plan.items():
|
|
if src.is_dir():
|
|
if not move(src=src, dst=dst, dry_run=dry_run):
|
|
moves_failed += 1
|
|
else:
|
|
moved += 1
|
|
else:
|
|
if not symlink(target=src, link=dst, dry_run=dry_run):
|
|
moves_failed += 1
|
|
else:
|
|
moved += 1
|
|
|
|
print(
|
|
color(
|
|
"\nMigration summary",
|
|
BOLD,
|
|
)
|
|
)
|
|
|
|
if any([skipped, lookup_failed, not accounts, moves_failed]):
|
|
print("""
|
|
We strongly recommend reviewing and remediating all potential issues before
|
|
running with `--execute`. Specific details can be found further up.""")
|
|
|
|
if moved:
|
|
print(f"""
|
|
- {color(f"{moved} Sieve script directories were migrated successfully.", GREEN)} {"(dry run)" if dry_run else ""}""")
|
|
|
|
if skipped and accounts:
|
|
print(f"""
|
|
- {color(f"{skipped} paths in {(sieve_root)!s} were skipped.", YELLOW)}
|
|
These were not a directory or did not contain a ./scripts directory.
|
|
They should be reviewed but can most likely be deleted.""")
|
|
|
|
if lookup_failed:
|
|
print(f"""
|
|
- {color(f"{lookup_failed} account lookups failed.", YELLOW)}
|
|
This could be a problem, because we cannot migrate the Sieve script
|
|
directory into the home directory without finding the owner of the
|
|
directory. In practice this can happen if an account was deleted but
|
|
its Sieve script directory remained.""")
|
|
|
|
if not accounts:
|
|
print(f"""
|
|
- {color("No Sieve script directories were found.", RED)}
|
|
Make sure you are passing the correct `sieve_root` argument. It must match
|
|
your `mailserver.sieveDirectory` setting. In practise this may also happen
|
|
if simply no account has Sieve scripts.""")
|
|
|
|
if moves_failed:
|
|
print(f"""
|
|
- {color(f"{moves_failed} Sieve script directories could not be renamed", RED)}
|
|
No reason to panic, but the script tried to rename a Sieve script directory
|
|
and that triggered and error. Check further up what went wrong.""")
|
|
|
|
if dry_run:
|
|
print(f"\n{color('No changes were made.', YELLOW)}")
|
|
print("Run the script with `--execute` to apply the listed changes.")
|
|
|
|
sys.exit(EXIT_OK if moves_failed == 0 else EXIT_ERROR)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="""
|
|
NixOS Mailserver Migration #5: Sieve script directory migration
|
|
(https://nixos-mailserver.readthedocs.io/en/latest/migrations.html#sieve-script-directory-migration)
|
|
"""
|
|
)
|
|
parser.add_argument(
|
|
"sieve_root", type=Path, help="Path to the `mailserver.sieveDirectory`"
|
|
)
|
|
parser.add_argument(
|
|
"--execute", action="store_true", help="Actually perform changes"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
check_user(args.sieve_root)
|
|
main(
|
|
sieve_root=args.sieve_root,
|
|
dry_run=not args.execute,
|
|
)
|