From 94aa8292e23a27b97e68ad69aaeba5a91fe30004 Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Thu, 9 Jul 2026 10:32:36 +0530 Subject: [PATCH] Add Coolify domain sync for system admin --- .env.example | 7 + .../domain_management/coolify_client.py | 262 ++++++++++++++++++ .../templates/domain_management/detail.html | 34 +++ .../templates/domain_management/ssl.html | 9 + .../domain_management/ssl_detail.html | 5 + app/modules/domain_management/ui.py | 64 ++++- 6 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 app/modules/domain_management/coolify_client.py diff --git a/.env.example b/.env.example index 256a7f4..311db38 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,10 @@ JWT_ISSUER="audit_firm_v2" JWT_AUDIENCE="audit_firm_clients" JWT_ACCESS_MINUTES=15 JWT_REFRESH_DAYS=30 + +# Coolify API integration for System Admin domain sync +# Enable API in Coolify first and create a token with write permission. +COOLIFY_API_BASE_URL="https://coolify.example.com/api/v1" +COOLIFY_API_TOKEN="" +COOLIFY_ERP_APPLICATION_UUID="" +COOLIFY_VERIFY_TLS=true diff --git a/app/modules/domain_management/coolify_client.py b/app/modules/domain_management/coolify_client.py new file mode 100644 index 0000000..472ff6b --- /dev/null +++ b/app/modules/domain_management/coolify_client.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import json +import os +import ssl +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urljoin +from urllib.request import Request, urlopen + +from sqlalchemy.orm import Session + +from app.modules.domain_management.models import DomainMapping +from app.modules.domain_management.services import normalize_domain + + +@dataclass(frozen=True) +class CoolifyConfig: + api_base_url: str + api_token: str + application_uuid: str + verify_tls: bool = True + + @property + def is_configured(self) -> bool: + return bool(self.api_base_url and self.api_token and self.application_uuid) + + +@dataclass(frozen=True) +class CoolifyDomainSyncResult: + ok: bool + status: str + message: str + domain_url: str | None = None + existing_domains: tuple[str, ...] = () + updated_domains: tuple[str, ...] = () + api_status_code: int | None = None + api_response: str | None = None + + +def get_coolify_config() -> CoolifyConfig: + """Read Coolify API configuration from environment. + + Required environment variables: + COOLIFY_API_BASE_URL Example: https://coolify.example.com/api/v1 + COOLIFY_API_TOKEN Bearer token created in Coolify + COOLIFY_ERP_APPLICATION_UUID Coolify ERP application UUID + + Optional: + COOLIFY_VERIFY_TLS=false Only for private/self-signed Coolify API endpoints + """ + base = (os.getenv("COOLIFY_API_BASE_URL") or os.getenv("COOLIFY_API_URL") or "").strip().rstrip("/") + token = (os.getenv("COOLIFY_API_TOKEN") or "").strip() + app_uuid = (os.getenv("COOLIFY_ERP_APPLICATION_UUID") or os.getenv("COOLIFY_APPLICATION_UUID") or "").strip() + verify_tls_raw = (os.getenv("COOLIFY_VERIFY_TLS") or "true").strip().lower() + verify_tls = verify_tls_raw not in {"0", "false", "no", "off"} + return CoolifyConfig(api_base_url=base, api_token=token, application_uuid=app_uuid, verify_tls=verify_tls) + + +def is_coolify_configured() -> bool: + return get_coolify_config().is_configured + + +def _normalise_api_base_url(value: str) -> str: + base = (value or "").strip().rstrip("/") + if not base: + return "" + if not base.endswith("/api/v1"): + base = base + "/api/v1" + return base + "/" + + +def _request_json(config: CoolifyConfig, method: str, endpoint: str, payload: dict[str, Any] | None = None) -> tuple[int, dict[str, Any], str]: + base = _normalise_api_base_url(config.api_base_url) + url = urljoin(base, endpoint.lstrip("/")) + body: bytes | None = None + headers = { + "Authorization": f"Bearer {config.api_token}", + "Accept": "application/json", + } + if payload is not None: + body = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + req = Request(url=url, data=body, method=method.upper(), headers=headers) + context = None if config.verify_tls else ssl._create_unverified_context() # noqa: SLF001 - explicit opt-out via env only + try: + with urlopen(req, timeout=25, context=context) as response: # nosec B310 - endpoint is admin-configured + raw = response.read().decode("utf-8", errors="replace") + try: + data = json.loads(raw) if raw else {} + except Exception: + data = {"raw": raw} + return int(response.status), data, raw + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + data = json.loads(raw) if raw else {} + except Exception: + data = {"raw": raw} + return int(exc.code), data, raw + except URLError as exc: + raise RuntimeError(f"Coolify API connection failed: {exc.reason}") from exc + except TimeoutError as exc: + raise RuntimeError("Coolify API request timed out.") from exc + + +def _split_domain_urls(value: str | None) -> tuple[str, ...]: + if not value: + return () + seen: set[str] = set() + out: list[str] = [] + for part in value.split(","): + item = part.strip().rstrip("/") + if not item: + continue + key = item.lower() + if key not in seen: + seen.add(key) + out.append(item) + return tuple(out) + + +def _domain_to_coolify_url(domain_name: str) -> str: + domain = normalize_domain(domain_name) + if not domain: + return "" + return f"https://{domain}" + + +def get_coolify_application_domains() -> CoolifyDomainSyncResult: + config = get_coolify_config() + if not config.is_configured: + return CoolifyDomainSyncResult( + ok=False, + status="not_configured", + message="Coolify API is not configured. Set COOLIFY_API_BASE_URL, COOLIFY_API_TOKEN and COOLIFY_ERP_APPLICATION_UUID.", + ) + try: + status_code, data, raw = _request_json(config, "GET", f"applications/{config.application_uuid}") + except Exception as exc: + return CoolifyDomainSyncResult(ok=False, status="api_error", message=str(exc)) + if status_code < 200 or status_code >= 300: + return CoolifyDomainSyncResult( + ok=False, + status="api_error", + message=f"Coolify returned HTTP {status_code} while reading the application.", + api_status_code=status_code, + api_response=raw[:1000], + ) + domains = _split_domain_urls(str(data.get("fqdn") or data.get("domains") or "")) + return CoolifyDomainSyncResult( + ok=True, + status="read_ok", + message="Coolify application domains were read successfully.", + existing_domains=domains, + updated_domains=domains, + api_status_code=status_code, + api_response=raw[:1000], + ) + + +def sync_domain_to_coolify(db: Session, mapping: DomainMapping, *, user_id: int | None = None) -> CoolifyDomainSyncResult: + """Add the mapping domain to the configured Coolify ERP application domains. + + This function deliberately does not create/update ERP domain mappings. It only updates + the Coolify application domain list and records proxy/SSL status on the existing mapping. + """ + config = get_coolify_config() + domain_url = _domain_to_coolify_url(mapping.domain_name) + if not domain_url: + return CoolifyDomainSyncResult(ok=False, status="invalid_domain", message="Domain name is empty or invalid.") + if not config.is_configured: + return CoolifyDomainSyncResult( + ok=False, + status="not_configured", + message="Coolify API is not configured. Set COOLIFY_API_BASE_URL, COOLIFY_API_TOKEN and COOLIFY_ERP_APPLICATION_UUID.", + domain_url=domain_url, + ) + + read_result = get_coolify_application_domains() + if not read_result.ok: + return CoolifyDomainSyncResult( + ok=False, + status=read_result.status, + message=read_result.message, + domain_url=domain_url, + api_status_code=read_result.api_status_code, + api_response=read_result.api_response, + ) + + existing = list(read_result.updated_domains) + existing_keys = {d.lower().rstrip("/") for d in existing} + if domain_url.lower() in existing_keys: + mapping.ssl_mode = "coolify" + mapping.ssl_provider = "coolify" + mapping.ssl_status = mapping.ssl_status or "pending_ssl" + mapping.ssl_last_error = None + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + db.commit() + return CoolifyDomainSyncResult( + ok=True, + status="already_present", + message="Domain is already present in Coolify application domains.", + domain_url=domain_url, + existing_domains=tuple(existing), + updated_domains=tuple(existing), + ) + + updated = existing + [domain_url] + domains_csv = ",".join(updated) + + # Current Coolify application API writes domain URLs through the request field named "domains". + # Some older builds used/accepted fqdn, so we fallback only if the first attempt is rejected. + patch_attempts = ({"domains": domains_csv}, {"fqdn": domains_csv}) + last_status = None + last_raw = "" + for payload in patch_attempts: + try: + status_code, _data, raw = _request_json(config, "PATCH", f"applications/{config.application_uuid}", payload) + except Exception as exc: + return CoolifyDomainSyncResult(ok=False, status="api_error", message=str(exc), domain_url=domain_url) + last_status = status_code + last_raw = raw + if 200 <= status_code < 300: + mapping.ssl_mode = "coolify" + mapping.ssl_provider = "coolify" + mapping.ssl_status = "pending_ssl" + mapping.ssl_last_error = None + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + db.commit() + return CoolifyDomainSyncResult( + ok=True, + status="synced", + message="Domain was added to Coolify application domains. Redeploy/restart the ERP app if your Coolify version does not refresh labels immediately.", + domain_url=domain_url, + existing_domains=tuple(existing), + updated_domains=tuple(updated), + api_status_code=status_code, + api_response=raw[:1000], + ) + + mapping.ssl_mode = "coolify" + mapping.ssl_provider = "coolify" + mapping.ssl_status = "failed" + mapping.ssl_last_error = f"Coolify API returned HTTP {last_status}: {last_raw[:500]}" + mapping.updated_by_user_id = user_id + mapping.updated_at_utc = datetime.now(timezone.utc) + db.commit() + return CoolifyDomainSyncResult( + ok=False, + status="api_error", + message=f"Coolify did not accept the domain update. HTTP {last_status}.", + domain_url=domain_url, + existing_domains=tuple(existing), + updated_domains=tuple(updated), + api_status_code=last_status, + api_response=last_raw[:1000], + ) diff --git a/app/modules/domain_management/templates/domain_management/detail.html b/app/modules/domain_management/templates/domain_management/detail.html index d46d457..e252c47 100644 --- a/app/modules/domain_management/templates/domain_management/detail.html +++ b/app/modules/domain_management/templates/domain_management/detail.html @@ -67,5 +67,39 @@ + + {% if coolify_result is defined and coolify_result %} +
+
Coolify: {{ coolify_result.status.replace('_', ' ')|title }}
+
{{ coolify_result.message }}
+ {% if coolify_result.domain_url %}
Domain URL: {{ coolify_result.domain_url }}
{% endif %} + {% if coolify_result.updated_domains %}
Application domains: {{ coolify_result.updated_domains|join(', ') }}
{% endif %} +
+ {% endif %} + +
+
+
+

