Skip to content

Commit

Permalink
feat!: single default ACME certificate
Browse files Browse the repository at this point in the history
- Reimplement {set,get,delete}-certificate actions to request a single
  certificate with SANs. Use Traefik's defaultGeneratedCert.

- Change list-certificates to list the ACME certificate host names and the
  custom/uploaded certs.

BREAKING CHANGE: the Traefik configuration does not create certificate
routers any more. Action data format is unchanged.
  • Loading branch information
DavidePrincipi committed Feb 7, 2025
1 parent 3dde512 commit b80d9cf
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 314 deletions.
20 changes: 10 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,12 @@ The action takes 3 parameters:

Example:
```
api-cli run set-certificate --agent module/traefik1 --data "{\"fqdn\": \"$(hostname -f)\""
api-cli run module/traefik1/set-certificate --data '{"fqdn":"myhost.example.com","sync":false}'
```

Output:
```json
{"fqdn": "example.com", "obtained": true}
{"obtained": false}
```

## get-certificate
Expand All @@ -250,12 +250,12 @@ The action takes 1 parameter:

Example:
```
api-cli run get-certificate --agent module/traefik1 --data "{\"fqdn\": \"$(hostname -f)\""
api-cli run module/traefik1/get-certificate --data '{"fqdn":"myhost.example.com"}'
```

Output:
```
{"fqdn": "example.com", "obtained": true}
{"fqdn": "myhost.example.com", "obtained": true, "type": "internal"}
```

## delete-certificate
Expand All @@ -282,22 +282,22 @@ The action takes 1 optional parameter:

Example:
```
api-cli run list-certificates --agent module/traefik1
api-cli run module/traefik1/list-certificates
```

Output:
Output (brief format):
```json
["example.com"]
["myhost.example.com"]
```

Example list expanded:
```
api-cli run list-certificates --agent module/traefik1 --data '{"expand_list": true}'
api-cli run module/traefik1/list-certificates --data '{"expand_list": true}'
```

Output:
Output (expanded format):
```json
[{"fqdn": "example.com", "obtained": false}]
[{"fqdn": "myhost.example.com", "obtained": true, "type": "internal"}]
```

## set-acme-server
Expand Down
53 changes: 18 additions & 35 deletions imageroot/actions/delete-certificate/20writeconfig
Original file line number Diff line number Diff line change
@@ -1,44 +1,27 @@
#!/usr/bin/env python3

#
# Copyright (C) 2023 Nethesis S.r.l.
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

#
# Delete a Let's Encrypt certificate
# Input example:
#
# {"fqdn": "example.com"}
#

import json
import sys
import os
import agent

from custom_certificate_manager import delete_custom_certificate, list_custom_certificates

# Try to parse the stdin as JSON.
# If parsing fails, output everything to stderr
data = json.load(sys.stdin)

agent_id = os.getenv("AGENT_ID", "")
if not agent_id:
raise Exception("AGENT_ID not found inside the environemnt")

# Try to delete uploaded certificate
custom_certificate = False
for cert in list_custom_certificates():
if cert.get('fqdn') == data['fqdn']:
delete_custom_certificate(data['fqdn'])
custom_certificate = True

# Try to delete the route for obtained certificate
if not custom_certificate:
cert_path = f'configs/certificate-{data["fqdn"]}.yml'
if os.path.isfile(cert_path):
os.unlink(cert_path)

# Output valid JSON
print("true")
import cert_helpers

def main():
request = json.load(sys.stdin)
fqdn = request['fqdn']
if fqdn in cert_helpers.read_custom_cert_names():
cert_helpers.remove_custom_cert(fqdn)
elif fqdn in cert_helpers.read_default_cert_names():
cert_helpers.remove_default_certificate_name(fqdn)
else:
agent.set_status('validation-failed')
json.dump([{'field': 'fqdn','parameter':'fqdn','value': fqdn,'error':'certificate_not_found'}], fp=sys.stdout)
sys.exit(2)
json.dump(True, fp=sys.stdout)

if __name__ == "__main__":
main()
17 changes: 4 additions & 13 deletions imageroot/actions/delete-certificate/21waitsync
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
#!/usr/bin/env python3
#!/bin/bash

#
# Copyright (C) 2023 Nethesis S.r.l.
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import json
import sys
import time
from get_certificate import get_certificate

data = json.load(sys.stdin)
retry = 0

while get_certificate(data).get('fqdn') == data['fqdn'] and retry <= 10:
retry += 1
time.sleep(1)
# Placeholder, see bug NethServer/dev#7058
exit 0
36 changes: 23 additions & 13 deletions imageroot/actions/get-certificate/20readconfig
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
#!/usr/bin/env python3

#
# Copyright (C) 2023 Nethesis S.r.l.
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import json
import sys
import os
import cert_helpers

from custom_certificate_manager import info_custom_certificate
from get_certificate import get_certificate
def main():
request = json.load(sys.stdin)
fqdn = request['fqdn']
if fqdn in cert_helpers.read_custom_cert_names():
response = {
"fqdn": fqdn,
"type": "custom",
"obtained": True,
}
elif fqdn in cert_helpers.read_default_cert_names():
response = {
"fqdn": fqdn,
"type": "internal",
"obtained": cert_helpers.has_acmejson_name(fqdn),
}
else:
response = {}
json.dump(response, fp=sys.stdout)

# Try to parse the stdin as JSON.
# If parsing fails, output everything to stderr

data = json.load(sys.stdin)
try:
cert_info = info_custom_certificate(data['fqdn'])
except FileNotFoundError:
cert_info = get_certificate(data)

