Add Coolify domain sync for system admin

This commit is contained in:
A R R R Associates
2026-07-09 10:32:36 +05:30
parent 2ef58ded85
commit 94aa8292e2
6 changed files with 380 additions and 1 deletions
+7
View File
@@ -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
@@ -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],
)
@@ -67,5 +67,39 @@
</div>
</div>
</div>
{% if coolify_result is defined and coolify_result %}
<div class="rounded-2xl {% if coolify_result.ok %}bg-emerald-50 text-emerald-800{% else %}bg-red-50 text-red-800{% endif %} p-4 text-sm">
<div class="font-semibold">Coolify: {{ coolify_result.status.replace('_', ' ')|title }}</div>
<div class="mt-1">{{ coolify_result.message }}</div>
{% if coolify_result.domain_url %}<div class="mt-2 text-xs">Domain URL: {{ coolify_result.domain_url }}</div>{% endif %}
{% if coolify_result.updated_domains %}<div class="mt-2 text-xs">Application domains: {{ coolify_result.updated_domains|join(', ') }}</div>{% endif %}
</div>
{% endif %}
<div class="rounded-2xl border bg-white p-5 shadow-soft">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-900">Coolify / Traefik Route</h2>
<p class="mt-1 text-sm text-slate-500">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.</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<form method="post" action="/domains/{{ mapping.id }}/coolify/check">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="w-full rounded-xl border px-4 py-2 text-sm font-medium text-slate-700">Check Coolify App</button>
</form>
<form method="post" action="/domains/{{ mapping.id }}/coolify/sync">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Add to Coolify</button>
</form>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<div class="rounded-xl bg-slate-50 p-3"><div class="text-slate-500">Coolify URL format</div><div class="font-medium text-slate-900">https://{{ mapping.domain_name }}</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-slate-500">Proxy mode</div><div class="font-medium text-slate-900">{{ mapping.ssl_mode or 'manual' }}</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-slate-500">Proxy/SSL status</div><div class="font-medium text-slate-900">{{ (mapping.ssl_status or 'not_checked').replace('_', ' ')|title }}</div></div>
</div>
</div>
</div>
{% endblock %}
@@ -1,6 +1,14 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
{% if coolify_result is defined and coolify_result %}
<div class="rounded-2xl {% if coolify_result.ok %}bg-emerald-50 text-emerald-800{% else %}bg-red-50 text-red-800{% endif %} p-4 text-sm">
<div class="font-semibold">Coolify API: {{ coolify_result.status.replace('_', ' ')|title }}</div>
<div class="mt-1">{{ coolify_result.message }}</div>
{% if coolify_result.updated_domains %}<div class="mt-2 text-xs">Application domains: {{ coolify_result.updated_domains|join(', ') }}</div>{% endif %}
</div>
{% endif %}
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-2xl font-semibold text-slate-900">SSL Automation</h1>
@@ -11,6 +19,7 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white shadow-sm">Check Active SSL</button>
</form>
<a href="/domains/coolify/status" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Coolify Status</a>
<a href="/domains/verification" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">DNS Verification</a>
<a href="/domains" class="rounded-xl border px-4 py-2 text-sm font-medium text-slate-700 shadow-sm">Domains</a>
</div>
@@ -34,6 +34,11 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Check SSL Now</button>
</form>
<form method="post" action="/domains/{{ mapping.id }}/coolify/sync">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button class="rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white">Add Domain to Coolify</button>
</form>
<form method="post" action="/domains/{{ mapping.id }}/ssl/mark-managed" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<select name="provider" class="rounded-xl border px-3 py-2 text-sm">
+63 -1
View File
@@ -10,6 +10,7 @@ from app.core.security.session_auth import get_current_user
from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from app.modules.domain_management.models import DomainMapping
from app.modules.domain_management.coolify_client import get_coolify_application_domains, sync_domain_to_coolify
from app.modules.domain_management.services import (
DomainMappingPayload,
create_domain_mapping,
@@ -38,7 +39,7 @@ router = APIRouter(prefix="/domains", tags=["domain-management-ui"])
def _is_domain_admin(db, user) -> bool:
roles = set(get_user_roles(db, user.id))
return bool(roles.intersection({"System Admin", "Firm Admin"}))
return "System Admin" in roles
def _base_ctx(request: Request, db, user, **ctx):
@@ -535,6 +536,24 @@ def ssl_check_all(request: Request, csrf_token: str = Form(...)):
db.close()
@router.get("/coolify/status")
def coolify_status_page(request: Request):
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
result = get_coolify_application_domains()
domains = list_ssl_domains(db)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/ssl.html",
_base_ctx(request, db, user, title="Coolify Domain Sync", domains=domains, coolify_result=result),
status_code=200 if result.ok else 400,
)
finally:
db.close()
@router.get("/{mapping_id}/ssl")
def ssl_detail(request: Request, mapping_id: int):
db = CommonSessionLocal()
@@ -554,6 +573,49 @@ def ssl_detail(request: Request, mapping_id: int):
db.close()
@router.post("/{mapping_id}/coolify/sync")
def coolify_sync_one(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains", status_code=303)
result = sync_domain_to_coolify(db, mapping, user_id=user.id)
db.refresh(mapping)
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/detail.html",
_base_ctx(request, db, user, title=mapping.domain_name, mapping=mapping, coolify_result=result),
status_code=200 if result.ok else 400,
)
finally:
db.close()
@router.post("/{mapping_id}/coolify/check")
def coolify_check_one(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)
db = CommonSessionLocal()
try:
user, response = _require_user(request, db)
if response:
return response
mapping = db.execute(select(DomainMapping).where(DomainMapping.id == mapping_id)).scalar_one_or_none()
if not mapping:
return RedirectResponse(url="/domains", status_code=303)
result = get_coolify_application_domains()
return templates.TemplateResponse(
"modules/domain_management/templates/domain_management/detail.html",
_base_ctx(request, db, user, title=mapping.domain_name, mapping=mapping, coolify_result=result),
status_code=200 if result.ok else 400,
)
finally:
db.close()
@router.post("/{mapping_id}/ssl/check")
def ssl_check_one(request: Request, mapping_id: int, csrf_token: str = Form(...)):
validate_csrf(request, csrf_token)