#!/usr/bin/env python3 """Reset RedmineUP Helpdesk mail settings on the LAN test Redmine instance. This is intended to be run after importing a production database into the test instance. It rewrites every active project's incoming/outgoing Helpdesk mail settings so test mail flows through Mailpit and imported real credentials cannot be used accidentally. """ import argparse import json import os import subprocess import sys from pathlib import Path from typing import Any DEFAULT_SSH_HOST = "reddev@192.168.50.170" DEFAULT_SSH_KEY = Path("/home/iadnah/reddev") DEFAULT_REMOTE_REDMINE = "/usr/share/redmine" DEFAULT_MAILPIT_HOST = "192.168.1.105" MAIL_SETTING_NAMES = [ "helpdesk_protocol", "helpdesk_host", "helpdesk_port", "helpdesk_username", "helpdesk_password", "helpdesk_use_ssl", "helpdesk_imap_folder", "helpdesk_move_on_success", "helpdesk_move_on_failure", "helpdesk_apop", "helpdesk_delete_unprocessed", "helpdesk_smtp_use_default_settings", "helpdesk_smtp_server", "helpdesk_smtp_port", "helpdesk_smtp_domain", "helpdesk_smtp_authentication", "helpdesk_smtp_username", "helpdesk_smtp_password", "helpdesk_smtp_ssl", "helpdesk_smtp_tls", "helpdesk_answer_from", ] SECRET_NAMES = {"helpdesk_password", "helpdesk_smtp_password"} class ResetError(RuntimeError): pass class RemoteRedmine: def __init__(self, ssh_host, ssh_key, remote_redmine, local=False): self.ssh_host = ssh_host self.ssh_key = ssh_key self.remote_redmine = remote_redmine self.local = local def mysql_json_lines(self, sql): stdout = self.mysql(sql) rows = [] for line in stdout.splitlines(): if not line.strip(): continue try: rows.append(json.loads(bytes.fromhex(line.strip()).decode("utf-8"))) except (ValueError, json.JSONDecodeError) as exc: raise ResetError(f"Remote query returned an unexpected row: {line[:200]}") from exc return rows def mysql(self, sql): command = self._mysql_runner_command() shell = True if not self.local: command = [ "ssh", "-i", str(self.ssh_key), "-o", "IdentitiesOnly=yes", self.ssh_host, self._mysql_runner_command(), ] shell = False try: result = subprocess.run( command, input=sql, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, shell=shell, ) except OSError as exc: raise ResetError(f"Could not run ssh: {exc}") from exc if result.returncode != 0: raise ResetError(result.stderr.strip() or "Remote MySQL command failed.") return result.stdout def _mysql_runner_command(self): ruby = ( "require 'yaml'; " "c = YAML.load_file('config/database.yml')['production']; " "ENV['MYSQL_PWD'] = c['password'].to_s; " "args = ['--batch', '--raw', '--quick', '--skip-column-names', " "'--default-character-set=utf8', '-h', c['host'].to_s, " "'-P', (c['port'] || 3306).to_s, '-u', c['username'].to_s, c['database'].to_s]; " "exec('mysql', *args)" ) return f"cd {shell_quote(self.remote_redmine)} && ruby -e {shell_quote(ruby)}" def main(): parser = argparse.ArgumentParser( description="Reset Helpdesk mail settings for all active projects." ) parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST)) parser.add_argument("--ssh-key", type=Path, default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY)))) parser.add_argument("--local", action="store_true", help="Read the Redmine database locally instead of over SSH.") parser.add_argument("--remote-redmine", default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE)) parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST, help="Host Redmine should use to reach Mailpit.") parser.add_argument("--pop3-port", type=int, default=1110) parser.add_argument("--smtp-port", type=int, default=1025) parser.add_argument("--username", default="test") parser.add_argument("--password", default="testpass") parser.add_argument("--smtp-domain", default="example.test") parser.add_argument( "--from-pattern", default="helpdesk-{identifier}@example.test", help="Pattern for helpdesk_answer_from. Available fields: {id}, {identifier}, {name}.", ) parser.add_argument( "--project", action="append", default=[], help="Optional project id or identifier to limit changes. Can be passed more than once.", ) parser.add_argument("--dry-run", action="store_true", help="Show affected projects and settings without writing.") args = parser.parse_args() remote = RemoteRedmine(args.ssh_host, args.ssh_key, args.remote_redmine, local=args.local) try: projects = find_active_projects(remote, args.project) if not projects: print("No active projects matched the requested filters.") return 0 print(f"Matched {len(projects)} active project(s):") for project in projects: print(f" - #{project['id']} {project['identifier']} ({project['name']})") values = build_values(args, projects) if args.dry_run: print("\nDry run. Planned settings:") print_plan(values) return 0 apply_values(remote, values) print(f"\nUpdated {len(values)} setting row(s) across {len(projects)} project(s).") print("Password values were written but not displayed.") return 0 except ResetError as exc: print(f"error: {exc}", file=sys.stderr) return 1 def find_active_projects(remote, filters): where = ["p.status = 1"] if filters: clauses = [] for value in filters: if str(value).isdigit(): clauses.append(f"p.id = {sql_int(value)}") clauses.append(f"p.identifier = {sql_string(value)}") where.append("(" + " OR ".join(clauses) + ")") return remote.mysql_json_lines( f""" SELECT HEX(CAST(JSON_OBJECT( 'id', p.id, 'identifier', p.identifier, 'name', p.name ) AS CHAR)) AS document FROM projects p WHERE {' AND '.join(where)} ORDER BY p.identifier; """ ) def build_values(args, projects): rows = [] for project in projects: project_id = int(project["id"]) answer_from = args.from_pattern.format( id=project_id, identifier=project["identifier"], name=project["name"], ) settings = { "helpdesk_protocol": "pop3", "helpdesk_host": args.mailpit_host, "helpdesk_port": str(args.pop3_port), "helpdesk_username": args.username, "helpdesk_password": args.password, "helpdesk_use_ssl": "0", "helpdesk_imap_folder": "", "helpdesk_move_on_success": "", "helpdesk_move_on_failure": "", "helpdesk_apop": "0", "helpdesk_delete_unprocessed": "0", # RedmineUP's UI label is confusing: 1 means use the custom SMTP block. "helpdesk_smtp_use_default_settings": "1", "helpdesk_smtp_server": args.mailpit_host, "helpdesk_smtp_port": str(args.smtp_port), "helpdesk_smtp_domain": args.smtp_domain, "helpdesk_smtp_authentication": "", "helpdesk_smtp_username": "", "helpdesk_smtp_password": "", "helpdesk_smtp_ssl": "0", "helpdesk_smtp_tls": "0", "helpdesk_answer_from": answer_from, } rows.extend((project_id, name, settings[name]) for name in MAIL_SETTING_NAMES) return rows def apply_values(remote, rows): statements = ["START TRANSACTION;"] for project_id, name, value in rows: project_id_sql = sql_int(project_id) name_sql = sql_string(name) value_sql = sql_string(value) statements.append( f""" UPDATE contacts_settings SET value = {value_sql}, updated_on = UTC_TIMESTAMP() WHERE project_id = {project_id_sql} AND name = {name_sql}; INSERT INTO contacts_settings (project_id, name, value, updated_on) SELECT {project_id_sql}, {name_sql}, {value_sql}, UTC_TIMESTAMP() WHERE NOT EXISTS ( SELECT 1 FROM contacts_settings WHERE project_id = {project_id_sql} AND name = {name_sql} ); """ ) statements.append("COMMIT;") remote.mysql("\n".join(statements)) def print_plan(rows): current_project_id = None for project_id, name, value in rows: if project_id != current_project_id: current_project_id = project_id print(f"\nProject #{project_id}") display_value = "" if name in SECRET_NAMES else value print(f" {name} = {display_value}") def sql_int(value): try: return max(0, int(value)) except (TypeError, ValueError): return 0 def sql_string(value): return "'" + str(value).replace("\\", "\\\\").replace("'", "\\'") + "'" def shell_quote(value): return "'" + value.replace("'", "'\"'\"'") + "'" if __name__ == "__main__": raise SystemExit(main())