Files
ServerKit/backend/app/services/sites_https_service.py
Juan DenisandClaude Opus 4.8 e718a95f43 feat(sites): per-base wildcard HTTPS + base-domain registry API
Wire the base-domain registry into HTTP + the cert flow:

- SitesHttpsService.setup(provider_id, email, base) now issues the wildcard cert
  for a specific base domain and persists HTTPS state to THAT base's registry row
  (recording the provider used), so example.com on Cloudflare and toto.com on
  Route53 each get their own cert; falls back to the legacy sites_* settings for
  a single-base install. status() now also returns the full `bases` registry.
- Admin API: POST/DELETE /admin/sites-https/base-domains[/<domain>] and
  .../<domain>/default to register, remove, and set the default base; setup
  accepts an optional `base`.
- GET /api/v1/domains/base-domains lists the bases a new site can use (registry,
  or the single legacy base) with the pre-selected default — the create-flow
  picker's data source. give-subdomain + suggest-subdomain accept a `base`.
- WordPress create accepts baseDomain end to end (API -> service -> subdomain_for),
  and picks the covering base's HTTPS state for the baked-in site URL.

Proven in test_site_base_domains.py: the list endpoint (registry + legacy),
admin add/set-default/remove, and per-base HTTPS setup persisting to one row
while the other base stays HTTP. WordPress suite (66) still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:11:18 -04:00

88 lines
4.5 KiB
Python

"""One-time setup that makes managed subdomains HTTPS.
Creates the wildcard DNS record (``*.<base_domain>``) via a connected provider
and issues a wildcard Let's Encrypt certificate over DNS-01 reusing that
provider's credentials. Once the cert exists, ``sites_https_enabled`` flips on
and managed subdomain vhosts serve TLS from it (see SiteDomainService and
NginxService.create_site's ssl args).
"""
from app.models.email import DNSProviderConfig
from app.services.advanced_ssl_service import AdvancedSSLService
from app.services.dns_provider_service import DNSProviderService
from app.services.settings_service import SettingsService
from app.services.site_domain_service import SiteDomainService
class SitesHttpsService:
@classmethod
def status(cls):
"""Current managed-sites routing/HTTPS configuration, including the full
base-domain registry (``bases``). The top-level fields reflect the default
base for back-compat."""
from app.services.site_base_domain_service import SiteBaseDomainService
return {
'base_domain': SiteDomainService.base_domain(),
'server_ip': SiteDomainService.server_ip(),
'https_enabled': SiteDomainService.https_enabled(),
'dns_mode': SiteDomainService.dns_mode(),
'providers': DNSProviderService.list_providers(),
'bases': [r.to_dict() for r in SiteBaseDomainService.list_rows()],
}
@classmethod
def setup(cls, provider_id, email=None, base=None):
"""Create ``*.<base>`` + ``<base>`` A records and issue the wildcard cert
for one base domain (the default base when ``base`` is None).
Requires a configured base domain and a connected DNS provider. The
server IP is needed to create the A records; without it the cert can
still be issued (DNS-01 doesn't need them) but traffic won't route until
the records exist — surfaced as a warning rather than a failure.
On success the base's HTTPS state is persisted: to its registry row when
it has one (also recording the provider used), else to the legacy
``sites_https_enabled`` / ``sites_dns_mode`` settings for the default base.
"""
from app.services.site_base_domain_service import SiteBaseDomainService
base = SiteDomainService._norm(base) if base else SiteDomainService.base_domain()
if not base:
return {'success': False, 'error': 'Set the sites base domain first (Settings).'}
config = DNSProviderConfig.query.get(provider_id) if provider_id else None
if not config:
return {'success': False, 'error': 'A connected DNS provider is required to issue the wildcard certificate.'}
warnings = []
ip = SiteDomainService.server_ip()
dns = {}
if ip:
dns['wildcard'] = DNSProviderService.ensure_a_record(f'*.{base}', ip)
dns['apex'] = DNSProviderService.ensure_a_record(base, ip)
for r in dns.values():
if not r.get('created'):
warnings.append(r.get('message') or 'A record not created.')
else:
warnings.append(f'No server public IP set — add the *.{base} and {base} A records manually (or set the IP in Settings).')
creds_src = DNSProviderService.decrypted_credentials(config)
creds = ({'api_token': creds_src['api_key']} if config.provider == 'cloudflare'
else {'api_key': creds_src['api_key'], 'api_secret': creds_src['api_secret']})
cert = AdvancedSSLService.issue_wildcard_cert(base, config.provider, creds, email=email)
if not cert.get('success'):
return {'success': False, 'error': f"Wildcard certificate failed: {cert.get('error')}",
'dns': dns, 'ssl': cert, 'warning': '; '.join(warnings) if warnings else None}
# The wildcard record now covers every subdomain, so wildcard is the mode.
row = SiteBaseDomainService.get(base)
if row:
SiteBaseDomainService.update(base, https_enabled=True, dns_mode='wildcard',
dns_provider_config_id=config.id)
else:
SettingsService.set('sites_https_enabled', True)
SettingsService.set('sites_dns_mode', 'wildcard')
return {'success': True, 'base_domain': base, 'https_enabled': True,
'dns': dns, 'ssl': cert, 'cert_path': cert.get('certificate_path'),
'warning': '; '.join(warnings) if warnings else None}