From 4eda0662642cfdf7ba45a85e4b2190462f87b1de Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 29 Aug 2026 14:43:36 -0400 Subject: [PATCH 01/18] cutover(localkit): serverkit-localkit leaves the tree (plan 52 Phase 2) builtin-extensions/serverkit-localkit removed; the bundled registry entry flips to bundled:false pointing at the (to-be-published) v0.2.0 release asset with the sha256 of the locally built zip. OPERATOR: publish jhd3197/serverkit-localkit, let its release workflow (or update-registry.mjs) upsert + sign the registry entry, then run scripts/sync-registry-index.sh - which replaces this hand-staged entry with the published, signed one. Until then the two registry-mirror tests (signature pin + published-index match) are red BY DESIGN on this branch. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + backend/app/data/registry_index.json | 7 +- .../serverkit-localkit/backend/__init__.py | 0 .../serverkit-localkit/backend/localkit.py | 928 ------------------ .../serverkit-localkit/plugin.json | 14 - 5 files changed, 6 insertions(+), 944 deletions(-) delete mode 100644 builtin-extensions/serverkit-localkit/backend/__init__.py delete mode 100644 builtin-extensions/serverkit-localkit/backend/localkit.py delete mode 100644 builtin-extensions/serverkit-localkit/plugin.json diff --git a/.gitignore b/.gitignore index 22ae7b2c..aaa01772 100644 --- a/.gitignore +++ b/.gitignore @@ -195,6 +195,7 @@ backend/app/plugins/serverkit-email/ backend/app/plugins/serverkit-ftp/ backend/app/plugins/serverkit-gpu/ backend/app/plugins/serverkit-gui/ +backend/app/plugins/serverkit-localkit/ backend/app/plugins/serverkit-k8s/ backend/app/plugins/serverkit-mail/ backend/app/plugins/serverkit-remote-access/ diff --git a/backend/app/data/registry_index.json b/backend/app/data/registry_index.json index 44658271..35623110 100644 --- a/backend/app/data/registry_index.json +++ b/backend/app/data/registry_index.json @@ -173,7 +173,7 @@ "category": "integration", "author": "ServerKit", "first_party": true, - "bundled": true, + "bundled": false, "permissions": [ "filesystem", "network", @@ -182,7 +182,10 @@ ], "min_panel_version": "1.7.0", "max_panel_version": null, - "repo": "https://github.com/jhd3197/ServerKit" + "repo": "https://github.com/jhd3197/serverkit-localkit", + "source": "https://github.com/jhd3197/serverkit-localkit/releases/download/v0.2.0/serverkit-localkit-0.2.0.zip", + "sha256": "7c7c37c83eb35e9f5075082b4e12984a1b9a7ed023028159ef53e511b42c806c", + "homepage": "https://github.com/jhd3197/serverkit-localkit" }, { "slug": "serverkit-gpu", diff --git a/builtin-extensions/serverkit-localkit/backend/__init__.py b/builtin-extensions/serverkit-localkit/backend/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/builtin-extensions/serverkit-localkit/backend/localkit.py b/builtin-extensions/serverkit-localkit/backend/localkit.py deleted file mode 100644 index e600fe50..00000000 --- a/builtin-extensions/serverkit-localkit/backend/localkit.py +++ /dev/null @@ -1,928 +0,0 @@ -""" -LocalKit Bridge API — endpoints for the LocalKit desktop app. - -Mounted at ``/api/v1/localkit`` (see plugin.json). Every route accepts -``X-API-Key`` auth because it is guarded by the RBAC decorators -(``auth_required`` / ``admin_required``) that honor the API-key middleware -(``g.api_key_user``) — bare flask ``@jwt_required()`` would reject API keys -(see docs/WORDPRESS_ROADMAP.md; this blueprint is the API-key-friendly -surface for WordPress automation). - -Endpoints (all JSON unless noted): - GET /pair — validate the API key + report panel info + `features` + - `kinds` (site kinds this extension can sync, plan 26) - GET /sites — list WordPress sites (delegates to serverkit-wordpress); - each carries a `kind` (plan 26) - POST /sites — provision a new WordPress site to push into - POST /push/code — multipart: site_id + wp-content tar.gz -> docker cp into the site - POST /push/db — multipart: site_id + SQL dump + local_url -> import + search-replace - GET /pull/db — ?site_id= -> gzipped SQL dump of the site's database - GET /pull/code — ?site_id= -> tar.gz of the site's wp-content directory - -Sync v2 (chunked, resumable — plan 19), for `` in {code, db}: - POST /push//init — describe the transfer -> {transfer_id, received} - PUT /push//chunk — ?transfer_id&offset&sha256 + raw body -> {received} - POST /push//finish — verify the whole-file sha256, then run the *same* - v1 processing path on the assembled file - -v1 push runs inline and is bounded by the panel's MAX_CONTENT_LENGTH (100MB). -v2 lifts that: every request is one 8 MiB chunk, so the limit stops applying to -the payload. Both directions end in the same `_install_code` / `_import_db` -helper — there is exactly one processing path, and v2 only changes how the -bytes arrive. Processing still happens inline in `finish` (no job queue yet), -but only *after* the hash verifies, so an abandoned transfer can never leave -half-applied state: it is just a temp directory the next `init` reaps. - -`FEATURES` below is the capability contract LocalKit gates its UI on: an older -copy of this extension simply omits a name, and the client disables the -matching button instead of failing halfway through an operation. -""" - -import hashlib -import importlib -import json -import os -import re -import shutil -import subprocess -import tarfile -import tempfile -import time -import uuid - -from flask import Blueprint, after_this_request, jsonify, request, send_file - -from app.middleware.rbac import admin_required, auth_required, get_current_user -from app.services.db_sync_service import DatabaseSyncService -from app.services.settings_service import SettingsService -from app.utils.domain import canonical_origin -from app.utils.version import get_panel_version - -localkit_bp = Blueprint('localkit', __name__) - -# What this build of the extension can do — reported by GET /pair so LocalKit -# can gate its UI on capability instead of discovering a 404 mid-operation. -# Append only; never rename an entry (clients match on the literal string). -FEATURES = ['sites', 'push-code', 'push-db', 'pull-db', 'pull-code', 'sync-v2'] - -# Site kinds this extension can sync (plan 26). LocalKit gates its per-kind -# sync/import UI on this list, so a new client never starts a php sync a server -# can't finish. Only WordPress is backed today (serverkit-wordpress is the only -# site backend); add 'php' here in lockstep with a php-stack backend that -# `_resolve_site` / `_install_code` / `_import_db` can dispatch to. -KINDS = ['wordpress'] - -# A transfer with no chunk activity for this long is abandoned; the next -# ``init`` sweeps it. Long enough that a user pausing on a slow link keeps -# their resume point, short enough that a dead client's partial upload does -# not sit on the panel's disk overnight. -TRANSFER_TTL_SECONDS = 30 * 60 - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _wp_service(): - """Import the serverkit-wordpress service layer (cross-extension), or - return None when that extension is not installed.""" - try: - return importlib.import_module( - 'app.plugins.serverkit-wordpress.wordpress_service').WordPressService - except Exception: - pass - try: - from app.services.plugin_service import _ensure_importable - if _ensure_importable('serverkit-wordpress'): - return importlib.import_module( - 'app.plugins.serverkit-wordpress.wordpress_service').WordPressService - except Exception: - pass - return None - - -def _require_wp_service(): - svc = _wp_service() - if svc is None: - return None, (jsonify({ - 'error': 'The serverkit-wordpress extension is not installed on this server' - }), 409) - return svc, None - - -def _resolve_site(site_id): - from app.models import WordPressSite - try: - site_id = int(site_id) - except (TypeError, ValueError): - return None, (jsonify({'error': 'site_id is required'}), 400) - site = WordPressSite.query.get(site_id) - if not site or not site.application: - return None, (jsonify({'error': 'Site not found'}), 404) - return site, None - - -def _site_url(site): - """Public URL of a site: primary domain if set, else localhost:port — - mirrors WordPressService._enrich_site_data.""" - app = site.application - domains = app.domains - primary = next((d for d in domains if d.is_primary), None) - if primary is None and domains: - primary = domains[0] - if primary is not None: - scheme = 'https' if primary.ssl_enabled else 'http' - return f'{scheme}://{primary.name}' - if app.port: - return f'http://localhost:{app.port}' - return None - - -def _read_env_value(root_path, key): - try: - with open(os.path.join(root_path, '.env')) as f: - for line in f: - if '=' in line: - k, v = line.split('=', 1) - if k.strip() == key: - return v.strip() - except OSError: - pass - return None - - -def _compose_file(site): - root_path = site.application.root_path - if not root_path: - return None - compose_file = os.path.join(root_path, 'docker-compose.yml') - return compose_file if os.path.exists(compose_file) else None - - -def _php_version(site): - """PHP version of a Docker-stack site, read from the compose image tag - (``wordpress:-php-apache`` — the same tag - ``WordPressService.set_php_version`` rewrites). - - Deliberately a file read rather than ``get_php_info``: that shells into the - container, and this runs once per site in a listing. - """ - compose_file = _compose_file(site) - if not compose_file: - return None - try: - with open(compose_file) as f: - content = f.read() - except OSError: - return None - m = re.search(r'image:\s*wordpress:\S*?-php(\d+\.\d+)', content) - return m.group(1) if m else None - - -def _safe_extract_tar_gz(archive_path, dest_dir): - """Extract a .tar.gz into dest_dir, rejecting path traversal and links - (same policy as WordPressService._safe_extract_zip).""" - dest_abs = os.path.abspath(dest_dir) - try: - with tarfile.open(archive_path, 'r:gz') as tf: - for member in tf.getmembers(): - if member.issym() or member.islnk(): - return {'success': False, - 'error': f'Links are not allowed in archive: {member.name}'} - target = os.path.abspath(os.path.join(dest_abs, member.name)) - if target != dest_abs and not target.startswith(dest_abs + os.sep): - return {'success': False, 'error': f'Unsafe path in archive: {member.name}'} - tf.extractall(dest_abs) - return {'success': True} - except (tarfile.TarError, OSError, EOFError) as e: - return {'success': False, 'error': f'Not a valid tar.gz archive: {e}'} - - -# --------------------------------------------------------------------------- -# Processing (shared by the v1 one-shot uploads and the v2 chunked `finish`) -# --------------------------------------------------------------------------- - -def _install_code(svc, site, archive_path): - """Extract a wp-content tar.gz and install it into the site's container. - - Returns ``(payload, status)``. This is the single code-push processing - path: v1 hands it the multipart upload, v2 hands it the file its chunks - were assembled into. - """ - tmp = tempfile.mkdtemp(prefix='localkit_extract_') - try: - extract_dir = os.path.join(tmp, 'x') - os.makedirs(extract_dir, exist_ok=True) - # The archive is hash-verified by the time v2 gets here, which proves - # it arrived intact — not that it is friendly. The safe-extract policy - # stays mandatory in both paths. - ext = _safe_extract_tar_gz(archive_path, extract_dir) - if not ext.get('success'): - return ext, 400 - - wpc = svc._resolve_wp_content_dir(extract_dir) - if not wpc: - return {'error': 'No wp-content found in the archive'}, 400 - - container = site.application.name - cp = subprocess.run( - ['docker', 'cp', f'{wpc}/.', f'{container}:/var/www/html/wp-content'], - capture_output=True, text=True, timeout=600, - ) - if cp.returncode != 0: - return {'error': f'docker cp failed: {cp.stderr}'}, 500 - # Imported files land root-owned; hand them to the web user. - subprocess.run( - ['docker', 'exec', container, 'chown', '-R', 'www-data:www-data', - '/var/www/html/wp-content'], - capture_output=True, text=True, timeout=300, - ) - if site.application.root_path: - svc.wp_cli(site.application.root_path, ['cache', 'flush']) - return {'success': True, 'message': 'wp-content pushed to the site'}, 200 - finally: - shutil.rmtree(tmp, ignore_errors=True) - - -def _import_db(svc, site, dump_path, local_url): - """Import a SQL dump over the site's database and rewrite local -> remote - URLs. Returns ``(payload, status)``. Shared by v1 and v2, as above.""" - compose_file = _compose_file(site) - if not compose_file: - return {'error': 'Not a Docker-stack site (no docker-compose.yml)'}, 409 - - result = DatabaseSyncService.import_to_container( - compose_path=compose_file, - snapshot_path=dump_path, - db_name='wordpress', - db_user='root', - db_password=_read_env_value(site.application.root_path, 'DB_PASSWORD'), - ) - if not result.get('success'): - return {'error': f"Database import failed: {result.get('error')}"}, 500 - - remote_url = _site_url(site) - search_replaced = False - local_url = (local_url or '').strip().rstrip('/') - if local_url and remote_url and local_url != remote_url: - root_path = site.application.root_path - svc.wp_cli(root_path, ['option', 'update', 'home', remote_url]) - svc.wp_cli(root_path, ['option', 'update', 'siteurl', remote_url]) - sr = svc.search_replace(root_path, local_url, remote_url) - search_replaced = bool(sr.get('success')) - svc.wp_cli(root_path, ['cache', 'flush']) - svc.wp_cli(root_path, ['rewrite', 'flush']) - - return { - 'success': True, - 'message': 'Database imported', - 'remote_url': remote_url, - 'search_replace': search_replaced, - }, 200 - - -# --------------------------------------------------------------------------- -# Pairing -# --------------------------------------------------------------------------- - -@localkit_bp.route('/pair', methods=['GET']) -@auth_required() -def pair(): - """Validate the API key and return panel info for LocalKit's settings UI.""" - user = get_current_user() - canonical_domain = SettingsService.get('canonical_domain', '') or '' - https_enabled = bool(SettingsService.get('canonical_https_enabled', False)) - return jsonify({ - 'status': 'ok', - 'service': 'serverkit-localkit', - 'panel': 'ServerKit', - 'version': get_panel_version(), - 'user': (getattr(user, 'username', None) or getattr(user, 'email', None) - or getattr(user, 'name', None)), - 'canonical_domain': canonical_domain, - 'canonical_origin': canonical_origin(canonical_domain, https_enabled) if canonical_domain else None, - 'features': FEATURES, - 'kinds': KINDS, - }), 200 - - -# --------------------------------------------------------------------------- -# Sites -# --------------------------------------------------------------------------- - -@localkit_bp.route('/sites', methods=['GET']) -@admin_required -def list_sites(): - """List WordPress sites — the API-key-friendly equivalent of - GET /api/v1/wordpress/sites (which is JWT-only). - - Enriched with the fields LocalKit's import flow needs and the hub payload - does not carry: ``php_version`` (to pick the closest local image) and - ``site_url`` (an explicit alias of ``url``, which is what search-replace - rewrites from). ``multisite`` already comes from the model — LocalKit - refuses to import those. - """ - svc, err = _require_wp_service() - if err: - return err - payload = svc.get_sites() - - from app.models import WordPressSite - for entry in payload.get('sites') or []: - site = WordPressSite.query.get(entry.get('id')) if entry.get('id') else None - if site is None or not site.application: - continue - entry['php_version'] = _php_version(site) - entry['site_url'] = entry.get('url') or _site_url(site) - # Stack kind (plan 26). Every site here is WordPress today; the client - # defaults an absent kind to 'wordpress', so this is also the safe value - # for a php-stack backend to override once one exists. - entry.setdefault('kind', 'wordpress') - return jsonify(payload), 200 - - -@localkit_bp.route('/sites', methods=['POST']) -@admin_required -def create_site(): - """Provision a fresh WordPress site to push into.""" - svc, err = _require_wp_service() - if err: - return err - data = request.get_json(silent=True) or {} - name = (data.get('name') or '').strip() - if not name: - return jsonify({'error': 'Site name is required'}), 400 - user = get_current_user() - admin_email = (data.get('admin_email') or getattr(user, 'email', None) - or 'admin@example.com') - result = svc.create_site( - name, - admin_email, - user.id, - php_version=data.get('php_version') or None, - ) - return jsonify(result), (201 if result.get('success') else 400) - - -# --------------------------------------------------------------------------- -# Push / pull -# --------------------------------------------------------------------------- - -@localkit_bp.route('/push/code', methods=['POST']) -@admin_required -def push_code(): - """Receive a tar.gz of wp-content and install it into the site's container.""" - svc, err = _require_wp_service() - if err: - return err - site, err = _resolve_site(request.form.get('site_id')) - if err: - return err - upload = request.files.get('file') - if upload is None: - return jsonify({'error': 'file is required (multipart tar.gz of wp-content)'}), 400 - - tmp = tempfile.mkdtemp(prefix='localkit_push_') - try: - archive_path = os.path.join(tmp, 'wp-content.tar.gz') - upload.save(archive_path) - payload, status = _install_code(svc, site, archive_path) - return jsonify(payload), status - finally: - shutil.rmtree(tmp, ignore_errors=True) - - -@localkit_bp.route('/push/db', methods=['POST']) -@admin_required -def push_db(): - """Receive a SQL dump, import it over the site's database and rewrite the - site URL local -> remote (serialization-safe wp-cli search-replace).""" - svc, err = _require_wp_service() - if err: - return err - site, err = _resolve_site(request.form.get('site_id')) - if err: - return err - upload = request.files.get('file') - if upload is None: - return jsonify({'error': 'file is required (multipart .sql or .sql.gz dump)'}), 400 - local_url = request.form.get('local_url') - - suffix = '.sql.gz' if (upload.filename or '').endswith('.gz') else '.sql' - fd, dump_path = tempfile.mkstemp(prefix='localkit_push_', suffix=suffix) - os.close(fd) - try: - upload.save(dump_path) - payload, status = _import_db(svc, site, dump_path, local_url) - return jsonify(payload), status - finally: - try: - os.remove(dump_path) - except OSError: - pass - - -# --------------------------------------------------------------------------- -# Chunked push (sync v2, plan 19) -# --------------------------------------------------------------------------- -# -# A transfer is a directory, not a database row — the panel owns no schema for -# something this short-lived, and a directory is trivially reapable: -# -# /localkit_transfers// -# meta.json written once at init, read-only afterwards -# payload the assembled file (written at offsets, sparse until full) -# chunks/ marker per confirmed chunk, containing its sha256 -# -# Confirmed chunks are marker *files* rather than a list inside meta.json on -# purpose: two chunk requests can then never race each other rewriting shared -# state, without the extension having to take a lock. - -_HEX64 = re.compile(r'^[0-9a-f]{64}$') -_TRANSFER_ID = re.compile(r'^[0-9a-f]{32}$') - -# Bounds ``request.get_data()`` on a chunk PUT. The panel's own -# MAX_CONTENT_LENGTH (100MB) is the hard stop; this keeps a client from -# negotiating a chunk size that makes each request pathologically large. -MAX_CHUNK_SIZE = 64 * 1024 * 1024 - - -def _transfer_root(): - root = os.path.join(tempfile.gettempdir(), 'localkit_transfers') - os.makedirs(root, exist_ok=True) - return root - - -def _transfer_dir(transfer_id): - """Directory for a transfer id, or None if the id is not one we minted. - - The id lands in a filesystem path, so it is validated against the exact - shape ``uuid4().hex`` produces rather than sanitized — anything else is a - client sending something it was never given. - """ - if not transfer_id or not _TRANSFER_ID.match(transfer_id): - return None - return os.path.join(_transfer_root(), transfer_id) - - -def _load_meta(transfer_id): - tdir = _transfer_dir(transfer_id) - if not tdir: - return None, None - try: - with open(os.path.join(tdir, 'meta.json')) as f: - return json.load(f), tdir - except (OSError, ValueError): - return None, None - - -def _received_offsets(tdir): - try: - names = os.listdir(os.path.join(tdir, 'chunks')) - except OSError: - return [] - out = [] - for name in names: - try: - out.append(int(name)) - except ValueError: - continue - return sorted(out) - - -def _touched_at(tdir): - """Last sign of life for a transfer directory: the newest mtime among the - directory itself and everything immediately inside it. Covers both shapes - living under the transfer root — an upload's ``chunks/`` subdirectory and a - download session's cached export file.""" - try: - children = os.listdir(tdir) - except OSError: - children = [] - stamps = [] - for path in (tdir, *(os.path.join(tdir, n) for n in children)): - try: - stamps.append(os.path.getmtime(path)) - except OSError: - pass - return max(stamps) if stamps else 0 - - -def _discard_transfer(tdir): - shutil.rmtree(tdir, ignore_errors=True) - - -def _reap_stale_transfers(): - """Lazy sweep, run on every ``init``. No cron, no background thread: an - abandoned transfer costs disk, and the next person to start one pays the - (cheap) cost of cleaning up after them.""" - root = _transfer_root() - cutoff = time.time() - TRANSFER_TTL_SECONDS - try: - entries = os.listdir(root) - except OSError: - return - for name in entries: - tdir = os.path.join(root, name) - if os.path.isdir(tdir) and _touched_at(tdir) < cutoff: - _discard_transfer(tdir) - - -def _find_resumable(site_id, kind, sha256, total_bytes, chunk_size): - """An existing transfer of the exact same payload, if one survived. - - Every field must match, chunk_size included: a different chunking of the - same bytes puts the confirmed offsets in the wrong places, and resuming - onto it would assemble a file that fails the final hash check for reasons - nobody could debug. - """ - root = _transfer_root() - try: - entries = sorted(os.listdir(root)) - except OSError: - return None - for name in entries: - meta, tdir = _load_meta(name) - if not meta or not os.path.isdir(tdir or ''): - continue - if (meta.get('site_id') == site_id and meta.get('kind') == kind - and meta.get('sha256') == sha256 - and meta.get('total_bytes') == total_bytes - and meta.get('chunk_size') == chunk_size): - return name - return None - - -def _download_cache(session, kind, site_id): - """Path where a resumable download session caches its materialized export, - or None when the client did not ask for one. - - Why a session id rather than plain ``Range`` against a freshly built - export: pull/db and pull/code *materialize* their payload per request - (mysqldump, ``tar czf``). Ranges taken from two different exports would - assemble into a file that is neither. Pinning an export to a client-chosen - session id makes the bytes stable for the life of that download, while a - new pull still gets a brand-new export — no staleness, no cross-request - corruption. Clients that send no session (v1) keep the old - export-and-delete behavior exactly. - """ - if not session or not _TRANSFER_ID.match(session): - return None - ddir = os.path.join(_transfer_root(), f'dl{session}') - os.makedirs(ddir, exist_ok=True) - return os.path.join(ddir, f'{kind}-{site_id}') - - -def _serve_export(path, download_name, session): - """Send a materialized export, with Range support when it is session-cached. - - Without a session the file is deleted after the response (v1 behavior). - With one it survives for the transfer TTL, and ``conditional=True`` gives - Range/If-Range for free — a client resuming a dropped download re-requests - only the tail, and a cache that has been reaped simply serves a 200 with - the whole body, which the client handles by starting over. - """ - if session: - # Keep an actively-downloading session from being reaped mid-transfer. - try: - os.utime(path, None) - except OSError: - pass - else: - @after_this_request - def _cleanup(response): - try: - os.remove(path) - except OSError: - pass - return response - - return send_file( - path, - mimetype='application/gzip', - as_attachment=True, - download_name=download_name, - conditional=True, - ) - - -def _expected_chunk_len(meta, offset): - """Length the chunk at `offset` must have, or None if `offset` is not a - chunk boundary of this transfer.""" - total, chunk_size = meta['total_bytes'], meta['chunk_size'] - if offset < 0 or offset >= total or offset % chunk_size != 0: - return None - return min(chunk_size, total - offset) - - -@localkit_bp.route('/push//init', methods=['POST']) -@admin_required -def push_init(kind): - """Open (or re-open) a chunked transfer and report what is already here. - - Resume lives entirely in the ``received`` array of this response: the - client subtracts it from its own chunk plan, so a retry after a dropped - connection re-sends only what was actually lost. - """ - if kind not in ('code', 'db'): - return jsonify({'error': 'Unknown transfer kind'}), 404 - svc, err = _require_wp_service() - if err: - return err - data = request.get_json(silent=True) or {} - site, err = _resolve_site(data.get('site_id')) - if err: - return err - - sha256 = (data.get('sha256') or '').lower() - if not _HEX64.match(sha256): - return jsonify({'error': 'sha256 must be a hex-encoded SHA-256 digest'}), 400 - try: - total_bytes = int(data.get('total_bytes')) - chunk_size = int(data.get('chunk_size')) - except (TypeError, ValueError): - return jsonify({'error': 'total_bytes and chunk_size are required'}), 400 - if total_bytes < 0: - return jsonify({'error': 'total_bytes must not be negative'}), 400 - if not 0 < chunk_size <= MAX_CHUNK_SIZE: - return jsonify({'error': f'chunk_size must be between 1 and {MAX_CHUNK_SIZE}'}), 400 - - _reap_stale_transfers() - - existing = _find_resumable(site.id, kind, sha256, total_bytes, chunk_size) - if existing: - tdir = _transfer_dir(existing) - return jsonify({ - 'transfer_id': existing, - 'chunk_size': chunk_size, - 'received': _received_offsets(tdir), - 'resumed': True, - }), 200 - - transfer_id = uuid.uuid4().hex - tdir = _transfer_dir(transfer_id) - os.makedirs(os.path.join(tdir, 'chunks'), exist_ok=True) - meta = { - 'transfer_id': transfer_id, - 'site_id': site.id, - 'kind': kind, - 'total_bytes': total_bytes, - 'chunk_size': chunk_size, - 'sha256': sha256, - 'local_url': (data.get('local_url') or '').strip(), - 'filename': data.get('filename') or ('wp-content.tar.gz' if kind == 'code' else 'dump.sql'), - 'created': time.time(), - } - with open(os.path.join(tdir, 'meta.json'), 'w') as f: - json.dump(meta, f) - # Create the payload so chunk writes only ever have to seek into it. - with open(os.path.join(tdir, 'payload'), 'wb'): - pass - - return jsonify({ - 'transfer_id': transfer_id, - 'chunk_size': chunk_size, - 'received': [], - 'resumed': False, - }), 201 - - -@localkit_bp.route('/push//chunk', methods=['PUT']) -@admin_required -def push_chunk(kind): - """Write one chunk into the transfer's payload file. - - Idempotent by design: re-sending a chunk that is already confirmed with the - same hash is a 200 no-op, so a client that loses the response to a chunk it - actually delivered can simply send it again. - """ - transfer_id = request.args.get('transfer_id') - meta, tdir = _load_meta(transfer_id) - if not meta or meta.get('kind') != kind: - return jsonify({'error': 'Unknown or expired transfer'}), 404 - - try: - offset = int(request.args.get('offset')) - except (TypeError, ValueError): - return jsonify({'error': 'offset is required'}), 400 - expected_len = _expected_chunk_len(meta, offset) - if expected_len is None: - return jsonify({'error': f'offset {offset} is not a chunk boundary of this transfer'}), 400 - - sha256 = (request.args.get('sha256') or '').lower() - if not _HEX64.match(sha256): - return jsonify({'error': 'sha256 must be a hex-encoded SHA-256 digest'}), 400 - - marker = os.path.join(tdir, 'chunks', str(offset)) - if os.path.exists(marker): - try: - with open(marker) as f: - if f.read().strip() == sha256: - return jsonify({ - 'received': _received_offsets(tdir), - 'duplicate': True, - }), 200 - except OSError: - pass - - body = request.get_data() - if len(body) != expected_len: - return jsonify({ - 'error': f'chunk at offset {offset} must be {expected_len} bytes, got {len(body)}' - }), 400 - if hashlib.sha256(body).hexdigest() != sha256: - return jsonify({'error': f'chunk at offset {offset} failed its checksum'}), 400 - - payload = os.path.join(tdir, 'payload') - try: - # r+b keeps the bytes already written; the file is created at init. - with open(payload, 'r+b') as f: - f.seek(offset) - f.write(body) - except OSError as e: - return jsonify({'error': f'failed to store chunk: {e}'}), 500 - - # Marker last: a chunk is only "received" once its bytes are on disk, so a - # crash mid-write costs one re-sent chunk rather than a corrupt payload. - with open(marker, 'w') as f: - f.write(sha256) - - return jsonify({'received': _received_offsets(tdir), 'duplicate': False}), 200 - - -@localkit_bp.route('/push//finish', methods=['POST']) -@admin_required -def push_finish(kind): - """Verify the assembled payload and run the v1 processing path on it.""" - if kind not in ('code', 'db'): - return jsonify({'error': 'Unknown transfer kind'}), 404 - svc, err = _require_wp_service() - if err: - return err - - data = request.get_json(silent=True) or {} - meta, tdir = _load_meta(data.get('transfer_id')) - if not meta or meta.get('kind') != kind: - return jsonify({'error': 'Unknown or expired transfer'}), 404 - - # Missing chunks are a resumable condition, not a failure: report what is - # here so the client can send the rest and call finish again. - expected = list(range(0, meta['total_bytes'], meta['chunk_size'])) - received = _received_offsets(tdir) - missing = [o for o in expected if o not in received] - if missing: - return jsonify({ - 'error': f'{len(missing)} chunk(s) are still missing', - 'received': received, - 'missing': missing, - }), 409 - - payload = os.path.join(tdir, 'payload') - digest = hashlib.sha256() - try: - with open(payload, 'rb') as f: - for block in iter(lambda: f.read(1024 * 1024), b''): - digest.update(block) - size = os.path.getsize(payload) - except OSError as e: - _discard_transfer(tdir) - return jsonify({'error': f'failed to read the assembled upload: {e}'}), 500 - - if size != meta['total_bytes'] or digest.hexdigest() != meta['sha256']: - # Unrecoverable: the bytes on disk are not the bytes the client meant - # to send, and no amount of resuming fixes that. Start over. - _discard_transfer(tdir) - return jsonify({ - 'error': 'The assembled upload failed its checksum — nothing was applied.' - }), 400 - - site, err = _resolve_site(meta.get('site_id')) - if err: - _discard_transfer(tdir) - return err - - # Processing only ever runs here, on verified bytes — which is why an - # abandoned transfer can never leave the site half-updated. - if kind == 'code': - result, status = _install_code(svc, site, payload) - else: - result, status = _import_db(svc, site, payload, meta.get('local_url')) - - # A processing failure keeps the transfer: the upload was fine, so a retry - # should resume straight to finish instead of re-sending the whole payload. - if status == 200: - _discard_transfer(tdir) - return jsonify(result), status - - -@localkit_bp.route('/pull/db', methods=['GET']) -@admin_required -def pull_db(): - """Stream a gzipped SQL dump of the site's database. - - ``?session=`` (plan 19) pins the export for the life of a resumable - download; without it the behavior is exactly v1's export-and-delete. - """ - site, err = _resolve_site(request.args.get('site_id')) - if err: - return err - compose_file = _compose_file(site) - if not compose_file: - return jsonify({'error': 'Not a Docker-stack site (no docker-compose.yml)'}), 409 - - session = request.args.get('session') - download_name = f'{site.application.name}-db.sql.gz' - - cached = _download_cache(session, 'db', site.id) - if cached and os.path.exists(cached) and os.path.getsize(cached) > 0: - # A resumed range request: serve the same bytes the client started on. - return _serve_export(cached, download_name, session) - - if cached: - dump_path = cached - else: - fd, dump_path = tempfile.mkstemp(prefix='localkit_pull_', suffix='.sql.gz') - os.close(fd) - - result = DatabaseSyncService.export_from_container( - compose_path=compose_file, - db_name='wordpress', - db_user='root', - db_password=_read_env_value(site.application.root_path, 'DB_PASSWORD'), - output_path=dump_path, - compress=True, - ) - if not result.get('success'): - try: - os.remove(dump_path) - except OSError: - pass - return jsonify({'error': f"Database export failed: {result.get('error')}"}), 500 - - return _serve_export(dump_path, download_name, session) - - -@localkit_bp.route('/pull/code', methods=['GET']) -@admin_required -def pull_code(): - """Stream a tar.gz of the site's ``wp-content`` directory. - - The mirror image of ``POST /push/code``, and the missing half LocalKit's - "import this site as a new local site" flow needs. The archive is built - *inside* the container (``docker exec ... tar czf -``) rather than from the - host: the site's files are container-owned, and tarring in place keeps - permissions and directory structure intact. Entries are prefixed - ``wp-content/``, exactly like the archive push/code accepts, so the two - directions share one format. - - ``?session=`` (plan 19) pins the archive for the life of a resumable - download — ranges taken from two different ``tar czf`` runs of a live site - would not assemble into anything valid. - """ - site, err = _resolve_site(request.args.get('site_id')) - if err: - return err - container = site.application.name - if not container: - return jsonify({'error': 'Site has no container'}), 409 - - session = request.args.get('session') - download_name = f'{container}-wp-content.tar.gz' - - cached = _download_cache(session, 'code', site.id) - if cached and os.path.exists(cached) and os.path.getsize(cached) > 0: - return _serve_export(cached, download_name, session) - - if cached: - archive_path = cached - fd = os.open(archive_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - else: - fd, archive_path = tempfile.mkstemp(prefix='localkit_pullcode_', suffix='.tar.gz') - - def _discard(): - try: - os.remove(archive_path) - except OSError: - pass - - try: - with os.fdopen(fd, 'wb') as out: - proc = subprocess.run( - ['docker', 'exec', container, 'tar', 'czf', '-', - '-C', '/var/www/html', 'wp-content'], - stdout=out, stderr=subprocess.PIPE, timeout=900, - ) - except (subprocess.SubprocessError, OSError) as e: - _discard() - return jsonify({'error': f'wp-content export failed: {e}'}), 500 - - # GNU tar exits 1 for "file changed as we read it" — a live site writing - # cache/uploads mid-archive is normal and the archive is still usable. - stderr = (proc.stderr or b'').decode('utf-8', 'replace').strip() - if proc.returncode not in (0, 1) or os.path.getsize(archive_path) == 0: - _discard() - return jsonify({ - 'error': f'wp-content export failed: {stderr or "is the site running?"}' - }), 500 - - return _serve_export(archive_path, download_name, session) diff --git a/builtin-extensions/serverkit-localkit/plugin.json b/builtin-extensions/serverkit-localkit/plugin.json deleted file mode 100644 index 109b2b2c..00000000 --- a/builtin-extensions/serverkit-localkit/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "serverkit-localkit", - "display_name": "LocalKit Bridge", - "version": "0.2.0", - "description": "API surface for the LocalKit desktop app: pair with an API key, list/create WordPress sites (API-key friendly), and push/pull wp-content code and database dumps between local and server sites.", - "author": "ServerKit", - "license": "MIT", - "category": "integration", - "homepage": "https://github.com/jhd3197/ServerKit", - "permissions": ["filesystem", "network", "docker", "database"], - "min_panel_version": "1.7.0", - "entry_point": "localkit:localkit_bp", - "url_prefix": "/api/v1/localkit" -} From 43fec8030cd73b94b866efceb53cc8c7514686b9 Mon Sep 17 00:00:00 2001 From: Juan Denis Date: Sat, 29 Aug 2026 14:45:22 -0400 Subject: [PATCH 02/18] cutover(email): serverkit-email leaves the tree (plan 52 Phase 2, D8) builtin-extensions/serverkit-email + both tracked live copies removed; the bundled registry entry flips to bundled:false with the local zip's sha256; the gated auto-install (_email_was_configured) moves from GATED_BUILTIN_SLUGS to REGISTRY_GATED_SLUGS so mail users still get it back once the entry is published. Panel-core tests re-home onto the sibling checkout via the new conftest.sibling_extension_dir helper (skip cleanly when absent): the extraction suite installs from the serverkit-email repo, the dovecot-probe suite loads its module from there. All 39 affected tests green with the sibling present. OPERATOR: publish jhd3197/serverkit-email, sign, sync-registry-index.sh. Co-Authored-By: Claude Fable 5 --- backend/app/data/registry_index.json | 9 +- .../app/plugins/serverkit-email/__init__.py | 1 - .../plugins/serverkit-email/dkim_service.py | 271 ----- .../serverkit-email/dovecot_service.py | 374 ------ backend/app/plugins/serverkit-email/email.py | 426 ------- .../plugins/serverkit-email/email_service.py | 550 --------- .../app/plugins/serverkit-email/plugin.json | 51 - .../serverkit-email/roundcube_service.py | 198 --- .../serverkit-email/spamassassin_service.py | 254 ---- backend/app/services/extension_migration.py | 4 +- backend/tests/conftest.py | 21 + backend/tests/test_email_dovecot_probe.py | 12 +- backend/tests/test_email_extraction.py | 37 +- .../serverkit-email/backend/__init__.py | 1 - .../serverkit-email/backend/dkim_service.py | 271 ----- .../backend/dovecot_service.py | 374 ------ .../serverkit-email/backend/email.py | 426 ------- .../serverkit-email/backend/email_service.py | 550 --------- .../backend/roundcube_service.py | 198 --- .../backend/spamassassin_service.py | 254 ---- .../serverkit-email/frontend/index.jsx | 18 - .../serverkit-email/frontend/pages/Email.jsx | 1070 ----------------- .../frontend/services/email.js | 69 -- .../frontend/styles/email.scss | 277 ----- .../serverkit-email/plugin.json | 41 - .../src/plugins/serverkit-email/index.jsx | 18 - .../plugins/serverkit-email/pages/Email.jsx | 1070 ----------------- .../src/plugins/serverkit-email/plugin.json | 51 - .../plugins/serverkit-email/services/email.js | 69 -- .../plugins/serverkit-email/styles/email.scss | 277 ----- 30 files changed, 67 insertions(+), 7175 deletions(-) delete mode 100644 backend/app/plugins/serverkit-email/__init__.py delete mode 100644 backend/app/plugins/serverkit-email/dkim_service.py delete mode 100644 backend/app/plugins/serverkit-email/dovecot_service.py delete mode 100644 backend/app/plugins/serverkit-email/email.py delete mode 100644 backend/app/plugins/serverkit-email/email_service.py delete mode 100644 backend/app/plugins/serverkit-email/plugin.json delete mode 100644 backend/app/plugins/serverkit-email/roundcube_service.py delete mode 100644 backend/app/plugins/serverkit-email/spamassassin_service.py delete mode 100644 builtin-extensions/serverkit-email/backend/__init__.py delete mode 100644 builtin-extensions/serverkit-email/backend/dkim_service.py delete mode 100644 builtin-extensions/serverkit-email/backend/dovecot_service.py delete mode 100644 builtin-extensions/serverkit-email/backend/email.py delete mode 100644 builtin-extensions/serverkit-email/backend/email_service.py delete mode 100644 builtin-extensions/serverkit-email/backend/roundcube_service.py delete mode 100644 builtin-extensions/serverkit-email/backend/spamassassin_service.py delete mode 100644 builtin-extensions/serverkit-email/frontend/index.jsx delete mode 100644 builtin-extensions/serverkit-email/frontend/pages/Email.jsx delete mode 100644 builtin-extensions/serverkit-email/frontend/services/email.js delete mode 100644 builtin-extensions/serverkit-email/frontend/styles/email.scss delete mode 100644 builtin-extensions/serverkit-email/plugin.json delete mode 100644 frontend/src/plugins/serverkit-email/index.jsx delete mode 100644 frontend/src/plugins/serverkit-email/pages/Email.jsx delete mode 100644 frontend/src/plugins/serverkit-email/plugin.json delete mode 100644 frontend/src/plugins/serverkit-email/services/email.js delete mode 100644 frontend/src/plugins/serverkit-email/styles/email.scss diff --git a/backend/app/data/registry_index.json b/backend/app/data/registry_index.json index 35623110..438a7066 100644 --- a/backend/app/data/registry_index.json +++ b/backend/app/data/registry_index.json @@ -118,7 +118,7 @@ "category": "integration", "author": "ServerKit", "first_party": true, - "bundled": true, + "bundled": false, "permissions": [ "shell", "filesystem", @@ -126,8 +126,11 @@ ], "min_panel_version": "1.7.0", "max_panel_version": null, - "repo": "https://github.com/jhd3197/ServerKit", - "logo": "assets/serverkit-email/logo.png" + "repo": "https://github.com/jhd3197/serverkit-email", + "logo": "assets/serverkit-email/logo.png", + "source": "https://github.com/jhd3197/serverkit-email/releases/download/v1.0.0/serverkit-email-1.0.0.zip", + "sha256": "896fea2df5dd5097b70e9401264555078acc6eaea9a8a57382b7e4be92ac381b", + "homepage": "https://github.com/jhd3197/serverkit-email" }, { "slug": "serverkit-ftp", diff --git a/backend/app/plugins/serverkit-email/__init__.py b/backend/app/plugins/serverkit-email/__init__.py deleted file mode 100644 index 51e67b6d..00000000 --- a/backend/app/plugins/serverkit-email/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ServerKit Email extension backend package.""" diff --git a/backend/app/plugins/serverkit-email/dkim_service.py b/backend/app/plugins/serverkit-email/dkim_service.py deleted file mode 100644 index e79ca0d8..00000000 --- a/backend/app/plugins/serverkit-email/dkim_service.py +++ /dev/null @@ -1,271 +0,0 @@ -"""OpenDKIM management service for DKIM signing.""" -import os -import re -import subprocess -from typing import Dict - -from app.utils.system import (PackageManager, ServiceControl, run_checked, - run_privileged) - - -class DKIMService: - """Service for managing OpenDKIM (DKIM email signing).""" - - OPENDKIM_CONF = '/etc/opendkim.conf' - OPENDKIM_DIR = '/etc/opendkim' - OPENDKIM_KEYS_DIR = '/etc/opendkim/keys' - KEY_TABLE = '/etc/opendkim/KeyTable' - SIGNING_TABLE = '/etc/opendkim/SigningTable' - TRUSTED_HOSTS = '/etc/opendkim/TrustedHosts' - - OPENDKIM_CONF_CONTENT = """# OpenDKIM configuration - Managed by ServerKit -Syslog yes -SyslogSuccess yes -LogWhy yes -Canonicalization relaxed/simple -Mode sv -SubDomains no -AutoRestart yes -AutoRestartRate 10/1M -Background yes -DNSTimeout 5 -SignatureAlgorithm rsa-sha256 -KeyTable refile:/etc/opendkim/KeyTable -SigningTable refile:/etc/opendkim/SigningTable -ExternalIgnoreList /etc/opendkim/TrustedHosts -InternalHosts /etc/opendkim/TrustedHosts -Socket inet:8891@localhost -PidFile /run/opendkim/opendkim.pid -OversignHeaders From -UserID opendkim -UMask 007 -""" - - TRUSTED_HOSTS_DEFAULT = """# Trusted hosts - Managed by ServerKit -127.0.0.1 -::1 -localhost -""" - - @classmethod - def get_status(cls) -> Dict: - """Get OpenDKIM installation and running status.""" - installed = False - running = False - enabled = False - version = None - try: - result = run_checked(['which', 'opendkim'], timeout=None) - installed = result['success'] - if not installed: - installed = PackageManager.is_installed('opendkim') - if installed: - running = ServiceControl.is_active('opendkim') - enabled = ServiceControl.is_enabled('opendkim') - result = run_checked(['opendkim', '-V'], timeout=None, merge_stderr=True) - match = re.search(r'OpenDKIM\s+Filter\s+v(\S+)', result['output']) - if match: - version = match.group(1) - except (subprocess.SubprocessError, FileNotFoundError): - pass - - return { - 'installed': installed, - 'running': running, - 'enabled': enabled, - 'version': version, - } - - @classmethod - def install(cls) -> Dict: - """Install OpenDKIM.""" - try: - result = PackageManager.install(['opendkim', 'opendkim-tools'], timeout=300) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Failed to install OpenDKIM'} - - # Create directories - run_privileged(['mkdir', '-p', cls.OPENDKIM_KEYS_DIR]) - run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR]) - - # Create config files - run_privileged(['tee', cls.OPENDKIM_CONF], input=cls.OPENDKIM_CONF_CONTENT) - run_privileged(['tee', cls.TRUSTED_HOSTS], input=cls.TRUSTED_HOSTS_DEFAULT) - run_privileged(['touch', cls.KEY_TABLE]) - run_privileged(['touch', cls.SIGNING_TABLE]) - - # Set permissions - run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR]) - run_privileged(['chmod', '700', cls.OPENDKIM_KEYS_DIR]) - - # Create PID directory - run_privileged(['mkdir', '-p', '/run/opendkim']) - run_privileged(['chown', 'opendkim:opendkim', '/run/opendkim']) - - # Add postfix to opendkim group - run_privileged(['usermod', '-aG', 'opendkim', 'postfix']) - - ServiceControl.enable('opendkim') - ServiceControl.start('opendkim', timeout=30) - - return {'success': True, 'message': 'OpenDKIM installed successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def generate_key(cls, domain: str, selector: str = 'default') -> Dict: - """Generate DKIM key pair for a domain.""" - try: - key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain) - run_privileged(['mkdir', '-p', key_dir]) - - # Generate key - result = run_privileged([ - 'opendkim-genkey', - '-s', selector, - '-d', domain, - '-D', key_dir, - '-b', '2048', - ]) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Key generation failed'} - - # Set permissions - run_privileged(['chown', '-R', 'opendkim:opendkim', key_dir]) - run_privileged(['chmod', '600', os.path.join(key_dir, f'{selector}.private')]) - - # Read the public key TXT record - txt_file = os.path.join(key_dir, f'{selector}.txt') - result = run_privileged(['cat', txt_file]) - public_key_record = result.stdout.strip() if result.returncode == 0 else '' - - # Extract just the key value from the TXT record - key_match = re.search(r'p=([A-Za-z0-9+/=\s]+)', public_key_record) - public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') if key_match else '' - - return { - 'success': True, - 'domain': domain, - 'selector': selector, - 'private_key_path': os.path.join(key_dir, f'{selector}.private'), - 'public_key': public_key, - 'dns_record': public_key_record, - 'dns_name': f'{selector}._domainkey.{domain}', - 'dns_value': f'v=DKIM1; k=rsa; p={public_key}', - } - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def add_domain(cls, domain: str, selector: str = 'default') -> Dict: - """Add domain to KeyTable and SigningTable.""" - try: - key_path = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.private') - - # Add to KeyTable - key_entry = f'{selector}._domainkey.{domain} {domain}:{selector}:{key_path}\n' - result = run_privileged(['cat', cls.KEY_TABLE]) - if domain not in (result.stdout or ''): - run_privileged(['tee', '-a', cls.KEY_TABLE], input=key_entry) - - # Add to SigningTable - signing_entry = f'*@{domain} {selector}._domainkey.{domain}\n' - result = run_privileged(['cat', cls.SIGNING_TABLE]) - if domain not in (result.stdout or ''): - run_privileged(['tee', '-a', cls.SIGNING_TABLE], input=signing_entry) - - # Add to TrustedHosts - result = run_privileged(['cat', cls.TRUSTED_HOSTS]) - if domain not in (result.stdout or ''): - run_privileged(['tee', '-a', cls.TRUSTED_HOSTS], input=f'*.{domain}\n') - - # Reload OpenDKIM - ServiceControl.restart('opendkim', timeout=30) - - return {'success': True, 'message': f'Domain {domain} added to DKIM'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def remove_domain(cls, domain: str) -> Dict: - """Remove domain from DKIM configuration.""" - try: - # Remove from KeyTable - result = run_privileged(['cat', cls.KEY_TABLE]) - lines = [l for l in (result.stdout or '').splitlines() if domain not in l] - run_privileged(['tee', cls.KEY_TABLE], input='\n'.join(lines) + '\n') - - # Remove from SigningTable - result = run_privileged(['cat', cls.SIGNING_TABLE]) - lines = [l for l in (result.stdout or '').splitlines() if domain not in l] - run_privileged(['tee', cls.SIGNING_TABLE], input='\n'.join(lines) + '\n') - - # Remove from TrustedHosts - result = run_privileged(['cat', cls.TRUSTED_HOSTS]) - lines = [l for l in (result.stdout or '').splitlines() if domain not in l] - run_privileged(['tee', cls.TRUSTED_HOSTS], input='\n'.join(lines) + '\n') - - # Remove key files - key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain) - run_privileged(['rm', '-rf', key_dir]) - - ServiceControl.restart('opendkim', timeout=30) - - return {'success': True, 'message': f'Domain {domain} removed from DKIM'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_dns_record(cls, domain: str, selector: str = 'default') -> Dict: - """Get the DKIM DNS TXT record content for a domain.""" - try: - txt_file = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.txt') - result = run_privileged(['cat', txt_file]) - if result.returncode != 0: - return {'success': False, 'error': 'DKIM key not found'} - - record = result.stdout.strip() - key_match = re.search(r'p=([A-Za-z0-9+/=\s"]+)', record) - public_key = '' - if key_match: - public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') - - return { - 'success': True, - 'dns_name': f'{selector}._domainkey.{domain}', - 'dns_value': f'v=DKIM1; k=rsa; p={public_key}', - 'raw_record': record, - } - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def verify_key(cls, domain: str, selector: str = 'default') -> Dict: - """Verify DKIM key configuration.""" - try: - result = run_privileged([ - 'opendkim-testkey', - '-d', domain, - '-s', selector, - '-vvv', - ]) - success = result.returncode == 0 - output = (result.stdout or '') + (result.stderr or '') - return { - 'success': success, - 'verified': success and 'key OK' in output, - 'output': output, - } - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def reload(cls) -> Dict: - """Reload OpenDKIM.""" - try: - result = ServiceControl.restart('opendkim', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'OpenDKIM restarted'} - return {'success': False, 'error': result.stderr or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/backend/app/plugins/serverkit-email/dovecot_service.py b/backend/app/plugins/serverkit-email/dovecot_service.py deleted file mode 100644 index b108a123..00000000 --- a/backend/app/plugins/serverkit-email/dovecot_service.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Dovecot IMAP/POP3 server management service.""" -import os -import re -import subprocess -from typing import Dict, Optional - -from app.utils.system import (PackageManager, ServiceControl, is_command_available, - run_privileged) -from app import paths - - -class DovecotService: - """Service for managing Dovecot (IMAP/POP3 server).""" - - DOVECOT_CONF_DIR = '/etc/dovecot' - DOVECOT_CONF = '/etc/dovecot/dovecot.conf' - DOVECOT_AUTH_CONF = '/etc/dovecot/conf.d/10-auth.conf' - DOVECOT_MAIL_CONF = '/etc/dovecot/conf.d/10-mail.conf' - DOVECOT_MASTER_CONF = '/etc/dovecot/conf.d/10-master.conf' - DOVECOT_SSL_CONF = '/etc/dovecot/conf.d/10-ssl.conf' - DOVECOT_PASSWD_FILE = '/etc/dovecot/users' - AUTH_PASSWDFILE_CONF = '/etc/dovecot/conf.d/auth-passwdfile.conf.ext' - - MAIL_CONF_CONTENT = """# Dovecot mail configuration - Managed by ServerKit -mail_location = maildir:{vmail_dir}/%d/%n/Maildir -namespace inbox {{ - inbox = yes - separator = / -}} -mail_uid = {vmail_uid} -mail_gid = {vmail_gid} -mail_privileged_group = vmail -first_valid_uid = {vmail_uid} -last_valid_uid = {vmail_uid} -""" - - AUTH_CONF_CONTENT = """# Dovecot auth configuration - Managed by ServerKit -disable_plaintext_auth = yes -auth_mechanisms = plain login -!include auth-passwdfile.conf.ext -""" - - AUTH_PASSWDFILE_CONTENT = """# Password file auth - Managed by ServerKit -passdb {{ - driver = passwd-file - args = scheme=SHA512-CRYPT /etc/dovecot/users -}} -userdb {{ - driver = static - args = uid={vmail_uid} gid={vmail_gid} home={vmail_dir}/%d/%n -}} -""" - - MASTER_CONF_CONTENT = """# Dovecot master configuration - Managed by ServerKit -service imap-login { - inet_listener imap { - port = 0 - } - inet_listener imaps { - port = 993 - ssl = yes - } -} -service pop3-login { - inet_listener pop3 { - port = 0 - } - inet_listener pop3s { - port = 995 - ssl = yes - } -} -service lmtp { - unix_listener /var/spool/postfix/private/dovecot-lmtp { - mode = 0600 - user = postfix - group = postfix - } -} -service auth { - unix_listener /var/spool/postfix/private/auth { - mode = 0666 - user = postfix - group = postfix - } - unix_listener auth-userdb { - mode = 0600 - user = vmail - group = vmail - } - user = dovecot -} -service auth-worker { - user = vmail -} -""" - - SSL_CONF_CONTENT = """# Dovecot SSL configuration - Managed by ServerKit -ssl = required -ssl_cert = <{tls_cert} -ssl_key = <{tls_key} -ssl_min_protocol = TLSv1.2 -ssl_prefer_server_ciphers = yes -""" - - @classmethod - def get_status(cls) -> Dict: - """Get Dovecot installation and running status.""" - installed = False - running = False - enabled = False - version = None - # `which` searches $PATH only, and dovecot lives in /usr/sbin — absent - # from the panel unit's PATH — so it reported a false negative on a host - # where dovecot was installed. is_command_available() also searches the - # sbin dirs. - try: - installed = is_command_available('dovecot') - if not installed: - installed = PackageManager.is_installed('dovecot-core') or PackageManager.is_installed('dovecot') - except Exception: - installed = False - - if installed: - try: - running = ServiceControl.is_active('dovecot') - enabled = ServiceControl.is_enabled('dovecot') - except (subprocess.SubprocessError, FileNotFoundError): - # No systemctl (container, non-systemd init), or systemd not - # answering inside PROBE_TIMEOUT: "could not check", not "not - # running" — and never a 500. Wider than FileNotFoundError on - # purpose: is_enabled already swallows the missing binary, but - # the probe timeout in both wrappers still raises - # TimeoutExpired straight through get_status and out of - # /api/v1/email/status. The sibling services in this extension - # catch the same pair. - running = None - enabled = None - # Its own try: failing to read the version says nothing about - # whether dovecot is installed. Collapsing the two is how - # FirewallService._check_ufw reported a working firewall as absent. - try: - result = run_privileged(['dovecot', '--version']) - version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout or '') - if version_match: - version = version_match.group(1) - except Exception: - pass - - return { - 'installed': installed, - 'running': running, - 'enabled': enabled, - 'version': version, - } - - @classmethod - def install(cls) -> Dict: - """Install Dovecot packages.""" - try: - manager = PackageManager.detect() - if manager == 'apt': - packages = ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'dovecot-lmtpd', 'dovecot-sieve'] - else: - packages = ['dovecot'] - - result = PackageManager.install(packages, timeout=300) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Failed to install Dovecot'} - - # Create empty passwd file - run_privileged(['touch', cls.DOVECOT_PASSWD_FILE]) - run_privileged(['chown', 'vmail:dovecot', cls.DOVECOT_PASSWD_FILE]) - run_privileged(['chmod', '640', cls.DOVECOT_PASSWD_FILE]) - - ServiceControl.enable('dovecot') - - return {'success': True, 'message': 'Dovecot installed successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def configure(cls, tls_cert: str = None, tls_key: str = None) -> Dict: - """Write Dovecot configuration files.""" - try: - cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem' - key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key' - - # 10-mail.conf - mail_conf = cls.MAIL_CONF_CONTENT.format( - vmail_dir=paths.VMAIL_DIR, - vmail_uid=paths.VMAIL_UID, - vmail_gid=paths.VMAIL_GID, - ) - run_privileged(['tee', cls.DOVECOT_MAIL_CONF], input=mail_conf) - - # 10-auth.conf - run_privileged(['tee', cls.DOVECOT_AUTH_CONF], input=cls.AUTH_CONF_CONTENT) - - # auth-passwdfile.conf.ext - auth_passwd = cls.AUTH_PASSWDFILE_CONTENT.format( - vmail_uid=paths.VMAIL_UID, - vmail_gid=paths.VMAIL_GID, - vmail_dir=paths.VMAIL_DIR, - ) - run_privileged(['tee', cls.AUTH_PASSWDFILE_CONF], input=auth_passwd) - - # 10-master.conf - run_privileged(['tee', cls.DOVECOT_MASTER_CONF], input=cls.MASTER_CONF_CONTENT) - - # 10-ssl.conf - ssl_conf = cls.SSL_CONF_CONTENT.format(tls_cert=cert, tls_key=key) - run_privileged(['tee', cls.DOVECOT_SSL_CONF], input=ssl_conf) - - # Restart to apply - ServiceControl.restart('dovecot', timeout=30) - - return {'success': True, 'message': 'Dovecot configured successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def create_mailbox(cls, email: str, password: str, domain: str, username: str, quota_mb: int = 1024) -> Dict: - """Create a virtual mailbox with password entry.""" - try: - # Generate password hash using doveadm - result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password]) - if result.returncode != 0: - return {'success': False, 'error': 'Failed to hash password'} - - password_hash = result.stdout.strip() - - # Build passwd-file entry - # Format: user@domain:{scheme}hash:uid:gid:home::userdb_quota_rule=*:storage=NM - entry = f'{email}:{password_hash}:{paths.VMAIL_UID}:{paths.VMAIL_GID}:{paths.VMAIL_DIR}/{domain}/{username}::userdb_quota_rule=*:storage={quota_mb}M' - - # Append to passwd file - run_privileged(['tee', '-a', cls.DOVECOT_PASSWD_FILE], input=entry + '\n') - - # Create Maildir - maildir = os.path.join(paths.VMAIL_DIR, domain, username, 'Maildir') - run_privileged(['mkdir', '-p', f'{maildir}/cur', f'{maildir}/new', f'{maildir}/tmp']) - run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}', - os.path.join(paths.VMAIL_DIR, domain, username)]) - - return {'success': True, 'message': f'Mailbox {email} created'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def delete_mailbox(cls, email: str, domain: str, username: str, remove_files: bool = False) -> Dict: - """Delete a virtual mailbox.""" - try: - # Remove from passwd file - result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) - lines = (result.stdout or '').splitlines() - new_lines = [l for l in lines if not l.startswith(f'{email}:')] - run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') - - # Optionally remove Maildir - if remove_files: - mailbox_path = os.path.join(paths.VMAIL_DIR, domain, username) - run_privileged(['rm', '-rf', mailbox_path]) - - return {'success': True, 'message': f'Mailbox {email} deleted'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def change_password(cls, email: str, new_password: str) -> Dict: - """Change a mailbox password.""" - try: - # Generate new hash - result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password]) - if result.returncode != 0: - return {'success': False, 'error': 'Failed to hash password'} - - new_hash = result.stdout.strip() - - # Read current file - result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) - lines = (result.stdout or '').splitlines() - - updated = False - new_lines = [] - for line in lines: - if line.startswith(f'{email}:'): - parts = line.split(':') - parts[1] = new_hash - new_lines.append(':'.join(parts)) - updated = True - else: - new_lines.append(line) - - if not updated: - return {'success': False, 'error': f'Account {email} not found'} - - run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') - - return {'success': True, 'message': 'Password changed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def set_quota(cls, email: str, quota_mb: int) -> Dict: - """Update mailbox quota.""" - try: - result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) - lines = (result.stdout or '').splitlines() - - updated = False - new_lines = [] - for line in lines: - if line.startswith(f'{email}:'): - # Replace quota in the userdb_quota_rule field - line = re.sub(r'userdb_quota_rule=\*:storage=\d+M', - f'userdb_quota_rule=*:storage={quota_mb}M', line) - new_lines.append(line) - updated = True - else: - new_lines.append(line) - - if not updated: - return {'success': False, 'error': f'Account {email} not found'} - - run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') - - return {'success': True, 'message': f'Quota set to {quota_mb}MB'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_quota_usage(cls, email: str) -> Dict: - """Get mailbox quota usage.""" - try: - result = run_privileged(['doveadm', 'quota', 'get', '-u', email]) - if result.returncode != 0: - return {'success': False, 'error': 'Failed to get quota'} - - # Parse output - usage = {'storage_used': 0, 'storage_limit': 0, 'message_count': 0} - for line in result.stdout.splitlines(): - parts = line.split() - if len(parts) >= 4 and parts[0] == 'STORAGE': - usage['storage_used'] = int(parts[1]) // 1024 # KB to MB - usage['storage_limit'] = int(parts[2]) // 1024 if parts[2] != '-' else 0 - elif len(parts) >= 4 and parts[0] == 'MESSAGE': - usage['message_count'] = int(parts[1]) - - return {'success': True, **usage} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def reload(cls) -> Dict: - """Reload Dovecot configuration.""" - try: - result = ServiceControl.reload('dovecot', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'Dovecot reloaded'} - return {'success': False, 'error': result.stderr or 'Reload failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def restart(cls) -> Dict: - """Restart Dovecot.""" - try: - result = ServiceControl.restart('dovecot', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'Dovecot restarted'} - return {'success': False, 'error': result.stderr or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/backend/app/plugins/serverkit-email/email.py b/backend/app/plugins/serverkit-email/email.py deleted file mode 100644 index 45afdf28..00000000 --- a/backend/app/plugins/serverkit-email/email.py +++ /dev/null @@ -1,426 +0,0 @@ -"""Email Server API endpoints for managing mail services, domains, accounts, and DNS. - -Lives in the serverkit-email extension (app.plugins.serverkit-email.email after -install). Intra-extension modules are imported relatively; core services -(postfix relay, DNS, relay-config, RBAC) are imported absolutely — the mail -server extends the core, never the other way around. -""" -from flask import Blueprint, request, jsonify - -from app.middleware.rbac import admin_required, viewer_required -from app.services.dns_provider_service import DNSProviderService -from app.services.postfix_service import PostfixService -from app.services.email_relay_service import EmailRelayService -from .email_service import EmailService -from .spamassassin_service import SpamAssassinService -from .roundcube_service import RoundcubeService - -email_bp = Blueprint('email', __name__) - - -# ── Status & Installation ── - -@email_bp.route('/status', methods=['GET']) -@viewer_required -def get_status(): - """Get aggregate email server status.""" - roundcube = RoundcubeService.get_status() - status = EmailService.get_status() - status['roundcube'] = roundcube - return jsonify(status), 200 - - -@email_bp.route('/install', methods=['POST']) -@admin_required -def install(): - """Install and configure all email components.""" - data = request.get_json() or {} - hostname = data.get('hostname') - result = EmailService.install_all(hostname) - return jsonify(result), 200 if result.get('success') else 500 - - -@email_bp.route('/service//', methods=['POST']) -@admin_required -def control_service(component, action): - """Start/stop/restart an email component.""" - result = EmailService.control_service(component, action) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Domains ── - -@email_bp.route('/domains', methods=['GET']) -@viewer_required -def list_domains(): - """List all email domains.""" - domains = EmailService.get_domains() - return jsonify({'domains': domains}), 200 - - -@email_bp.route('/domains', methods=['POST']) -@admin_required -def add_domain(): - """Add an email domain.""" - data = request.get_json() - if not data or not data.get('name'): - return jsonify({'success': False, 'error': 'Domain name is required'}), 400 - result = EmailService.add_domain( - data['name'], - dns_provider_id=data.get('dns_provider_id'), - dns_zone_id=data.get('dns_zone_id'), - ) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/domains/', methods=['GET']) -@viewer_required -def get_domain(domain_id): - """Get domain details.""" - result = EmailService.get_domain(domain_id) - if result.get('success'): - return jsonify(result), 200 - return jsonify(result), 404 - - -@email_bp.route('/domains/', methods=['DELETE']) -@admin_required -def remove_domain(domain_id): - """Remove an email domain.""" - result = EmailService.remove_domain(domain_id) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/domains//verify-dns', methods=['POST']) -@viewer_required -def verify_dns(domain_id): - """Verify DNS records for a domain.""" - result = EmailService.verify_dns(domain_id) - return jsonify(result), 200 - - -@email_bp.route('/domains//deploy-dns', methods=['POST']) -@admin_required -def deploy_dns(domain_id): - """Deploy DNS records via provider API.""" - from app.models.email import EmailDomain - domain = EmailDomain.query.get(domain_id) - if not domain: - return jsonify({'success': False, 'error': 'Domain not found'}), 404 - if not domain.dns_provider_id or not domain.dns_zone_id: - return jsonify({'success': False, 'error': 'No DNS provider configured for this domain'}), 400 - result = DNSProviderService.deploy_email_records( - domain.dns_provider_id, - domain.dns_zone_id, - domain.name, - domain.dkim_selector or 'default', - domain.dkim_public_key or '', - ) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Accounts ── - -@email_bp.route('/domains//accounts', methods=['GET']) -@viewer_required -def list_accounts(domain_id): - """List email accounts for a domain.""" - accounts = EmailService.get_accounts(domain_id) - return jsonify({'accounts': accounts}), 200 - - -@email_bp.route('/domains//accounts', methods=['POST']) -@admin_required -def create_account(domain_id): - """Create an email account.""" - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'Request body required'}), 400 - username = data.get('username') - password = data.get('password') - if not username or not password: - return jsonify({'success': False, 'error': 'Username and password are required'}), 400 - result = EmailService.add_account( - domain_id, - username, - password, - quota_mb=data.get('quota_mb', 1024), - ) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/accounts/', methods=['GET']) -@viewer_required -def get_account(account_id): - """Get account details.""" - from app.models.email import EmailAccount - account = EmailAccount.query.get(account_id) - if not account: - return jsonify({'success': False, 'error': 'Account not found'}), 404 - return jsonify({'success': True, 'account': account.to_dict()}), 200 - - -@email_bp.route('/accounts/', methods=['PUT']) -@admin_required -def update_account(account_id): - """Update account settings.""" - data = request.get_json() or {} - result = EmailService.update_account( - account_id, - quota_mb=data.get('quota_mb'), - is_active=data.get('is_active'), - ) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/accounts/', methods=['DELETE']) -@admin_required -def delete_account(account_id): - """Delete an email account.""" - result = EmailService.delete_account(account_id) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/accounts//password', methods=['POST']) -@admin_required -def change_password(account_id): - """Change account password.""" - data = request.get_json() - if not data or not data.get('password'): - return jsonify({'success': False, 'error': 'New password is required'}), 400 - result = EmailService.change_password(account_id, data['password']) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Aliases ── - -@email_bp.route('/domains//aliases', methods=['GET']) -@viewer_required -def list_aliases(domain_id): - """List email aliases for a domain.""" - aliases = EmailService.get_aliases(domain_id) - return jsonify({'aliases': aliases}), 200 - - -@email_bp.route('/domains//aliases', methods=['POST']) -@admin_required -def create_alias(domain_id): - """Create an email alias.""" - data = request.get_json() - if not data or not data.get('source') or not data.get('destination'): - return jsonify({'success': False, 'error': 'Source and destination are required'}), 400 - result = EmailService.add_alias(domain_id, data['source'], data['destination']) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/aliases/', methods=['DELETE']) -@admin_required -def delete_alias(alias_id): - """Delete an email alias.""" - result = EmailService.remove_alias(alias_id) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Forwarding Rules ── - -@email_bp.route('/accounts//forwarding', methods=['GET']) -@viewer_required -def list_forwarding(account_id): - """List forwarding rules for an account.""" - rules = EmailService.get_forwarding(account_id) - return jsonify({'rules': rules}), 200 - - -@email_bp.route('/accounts//forwarding', methods=['POST']) -@admin_required -def create_forwarding(account_id): - """Create a forwarding rule.""" - data = request.get_json() - if not data or not data.get('destination'): - return jsonify({'success': False, 'error': 'Destination is required'}), 400 - result = EmailService.add_forwarding( - account_id, - data['destination'], - keep_copy=data.get('keep_copy', True), - ) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/forwarding/', methods=['PUT']) -@admin_required -def update_forwarding(rule_id): - """Update a forwarding rule.""" - data = request.get_json() or {} - result = EmailService.update_forwarding( - rule_id, - destination=data.get('destination'), - keep_copy=data.get('keep_copy'), - is_active=data.get('is_active'), - ) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/forwarding/', methods=['DELETE']) -@admin_required -def delete_forwarding(rule_id): - """Delete a forwarding rule.""" - result = EmailService.remove_forwarding(rule_id) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── DNS Providers ── -# Moved to core (app/api/dns_providers.py, same /api/v1/email/dns-providers -# paths): the Connections page and wildcard TLS need them without this -# extension installed. - - -# ── Outbound SMTP Relay (smarthost) ── - -@email_bp.route('/relay', methods=['GET']) -@viewer_required -def get_relay(): - """Get the outbound SMTP relay configuration (password masked).""" - return jsonify(EmailRelayService.get_config()), 200 - - -@email_bp.route('/relay', methods=['PUT']) -@admin_required -def save_relay(): - """Save the relay configuration and apply it to Postfix (if installed).""" - data = request.get_json() or {} - if not data.get('host'): - return jsonify({'success': False, 'error': 'A relay host is required'}), 400 - result = EmailRelayService.save_config(data) - return jsonify(result), 200 - - -@email_bp.route('/relay/test', methods=['POST']) -@admin_required -def test_relay(): - """Open a real SMTP connection to validate the relay credentials.""" - data = request.get_json() or {} - result = EmailRelayService.test(data) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/relay', methods=['DELETE']) -@admin_required -def disable_relay(): - """Disable the outbound relay (revert Postfix to direct delivery).""" - result = EmailRelayService.disable() - return jsonify(result), 200 - - -# ── SpamAssassin Config ── - -@email_bp.route('/spam/config', methods=['GET']) -@viewer_required -def get_spam_config(): - """Get SpamAssassin configuration.""" - result = SpamAssassinService.get_config() - return jsonify(result), 200 - - -@email_bp.route('/spam/config', methods=['PUT']) -@admin_required -def update_spam_config(): - """Update SpamAssassin configuration.""" - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'Request body required'}), 400 - result = SpamAssassinService.configure(data) - if result.get('success'): - SpamAssassinService.reload() - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/spam/update-rules', methods=['POST']) -@admin_required -def update_spam_rules(): - """Update SpamAssassin rules.""" - result = SpamAssassinService.update_rules() - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Roundcube Webmail ── - -@email_bp.route('/webmail/status', methods=['GET']) -@viewer_required -def webmail_status(): - """Get Roundcube webmail status.""" - result = RoundcubeService.get_status() - return jsonify(result), 200 - - -@email_bp.route('/webmail/install', methods=['POST']) -@admin_required -def webmail_install(): - """Install Roundcube webmail.""" - data = request.get_json() or {} - result = RoundcubeService.install( - imap_host=data.get('imap_host', 'host.docker.internal'), - smtp_host=data.get('smtp_host', 'host.docker.internal'), - domain=(data.get('domain') or '').strip() or None, - ) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/webmail/service/', methods=['POST']) -@admin_required -def webmail_control(action): - """Start/stop/restart Roundcube.""" - actions = { - 'start': RoundcubeService.start, - 'stop': RoundcubeService.stop, - 'restart': RoundcubeService.restart, - } - if action not in actions: - return jsonify({'success': False, 'error': 'Invalid action'}), 400 - result = actions[action]() - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/webmail/configure-proxy', methods=['POST']) -@admin_required -def webmail_configure_proxy(): - """Configure Nginx reverse proxy for Roundcube.""" - data = request.get_json() - if not data or not data.get('domain'): - return jsonify({'success': False, 'error': 'Domain is required'}), 400 - result = RoundcubeService.configure_nginx_proxy(data['domain']) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Mail Queue & Logs ── - -@email_bp.route('/queue', methods=['GET']) -@viewer_required -def get_queue(): - """Get Postfix mail queue.""" - result = PostfixService.get_queue() - return jsonify(result), 200 - - -@email_bp.route('/queue/flush', methods=['POST']) -@admin_required -def flush_queue(): - """Flush the mail queue.""" - result = PostfixService.flush_queue() - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/queue/', methods=['DELETE']) -@admin_required -def delete_queue_item(queue_id): - """Delete a message from the queue.""" - result = PostfixService.delete_from_queue(queue_id) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/logs', methods=['GET']) -@viewer_required -def get_logs(): - """Get mail logs.""" - lines = request.args.get('lines', 100, type=int) - result = PostfixService.get_logs(lines) - return jsonify(result), 200 diff --git a/backend/app/plugins/serverkit-email/email_service.py b/backend/app/plugins/serverkit-email/email_service.py deleted file mode 100644 index fdadf70a..00000000 --- a/backend/app/plugins/serverkit-email/email_service.py +++ /dev/null @@ -1,550 +0,0 @@ -"""Email service orchestrator — delegates to sub-services for each component.""" -import logging -import subprocess -from typing import Dict, List, Optional - -from app import db -from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule -from app.services.postfix_service import PostfixService -from app.utils.system import PackageManager, ServiceControl, run_checked -from .dovecot_service import DovecotService -from .dkim_service import DKIMService -from .spamassassin_service import SpamAssassinService -from .roundcube_service import RoundcubeService - -logger = logging.getLogger(__name__) - - -class EmailService: - """High-level email service that coordinates Postfix, Dovecot, DKIM, and SpamAssassin.""" - - COMPONENTS = { - 'postfix': PostfixService, - 'dovecot': DovecotService, - 'opendkim': DKIMService, - 'spamassassin': SpamAssassinService, - } - - # ── Status ── - - @classmethod - def get_status(cls) -> Dict: - """Get aggregate email server status.""" - postfix = PostfixService.get_status() - dovecot = DovecotService.get_status() - dkim = DKIMService.get_status() - spam = SpamAssassinService.get_status() - - all_installed = postfix['installed'] and dovecot['installed'] - # bool(): a component whose probe could not run reports running=None - # (see DovecotService.get_status), and `True and None` is None — the - # top-level flag is documented as a boolean, so keep it one. The - # honest per-component tri-state is still in the nested dicts. - all_running = bool(postfix.get('running', False) and dovecot.get('running', False)) - - return { - 'installed': all_installed, - 'running': all_running, - 'postfix': postfix, - 'dovecot': dovecot, - 'dkim': dkim, - 'spamassassin': spam, - } - - # ── Installation ── - - @classmethod - def install_all(cls, hostname: str = None) -> Dict: - """Install and configure all email components.""" - results = {} - - # 1. Install Postfix - results['postfix'] = PostfixService.install(hostname=hostname) - if not results['postfix'].get('success'): - return {'success': False, 'error': 'Postfix installation failed', 'results': results} - - # 2. Configure Postfix - postfix_config = PostfixService.configure(hostname=hostname) - if not postfix_config.get('success'): - logger.warning(f"Postfix configuration warning: {postfix_config.get('error')}") - - # 3. Install Dovecot - results['dovecot'] = DovecotService.install() - if not results['dovecot'].get('success'): - return {'success': False, 'error': 'Dovecot installation failed', 'results': results} - - # 4. Configure Dovecot - dovecot_config = DovecotService.configure() - if not dovecot_config.get('success'): - logger.warning(f"Dovecot configuration warning: {dovecot_config.get('error')}") - - # 5. Install OpenDKIM - results['dkim'] = DKIMService.install() - if not results['dkim'].get('success'): - logger.warning(f"DKIM installation warning: {results['dkim'].get('error')}") - - # 6. Install SpamAssassin - results['spamassassin'] = SpamAssassinService.install() - if not results['spamassassin'].get('success'): - logger.warning(f"SpamAssassin installation warning: {results['spamassassin'].get('error')}") - - all_ok = all(r.get('success') for r in results.values()) - return { - 'success': all_ok, - 'message': 'All email components installed' if all_ok else 'Some components had issues', - 'results': results, - } - - # ── Service Control ── - - @classmethod - def control_service(cls, component: str, action: str) -> Dict: - """Start/stop/restart an email component.""" - if component not in cls.COMPONENTS and component != 'roundcube': - return {'success': False, 'error': f'Unknown component: {component}'} - - if action not in ('start', 'stop', 'restart', 'reload'): - return {'success': False, 'error': f'Invalid action: {action}'} - - try: - if component == 'roundcube': - actions = { - 'start': RoundcubeService.start, - 'stop': RoundcubeService.stop, - 'restart': RoundcubeService.restart, - 'reload': RoundcubeService.restart, - } - return actions[action]() - - service_name = component - if action == 'start': - result = ServiceControl.start(service_name, timeout=30) - elif action == 'stop': - result = ServiceControl.stop(service_name, timeout=30) - elif action == 'restart': - result = ServiceControl.restart(service_name, timeout=30) - elif action == 'reload': - result = ServiceControl.reload(service_name, timeout=30) - - if result.returncode == 0: - return {'success': True, 'message': f'{component} {action} successful'} - return {'success': False, 'error': result.stderr or f'{action} failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - # ── Domains ── - - @classmethod - def get_domains(cls) -> List[Dict]: - """List all email domains.""" - domains = EmailDomain.query.all() - return [d.to_dict() for d in domains] - - @classmethod - def add_domain(cls, name: str, dns_provider_id: int = None, dns_zone_id: str = None) -> Dict: - """Add an email domain.""" - try: - existing = EmailDomain.query.filter_by(name=name).first() - if existing: - return {'success': False, 'error': f'Domain {name} already exists'} - - domain = EmailDomain( - name=name, - dns_provider_id=dns_provider_id, - dns_zone_id=dns_zone_id, - ) - db.session.add(domain) - db.session.commit() - - # Add to Postfix virtual domains - PostfixService.add_domain(name) - - # Generate DKIM key - dkim_result = DKIMService.generate_key(name) - if dkim_result.get('success'): - domain.dkim_selector = 'default' - domain.dkim_private_key_path = dkim_result.get('private_key_path') - domain.dkim_public_key = dkim_result.get('public_key') - domain.spf_record = f'v=spf1 mx a ~all' - domain.dmarc_record = f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{name}; pct=100' - DKIMService.add_domain(name) - db.session.commit() - - return {'success': True, 'domain': domain.to_dict(), 'message': f'Domain {name} added'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def get_domain(cls, domain_id: int) -> Dict: - """Get domain details.""" - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - return {'success': True, 'domain': domain.to_dict()} - - @classmethod - def remove_domain(cls, domain_id: int) -> Dict: - """Remove an email domain and all its accounts/aliases.""" - try: - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - domain_name = domain.name - - # Remove accounts from Dovecot/Postfix - for account in domain.accounts: - DovecotService.delete_mailbox(account.email, domain_name, account.username, remove_files=True) - PostfixService.remove_mailbox(account.email) - - # Remove aliases from Postfix - for alias in domain.aliases: - PostfixService.remove_alias(alias.source) - - # Remove from DKIM - DKIMService.remove_domain(domain_name) - - # Remove from Postfix virtual domains - PostfixService.remove_domain(domain_name) - - # Delete from database - db.session.delete(domain) - db.session.commit() - - return {'success': True, 'message': f'Domain {domain_name} removed'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def verify_dns(cls, domain_id: int) -> Dict: - """Verify DNS records for a domain.""" - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - results = {} - name = domain.name - - # Check MX record - try: - result = run_checked(['dig', '+short', 'MX', name], timeout=10) - results['mx'] = { - 'found': bool(result['output'].strip()), - 'value': result['output'].strip() or None, - } - except Exception: - results['mx'] = {'found': False, 'error': 'DNS lookup failed'} - - # Check SPF record - try: - result = run_checked(['dig', '+short', 'TXT', name], timeout=10) - spf_found = 'v=spf1' in result['output'] - results['spf'] = { - 'found': spf_found, - 'value': result['output'].strip() or None, - } - except Exception: - results['spf'] = {'found': False, 'error': 'DNS lookup failed'} - - # Check DKIM record - selector = domain.dkim_selector or 'default' - try: - dkim_name = f'{selector}._domainkey.{name}' - result = run_checked(['dig', '+short', 'TXT', dkim_name], timeout=10) - dkim_found = 'v=DKIM1' in result['output'] - results['dkim'] = { - 'found': dkim_found, - 'value': result['output'].strip() or None, - 'name': dkim_name, - } - except Exception: - results['dkim'] = {'found': False, 'error': 'DNS lookup failed'} - - # Check DMARC record - try: - dmarc_name = f'_dmarc.{name}' - result = run_checked(['dig', '+short', 'TXT', dmarc_name], timeout=10) - dmarc_found = 'v=DMARC1' in result['output'] - results['dmarc'] = { - 'found': dmarc_found, - 'value': result['output'].strip() or None, - } - except Exception: - results['dmarc'] = {'found': False, 'error': 'DNS lookup failed'} - - all_ok = all(r.get('found') for r in results.values()) - return { - 'success': True, - 'domain': name, - 'all_verified': all_ok, - 'records': results, - } - - # ── Accounts ── - - @classmethod - def get_accounts(cls, domain_id: int) -> List[Dict]: - """List email accounts for a domain.""" - accounts = EmailAccount.query.filter_by(domain_id=domain_id).all() - return [a.to_dict() for a in accounts] - - @classmethod - def add_account(cls, domain_id: int, username: str, password: str, quota_mb: int = 1024) -> Dict: - """Create an email account.""" - try: - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - email = f'{username}@{domain.name}' - - existing = EmailAccount.query.filter_by(email=email).first() - if existing: - return {'success': False, 'error': f'Account {email} already exists'} - - # Create mailbox in Dovecot - dovecot_result = DovecotService.create_mailbox(email, password, domain.name, username, quota_mb) - if not dovecot_result.get('success'): - return dovecot_result - - # Add to Postfix virtual mailboxes - PostfixService.add_mailbox(email, domain.name, username) - - # Get password hash for storage - password_hash = dovecot_result.get('password_hash', 'stored_in_dovecot') - - account = EmailAccount( - email=email, - username=username, - password_hash=password_hash, - domain_id=domain_id, - quota_mb=quota_mb, - ) - db.session.add(account) - db.session.commit() - - return {'success': True, 'account': account.to_dict(), 'message': f'Account {email} created'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def update_account(cls, account_id: int, quota_mb: int = None, is_active: bool = None) -> Dict: - """Update account settings.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - if quota_mb is not None: - account.quota_mb = quota_mb - DovecotService.set_quota(account.email, quota_mb) - - if is_active is not None: - account.is_active = is_active - - db.session.commit() - return {'success': True, 'account': account.to_dict(), 'message': 'Account updated'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def delete_account(cls, account_id: int) -> Dict: - """Delete an email account.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - email = account.email - domain_name = account.domain.name if account.domain else '' - username = account.username - - # Remove from Dovecot - DovecotService.delete_mailbox(email, domain_name, username, remove_files=True) - - # Remove from Postfix - PostfixService.remove_mailbox(email) - - db.session.delete(account) - db.session.commit() - - return {'success': True, 'message': f'Account {email} deleted'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def change_password(cls, account_id: int, new_password: str) -> Dict: - """Change an account password.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - result = DovecotService.change_password(account.email, new_password) - if not result.get('success'): - return result - - account.password_hash = 'updated_in_dovecot' - db.session.commit() - - return {'success': True, 'message': 'Password changed successfully'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - # ── Aliases ── - - @classmethod - def get_aliases(cls, domain_id: int) -> List[Dict]: - """List email aliases for a domain.""" - aliases = EmailAlias.query.filter_by(domain_id=domain_id).all() - return [a.to_dict() for a in aliases] - - @classmethod - def add_alias(cls, domain_id: int, source: str, destination: str) -> Dict: - """Create an email alias.""" - try: - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - # Add @ domain if not already qualified - if '@' not in source: - source = f'{source}@{domain.name}' - - alias = EmailAlias( - source=source, - destination=destination, - domain_id=domain_id, - ) - db.session.add(alias) - db.session.commit() - - # Add to Postfix virtual aliases - PostfixService.add_alias(source, destination) - - return {'success': True, 'alias': alias.to_dict(), 'message': f'Alias {source} created'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def remove_alias(cls, alias_id: int) -> Dict: - """Delete an email alias.""" - try: - alias = EmailAlias.query.get(alias_id) - if not alias: - return {'success': False, 'error': 'Alias not found'} - - PostfixService.remove_alias(alias.source) - - db.session.delete(alias) - db.session.commit() - - return {'success': True, 'message': 'Alias removed'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - # ── Forwarding Rules ── - - @classmethod - def get_forwarding(cls, account_id: int) -> List[Dict]: - """List forwarding rules for an account.""" - rules = EmailForwardingRule.query.filter_by(account_id=account_id).all() - return [r.to_dict() for r in rules] - - @classmethod - def add_forwarding(cls, account_id: int, destination: str, keep_copy: bool = True) -> Dict: - """Create a forwarding rule.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - rule = EmailForwardingRule( - account_id=account_id, - destination=destination, - keep_copy=keep_copy, - ) - db.session.add(rule) - db.session.commit() - - # Update Postfix aliases for forwarding - cls._sync_forwarding_aliases(account) - - return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule created'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def update_forwarding(cls, rule_id: int, destination: str = None, - keep_copy: bool = None, is_active: bool = None) -> Dict: - """Update a forwarding rule.""" - try: - rule = EmailForwardingRule.query.get(rule_id) - if not rule: - return {'success': False, 'error': 'Forwarding rule not found'} - - if destination is not None: - rule.destination = destination - if keep_copy is not None: - rule.keep_copy = keep_copy - if is_active is not None: - rule.is_active = is_active - - db.session.commit() - - # Re-sync Postfix aliases - cls._sync_forwarding_aliases(rule.account) - - return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule updated'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def remove_forwarding(cls, rule_id: int) -> Dict: - """Delete a forwarding rule.""" - try: - rule = EmailForwardingRule.query.get(rule_id) - if not rule: - return {'success': False, 'error': 'Forwarding rule not found'} - - account = rule.account - db.session.delete(rule) - db.session.commit() - - # Re-sync Postfix aliases - cls._sync_forwarding_aliases(account) - - return {'success': True, 'message': 'Forwarding rule removed'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def _sync_forwarding_aliases(cls, account: EmailAccount): - """Sync forwarding rules to Postfix virtual aliases for an account.""" - active_rules = [r for r in account.forwarding_rules if r.is_active] - - if not active_rules: - # Remove any forwarding alias - PostfixService.remove_alias(account.email) - return - - # Build destination list - destinations = [r.destination for r in active_rules] - # If any rule wants to keep a copy, include the original mailbox - if any(r.keep_copy for r in active_rules): - destinations.append(account.email) - - # Remove old alias and add new one - PostfixService.remove_alias(account.email) - PostfixService.add_alias(account.email, ', '.join(destinations)) diff --git a/backend/app/plugins/serverkit-email/plugin.json b/backend/app/plugins/serverkit-email/plugin.json deleted file mode 100644 index cae41e3e..00000000 --- a/backend/app/plugins/serverkit-email/plugin.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "serverkit-email", - "display_name": "Email Server", - "version": "1.0.0", - "description": "Full mail-server stack with Postfix/Dovecot, domains, mailboxes, aliases, DKIM/SPF/DMARC, SpamAssassin filtering, and Roundcube webmail. Most panels never run a mail server, so it ships as an opt-in extension.", - "author": "ServerKit", - "license": "MIT", - "category": "integration", - "homepage": "https://github.com/jhd3197/ServerKit", - "permissions": [ - "shell", - "filesystem", - "network" - ], - "min_panel_version": "1.7.0", - "entry_point": "email:email_bp", - "url_prefix": "/api/v1/email", - "icon": "", - "contributions": { - "nav": [ - { - "id": "email", - "label": "Email Server", - "route": "/email", - "category": "operations", - "icon": "" - } - ], - "routes": [ - { - "path": "email", - "component": "EmailPage" - }, - { - "path": "email/:tab", - "component": "EmailPage" - } - ], - "page_titles": { - "/email": "Email Server" - }, - "command_palette": [ - { - "label": "Email", - "path": "/email", - "category": "Pages", - "keywords": "smtp postfix dovecot mail imap dkim webmail" - } - ] - } -} \ No newline at end of file diff --git a/backend/app/plugins/serverkit-email/roundcube_service.py b/backend/app/plugins/serverkit-email/roundcube_service.py deleted file mode 100644 index fb0f2d6f..00000000 --- a/backend/app/plugins/serverkit-email/roundcube_service.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Roundcube webmail management service (Docker-based).""" -import subprocess -from typing import Dict - -from app.utils.system import run_checked, run_privileged, is_command_available - - -class RoundcubeService: - """Service for managing Roundcube webmail via Docker.""" - - CONTAINER_NAME = 'serverkit-roundcube' - VOLUME_NAME = 'roundcube_data' - IMAGE = 'roundcube/roundcubemail:latest' - HOST_PORT = 9000 - - @classmethod - def get_status(cls) -> Dict: - """Get Roundcube container status.""" - if not is_command_available('docker'): - return {'installed': False, 'running': False, 'error': 'Docker not available'} - - try: - result = run_checked( - ['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME], - timeout=None) - if not result['success']: - return {'installed': False, 'running': False} - - status = result['output'].strip() - return { - 'installed': True, - 'running': status == 'running', - 'status': status, - 'port': cls.HOST_PORT, - } - except Exception as e: - return {'installed': False, 'running': False, 'error': str(e)} - - @classmethod - def install(cls, imap_host: str = 'host.docker.internal', - smtp_host: str = 'host.docker.internal', - domain: str = None) -> Dict: - """Install Roundcube via Docker container.""" - if not is_command_available('docker'): - return {'success': False, 'error': 'Docker is not installed'} - - try: - # Remove existing container if any - run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None) - - # Create volume - run_checked(['docker', 'volume', 'create', cls.VOLUME_NAME], timeout=None) - - # Run container - result = run_checked([ - 'docker', 'run', '-d', - '--name', cls.CONTAINER_NAME, - '--restart', 'unless-stopped', - '--add-host', 'host.docker.internal:host-gateway', - '-p', f'{cls.HOST_PORT}:80', - '-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}', - '-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}', - '-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993', - '-e', 'ROUNDCUBEMAIL_SMTP_PORT=587', - '-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M', - '-e', 'ROUNDCUBEMAIL_SKIN=elastic', - '-v', f'{cls.VOLUME_NAME}:/var/roundcube/db', - cls.IMAGE, - ], timeout=None) - - if not result['success']: - return {'success': False, - 'error': result['error'] or 'Failed to start container'} - - # Build a public URL: prefer a supplied domain, then auto-generate - # webmail., and only fall back to localhost:port. - from app.services.site_domain_service import SiteDomainService - public_url = None - warning = None - if domain: - public_url = f'http://{domain}' - proxy_res = cls.configure_nginx_proxy(domain) - if not proxy_res.get('success'): - warning = proxy_res.get('error') - else: - panel_origin = SiteDomainService.panel_origin() - panel_host = None - if panel_origin: - from urllib.parse import urlparse - panel_host = urlparse(panel_origin).hostname - if panel_host: - webmail_domain = f'webmail.{panel_host}' - public_url = f'http://{webmail_domain}' - proxy_res = cls.configure_nginx_proxy(webmail_domain) - if not proxy_res.get('success'): - warning = proxy_res.get('error') - - if not public_url: - public_url = f'http://localhost:{cls.HOST_PORT}' - - result = { - 'success': True, - 'message': 'Roundcube installed successfully', - 'port': cls.HOST_PORT, - 'url': public_url, - } - if warning: - result['warning'] = warning - return result - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def uninstall(cls) -> Dict: - """Stop and remove Roundcube container.""" - try: - run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None) - return {'success': True, 'message': 'Roundcube uninstalled'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def start(cls) -> Dict: - """Start Roundcube container.""" - try: - result = run_checked(['docker', 'start', cls.CONTAINER_NAME], timeout=None) - if result['success']: - return {'success': True, 'message': 'Roundcube started'} - return {'success': False, 'error': result['error'] or 'Start failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def stop(cls) -> Dict: - """Stop Roundcube container.""" - try: - result = run_checked(['docker', 'stop', cls.CONTAINER_NAME], timeout=None) - if result['success']: - return {'success': True, 'message': 'Roundcube stopped'} - return {'success': False, 'error': result['error'] or 'Stop failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def restart(cls) -> Dict: - """Restart Roundcube container.""" - try: - result = run_checked(['docker', 'restart', cls.CONTAINER_NAME], timeout=None) - if result['success']: - return {'success': True, 'message': 'Roundcube restarted'} - return {'success': False, 'error': result['error'] or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def configure_nginx_proxy(cls, domain: str) -> Dict: - """Create Nginx reverse proxy config for Roundcube.""" - try: - from app.services.nginx_service import NginxService - - config = f"""# Roundcube Webmail - Managed by ServerKit -server {{ - listen 80; - server_name {domain}; - - location / {{ - proxy_pass http://127.0.0.1:{cls.HOST_PORT}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - client_max_body_size 25m; - }} -}} -""" - site_name = f'roundcube-{domain.replace(".", "-")}' - config_path = f'/etc/nginx/sites-available/{site_name}' - enabled_path = f'/etc/nginx/sites-enabled/{site_name}' - - run_privileged(['tee', config_path], input=config) - run_privileged(['ln', '-sf', config_path, enabled_path]) - - # Test and reload - test = run_privileged(['nginx', '-t']) - if test.returncode != 0: - # Rollback - run_privileged(['rm', '-f', enabled_path]) - return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'} - - run_privileged(['systemctl', 'reload', 'nginx']) - - return { - 'success': True, - 'message': f'Nginx proxy configured for {domain}', - 'url': f'http://{domain}', - } - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/backend/app/plugins/serverkit-email/spamassassin_service.py b/backend/app/plugins/serverkit-email/spamassassin_service.py deleted file mode 100644 index 85516e42..00000000 --- a/backend/app/plugins/serverkit-email/spamassassin_service.py +++ /dev/null @@ -1,254 +0,0 @@ -"""SpamAssassin management service.""" -import os -import re -import subprocess -from typing import Dict - -from app.utils.system import (PackageManager, ServiceControl, run_checked, - run_privileged) - - -class SpamAssassinService: - """Service for managing SpamAssassin spam filtering.""" - - SPAMASSASSIN_CONF = '/etc/spamassassin/local.cf' - SPAMASSASSIN_DEFAULT = '/etc/default/spamassassin' - SPAMASS_MILTER_DEFAULT = '/etc/default/spamass-milter' - - LOCAL_CF_TEMPLATE = """# SpamAssassin local configuration - Managed by ServerKit -required_score {required_score} -report_safe {report_safe} -rewrite_header Subject {rewrite_subject} -use_bayes {use_bayes} -bayes_auto_learn {bayes_auto_learn} -bayes_auto_learn_threshold_nonspam 0.1 -bayes_auto_learn_threshold_spam 12.0 -skip_rbl_checks {skip_rbl_checks} -use_razor2 0 -use_pyzor 0 - -# Network checks -dns_available yes - -# Trusted networks -trusted_networks 127.0.0.0/8 -internal_networks 127.0.0.0/8 -""" - - DEFAULT_CONFIG = { - 'required_score': 5.0, - 'report_safe': 0, - 'rewrite_subject': '[SPAM]', - 'use_bayes': 1, - 'bayes_auto_learn': 1, - 'skip_rbl_checks': 0, - } - - @classmethod - def get_status(cls) -> Dict: - """Get SpamAssassin installation and running status.""" - installed = False - running = False - enabled = False - version = None - milter_installed = False - milter_running = False - - try: - installed = PackageManager.is_installed('spamassassin') - if installed: - running = ServiceControl.is_active('spamassassin') - enabled = ServiceControl.is_enabled('spamassassin') - result = run_checked(['spamassassin', '--version'], timeout=None) - match = re.search(r'version\s+(\S+)', result['output']) - if match: - version = match.group(1) - - # Check milter - milter_installed = PackageManager.is_installed('spamass-milter') - if milter_installed: - milter_running = ServiceControl.is_active('spamass-milter') - except (subprocess.SubprocessError, FileNotFoundError): - pass - - return { - 'installed': installed, - 'running': running, - 'enabled': enabled, - 'version': version, - 'milter_installed': milter_installed, - 'milter_running': milter_running, - } - - @classmethod - def install(cls) -> Dict: - """Install SpamAssassin and spamass-milter.""" - try: - manager = PackageManager.detect() - if manager == 'apt': - packages = ['spamassassin', 'spamass-milter', 'spamc'] - else: - packages = ['spamassassin', 'spamass-milter-postfix'] - - result = PackageManager.install(packages, timeout=300) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Failed to install SpamAssassin'} - - # Enable spamd on Debian/Ubuntu - if manager == 'apt' and os.path.exists(cls.SPAMASSASSIN_DEFAULT): - result = run_privileged(['cat', cls.SPAMASSASSIN_DEFAULT]) - content = result.stdout or '' - content = re.sub(r'ENABLED=0', 'ENABLED=1', content) - content = re.sub(r'CRON=0', 'CRON=1', content) - run_privileged(['tee', cls.SPAMASSASSIN_DEFAULT], input=content) - - # Configure milter to listen on port 8893 - milter_config = 'OPTIONS="-u spamass-milter -i 127.0.0.1 -p inet:8893@localhost -- --socket=/var/run/spamassassin/spamd.sock"\n' - if os.path.exists(cls.SPAMASS_MILTER_DEFAULT): - run_privileged(['tee', cls.SPAMASS_MILTER_DEFAULT], input=milter_config) - - # Write default config - cls.configure(cls.DEFAULT_CONFIG) - - # Update rules - run_privileged(['sa-update'], timeout=120) - - ServiceControl.enable('spamassassin') - ServiceControl.start('spamassassin', timeout=30) - - if PackageManager.is_installed('spamass-milter'): - ServiceControl.enable('spamass-milter') - ServiceControl.start('spamass-milter', timeout=30) - - return {'success': True, 'message': 'SpamAssassin installed successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def configure(cls, settings: Dict = None) -> Dict: - """Write SpamAssassin configuration.""" - try: - config = dict(cls.DEFAULT_CONFIG) - if settings: - config.update(settings) - content = cls.LOCAL_CF_TEMPLATE.format(**config) - run_privileged(['tee', cls.SPAMASSASSIN_CONF], input=content) - return {'success': True, 'message': 'SpamAssassin configured'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_config(cls) -> Dict: - """Get current SpamAssassin configuration.""" - try: - if not os.path.exists(cls.SPAMASSASSIN_CONF): - return {'success': True, 'config': dict(cls.DEFAULT_CONFIG)} - - result = run_privileged(['cat', cls.SPAMASSASSIN_CONF]) - content = result.stdout or '' - - config = dict(cls.DEFAULT_CONFIG) - - # Parse values - score_match = re.search(r'required_score\s+(\S+)', content) - if score_match: - config['required_score'] = float(score_match.group(1)) - - report_match = re.search(r'report_safe\s+(\d+)', content) - if report_match: - config['report_safe'] = int(report_match.group(1)) - - subject_match = re.search(r'rewrite_header Subject\s+(.+)', content) - if subject_match: - config['rewrite_subject'] = subject_match.group(1).strip() - - bayes_match = re.search(r'use_bayes\s+(\d+)', content) - if bayes_match: - config['use_bayes'] = int(bayes_match.group(1)) - - auto_learn_match = re.search(r'bayes_auto_learn\s+(\d+)', content) - if auto_learn_match: - config['bayes_auto_learn'] = int(auto_learn_match.group(1)) - - rbl_match = re.search(r'skip_rbl_checks\s+(\d+)', content) - if rbl_match: - config['skip_rbl_checks'] = int(rbl_match.group(1)) - - return {'success': True, 'config': config} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def update_rules(cls) -> Dict: - """Update SpamAssassin rules.""" - try: - result = run_privileged(['sa-update'], timeout=120) - # sa-update returns 0 for updates, 1 for no updates, 2+ for errors - if result.returncode <= 1: - ServiceControl.restart('spamassassin', timeout=30) - return { - 'success': True, - 'message': 'Rules updated' if result.returncode == 0 else 'Rules already up to date', - 'updated': result.returncode == 0, - } - return {'success': False, 'error': result.stderr or 'Update failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def train_spam(cls, message_path: str) -> Dict: - """Train SpamAssassin with a spam message.""" - try: - result = run_privileged(['sa-learn', '--spam', message_path]) - if result.returncode == 0: - return {'success': True, 'message': 'Message learned as spam'} - return {'success': False, 'error': result.stderr or 'Training failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def train_ham(cls, message_path: str) -> Dict: - """Train SpamAssassin with a ham (non-spam) message.""" - try: - result = run_privileged(['sa-learn', '--ham', message_path]) - if result.returncode == 0: - return {'success': True, 'message': 'Message learned as ham'} - return {'success': False, 'error': result.stderr or 'Training failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_stats(cls) -> Dict: - """Get SpamAssassin Bayes statistics.""" - try: - result = run_privileged(['sa-learn', '--dump', 'magic']) - output = result.stdout or '' - stats = { - 'nspam': 0, - 'nham': 0, - 'ntokens': 0, - } - for line in output.splitlines(): - parts = line.split() - if len(parts) >= 4: - if parts[2] == 'nspam': - stats['nspam'] = int(parts[1]) - elif parts[2] == 'nham': - stats['nham'] = int(parts[1]) - elif parts[2] == 'ntokens': - stats['ntokens'] = int(parts[1]) - - return {'success': True, 'stats': stats} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def reload(cls) -> Dict: - """Reload SpamAssassin.""" - try: - result = ServiceControl.restart('spamassassin', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'SpamAssassin restarted'} - return {'success': False, 'error': result.stderr or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/backend/app/services/extension_migration.py b/backend/app/services/extension_migration.py index 81cf3455..544d90b3 100644 --- a/backend/app/services/extension_migration.py +++ b/backend/app/services/extension_migration.py @@ -107,7 +107,6 @@ def _email_was_configured(): # actually used the feature (D3/#34). Fresh installs and panels that never used # the feature just see it in the Marketplace. GATED_BUILTIN_SLUGS = { - 'serverkit-email': _email_was_configured, 'serverkit-git': _gitea_was_used, } @@ -140,6 +139,9 @@ def _scanners_dir_populated(): # unmarked so the next boot retries; panels without the host tool just see # the extension in the Marketplace. REGISTRY_GATED_SLUGS = { + # serverkit-email left the tree (plan 52 Phase 2 cutover): its gated + # auto-install rides the registry now, same predicate as before. + 'serverkit-email': _email_was_configured, 'serverkit-fail2ban': _has_command('fail2ban-client'), 'serverkit-clamav': _has_command('clamscan'), 'serverkit-lynis': _has_command('lynis'), diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5ab46065..a1f47254 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -379,6 +379,27 @@ def scoping_rbac(app): # ServerKit clone's suite must pass without the extension source. +def sibling_extension_dir(slug, env_var=None): + """A standalone extension repo's checkout dir, or None (plan 52 cutover). + + Resolution mirrors _wp_ext_tests_dir: an explicit env override first + (SERVERKIT__DIR, dashes/prefix dropped), then the default sibling + checkout next to the ServerKit workspace. Suites that exercise an + extracted extension mount it from here and skip cleanly when absent. + """ + env_name = env_var or 'SERVERKIT_{}_DIR'.format( + slug.replace('serverkit-', '').replace('-', '_').upper()) + env = os.environ.get(env_name) + candidates = [env] if env else [] + candidates.append(os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))))), slug)) + for cand in candidates: + if cand and os.path.isfile(os.path.join(cand, 'plugin.json')): + return cand + return None + + def _wp_ext_tests_dir(): """The standalone serverkit-wordpress repo's tests/ dir, or None.""" env = os.environ.get('SERVERKIT_WORDPRESS_DIR') diff --git a/backend/tests/test_email_dovecot_probe.py b/backend/tests/test_email_dovecot_probe.py index d2725404..afa44df6 100644 --- a/backend/tests/test_email_dovecot_probe.py +++ b/backend/tests/test_email_dovecot_probe.py @@ -22,8 +22,16 @@ from pathlib import Path import pytest -_DOVECOT = (Path(__file__).resolve().parent.parent - / 'app' / 'plugins' / 'serverkit-email' / 'dovecot_service.py') +from tests.conftest import sibling_extension_dir + +_EMAIL_DIR = sibling_extension_dir('serverkit-email') +_DOVECOT = (Path(_EMAIL_DIR) / 'backend' / 'dovecot_service.py' + if _EMAIL_DIR else None) + +pytestmark = pytest.mark.skipif( + _DOVECOT is None or not _DOVECOT.is_file(), + reason='serverkit-email checkout not available ' + '(set SERVERKIT_EMAIL_DIR to its checkout)') def _load(): diff --git a/backend/tests/test_email_extraction.py b/backend/tests/test_email_extraction.py index ce9ae38a..bec3ab25 100644 --- a/backend/tests/test_email_extraction.py +++ b/backend/tests/test_email_extraction.py @@ -1,7 +1,8 @@ -"""Prove the Email extraction (Phase 4, #35). +"""Prove the Email extraction (Phase 4, #35; standalone repo since plan 52 Ph2). -A core panel no longer carries the mail-server API; installing the serverkit-email -builtin registers `/api/v1/email` and its routes respond (which also exercises the +A core panel no longer carries the mail-server API; installing serverkit-email +(now a standalone repo — mounted from the sibling checkout, skip when absent) +registers `/api/v1/email` and its routes respond (which also exercises the extension's relative-import rewiring). Uninstall removes it again. """ import sys @@ -10,6 +11,7 @@ import pytest import app as app_pkg from app.models.plugin import InstalledPlugin +from tests.conftest import sibling_extension_dir from app.services import plugin_service # Installs plugins, and plugin_service hot-loads their blueprints onto the @@ -62,12 +64,27 @@ def install_dirs(tmp_path, monkeypatch): del sys.modules[name] -def test_install_email_extension_registers_routes(app, client, auth_headers, install_dirs): - # The real builtin folder is discovered from the repo's builtin-extensions/. - available = {e['slug'] for e in plugin_service.list_builtin_extensions()} - assert SLUG in available, 'serverkit-email builtin folder should exist' +def _install_email_from_sibling(monkeypatch): + src = sibling_extension_dir(SLUG) + if not src: + pytest.skip('serverkit-email checkout not available ' + '(set SERVERKIT_EMAIL_DIR to its checkout)') + # The sibling repo pins min_panel_version to the first SDK-1.4.0 release; + # the working tree may still carry the previous version string. + from app.utils import version as version_mod + monkeypatch.setattr(version_mod, 'get_panel_version', lambda: '1.9.11') + monkeypatch.setattr(plugin_service, 'get_panel_version', + lambda: '1.9.11', raising=False) + return plugin_service.install_from_path(src, force=True) - plugin = plugin_service.install_builtin_extension('serverkit-email') + +def test_email_is_no_longer_a_builtin(app): + available = {e['slug'] for e in plugin_service.list_builtin_extensions()} + assert SLUG not in available, 'serverkit-email left the tree (plan 52 Ph2)' + + +def test_install_email_extension_registers_routes(app, client, auth_headers, install_dirs, monkeypatch): + plugin = _install_email_from_sibling(monkeypatch) assert plugin.status == InstalledPlugin.STATUS_ACTIVE assert plugin.has_backend is True assert plugin.url_prefix == '/api/v1/email' @@ -79,7 +96,7 @@ def test_install_email_extension_registers_routes(app, client, auth_headers, ins assert resp.status_code not in (404, 503), resp.status_code -def test_uninstall_removes_email_plugin(app, install_dirs): - plugin = plugin_service.install_builtin_extension('serverkit-email') +def test_uninstall_removes_email_plugin(app, install_dirs, monkeypatch): + plugin = _install_email_from_sibling(monkeypatch) assert plugin_service.uninstall_plugin(plugin.id) is True assert InstalledPlugin.query.filter_by(slug=SLUG).first() is None diff --git a/builtin-extensions/serverkit-email/backend/__init__.py b/builtin-extensions/serverkit-email/backend/__init__.py deleted file mode 100644 index 51e67b6d..00000000 --- a/builtin-extensions/serverkit-email/backend/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ServerKit Email extension backend package.""" diff --git a/builtin-extensions/serverkit-email/backend/dkim_service.py b/builtin-extensions/serverkit-email/backend/dkim_service.py deleted file mode 100644 index e79ca0d8..00000000 --- a/builtin-extensions/serverkit-email/backend/dkim_service.py +++ /dev/null @@ -1,271 +0,0 @@ -"""OpenDKIM management service for DKIM signing.""" -import os -import re -import subprocess -from typing import Dict - -from app.utils.system import (PackageManager, ServiceControl, run_checked, - run_privileged) - - -class DKIMService: - """Service for managing OpenDKIM (DKIM email signing).""" - - OPENDKIM_CONF = '/etc/opendkim.conf' - OPENDKIM_DIR = '/etc/opendkim' - OPENDKIM_KEYS_DIR = '/etc/opendkim/keys' - KEY_TABLE = '/etc/opendkim/KeyTable' - SIGNING_TABLE = '/etc/opendkim/SigningTable' - TRUSTED_HOSTS = '/etc/opendkim/TrustedHosts' - - OPENDKIM_CONF_CONTENT = """# OpenDKIM configuration - Managed by ServerKit -Syslog yes -SyslogSuccess yes -LogWhy yes -Canonicalization relaxed/simple -Mode sv -SubDomains no -AutoRestart yes -AutoRestartRate 10/1M -Background yes -DNSTimeout 5 -SignatureAlgorithm rsa-sha256 -KeyTable refile:/etc/opendkim/KeyTable -SigningTable refile:/etc/opendkim/SigningTable -ExternalIgnoreList /etc/opendkim/TrustedHosts -InternalHosts /etc/opendkim/TrustedHosts -Socket inet:8891@localhost -PidFile /run/opendkim/opendkim.pid -OversignHeaders From -UserID opendkim -UMask 007 -""" - - TRUSTED_HOSTS_DEFAULT = """# Trusted hosts - Managed by ServerKit -127.0.0.1 -::1 -localhost -""" - - @classmethod - def get_status(cls) -> Dict: - """Get OpenDKIM installation and running status.""" - installed = False - running = False - enabled = False - version = None - try: - result = run_checked(['which', 'opendkim'], timeout=None) - installed = result['success'] - if not installed: - installed = PackageManager.is_installed('opendkim') - if installed: - running = ServiceControl.is_active('opendkim') - enabled = ServiceControl.is_enabled('opendkim') - result = run_checked(['opendkim', '-V'], timeout=None, merge_stderr=True) - match = re.search(r'OpenDKIM\s+Filter\s+v(\S+)', result['output']) - if match: - version = match.group(1) - except (subprocess.SubprocessError, FileNotFoundError): - pass - - return { - 'installed': installed, - 'running': running, - 'enabled': enabled, - 'version': version, - } - - @classmethod - def install(cls) -> Dict: - """Install OpenDKIM.""" - try: - result = PackageManager.install(['opendkim', 'opendkim-tools'], timeout=300) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Failed to install OpenDKIM'} - - # Create directories - run_privileged(['mkdir', '-p', cls.OPENDKIM_KEYS_DIR]) - run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR]) - - # Create config files - run_privileged(['tee', cls.OPENDKIM_CONF], input=cls.OPENDKIM_CONF_CONTENT) - run_privileged(['tee', cls.TRUSTED_HOSTS], input=cls.TRUSTED_HOSTS_DEFAULT) - run_privileged(['touch', cls.KEY_TABLE]) - run_privileged(['touch', cls.SIGNING_TABLE]) - - # Set permissions - run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR]) - run_privileged(['chmod', '700', cls.OPENDKIM_KEYS_DIR]) - - # Create PID directory - run_privileged(['mkdir', '-p', '/run/opendkim']) - run_privileged(['chown', 'opendkim:opendkim', '/run/opendkim']) - - # Add postfix to opendkim group - run_privileged(['usermod', '-aG', 'opendkim', 'postfix']) - - ServiceControl.enable('opendkim') - ServiceControl.start('opendkim', timeout=30) - - return {'success': True, 'message': 'OpenDKIM installed successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def generate_key(cls, domain: str, selector: str = 'default') -> Dict: - """Generate DKIM key pair for a domain.""" - try: - key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain) - run_privileged(['mkdir', '-p', key_dir]) - - # Generate key - result = run_privileged([ - 'opendkim-genkey', - '-s', selector, - '-d', domain, - '-D', key_dir, - '-b', '2048', - ]) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Key generation failed'} - - # Set permissions - run_privileged(['chown', '-R', 'opendkim:opendkim', key_dir]) - run_privileged(['chmod', '600', os.path.join(key_dir, f'{selector}.private')]) - - # Read the public key TXT record - txt_file = os.path.join(key_dir, f'{selector}.txt') - result = run_privileged(['cat', txt_file]) - public_key_record = result.stdout.strip() if result.returncode == 0 else '' - - # Extract just the key value from the TXT record - key_match = re.search(r'p=([A-Za-z0-9+/=\s]+)', public_key_record) - public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') if key_match else '' - - return { - 'success': True, - 'domain': domain, - 'selector': selector, - 'private_key_path': os.path.join(key_dir, f'{selector}.private'), - 'public_key': public_key, - 'dns_record': public_key_record, - 'dns_name': f'{selector}._domainkey.{domain}', - 'dns_value': f'v=DKIM1; k=rsa; p={public_key}', - } - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def add_domain(cls, domain: str, selector: str = 'default') -> Dict: - """Add domain to KeyTable and SigningTable.""" - try: - key_path = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.private') - - # Add to KeyTable - key_entry = f'{selector}._domainkey.{domain} {domain}:{selector}:{key_path}\n' - result = run_privileged(['cat', cls.KEY_TABLE]) - if domain not in (result.stdout or ''): - run_privileged(['tee', '-a', cls.KEY_TABLE], input=key_entry) - - # Add to SigningTable - signing_entry = f'*@{domain} {selector}._domainkey.{domain}\n' - result = run_privileged(['cat', cls.SIGNING_TABLE]) - if domain not in (result.stdout or ''): - run_privileged(['tee', '-a', cls.SIGNING_TABLE], input=signing_entry) - - # Add to TrustedHosts - result = run_privileged(['cat', cls.TRUSTED_HOSTS]) - if domain not in (result.stdout or ''): - run_privileged(['tee', '-a', cls.TRUSTED_HOSTS], input=f'*.{domain}\n') - - # Reload OpenDKIM - ServiceControl.restart('opendkim', timeout=30) - - return {'success': True, 'message': f'Domain {domain} added to DKIM'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def remove_domain(cls, domain: str) -> Dict: - """Remove domain from DKIM configuration.""" - try: - # Remove from KeyTable - result = run_privileged(['cat', cls.KEY_TABLE]) - lines = [l for l in (result.stdout or '').splitlines() if domain not in l] - run_privileged(['tee', cls.KEY_TABLE], input='\n'.join(lines) + '\n') - - # Remove from SigningTable - result = run_privileged(['cat', cls.SIGNING_TABLE]) - lines = [l for l in (result.stdout or '').splitlines() if domain not in l] - run_privileged(['tee', cls.SIGNING_TABLE], input='\n'.join(lines) + '\n') - - # Remove from TrustedHosts - result = run_privileged(['cat', cls.TRUSTED_HOSTS]) - lines = [l for l in (result.stdout or '').splitlines() if domain not in l] - run_privileged(['tee', cls.TRUSTED_HOSTS], input='\n'.join(lines) + '\n') - - # Remove key files - key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain) - run_privileged(['rm', '-rf', key_dir]) - - ServiceControl.restart('opendkim', timeout=30) - - return {'success': True, 'message': f'Domain {domain} removed from DKIM'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_dns_record(cls, domain: str, selector: str = 'default') -> Dict: - """Get the DKIM DNS TXT record content for a domain.""" - try: - txt_file = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.txt') - result = run_privileged(['cat', txt_file]) - if result.returncode != 0: - return {'success': False, 'error': 'DKIM key not found'} - - record = result.stdout.strip() - key_match = re.search(r'p=([A-Za-z0-9+/=\s"]+)', record) - public_key = '' - if key_match: - public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') - - return { - 'success': True, - 'dns_name': f'{selector}._domainkey.{domain}', - 'dns_value': f'v=DKIM1; k=rsa; p={public_key}', - 'raw_record': record, - } - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def verify_key(cls, domain: str, selector: str = 'default') -> Dict: - """Verify DKIM key configuration.""" - try: - result = run_privileged([ - 'opendkim-testkey', - '-d', domain, - '-s', selector, - '-vvv', - ]) - success = result.returncode == 0 - output = (result.stdout or '') + (result.stderr or '') - return { - 'success': success, - 'verified': success and 'key OK' in output, - 'output': output, - } - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def reload(cls) -> Dict: - """Reload OpenDKIM.""" - try: - result = ServiceControl.restart('opendkim', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'OpenDKIM restarted'} - return {'success': False, 'error': result.stderr or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/builtin-extensions/serverkit-email/backend/dovecot_service.py b/builtin-extensions/serverkit-email/backend/dovecot_service.py deleted file mode 100644 index b108a123..00000000 --- a/builtin-extensions/serverkit-email/backend/dovecot_service.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Dovecot IMAP/POP3 server management service.""" -import os -import re -import subprocess -from typing import Dict, Optional - -from app.utils.system import (PackageManager, ServiceControl, is_command_available, - run_privileged) -from app import paths - - -class DovecotService: - """Service for managing Dovecot (IMAP/POP3 server).""" - - DOVECOT_CONF_DIR = '/etc/dovecot' - DOVECOT_CONF = '/etc/dovecot/dovecot.conf' - DOVECOT_AUTH_CONF = '/etc/dovecot/conf.d/10-auth.conf' - DOVECOT_MAIL_CONF = '/etc/dovecot/conf.d/10-mail.conf' - DOVECOT_MASTER_CONF = '/etc/dovecot/conf.d/10-master.conf' - DOVECOT_SSL_CONF = '/etc/dovecot/conf.d/10-ssl.conf' - DOVECOT_PASSWD_FILE = '/etc/dovecot/users' - AUTH_PASSWDFILE_CONF = '/etc/dovecot/conf.d/auth-passwdfile.conf.ext' - - MAIL_CONF_CONTENT = """# Dovecot mail configuration - Managed by ServerKit -mail_location = maildir:{vmail_dir}/%d/%n/Maildir -namespace inbox {{ - inbox = yes - separator = / -}} -mail_uid = {vmail_uid} -mail_gid = {vmail_gid} -mail_privileged_group = vmail -first_valid_uid = {vmail_uid} -last_valid_uid = {vmail_uid} -""" - - AUTH_CONF_CONTENT = """# Dovecot auth configuration - Managed by ServerKit -disable_plaintext_auth = yes -auth_mechanisms = plain login -!include auth-passwdfile.conf.ext -""" - - AUTH_PASSWDFILE_CONTENT = """# Password file auth - Managed by ServerKit -passdb {{ - driver = passwd-file - args = scheme=SHA512-CRYPT /etc/dovecot/users -}} -userdb {{ - driver = static - args = uid={vmail_uid} gid={vmail_gid} home={vmail_dir}/%d/%n -}} -""" - - MASTER_CONF_CONTENT = """# Dovecot master configuration - Managed by ServerKit -service imap-login { - inet_listener imap { - port = 0 - } - inet_listener imaps { - port = 993 - ssl = yes - } -} -service pop3-login { - inet_listener pop3 { - port = 0 - } - inet_listener pop3s { - port = 995 - ssl = yes - } -} -service lmtp { - unix_listener /var/spool/postfix/private/dovecot-lmtp { - mode = 0600 - user = postfix - group = postfix - } -} -service auth { - unix_listener /var/spool/postfix/private/auth { - mode = 0666 - user = postfix - group = postfix - } - unix_listener auth-userdb { - mode = 0600 - user = vmail - group = vmail - } - user = dovecot -} -service auth-worker { - user = vmail -} -""" - - SSL_CONF_CONTENT = """# Dovecot SSL configuration - Managed by ServerKit -ssl = required -ssl_cert = <{tls_cert} -ssl_key = <{tls_key} -ssl_min_protocol = TLSv1.2 -ssl_prefer_server_ciphers = yes -""" - - @classmethod - def get_status(cls) -> Dict: - """Get Dovecot installation and running status.""" - installed = False - running = False - enabled = False - version = None - # `which` searches $PATH only, and dovecot lives in /usr/sbin — absent - # from the panel unit's PATH — so it reported a false negative on a host - # where dovecot was installed. is_command_available() also searches the - # sbin dirs. - try: - installed = is_command_available('dovecot') - if not installed: - installed = PackageManager.is_installed('dovecot-core') or PackageManager.is_installed('dovecot') - except Exception: - installed = False - - if installed: - try: - running = ServiceControl.is_active('dovecot') - enabled = ServiceControl.is_enabled('dovecot') - except (subprocess.SubprocessError, FileNotFoundError): - # No systemctl (container, non-systemd init), or systemd not - # answering inside PROBE_TIMEOUT: "could not check", not "not - # running" — and never a 500. Wider than FileNotFoundError on - # purpose: is_enabled already swallows the missing binary, but - # the probe timeout in both wrappers still raises - # TimeoutExpired straight through get_status and out of - # /api/v1/email/status. The sibling services in this extension - # catch the same pair. - running = None - enabled = None - # Its own try: failing to read the version says nothing about - # whether dovecot is installed. Collapsing the two is how - # FirewallService._check_ufw reported a working firewall as absent. - try: - result = run_privileged(['dovecot', '--version']) - version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout or '') - if version_match: - version = version_match.group(1) - except Exception: - pass - - return { - 'installed': installed, - 'running': running, - 'enabled': enabled, - 'version': version, - } - - @classmethod - def install(cls) -> Dict: - """Install Dovecot packages.""" - try: - manager = PackageManager.detect() - if manager == 'apt': - packages = ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'dovecot-lmtpd', 'dovecot-sieve'] - else: - packages = ['dovecot'] - - result = PackageManager.install(packages, timeout=300) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Failed to install Dovecot'} - - # Create empty passwd file - run_privileged(['touch', cls.DOVECOT_PASSWD_FILE]) - run_privileged(['chown', 'vmail:dovecot', cls.DOVECOT_PASSWD_FILE]) - run_privileged(['chmod', '640', cls.DOVECOT_PASSWD_FILE]) - - ServiceControl.enable('dovecot') - - return {'success': True, 'message': 'Dovecot installed successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def configure(cls, tls_cert: str = None, tls_key: str = None) -> Dict: - """Write Dovecot configuration files.""" - try: - cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem' - key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key' - - # 10-mail.conf - mail_conf = cls.MAIL_CONF_CONTENT.format( - vmail_dir=paths.VMAIL_DIR, - vmail_uid=paths.VMAIL_UID, - vmail_gid=paths.VMAIL_GID, - ) - run_privileged(['tee', cls.DOVECOT_MAIL_CONF], input=mail_conf) - - # 10-auth.conf - run_privileged(['tee', cls.DOVECOT_AUTH_CONF], input=cls.AUTH_CONF_CONTENT) - - # auth-passwdfile.conf.ext - auth_passwd = cls.AUTH_PASSWDFILE_CONTENT.format( - vmail_uid=paths.VMAIL_UID, - vmail_gid=paths.VMAIL_GID, - vmail_dir=paths.VMAIL_DIR, - ) - run_privileged(['tee', cls.AUTH_PASSWDFILE_CONF], input=auth_passwd) - - # 10-master.conf - run_privileged(['tee', cls.DOVECOT_MASTER_CONF], input=cls.MASTER_CONF_CONTENT) - - # 10-ssl.conf - ssl_conf = cls.SSL_CONF_CONTENT.format(tls_cert=cert, tls_key=key) - run_privileged(['tee', cls.DOVECOT_SSL_CONF], input=ssl_conf) - - # Restart to apply - ServiceControl.restart('dovecot', timeout=30) - - return {'success': True, 'message': 'Dovecot configured successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def create_mailbox(cls, email: str, password: str, domain: str, username: str, quota_mb: int = 1024) -> Dict: - """Create a virtual mailbox with password entry.""" - try: - # Generate password hash using doveadm - result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password]) - if result.returncode != 0: - return {'success': False, 'error': 'Failed to hash password'} - - password_hash = result.stdout.strip() - - # Build passwd-file entry - # Format: user@domain:{scheme}hash:uid:gid:home::userdb_quota_rule=*:storage=NM - entry = f'{email}:{password_hash}:{paths.VMAIL_UID}:{paths.VMAIL_GID}:{paths.VMAIL_DIR}/{domain}/{username}::userdb_quota_rule=*:storage={quota_mb}M' - - # Append to passwd file - run_privileged(['tee', '-a', cls.DOVECOT_PASSWD_FILE], input=entry + '\n') - - # Create Maildir - maildir = os.path.join(paths.VMAIL_DIR, domain, username, 'Maildir') - run_privileged(['mkdir', '-p', f'{maildir}/cur', f'{maildir}/new', f'{maildir}/tmp']) - run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}', - os.path.join(paths.VMAIL_DIR, domain, username)]) - - return {'success': True, 'message': f'Mailbox {email} created'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def delete_mailbox(cls, email: str, domain: str, username: str, remove_files: bool = False) -> Dict: - """Delete a virtual mailbox.""" - try: - # Remove from passwd file - result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) - lines = (result.stdout or '').splitlines() - new_lines = [l for l in lines if not l.startswith(f'{email}:')] - run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') - - # Optionally remove Maildir - if remove_files: - mailbox_path = os.path.join(paths.VMAIL_DIR, domain, username) - run_privileged(['rm', '-rf', mailbox_path]) - - return {'success': True, 'message': f'Mailbox {email} deleted'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def change_password(cls, email: str, new_password: str) -> Dict: - """Change a mailbox password.""" - try: - # Generate new hash - result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password]) - if result.returncode != 0: - return {'success': False, 'error': 'Failed to hash password'} - - new_hash = result.stdout.strip() - - # Read current file - result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) - lines = (result.stdout or '').splitlines() - - updated = False - new_lines = [] - for line in lines: - if line.startswith(f'{email}:'): - parts = line.split(':') - parts[1] = new_hash - new_lines.append(':'.join(parts)) - updated = True - else: - new_lines.append(line) - - if not updated: - return {'success': False, 'error': f'Account {email} not found'} - - run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') - - return {'success': True, 'message': 'Password changed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def set_quota(cls, email: str, quota_mb: int) -> Dict: - """Update mailbox quota.""" - try: - result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE]) - lines = (result.stdout or '').splitlines() - - updated = False - new_lines = [] - for line in lines: - if line.startswith(f'{email}:'): - # Replace quota in the userdb_quota_rule field - line = re.sub(r'userdb_quota_rule=\*:storage=\d+M', - f'userdb_quota_rule=*:storage={quota_mb}M', line) - new_lines.append(line) - updated = True - else: - new_lines.append(line) - - if not updated: - return {'success': False, 'error': f'Account {email} not found'} - - run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n') - - return {'success': True, 'message': f'Quota set to {quota_mb}MB'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_quota_usage(cls, email: str) -> Dict: - """Get mailbox quota usage.""" - try: - result = run_privileged(['doveadm', 'quota', 'get', '-u', email]) - if result.returncode != 0: - return {'success': False, 'error': 'Failed to get quota'} - - # Parse output - usage = {'storage_used': 0, 'storage_limit': 0, 'message_count': 0} - for line in result.stdout.splitlines(): - parts = line.split() - if len(parts) >= 4 and parts[0] == 'STORAGE': - usage['storage_used'] = int(parts[1]) // 1024 # KB to MB - usage['storage_limit'] = int(parts[2]) // 1024 if parts[2] != '-' else 0 - elif len(parts) >= 4 and parts[0] == 'MESSAGE': - usage['message_count'] = int(parts[1]) - - return {'success': True, **usage} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def reload(cls) -> Dict: - """Reload Dovecot configuration.""" - try: - result = ServiceControl.reload('dovecot', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'Dovecot reloaded'} - return {'success': False, 'error': result.stderr or 'Reload failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def restart(cls) -> Dict: - """Restart Dovecot.""" - try: - result = ServiceControl.restart('dovecot', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'Dovecot restarted'} - return {'success': False, 'error': result.stderr or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/builtin-extensions/serverkit-email/backend/email.py b/builtin-extensions/serverkit-email/backend/email.py deleted file mode 100644 index 45afdf28..00000000 --- a/builtin-extensions/serverkit-email/backend/email.py +++ /dev/null @@ -1,426 +0,0 @@ -"""Email Server API endpoints for managing mail services, domains, accounts, and DNS. - -Lives in the serverkit-email extension (app.plugins.serverkit-email.email after -install). Intra-extension modules are imported relatively; core services -(postfix relay, DNS, relay-config, RBAC) are imported absolutely — the mail -server extends the core, never the other way around. -""" -from flask import Blueprint, request, jsonify - -from app.middleware.rbac import admin_required, viewer_required -from app.services.dns_provider_service import DNSProviderService -from app.services.postfix_service import PostfixService -from app.services.email_relay_service import EmailRelayService -from .email_service import EmailService -from .spamassassin_service import SpamAssassinService -from .roundcube_service import RoundcubeService - -email_bp = Blueprint('email', __name__) - - -# ── Status & Installation ── - -@email_bp.route('/status', methods=['GET']) -@viewer_required -def get_status(): - """Get aggregate email server status.""" - roundcube = RoundcubeService.get_status() - status = EmailService.get_status() - status['roundcube'] = roundcube - return jsonify(status), 200 - - -@email_bp.route('/install', methods=['POST']) -@admin_required -def install(): - """Install and configure all email components.""" - data = request.get_json() or {} - hostname = data.get('hostname') - result = EmailService.install_all(hostname) - return jsonify(result), 200 if result.get('success') else 500 - - -@email_bp.route('/service//', methods=['POST']) -@admin_required -def control_service(component, action): - """Start/stop/restart an email component.""" - result = EmailService.control_service(component, action) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Domains ── - -@email_bp.route('/domains', methods=['GET']) -@viewer_required -def list_domains(): - """List all email domains.""" - domains = EmailService.get_domains() - return jsonify({'domains': domains}), 200 - - -@email_bp.route('/domains', methods=['POST']) -@admin_required -def add_domain(): - """Add an email domain.""" - data = request.get_json() - if not data or not data.get('name'): - return jsonify({'success': False, 'error': 'Domain name is required'}), 400 - result = EmailService.add_domain( - data['name'], - dns_provider_id=data.get('dns_provider_id'), - dns_zone_id=data.get('dns_zone_id'), - ) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/domains/', methods=['GET']) -@viewer_required -def get_domain(domain_id): - """Get domain details.""" - result = EmailService.get_domain(domain_id) - if result.get('success'): - return jsonify(result), 200 - return jsonify(result), 404 - - -@email_bp.route('/domains/', methods=['DELETE']) -@admin_required -def remove_domain(domain_id): - """Remove an email domain.""" - result = EmailService.remove_domain(domain_id) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/domains//verify-dns', methods=['POST']) -@viewer_required -def verify_dns(domain_id): - """Verify DNS records for a domain.""" - result = EmailService.verify_dns(domain_id) - return jsonify(result), 200 - - -@email_bp.route('/domains//deploy-dns', methods=['POST']) -@admin_required -def deploy_dns(domain_id): - """Deploy DNS records via provider API.""" - from app.models.email import EmailDomain - domain = EmailDomain.query.get(domain_id) - if not domain: - return jsonify({'success': False, 'error': 'Domain not found'}), 404 - if not domain.dns_provider_id or not domain.dns_zone_id: - return jsonify({'success': False, 'error': 'No DNS provider configured for this domain'}), 400 - result = DNSProviderService.deploy_email_records( - domain.dns_provider_id, - domain.dns_zone_id, - domain.name, - domain.dkim_selector or 'default', - domain.dkim_public_key or '', - ) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Accounts ── - -@email_bp.route('/domains//accounts', methods=['GET']) -@viewer_required -def list_accounts(domain_id): - """List email accounts for a domain.""" - accounts = EmailService.get_accounts(domain_id) - return jsonify({'accounts': accounts}), 200 - - -@email_bp.route('/domains//accounts', methods=['POST']) -@admin_required -def create_account(domain_id): - """Create an email account.""" - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'Request body required'}), 400 - username = data.get('username') - password = data.get('password') - if not username or not password: - return jsonify({'success': False, 'error': 'Username and password are required'}), 400 - result = EmailService.add_account( - domain_id, - username, - password, - quota_mb=data.get('quota_mb', 1024), - ) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/accounts/', methods=['GET']) -@viewer_required -def get_account(account_id): - """Get account details.""" - from app.models.email import EmailAccount - account = EmailAccount.query.get(account_id) - if not account: - return jsonify({'success': False, 'error': 'Account not found'}), 404 - return jsonify({'success': True, 'account': account.to_dict()}), 200 - - -@email_bp.route('/accounts/', methods=['PUT']) -@admin_required -def update_account(account_id): - """Update account settings.""" - data = request.get_json() or {} - result = EmailService.update_account( - account_id, - quota_mb=data.get('quota_mb'), - is_active=data.get('is_active'), - ) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/accounts/', methods=['DELETE']) -@admin_required -def delete_account(account_id): - """Delete an email account.""" - result = EmailService.delete_account(account_id) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/accounts//password', methods=['POST']) -@admin_required -def change_password(account_id): - """Change account password.""" - data = request.get_json() - if not data or not data.get('password'): - return jsonify({'success': False, 'error': 'New password is required'}), 400 - result = EmailService.change_password(account_id, data['password']) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Aliases ── - -@email_bp.route('/domains//aliases', methods=['GET']) -@viewer_required -def list_aliases(domain_id): - """List email aliases for a domain.""" - aliases = EmailService.get_aliases(domain_id) - return jsonify({'aliases': aliases}), 200 - - -@email_bp.route('/domains//aliases', methods=['POST']) -@admin_required -def create_alias(domain_id): - """Create an email alias.""" - data = request.get_json() - if not data or not data.get('source') or not data.get('destination'): - return jsonify({'success': False, 'error': 'Source and destination are required'}), 400 - result = EmailService.add_alias(domain_id, data['source'], data['destination']) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/aliases/', methods=['DELETE']) -@admin_required -def delete_alias(alias_id): - """Delete an email alias.""" - result = EmailService.remove_alias(alias_id) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Forwarding Rules ── - -@email_bp.route('/accounts//forwarding', methods=['GET']) -@viewer_required -def list_forwarding(account_id): - """List forwarding rules for an account.""" - rules = EmailService.get_forwarding(account_id) - return jsonify({'rules': rules}), 200 - - -@email_bp.route('/accounts//forwarding', methods=['POST']) -@admin_required -def create_forwarding(account_id): - """Create a forwarding rule.""" - data = request.get_json() - if not data or not data.get('destination'): - return jsonify({'success': False, 'error': 'Destination is required'}), 400 - result = EmailService.add_forwarding( - account_id, - data['destination'], - keep_copy=data.get('keep_copy', True), - ) - return jsonify(result), 201 if result.get('success') else 400 - - -@email_bp.route('/forwarding/', methods=['PUT']) -@admin_required -def update_forwarding(rule_id): - """Update a forwarding rule.""" - data = request.get_json() or {} - result = EmailService.update_forwarding( - rule_id, - destination=data.get('destination'), - keep_copy=data.get('keep_copy'), - is_active=data.get('is_active'), - ) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/forwarding/', methods=['DELETE']) -@admin_required -def delete_forwarding(rule_id): - """Delete a forwarding rule.""" - result = EmailService.remove_forwarding(rule_id) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── DNS Providers ── -# Moved to core (app/api/dns_providers.py, same /api/v1/email/dns-providers -# paths): the Connections page and wildcard TLS need them without this -# extension installed. - - -# ── Outbound SMTP Relay (smarthost) ── - -@email_bp.route('/relay', methods=['GET']) -@viewer_required -def get_relay(): - """Get the outbound SMTP relay configuration (password masked).""" - return jsonify(EmailRelayService.get_config()), 200 - - -@email_bp.route('/relay', methods=['PUT']) -@admin_required -def save_relay(): - """Save the relay configuration and apply it to Postfix (if installed).""" - data = request.get_json() or {} - if not data.get('host'): - return jsonify({'success': False, 'error': 'A relay host is required'}), 400 - result = EmailRelayService.save_config(data) - return jsonify(result), 200 - - -@email_bp.route('/relay/test', methods=['POST']) -@admin_required -def test_relay(): - """Open a real SMTP connection to validate the relay credentials.""" - data = request.get_json() or {} - result = EmailRelayService.test(data) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/relay', methods=['DELETE']) -@admin_required -def disable_relay(): - """Disable the outbound relay (revert Postfix to direct delivery).""" - result = EmailRelayService.disable() - return jsonify(result), 200 - - -# ── SpamAssassin Config ── - -@email_bp.route('/spam/config', methods=['GET']) -@viewer_required -def get_spam_config(): - """Get SpamAssassin configuration.""" - result = SpamAssassinService.get_config() - return jsonify(result), 200 - - -@email_bp.route('/spam/config', methods=['PUT']) -@admin_required -def update_spam_config(): - """Update SpamAssassin configuration.""" - data = request.get_json() - if not data: - return jsonify({'success': False, 'error': 'Request body required'}), 400 - result = SpamAssassinService.configure(data) - if result.get('success'): - SpamAssassinService.reload() - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/spam/update-rules', methods=['POST']) -@admin_required -def update_spam_rules(): - """Update SpamAssassin rules.""" - result = SpamAssassinService.update_rules() - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Roundcube Webmail ── - -@email_bp.route('/webmail/status', methods=['GET']) -@viewer_required -def webmail_status(): - """Get Roundcube webmail status.""" - result = RoundcubeService.get_status() - return jsonify(result), 200 - - -@email_bp.route('/webmail/install', methods=['POST']) -@admin_required -def webmail_install(): - """Install Roundcube webmail.""" - data = request.get_json() or {} - result = RoundcubeService.install( - imap_host=data.get('imap_host', 'host.docker.internal'), - smtp_host=data.get('smtp_host', 'host.docker.internal'), - domain=(data.get('domain') or '').strip() or None, - ) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/webmail/service/', methods=['POST']) -@admin_required -def webmail_control(action): - """Start/stop/restart Roundcube.""" - actions = { - 'start': RoundcubeService.start, - 'stop': RoundcubeService.stop, - 'restart': RoundcubeService.restart, - } - if action not in actions: - return jsonify({'success': False, 'error': 'Invalid action'}), 400 - result = actions[action]() - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/webmail/configure-proxy', methods=['POST']) -@admin_required -def webmail_configure_proxy(): - """Configure Nginx reverse proxy for Roundcube.""" - data = request.get_json() - if not data or not data.get('domain'): - return jsonify({'success': False, 'error': 'Domain is required'}), 400 - result = RoundcubeService.configure_nginx_proxy(data['domain']) - return jsonify(result), 200 if result.get('success') else 400 - - -# ── Mail Queue & Logs ── - -@email_bp.route('/queue', methods=['GET']) -@viewer_required -def get_queue(): - """Get Postfix mail queue.""" - result = PostfixService.get_queue() - return jsonify(result), 200 - - -@email_bp.route('/queue/flush', methods=['POST']) -@admin_required -def flush_queue(): - """Flush the mail queue.""" - result = PostfixService.flush_queue() - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/queue/', methods=['DELETE']) -@admin_required -def delete_queue_item(queue_id): - """Delete a message from the queue.""" - result = PostfixService.delete_from_queue(queue_id) - return jsonify(result), 200 if result.get('success') else 400 - - -@email_bp.route('/logs', methods=['GET']) -@viewer_required -def get_logs(): - """Get mail logs.""" - lines = request.args.get('lines', 100, type=int) - result = PostfixService.get_logs(lines) - return jsonify(result), 200 diff --git a/builtin-extensions/serverkit-email/backend/email_service.py b/builtin-extensions/serverkit-email/backend/email_service.py deleted file mode 100644 index fdadf70a..00000000 --- a/builtin-extensions/serverkit-email/backend/email_service.py +++ /dev/null @@ -1,550 +0,0 @@ -"""Email service orchestrator — delegates to sub-services for each component.""" -import logging -import subprocess -from typing import Dict, List, Optional - -from app import db -from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule -from app.services.postfix_service import PostfixService -from app.utils.system import PackageManager, ServiceControl, run_checked -from .dovecot_service import DovecotService -from .dkim_service import DKIMService -from .spamassassin_service import SpamAssassinService -from .roundcube_service import RoundcubeService - -logger = logging.getLogger(__name__) - - -class EmailService: - """High-level email service that coordinates Postfix, Dovecot, DKIM, and SpamAssassin.""" - - COMPONENTS = { - 'postfix': PostfixService, - 'dovecot': DovecotService, - 'opendkim': DKIMService, - 'spamassassin': SpamAssassinService, - } - - # ── Status ── - - @classmethod - def get_status(cls) -> Dict: - """Get aggregate email server status.""" - postfix = PostfixService.get_status() - dovecot = DovecotService.get_status() - dkim = DKIMService.get_status() - spam = SpamAssassinService.get_status() - - all_installed = postfix['installed'] and dovecot['installed'] - # bool(): a component whose probe could not run reports running=None - # (see DovecotService.get_status), and `True and None` is None — the - # top-level flag is documented as a boolean, so keep it one. The - # honest per-component tri-state is still in the nested dicts. - all_running = bool(postfix.get('running', False) and dovecot.get('running', False)) - - return { - 'installed': all_installed, - 'running': all_running, - 'postfix': postfix, - 'dovecot': dovecot, - 'dkim': dkim, - 'spamassassin': spam, - } - - # ── Installation ── - - @classmethod - def install_all(cls, hostname: str = None) -> Dict: - """Install and configure all email components.""" - results = {} - - # 1. Install Postfix - results['postfix'] = PostfixService.install(hostname=hostname) - if not results['postfix'].get('success'): - return {'success': False, 'error': 'Postfix installation failed', 'results': results} - - # 2. Configure Postfix - postfix_config = PostfixService.configure(hostname=hostname) - if not postfix_config.get('success'): - logger.warning(f"Postfix configuration warning: {postfix_config.get('error')}") - - # 3. Install Dovecot - results['dovecot'] = DovecotService.install() - if not results['dovecot'].get('success'): - return {'success': False, 'error': 'Dovecot installation failed', 'results': results} - - # 4. Configure Dovecot - dovecot_config = DovecotService.configure() - if not dovecot_config.get('success'): - logger.warning(f"Dovecot configuration warning: {dovecot_config.get('error')}") - - # 5. Install OpenDKIM - results['dkim'] = DKIMService.install() - if not results['dkim'].get('success'): - logger.warning(f"DKIM installation warning: {results['dkim'].get('error')}") - - # 6. Install SpamAssassin - results['spamassassin'] = SpamAssassinService.install() - if not results['spamassassin'].get('success'): - logger.warning(f"SpamAssassin installation warning: {results['spamassassin'].get('error')}") - - all_ok = all(r.get('success') for r in results.values()) - return { - 'success': all_ok, - 'message': 'All email components installed' if all_ok else 'Some components had issues', - 'results': results, - } - - # ── Service Control ── - - @classmethod - def control_service(cls, component: str, action: str) -> Dict: - """Start/stop/restart an email component.""" - if component not in cls.COMPONENTS and component != 'roundcube': - return {'success': False, 'error': f'Unknown component: {component}'} - - if action not in ('start', 'stop', 'restart', 'reload'): - return {'success': False, 'error': f'Invalid action: {action}'} - - try: - if component == 'roundcube': - actions = { - 'start': RoundcubeService.start, - 'stop': RoundcubeService.stop, - 'restart': RoundcubeService.restart, - 'reload': RoundcubeService.restart, - } - return actions[action]() - - service_name = component - if action == 'start': - result = ServiceControl.start(service_name, timeout=30) - elif action == 'stop': - result = ServiceControl.stop(service_name, timeout=30) - elif action == 'restart': - result = ServiceControl.restart(service_name, timeout=30) - elif action == 'reload': - result = ServiceControl.reload(service_name, timeout=30) - - if result.returncode == 0: - return {'success': True, 'message': f'{component} {action} successful'} - return {'success': False, 'error': result.stderr or f'{action} failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - # ── Domains ── - - @classmethod - def get_domains(cls) -> List[Dict]: - """List all email domains.""" - domains = EmailDomain.query.all() - return [d.to_dict() for d in domains] - - @classmethod - def add_domain(cls, name: str, dns_provider_id: int = None, dns_zone_id: str = None) -> Dict: - """Add an email domain.""" - try: - existing = EmailDomain.query.filter_by(name=name).first() - if existing: - return {'success': False, 'error': f'Domain {name} already exists'} - - domain = EmailDomain( - name=name, - dns_provider_id=dns_provider_id, - dns_zone_id=dns_zone_id, - ) - db.session.add(domain) - db.session.commit() - - # Add to Postfix virtual domains - PostfixService.add_domain(name) - - # Generate DKIM key - dkim_result = DKIMService.generate_key(name) - if dkim_result.get('success'): - domain.dkim_selector = 'default' - domain.dkim_private_key_path = dkim_result.get('private_key_path') - domain.dkim_public_key = dkim_result.get('public_key') - domain.spf_record = f'v=spf1 mx a ~all' - domain.dmarc_record = f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{name}; pct=100' - DKIMService.add_domain(name) - db.session.commit() - - return {'success': True, 'domain': domain.to_dict(), 'message': f'Domain {name} added'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def get_domain(cls, domain_id: int) -> Dict: - """Get domain details.""" - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - return {'success': True, 'domain': domain.to_dict()} - - @classmethod - def remove_domain(cls, domain_id: int) -> Dict: - """Remove an email domain and all its accounts/aliases.""" - try: - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - domain_name = domain.name - - # Remove accounts from Dovecot/Postfix - for account in domain.accounts: - DovecotService.delete_mailbox(account.email, domain_name, account.username, remove_files=True) - PostfixService.remove_mailbox(account.email) - - # Remove aliases from Postfix - for alias in domain.aliases: - PostfixService.remove_alias(alias.source) - - # Remove from DKIM - DKIMService.remove_domain(domain_name) - - # Remove from Postfix virtual domains - PostfixService.remove_domain(domain_name) - - # Delete from database - db.session.delete(domain) - db.session.commit() - - return {'success': True, 'message': f'Domain {domain_name} removed'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def verify_dns(cls, domain_id: int) -> Dict: - """Verify DNS records for a domain.""" - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - results = {} - name = domain.name - - # Check MX record - try: - result = run_checked(['dig', '+short', 'MX', name], timeout=10) - results['mx'] = { - 'found': bool(result['output'].strip()), - 'value': result['output'].strip() or None, - } - except Exception: - results['mx'] = {'found': False, 'error': 'DNS lookup failed'} - - # Check SPF record - try: - result = run_checked(['dig', '+short', 'TXT', name], timeout=10) - spf_found = 'v=spf1' in result['output'] - results['spf'] = { - 'found': spf_found, - 'value': result['output'].strip() or None, - } - except Exception: - results['spf'] = {'found': False, 'error': 'DNS lookup failed'} - - # Check DKIM record - selector = domain.dkim_selector or 'default' - try: - dkim_name = f'{selector}._domainkey.{name}' - result = run_checked(['dig', '+short', 'TXT', dkim_name], timeout=10) - dkim_found = 'v=DKIM1' in result['output'] - results['dkim'] = { - 'found': dkim_found, - 'value': result['output'].strip() or None, - 'name': dkim_name, - } - except Exception: - results['dkim'] = {'found': False, 'error': 'DNS lookup failed'} - - # Check DMARC record - try: - dmarc_name = f'_dmarc.{name}' - result = run_checked(['dig', '+short', 'TXT', dmarc_name], timeout=10) - dmarc_found = 'v=DMARC1' in result['output'] - results['dmarc'] = { - 'found': dmarc_found, - 'value': result['output'].strip() or None, - } - except Exception: - results['dmarc'] = {'found': False, 'error': 'DNS lookup failed'} - - all_ok = all(r.get('found') for r in results.values()) - return { - 'success': True, - 'domain': name, - 'all_verified': all_ok, - 'records': results, - } - - # ── Accounts ── - - @classmethod - def get_accounts(cls, domain_id: int) -> List[Dict]: - """List email accounts for a domain.""" - accounts = EmailAccount.query.filter_by(domain_id=domain_id).all() - return [a.to_dict() for a in accounts] - - @classmethod - def add_account(cls, domain_id: int, username: str, password: str, quota_mb: int = 1024) -> Dict: - """Create an email account.""" - try: - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - email = f'{username}@{domain.name}' - - existing = EmailAccount.query.filter_by(email=email).first() - if existing: - return {'success': False, 'error': f'Account {email} already exists'} - - # Create mailbox in Dovecot - dovecot_result = DovecotService.create_mailbox(email, password, domain.name, username, quota_mb) - if not dovecot_result.get('success'): - return dovecot_result - - # Add to Postfix virtual mailboxes - PostfixService.add_mailbox(email, domain.name, username) - - # Get password hash for storage - password_hash = dovecot_result.get('password_hash', 'stored_in_dovecot') - - account = EmailAccount( - email=email, - username=username, - password_hash=password_hash, - domain_id=domain_id, - quota_mb=quota_mb, - ) - db.session.add(account) - db.session.commit() - - return {'success': True, 'account': account.to_dict(), 'message': f'Account {email} created'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def update_account(cls, account_id: int, quota_mb: int = None, is_active: bool = None) -> Dict: - """Update account settings.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - if quota_mb is not None: - account.quota_mb = quota_mb - DovecotService.set_quota(account.email, quota_mb) - - if is_active is not None: - account.is_active = is_active - - db.session.commit() - return {'success': True, 'account': account.to_dict(), 'message': 'Account updated'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def delete_account(cls, account_id: int) -> Dict: - """Delete an email account.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - email = account.email - domain_name = account.domain.name if account.domain else '' - username = account.username - - # Remove from Dovecot - DovecotService.delete_mailbox(email, domain_name, username, remove_files=True) - - # Remove from Postfix - PostfixService.remove_mailbox(email) - - db.session.delete(account) - db.session.commit() - - return {'success': True, 'message': f'Account {email} deleted'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def change_password(cls, account_id: int, new_password: str) -> Dict: - """Change an account password.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - result = DovecotService.change_password(account.email, new_password) - if not result.get('success'): - return result - - account.password_hash = 'updated_in_dovecot' - db.session.commit() - - return {'success': True, 'message': 'Password changed successfully'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - # ── Aliases ── - - @classmethod - def get_aliases(cls, domain_id: int) -> List[Dict]: - """List email aliases for a domain.""" - aliases = EmailAlias.query.filter_by(domain_id=domain_id).all() - return [a.to_dict() for a in aliases] - - @classmethod - def add_alias(cls, domain_id: int, source: str, destination: str) -> Dict: - """Create an email alias.""" - try: - domain = EmailDomain.query.get(domain_id) - if not domain: - return {'success': False, 'error': 'Domain not found'} - - # Add @ domain if not already qualified - if '@' not in source: - source = f'{source}@{domain.name}' - - alias = EmailAlias( - source=source, - destination=destination, - domain_id=domain_id, - ) - db.session.add(alias) - db.session.commit() - - # Add to Postfix virtual aliases - PostfixService.add_alias(source, destination) - - return {'success': True, 'alias': alias.to_dict(), 'message': f'Alias {source} created'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def remove_alias(cls, alias_id: int) -> Dict: - """Delete an email alias.""" - try: - alias = EmailAlias.query.get(alias_id) - if not alias: - return {'success': False, 'error': 'Alias not found'} - - PostfixService.remove_alias(alias.source) - - db.session.delete(alias) - db.session.commit() - - return {'success': True, 'message': 'Alias removed'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - # ── Forwarding Rules ── - - @classmethod - def get_forwarding(cls, account_id: int) -> List[Dict]: - """List forwarding rules for an account.""" - rules = EmailForwardingRule.query.filter_by(account_id=account_id).all() - return [r.to_dict() for r in rules] - - @classmethod - def add_forwarding(cls, account_id: int, destination: str, keep_copy: bool = True) -> Dict: - """Create a forwarding rule.""" - try: - account = EmailAccount.query.get(account_id) - if not account: - return {'success': False, 'error': 'Account not found'} - - rule = EmailForwardingRule( - account_id=account_id, - destination=destination, - keep_copy=keep_copy, - ) - db.session.add(rule) - db.session.commit() - - # Update Postfix aliases for forwarding - cls._sync_forwarding_aliases(account) - - return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule created'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def update_forwarding(cls, rule_id: int, destination: str = None, - keep_copy: bool = None, is_active: bool = None) -> Dict: - """Update a forwarding rule.""" - try: - rule = EmailForwardingRule.query.get(rule_id) - if not rule: - return {'success': False, 'error': 'Forwarding rule not found'} - - if destination is not None: - rule.destination = destination - if keep_copy is not None: - rule.keep_copy = keep_copy - if is_active is not None: - rule.is_active = is_active - - db.session.commit() - - # Re-sync Postfix aliases - cls._sync_forwarding_aliases(rule.account) - - return {'success': True, 'rule': rule.to_dict(), 'message': 'Forwarding rule updated'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def remove_forwarding(cls, rule_id: int) -> Dict: - """Delete a forwarding rule.""" - try: - rule = EmailForwardingRule.query.get(rule_id) - if not rule: - return {'success': False, 'error': 'Forwarding rule not found'} - - account = rule.account - db.session.delete(rule) - db.session.commit() - - # Re-sync Postfix aliases - cls._sync_forwarding_aliases(account) - - return {'success': True, 'message': 'Forwarding rule removed'} - except Exception as e: - db.session.rollback() - return {'success': False, 'error': str(e)} - - @classmethod - def _sync_forwarding_aliases(cls, account: EmailAccount): - """Sync forwarding rules to Postfix virtual aliases for an account.""" - active_rules = [r for r in account.forwarding_rules if r.is_active] - - if not active_rules: - # Remove any forwarding alias - PostfixService.remove_alias(account.email) - return - - # Build destination list - destinations = [r.destination for r in active_rules] - # If any rule wants to keep a copy, include the original mailbox - if any(r.keep_copy for r in active_rules): - destinations.append(account.email) - - # Remove old alias and add new one - PostfixService.remove_alias(account.email) - PostfixService.add_alias(account.email, ', '.join(destinations)) diff --git a/builtin-extensions/serverkit-email/backend/roundcube_service.py b/builtin-extensions/serverkit-email/backend/roundcube_service.py deleted file mode 100644 index fb0f2d6f..00000000 --- a/builtin-extensions/serverkit-email/backend/roundcube_service.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Roundcube webmail management service (Docker-based).""" -import subprocess -from typing import Dict - -from app.utils.system import run_checked, run_privileged, is_command_available - - -class RoundcubeService: - """Service for managing Roundcube webmail via Docker.""" - - CONTAINER_NAME = 'serverkit-roundcube' - VOLUME_NAME = 'roundcube_data' - IMAGE = 'roundcube/roundcubemail:latest' - HOST_PORT = 9000 - - @classmethod - def get_status(cls) -> Dict: - """Get Roundcube container status.""" - if not is_command_available('docker'): - return {'installed': False, 'running': False, 'error': 'Docker not available'} - - try: - result = run_checked( - ['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME], - timeout=None) - if not result['success']: - return {'installed': False, 'running': False} - - status = result['output'].strip() - return { - 'installed': True, - 'running': status == 'running', - 'status': status, - 'port': cls.HOST_PORT, - } - except Exception as e: - return {'installed': False, 'running': False, 'error': str(e)} - - @classmethod - def install(cls, imap_host: str = 'host.docker.internal', - smtp_host: str = 'host.docker.internal', - domain: str = None) -> Dict: - """Install Roundcube via Docker container.""" - if not is_command_available('docker'): - return {'success': False, 'error': 'Docker is not installed'} - - try: - # Remove existing container if any - run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None) - - # Create volume - run_checked(['docker', 'volume', 'create', cls.VOLUME_NAME], timeout=None) - - # Run container - result = run_checked([ - 'docker', 'run', '-d', - '--name', cls.CONTAINER_NAME, - '--restart', 'unless-stopped', - '--add-host', 'host.docker.internal:host-gateway', - '-p', f'{cls.HOST_PORT}:80', - '-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}', - '-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}', - '-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993', - '-e', 'ROUNDCUBEMAIL_SMTP_PORT=587', - '-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M', - '-e', 'ROUNDCUBEMAIL_SKIN=elastic', - '-v', f'{cls.VOLUME_NAME}:/var/roundcube/db', - cls.IMAGE, - ], timeout=None) - - if not result['success']: - return {'success': False, - 'error': result['error'] or 'Failed to start container'} - - # Build a public URL: prefer a supplied domain, then auto-generate - # webmail., and only fall back to localhost:port. - from app.services.site_domain_service import SiteDomainService - public_url = None - warning = None - if domain: - public_url = f'http://{domain}' - proxy_res = cls.configure_nginx_proxy(domain) - if not proxy_res.get('success'): - warning = proxy_res.get('error') - else: - panel_origin = SiteDomainService.panel_origin() - panel_host = None - if panel_origin: - from urllib.parse import urlparse - panel_host = urlparse(panel_origin).hostname - if panel_host: - webmail_domain = f'webmail.{panel_host}' - public_url = f'http://{webmail_domain}' - proxy_res = cls.configure_nginx_proxy(webmail_domain) - if not proxy_res.get('success'): - warning = proxy_res.get('error') - - if not public_url: - public_url = f'http://localhost:{cls.HOST_PORT}' - - result = { - 'success': True, - 'message': 'Roundcube installed successfully', - 'port': cls.HOST_PORT, - 'url': public_url, - } - if warning: - result['warning'] = warning - return result - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def uninstall(cls) -> Dict: - """Stop and remove Roundcube container.""" - try: - run_checked(['docker', 'rm', '-f', cls.CONTAINER_NAME], timeout=None) - return {'success': True, 'message': 'Roundcube uninstalled'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def start(cls) -> Dict: - """Start Roundcube container.""" - try: - result = run_checked(['docker', 'start', cls.CONTAINER_NAME], timeout=None) - if result['success']: - return {'success': True, 'message': 'Roundcube started'} - return {'success': False, 'error': result['error'] or 'Start failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def stop(cls) -> Dict: - """Stop Roundcube container.""" - try: - result = run_checked(['docker', 'stop', cls.CONTAINER_NAME], timeout=None) - if result['success']: - return {'success': True, 'message': 'Roundcube stopped'} - return {'success': False, 'error': result['error'] or 'Stop failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def restart(cls) -> Dict: - """Restart Roundcube container.""" - try: - result = run_checked(['docker', 'restart', cls.CONTAINER_NAME], timeout=None) - if result['success']: - return {'success': True, 'message': 'Roundcube restarted'} - return {'success': False, 'error': result['error'] or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def configure_nginx_proxy(cls, domain: str) -> Dict: - """Create Nginx reverse proxy config for Roundcube.""" - try: - from app.services.nginx_service import NginxService - - config = f"""# Roundcube Webmail - Managed by ServerKit -server {{ - listen 80; - server_name {domain}; - - location / {{ - proxy_pass http://127.0.0.1:{cls.HOST_PORT}; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - client_max_body_size 25m; - }} -}} -""" - site_name = f'roundcube-{domain.replace(".", "-")}' - config_path = f'/etc/nginx/sites-available/{site_name}' - enabled_path = f'/etc/nginx/sites-enabled/{site_name}' - - run_privileged(['tee', config_path], input=config) - run_privileged(['ln', '-sf', config_path, enabled_path]) - - # Test and reload - test = run_privileged(['nginx', '-t']) - if test.returncode != 0: - # Rollback - run_privileged(['rm', '-f', enabled_path]) - return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'} - - run_privileged(['systemctl', 'reload', 'nginx']) - - return { - 'success': True, - 'message': f'Nginx proxy configured for {domain}', - 'url': f'http://{domain}', - } - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/builtin-extensions/serverkit-email/backend/spamassassin_service.py b/builtin-extensions/serverkit-email/backend/spamassassin_service.py deleted file mode 100644 index 85516e42..00000000 --- a/builtin-extensions/serverkit-email/backend/spamassassin_service.py +++ /dev/null @@ -1,254 +0,0 @@ -"""SpamAssassin management service.""" -import os -import re -import subprocess -from typing import Dict - -from app.utils.system import (PackageManager, ServiceControl, run_checked, - run_privileged) - - -class SpamAssassinService: - """Service for managing SpamAssassin spam filtering.""" - - SPAMASSASSIN_CONF = '/etc/spamassassin/local.cf' - SPAMASSASSIN_DEFAULT = '/etc/default/spamassassin' - SPAMASS_MILTER_DEFAULT = '/etc/default/spamass-milter' - - LOCAL_CF_TEMPLATE = """# SpamAssassin local configuration - Managed by ServerKit -required_score {required_score} -report_safe {report_safe} -rewrite_header Subject {rewrite_subject} -use_bayes {use_bayes} -bayes_auto_learn {bayes_auto_learn} -bayes_auto_learn_threshold_nonspam 0.1 -bayes_auto_learn_threshold_spam 12.0 -skip_rbl_checks {skip_rbl_checks} -use_razor2 0 -use_pyzor 0 - -# Network checks -dns_available yes - -# Trusted networks -trusted_networks 127.0.0.0/8 -internal_networks 127.0.0.0/8 -""" - - DEFAULT_CONFIG = { - 'required_score': 5.0, - 'report_safe': 0, - 'rewrite_subject': '[SPAM]', - 'use_bayes': 1, - 'bayes_auto_learn': 1, - 'skip_rbl_checks': 0, - } - - @classmethod - def get_status(cls) -> Dict: - """Get SpamAssassin installation and running status.""" - installed = False - running = False - enabled = False - version = None - milter_installed = False - milter_running = False - - try: - installed = PackageManager.is_installed('spamassassin') - if installed: - running = ServiceControl.is_active('spamassassin') - enabled = ServiceControl.is_enabled('spamassassin') - result = run_checked(['spamassassin', '--version'], timeout=None) - match = re.search(r'version\s+(\S+)', result['output']) - if match: - version = match.group(1) - - # Check milter - milter_installed = PackageManager.is_installed('spamass-milter') - if milter_installed: - milter_running = ServiceControl.is_active('spamass-milter') - except (subprocess.SubprocessError, FileNotFoundError): - pass - - return { - 'installed': installed, - 'running': running, - 'enabled': enabled, - 'version': version, - 'milter_installed': milter_installed, - 'milter_running': milter_running, - } - - @classmethod - def install(cls) -> Dict: - """Install SpamAssassin and spamass-milter.""" - try: - manager = PackageManager.detect() - if manager == 'apt': - packages = ['spamassassin', 'spamass-milter', 'spamc'] - else: - packages = ['spamassassin', 'spamass-milter-postfix'] - - result = PackageManager.install(packages, timeout=300) - if result.returncode != 0: - return {'success': False, 'error': result.stderr or 'Failed to install SpamAssassin'} - - # Enable spamd on Debian/Ubuntu - if manager == 'apt' and os.path.exists(cls.SPAMASSASSIN_DEFAULT): - result = run_privileged(['cat', cls.SPAMASSASSIN_DEFAULT]) - content = result.stdout or '' - content = re.sub(r'ENABLED=0', 'ENABLED=1', content) - content = re.sub(r'CRON=0', 'CRON=1', content) - run_privileged(['tee', cls.SPAMASSASSIN_DEFAULT], input=content) - - # Configure milter to listen on port 8893 - milter_config = 'OPTIONS="-u spamass-milter -i 127.0.0.1 -p inet:8893@localhost -- --socket=/var/run/spamassassin/spamd.sock"\n' - if os.path.exists(cls.SPAMASS_MILTER_DEFAULT): - run_privileged(['tee', cls.SPAMASS_MILTER_DEFAULT], input=milter_config) - - # Write default config - cls.configure(cls.DEFAULT_CONFIG) - - # Update rules - run_privileged(['sa-update'], timeout=120) - - ServiceControl.enable('spamassassin') - ServiceControl.start('spamassassin', timeout=30) - - if PackageManager.is_installed('spamass-milter'): - ServiceControl.enable('spamass-milter') - ServiceControl.start('spamass-milter', timeout=30) - - return {'success': True, 'message': 'SpamAssassin installed successfully'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def configure(cls, settings: Dict = None) -> Dict: - """Write SpamAssassin configuration.""" - try: - config = dict(cls.DEFAULT_CONFIG) - if settings: - config.update(settings) - content = cls.LOCAL_CF_TEMPLATE.format(**config) - run_privileged(['tee', cls.SPAMASSASSIN_CONF], input=content) - return {'success': True, 'message': 'SpamAssassin configured'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_config(cls) -> Dict: - """Get current SpamAssassin configuration.""" - try: - if not os.path.exists(cls.SPAMASSASSIN_CONF): - return {'success': True, 'config': dict(cls.DEFAULT_CONFIG)} - - result = run_privileged(['cat', cls.SPAMASSASSIN_CONF]) - content = result.stdout or '' - - config = dict(cls.DEFAULT_CONFIG) - - # Parse values - score_match = re.search(r'required_score\s+(\S+)', content) - if score_match: - config['required_score'] = float(score_match.group(1)) - - report_match = re.search(r'report_safe\s+(\d+)', content) - if report_match: - config['report_safe'] = int(report_match.group(1)) - - subject_match = re.search(r'rewrite_header Subject\s+(.+)', content) - if subject_match: - config['rewrite_subject'] = subject_match.group(1).strip() - - bayes_match = re.search(r'use_bayes\s+(\d+)', content) - if bayes_match: - config['use_bayes'] = int(bayes_match.group(1)) - - auto_learn_match = re.search(r'bayes_auto_learn\s+(\d+)', content) - if auto_learn_match: - config['bayes_auto_learn'] = int(auto_learn_match.group(1)) - - rbl_match = re.search(r'skip_rbl_checks\s+(\d+)', content) - if rbl_match: - config['skip_rbl_checks'] = int(rbl_match.group(1)) - - return {'success': True, 'config': config} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def update_rules(cls) -> Dict: - """Update SpamAssassin rules.""" - try: - result = run_privileged(['sa-update'], timeout=120) - # sa-update returns 0 for updates, 1 for no updates, 2+ for errors - if result.returncode <= 1: - ServiceControl.restart('spamassassin', timeout=30) - return { - 'success': True, - 'message': 'Rules updated' if result.returncode == 0 else 'Rules already up to date', - 'updated': result.returncode == 0, - } - return {'success': False, 'error': result.stderr or 'Update failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def train_spam(cls, message_path: str) -> Dict: - """Train SpamAssassin with a spam message.""" - try: - result = run_privileged(['sa-learn', '--spam', message_path]) - if result.returncode == 0: - return {'success': True, 'message': 'Message learned as spam'} - return {'success': False, 'error': result.stderr or 'Training failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def train_ham(cls, message_path: str) -> Dict: - """Train SpamAssassin with a ham (non-spam) message.""" - try: - result = run_privileged(['sa-learn', '--ham', message_path]) - if result.returncode == 0: - return {'success': True, 'message': 'Message learned as ham'} - return {'success': False, 'error': result.stderr or 'Training failed'} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def get_stats(cls) -> Dict: - """Get SpamAssassin Bayes statistics.""" - try: - result = run_privileged(['sa-learn', '--dump', 'magic']) - output = result.stdout or '' - stats = { - 'nspam': 0, - 'nham': 0, - 'ntokens': 0, - } - for line in output.splitlines(): - parts = line.split() - if len(parts) >= 4: - if parts[2] == 'nspam': - stats['nspam'] = int(parts[1]) - elif parts[2] == 'nham': - stats['nham'] = int(parts[1]) - elif parts[2] == 'ntokens': - stats['ntokens'] = int(parts[1]) - - return {'success': True, 'stats': stats} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def reload(cls) -> Dict: - """Reload SpamAssassin.""" - try: - result = ServiceControl.restart('spamassassin', timeout=30) - if result.returncode == 0: - return {'success': True, 'message': 'SpamAssassin restarted'} - return {'success': False, 'error': result.stderr or 'Restart failed'} - except Exception as e: - return {'success': False, 'error': str(e)} diff --git a/builtin-extensions/serverkit-email/frontend/index.jsx b/builtin-extensions/serverkit-email/frontend/index.jsx deleted file mode 100644 index 8d791800..00000000 --- a/builtin-extensions/serverkit-email/frontend/index.jsx +++ /dev/null @@ -1,18 +0,0 @@ -// Email Server, contributed through the extension system. Both halves live in -// this extension: the mail-server backend (Postfix/Dovecot/DKIM/SpamAssassin/ -// Roundcube orchestration + the /api/v1/email blueprint) under backend/, and -// the full page here (pages/Email.jsx + styles/email.scss + its own API -// client in services/email.js). Imports go through 'serverkit-sdk' so the -// same source works baked in-tree (the panel's build-time glob) and as a -// runtime-ESM bundle once the extension leaves the tree (plan 52 Phase 2). -// -// The SCSS side-effect import compiles the page styles through this -// extension's own module graph — the host's main.scss no longer carries an -// _email partial. -import './styles/email.scss'; - -import Email from './pages/Email'; - -export function EmailPage() { - return ; -} diff --git a/builtin-extensions/serverkit-email/frontend/pages/Email.jsx b/builtin-extensions/serverkit-email/frontend/pages/Email.jsx deleted file mode 100644 index 6ac631ef..00000000 --- a/builtin-extensions/serverkit-email/frontend/pages/Email.jsx +++ /dev/null @@ -1,1070 +0,0 @@ -// Email Server — management page for the serverkit-email extension. -// -// NOTE: This file was reconstructed after a data-loss corruption. The original -// was ~63KB; this is a coherent, functional, on-style rebuild — not a -// byte-for-byte restore. It is wired to the real ApiService email methods -// (see frontend/src/services/api/system.js, the `/email/*` endpoints) and -// covers the same surface area: server/service status, mail domains with -// SPF/DKIM/DMARC/PTR presence pills + DNS verify/deploy, accounts, aliases, -// outbound relay (smarthost), SpamAssassin, webmail, and the mail queue. -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { - RefreshCw, Plus, Trash2, ShieldCheck, Server, Globe, Users, - Send, Filter, Inbox, ExternalLink, CheckCircle2, XCircle, HelpCircle, Search, -} from 'lucide-react'; -import api from '../services/email'; -import { - Pill, MetricCard, KpiBand, DataTable, DataTableFooter, ListToolbar, - SearchField, PageLayout, Button, Input, EmptyState, useToast, useConfirm, - useTabParam, useTableSort, useColumnVisibility, useTranslation, - useTableChrome, GridViewPicker, GridChips, GridFilterButton, - GridToolsMenu, GridFilterDrawer, -} from 'serverkit-sdk'; - -const VALID_TABS = ['overview', 'domains', 'accounts', 'relay', 'spam', 'webmail', 'queue']; - -// A sort-only preset still has to say so: `apply` clears the live rules from -// whatever was selected before, and an absent `columnFilters` would leave the -// previous view's filters silently in place. -const NO_RULES = { match: 'all', rules: [] }; - -// DNS record checks surfaced as presence pills. `state` is one of -// 'ok' | 'missing' | 'unknown' and maps to a DS Pill colour + dot. -const DNS_RECORDS = [ - { key: 'spf', label: 'SPF' }, - { key: 'dkim', label: 'DKIM' }, - { key: 'dmarc', label: 'DMARC' }, - { key: 'ptr', label: 'PTR' }, - { key: 'mx', label: 'MX' }, -]; - -function dnsPillKind(state) { - if (state === 'ok') return 'green'; - if (state === 'missing') return 'red'; - return 'gray'; -} - -// Derive a per-record status map from a domain record and an optional live -// verify result. Falls back to "does the stored record string exist" when we -// have not run a live DNS check yet. -function deriveDnsStatus(domain, verify) { - const out = {}; - for (const { key } of DNS_RECORDS) { - if (verify && verify[key]) { - out[key] = verify[key].valid || verify[key].present ? 'ok' : 'missing'; - } else if (key === 'spf') { - out[key] = domain?.spf_record ? 'ok' : 'unknown'; - } else if (key === 'dkim') { - out[key] = domain?.dkim_public_key ? 'ok' : 'unknown'; - } else if (key === 'dmarc') { - out[key] = domain?.dmarc_record ? 'ok' : 'unknown'; - } else { - out[key] = 'unknown'; - } - } - return out; -} - -function DnsIcon({ state }) { - if (state === 'ok') return