diff --git a/README.md b/README.md index 239214f..8ff3de5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/build-images.sh b/build-images.sh index f40377f..a75d599 100644 --- a/build-images.sh +++ b/build-images.sh @@ -10,7 +10,7 @@ container=$(buildah from scratch) buildah add "${container}" imageroot /imageroot buildah add "${container}" ui /ui buildah config --entrypoint=/ \ - --label="org.nethserver.images=docker.io/traefik:v2.11.18" \ + --label="org.nethserver.images=docker.io/traefik:v3.3.2" \ --label="org.nethserver.flags=core_module" \ "${container}" buildah commit "${container}" "${repobase}/${reponame}" diff --git a/imageroot/actions/create-module/10expandconfig b/imageroot/actions/create-module/10expandconfig index 1457526..dba745c 100755 --- a/imageroot/actions/create-module/10expandconfig +++ b/imageroot/actions/create-module/10expandconfig @@ -50,4 +50,7 @@ ping: manualRouting: true api: {} + +core: + defaultRuleSyntax: v3 EOF diff --git a/imageroot/actions/create-module/50create b/imageroot/actions/create-module/50create index a75cade..ec81090 100755 --- a/imageroot/actions/create-module/50create +++ b/imageroot/actions/create-module/50create @@ -25,7 +25,6 @@ http: middlewares: ApiServer-stripprefix: stripPrefix: - forceSlash: 'false' prefixes: - /cluster-admin ApiServerMw2: @@ -90,7 +89,7 @@ cat < configs/_api.yml http: middlewares: ApisEndpointMw0: - ipWhiteList: + IPAllowList: sourceRange: - 127.0.0.1 ApisEndpointMw1: @@ -112,5 +111,8 @@ EOF # Create uploaded certificates folder mkdir -p custom_certificates +# Create acme storage folder +mkdir -p acme + # Enable and start the services systemctl --user enable --now traefik.service diff --git a/imageroot/actions/delete-certificate/20writeconfig b/imageroot/actions/delete-certificate/20writeconfig index 5af1f82..11990b0 100755 --- a/imageroot/actions/delete-certificate/20writeconfig +++ b/imageroot/actions/delete-certificate/20writeconfig @@ -1,44 +1,28 @@ #!/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 cert_helpers 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") +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() diff --git a/imageroot/actions/delete-certificate/21waitsync b/imageroot/actions/delete-certificate/21waitsync index 196d331..7615ad3 100755 --- a/imageroot/actions/delete-certificate/21waitsync +++ b/imageroot/actions/delete-certificate/21waitsync @@ -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 diff --git a/imageroot/actions/get-certificate/20readconfig b/imageroot/actions/get-certificate/20readconfig index eac1d8b..b21c742 100755 --- a/imageroot/actions/get-certificate/20readconfig +++ b/imageroot/actions/get-certificate/20readconfig @@ -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() diff --git a/imageroot/actions/get-certificate/validate-output.json b/imageroot/actions/get-certificate/validate-output.json index 91a282e..31fd04f 100644 --- a/imageroot/actions/get-certificate/validate-output.json +++ b/imageroot/actions/get-certificate/validate-output.json @@ -6,7 +6,7 @@ "examples": [ { "fqdn": "example.com", - "obtained": "true", + "obtained": true, "type": "internal" } ], diff --git a/imageroot/actions/list-certificates/20readconfig b/imageroot/actions/list-certificates/20readconfig index 4d3e73c..f9fea13 100755 --- a/imageroot/actions/list-certificates/20readconfig +++ b/imageroot/actions/list-certificates/20readconfig @@ -1,7 +1,7 @@ #!/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 # @@ -9,42 +9,36 @@ 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() diff --git a/imageroot/actions/set-certificate/20writeconfig b/imageroot/actions/set-certificate/20writeconfig index fc548f7..3a81248 100755 --- a/imageroot/actions/set-certificate/20writeconfig +++ b/imageroot/actions/set-certificate/20writeconfig @@ -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 HTTP ans HTTPS routers -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() diff --git a/imageroot/actions/set-certificate/21waitsync b/imageroot/actions/set-certificate/21waitsync old mode 100755 new mode 100644 index 386d401..7615ad3 --- a/imageroot/actions/set-certificate/21waitsync +++ b/imageroot/actions/set-certificate/21waitsync @@ -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 diff --git a/imageroot/actions/set-route/20writeconfig b/imageroot/actions/set-route/20writeconfig index 32b59f0..c1f0ab4 100755 --- a/imageroot/actions/set-route/20writeconfig +++ b/imageroot/actions/set-route/20writeconfig @@ -55,11 +55,12 @@ else: route = { "rule": f'Path(`{path}`) || PathPrefix(`{path_prefix}`)', "priority": "1"} # Setup routers -route["entryPoints"] = "http,https" route["service"] = data["instance"] route["middlewares"] = [] router_http = copy.deepcopy(route) +router_http['entryPoints'] = ["http"] router_https = copy.deepcopy(route) +router_https['entryPoints'] = ["https"] router_https["tls"] = {} #router_https = route_s if data.get("host") is not None: @@ -70,10 +71,6 @@ if data.get("host") is not None: if data["lets_encrypt"]: router_https["tls"]["certresolver"] = "acmeServer" -# Enable or disable HTTP 2 HTTPS redirection -if data["http2https"]: - router_http["middlewares"] = ["http2https-redirectscheme"] - # Strip the path from the request if data.get("strip_prefix"): middlewares[f'{data["instance"]}-stripprefix'] = { "stripPrefix": { "prefixes": path } } @@ -106,6 +103,10 @@ if "headers" in data and data["headers"]: router_http["middlewares"].append(f'{data["instance"]}-headers') router_https["middlewares"].append(f'{data["instance"]}-headers') +# Enable or disable HTTP 2 HTTPS redirection +if data["http2https"]: + router_http["middlewares"] = ["http2https-redirectscheme"] + # Cleanup middleware pointers if not router_http["middlewares"]: del(router_http["middlewares"]) diff --git a/imageroot/actions/upload-certificate/23export_certificates b/imageroot/actions/upload-certificate/23export_certificates index a390c88..e1674a0 100755 --- a/imageroot/actions/upload-certificate/23export_certificates +++ b/imageroot/actions/upload-certificate/23export_certificates @@ -11,6 +11,7 @@ import agent import sys import subprocess from base64 import b64decode +import re module_id = os.environ['MODULE_ID'] @@ -22,16 +23,15 @@ data = json.load(sys.stdin) cert = b64decode(data["certFile"]).decode() key = b64decode(data["keyFile"]).decode() -# read the common name from the certificate +# read the common name from the certificate, in the same format used by the action validator result = subprocess.run( - ['openssl', 'x509', '-noout', '-subject', '-in', '/dev/stdin', '-nameopt', 'sep_multiline', '-nameopt', 'utf8'], + ['openssl', 'x509', '-noout', '-subject', '-nameopt', 'multiline', '-nameopt', 'utf8'], input=cert, capture_output=True, text=True ) -subject = result.stdout -domain = {'main': subject.split("\n")[1].split("CN=")[1]} +domain = {'main': re.search(r"^ *commonName *= ([^\n]+)$", result.stdout, flags=re.MULTILINE).group(1)} # save the certificate and key in redis rdb = agent.redis_connect(privileged=True) diff --git a/imageroot/pypkg/cert_helpers.py b/imageroot/pypkg/cert_helpers.py new file mode 100644 index 0000000..069280c --- /dev/null +++ b/imageroot/pypkg/cert_helpers.py @@ -0,0 +1,167 @@ +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import agent +import sys +import os +import yaml +import json +import time +import glob + +def read_default_cert_names(): + """Return the list of host names configured in the + defaultGeneratedCert section.""" + conf = parse_yaml_config('configs/_default_cert.yml') + try: + main = [conf['tls']['stores']['default']['defaultGeneratedCert']['domain']['main']] + except KeyError: + main = [] + try: + sans = conf['tls']['stores']['default']['defaultGeneratedCert']['domain']['sans'] + except KeyError: + sans = [] + return main + sans + +def read_custom_cert_names(): + """Return the list of main hostnames provided by custom/uploaded + certificates.""" + main_hostnames = list() + for cert_path in glob.glob("custom_certificates/*.crt"): + hostname = cert_path.removeprefix("custom_certificates/").removesuffix(".crt") + main_hostnames.append(hostname) + return main_hostnames + +def remove_custom_cert(name): + """Remove the custom/uploaded certificate files and its Traefik + configuration.""" + for path in [ + f"custom_certificates/{name}.crt", + f"custom_certificates/{name}.key", + f"configs/certificate_{name}.yml", + ]: + try: + os.unlink(path) + except FileNotFoundError: + pass + rdb = agent.redis_connect(privileged=True) + rdb.delete(f'module/{os.environ["MODULE_ID"]}/certificate/{name}') + +def has_acmejson_name(name): + """Return True if name is found among acme.json Certificates.""" + with open('acme/acme.json', 'r') as fp: + acmejson = json.load(fp) + for ocert in acmejson['acmeServer']["Certificates"] or []: + if ocert["domain"]["main"] == name or name in ocert["domain"].get("sans", []): + return True + return False + +def has_acmejson_cert(main, sans=[]): + """Return True if a certificate matching main and sans is found among + acme.json Certificates.""" + with open('acme/acme.json', 'r') as fp: + acmejson = json.load(fp) + for ocert in acmejson['acmeServer']["Certificates"] or []: + if ocert["domain"]["main"] == main and set(ocert["domain"].get("sans", [])) == set(sans): + return True + return False + +def wait_acmejson_sync(timeout=120, interval=2.1, names=[]): + """Poll the acme.json file every 'interval' seconds, until a + certificate matching 'names' appears, or timeout seconds are elapsed. + If list 'names' is given, it is expected to have subject at index 0 + and sans in the rest of the list. If not, this function waits for the + default certificate.""" + if not names: + # Wait for the default certificate. + names = read_default_cert_names() + elapsed = 0.0 + while elapsed < timeout: + time.sleep(interval) + elapsed += interval + if has_acmejson_cert(names[0], names[1:]): + return True + return False + +def add_default_certificate_name(main, sans=[]): + """Add 'main' and 'sans' to the current certificate configuration. If + the current certificate is already configured, 'main' is added as SAN, + otherwise it is used to initialize a new defaultGeneratedCert + configuration.""" + tlsconf = parse_yaml_config("configs/_default_cert.yml") + defstore = tlsconf['tls']['stores']['default'] + if 'defaultCertificate' in defstore: + # Remove self-signed cert config. + del defstore['defaultCertificate'] + # Initialize config for ACME. + defstore['defaultGeneratedCert'] = { + 'resolver': 'acmeServer', + 'domain': { + 'main': main, + 'sans': sans, + }, + } + elif 'defaultGeneratedCert' in defstore: + # ACME config already exists. Merge names from request into SANs of + # defaultGeneratedCert. + sans = set(defstore['defaultGeneratedCert']['domain']['sans']) + sans.add(main) + sans.update(set(sans)) + sans.discard(defstore['defaultGeneratedCert']['domain']['main']) + defstore['defaultGeneratedCert']['domain']['sans'] = list(sans) + write_yaml_config(tlsconf, 'configs/_default_cert.yml') + +def remove_default_certificate_name(name): + """Remove 'name' from defaultGeneratedCert configuration. If 'name' + matches the certificate main name, the first item of sans becomes the + certificate main name. If there are no more sans available, the + default self-signed certificate configuration is applied.""" + tlsconf = parse_yaml_config("configs/_default_cert.yml") + defstore = tlsconf['tls']['stores']['default'] + main = defstore['defaultGeneratedCert']['domain']['main'] + names = defstore['defaultGeneratedCert']['domain'].get('sans', []) + if name == main: + if len(names) == 0: + reset_selfsigned_certificate() + elif len(names) == 1: + defstore['defaultGeneratedCert']['domain']['main'] = names[0] + defstore['defaultGeneratedCert']['domain']['sans'] = [] + write_yaml_config(tlsconf, 'configs/_default_cert.yml') + else: + defstore['defaultGeneratedCert']['domain']['main'] = names[0] + defstore['defaultGeneratedCert']['domain']['sans'] = names[1:] + write_yaml_config(tlsconf, 'configs/_default_cert.yml') + else: + defstore['defaultGeneratedCert']['domain']['sans'].remove(name) + write_yaml_config(tlsconf, 'configs/_default_cert.yml') + +def reset_selfsigned_certificate(): + """Replaces the default certificate configuration, restoring the + config for the self-signed one.""" + tlsconf = { + "tls": { + "stores": { + "default": { + "defaultCertificate": { + "certFile": "/etc/traefik/selfsigned.crt", + "keyFile": "/etc/traefik/selfsigned.key", + } + } + } + } + } + write_yaml_config(tlsconf, 'configs/_default_cert.yml') + +def write_yaml_config(conf, path): + """Safely write a configuration file.""" + with open(path + '.tmp', 'w') as fp: + fp.write(yaml.safe_dump(conf, default_flow_style=False, sort_keys=False, allow_unicode=True)) + os.rename(path + '.tmp', path) + +def parse_yaml_config(path): + """Parse a YAML configuration file.""" + with open(path, 'r') as fp: + conf = yaml.safe_load(fp) + return conf diff --git a/imageroot/pypkg/custom_certificate_manager.py b/imageroot/pypkg/custom_certificate_manager.py deleted file mode 100755 index 92cef6a..0000000 --- a/imageroot/pypkg/custom_certificate_manager.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 - -# -# Copyright (C) 2023 Nethesis S.r.l. -# SPDX-License-Identifier: GPL-3.0-or-later -# - - -import agent -import os -from pathlib import Path - -CUSTOM_CERTIFICATES_DIR = 'custom_certificates' -CUSTOM_TRAEFIK_CONFIG_DIR = 'configs' -CRT_SUFFIX = '.crt' -KEY_SUFFIX = '.key' - - -def info_custom_certificate(fqdn): - """Get info for custom certificate - - :param fqdn: The FQDN to find in the certificates - :type fqdn: str - :return: dictionary containing custom certificate info - :rtype: dict - :raises FileNotFoundError: if no certificate is found for the given FQDN - """ - custom_cert_path = Path(CUSTOM_CERTIFICATES_DIR) - for item in custom_cert_path.iterdir(): - if item.is_file() and item.name == fqdn + CRT_SUFFIX: - return { - 'fqdn': fqdn, - 'type': 'custom', - 'obtained': True - } - - raise FileNotFoundError(f'Can\'t find custom certificate for {fqdn}.') - - -def list_custom_certificates(): - """Returns a list of custom certificates uploaded - - :return: list containing data for each custom_certificate found, see info_custom_certificate for more info - :rtype: list - """ - custom_certificates = [] - custom_cert_path = Path(CUSTOM_CERTIFICATES_DIR) - for item in custom_cert_path.iterdir(): - if item.is_file() and item.suffix == CRT_SUFFIX: - custom_certificates.append(info_custom_certificate(item.name.removesuffix(CRT_SUFFIX))) - - return custom_certificates - - -def delete_custom_certificate(fqdn): - """Delete custom certificate from system and Traefik configuration - - :param fqdn: FQDN to delete from filesystem - :type fqdn: str - :raises FileNotFoundError: if certificate or key is missing - """ - cert_file_path = Path(CUSTOM_CERTIFICATES_DIR + f'/{fqdn}{CRT_SUFFIX}') - key_file_path = Path(CUSTOM_CERTIFICATES_DIR + f'/{fqdn}{KEY_SUFFIX}') - cert_config_path = Path(CUSTOM_TRAEFIK_CONFIG_DIR + f'/certificate_{fqdn}.yml') - # check the presence of both file before removing, ensures that both are deleted, avoiding system inconsistencies - if cert_file_path.is_file() and key_file_path.is_file() and cert_config_path.is_file(): - cert_file_path.unlink() - key_file_path.unlink() - cert_config_path.unlink() - # remove the certificate and key from redis - rdb = agent.redis_connect(privileged=True) - rdb.delete(f'module/{os.environ["MODULE_ID"]}/certificate/{fqdn}') - - else: - raise FileNotFoundError(f'Invalid custom certificate state for {fqdn}.') diff --git a/imageroot/pypkg/get_certificate.py b/imageroot/pypkg/get_certificate.py deleted file mode 100755 index 6dc7a25..0000000 --- a/imageroot/pypkg/get_certificate.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 - -# -# Copyright (C) 2023 Nethesis S.r.l. -# SPDX-License-Identifier: GPL-3.0-or-later -# - -import json -import os -import agent -import urllib.request - -def get_certificate(data): - try: - name = data.get('name','') - fqdn = data.get('fqdn','') - certificate = {} - api_path = os.environ["API_PATH"] - moduleid = os.environ["MODULE_ID"] - - # we retrieve route and certificate for list-certificates action - if name != "": - with urllib.request.urlopen(f'http://127.0.0.1/{api_path}/api/http/routers/{name}') as res: - traefik_https_route = json.load(res) - - # we retrieve cert from fqdn for backward compatibility (it is an acme certificate) - elif fqdn != "": - with urllib.request.urlopen(f'http://127.0.0.1/{api_path}/api/http/routers/certificate-{fqdn}@file') as res: - traefik_https_route = json.load(res) - - # Check if the route is ready to use - if traefik_https_route['status'] == 'disabled': - return {} - - certificate['fqdn'] = traefik_https_route['tls']['domains'][0]['main'] - # either from internal or route (type could be also custom cert) - certificate['type'] = 'internal' if traefik_https_route['name'].startswith('certificate-') else 'route' - certificate['obtained'] = False - - # Open the certificates storage file - with open(f'/home/{moduleid}/.local/share/containers/storage/volumes/traefik-acme/_data/acme.json') as f: - acme_storage = json.load(f) - - resolver = traefik_https_route['tls']['certResolver'] - certificates = acme_storage[resolver].get('Certificates') - - # Check if the certificate is present in the storage - for cert in certificates if certificates else []: - if cert['domain']['main'] == certificate['fqdn']: - certificate['obtained'] = True - break - - except urllib.error.HTTPError as e: - if e.code == 404: - # If the certificate is not found, return an empty JSON object - pass - - except urllib.error.URLError as e: - raise Exception(f'Error reaching traefik daemon: {e.reason}') from e - - except json.decoder.JSONDecodeError: - # The acme storage is empty or corrupted, return the certificate as requested but not obtained - pass - - return certificate diff --git a/imageroot/pypkg/get_route.py b/imageroot/pypkg/get_route.py old mode 100755 new mode 100644 index 99ed098..3b6dca1 --- a/imageroot/pypkg/get_route.py +++ b/imageroot/pypkg/get_route.py @@ -63,8 +63,8 @@ def get_route(data, ignore_error = False): # Check if the certificate is retrieved automatically route['lets_encrypt'] = True if traefik_https_route['tls'].get("certResolver") else False - # Strip @file suffix from middlware names - for mid in traefik_http_route.get("middlewares",[]): + # Strip @file suffix from middleware names + for mid in traefik_http_route.get("middlewares",[]) + traefik_https_route.get("middlewares",[]): middlewares.append(mid[0:mid.index('@')]) # Check if redirect http to https is enabled diff --git a/imageroot/systemd/user/certificate-exporter.path b/imageroot/systemd/user/certificate-exporter.path index 6526f27..55e6284 100644 --- a/imageroot/systemd/user/certificate-exporter.path +++ b/imageroot/systemd/user/certificate-exporter.path @@ -2,5 +2,5 @@ Description=Monitor acme.json file for changes [Path] -PathChanged=%h/.local/share/containers/storage/volumes/traefik-acme/_data/acme.json +PathChanged=%S/state/acme/acme.json Unit=certificate-exporter.service diff --git a/imageroot/systemd/user/certificate-exporter.service b/imageroot/systemd/user/certificate-exporter.service index 5094b88..2eaa0ed 100644 --- a/imageroot/systemd/user/certificate-exporter.service +++ b/imageroot/systemd/user/certificate-exporter.service @@ -3,5 +3,5 @@ Description=Export acme.json changes [Service] Type=simple -ExecStart=/usr/local/bin/runagent export-certificate %h/.local/share/containers/storage/volumes/traefik-acme/_data/acme.json +ExecStart=/usr/local/bin/runagent export-certificate %S/state/acme/acme.json SyslogIdentifier=%u diff --git a/imageroot/systemd/user/traefik.service b/imageroot/systemd/user/traefik.service index 75a3587..cdbd017 100644 --- a/imageroot/systemd/user/traefik.service +++ b/imageroot/systemd/user/traefik.service @@ -14,12 +14,12 @@ ExecStart=/usr/bin/podman run \ --cgroups=no-conmon \ --network=host \ --replace --name=%N \ - --volume=traefik-acme:/etc/traefik/acme \ - --volume=./traefik.yaml:/etc/traefik/traefik.yaml:Z \ - --volume=./selfsigned.crt:/etc/traefik/selfsigned.crt:Z \ - --volume=./selfsigned.key:/etc/traefik/selfsigned.key:Z \ - --volume=./configs:/etc/traefik/configs:Z \ - --volume=./custom_certificates:/etc/traefik/custom_certificates:Z \ + --volume=./acme:/etc/traefik/acme:z \ + --volume=./traefik.yaml:/etc/traefik/traefik.yaml:z \ + --volume=./selfsigned.crt:/etc/traefik/selfsigned.crt:z \ + --volume=./selfsigned.key:/etc/traefik/selfsigned.key:z \ + --volume=./configs:/etc/traefik/configs:z \ + --volume=./custom_certificates:/etc/traefik/custom_certificates:z \ ${TRAEFIK_IMAGE} ExecStartPost=-runagent write-hosts ExecStop=/usr/bin/podman stop --ignore --cidfile %t/traefik.ctr-id -t 15 diff --git a/imageroot/update-module.d/15upgrade_v3 b/imageroot/update-module.d/15upgrade_v3 new file mode 100755 index 0000000..91514ac --- /dev/null +++ b/imageroot/update-module.d/15upgrade_v3 @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2025 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import os +import sys +import agent +import yaml +import glob + +yaml_options = { + "default_flow_style": False, + "sort_keys": False, + "allow_unicode": True, +} + +def main(): + static_cfg = yaml.safe_load(open("traefik.yaml")) + upgrade_to_v3(static_cfg) + +def upgrade_to_v3(static_cfg): + if not 'core' in static_cfg: + static_cfg['core'] = {} + elif static_cfg['core'].get('defaultRuleSyntax') == 'v3': + return # Nothing to do + print("Upgrading configuration for Traefik v3") + agent.run_helper("mkdir", "-vp", "acme") + agent.run_helper("podman", "cp", "traefik:/etc/traefik/acme/acme.json", "acme/acme.json") + agent.run_helper("cp", "-vfT", "traefik.yaml", "traefik.yaml.v2") + agent.run_helper("cp", "-rvfT", "configs", "configs.v2") + agent.run_helper("cp", "-rvfT", "custom_certificates", "custom_certificates.v2") + # Merge special routers for certificates into one default certificate: + defcert = {} + certificate_config_files = list(glob.glob("./configs/certificate-*.yml")) + for config_file in certificate_config_files: + try: + with open(config_file) as ofile: + certdyn = yaml.safe_load(ofile) or {} + upgrade_as_default_certificate(certdyn, defcert) + except Exception as ex: + print("ERROR", config_file, ex, file=sys.stderr) + os.unlink(config_file) + print("Remove certificate config file:", config_file, file=sys.stderr) + if defcert: + write_dynamic_config("./configs/_default_cert.yml", defcert) + # Required configuration upgrade from v2 to v3 format: + static_cfg['core']['defaultRuleSyntax'] = 'v3' + for config_file in glob.glob("./configs/*.yml"): + try: + with open(config_file) as ofile: + dynamic_config = yaml.safe_load(ofile) or {} + if 'http' in dynamic_config: + upgrade_http_to_v3(dynamic_config['http']) + write_dynamic_config(config_file, dynamic_config) + except Exception as ex: + print("ERROR", config_file, ex, file=sys.stderr) + write_static_cfg(static_cfg) + +def upgrade_as_default_certificate(certdyn, defcert): + """Transform the router TLS configuration into configuration for + defaultGeneratedCert.""" + try: + _, router = certdyn['http']['routers'].popitem() + odomain = router['tls']['domains'].pop() + del certdyn['http'] + except (KeyError, IndexError) as ex: + print("Certificate router parse error", ex, file=sys.stderr) + return # skip upgrade: the router is not as we expect + defcert.setdefault('tls', { + 'stores': { + 'default': { + 'defaultGeneratedCert': { + 'resolver': 'acmeServer', + 'domain': { + 'main': odomain['main'], + 'sans': [], + }, + }, + }, + }, + }) + # Merge the domain main and sans keys into defaultGeneratedCert SANs set: + defdom = defcert['tls']['stores']['default']['defaultGeneratedCert']['domain'] + sans = set(defdom.get('sans', [])) + sans.add(odomain['main']) + sans.update(set(odomain.get('sans', []))) + sans.discard(defdom['main']) + defdom['sans'] = list(sans) + print("Recording names for defaultGeneratedCert:", defdom['main'], sans, file=sys.stderr) + +def upgrade_http_to_v3(ohttp): + """Upgrade an http dynamic configuration to Traefik v3 format.""" + if 'routers' in ohttp: + for krouter in ohttp['routers']: + orouter = ohttp['routers'][krouter] + is_http = krouter.endswith("-http") + is_https = krouter.endswith("-https") + is_certificate = krouter.startswith("certificate-") + is_api = krouter == "ApisEndpointHttp" + if type(orouter.get('entryPoints')) is str: + if is_http: + orouter['entryPoints'] = ['http'] + elif is_https or is_certificate: + orouter['entryPoints'] = ['https'] + else: + orouter['entryPoints'] = orouter['entryPoints'].split(",") + print(f"Replaced string value for entryPoints in {krouter}", orouter['entryPoints']) + if not 'ruleSyntax' in orouter and not (is_http or is_https or is_certificate or is_api): + orouter['ruleSyntax'] = 'v2' # retain custom router rule syntax v2-compatible + if 'tls' in orouter and is_https: + try: + del orouter['tls']['domains'] + print(f"Removed TLS domains in {krouter}") + except: + pass + if 'middlewares' in ohttp: + for kmiddleware in ohttp['middlewares']: + omw = ohttp['middlewares'][kmiddleware] + if 'ipWhiteList' in omw: + omw.setdefault('IPAllowList', {}) # initialize if missing + # Merge ipWhiteList settings into IPAllowList: + omw['IPAllowList'].update(omw['ipWhiteList']) + del omw['ipWhiteList'] # remove obsolete key + print(f"Converted middleware ipWhiteList in {kmiddleware} to IPAllowList") + if 'stripPrefix' in omw: + if 'forceSlash' in omw['stripPrefix']: + del omw['stripPrefix']['forceSlash'] + print(f"Removed forceSlash option from middleware {kmiddleware}") + +def write_dynamic_config(config_file, dynamic_config): + """Atomically overwrite a Traefik dynamic config file with a new + configuration.""" + global yaml_options + with open(config_file + ".tmp", "w") as ofile: + yaml.safe_dump(dynamic_config, stream=ofile, **yaml_options) + os.rename(config_file + ".tmp", config_file) + print(f"Dynamic configuration written to {config_file}") + +def write_static_cfg(static_cfg): + """Atomically overwrite Traefik's static config file with a new + configuration.""" + global yaml_options + with open("traefik.yaml.tmp", "w") as ofile: + yaml.safe_dump(static_cfg, stream=ofile, **yaml_options) + os.rename("traefik.yaml.tmp", "traefik.yaml") + print("Static configuration written to traefik.yaml") + +if __name__ == "__main__": + main()