json.dump(cert_info, fp=sys.stdout)
if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion imageroot/actions/get-certificate/validate-output.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"examples": [
{
"fqdn": "example.com",
"obtained": "true",
"obtained": true,
"type": "internal"
}
],
Expand Down
74 changes: 34 additions & 40 deletions imageroot/actions/list-certificates/20readconfig
Original file line number Diff line number Diff line change
@@ -1,50 +1,44 @@
#!/usr/bin/env python3

#
# Copyright (C) 2023 Nethesis S.r.l.
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import json
import os
import agent
import sys
import urllib.request

from custom_certificate_manager import list_custom_certificates
from get_certificate import get_certificate


api_path = os.environ["API_PATH"]

data = json.load(sys.stdin)

# Get the list of routers keys
try:
with urllib.request.urlopen(f'http://127.0.0.1/{api_path}/api/http/routers') as res:
traefik_routes = json.load(res)
except urllib.error.URLError as e:
raise Exception(f'Error reaching traefik daemon: {e.reason}') from e
certificates= []

# list routes and retrieve either main for a simple list
# or name to use it inside the traefik API and list following type and valid acme cert
for route in traefik_routes:
if "certResolver" in route.get("tls", {}) and route['status'] == 'enabled':
domains = route["tls"]["domains"]
if data != None and data.get('expand_list'):
# we do not use fqdn, we use name : certificate-sub.domain.com@file or nextcloud1-https@file
certificates.append(get_certificate({'name': route['name']}))
else:
certificates.append(domains[0]["main"])

# Retrieve custom certificate
if data != None and data.get('expand_list'):
certificates = certificates + list_custom_certificates()
else:
certificates_custom = []
for item in list_custom_certificates():
certificates_custom.append(item["fqdn"])
certificates = certificates + certificates_custom

json.dump(certificates, fp=sys.stdout)
import cert_helpers

def main():
request = json.load(sys.stdin)
# Choose the action output format brief/detailed:
if request is None or request["expand_list"] is False:
response = list_certificates_brief()
else:
response = list_certificates_detailed()
json.dump(response, fp=sys.stdout)

def list_certificates_brief():
return cert_helpers.read_default_cert_names() + cert_helpers.read_custom_cert_names()

def list_certificates_detailed():
response = []
for acmename in cert_helpers.read_default_cert_names():
response.append({
"fqdn": acmename,
"type": "internal",
"obtained": cert_helpers.has_acmejson_name(acmename),
})
for certsubject in cert_helpers.read_custom_cert_names():
response.append({
"fqdn": certsubject,
"type": "custom",
"obtained": True,
})
response.sort(key=lambda item: (item["type"], item["fqdn"]))
return response

if __name__ == "__main__":
main()
44 changes: 14 additions & 30 deletions imageroot/actions/set-certificate/20writeconfig
Original file line number Diff line number Diff line change
@@ -1,41 +1,25 @@
#!/usr/bin/env python3

#
# Copyright (C) 2023 Nethesis S.r.l.
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

#
# Request a let's encrypt certificate
# Input example:
# {"fqdn": "example.com"}
#

import json
import sys
import os
import uuid
import yaml

# Try to parse the stdin as JSON.
# If parsing fails, output everything to stderr
data = json.load(sys.stdin)

agent_id = os.getenv("AGENT_ID", "")
if not agent_id:
raise Exception("AGENT_ID not found inside the environemnt")
import cert_helpers

# Setup HTTPS router
path = uuid.uuid4()
router = {
'entryPoints': ["https"],
'service': "ping@internal",
'rule' : f'Host(`{data["fqdn"]}`) && Path(`/{path}`)',
'priority': '1',
'tls': { 'domains': [{'main': data["fqdn"]}], 'certresolver': "acmeServer"}
}
def main():
request = json.load(sys.stdin)
cert_helpers.add_default_certificate_name(request['fqdn'])
if request.get('sync'):
obtained = cert_helpers.wait_acmejson_sync(timeout=request.get('sync_timeout', 120))
else:
obtained = False
json.dump({"obtained": obtained}, fp=sys.stdout)
if request.get('sync') is not None and obtained is False:
exit(2)

# Write configuration file
config = {"http": {"routers": {f'certificate-{data["fqdn"]}': router}}}
with open(f'configs/certificate-{data["fqdn"]}.yml', 'w') as fp:
fp.write(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True))
if __name__ == "__main__":
main()
36 changes: 4 additions & 32 deletions imageroot/actions/set-certificate/21waitsync
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,37 +1,9 @@
#!/usr/bin/env python3
#!/bin/bash

#
# Copyright (C) 2023 Nethesis S.r.l.
# Copyright (C) 2025 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import json
import sys
import time
import agent
from get_certificate import get_certificate

data = json.load(sys.stdin)
retry = 0
certificate = {}

sync_timeout = data['sync_timeout'] if data.get('sync_timeout') is not None else 120

while get_certificate(data).get('fqdn') != data['fqdn'] and retry <= 10:
retry += 1
time.sleep(1)

certificate['obtained'] = get_certificate(data).get('obtained')

if certificate['obtained'] is False and data.get('sync') is not None and data['sync'] is True:
retry = 0
while certificate['obtained'] != True and retry < sync_timeout:
agent.set_progress(round((retry*100)/sync_timeout))
certificate['obtained'] = get_certificate(data).get('obtained')
retry += 1
time.sleep(1)

json.dump(certificate, fp=sys.stdout)

if data.get('sync') is not None and certificate['obtained'] is False:
exit(2)
# Placeholder, see bug NethServer/dev#7058
exit 0
Loading

0 comments on commit b80d9cf

Please sign in to comment.