2026-04-07 02:04:22 +05:30
""" YakPanel - SSL/Domains API - Let ' s Encrypt via certbot """
import os
2026-04-07 11:42:19 +05:30
import re
2026-04-07 10:23:05 +05:30
import shutil
2026-04-07 12:00:10 +05:30
import socket
2026-04-07 10:23:05 +05:30
import subprocess
2026-04-07 10:29:29 +05:30
import sys
2026-04-07 13:23:35 +05:30
import tempfile
2026-04-07 02:04:22 +05:30
from fastapi import APIRouter , Depends , HTTPException
from sqlalchemy . ext . asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
2026-04-07 10:47:27 +05:30
from typing import Optional
2026-04-07 02:04:22 +05:30
from app . core . database import get_db
from app . core . config import get_runtime_config
2026-04-07 12:00:10 +05:30
from app . core . utils import environment_with_system_path , exec_shell_sync , read_file , nginx_reload_all_known , nginx_binary_candidates
2026-04-07 02:04:22 +05:30
from app . api . auth import get_current_user
from app . models . user import User
from app . models . site import Site , Domain
2026-04-07 10:23:05 +05:30
from app . services . site_service import regenerate_site_vhost
2026-04-07 02:04:22 +05:30
router = APIRouter ( prefix = " /ssl " , tags = [ " ssl " ] )
2026-04-07 10:29:29 +05:30
_CERTBOT_PATH_CANDIDATES = (
" /usr/bin/certbot " ,
" /usr/local/bin/certbot " ,
" /snap/bin/certbot " ,
)
2026-04-07 02:04:22 +05:30
2026-04-07 10:29:29 +05:30
def _certbot_command ( ) - > list [ str ] | None :
""" Resolve argv prefix to run certbot: [binary] or [python, -m, certbot]. """
env = environment_with_system_path ( )
path_var = env . get ( " PATH " , " " )
exe = getattr ( sys , " executable " , None ) or " "
if exe and os . path . isfile ( exe ) :
try :
r = subprocess . run (
[ exe , " -m " , " certbot " , " --version " ] ,
capture_output = True ,
text = True ,
timeout = 20 ,
env = env ,
)
if r . returncode == 0 :
return [ exe , " -m " , " certbot " ]
except ( FileNotFoundError , OSError , subprocess . TimeoutExpired ) :
pass
tried : list [ str ] = [ ]
w = shutil . which ( " certbot " , path = path_var )
if w and os . path . isfile ( w ) :
tried . append ( w )
for p in _CERTBOT_PATH_CANDIDATES :
if p not in tried and os . path . isfile ( p ) :
tried . append ( p )
for exe in tried :
try :
r = subprocess . run (
[ exe , " --version " ] ,
capture_output = True ,
text = True ,
timeout = 15 ,
env = env ,
)
if r . returncode == 0 :
return [ exe ]
except ( FileNotFoundError , OSError , subprocess . TimeoutExpired ) :
continue
for py_name in ( " python3 " , " python " ) :
py = shutil . which ( py_name , path = path_var )
if not py or not os . path . isfile ( py ) :
continue
try :
r = subprocess . run (
[ py , " -m " , " certbot " , " --version " ] ,
capture_output = True ,
text = True ,
timeout = 20 ,
env = env ,
)
if r . returncode == 0 :
return [ py , " -m " , " certbot " ]
except ( FileNotFoundError , OSError , subprocess . TimeoutExpired ) :
continue
return None
def _certbot_missing_message ( ) - > str :
return (
" certbot is not installed or not reachable from the panel process. "
" On the server, run one of: apt install certbot | dnf install certbot | yum install certbot | snap install certbot. "
" Alternatively: pip install certbot (panel can use python3 -m certbot). "
" If certbot is already installed, ensure /usr/bin is on PATH for the YakPanel service. "
)
2026-04-07 10:23:05 +05:30
2026-04-07 10:47:27 +05:30
async def _le_hostnames_for_domain_row ( db : AsyncSession , dom_row : Optional [ Domain ] , primary : str ) - > list [ str ] :
""" All distinct hostnames for the site (for -d flags). Falls back to primary. """
if not dom_row :
return [ primary ] if primary else [ ]
result = await db . execute ( select ( Domain ) . where ( Domain . pid == dom_row . pid ) . order_by ( Domain . id ) )
rows = result . scalars ( ) . all ( )
seen : set [ str ] = set ( )
out : list [ str ] = [ ]
for d in rows :
n = ( d . name or " " ) . strip ( )
if not n :
continue
key = n . lower ( )
if key not in seen :
seen . add ( key )
out . append ( n )
if primary and primary . lower ( ) not in seen :
out . insert ( 0 , primary )
return out if out else ( [ primary ] if primary else [ ] )
2026-04-07 11:42:19 +05:30
def _reload_panel_and_common_nginx ( ) - > tuple [ bool , str ] :
2026-04-07 10:35:44 +05:30
""" Reload nginx so new vhost (ACME path) is live before certbot HTTP-01. """
2026-04-07 11:42:19 +05:30
return nginx_reload_all_known ( timeout = 60 )
2026-04-07 10:35:44 +05:30
2026-04-07 12:00:10 +05:30
def _localhost_accepts_tcp ( port : int , timeout : float = 2.0 ) - > bool :
""" True if something accepts a TCP connection on this machine (checks IPv4 loopback). """
try :
with socket . create_connection ( ( " 127.0.0.1 " , port ) , timeout = timeout ) :
return True
except OSError :
return False
def _ss_reports_listen_443 ( ) - > bool | None :
""" Parse ss/netstat output; None if the probe could not run. """
out , _ = exec_shell_sync ( " ss -tln 2>/dev/null || netstat -tln 2>/dev/null " , timeout = 5 )
if not out or not out . strip ( ) :
return None
return bool ( re . search ( r " :443 \ b " , out ) )
2026-04-07 02:04:22 +05:30
@router.get ( " /domains " )
async def ssl_domains (
current_user : User = Depends ( get_current_user ) ,
db : AsyncSession = Depends ( get_db ) ,
) :
""" List all domains from sites with site path for certbot webroot """
result = await db . execute (
select ( Domain , Site ) . join ( Site , Domain . pid == Site . id ) . order_by ( Domain . name )
)
rows = result . all ( )
return [
{
" id " : d . id ,
" name " : d . name ,
" port " : d . port ,
" site_id " : s . id ,
" site_name " : s . name ,
" site_path " : s . path ,
}
for d , s in rows
]
class RequestCertRequest ( BaseModel ) :
domain : str
webroot : str
email : str
@router.post ( " /request " )
async def ssl_request_cert (
body : RequestCertRequest ,
current_user : User = Depends ( get_current_user ) ,
2026-04-07 10:23:05 +05:30
db : AsyncSession = Depends ( get_db ) ,
2026-04-07 02:04:22 +05:30
) :
2026-04-07 10:23:05 +05:30
""" Request Let ' s Encrypt certificate via certbot (webroot challenge). """
2026-04-07 02:04:22 +05:30
if not body . domain or not body . webroot or not body . email :
raise HTTPException ( status_code = 400 , detail = " domain, webroot and email required " )
if " .. " in body . domain or " .. " in body . webroot :
raise HTTPException ( status_code = 400 , detail = " Invalid path " )
cfg = get_runtime_config ( )
allowed = [ os . path . abspath ( cfg [ " www_root " ] ) , os . path . abspath ( cfg [ " setup_path " ] ) ]
webroot_abs = os . path . abspath ( body . webroot )
if not any ( webroot_abs . startswith ( a + os . sep ) or webroot_abs == a for a in allowed ) :
raise HTTPException ( status_code = 400 , detail = " Webroot must be under www_root or setup_path " )
2026-04-07 10:23:05 +05:30
dom = body . domain . split ( " : " ) [ 0 ] . strip ( )
2026-04-07 10:35:44 +05:30
webroot_norm = webroot_abs . rstrip ( os . sep )
result_dom = await db . execute ( select ( Domain ) . where ( Domain . name == dom ) . limit ( 1 ) )
dom_row = result_dom . scalar_one_or_none ( )
if dom_row :
regen_pre = await regenerate_site_vhost ( db , dom_row . pid )
if not regen_pre . get ( " status " ) :
raise HTTPException (
status_code = 500 ,
detail = " Cannot refresh nginx vhost before certificate request: " + str ( regen_pre . get ( " msg " , " " ) ) ,
)
2026-04-07 11:42:19 +05:30
ok_ngx , err_ngx = _reload_panel_and_common_nginx ( )
if not ok_ngx :
raise HTTPException (
status_code = 500 ,
detail = " Nginx test/reload failed before certificate request (fix config, then retry): " + err_ngx ,
)
2026-04-07 10:35:44 +05:30
challenge_dir = os . path . join ( webroot_norm , " .well-known " , " acme-challenge " )
try :
os . makedirs ( challenge_dir , mode = 0o755 , exist_ok = True )
except OSError as e :
raise HTTPException ( status_code = 500 , detail = f " Cannot create ACME webroot directory: { e } " ) from e
2026-04-07 10:29:29 +05:30
prefix = _certbot_command ( )
if not prefix :
raise HTTPException ( status_code = 500 , detail = _certbot_missing_message ( ) )
2026-04-07 10:47:27 +05:30
hostnames = await _le_hostnames_for_domain_row ( db , dom_row , dom )
base_flags = [
2026-04-07 10:23:05 +05:30
" --non-interactive " ,
" --agree-tos " ,
" --email " ,
body . email ,
" --no-eff-email " ,
]
2026-04-07 10:47:27 +05:30
cmd_webroot = prefix + [ " certonly " , " --webroot " , " -w " , webroot_norm , * base_flags ]
for h in hostnames :
cmd_webroot . extend ( [ " -d " , h ] )
cmd_webroot . extend ( [ " --preferred-challenges " , " http " ] )
2026-04-07 10:23:05 +05:30
2026-04-07 10:47:27 +05:30
cmd_nginx = prefix + [ " certonly " , " --nginx " , * base_flags ]
for h in hostnames :
cmd_nginx . extend ( [ " -d " , h ] )
env = environment_with_system_path ( )
proc : subprocess . CompletedProcess [ str ] | None = None
last_err = " "
for cmd , label in ( ( cmd_webroot , " webroot " ) , ( cmd_nginx , " nginx " ) ) :
try :
proc = subprocess . run (
cmd ,
capture_output = True ,
text = True ,
timeout = 300 ,
env = env ,
)
except FileNotFoundError :
raise HTTPException ( status_code = 500 , detail = _certbot_missing_message ( ) ) from None
except subprocess . TimeoutExpired :
raise HTTPException ( status_code = 500 , detail = " certbot timed out (300s) " ) from None
if proc . returncode == 0 :
break
chunk = ( proc . stderr or proc . stdout or " " ) . strip ( ) or f " exit { proc . returncode } "
last_err = f " [ { label } ] { chunk } "
if proc is None or proc . returncode != 0 :
msg = last_err or " certbot failed "
2026-04-07 10:35:44 +05:30
hint = (
2026-04-07 10:47:27 +05:30
" Webroot and nginx plugins both failed. Check: "
" DNS A/AAAA for every -d name points to this server; port 80 reaches the nginx that serves these hosts; "
" site is enabled; install python3-certbot-nginx if the nginx method reports a missing plugin. "
" If you use a CDN proxy, pause it or use DNS validation instead. "
2026-04-07 10:35:44 +05:30
)
raise HTTPException ( status_code = 500 , detail = ( msg + hint ) [ : 8000 ] )
2026-04-07 10:23:05 +05:30
2026-04-07 10:35:44 +05:30
row = dom_row
2026-04-07 10:23:05 +05:30
if row :
regen = await regenerate_site_vhost ( db , row . pid )
if not regen . get ( " status " ) :
return {
" status " : True ,
" msg " : " Certificate issued but nginx vhost update failed: " + str ( regen . get ( " msg " , " " ) ) ,
" output " : ( proc . stdout or " " ) [ - 2000 : ] ,
}
return {
" status " : True ,
" msg " : " Certificate issued and nginx updated " ,
" output " : ( proc . stdout or " " ) [ - 2000 : ] ,
}
2026-04-07 02:04:22 +05:30
2026-04-07 11:42:19 +05:30
@router.get ( " /diagnostics " )
async def ssl_diagnostics ( current_user : User = Depends ( get_current_user ) ) :
"""
Help debug HTTP vs HTTPS : compares panel - written vhosts with what nginx - T actually loads .
ERR_CONNECTION_REFUSED on 443 usually means no listen 443 in the active nginx , or a firewall .
"""
cfg = get_runtime_config ( )
setup_abs = os . path . abspath ( ( cfg . get ( " setup_path " ) or " " ) . strip ( ) or " . " )
vhost_dir = os . path . join ( setup_abs , " panel " , " vhost " , " nginx " )
include_snippet = " include " + vhost_dir . replace ( os . sep , " / " ) + " /*.conf; "
vhost_summaries : list [ dict ] = [ ]
if os . path . isdir ( vhost_dir ) :
try :
names = sorted ( os . listdir ( vhost_dir ) )
except OSError :
names = [ ]
for fn in names :
if not fn . endswith ( " .conf " ) or fn . startswith ( " . " ) :
continue
fp = os . path . join ( vhost_dir , fn )
if not os . path . isfile ( fp ) :
continue
body = read_file ( fp ) or " "
vhost_summaries . append ( {
" file " : fn ,
" has_listen_80 " : bool ( re . search ( r " \ blisten \ s+80 \ b " , body ) ) ,
" has_listen_443 " : bool ( re . search ( r " \ blisten \ s+.*443 " , body ) ) ,
" has_ssl_directives " : " ssl_certificate " in body ,
} )
any_vhost_443 = any (
v . get ( " has_listen_443 " ) and v . get ( " has_ssl_directives " ) for v in vhost_summaries
)
effective_listen_443 = False
panel_include_in_effective_config = False
nginx_t_errors : list [ str ] = [ ]
norm_vhost = vhost_dir . replace ( os . sep , " / " )
env = environment_with_system_path ( )
for ngx in nginx_binary_candidates ( ) :
try :
r = subprocess . run (
[ ngx , " -T " ] ,
capture_output = True ,
text = True ,
timeout = 25 ,
env = env ,
)
except ( FileNotFoundError , OSError , subprocess . TimeoutExpired ) as e :
nginx_t_errors . append ( f " { ngx } : { e } " )
continue
dump = ( r . stdout or " " ) + ( r . stderr or " " )
if r . returncode != 0 :
nginx_t_errors . append ( f " { ngx } : " + ( dump . strip ( ) [ : 800 ] or f " -T exit { r . returncode } " ) )
continue
if re . search ( r " \ blisten \ s+.*443 " , dump ) :
effective_listen_443 = True
if norm_vhost in dump or " panel/vhost/nginx " in dump :
panel_include_in_effective_config = True
hints : list [ str ] = [ ]
if not os . path . isdir ( vhost_dir ) :
hints . append ( f " The panel vhost directory is missing ( { vhost_dir } ). Create a website in YakPanel first. " )
elif not vhost_summaries :
hints . append ( " There are no .conf files under the panel nginx vhost directory. " )
le_live = " /etc/letsencrypt/live "
le_present = False
if os . path . isdir ( le_live ) :
try :
le_present = any (
n and not n . startswith ( " . " )
for n in os . listdir ( le_live )
)
except OSError :
le_present = False
if le_present and vhost_summaries and not any_vhost_443 :
hints . append (
" Let ' s Encrypt certs exist on this server but panel vhosts do not include an HTTPS (listen 443 ssl) block. "
" Regenerate the vhost: edit the site and save, or use Request SSL again. "
)
if any_vhost_443 and not effective_listen_443 :
hints . append (
" Your panel .conf files define HTTPS, but nginx -T does not show any listen 443 — the daemon that handles traffic is not loading YakPanel vhosts. "
" Add the include line below inside http { } for that nginx (e.g. /etc/nginx/nginx.conf), then nginx -t && reload. "
)
elif vhost_summaries and not panel_include_in_effective_config :
hints . append (
" If http://domain shows the default ' Welcome to nginx ' page, stock nginx is answering and likely does not include YakPanel vhosts. "
" Add the include below (or symlink this directory into /etc/nginx/conf.d/). "
)
2026-04-07 12:00:10 +05:30
localhost_443_open = _localhost_accepts_tcp ( 443 )
ss_443 = _ss_reports_listen_443 ( )
if not localhost_443_open and not effective_listen_443 :
2026-04-07 11:42:19 +05:30
hints . append (
2026-04-07 12:00:10 +05:30
" This server is not accepting TCP on 127.0.0.1:443 — nothing is listening on 443 yet. "
" Fix nginx (listen 443 ssl + include panel vhosts) first; opening only the cloud firewall will not fix ERR_CONNECTION_REFUSED until nginx binds 443. "
)
elif effective_listen_443 and localhost_443_open :
hints . append (
" Nginx loads HTTPS and 127.0.0.1:443 accepts connections on this host. "
" If browsers off this machine still see connection refused, allow inbound TCP 443: "
" sudo ufw allow 443/tcp && sudo ufw reload (or firewalld), and your VPS Security Group / provider firewall. "
)
elif effective_listen_443 and not localhost_443_open :
hints . append (
" nginx -T reports listen 443, but connecting to 127.0.0.1:443 failed — check nginx error.log; nginx may have failed to bind (permission or address already in use). "
)
elif localhost_443_open and not effective_listen_443 :
hints . append (
" 127.0.0.1:443 accepts TCP, but nginx -T from panel binaries did not show listen 443 — another process may own 443; check ss -tlnp and which nginx serves port 80. "
2026-04-07 11:42:19 +05:30
)
2026-04-07 13:23:35 +05:30
debian_sites = os . path . isdir ( " /etc/nginx/sites-available " )
rhel_conf = os . path . isdir ( " /etc/nginx/conf.d " )
layout = " unknown "
if debian_sites :
layout = " debian_sites_available "
elif rhel_conf :
layout = " rhel_conf_d "
drop_deb = " /etc/nginx/sites-available/yakpanel-vhosts.conf "
drop_rhel = " /etc/nginx/conf.d/yakpanel-vhosts.conf "
nginx_wizard = {
" detected_layout " : layout ,
" include_snippet " : include_snippet ,
" dropin_file_suggested " : drop_deb if debian_sites else drop_rhel ,
" debian " : {
" sites_available_file " : drop_deb ,
" sites_enabled_symlink " : " /etc/nginx/sites-enabled/yakpanel-vhosts.conf " ,
" steps " : [
f " printf ' %s \\ n ' ' { include_snippet } ' | sudo tee { drop_deb } " ,
f " sudo ln -sf { drop_deb } /etc/nginx/sites-enabled/yakpanel-vhosts.conf " ,
" sudo nginx -t && sudo systemctl reload nginx " ,
] ,
} ,
" rhel " : {
" conf_d_file " : drop_rhel ,
" steps " : [
f " printf ' %s \\ n ' ' { include_snippet } ' | sudo tee { drop_rhel } " ,
" sudo nginx -t && sudo systemctl reload nginx " ,
] ,
} ,
" note " : " Run the steps for your distro as root. The include line must appear inside the main http { } context (conf.d files do automatically). " ,
}
2026-04-07 11:42:19 +05:30
return {
" vhost_dir " : vhost_dir ,
" include_snippet " : include_snippet ,
2026-04-07 13:23:35 +05:30
" nginx_wizard " : nginx_wizard ,
2026-04-07 11:42:19 +05:30
" vhosts " : vhost_summaries ,
" any_vhost_listen_ssl " : any_vhost_443 ,
" nginx_effective_listen_443 " : effective_listen_443 ,
" panel_vhost_path_in_nginx_t " : panel_include_in_effective_config ,
" nginx_t_probe_errors " : nginx_t_errors ,
2026-04-07 12:00:10 +05:30
" localhost_443_accepts_tcp " : localhost_443_open ,
" ss_reports_443_listen " : ss_443 ,
2026-04-07 11:42:19 +05:30
" hints " : hints ,
}
2026-04-07 13:23:35 +05:30
class DnsCertCloudflareRequest ( BaseModel ) :
domain : str
email : str
api_token : str
class DnsManualInstructionsRequest ( BaseModel ) :
domain : str
@router.post ( " /dns-request/cloudflare " )
async def ssl_dns_cloudflare_cert (
body : DnsCertCloudflareRequest ,
current_user : User = Depends ( get_current_user ) ,
db : AsyncSession = Depends ( get_db ) ,
) :
""" Request Let ' s Encrypt certificate using DNS-01 via Cloudflare (requires certbot-dns-cloudflare). """
dom = ( body . domain or " " ) . split ( " : " ) [ 0 ] . strip ( )
if not dom or " .. " in dom or not body . email or not body . api_token :
raise HTTPException ( status_code = 400 , detail = " domain, email, and api_token required " )
result_dom = await db . execute ( select ( Domain ) . where ( Domain . name == dom ) . limit ( 1 ) )
dom_row = result_dom . scalar_one_or_none ( )
if dom_row :
regen_pre = await regenerate_site_vhost ( db , dom_row . pid )
if not regen_pre . get ( " status " ) :
raise HTTPException (
status_code = 500 ,
detail = " Cannot refresh nginx vhost: " + str ( regen_pre . get ( " msg " , " " ) ) ,
)
ok_ngx , err_ngx = _reload_panel_and_common_nginx ( )
if not ok_ngx :
raise HTTPException (
status_code = 500 ,
detail = " Nginx reload failed: " + err_ngx ,
)
prefix = _certbot_command ( )
if not prefix :
raise HTTPException ( status_code = 500 , detail = _certbot_missing_message ( ) )
hostnames = await _le_hostnames_for_domain_row ( db , dom_row , dom )
if not hostnames :
hostnames = [ dom ]
cred_lines = f ' dns_cloudflare_api_token = { body . api_token . strip ( ) } \n '
fd , cred_path = tempfile . mkstemp ( suffix = " .ini " , prefix = " yakpanel_cf_ " )
try :
os . write ( fd , cred_lines . encode ( ) )
os . close ( fd )
os . chmod ( cred_path , 0o600 )
except OSError as e :
try :
os . close ( fd )
except OSError :
pass
raise HTTPException ( status_code = 500 , detail = f " Cannot write credentials temp file: { e } " ) from e
base_flags = [
" --non-interactive " ,
" --agree-tos " ,
" --email " ,
body . email . strip ( ) ,
" --no-eff-email " ,
" --dns-cloudflare " ,
" --dns-cloudflare-credentials " ,
cred_path ,
]
cmd = prefix + [ " certonly " ] + base_flags
for h in hostnames :
cmd . extend ( [ " -d " , h ] )
env = environment_with_system_path ( )
try :
proc = subprocess . run ( cmd , capture_output = True , text = True , timeout = 600 , env = env )
except ( FileNotFoundError , subprocess . TimeoutExpired ) as e :
try :
os . unlink ( cred_path )
except OSError :
pass
raise HTTPException ( status_code = 500 , detail = str ( e ) ) from e
finally :
try :
os . unlink ( cred_path )
except OSError :
pass
if proc . returncode != 0 :
err = ( proc . stderr or proc . stdout or " " ) . strip ( ) or f " exit { proc . returncode } "
raise HTTPException (
status_code = 500 ,
detail = " certbot DNS failed. Install certbot-dns-cloudflare (pip or OS package) if missing. " + err [ : 6000 ] ,
)
if dom_row :
regen = await regenerate_site_vhost ( db , dom_row . pid )
if not regen . get ( " status " ) :
return {
" status " : True ,
" msg " : " Certificate issued but vhost regen failed: " + str ( regen . get ( " msg " , " " ) ) ,
" output " : ( proc . stdout or " " ) [ - 2000 : ] ,
}
return {
" status " : True ,
" msg " : " Certificate issued via Cloudflare DNS-01 " ,
" output " : ( proc . stdout or " " ) [ - 2000 : ] ,
}
@router.post ( " /dns-request/manual-instructions " )
async def ssl_dns_manual_instructions (
body : DnsManualInstructionsRequest ,
current_user : User = Depends ( get_current_user ) ,
) :
""" Return TXT record host for ACME DNS-01 (user creates record then runs certbot --manual). """
d = ( body . domain or " " ) . split ( " : " ) [ 0 ] . strip ( )
if not d or " .. " in d :
raise HTTPException ( status_code = 400 , detail = " Invalid domain " )
return {
" txt_record_name " : f " _acme-challenge. { d } " ,
" certbot_example " : (
f " sudo certbot certonly --manual --preferred-challenges dns --email you@example.com "
f " --agree-tos -d { d } "
) ,
" note " : " Certbot will display the exact TXT value to create. After DNS propagates, continue in the terminal. " ,
}
2026-04-07 02:04:22 +05:30
@router.get ( " /certificates " )
async def ssl_list_certificates ( current_user : User = Depends ( get_current_user ) ) :
""" List existing Let ' s Encrypt certificates """
live_dir = " /etc/letsencrypt/live "
if not os . path . isdir ( live_dir ) :
return { " certificates " : [ ] }
certs = [ ]
for name in os . listdir ( live_dir ) :
if name . startswith ( " . " ) :
continue
path = os . path . join ( live_dir , name )
if os . path . isdir ( path ) and os . path . isfile ( os . path . join ( path , " fullchain.pem " ) ) :
certs . append ( { " name " : name , " path " : path } )
return { " certificates " : sorted ( certs , key = lambda x : x [ " name " ] ) }