diff --git a/.gitignore b/.gitignore index 22ae7b2c..aeefdab7 100644 --- a/.gitignore +++ b/.gitignore @@ -195,16 +195,23 @@ backend/app/plugins/serverkit-email/ backend/app/plugins/serverkit-ftp/ backend/app/plugins/serverkit-gpu/ backend/app/plugins/serverkit-gui/ +backend/app/plugins/serverkit-git/ +backend/app/plugins/serverkit-localkit/ +backend/app/plugins/serverkit-cloudflare-ops/ backend/app/plugins/serverkit-k8s/ backend/app/plugins/serverkit-mail/ backend/app/plugins/serverkit-remote-access/ -backend/app/plugins/serverkit-status/ +# serverkit-status is deliberately NOT ignored: it is the tracked live copy +# that keeps test_builtin_extension_drift non-vacuous on a fresh clone. +# (serverkit-email held this role until it left the tree in plan 52.) backend/app/plugins/serverkit-tramo/ backend/app/plugins/serverkit-wordpress/ frontend/src/plugins/serverkit-analytics/ frontend/src/plugins/serverkit-crowdsec/ frontend/src/plugins/serverkit-dns-server/ frontend/src/plugins/serverkit-email/ +frontend/src/plugins/serverkit-git/ +frontend/src/plugins/serverkit-cloudflare-ops/ frontend/src/plugins/serverkit-gpu/ frontend/src/plugins/serverkit-gui/ frontend/src/plugins/serverkit-k8s/ diff --git a/AGENTS.md b/AGENTS.md index 0a427e82..6bc19648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,6 +215,42 @@ authorization decision — `@jwt_required()` alone is never sufficient When in doubt: admin-only for panel-wide resources, `@developer_required` for operational actions, the file's own inline ownership helper for user data. +#### Do not sweep `@jwt_required()` + inline `is_admin` to rbac decorators + +An existing route shaped like this **already satisfies default-deny** — +`test_route_authz_static.py` counts inspecting `.is_admin` / `.is_developer` as +an inline role check (`ATTRIBUTE_GATES`): + +```python +@bp.route('/', methods=['PUT']) +@jwt_required() +def update_page(page_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 +``` + +It is also the majority shape: ~91 routes across 21 files (audited 2026-08-31). + +Converting one to `@admin_required` is **not** a refactor. Every rbac decorator +authenticates through `auth_required()`, which accepts an `X-API-Key`, so the +conversion *grants API-key callers access to that route*. The decision recorded +when plan 76 closed (2026-08-19) still governs: + +> JWT-only is the deliberate default. A route becomes API-key capable one at a +> time, when an API-key use case exists for it, in a commit that says so — +> never as a mechanical sweep. + +`backend/tests/jwt_only_census.py` ratchets the population (`JWT_ONLY_CEILING`, +scanning `app/api` **and** the git-tracked `builtin-extensions/` sources): it +may shrink through deliberate conversions and may not grow, so a NEW route must pick a policy decorator +instead of bare `@jwt_required()`. + +The rule above ("do not stack `@jwt_required()` outside a gate") is about not +having *both*. It is not an instruction to convert routes that have neither. +Automated reviewers suggest this conversion routinely — decline it unless the +commit can name the API-key use case. + ## Key Environment Variables | Variable | Purpose | diff --git a/CLAUDE.md b/CLAUDE.md index 08330cf9..acef3186 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,6 +129,28 @@ outside one. `backend/tests/test_route_authz_static.py` (coverage), `test_route_authz_sweep.py` (enforcement) and `test_auth_decorator_boundaries.py` (one boundary) all fail the build otherwise. See AGENTS.md for the allowlists. +**`@jwt_required()` + an inline `is_admin` check is a valid explicit decision — +do NOT sweep it to rbac decorators.** `test_route_authz_static.py` counts +inspecting `.is_admin` / `.is_developer` as an inline role check +(`ATTRIBUTE_GATES`), so those routes already satisfy default-deny. They are +also the majority: ~91 routes across 21 files use exactly that shape. + +The reason it is not a free refactor: every rbac decorator authenticates via +`auth_required()`, which accepts an `X-API-Key` as well as a JWT. Converting a +route therefore **grants API-key callers access to it** — an expansion of the +authenticated surface, not a cleanup. The recorded decision (2026-08-19, +closing plan 76) stands: + +> JWT-only is the deliberate default. A route becomes API-key capable one at a +> time, when an API-key use case exists for it, in a commit that says so — +> never as a mechanical sweep. + +`backend/tests/jwt_only_census.py` ratchets the population (`JWT_ONLY_CEILING`) +over `app/api` **and** the git-tracked `builtin-extensions/` sources: it may +shrink as routes are deliberately converted, and may not grow — a NEW route picks a policy decorator rather than +bare `@jwt_required()`. Automated reviewers (Copilot) suggest this conversion +routinely; decline it unless the commit can name the API-key use case. + ## Key Environment Variables | Variable | Purpose | diff --git a/VERSION b/VERSION index 5e9287b8..e5f8fef8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.11 +1.9.14 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/plugins/serverkit-status/__init__.py b/backend/app/plugins/serverkit-status/__init__.py new file mode 100644 index 00000000..5087b60e --- /dev/null +++ b/backend/app/plugins/serverkit-status/__init__.py @@ -0,0 +1,16 @@ +"""ServerKit Status Pages extension backend package. + +Extracted from core (plan 47): the status-pages blueprint (public status/badge +routes + authenticated management API) and its service ship here so a fresh panel +that never publishes a status page loads none of it. Mounted under +``/api/v1/status`` via the manifest's ``url_prefix``; the public /status/ +and /status/badge/ pages work whenever the extension is installed. The +StatusPage / StatusComponent / StatusIncident / HealthCheck models stay in core +(G2). Core's WordPress health-check job reaches +``StatusPageService.sync_component_from_health`` through +``plugin_service.get_installed_extension_attr`` only when installed. Upgrades +re-acquire it automatically (``CONVERTED_BUILTIN_SLUGS``). +""" +from .status_pages import status_pages_bp + +__all__ = ['status_pages_bp'] diff --git a/backend/app/plugins/serverkit-status/plugin.json b/backend/app/plugins/serverkit-status/plugin.json new file mode 100644 index 00000000..71af50e3 --- /dev/null +++ b/backend/app/plugins/serverkit-status/plugin.json @@ -0,0 +1,42 @@ +{ + "name": "serverkit-status", + "display_name": "Status Pages", + "version": "1.0.0", + "description": "Public status pages backed by uptime monitors, with the management UI as a tab in the Observability group. The public /status/ page itself stays core.", + "author": "ServerKit", + "license": "MIT", + "category": "monitoring", + "homepage": "https://github.com/jhd3197/ServerKit", + "permissions": [], + "entry_point": "status_pages:status_pages_bp", + "url_prefix": "/api/v1/status", + "icon": "", + "contributions": { + "tabs": [ + { + "group": "monitoring", + "to": "/status-pages", + "label": "Status Pages", + "icon": "" + } + ], + "routes": [ + { + "path": "status-pages", + "component": "StatusPagesPage", + "group": "monitoring" + } + ], + "page_titles": { + "/status-pages": "Status Pages" + }, + "command_palette": [ + { + "label": "Status Pages", + "path": "/status-pages", + "category": "Pages", + "keywords": "uptime incidents public status" + } + ] + } +} diff --git a/backend/app/plugins/serverkit-status/status_page_service.py b/backend/app/plugins/serverkit-status/status_page_service.py new file mode 100644 index 00000000..4b81fa28 --- /dev/null +++ b/backend/app/plugins/serverkit-status/status_page_service.py @@ -0,0 +1,213 @@ +"""Public status pages. + +Scope note: this service owns *publishing* — pages, branding, grouping and the +public JSON/badge. It no longer owns the check engine. Monitors (what used to be +called "components") and their incidents are core, in +``app.services.monitor_service``, because watching a site must not depend on +having this extension installed. The component/health/incident methods below are +thin delegates kept so existing callers and the extension's own API blueprint +keep working unchanged. +""" +import logging + +from app.models.status_page import StatusPage, StatusComponent, StatusIncident +from app.services.monitor_service import MonitorService +from app.utils.slug import validate_slug +from app import db + +logger = logging.getLogger(__name__) + + +class StatusPageService: + """Service for public status pages.""" + + @staticmethod + def normalize_slug(value): + return validate_slug(value) + + # --- Pages --- + + @staticmethod + def list_pages(): + return StatusPage.query.order_by(StatusPage.name).all() + + @staticmethod + def get_page(page_id): + return StatusPage.query.get(page_id) + + @staticmethod + def get_page_by_slug(slug): + return StatusPage.query.filter_by(slug=slug).first() + + @staticmethod + def create_page(data): + slug = StatusPageService.normalize_slug(data.get('slug')) + if StatusPage.query.filter_by(slug=slug).first(): + raise ValueError(f"Status page '{slug}' already exists") + + page = StatusPage( + name=data['name'], + slug=slug, + description=data.get('description', ''), + logo_url=data.get('logo_url'), + primary_color=data.get('primary_color', '#4f46e5'), + custom_domain=data.get('custom_domain'), + is_public=data.get('is_public', True), + show_uptime=data.get('show_uptime', True), + show_history=data.get('show_history', True), + ) + db.session.add(page) + db.session.commit() + return page + + @staticmethod + def update_page(page_id, data): + page = StatusPage.query.get(page_id) + if not page: + return None + for field in ['name', 'description', 'logo_url', 'primary_color', + 'custom_domain', 'is_public', 'show_uptime', 'show_history']: + if field in data: + setattr(page, field, data[field]) + db.session.commit() + return page + + @staticmethod + def delete_page(page_id): + page = StatusPage.query.get(page_id) + if not page: + return False + db.session.delete(page) + db.session.commit() + return True + + @staticmethod + def get_public_page(slug): + """Get public status page data (no auth required).""" + page = StatusPage.query.filter_by(slug=slug, is_public=True).first() + if not page: + return None + + # Internal probe config must not appear on the unauthenticated public page + # (a health-driven WP component may carry an internal localhost:port target). + public_hidden = ('check_type', 'check_target', 'check_interval', 'check_timeout', + 'check_method', 'expected_status', 'keyword', 'follow_redirects', + 'verify_tls', 'retries', 'consecutive_failures', + 'cert_issuer', 'cert_expires_at', 'next_check_at') + components = page.components.all() + grouped = {} + for comp in components: + group = comp.group or 'Services' + cd = comp.to_dict() + for k in public_hidden: + cd.pop(k, None) + grouped.setdefault(group, []).append(cd) + + # Active incidents + active_incidents = page.incidents.filter( + StatusIncident.status != 'resolved' + ).limit(10).all() + + # Recent resolved + resolved = page.incidents.filter_by(status='resolved').limit(5).all() + + # Overall status + statuses = [c.status for c in components] + if any(s == 'major_outage' for s in statuses): + overall = 'major_outage' + elif any(s in ('partial_outage', 'degraded') for s in statuses): + overall = 'degraded' + elif any(s == 'maintenance' for s in statuses): + overall = 'maintenance' + else: + overall = 'operational' + + return { + 'page': page.to_dict(), + 'overall_status': overall, + 'groups': grouped, + 'active_incidents': [i.to_dict() for i in active_incidents], + 'recent_incidents': [i.to_dict() for i in resolved], + } + + # --- Components (monitors attached to a page) --- + # Delegates to MonitorService; a component is just a monitor with a page_id. + + @staticmethod + def create_component(page_id, data): + page = StatusPage.query.get(page_id) + if not page: + raise ValueError('Status page not found') + return MonitorService.create({**data, 'page_id': page_id}) + + @staticmethod + def attach_component(page_id, monitor_id): + """Publish an existing monitor on a page. Lets an operator build a status + page out of monitors they already have instead of re-declaring probes.""" + if not StatusPage.query.get(page_id): + raise ValueError('Status page not found') + return MonitorService.update(monitor_id, {'page_id': page_id}) + + @staticmethod + def detach_component(monitor_id): + """Remove a monitor from its page without deleting the monitor.""" + return MonitorService.update(monitor_id, {'page_id': None}) + + @staticmethod + def update_component(comp_id, data): + return MonitorService.update(comp_id, data) + + @staticmethod + def delete_component(comp_id): + return MonitorService.delete(comp_id) + + # --- Health Checks (delegated to the core engine) --- + + @staticmethod + def run_check(component_id): + return MonitorService.run_check(component_id) + + @staticmethod + def get_check_history(component_id, hours=24): + return MonitorService.get_check_history(component_id, hours=hours) + + @staticmethod + def recompute_uptime(comp): + return MonitorService.recompute_uptime(comp) + + @staticmethod + def sync_component_from_health(comp, overall_status, error=None): + return MonitorService.sync_component_from_health(comp, overall_status, error) + + # --- Incidents (delegated) --- + + @staticmethod + def create_incident(page_id, data): + return MonitorService.create_incident(page_id, data) + + @staticmethod + def update_incident(incident_id, data): + return MonitorService.update_incident(incident_id, data) + + @staticmethod + def delete_incident(incident_id): + return MonitorService.delete_incident(incident_id) + + @staticmethod + def get_badge(slug): + """Generate status badge data.""" + page = StatusPage.query.filter_by(slug=slug).first() + if not page: + return None + + components = page.components.all() + statuses = [c.status for c in components] + + if not statuses or all(s == 'operational' for s in statuses): + return {'label': 'status', 'message': 'operational', 'color': 'brightgreen'} + elif any(s == 'major_outage' for s in statuses): + return {'label': 'status', 'message': 'major outage', 'color': 'red'} + elif any(s in ('partial_outage', 'degraded') for s in statuses): + return {'label': 'status', 'message': 'degraded', 'color': 'yellow'} + else: + return {'label': 'status', 'message': 'maintenance', 'color': 'blue'} diff --git a/backend/app/plugins/serverkit-status/status_pages.py b/backend/app/plugins/serverkit-status/status_pages.py new file mode 100644 index 00000000..cb71f0dc --- /dev/null +++ b/backend/app/plugins/serverkit-status/status_pages.py @@ -0,0 +1,200 @@ +from flask import Blueprint, request, jsonify +from flask_jwt_extended import jwt_required +from app.middleware.rbac import get_current_user +from .status_page_service import StatusPageService + +status_pages_bp = Blueprint('status_pages', __name__) + + +# --- Public endpoints (no auth) --- + +@status_pages_bp.route('/public/', methods=['GET']) +def get_public_page(slug): + data = StatusPageService.get_public_page(slug) + if not data: + return jsonify({'error': 'Status page not found'}), 404 + return jsonify(data) + + +@status_pages_bp.route('/badge/', methods=['GET']) +def get_badge(slug): + badge = StatusPageService.get_badge(slug) + if not badge: + return jsonify({'error': 'Not found'}), 404 + return jsonify(badge) + + +# --- Admin endpoints --- + +@status_pages_bp.route('/', methods=['GET']) +@jwt_required() +def list_pages(): + pages = StatusPageService.list_pages() + return jsonify({'pages': [p.to_dict() for p in pages]}) + + +@status_pages_bp.route('/', methods=['GET']) +@jwt_required() +def get_page(page_id): + page = StatusPageService.get_page(page_id) + if not page: + return jsonify({'error': 'Not found'}), 404 + return jsonify(page.to_dict()) + + +@status_pages_bp.route('/', methods=['POST']) +@jwt_required() +def create_page(): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + data = request.get_json() + if not data or 'name' not in data or 'slug' not in data: + return jsonify({'error': 'name and slug required'}), 400 + try: + page = StatusPageService.create_page(data) + return jsonify(page.to_dict()), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + +@status_pages_bp.route('/', methods=['PUT']) +@jwt_required() +def update_page(page_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + data = request.get_json() or {} + page = StatusPageService.update_page(page_id, data) + if not page: + return jsonify({'error': 'Not found'}), 404 + return jsonify(page.to_dict()) + + +@status_pages_bp.route('/', methods=['DELETE']) +@jwt_required() +def delete_page(page_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + if not StatusPageService.delete_page(page_id): + return jsonify({'error': 'Not found'}), 404 + return jsonify({'message': 'Status page deleted'}) + + +# --- Components --- + +@status_pages_bp.route('//components', methods=['GET']) +@jwt_required() +def get_components(page_id): + page = StatusPageService.get_page(page_id) + if not page: + return jsonify({'error': 'Not found'}), 404 + return jsonify({'components': [c.to_dict() for c in page.components.all()]}) + + +@status_pages_bp.route('//components', methods=['POST']) +@jwt_required() +def create_component(page_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + data = request.get_json() or {} + try: + comp = StatusPageService.create_component(page_id, data) + return jsonify(comp.to_dict()), 201 + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + +@status_pages_bp.route('/components/', methods=['PUT']) +@jwt_required() +def update_component(comp_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + data = request.get_json() or {} + comp = StatusPageService.update_component(comp_id, data) + if not comp: + return jsonify({'error': 'Not found'}), 404 + return jsonify(comp.to_dict()) + + +@status_pages_bp.route('/components/', methods=['DELETE']) +@jwt_required() +def delete_component(comp_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + if not StatusPageService.delete_component(comp_id): + return jsonify({'error': 'Not found'}), 404 + return jsonify({'message': 'Component deleted'}) + + +@status_pages_bp.route('/components//check', methods=['POST']) +@jwt_required() +def run_check(comp_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + hc = StatusPageService.run_check(comp_id) + if not hc: + return jsonify({'error': 'Component not found'}), 404 + return jsonify(hc.to_dict()) + + +@status_pages_bp.route('/components//history', methods=['GET']) +@jwt_required() +def get_history(comp_id): + hours = request.args.get('hours', 24, type=int) + checks = StatusPageService.get_check_history(comp_id, hours) + return jsonify({'checks': [c.to_dict() for c in checks]}) + + +# --- Incidents --- + +@status_pages_bp.route('//incidents', methods=['GET']) +@jwt_required() +def list_incidents(page_id): + page = StatusPageService.get_page(page_id) + if not page: + return jsonify({'error': 'Not found'}), 404 + incidents = page.incidents.limit(50).all() + return jsonify({'incidents': [i.to_dict() for i in incidents]}) + + +@status_pages_bp.route('//incidents', methods=['POST']) +@jwt_required() +def create_incident(page_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + data = request.get_json() + if not data or 'title' not in data: + return jsonify({'error': 'title required'}), 400 + incident = StatusPageService.create_incident(page_id, data) + return jsonify(incident.to_dict()), 201 + + +@status_pages_bp.route('/incidents/', methods=['PUT']) +@jwt_required() +def update_incident(incident_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + data = request.get_json() or {} + incident = StatusPageService.update_incident(incident_id, data) + if not incident: + return jsonify({'error': 'Not found'}), 404 + return jsonify(incident.to_dict()) + + +@status_pages_bp.route('/incidents/', methods=['DELETE']) +@jwt_required() +def delete_incident(incident_id): + user = get_current_user() + if not user or not user.is_admin: + return jsonify({'error': 'Admin access required'}), 403 + if not StatusPageService.delete_incident(incident_id): + return jsonify({'error': 'Not found'}), 404 + return jsonify({'message': 'Incident deleted'}) diff --git a/backend/app/services/cloudflare_ops_bridge.py b/backend/app/services/cloudflare_ops_bridge.py index 48951250..fbf6777c 100644 --- a/backend/app/services/cloudflare_ops_bridge.py +++ b/backend/app/services/cloudflare_ops_bridge.py @@ -28,7 +28,19 @@ def ensure_loadable(): def load(module_name): - """Import a module from the extension backend (e.g. ``'cloudflare_service'``).""" + """Import a module from the extension backend (e.g. ``'cloudflare_service'``). + + Resolution order (plan 52 Ph2 cutover): a copy-installed extension first + (the registry install lands under the normal ``app.plugins`` path), then + the builtin registration (pre-cutover trees), then the historical core + location. Callers get ImportError when the extension truly is absent — + every caller is behind the extension's own routes, so absence is a 404 + before this ever raises. + """ + try: + return importlib.import_module(f'{PKG}.{module_name}') + except ImportError: + pass if ensure_loadable(): try: return importlib.import_module(f'{PKG}.{module_name}') diff --git a/backend/app/services/extension_migration.py b/backend/app/services/extension_migration.py index 81cf3455..0bd9c017 100644 --- a/backend/app/services/extension_migration.py +++ b/backend/app/services/extension_migration.py @@ -106,10 +106,10 @@ def _email_was_configured(): # Builtins auto-installed on upgrade ONLY when a usage predicate says the panel # 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, -} +# Empty since the plan 52 Ph2 cutover — every gated slug rides the registry +# now. The dict (and its predicate wiring) stays: the next core->builtin +# conversion drops straight in. +GATED_BUILTIN_SLUGS = {} def _has_command(cmd): @@ -140,6 +140,11 @@ 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 / serverkit-git left the tree (plan 52 Ph2/Ph6 + # cutover): their gated auto-installs ride the registry now, same + # predicates as before. + 'serverkit-email': _email_was_configured, + 'serverkit-git': _gitea_was_used, 'serverkit-fail2ban': _has_command('fail2ban-client'), 'serverkit-clamav': _has_command('clamscan'), 'serverkit-lynis': _has_command('lynis'), diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 606a299f..17de391b 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -10,6 +10,7 @@ Handles sending notifications to various channels: """ import os +import html import json import requests import smtplib @@ -333,22 +334,33 @@ class NotificationService: return {'success': True, 'message': 'No alerts to send'} try: - # Build message - lines = [f"🔔 ServerKit Alert", f"Host: {cls.get_hostname()}", ""] + # parse_mode='HTML' means Telegram parses the body as markup, so every + # interpolated value has to be escaped. Unescaped, an alert message as + # ordinary as "Disk usage > 90% on /var & /tmp" makes the API reject + # the request with "can't parse entities" and the whole notification + # is dropped -- exactly when something is already wrong. Only the + # literal tags below are markup; everything substituted in is data. + def esc(value): + return html.escape(str(value), quote=False) + + lines = [f"🔔 ServerKit Alert", f"Host: {esc(cls.get_hostname())}", ""] for alert in alerts: severity = alert.get('severity', 'info') color_info = cls.SEVERITY_COLORS.get(severity, cls.SEVERITY_COLORS['info']) - lines.append(f"{color_info['emoji']} {severity.upper()}: {alert.get('type', 'Alert').upper()}") - lines.append(alert.get('message', 'No message')) + lines.append( + f"{color_info['emoji']} {esc(severity.upper())}: " + f"{esc(alert.get('type', 'Alert').upper())}" + ) + lines.append(esc(alert.get('message', 'No message'))) if 'value' in alert or 'threshold' in alert: details = [] if 'value' in alert: - details.append(f"Current: {alert['value']}") + details.append(f"Current: {esc(alert['value'])}") if 'threshold' in alert: - details.append(f"Threshold: {alert['threshold']}") + details.append(f"Threshold: {esc(alert['threshold'])}") lines.append(f"{' | '.join(details)}") lines.append("") diff --git a/backend/app/services/plugin_service.py b/backend/app/services/plugin_service.py index 06403171..d09002f3 100644 --- a/backend/app/services/plugin_service.py +++ b/backend/app/services/plugin_service.py @@ -72,7 +72,7 @@ BUILTIN_EXTENSIONS_DIR = os.environ.get( # onboarding via RECOMMENDED_EXTENSIONS_BY_USE_CASE. Its uninstall-marker # semantics are preserved (the settings key machinery below is untouched), so # a user who uninstalled WP before the extraction is never re-seeded. -FLAGSHIP_SLUGS = ['serverkit-cloudflare-ops'] +FLAGSHIP_SLUGS = [] # cloudflare-ops left the tree (plan 52 Ph2 cutover); machinery kept for future flagships _FLAGSHIP_UNINSTALLED_KEY = 'extensions.flagship_uninstalled' # Wizard-optional flagships (plan 47): offered — not force-installed — during diff --git a/backend/tests/BASELINE_COUNT b/backend/tests/BASELINE_COUNT index 7f796be5..f55a50d6 100644 --- a/backend/tests/BASELINE_COUNT +++ b/backend/tests/BASELINE_COUNT @@ -1 +1 @@ -4799 +4791 diff --git a/backend/tests/JWT_ONLY_CEILING b/backend/tests/JWT_ONLY_CEILING index 3fa81504..ce5acf70 100644 --- a/backend/tests/JWT_ONLY_CEILING +++ b/backend/tests/JWT_ONLY_CEILING @@ -1 +1 @@ -577 +608 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 5ab46065..5e51a292 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -229,6 +229,7 @@ def app(request, _flask_app): needs_private_app = ( request.node.get_closest_marker('fresh_app') is not None or 'wp_extension' in request.fixturenames + or 'cf_extension' in request.fixturenames ) target = create_app('testing') if needs_private_app else _flask_app @@ -379,6 +380,83 @@ 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 mount_sibling_extension(app, slug, url_prefix, entry_module, entry_attr, + display_name=None): + """Mirror a real install of an extracted extension from its sibling repo: + register its backend dir as the ``app.plugins.`` package, seed an + active InstalledPlugin row (manifest included), and mount its blueprint. + The plan 52 cutover analogue of _wp_support.ensure_plugin.""" + import importlib.util + import json as _json + + src = sibling_extension_dir(slug) + if not src: + pytest.skip(f'{slug} checkout not available ' + f'(set SERVERKIT_{slug.replace("serverkit-", "").replace("-", "_").upper()}_DIR)') + backend_dir = os.path.join(src, 'backend') + pkg = f'app.plugins.{slug}' + if pkg not in sys.modules: + spec = importlib.util.spec_from_file_location( + pkg, os.path.join(backend_dir, '__init__.py'), + submodule_search_locations=[backend_dir]) + module = importlib.util.module_from_spec(spec) + sys.modules[pkg] = module + spec.loader.exec_module(module) + + from app import db + from app.models.plugin import InstalledPlugin + with open(os.path.join(src, 'plugin.json'), encoding='utf-8') as f: + manifest = _json.load(f) + plugin = InstalledPlugin.query.filter_by(slug=slug).first() + if not plugin: + plugin = InstalledPlugin( + name=slug, display_name=display_name or manifest.get('display_name', slug), + slug=slug, version=manifest.get('version', '0.0.0'), + status=InstalledPlugin.STATUS_ACTIVE, + has_backend=True, url_prefix=url_prefix, + ) + db.session.add(plugin) + plugin.status = InstalledPlugin.STATUS_ACTIVE + plugin.manifest = manifest + db.session.commit() + + mod = __import__(f'{pkg}.{entry_module}', fromlist=[entry_attr]) + bp = getattr(mod, entry_attr) + if bp.name not in app.blueprints: + app.register_blueprint(bp, url_prefix=url_prefix) + return plugin + + +@pytest.fixture +def cf_extension(app): + """serverkit-cloudflare-ops mounted from its sibling checkout (skip when + absent). Structure-mutating — forces a private app like wp_extension.""" + return mount_sibling_extension( + app, 'serverkit-cloudflare-ops', '/api/v1/cloudflare', + 'cloudflare', 'cloudflare_bp', display_name='Cloudflare Zone Ops') + + 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/jwt_only_census.py b/backend/tests/jwt_only_census.py index a6561234..d5b644d2 100644 --- a/backend/tests/jwt_only_census.py +++ b/backend/tests/jwt_only_census.py @@ -33,11 +33,32 @@ Usage (from ``backend/``):: import ast import os +import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) BACKEND = os.path.dirname(HERE) API_DIR = os.path.join(BACKEND, 'app', 'api') +# Extension routes are the same authenticated surface as core routes, and were +# invisible here until 2026-08-31: the serverkit-status blueprint carried 15 +# bare-@jwt_required() routes this census never saw, so the ratchet could not +# object when a review proposed converting them wholesale. +# +# They are counted from builtin-extensions/ (the SOURCE), not app/plugins/ (the +# live copy the loader imports). The two are pinned equal by +# test_builtin_extension_drift.py, so either would give the same answer on a +# clean checkout -- but app/plugins/ is mostly GITIGNORED, so counting it makes +# the number depend on which extensions a given box happens to have installed. +# That is not hypothetical: it shipped a ceiling of 611 from a dev box where CI +# measured 591. +# +# For the same reason the population is filtered to git-TRACKED files: a stale +# ignored directory left behind by an extraction (cloudflare-ops, git, localkit +# after plan 52) still sits on some working copies and would otherwise be +# counted. Without git (release tarball, Docker build) the tree has no such +# leftovers and the unfiltered walk agrees. +EXT_DIR = os.path.join(BACKEND, os.pardir, 'builtin-extensions') +SCAN_DIRS = (API_DIR, EXT_DIR) CEILING_FILE = os.path.join(HERE, 'JWT_ONLY_CEILING') SKIP_DIRS = {'__pycache__'} @@ -70,17 +91,46 @@ def count_file(path): return sorted(found) +REPO = os.path.dirname(BACKEND) + + +def _repo_rel(path): + return os.path.relpath(path, REPO).replace(os.sep, '/') + + +def _tracked_files(): + """Repo-relative paths git tracks, or None when git cannot answer. + + Run from the repo ROOT, not backend/: `git ls-files` only lists paths under + its working directory, so from backend/ the builtin-extensions entries are + absent and every extension file would be filtered out as "untracked". + """ + try: + proc = subprocess.run(['git', 'ls-files'], cwd=REPO, + capture_output=True, text=True) + except OSError: + return None + if proc.returncode != 0: + return None + return {line.strip() for line in proc.stdout.splitlines() if line.strip()} + + def census(): found = {} - for dirpath, dirnames, filenames in os.walk(API_DIR): - dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] - for name in sorted(filenames): - if not name.endswith('.py'): - continue - path = os.path.join(dirpath, name) - hits = count_file(path) - if hits: - found[_rel(path)] = hits + tracked = _tracked_files() + for root in SCAN_DIRS: + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + for name in sorted(filenames): + if not name.endswith('.py'): + continue + path = os.path.join(dirpath, name) + if tracked is not None and _repo_rel(path) not in tracked: + continue + rel = _rel(path) + hits = count_file(path) + if hits: + found[rel] = hits return found diff --git a/backend/tests/test_builtin_extension_drift.py b/backend/tests/test_builtin_extension_drift.py index 3908336b..4575a480 100644 --- a/backend/tests/test_builtin_extension_drift.py +++ b/backend/tests/test_builtin_extension_drift.py @@ -13,11 +13,11 @@ Written while drift is **zero** (21 file pairs). That is the point — this lock in a clean invariant rather than repairing a dirty one, and it only stays cheap while that is true. -Scope note: only pairs present on BOTH sides are compared. Several extensions -legitimately have one side only — cloudflare-ops and localkit ship source with -no materialised live copy, serverkit-git uses a different layout — and most live -copies are gitignored, so a fresh clone sees fewer pairs than a dev machine. -`test_drift_check_is_not_vacuous` stops that from quietly reducing to nothing. +Scope note: only pairs present on BOTH sides are compared. Some extensions +legitimately have one side only, and most live copies are gitignored, so a +fresh clone sees fewer pairs than a dev machine. serverkit-status is the one +live copy git tracks, so a fresh clone always has at least one comparable +pair; `test_drift_check_is_not_vacuous` stops that reducing to nothing. That machine-dependence is also why the test-count ratchet runs collection with `SERVERKIT_CLEAN_COLLECT=1` — see `CLEAN_COLLECT` below. @@ -149,15 +149,16 @@ def test_drift_check_is_not_vacuous(): """Guard the guard. Most live copies are gitignored, so a fresh clone sees far fewer pairs than - a dev machine. serverkit-email's live copy predates that ignore rule and is - tracked, so at least one extension must always be comparable. Without this, - a reorganisation could silently reduce the parametrised test above to zero - cases and it would still report green. + a dev machine. serverkit-status's live copy is deliberately tracked (see the + note in .gitignore), so at least one extension must always be comparable. + Without this, a reorganisation could silently reduce the parametrised test + above to zero cases and it would still report green -- which is exactly what + happened when serverkit-email, the previous anchor, left the tree in plan 52. """ assert PAIRS, ( 'No extension had both a builtin-extensions//backend and an ' 'app/plugins/ copy, so the drift check compared nothing. Either ' - 'the layout moved or the tracked serverkit-email copy is gone.' + 'the layout moved or the tracked serverkit-status copy is gone.' ) diff --git a/backend/tests/test_cloudflare_extraction.py b/backend/tests/test_cloudflare_extraction.py index 584d4222..dc8cb443 100644 --- a/backend/tests/test_cloudflare_extraction.py +++ b/backend/tests/test_cloudflare_extraction.py @@ -1,9 +1,11 @@ -"""Prove the Cloudflare zone-ops extraction (Phase 5, #36). +"""Prove the Cloudflare zone-ops extraction (Phase 5, #36; standalone repo +since the plan 52 Ph2 cutover). -Zone-ops (the /api/v1/cloudflare blueprint + CloudflareService) physically moved -into the bundled, default-installed serverkit-cloudflare-ops extension (a flagship, -like WordPress). The DNS layer stays core: the moved service borrows the single -core CloudflareClient via DNSZoneService — there is no duplicate Cloudflare client. +Zone-ops (the /api/v1/cloudflare blueprint + CloudflareService) lives in the +standalone serverkit-cloudflare-ops repo now — no longer a seeded flagship. A +lean panel boots with no cloudflare routes; the suite mounts the extension +from the sibling checkout (skip when absent). The DNS layer stays core: the +moved service borrows the single core CloudflareClient via DNSZoneService. """ import importlib @@ -27,7 +29,7 @@ def test_dns_client_stays_core_single_source(): assert CloudflareClient.__module__ == 'app.services.dns.cloudflare' -def test_bridge_resolves_service_from_extension(): +def test_bridge_resolves_service_from_extension(cf_extension): CloudflareService = cloudflare_ops_bridge.cloudflare_service() assert CloudflareService.__name__ == 'CloudflareService' assert CloudflareService.__module__.startswith(f'app.plugins.{SLUG}') @@ -38,22 +40,24 @@ def test_bridge_resolves_service_from_extension(): assert 'DNSZoneService._resolve_credential' in src -def test_cloudflare_seeded_as_flagship(app): - plugin = InstalledPlugin.query.filter_by(slug=SLUG).first() - assert plugin is not None, 'Cloudflare zone-ops flagship should be seeded on boot' - assert plugin.status == InstalledPlugin.STATUS_ACTIVE - assert plugin.url_prefix == '/api/v1/cloudflare' +@pytest.mark.fresh_app +def test_cloudflare_is_not_seeded_on_a_lean_panel(app): + """The flagship era ended with the cutover: a fresh panel has no + cloudflare-ops row and no zone routes until a registry install.""" + assert InstalledPlugin.query.filter_by(slug=SLUG).first() is None + rules = {r.rule for r in app.url_map.iter_rules()} + assert not any(r.startswith('/api/v1/cloudflare/') for r in rules) -def test_cloudflare_blueprint_registered_from_extension(app): +def test_cloudflare_blueprint_registered_from_extension(cf_extension, app): rules = [r.rule for r in app.url_map.iter_rules()] assert any(r.startswith('/api/v1/cloudflare/zones') for r in rules), \ 'Cloudflare zone-ops API should be registered from the extension' -def test_cloudflare_api_reachable_not_404(app, client, auth_headers): +def test_cloudflare_api_reachable_not_404(cf_extension, app, client, auth_headers): """The extension blueprint is live (not 404) and the status guard passes for - the active flagship (not 503). It may 400/502 without a real CF zone — we only - assert the route is wired.""" + the mounted extension (not 503). It may 400/502 without a real CF zone — we + only assert the route is wired.""" resp = client.get('/api/v1/cloudflare/zones/1/settings', headers=auth_headers) assert resp.status_code not in (404, 503), resp.status_code diff --git a/backend/tests/test_cloudflare_round2.py b/backend/tests/test_cloudflare_round2.py index 6db54adf..6eea7058 100644 --- a/backend/tests/test_cloudflare_round2.py +++ b/backend/tests/test_cloudflare_round2.py @@ -11,6 +11,14 @@ import types import pytest +# The zone-ops backend lives in the standalone serverkit-cloudflare-ops repo +# since the plan 52 Ph2 cutover; every test here runs against the extension +# mounted from the sibling checkout (module skips when it is absent). +@pytest.fixture(autouse=True) +def _mounted_cf(cf_extension): + yield + + class _Resp: def __init__(self, js, status=200): diff --git a/backend/tests/test_cloudflare_zone_settings.py b/backend/tests/test_cloudflare_zone_settings.py index b6dbad7e..6bb73b54 100644 --- a/backend/tests/test_cloudflare_zone_settings.py +++ b/backend/tests/test_cloudflare_zone_settings.py @@ -10,6 +10,14 @@ imported ``requests`` module (the generic helper uses ``requests.request``). """ import pytest +# The zone-ops backend lives in the standalone serverkit-cloudflare-ops repo +# since the plan 52 Ph2 cutover; every test here runs against the extension +# mounted from the sibling checkout (module skips when it is absent). +@pytest.fixture(autouse=True) +def _mounted_cf(cf_extension): + yield + + class _Resp: def __init__(self, js, status=200): 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/backend/tests/test_frontend_route_contract.py b/backend/tests/test_frontend_route_contract.py index 04f6aacc..90555614 100644 --- a/backend/tests/test_frontend_route_contract.py +++ b/backend/tests/test_frontend_route_contract.py @@ -48,6 +48,18 @@ ALLOWLIST = { BUILTIN_EXTENSIONS_DIR = (Path(__file__).resolve().parents[2] / 'builtin-extensions') +# Prefixes owned by EXTRACTED (standalone-repo) extensions that core clients +# still probe fail-soft — the plan 52 cutover deleted their builtin dirs, so +# their plugin.json can no longer vouch for them. Kept explicit and minimal: +# each entry names the extension serving the prefix when installed. The +# core callers behind them degrade to "not installed" on 404 by design +# (Settings relay tile, the app-creation repo pickers). +EXTRACTED_EXTENSION_PREFIXES = { + '/git': 'serverkit-git', + '/email': 'serverkit-email', + '/cloudflare': 'serverkit-cloudflare-ops', +} + def _extension_prefixes(): """/api/v1-relative url_prefix of every builtin extension. @@ -55,9 +67,10 @@ def _extension_prefixes(): Paths under these prefixes are owned by extensions the session app may not have loaded, so an unresolved path there is excused — but only while a plugin.json still declares the prefix, so a removed extension - turns its frontend calls back into failures.""" + turns its frontend calls back into failures. Extracted extensions keep + their prefixes excused via EXTRACTED_EXTENSION_PREFIXES above.""" import json - prefixes = {} + prefixes = dict(EXTRACTED_EXTENSION_PREFIXES) for manifest in sorted(BUILTIN_EXTENSIONS_DIR.glob('*/plugin.json')): prefix = json.loads(manifest.read_text(encoding='utf-8')).get('url_prefix', '') if prefix.startswith('/api/v1/'): diff --git a/backend/tests/test_notification_telegram_format.py b/backend/tests/test_notification_telegram_format.py new file mode 100644 index 00000000..86a3362e --- /dev/null +++ b/backend/tests/test_notification_telegram_format.py @@ -0,0 +1,106 @@ +"""Telegram alerts are sent with parse_mode='HTML', so every interpolated +value has to be escaped. + +Unescaped, an alert message as ordinary as "Disk usage > 90% on /var & /tmp" +makes the Bot API reject the request with "can't parse entities" and the whole +notification is dropped — precisely when something is already wrong on the box. +These pin the escaping and that the literal markup survives it. +""" + +from unittest.mock import patch + +from app.services.notification_service import NotificationService + + +TELEGRAM_CONFIG = { + 'enabled': True, + 'bot_token': 'test-token', + 'chat_id': '123456', + 'notify_on': ['critical', 'warning', 'info'], +} + + +class _FakeResponse: + def __init__(self, payload): + self._payload = payload + + def json(self): + return self._payload + + +def _send(alerts, hostname='test-host'): + """Run send_telegram against a stubbed API and return the posted payload.""" + captured = {} + + def fake_post(url, json=None, timeout=None): + captured['url'] = url + captured['payload'] = json + return _FakeResponse({'ok': True}) + + with patch('app.services.notification_service.requests.post', side_effect=fake_post), \ + patch.object(NotificationService, 'get_hostname', return_value=hostname): + result = NotificationService.send_telegram(alerts, config=dict(TELEGRAM_CONFIG)) + + assert result['success'] is True, result + return captured['payload'] + + +def test_alert_message_with_html_metacharacters_is_escaped(): + """The regression: > and & in a message used to reach Telegram raw.""" + payload = _send([{ + 'severity': 'warning', + 'type': 'disk', + 'message': 'Disk usage > 90% on /var & /tmp', + }]) + + text = payload['text'] + assert 'Disk usage > 90% on /var & /tmp' in text + # The raw form must be gone, or the API rejects the whole message. + assert 'Disk usage > 90%' not in text + + +def test_angle_bracket_payload_cannot_inject_markup(): + """A value that looks like a tag is data, not markup.""" + payload = _send([{ + 'severity': 'critical', + 'type': 'service', + 'message': 'container web exited', + }]) + + text = payload['text'] + assert '<b>web</b>' in text + assert 'web' not in text + + +def test_hostname_and_threshold_values_are_escaped(): + """Every interpolated field, not just the message.""" + payload = _send( + [{ + 'severity': 'warning', + 'type': 'cpu', + 'message': 'high load', + 'value': '95% > limit', + 'threshold': '90% & rising', + }], + hostname='box<1>&2', + ) + + text = payload['text'] + assert 'box<1>&2' in text + assert 'Current: 95% > limit' in text + assert 'Threshold: 90% & rising' in text + + +def test_literal_markup_and_parse_mode_survive_escaping(): + """Escaping data must not escape the template's own tags.""" + payload = _send([{ + 'severity': 'info', + 'type': 'test', + 'message': 'plain message', + }]) + + text = payload['text'] + assert payload['parse_mode'] == 'HTML' + assert '🔔 ServerKit Alert' in text + assert 'Host: test-host' in text + assert 'INFO: TEST' in text diff --git a/backend/tests/test_onboarding_recommendations.py b/backend/tests/test_onboarding_recommendations.py index bd34d4ec..d00dd783 100644 --- a/backend/tests/test_onboarding_recommendations.py +++ b/backend/tests/test_onboarding_recommendations.py @@ -117,20 +117,24 @@ def test_finalize_setup_flagships_marks_uninstalled_when_absent(app, monkeypatch def test_finalize_setup_flagships_keeps_installed(app, monkeypatch): - """An INSTALLED wizard-optional flagship is never marked uninstalled.""" - monkeypatch.setattr(ps, 'WIZARD_OPTIONAL_FLAGSHIP_SLUGS', ['serverkit-cloudflare-ops']) + """An INSTALLED wizard-optional flagship is never marked uninstalled. + + FLAGSHIP_SLUGS is empty since the cloudflare-ops cutover (plan 52 Ph2), + so the machinery is proven with a fabricated installed row.""" + monkeypatch.setattr(ps, 'WIZARD_OPTIONAL_FLAGSHIP_SLUGS', ['serverkit-demo-opt']) with app.app_context(): - # the flagship seeder keeps cloudflare-ops installed under TESTING - assert InstalledPlugin.query.filter_by(slug='serverkit-cloudflare-ops').first() + db.session.add(InstalledPlugin( + name='serverkit-demo-opt', display_name='Demo', slug='serverkit-demo-opt', + version='1.0.0', status=InstalledPlugin.STATUS_ACTIVE)) + db.session.commit() ps.finalize_setup_flagships() - assert 'serverkit-cloudflare-ops' not in ps._flagship_uninstalled_set() + assert 'serverkit-demo-opt' not in ps._flagship_uninstalled_set() def test_wordpress_not_seeded_post_extraction(app): - """Plan 52 Phase 5: WordPress is a registry extension now — the boot-time - flagship seeder never installs it (fresh OR completed setup), while the - remaining in-tree flagship (cloudflare-ops) still seeds. The wizard offers - WP through the use-case map instead (proven above).""" + """Plan 52 Phase 5 + Ph2 cutover: WordPress AND cloudflare-ops are + registry extensions now — the boot-time flagship seeder (an empty list) + installs neither. The wizard offers them through the catalog instead.""" with app.app_context(): InstalledPlugin.query.filter_by(slug='serverkit-wordpress').delete() db.session.commit() @@ -140,7 +144,7 @@ def test_wordpress_not_seeded_post_extraction(app): assert InstalledPlugin.query.filter_by( slug='serverkit-wordpress').first() is None assert InstalledPlugin.query.filter_by( - slug='serverkit-cloudflare-ops').first() is not None + slug='serverkit-cloudflare-ops').first() is None finally: app.config['TESTING'] = True diff --git a/backend/tests/test_status_pages_json_body.py b/backend/tests/test_status_pages_json_body.py new file mode 100644 index 00000000..76062199 --- /dev/null +++ b/backend/tests/test_status_pages_json_body.py @@ -0,0 +1,108 @@ +"""A JSON body of literal `null` must not 500 the status-page routes. + +Flask already answers the two obvious bad bodies itself — an empty body is a +400 and a wrong content-type is a 415 — so those were never the exposure. The +one that reaches the view is `null`, which is valid JSON: `get_json()` returns +None, and the service then does `if field in data` and raises TypeError. + +Measured on Flask 3.1.3: + body='null' + application/json -> get_json() is None + body='' + application/json -> 400 (raised by Flask) + body='x' + text/plain -> 415 (raised by Flask) +""" + +import importlib.util +import pathlib +import re + +import pytest + +EXT_DIR = pathlib.Path(__file__).resolve().parent.parent / 'app' / 'plugins' / 'serverkit-status' + + +def _load(module_name, filename): + """Load a module out of the hyphenated plugin dir (not importable normally).""" + spec = importlib.util.spec_from_file_location(module_name, EXT_DIR / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_service_still_assumes_a_dict(app): + """Pins WHY the route guard matters: the service has no defence of its own.""" + service = _load('_sk_status_service', 'status_page_service.py').StatusPageService + + with app.app_context(): + # The crash only happens past the not-found early return, so the row + # has to exist — with no page, update_page returns None harmlessly and + # the test would pass for the wrong reason. + page = service.create_page({'name': 'Probe', 'slug': 'probe-null-body'}) + + # None is exactly what an unguarded request.get_json() handed it. + with pytest.raises(TypeError): + service.update_page(page.id, None) + + # The value the route now passes instead is safe. + assert service.update_page(page.id, {}) is not None + + +def test_no_route_passes_a_possibly_none_body_to_the_service(): + """Every call site either coerces None away or rejects a falsy body inline.""" + src = (EXT_DIR / 'status_pages.py').read_text(encoding='utf-8') + lines = src.splitlines() + + assert 'request.get_json()' in src, 'no get_json call sites — did the file move?' + + unguarded = [] + for i, line in enumerate(lines): + if line.strip() == 'data = request.get_json()': + following = ' '.join(lines[i + 1:i + 3]) + if 'if not data' not in following: + unguarded.append(i + 1) + + assert not unguarded, ( + f'status_pages.py lines {unguarded} pass a possibly-None body straight ' + 'into the service; use `request.get_json() or {}` or reject it inline.' + ) + + +def test_live_copy_and_source_agree_on_the_guard(): + """The extension exists twice on disk; a one-sided fix is the classic drift.""" + repo = pathlib.Path(__file__).resolve().parent.parent.parent + source = repo / 'builtin-extensions' / 'serverkit-status' / 'backend' / 'status_pages.py' + if not source.is_file(): + pytest.skip('builtin-extensions source copy not present in this checkout') + + def norm(p): + return p.read_bytes().replace(b'\r\n', b'\n') + + assert norm(EXT_DIR / 'status_pages.py') == norm(source), ( + 'status_pages.py differs between app/plugins/ and builtin-extensions/ — ' + 'the loader imports the live copy, so edit BOTH.' + ) + + +def test_flask_still_handles_the_bodies_we_rely_on_it_for(): + """If Flask ever stopped raising on these, the routes would need their own + handling — this is the assumption the guard is scoped against. + + Deliberately a throwaway Flask app rather than the shared `app` fixture: + adding a route there leaks into every route-level sweep in the suite (the + mutating-route authz sweep would see an unauthenticated PUT and fail). + """ + from flask import Flask, request + + probe = Flask(__name__) + + @probe.route('/probe', methods=['PUT']) + def _probe(): # pragma: no cover - exercised via the client below + return {'is_none': request.get_json() is None} + + client = probe.test_client() + + assert client.put('/probe', data='null', + content_type='application/json').get_json() == {'is_none': True} + assert client.put('/probe', data='', + content_type='application/json').status_code == 400 + assert client.put('/probe', data='x', + content_type='text/plain').status_code == 415 diff --git a/builtin-extensions/serverkit-cloudflare-ops/backend/__init__.py b/builtin-extensions/serverkit-cloudflare-ops/backend/__init__.py deleted file mode 100644 index fe9ccee3..00000000 --- a/builtin-extensions/serverkit-cloudflare-ops/backend/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ServerKit Cloudflare zone-ops extension backend package.""" diff --git a/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py b/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py deleted file mode 100644 index f7ef9996..00000000 --- a/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare.py +++ /dev/null @@ -1,547 +0,0 @@ -"""Cloudflare operations API — zone settings (SSL/TLS, Speed, Caching, Security) -and one-click hardening, on top of the existing Cloudflare DNS connection. - -Zones are addressed by their ServerKit ``DNSZone`` id (same as the ``/dns`` API); -the service resolves the Cloudflare credential + zone id server-side. Reads are -available to any authenticated user; writes require admin. Every mutating call is -captured by the global audit fallback (method, route args, sanitized body). -""" -from flask import Blueprint, request, jsonify -from flask_jwt_extended import jwt_required - -from app.middleware.rbac import require_admin_user -from .cloudflare_service import CloudflareService, CloudflareError - -cloudflare_bp = Blueprint('cloudflare', __name__) - - -def _service_response(res): - """Map a service result dict to JSON. A failed *provider* call (reached - Cloudflare, got an error back) is a 502; resolution errors are raised as - CloudflareError upstream and handled separately as 400s.""" - if not res.get('success'): - return jsonify({'error': res.get('error', 'Cloudflare request failed')}), 502 - return jsonify(res) - - -# ── Zone settings ──────────────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//settings', methods=['GET']) -@jwt_required() -def get_zone_settings(zone_id): - try: - res = CloudflareService.get_settings(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//settings/apply-preset', methods=['POST']) -@jwt_required() -def apply_preset(zone_id): - require_admin_user() - try: - res = CloudflareService.apply_recommended(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - # A preset is best-effort across many toggles; return 200 with the per-setting - # report even when the plan gated some (the UI surfaces partial success). - return jsonify(res) - - -@cloudflare_bp.route('/zones//settings/', methods=['GET']) -@jwt_required() -def get_zone_setting(zone_id, setting_id): - try: - res = CloudflareService.get_setting(zone_id, setting_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//settings/', methods=['PATCH']) -@jwt_required() -def update_zone_setting(zone_id, setting_id): - require_admin_user() - data = request.get_json(silent=True) or {} - if 'value' not in data: - return jsonify({'error': 'A "value" field is required'}), 400 - try: - res = CloudflareService.update_setting(zone_id, setting_id, data['value']) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Cache ──────────────────────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//purge-cache', methods=['POST']) -@jwt_required() -def purge_cache(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.purge_cache( - zone_id, - everything=bool(data.get('purge_everything') or data.get('everything')), - files=data.get('files'), hosts=data.get('hosts'), - prefixes=data.get('prefixes'), tags=data.get('tags')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── WAF custom rules ───────────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//waf/rules', methods=['GET']) -@jwt_required() -def list_waf_rules(zone_id): - try: - res = CloudflareService.list_waf_rules(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//waf/rules', methods=['POST']) -@jwt_required() -def add_waf_rule(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.add_waf_rule( - zone_id, - description=data.get('description'), - expression=data.get('expression'), - action=data.get('action'), - enabled=data.get('enabled', True)) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//waf/presets/', methods=['POST']) -@jwt_required() -def apply_waf_preset(zone_id, preset_key): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.apply_waf_preset(zone_id, preset_key, data.get('params') or {}) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//waf/rulesets//rules/', - methods=['PATCH']) -@jwt_required() -def update_waf_rule(zone_id, ruleset_id, rule_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.update_waf_rule(zone_id, ruleset_id, rule_id, data) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//waf/rulesets//rules/', - methods=['DELETE']) -@jwt_required() -def delete_waf_rule(zone_id, ruleset_id, rule_id): - require_admin_user() - try: - res = CloudflareService.delete_waf_rule(zone_id, ruleset_id, rule_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Workers (edge hosting) ─────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//workers', methods=['GET']) -@jwt_required() -def list_workers(zone_id): - try: - res = CloudflareService.list_workers(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//workers', methods=['POST']) -@jwt_required() -def deploy_worker(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.deploy_worker( - zone_id, - name=data.get('name'), - code=data.get('code'), - compatibility_date=data.get('compatibility_date'), - route_pattern=data.get('route_pattern')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//workers/routes', methods=['POST']) -@jwt_required() -def add_worker_route(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.add_worker_route(zone_id, data.get('pattern'), data.get('script')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//workers/routes/', methods=['DELETE']) -@jwt_required() -def delete_worker_route(zone_id, route_id): - require_admin_user() - try: - res = CloudflareService.delete_worker_route(zone_id, route_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# Note: this dynamic route is registered last so it can't shadow the more -# specific /workers/routes paths above. -@cloudflare_bp.route('/zones//workers/', methods=['DELETE']) -@jwt_required() -def delete_worker(zone_id, name): - require_admin_user() - try: - res = CloudflareService.delete_worker(zone_id, name) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Tunnels (cloudflared) ──────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//tunnels', methods=['GET']) -@jwt_required() -def list_tunnels(zone_id): - try: - res = CloudflareService.list_tunnels(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//tunnels', methods=['POST']) -@jwt_required() -def create_tunnel(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.create_tunnel(zone_id, data.get('name')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//tunnels//install', methods=['GET']) -@jwt_required() -def tunnel_install(zone_id, tunnel_id): - require_admin_user() - try: - res = CloudflareService.get_tunnel_install(zone_id, tunnel_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//tunnels//hostnames', methods=['GET']) -@jwt_required() -def tunnel_hostnames(zone_id, tunnel_id): - try: - res = CloudflareService.get_tunnel_hostnames(zone_id, tunnel_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//tunnels//hostnames', methods=['POST']) -@jwt_required() -def add_tunnel_hostname(zone_id, tunnel_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.add_tunnel_hostname( - zone_id, tunnel_id, data.get('hostname'), data.get('service')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//tunnels//hostnames', methods=['DELETE']) -@jwt_required() -def remove_tunnel_hostname(zone_id, tunnel_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.remove_tunnel_hostname(zone_id, tunnel_id, data.get('hostname')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//tunnels/', methods=['DELETE']) -@jwt_required() -def delete_tunnel(zone_id, tunnel_id): - require_admin_user() - try: - res = CloudflareService.delete_tunnel(zone_id, tunnel_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Developer platform: R2 / KV / D1 ───────────────────────────────────────── - -@cloudflare_bp.route('/zones//storage', methods=['GET']) -@jwt_required() -def list_storage(zone_id): - try: - res = CloudflareService.list_storage(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//storage/r2', methods=['POST']) -@jwt_required() -def create_r2_bucket(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.create_r2_bucket(zone_id, data.get('name')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//storage/r2/', methods=['DELETE']) -@jwt_required() -def delete_r2_bucket(zone_id, bucket): - require_admin_user() - try: - res = CloudflareService.delete_r2_bucket(zone_id, bucket) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//storage/kv', methods=['POST']) -@jwt_required() -def create_kv_namespace(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.create_kv_namespace(zone_id, data.get('title')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//storage/kv/', methods=['DELETE']) -@jwt_required() -def delete_kv_namespace(zone_id, namespace_id): - require_admin_user() - try: - res = CloudflareService.delete_kv_namespace(zone_id, namespace_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//storage/d1', methods=['POST']) -@jwt_required() -def create_d1_database(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.create_d1_database(zone_id, data.get('name')) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//storage/d1/', methods=['DELETE']) -@jwt_required() -def delete_d1_database(zone_id, database_id): - require_admin_user() - try: - res = CloudflareService.delete_d1_database(zone_id, database_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── DNSSEC ──────────────────────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//dnssec', methods=['GET']) -@jwt_required() -def get_dnssec(zone_id): - try: - res = CloudflareService.get_dnssec(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//dnssec', methods=['PATCH']) -@jwt_required() -def set_dnssec(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - if 'enabled' not in data: - return jsonify({'error': 'An "enabled" boolean is required'}), 400 - try: - res = CloudflareService.set_dnssec(zone_id, bool(data['enabled'])) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Origin CA certificates ───────────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//origin-certificates', methods=['GET']) -@jwt_required() -def list_origin_certificates(zone_id): - try: - res = CloudflareService.list_origin_certificates(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//origin-certificates', methods=['POST']) -@jwt_required() -def issue_origin_certificate(zone_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.issue_origin_certificate( - zone_id, - hostnames=data.get('hostnames'), - validity_days=int(data.get('validity_days', 5475)), - install=bool(data.get('install', True))) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - except (TypeError, ValueError): - return jsonify({'error': 'validity_days must be a number'}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//origin-certificates/', - methods=['DELETE']) -@jwt_required() -def revoke_origin_certificate(zone_id, certificate_id): - require_admin_user() - try: - res = CloudflareService.revoke_origin_certificate(zone_id, certificate_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Redirect + Transform rules ───────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//rules/', methods=['GET']) -@jwt_required() -def list_rules(zone_id, slug): - try: - res = CloudflareService.list_rules(zone_id, slug) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//rules/', methods=['POST']) -@jwt_required() -def add_rule(zone_id, slug): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.add_rule( - zone_id, slug, - description=data.get('description'), - expression=data.get('expression'), - action=data.get('action'), - action_parameters=data.get('action_parameters'), - enabled=data.get('enabled', True)) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//rules//presets/', methods=['POST']) -@jwt_required() -def apply_rule_preset(zone_id, slug, preset_key): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.apply_rule_preset(zone_id, slug, preset_key, data.get('params') or {}) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//rules//rulesets//rules/', - methods=['PATCH']) -@jwt_required() -def update_rule(zone_id, slug, ruleset_id, rule_id): - require_admin_user() - data = request.get_json(silent=True) or {} - try: - res = CloudflareService.update_rule(zone_id, slug, ruleset_id, rule_id, data) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//rules//rulesets//rules/', - methods=['DELETE']) -@jwt_required() -def delete_rule(zone_id, slug, ruleset_id, rule_id): - require_admin_user() - try: - res = CloudflareService.delete_rule(zone_id, slug, ruleset_id, rule_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) - - -# ── Activity + scope diagnostics ─────────────────────────────────────────────── - -@cloudflare_bp.route('/zones//activity', methods=['GET']) -@jwt_required() -def list_activity(zone_id): - try: - res = CloudflareService.list_activity( - zone_id, - product=request.args.get('product'), - result=request.args.get('result'), - limit=min(int(request.args.get('limit', 100) or 100), 500)) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - except (TypeError, ValueError): - return jsonify({'error': 'limit must be a number'}), 400 - return _service_response(res) - - -@cloudflare_bp.route('/zones//scope-check', methods=['GET']) -@jwt_required() -def scope_check(zone_id): - try: - res = CloudflareService.scope_check(zone_id) - except CloudflareError as e: - return jsonify({'error': str(e)}), 400 - return _service_response(res) diff --git a/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare_service.py b/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare_service.py deleted file mode 100644 index a397b11c..00000000 --- a/builtin-extensions/serverkit-cloudflare-ops/backend/cloudflare_service.py +++ /dev/null @@ -1,1332 +0,0 @@ -"""Cloudflare zone operations beyond DNS records. - -ServerKit already connects Cloudflare as a DNS provider (``DNSProviderConfig`` + -the shared :class:`~app.services.dns.cloudflare.CloudflareClient`). This service -builds the *operations* surface on top of that same connection — starting with -zone settings (SSL/TLS, Speed, Caching, Security) and a one-click hardening -preset — so auth, encryption-at-rest, and credential resolution are reused, not -re-implemented. - -A zone is addressed by its ServerKit ``DNSZone`` id (the same integer the rest of -the ``/dns`` API uses); credential + Cloudflare zone id are resolved server-side -via :meth:`DNSZoneService._resolve_credential`, the canonical resolver. -""" -import logging -import os -import re - -logger = logging.getLogger(__name__) - - -class CloudflareError(Exception): - """A caller-facing problem resolving a zone (not found, not Cloudflare, no - connected credential). Mapped to a 400 by the API layer.""" - - -class CloudflareService: - """Zone settings + hardening on a connected Cloudflare zone.""" - - # Curated subset of Cloudflare zone settings ServerKit surfaces, grouped for - # the UI. Each setting: ``id`` (Cloudflare setting id), ``label``, ``type`` - # (toggle | select | hsts) and, for selects, ``options`` ({value, label}). - # The page renders straight from this metadata; current values + the - # ``editable`` (plan-gating) flag come from the live settings response. - SETTING_GROUPS = [ - { - 'key': 'ssl', - 'label': 'SSL/TLS', - 'settings': [ - {'id': 'ssl', 'label': 'SSL/TLS encryption mode', 'type': 'select', - 'help': 'How Cloudflare connects to your origin. "Full (strict)" ' - 'is the most secure and requires a valid origin certificate.', - 'options': [ - {'value': 'off', 'label': 'Off (not secure)'}, - {'value': 'flexible', 'label': 'Flexible'}, - {'value': 'full', 'label': 'Full'}, - {'value': 'strict', 'label': 'Full (strict)'}, - ]}, - {'id': 'always_use_https', 'label': 'Always use HTTPS', 'type': 'toggle', - 'help': 'Redirect every HTTP request to HTTPS.'}, - {'id': 'automatic_https_rewrites', 'label': 'Automatic HTTPS rewrites', - 'type': 'toggle', - 'help': 'Rewrite insecure http:// links to https:// to avoid mixed content.'}, - {'id': 'min_tls_version', 'label': 'Minimum TLS version', 'type': 'select', - 'options': [ - {'value': '1.0', 'label': 'TLS 1.0'}, - {'value': '1.1', 'label': 'TLS 1.1'}, - {'value': '1.2', 'label': 'TLS 1.2 (recommended)'}, - {'value': '1.3', 'label': 'TLS 1.3'}, - ]}, - {'id': 'tls_1_3', 'label': 'TLS 1.3', 'type': 'toggle', - 'help': 'Enable the latest, fastest TLS version.'}, - {'id': 'security_header', 'label': 'HTTP Strict Transport Security (HSTS)', - 'type': 'hsts', - 'help': 'Tell browsers to only ever connect over HTTPS. Enable only once ' - 'HTTPS works everywhere — it is hard to undo before max-age expires.'}, - ], - }, - { - 'key': 'speed', - 'label': 'Speed', - 'settings': [ - {'id': 'brotli', 'label': 'Brotli compression', 'type': 'toggle', - 'help': 'Compress responses with Brotli for supporting browsers.'}, - {'id': 'early_hints', 'label': 'Early Hints', 'type': 'toggle', - 'help': 'Send 103 Early Hints so browsers can preload assets sooner.'}, - {'id': 'http3', 'label': 'HTTP/3 (with QUIC)', 'type': 'toggle'}, - ], - }, - { - 'key': 'caching', - 'label': 'Caching', - 'settings': [ - {'id': 'cache_level', 'label': 'Caching level', 'type': 'select', - 'options': [ - {'value': 'bypass', 'label': 'Bypass'}, - {'value': 'basic', 'label': 'Basic'}, - {'value': 'simplified', 'label': 'Simplified'}, - {'value': 'aggressive', 'label': 'Aggressive (recommended)'}, - {'value': 'cache_everything', 'label': 'Cache everything'}, - ]}, - {'id': 'browser_cache_ttl', 'label': 'Browser cache TTL', 'type': 'select', - 'options': [ - {'value': 0, 'label': 'Respect existing headers'}, - {'value': 1800, 'label': '30 minutes'}, - {'value': 3600, 'label': '1 hour'}, - {'value': 14400, 'label': '4 hours'}, - {'value': 28800, 'label': '8 hours'}, - {'value': 86400, 'label': '1 day'}, - {'value': 604800, 'label': '1 week'}, - ]}, - {'id': 'development_mode', 'label': 'Development mode', 'type': 'toggle', - 'help': 'Temporarily bypass the cache while you work. Auto-expires after 3 hours.'}, - {'id': 'always_online', 'label': 'Always Online', 'type': 'toggle', - 'help': 'Serve a cached copy of your site if your origin is unreachable.'}, - ], - }, - { - 'key': 'security', - 'label': 'Security', - 'settings': [ - {'id': 'security_level', 'label': 'Security level', 'type': 'select', - 'options': [ - {'value': 'off', 'label': 'Off'}, - {'value': 'essentially_off', 'label': 'Essentially off'}, - {'value': 'low', 'label': 'Low'}, - {'value': 'medium', 'label': 'Medium'}, - {'value': 'high', 'label': 'High'}, - {'value': 'under_attack', 'label': "I'm under attack"}, - ]}, - {'id': 'browser_check', 'label': 'Browser integrity check', 'type': 'toggle', - 'help': 'Block requests from common malicious bots and crawlers.'}, - {'id': 'challenge_ttl', 'label': 'Challenge passage', 'type': 'select', - 'help': 'How long a visitor stays verified after passing a challenge.', - 'options': [ - {'value': 300, 'label': '5 minutes'}, - {'value': 900, 'label': '15 minutes'}, - {'value': 1800, 'label': '30 minutes'}, - {'value': 3600, 'label': '1 hour'}, - {'value': 7200, 'label': '2 hours'}, - {'value': 10800, 'label': '3 hours'}, - {'value': 14400, 'label': '4 hours'}, - {'value': 28800, 'label': '8 hours'}, - {'value': 86400, 'label': '1 day'}, - ]}, - ], - }, - ] - - # One-click hardening (plan §Phase 1 Actions): Full (strict), Always HTTPS, - # HSTS (6 months), TLS 1.2 floor + 1.3, Brotli, HTTP/3, 4h browser cache. - RECOMMENDED_PRESET = [ - ('ssl', 'strict'), - ('always_use_https', 'on'), - ('automatic_https_rewrites', 'on'), - ('min_tls_version', '1.2'), - ('tls_1_3', 'on'), - ('brotli', 'on'), - ('http3', 'on'), - ('browser_cache_ttl', 14400), - ('security_header', {'strict_transport_security': { - 'enabled': True, 'max_age': 15552000, - 'include_subdomains': True, 'preload': False, 'nosniff': True}}), - ] - - @staticmethod - def _zone_and_client(zone_id): - """Resolve ``(zone, CloudflareClient)`` for a ServerKit DNS zone id, or raise - :class:`CloudflareError` with a user-facing reason. Credential resolution - reuses the canonical resolver so the connection store is the single source - of truth.""" - from app.services.dns_zone_service import DNSZoneService - from app.services.dns import CloudflareClient - - zone = DNSZoneService.get_zone(zone_id) - if not zone: - raise CloudflareError('Zone not found') - if (zone.provider or '').lower() != 'cloudflare': - raise CloudflareError('This zone is not managed by Cloudflare') - credential = DNSZoneService._resolve_credential(zone) - if not credential: - raise CloudflareError('No connected Cloudflare credential resolves for this zone') - if not zone.provider_zone_id: - raise CloudflareError("Cloudflare hasn't been matched to this domain yet — " - 'open the DNS zone once to link it, then retry') - return zone, CloudflareClient(credential) - - @staticmethod - def _zone_dict(zone): - return {'id': zone.id, 'domain': zone.domain, - 'provider_zone_id': zone.provider_zone_id} - - @staticmethod - def _record(zone, product, action, target=None, result='ok', error=None): - """Best-effort append to the Cloudflare-ops activity ledger. Never raises — - an audit write must not break the operation it describes.""" - try: - from app.services.cf_ops_change_service import CfOpsChangeService - CfOpsChangeService.record( - provider_zone_id=getattr(zone, 'provider_zone_id', None), - product=product, action=action, target=target, - result=result, error=error, - config_id=getattr(zone, 'dns_provider_config_id', None)) - except Exception: # pragma: no cover - ledger writes are best-effort - pass - - @classmethod - def get_settings(cls, zone_id): - """Live zone settings, indexed by id, plus the UI grouping metadata.""" - zone, client = cls._zone_and_client(zone_id) - res = client.get_zone_settings(zone.provider_zone_id) - if not res.get('success'): - return {'success': False, 'error': res.get('error', 'Failed to load zone settings')} - by_id = {s.get('id'): s for s in (res.get('result') or []) if isinstance(s, dict)} - return {'success': True, 'zone': cls._zone_dict(zone), - 'groups': cls.SETTING_GROUPS, 'settings': by_id} - - @classmethod - def get_setting(cls, zone_id, setting_id): - zone, client = cls._zone_and_client(zone_id) - res = client.get_zone_setting(zone.provider_zone_id, setting_id) - if not res.get('success'): - return {'success': False, 'error': res.get('error', 'Failed to load setting')} - return {'success': True, 'setting': res.get('result')} - - @classmethod - def update_setting(cls, zone_id, setting_id, value): - zone, client = cls._zone_and_client(zone_id) - res = client.update_zone_setting(zone.provider_zone_id, setting_id, value) - if not res.get('success'): - cls._record(zone, 'settings', 'update', setting_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Update failed')} - cls._record(zone, 'settings', 'update', setting_id) - return {'success': True, 'setting': res.get('result')} - - @classmethod - def apply_recommended(cls, zone_id): - """Apply the recommended hardening preset, returning a per-setting report so - the UI can show which toggles the plan allowed and which it gated.""" - zone, client = cls._zone_and_client(zone_id) - results = [] - for setting_id, value in cls.RECOMMENDED_PRESET: - res = client.update_zone_setting(zone.provider_zone_id, setting_id, value) - results.append({'setting': setting_id, - 'success': bool(res.get('success')), - 'error': None if res.get('success') else res.get('error')}) - applied = sum(1 for r in results if r['success']) - cls._record(zone, 'settings', 'apply-preset', 'recommended', - 'ok' if applied else 'error', - None if applied else 'No settings applied') - return {'success': applied > 0, 'applied': applied, - 'total': len(results), 'results': results} - - # Free/Pro plans can purge everything or up to 30 individual files per request; - # hosts/prefixes/tags are Enterprise-only (Cloudflare returns a plan error, - # which we surface verbatim). - MAX_PURGE_FILES = 30 - - @classmethod - def purge_cache(cls, zone_id, *, everything=False, files=None, hosts=None, - prefixes=None, tags=None): - """Purge the zone's Cloudflare cache. Either ``everything`` or one/more of - ``files``/``hosts``/``prefixes``/``tags``. Raises :class:`CloudflareError` - when nothing was requested (a caller error).""" - zone, client = cls._zone_and_client(zone_id) - - if everything: - payload = {'purge_everything': True} - else: - payload = {} - clean = [f.strip() for f in (files or []) if f and f.strip()] - if clean: - payload['files'] = clean[:cls.MAX_PURGE_FILES] - for key, val in (('hosts', hosts), ('prefixes', prefixes), ('tags', tags)): - items = [v.strip() for v in (val or []) if v and v.strip()] - if items: - payload[key] = items - if not payload: - raise CloudflareError('Nothing to purge — choose "everything" or ' - 'provide files, hosts, prefixes, or tags') - - res = client.purge_cache(zone.provider_zone_id, payload) - target = 'everything' if everything else 'files' - if not res.get('success'): - cls._record(zone, 'cache', 'purge', target, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Cache purge failed')} - cls._record(zone, 'cache', 'purge', target) - return {'success': True, 'purged': payload} - - # ── WAF custom rules ───────────────────────────────────────────────────── - - WAF_PHASE = 'http_request_firewall_custom' - # Actions ServerKit lets you set on a custom rule (a safe subset of - # Cloudflare's). Terminal actions only — no "skip", which can disable WAF. - WAF_ACTIONS = {'block', 'managed_challenge', 'js_challenge', 'challenge', 'log'} - - # One-click rule templates (plan §Phase 3). ``params`` are collected from the - # admin and validated before being interpolated into a Cloudflare expression. - WAF_PRESETS = [ - { - 'key': 'lock_wp_admin', - 'label': 'Lock WordPress admin to an IP', - 'description': 'Block /wp-admin and /wp-login.php for everyone except a ' - 'trusted IP address (e.g. your office or home).', - 'action': 'block', - 'params': [{'key': 'ip', 'label': 'Allowed IP address', - 'placeholder': 'e.g. 203.0.113.7'}], - }, - { - 'key': 'block_exploit_paths', - 'label': 'Block common exploit paths', - 'description': 'Block requests probing for /xmlrpc.php, dotfiles like ' - '/.env and /.git, and exposed config files.', - 'action': 'block', - 'params': [], - }, - { - 'key': 'challenge_bad_bots', - 'label': 'Challenge suspicious bots', - 'description': 'Show a managed challenge to traffic with a low bot score. ' - 'Requires Cloudflare Bot Management on some plans.', - 'action': 'managed_challenge', - 'params': [], - }, - ] - - @classmethod - def _validate_action(cls, action): - if action not in cls.WAF_ACTIONS: - raise CloudflareError( - f'Unsupported action "{action}". Use one of: {", ".join(sorted(cls.WAF_ACTIONS))}') - - @classmethod - def _build_preset_rule(cls, key, params): - """Turn a preset key + admin-supplied params into a concrete rule dict. - Validates any user input that lands inside a Cloudflare expression (the IP) - so a preset can't be used to inject expression syntax.""" - import ipaddress - if key == 'lock_wp_admin': - ip = (params.get('ip') or '').strip() - try: - ipaddress.ip_address(ip) - except ValueError: - raise CloudflareError('A valid IP address is required for this rule') - return { - 'description': 'ServerKit: lock WordPress admin to a trusted IP', - 'expression': ('(http.request.uri.path contains "/wp-admin" or ' - 'http.request.uri.path contains "/wp-login.php") ' - f'and ip.src ne {ip}'), - 'action': 'block', - } - if key == 'block_exploit_paths': - return { - 'description': 'ServerKit: block common exploit paths', - 'expression': ('(http.request.uri.path contains "/xmlrpc.php") or ' - '(http.request.uri.path contains "/.env") or ' - '(http.request.uri.path contains "/.git/") or ' - '(http.request.uri.path contains "/wp-config.php")'), - 'action': 'block', - } - if key == 'challenge_bad_bots': - return { - 'description': 'ServerKit: challenge suspicious bots', - 'expression': '(cf.bot_management.score lt 30)', - 'action': 'managed_challenge', - } - raise CloudflareError(f'Unknown WAF preset: {key}') - - @classmethod - def _find_custom_ruleset(cls, client, provider_zone_id, phase=None): - """Return ``(ruleset_dict, listing_error)``. ``ruleset_dict`` is the zone's - custom-firewall entry-point ruleset or ``None`` when it doesn't exist yet; - ``listing_error`` is set only when the list call itself failed (auth/scope).""" - phase = phase or cls.WAF_PHASE - listing = client.list_rulesets(provider_zone_id) - if not listing.get('success'): - return None, listing.get('error', 'Failed to list rulesets') - custom = next((rs for rs in (listing.get('result') or []) - if rs.get('phase') == phase and rs.get('kind') == 'zone'), None) - return custom, None - - @staticmethod - def _rule_dict(r): - return {'id': r.get('id'), 'description': r.get('description'), - 'expression': r.get('expression'), 'action': r.get('action'), - 'enabled': r.get('enabled', True), 'ref': r.get('ref')} - - @classmethod - def list_waf_rules(cls, zone_id): - """Custom firewall rules for the zone, plus the preset catalog. A zone with - no custom ruleset yet returns an empty list (not an error).""" - zone, client = cls._zone_and_client(zone_id) - custom, err = cls._find_custom_ruleset(client, zone.provider_zone_id) - if err: - return {'success': False, 'error': err} - if not custom: - return {'success': True, 'ruleset_id': None, 'rules': [], - 'presets': cls.WAF_PRESETS} - detail = client.get_ruleset(zone.provider_zone_id, custom['id']) - if not detail.get('success'): - return {'success': False, 'error': detail.get('error', 'Failed to load ruleset')} - result = detail.get('result') or {} - rules = [cls._rule_dict(r) for r in (result.get('rules') or [])] - return {'success': True, 'ruleset_id': result.get('id'), - 'rules': rules, 'presets': cls.WAF_PRESETS} - - @classmethod - def add_waf_rule(cls, zone_id, *, description, expression, action, enabled=True): - """Append a custom firewall rule, creating the zone's custom ruleset on first - use. Returns the created rule's ruleset on success.""" - cls._validate_action(action) - if not (expression or '').strip(): - raise CloudflareError('A rule expression is required') - zone, client = cls._zone_and_client(zone_id) - rule = {'description': description or 'ServerKit rule', - 'expression': expression, 'action': action, 'enabled': bool(enabled)} - - custom, err = cls._find_custom_ruleset(client, zone.provider_zone_id) - if err: - return {'success': False, 'error': err} - if custom: - res = client.add_ruleset_rule(zone.provider_zone_id, custom['id'], rule) - else: - res = client.create_phase_ruleset(zone.provider_zone_id, cls.WAF_PHASE, [rule]) - if not res.get('success'): - cls._record(zone, 'waf', 'add-rule', rule['description'], 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to add rule')} - cls._record(zone, 'waf', 'add-rule', rule['description']) - return {'success': True, 'result': res.get('result')} - - @classmethod - def apply_waf_preset(cls, zone_id, preset_key, params=None): - rule = cls._build_preset_rule(preset_key, params or {}) - return cls.add_waf_rule(zone_id, description=rule['description'], - expression=rule['expression'], action=rule['action']) - - @classmethod - def update_waf_rule(cls, zone_id, ruleset_id, rule_id, fields): - """Patch a custom rule. Only known fields are forwarded; an ``action``, if - present, is validated.""" - zone, client = cls._zone_and_client(zone_id) - rule = {} - for key in ('description', 'expression', 'action', 'enabled'): - if key in fields: - rule[key] = fields[key] - if 'action' in rule: - cls._validate_action(rule['action']) - if not rule: - raise CloudflareError('No updatable fields provided') - res = client.update_ruleset_rule(zone.provider_zone_id, ruleset_id, rule_id, rule) - if not res.get('success'): - cls._record(zone, 'waf', 'update-rule', rule_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to update rule')} - cls._record(zone, 'waf', 'update-rule', rule_id) - return {'success': True, 'result': res.get('result')} - - @classmethod - def delete_waf_rule(cls, zone_id, ruleset_id, rule_id): - zone, client = cls._zone_and_client(zone_id) - res = client.delete_ruleset_rule(zone.provider_zone_id, ruleset_id, rule_id) - if not res.get('success'): - cls._record(zone, 'waf', 'delete-rule', rule_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete rule')} - cls._record(zone, 'waf', 'delete-rule', rule_id) - return {'success': True} - - # ── Workers (edge hosting) ─────────────────────────────────────────────── - # Workers are account-scoped; the owning account is read from the zone, so the - # whole feature reuses the same Cloudflare connection the DNS zone already has. - - WORKER_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,62}$') - DEFAULT_COMPAT_DATE = '2025-01-01' - - @classmethod - def _account_id(cls, zone, client): - """The Cloudflare account that owns ``zone`` (for account-scoped resources).""" - res = client.get_zone_account_id(zone.provider_zone_id) - if not res.get('success'): - raise CloudflareError( - res.get('error') or 'Could not resolve the Cloudflare account for this zone') - acct = ((res.get('result') or {}).get('account') or {}).get('id') - if not acct: - raise CloudflareError('Cloudflare did not return an account for this zone') - return acct - - @classmethod - def list_workers(cls, zone_id): - """Live Worker scripts in the zone's account (flagged when ServerKit manages - them), plus the zone's Worker routes.""" - from app.models.cloudflare_worker import CloudflareWorker - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.list_worker_scripts(account_id) - if not res.get('success'): - return {'success': False, 'error': res.get('error', 'Failed to list workers')} - managed = {w.name: w for w in - CloudflareWorker.query.filter_by(account_id=account_id).all()} - scripts = [] - for s in (res.get('result') or []): - name = s.get('id') # Cloudflare returns the script name in `id` - rec = managed.get(name) - scripts.append({'name': name, 'created_on': s.get('created_on'), - 'modified_on': s.get('modified_on'), - 'managed': rec is not None, - 'has_source': bool(rec and rec.source)}) - routes = [] - rres = client.list_worker_routes(zone.provider_zone_id) - if rres.get('success'): - routes = [{'id': r.get('id'), 'pattern': r.get('pattern'), 'script': r.get('script')} - for r in (rres.get('result') or [])] - return {'success': True, 'account_id': account_id, 'workers': scripts, 'routes': routes} - - @classmethod - def deploy_worker(cls, zone_id, *, name, code, compatibility_date=None, route_pattern=None): - """Upload a module Worker, record the source locally, and optionally attach a - route in this zone.""" - from app import db - from app.models.cloudflare_worker import CloudflareWorker - - name = (name or '').strip().lower() - if not cls.WORKER_NAME_RE.match(name): - raise CloudflareError('Worker name must be 1–63 chars: lowercase letters, ' - 'digits, hyphens or underscores, starting alphanumeric') - if not (code or '').strip(): - raise CloudflareError('Worker code is required') - compat = compatibility_date or cls.DEFAULT_COMPAT_DATE - - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.upload_worker_module(account_id, name, code, compat) - if not res.get('success'): - cls._record(zone, 'workers', 'deploy', name, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Worker upload failed')} - - rec = CloudflareWorker.query.filter_by(account_id=account_id, name=name).first() - if not rec: - rec = CloudflareWorker(account_id=account_id, name=name, - dns_provider_config_id=zone.dns_provider_config_id) - db.session.add(rec) - rec.source = code - rec.compatibility_date = compat - db.session.commit() - - route = None - if route_pattern and route_pattern.strip(): - rr = client.add_worker_route(zone.provider_zone_id, route_pattern.strip(), name) - route = {'success': bool(rr.get('success')), - 'error': None if rr.get('success') else rr.get('error')} - cls._record(zone, 'workers', 'deploy', name) - return {'success': True, 'worker': rec.to_dict(), 'route': route} - - @classmethod - def delete_worker(cls, zone_id, name): - from app import db - from app.models.cloudflare_worker import CloudflareWorker - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.delete_worker_script(account_id, name) - if not res.get('success'): - cls._record(zone, 'workers', 'delete', name, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete worker')} - rec = CloudflareWorker.query.filter_by(account_id=account_id, name=name).first() - if rec: - db.session.delete(rec) - db.session.commit() - cls._record(zone, 'workers', 'delete', name) - return {'success': True} - - @classmethod - def add_worker_route(cls, zone_id, pattern, script): - if not (pattern or '').strip() or not (script or '').strip(): - raise CloudflareError('Both a route pattern and a worker name are required') - zone, client = cls._zone_and_client(zone_id) - res = client.add_worker_route(zone.provider_zone_id, pattern.strip(), script.strip()) - if not res.get('success'): - cls._record(zone, 'workers', 'add-route', pattern.strip(), 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to add route')} - cls._record(zone, 'workers', 'add-route', pattern.strip()) - return {'success': True, 'result': res.get('result')} - - @classmethod - def delete_worker_route(cls, zone_id, route_id): - zone, client = cls._zone_and_client(zone_id) - res = client.delete_worker_route(zone.provider_zone_id, route_id) - if not res.get('success'): - cls._record(zone, 'workers', 'delete-route', route_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete route')} - cls._record(zone, 'workers', 'delete-route', route_id) - return {'success': True} - - # ── Tunnels (cloudflared) ──────────────────────────────────────────────── - # Cloudflare Tunnels expose a local/private service through Cloudflare's edge - # without a public IP. Distinct from ServerKit's WireGuard remote-access - # tunnels. Account-scoped; account resolved from the zone. - - @staticmethod - def _install_command(token): - """The one-liner an operator runs on the target host to attach a connector.""" - if not token: - return None - return f'cloudflared service install {token}' - - @classmethod - def list_tunnels(cls, zone_id): - from app.models.cloudflare_tunnel import CloudflareTunnel - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.list_tunnels(account_id) - if not res.get('success'): - return {'success': False, 'error': res.get('error', 'Failed to list tunnels')} - managed = {t.tunnel_id for t in - CloudflareTunnel.query.filter_by(account_id=account_id).all()} - tunnels = [{'id': t.get('id'), 'name': t.get('name'), 'status': t.get('status'), - 'created_at': t.get('created_at'), - 'connections': len(t.get('connections') or []), - 'managed': t.get('id') in managed} - for t in (res.get('result') or [])] - return {'success': True, 'account_id': account_id, 'tunnels': tunnels} - - @classmethod - def create_tunnel(cls, zone_id, name): - """Create a tunnel and return the connector token + install command (the - token is revealed once here and stored encrypted for later).""" - from app import db - from app.models.cloudflare_tunnel import CloudflareTunnel - from app.utils.crypto import encrypt_secret - - name = (name or '').strip() - if not name: - raise CloudflareError('A tunnel name is required') - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.create_tunnel(account_id, name) - if not res.get('success'): - cls._record(zone, 'tunnels', 'create', name, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to create tunnel')} - result = res.get('result') or {} - tunnel_id = result.get('id') - token = result.get('token') - if not token and tunnel_id: - tres = client.get_tunnel_token(account_id, tunnel_id) - token = tres.get('result') if tres.get('success') else None - - rec = CloudflareTunnel( - tunnel_id=tunnel_id, name=name, account_id=account_id, - dns_provider_config_id=zone.dns_provider_config_id, - token_encrypted=encrypt_secret(token) if token else None) - db.session.add(rec) - db.session.commit() - cls._record(zone, 'tunnels', 'create', name) - return {'success': True, 'tunnel': rec.to_dict(), - 'token': token, 'install': cls._install_command(token)} - - @classmethod - def get_tunnel_install(cls, zone_id, tunnel_id): - """Re-fetch the connector token + install command for a tunnel.""" - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - tres = client.get_tunnel_token(account_id, tunnel_id) - if not tres.get('success'): - return {'success': False, 'error': tres.get('error', 'Failed to fetch token')} - token = tres.get('result') - return {'success': True, 'token': token, 'install': cls._install_command(token)} - - @classmethod - def delete_tunnel(cls, zone_id, tunnel_id): - from app import db - from app.models.cloudflare_tunnel import CloudflareTunnel - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.delete_tunnel(account_id, tunnel_id) - if not res.get('success'): - cls._record(zone, 'tunnels', 'delete', tunnel_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete tunnel')} - rec = CloudflareTunnel.query.filter_by(account_id=account_id, tunnel_id=tunnel_id).first() - if rec: - db.session.delete(rec) - db.session.commit() - cls._record(zone, 'tunnels', 'delete', tunnel_id) - return {'success': True} - - @classmethod - def get_tunnel_hostnames(cls, zone_id, tunnel_id): - """The public-hostname ingress rules configured on a tunnel.""" - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.get_tunnel_configuration(account_id, tunnel_id) - if not res.get('success'): - return {'success': False, 'error': res.get('error', 'Failed to load tunnel config')} - ingress = ((res.get('result') or {}).get('config') or {}).get('ingress') or [] - hostnames = [{'hostname': r.get('hostname'), 'service': r.get('service')} - for r in ingress if r.get('hostname')] - return {'success': True, 'hostnames': hostnames} - - @classmethod - def add_tunnel_hostname(cls, zone_id, tunnel_id, hostname, service): - """Route a public hostname to a local service through the tunnel, and - best-effort create the proxied CNAME that points it at the tunnel.""" - hostname = (hostname or '').strip().lower().rstrip('.') - service = (service or '').strip() - if not hostname or not service: - raise CloudflareError('Both a hostname and a service ' - '(e.g. http://localhost:8080) are required') - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - - cur = client.get_tunnel_configuration(account_id, tunnel_id) - config = ((cur.get('result') or {}).get('config') or {}) if cur.get('success') else {} - # Keep only real hostname rules, replace/insert this one, re-add catch-all. - ingress = [r for r in (config.get('ingress') or []) - if r.get('hostname') and r.get('hostname') != hostname] - ingress.append({'hostname': hostname, 'service': service}) - ingress.append({'service': 'http_status:404'}) - config['ingress'] = ingress - - res = client.put_tunnel_configuration(account_id, tunnel_id, config) - if not res.get('success'): - cls._record(zone, 'tunnels', 'add-hostname', hostname, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to set tunnel route')} - dns = cls._ensure_tunnel_cname(zone, client, hostname, tunnel_id) - cls._record(zone, 'tunnels', 'add-hostname', hostname) - return {'success': True, 'dns': dns} - - @classmethod - def remove_tunnel_hostname(cls, zone_id, tunnel_id, hostname): - hostname = (hostname or '').strip().lower().rstrip('.') - if not hostname: - raise CloudflareError('A hostname is required') - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - cur = client.get_tunnel_configuration(account_id, tunnel_id) - config = ((cur.get('result') or {}).get('config') or {}) if cur.get('success') else {} - ingress = [r for r in (config.get('ingress') or []) - if r.get('hostname') and r.get('hostname') != hostname] - ingress.append({'service': 'http_status:404'}) - config['ingress'] = ingress - res = client.put_tunnel_configuration(account_id, tunnel_id, config) - if not res.get('success'): - cls._record(zone, 'tunnels', 'remove-hostname', hostname, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to remove tunnel route')} - cls._record(zone, 'tunnels', 'remove-hostname', hostname) - return {'success': True} - - @staticmethod - def _ensure_tunnel_cname(zone, client, hostname, tunnel_id): - """Best-effort: upsert the proxied CNAME ``hostname → .cfargotunnel.com`` - that publishes the tunnel. Reported, never fatal — the route is already set.""" - try: - from app.services.dns.base import DnsRecordSpec - from app.services.dns_ownership_service import DnsOwnershipService - target = f'{tunnel_id}.cfargotunnel.com' - spec = DnsRecordSpec(record_type='CNAME', name=hostname, content=target, - ttl=1, proxied=True) - res = DnsOwnershipService.guarded_upsert( - client, provider='cloudflare', provider_zone_id=zone.provider_zone_id, - spec=spec, source='cf-tunnel', config_id=zone.dns_provider_config_id, - allow_foreign=True) - return {'created': bool(res.get('success')), - 'error': None if res.get('success') else res.get('error')} - except Exception as e: - return {'created': False, 'error': str(e)} - - # ── Developer platform: R2 / KV / D1 ───────────────────────────────────── - # Account-scoped storage. Management only — listing the inventory and - # creating/deleting resources. R2 is S3-compatible, so a bucket made here can - # later back ServerKit backups via the existing S3 storage backend (a separate - # follow-up that mints scoped R2 access keys). - - R2_BUCKET_RE = re.compile(r'^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$') - - @classmethod - def list_storage(cls, zone_id): - """Combined developer-platform inventory. Each product is fetched - independently so a missing token scope degrades to a per-product error - rather than failing the whole tab.""" - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - out = {'success': True, 'account_id': account_id, - 'r2': [], 'kv': [], 'd1': [], 'errors': {}} - - r2 = client.list_r2_buckets(account_id) - if r2.get('success'): - out['r2'] = [{'name': b.get('name'), 'creation_date': b.get('creation_date')} - for b in ((r2.get('result') or {}).get('buckets') or [])] - else: - out['errors']['r2'] = r2.get('error') - - kv = client.list_kv_namespaces(account_id) - if kv.get('success'): - out['kv'] = [{'id': n.get('id'), 'title': n.get('title')} - for n in (kv.get('result') or [])] - else: - out['errors']['kv'] = kv.get('error') - - d1 = client.list_d1_databases(account_id) - if d1.get('success'): - out['d1'] = [{'uuid': d.get('uuid'), 'name': d.get('name')} - for d in (d1.get('result') or [])] - else: - out['errors']['d1'] = d1.get('error') - return out - - @classmethod - def create_r2_bucket(cls, zone_id, name): - name = (name or '').strip().lower() - if not cls.R2_BUCKET_RE.match(name): - raise CloudflareError('Bucket name must be 3–63 chars: lowercase letters, ' - 'digits and hyphens, starting and ending alphanumeric') - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.create_r2_bucket(account_id, name) - if not res.get('success'): - cls._record(zone, 'storage', 'create-r2', name, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to create bucket')} - cls._record(zone, 'storage', 'create-r2', name) - return {'success': True, 'bucket': name} - - @classmethod - def delete_r2_bucket(cls, zone_id, name): - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.delete_r2_bucket(account_id, name) - if not res.get('success'): - cls._record(zone, 'storage', 'delete-r2', name, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete bucket')} - cls._record(zone, 'storage', 'delete-r2', name) - return {'success': True} - - @classmethod - def create_kv_namespace(cls, zone_id, title): - title = (title or '').strip() - if not title: - raise CloudflareError('A namespace title is required') - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.create_kv_namespace(account_id, title) - if not res.get('success'): - cls._record(zone, 'storage', 'create-kv', title, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to create namespace')} - cls._record(zone, 'storage', 'create-kv', title) - return {'success': True, 'namespace': res.get('result')} - - @classmethod - def delete_kv_namespace(cls, zone_id, namespace_id): - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.delete_kv_namespace(account_id, namespace_id) - if not res.get('success'): - cls._record(zone, 'storage', 'delete-kv', namespace_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete namespace')} - cls._record(zone, 'storage', 'delete-kv', namespace_id) - return {'success': True} - - @classmethod - def create_d1_database(cls, zone_id, name): - name = (name or '').strip() - if not name: - raise CloudflareError('A database name is required') - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.create_d1_database(account_id, name) - if not res.get('success'): - cls._record(zone, 'storage', 'create-d1', name, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to create database')} - cls._record(zone, 'storage', 'create-d1', name) - return {'success': True, 'database': res.get('result')} - - @classmethod - def delete_d1_database(cls, zone_id, database_id): - zone, client = cls._zone_and_client(zone_id) - account_id = cls._account_id(zone, client) - res = client.delete_d1_database(account_id, database_id) - if not res.get('success'): - cls._record(zone, 'storage', 'delete-d1', database_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete database')} - cls._record(zone, 'storage', 'delete-d1', database_id) - return {'success': True} - - # ── DNSSEC ──────────────────────────────────────────────────────────────── - # Display-only: enabling DNSSEC returns the DS record the operator must place - # at their registrar. No registrar automation. - - @staticmethod - def _normalize_dnssec(r): - """Flatten Cloudflare's DNSSEC ``result`` to the fields the DS card needs.""" - r = r or {} - return { - 'status': r.get('status'), # active | pending | pending-disabled | disabled - 'ds': r.get('ds'), - 'digest': r.get('digest'), - 'digest_type': r.get('digest_type'), - 'digest_algorithm': r.get('digest_algorithm'), - 'algorithm': r.get('algorithm'), - 'key_tag': r.get('key_tag'), - 'key_type': r.get('key_type'), - 'public_key': r.get('public_key'), - 'flags': r.get('flags'), - } - - @classmethod - def get_dnssec(cls, zone_id): - zone, client = cls._zone_and_client(zone_id) - res = client.get_dnssec(zone.provider_zone_id) - if not res.get('success'): - return {'success': False, 'error': res.get('error', 'Failed to load DNSSEC status')} - return {'success': True, 'zone': cls._zone_dict(zone), - 'dnssec': cls._normalize_dnssec(res.get('result'))} - - @classmethod - def set_dnssec(cls, zone_id, enabled): - """Enable or disable DNSSEC. Returns the DS record payload on enable.""" - zone, client = cls._zone_and_client(zone_id) - action = 'enable' if enabled else 'disable' - res = client.set_dnssec(zone.provider_zone_id, 'active' if enabled else 'disabled') - if not res.get('success'): - cls._record(zone, 'dnssec', action, zone.domain, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to update DNSSEC')} - cls._record(zone, 'dnssec', action, zone.domain) - return {'success': True, 'dnssec': cls._normalize_dnssec(res.get('result'))} - - # ── Origin CA certificates ──────────────────────────────────────────────── - # Issue-and-install, honest about trust: Origin CA certs are valid ONLY behind - # the Cloudflare proxy. The CSR is generated panel-side; the private key never - # leaves the box (only the CSR is sent to Cloudflare). Install reuses the - # existing custom-cert seam (Linux-only). A credential lacking the Origin CA - # permission is surfaced as an actionable 400 (CloudflareError), never a 502. - - # Cloudflare's accepted Origin CA validity windows, in days (7d … 15y). - ORIGIN_CA_VALIDITY_DAYS = [7, 30, 90, 365, 730, 1095, 5475] - - # Substrings that mark a provider error as a credential/scope problem rather - # than a transient provider failure — surfaced as a scoped 400. - _SCOPE_ERROR_MARKERS = ( - 'permission', 'not authorized', 'unauthorized', 'authentication', - 'not allowed', 'insufficient', 'invalid api', 'access denied', - 'forbidden', 'origin ca', 'service key', 'scope', - ) - - @classmethod - def _is_scope_error(cls, error): - e = (error or '').lower() - return any(m in e for m in cls._SCOPE_ERROR_MARKERS) - - @staticmethod - def _origin_ca_service_key(zone): - """The optional account-level Origin CA service key stored (Fernet) on the - zone's DNS provider connection, or ``None`` (a suitably-scoped token also - authenticates the Origin CA endpoints).""" - try: - from app.models.email import DNSProviderConfig - from app.utils.crypto import decrypt_secret_safe - cfg_id = getattr(zone, 'dns_provider_config_id', None) - cfg = DNSProviderConfig.query.get(cfg_id) if cfg_id else None - if cfg and getattr(cfg, 'origin_ca_key', None): - return decrypt_secret_safe(cfg.origin_ca_key) - except Exception: - pass - return None - - @staticmethod - def _generate_csr(hostnames): - """Generate an RSA private key + CSR for ``hostnames`` panel-side. Returns - ``(csr_pem, key_pem)``; the key is kept locally for install and never sent - to Cloudflare.""" - from cryptography import x509 - from cryptography.x509.oid import NameOID - from cryptography.hazmat.primitives import hashes, serialization - from cryptography.hazmat.primitives.asymmetric import rsa - - key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - builder = x509.CertificateSigningRequestBuilder().subject_name( - x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostnames[0])])) - builder = builder.add_extension( - x509.SubjectAlternativeName([x509.DNSName(h) for h in hostnames]), critical=False) - csr = builder.sign(key, hashes.SHA256()) - csr_pem = csr.public_bytes(serialization.Encoding.PEM).decode() - key_pem = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption()).decode() - return csr_pem, key_pem - - @staticmethod - def _proxy_warnings(zone, client, hostnames): - """Best-effort: warn when a covered hostname isn't proxied (grey cloud) or - has no DNS record — an Origin CA cert is only valid behind the proxy.""" - warnings = [] - try: - rec = client.list_records(zone.provider_zone_id) - records = (rec.get('records') or []) if rec.get('success') else [] - by_name = {} - for r in records: - by_name.setdefault((r.get('name') or '').lower().rstrip('.'), []).append(r) - for h in hostnames: - base = h[2:] if h.startswith('*.') else h - matches = by_name.get(base, []) - if not matches: - warnings.append( - f'{h}: no DNS record found — an Origin CA certificate is only ' - 'valid when traffic is proxied through Cloudflare.') - elif not any(m.get('proxied') for m in matches): - warnings.append( - f'{h}: the DNS record is not proxied (grey cloud). This ' - 'certificate only works when traffic goes through Cloudflare.') - except Exception: - pass - return warnings - - @staticmethod - def _install_origin_cert(hostnames, cert_pem, key_pem): - """Install the issued cert + panel-generated key via the existing custom-cert - seam (writes PEMs to ``/etc/ssl/serverkit//``, Linux-only).""" - try: - from app.services.advanced_ssl_service import AdvancedSSLService - install_domain = next((h for h in hostnames if not h.startswith('*.')), None) - if not install_domain: - install_domain = hostnames[0][2:] if hostnames[0].startswith('*.') else hostnames[0] - info = AdvancedSSLService.upload_custom_cert(install_domain, cert_pem, key_pem) - return {'success': True, **info} - except Exception as e: - return {'success': False, 'error': str(e)} - - @classmethod - def issue_origin_certificate(cls, zone_id, hostnames, validity_days=5475, install=True): - """Issue an Origin CA certificate for ``hostnames`` and (optionally) install - it on the origin. The private key is generated panel-side and never sent to - Cloudflare.""" - zone, client = cls._zone_and_client(zone_id) - hostnames = [h.strip().lower().rstrip('.') for h in (hostnames or []) if h and h.strip()] - if not hostnames: - raise CloudflareError('At least one hostname is required') - if validity_days not in cls.ORIGIN_CA_VALIDITY_DAYS: - raise CloudflareError( - 'Unsupported validity — choose one of: ' - + ', '.join(str(v) for v in cls.ORIGIN_CA_VALIDITY_DAYS) + ' days') - - csr_pem, key_pem = cls._generate_csr(hostnames) - res = client.create_origin_certificate( - csr=csr_pem, hostnames=hostnames, requested_validity=validity_days, - service_key=cls._origin_ca_service_key(zone)) - if not res.get('success'): - err = res.get('error', 'Failed to issue Origin CA certificate') - cls._record(zone, 'origin_ca', 'issue', hostnames[0], 'error', err) - if cls._is_scope_error(err): - raise CloudflareError( - 'This Cloudflare credential lacks the Origin CA permission. Add the ' - '"SSL and Certificates: Edit" scope to the token, or set an Origin CA ' - f'key on the connection. ({err})') - return {'success': False, 'error': err} - - result = res.get('result') or {} - cert_pem = result.get('certificate') - out = { - 'success': True, - 'certificate_id': result.get('id'), - 'certificate': cert_pem, - 'hostnames': hostnames, - 'expires_on': result.get('expires_on'), - 'proxy_only': True, - 'warnings': cls._proxy_warnings(zone, client, hostnames), - } - if install and cert_pem and os.name != 'nt': - out['install'] = cls._install_origin_cert(hostnames, cert_pem, key_pem) - cls._record(zone, 'origin_ca', 'issue', hostnames[0]) - return out - - @classmethod - def list_origin_certificates(cls, zone_id): - zone, client = cls._zone_and_client(zone_id) - res = client.list_origin_certificates( - zone.provider_zone_id, service_key=cls._origin_ca_service_key(zone)) - if not res.get('success'): - err = res.get('error', 'Failed to list Origin CA certificates') - if cls._is_scope_error(err): - raise CloudflareError( - 'This Cloudflare credential lacks the Origin CA permission. Add the ' - f'"SSL and Certificates: Read" scope to the token. ({err})') - return {'success': False, 'error': err} - certs = [{ - 'id': c.get('id'), - 'hostnames': c.get('hostnames'), - 'expires_on': c.get('expires_on'), - 'requested_validity': c.get('requested_validity'), - 'certificate': c.get('certificate'), - } for c in (res.get('result') or [])] - return {'success': True, 'certificates': certs, 'proxy_only': True} - - @classmethod - def revoke_origin_certificate(cls, zone_id, certificate_id): - zone, client = cls._zone_and_client(zone_id) - res = client.revoke_origin_certificate( - certificate_id, service_key=cls._origin_ca_service_key(zone)) - if not res.get('success'): - err = res.get('error', 'Failed to revoke certificate') - cls._record(zone, 'origin_ca', 'revoke', certificate_id, 'error', err) - if cls._is_scope_error(err): - raise CloudflareError( - 'This Cloudflare credential lacks the Origin CA permission. ' - f'({err})') - return {'success': False, 'error': err} - cls._record(zone, 'origin_ca', 'revoke', certificate_id) - return {'success': True, 'certificate_id': certificate_id} - - # ── Redirect + Transform rules ──────────────────────────────────────────── - # The same ruleset machinery as WAF, in different phases, with per-phase action - # allowlists + the shared expression-injection guard. - - RULE_PHASES = { - 'redirect': 'http_request_dynamic_redirect', - 'transform': 'http_request_transform', - } - RULE_ACTIONS = { - 'redirect': {'redirect'}, - 'transform': {'rewrite'}, - } - - # Preset library per rule type (plan §Phase 3). Presets take no free-form user - # input that lands in an expression — the only interpolated value is the zone's - # own (validated) domain — so there is no injection surface. - RULE_PRESETS = { - 'redirect': [ - {'key': 'force_www', 'label': 'Redirect apex to www', - 'description': 'Send https://example.com/* to https://www.example.com/*.', - 'params': []}, - {'key': 'www_to_apex', 'label': 'Redirect www to apex', - 'description': 'Send https://www.example.com/* to https://example.com/*.', - 'params': []}, - {'key': 'strip_trailing_slash', 'label': 'Remove trailing slash', - 'description': 'Redirect /path/ to /path (except the root).', - 'params': []}, - ], - 'transform': [ - {'key': 'strip_tracking', 'label': 'Strip tracking parameters', - 'description': 'Remove utm_*, gclid and fbclid query parameters.', - 'params': []}, - ], - } - - _SAFE_HOST_RE = re.compile(r'^[a-z0-9.-]+$') - - @classmethod - def _resolve_rule_phase(cls, slug): - phase = cls.RULE_PHASES.get(slug) - if not phase: - raise CloudflareError( - f'Unknown rule type "{slug}". Use one of: ' - + ', '.join(sorted(cls.RULE_PHASES))) - return phase - - @classmethod - def _validate_rule_action(cls, slug, action): - allowed = cls.RULE_ACTIONS.get(slug, set()) - if action not in allowed: - raise CloudflareError( - f'Unsupported action "{action}" for {slug} rules. ' - f'Use one of: {", ".join(sorted(allowed))}') - - @staticmethod - def _full_rule_dict(r): - return {'id': r.get('id'), 'description': r.get('description'), - 'expression': r.get('expression'), 'action': r.get('action'), - 'action_parameters': r.get('action_parameters'), - 'enabled': r.get('enabled', True), 'ref': r.get('ref')} - - @classmethod - def list_rules(cls, zone_id, slug): - """Rules for a phase (redirect|transform), plus the preset catalog. A zone - with no ruleset in that phase yet returns an empty list (not an error).""" - phase = cls._resolve_rule_phase(slug) - zone, client = cls._zone_and_client(zone_id) - custom, err = cls._find_custom_ruleset(client, zone.provider_zone_id, phase) - if err: - return {'success': False, 'error': err} - presets = cls.RULE_PRESETS.get(slug, []) - if not custom: - return {'success': True, 'ruleset_id': None, 'rules': [], 'presets': presets} - detail = client.get_ruleset(zone.provider_zone_id, custom['id']) - if not detail.get('success'): - return {'success': False, 'error': detail.get('error', 'Failed to load ruleset')} - result = detail.get('result') or {} - rules = [cls._full_rule_dict(r) for r in (result.get('rules') or [])] - return {'success': True, 'ruleset_id': result.get('id'), - 'rules': rules, 'presets': presets} - - @classmethod - def add_rule(cls, zone_id, slug, *, description, expression, action, - action_parameters=None, enabled=True): - """Append a rule to the phase's entry-point ruleset, creating it on first use.""" - phase = cls._resolve_rule_phase(slug) - cls._validate_rule_action(slug, action) - if not (expression or '').strip(): - raise CloudflareError('A rule expression is required') - zone, client = cls._zone_and_client(zone_id) - rule = {'description': description or 'ServerKit rule', 'expression': expression, - 'action': action, 'enabled': bool(enabled)} - if action_parameters: - rule['action_parameters'] = action_parameters - - custom, err = cls._find_custom_ruleset(client, zone.provider_zone_id, phase) - if err: - return {'success': False, 'error': err} - if custom: - res = client.add_ruleset_rule(zone.provider_zone_id, custom['id'], rule) - else: - res = client.create_phase_ruleset(zone.provider_zone_id, phase, [rule]) - if not res.get('success'): - cls._record(zone, slug, 'add-rule', rule['description'], 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to add rule')} - cls._record(zone, slug, 'add-rule', rule['description']) - return {'success': True, 'result': res.get('result')} - - @classmethod - def _build_rule_preset(cls, slug, key, domain, params): - """Turn a preset key + the zone domain into a concrete rule dict. The domain - is validated before interpolation so a preset can't inject expression syntax.""" - domain = (domain or '').strip().lower().rstrip('.') - if not cls._SAFE_HOST_RE.match(domain or ''): - raise CloudflareError('The zone domain is not a valid hostname') - if slug == 'redirect': - if key == 'force_www': - return {'description': 'ServerKit: redirect apex to www', - 'expression': f'(http.host eq "{domain}")', - 'action': 'redirect', - 'action_parameters': {'from_value': { - 'status_code': 301, - 'target_url': {'expression': - f'concat("https://www.{domain}", http.request.uri.path)'}, - 'preserve_query_string': True}}} - if key == 'www_to_apex': - return {'description': 'ServerKit: redirect www to apex', - 'expression': f'(http.host eq "www.{domain}")', - 'action': 'redirect', - 'action_parameters': {'from_value': { - 'status_code': 301, - 'target_url': {'expression': - f'concat("https://{domain}", http.request.uri.path)'}, - 'preserve_query_string': True}}} - if key == 'strip_trailing_slash': - return {'description': 'ServerKit: remove trailing slash', - 'expression': ('(ends_with(http.request.uri.path, "/") and ' - 'http.request.uri.path ne "/")'), - 'action': 'redirect', - 'action_parameters': {'from_value': { - 'status_code': 301, - 'target_url': {'expression': - 'concat("https://", http.host, ' - 'substring(http.request.uri.path, 0, -1))'}, - 'preserve_query_string': True}}} - elif slug == 'transform': - if key == 'strip_tracking': - return {'description': 'ServerKit: strip tracking parameters', - 'expression': '(http.request.uri.query ne "")', - 'action': 'rewrite', - 'action_parameters': {'uri': {'query': {'expression': - 'regex_replace(http.request.uri.query, ' - '"(^|&)(utm_[a-z]+|gclid|fbclid)=[^&]*", "")'}}}} - raise CloudflareError(f'Unknown {slug} preset: {key}') - - @classmethod - def apply_rule_preset(cls, zone_id, slug, preset_key, params=None): - cls._resolve_rule_phase(slug) # validates slug up front - zone, _ = cls._zone_and_client(zone_id) - rule = cls._build_rule_preset(slug, preset_key, zone.domain, params or {}) - return cls.add_rule(zone_id, slug, description=rule['description'], - expression=rule['expression'], action=rule['action'], - action_parameters=rule.get('action_parameters')) - - @classmethod - def update_rule(cls, zone_id, slug, ruleset_id, rule_id, fields): - cls._resolve_rule_phase(slug) - zone, client = cls._zone_and_client(zone_id) - rule = {} - for k in ('description', 'expression', 'action', 'enabled', 'action_parameters'): - if k in fields: - rule[k] = fields[k] - if 'action' in rule: - cls._validate_rule_action(slug, rule['action']) - if not rule: - raise CloudflareError('No updatable fields provided') - res = client.update_ruleset_rule(zone.provider_zone_id, ruleset_id, rule_id, rule) - if not res.get('success'): - cls._record(zone, slug, 'update-rule', rule_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to update rule')} - cls._record(zone, slug, 'update-rule', rule_id) - return {'success': True, 'result': res.get('result')} - - @classmethod - def delete_rule(cls, zone_id, slug, ruleset_id, rule_id): - cls._resolve_rule_phase(slug) - zone, client = cls._zone_and_client(zone_id) - res = client.delete_ruleset_rule(zone.provider_zone_id, ruleset_id, rule_id) - if not res.get('success'): - cls._record(zone, slug, 'delete-rule', rule_id, 'error', res.get('error')) - return {'success': False, 'error': res.get('error', 'Failed to delete rule')} - cls._record(zone, slug, 'delete-rule', rule_id) - return {'success': True} - - # ── Activity (local ops ledger) ─────────────────────────────────────────── - - @classmethod - def list_activity(cls, zone_id, product=None, result=None, limit=100): - """The per-zone Cloudflare-ops change ledger, newest first.""" - zone, _ = cls._zone_and_client(zone_id) - from app.services.cf_ops_change_service import CfOpsChangeService - rows = CfOpsChangeService.list( - provider_zone_id=zone.provider_zone_id, - product=product, result=result, limit=limit) - return {'success': True, 'zone': cls._zone_dict(zone), - 'changes': [r.to_dict() for r in rows]} - - # ── Token scope diagnosability ──────────────────────────────────────────── - - @classmethod - def scope_check(cls, zone_id): - """Probe each product with a cheap read and report - ``{product: ok|missing_scope|error}`` — round 2 is exactly where DNS-only - tokens start failing.""" - zone, client = cls._zone_and_client(zone_id) - - def classify(res): - if res.get('success'): - return 'ok' - return 'missing_scope' if cls._is_scope_error(res.get('error')) else 'error' - - products = { - 'dns': classify(client.list_records(zone.provider_zone_id)), - 'settings': classify(client.get_zone_settings(zone.provider_zone_id)), - 'dnssec': classify(client.get_dnssec(zone.provider_zone_id)), - 'waf': classify(client.list_rulesets(zone.provider_zone_id)), - 'origin_ca': classify(client.list_origin_certificates( - zone.provider_zone_id, service_key=cls._origin_ca_service_key(zone))), - } - try: - account_id = cls._account_id(zone, client) - except CloudflareError: - account_id = None - if account_id: - products['workers'] = classify(client.list_worker_scripts(account_id)) - products['tunnels'] = classify(client.list_tunnels(account_id)) - products['storage'] = classify(client.list_r2_buckets(account_id)) - else: - products['workers'] = products['tunnels'] = products['storage'] = 'missing_scope' - return {'success': True, 'zone': cls._zone_dict(zone), 'products': products} diff --git a/builtin-extensions/serverkit-cloudflare-ops/frontend/components/CloudflareWafPanel.jsx b/builtin-extensions/serverkit-cloudflare-ops/frontend/components/CloudflareWafPanel.jsx deleted file mode 100644 index fbb7a2ea..00000000 --- a/builtin-extensions/serverkit-cloudflare-ops/frontend/components/CloudflareWafPanel.jsx +++ /dev/null @@ -1,335 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; -import { ShieldAlert } from 'lucide-react'; -import api from '../services/cloudflare'; -import { - Button, Input, Textarea, Switch, Badge, - Select, SelectTrigger, SelectContent, SelectItem, SelectValue, - EmptyState, useToast, -} from 'serverkit-sdk'; -import { useTranslation } from 'react-i18next'; - -const ACTION_OPTIONS = [ - { value: 'block', labelKey: 'app.cloudflareWafPanel.block', label: 'Block' }, - { value: 'managed_challenge', labelKey: 'app.cloudflareWafPanel.managedChallenge', label: 'Managed Challenge' }, - { value: 'js_challenge', labelKey: 'app.cloudflareWafPanel.jsChallenge', label: 'JS Challenge' }, - { value: 'log', labelKey: 'app.cloudflareWafPanel.log', label: 'Log' }, -]; - -const EXPRESSION_PLACEHOLDER = '(http.request.uri.path contains "/admin")'; - -export default function CloudflareWafPanel({ zoneId, isAdmin }) { - const { t } = useTranslation(); - const toast = useToast(); - - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [rulesetId, setRulesetId] = useState(null); - const [rules, setRules] = useState([]); - const [presets, setPresets] = useState([]); - - // Preset form state - const [selectedPreset, setSelectedPreset] = useState(''); - const [presetParams, setPresetParams] = useState({}); - - // Custom rule form state - const [customDescription, setCustomDescription] = useState(''); - const [customExpression, setCustomExpression] = useState(''); - const [customAction, setCustomAction] = useState('block'); - - // Tracks any in-flight write so buttons can disable - const [applying, setApplying] = useState(false); - - const loadRules = useCallback(async () => { - try { - const data = await api.getCloudflareWafRules(zoneId); - setRulesetId(data.ruleset_id ?? null); - setRules(data.rules || []); - setPresets(data.presets || []); - setError(null); - } catch (err) { - setError(err.message); - } - }, [zoneId]); - - useEffect(() => { - let active = true; - setLoading(true); - (async () => { - try { - const data = await api.getCloudflareWafRules(zoneId); - if (!active) return; - setRulesetId(data.ruleset_id ?? null); - setRules(data.rules || []); - setPresets(data.presets || []); - setError(null); - } catch (err) { - if (active) setError(err.message); - } finally { - if (active) setLoading(false); - } - })(); - return () => { - active = false; - }; - }, [zoneId]); - - const activePreset = presets.find((p) => p.key === selectedPreset) || null; - const presetParamDefs = activePreset?.params || []; - - const handleSelectPreset = (key) => { - setSelectedPreset(key); - setPresetParams({}); - }; - - const handleParamChange = (key, value) => { - setPresetParams((prev) => ({ ...prev, [key]: value })); - }; - - const handleApplyPreset = async () => { - if (!activePreset) return; - setApplying(true); - try { - await api.applyCloudflareWafPreset(zoneId, activePreset.key, presetParams); - await loadRules(); - setPresetParams({}); - toast.success(t('app.cloudflareWafPanel.addedQuickRule', 'Added quick rule: {{label}}', { label: activePreset.label })); - } catch (err) { - toast.error(err.message); - } finally { - setApplying(false); - } - }; - - const handleAddCustomRule = async () => { - setApplying(true); - try { - await api.addCloudflareWafRule(zoneId, { - description: customDescription, - expression: customExpression, - action: customAction, - enabled: true, - }); - await loadRules(); - setCustomDescription(''); - setCustomExpression(''); - setCustomAction('block'); - toast.success(t('app.cloudflareWafPanel.customRuleAdded', 'Custom rule added')); - } catch (err) { - toast.error(err.message); - } finally { - setApplying(false); - } - }; - - const handleToggleRule = async (rule) => { - setApplying(true); - try { - await api.updateCloudflareWafRule(zoneId, rulesetId, rule.id, { - enabled: !rule.enabled, - }); - await loadRules(); - } catch (err) { - toast.error(err.message); - } finally { - setApplying(false); - } - }; - - const handleDeleteRule = async (rule) => { - setApplying(true); - try { - await api.deleteCloudflareWafRule(zoneId, rulesetId, rule.id); - await loadRules(); - toast.success(t('app.cloudflareWafPanel.ruleDeleted', 'Rule deleted')); - } catch (err) { - toast.error(err.message); - } finally { - setApplying(false); - } - }; - - if (loading) { - return
{t('app.cloudflareWafPanel.loadingFirewallRules', 'Loading firewall rules…')}
; - } - - if (error) { - return ( - - ); - } - - const writeDisabled = !isAdmin || applying; - - return ( -
- {/* Presets / quick rules */} -
-

{t('app.cloudflareWafPanel.quickRules', 'Quick rules')}

-

- {t('app.cloudflareWafPanel.applyACuratedCloudflareRuleWithout', 'Apply a curated Cloudflare rule without writing an expression by hand.')} -

- -
- -
- - {activePreset && ( -

{activePreset.description}

- )} - - {presetParamDefs.length > 0 && ( -
- {presetParamDefs.map((param) => ( -
- - handleParamChange(param.key, e.target.value)} - disabled={writeDisabled} - /> -
- ))} -
- )} - -
- -
-
- - {/* Custom rule */} -
-

{t('app.cloudflareWafPanel.customRule', 'Custom rule')}

-

- {t('app.cloudflareWafPanel.writeYourOwnCloudflareFirewallExpression', 'Write your own Cloudflare firewall expression for full control.')} -

- -
- - setCustomDescription(e.target.value)} - disabled={writeDisabled} - /> -
- -
- -