Coolify / Traefik Route

+

System Admin can add this custom domain to the configured Coolify ERP application. DNS should already point to your Coolify server before SSL is issued.

+
+
+
+ + +
+
+ + +
+
+
+
+
Coolify URL format
https://{{ mapping.domain_name }}
+
Proxy mode
{{ mapping.ssl_mode or 'manual' }}
+
Proxy/SSL status
{{ (mapping.ssl_status or 'not_checked').replace('_', ' ')|title }}
+
+
+ {% endblock %} diff --git a/app/modules/domain_management/templates/domain_management/ssl.html b/app/modules/domain_management/templates/domain_management/ssl.html index 37cb528..3a23904 100644 --- a/app/modules/domain_management/templates/domain_management/ssl.html +++ b/app/modules/domain_management/templates/domain_management/ssl.html @@ -1,6 +1,14 @@ {% extends "ui/templates/base/layout.html" %} {% block content %}
+ + {% if coolify_result is defined and coolify_result %} +
+
Coolify API: {{ coolify_result.status.replace('_', ' ')|title }}
+
{{ coolify_result.message }}
+ {% if coolify_result.updated_domains %}
Application domains: {{ coolify_result.updated_domains|join(', ') }}
{% endif %} +
+ {% endif %}

SSL Automation

@@ -11,6 +19,7 @@ + Coolify Status DNS Verification Domains
diff --git a/app/modules/domain_management/templates/domain_management/ssl_detail.html b/app/modules/domain_management/templates/domain_management/ssl_detail.html index a4d85d0..0bc5609 100644 --- a/app/modules/domain_management/templates/domain_management/ssl_detail.html +++ b/app/modules/domain_management/templates/domain_management/ssl_detail.html @@ -34,6 +34,11 @@ + +
+ + +