diff --git a/README.md b/README.md index ade3b10..58db957 100644 --- a/README.md +++ b/README.md @@ -218,27 +218,22 @@ Output: ## set-certificate -Run this action to request a Let's Encrypt certificate if [HTTP-01 -challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) -requirements are met. +Run this action to request a new default certificate for Traefik. The +action parameters are: -It can be used when there is no hostname (or hostname + path) route -configured on traefik module or if the service is not make accessible via -traefik. +- `fqdn` (string): the name of the requested certificate +- `sync_timeout` (integer, default `30`): the maximum number of seconds to + wait for the certificate to be obtained -The action takes 3 parameters: -- `fqdn`: the fqdn of the requested certificate -- `sync`: wait until the certificate is obtained before return, default `false`. -- `sync_timeout`: Max number of seconds to wait for the certificate to be obtained, default `120`. +If ACME challenge requirements are met, the new certificate will be valid +for the given `fqdn` and any other names configured by previous action +calls. See also . If not, +the previous configuration is retained. Example: -``` -api-cli run module/traefik1/set-certificate --data '{"fqdn":"myhost.example.com","sync":false}' -``` -Output: -```json -{"obtained": false} +``` +api-cli run module/traefik1/set-certificate --data '{"fqdn":"myhost.example.com"}' ``` ## get-certificate @@ -260,16 +255,27 @@ Output: ## delete-certificate -This action deletes an existing route used for explicit request a certificate. +This action deletes a TLS certificate from Traefik's configuration. Its +parameters are: -NB. The certificate will **not** actually be removed from traefik and if the conditions will remain in place it will be renewed. +- `fqdn` (string): the name of the TLS certificate +- `type` (one of `internal` or `custom`): use `internal` for Let's Encrypt + certificates, `custom` for uploaded certificates. -The action takes 1 parameter: -- `fqdn`: the fqdn of the requested certificate +The effects depend on the certificate type: + +- `internal` If the certificate was obtained from Let's Encrypt using the + ACME protocol, the `fqdn` is removed from Traefik's + `defaultGeneratedCert` configuration. The certificate will **not** + actually be removed from Traefik's `acme.json` certificate storage. Even + if unused, it will be renewed as long as the conditions permit. +- `custom` If the certificate was uploaded, it is erased from disk along + with its private key and removed from Traefik's TLS configuration. Example: + ``` -api-cli run delete-certificate --agent module/traefik1 --data "{\"fqdn\": \"$(hostname -f)\"" +api-cli run module/traefik1/delete-certificate --data '{"fqdn":"myhost.example.com","type":"internal"}' ``` ## list-certificates diff --git a/imageroot/actions/delete-certificate/20writeconfig b/imageroot/actions/delete-certificate/20writeconfig index 3c56b72..3f13c58 100755 --- a/imageroot/actions/delete-certificate/20writeconfig +++ b/imageroot/actions/delete-certificate/20writeconfig @@ -16,21 +16,32 @@ def main(): tstart = datetime.datetime.now(datetime.UTC) 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) - obtained = cert_helpers.wait_acmejson_sync(timeout=request.get('sync_timeout', 30)) - if not obtained: + if request['type'] == 'custom': + if fqdn in cert_helpers.read_custom_cert_names(): + cert_helpers.remove_custom_cert(fqdn) + else: + exit_certificate_not_found_error(fqdn) + elif request['type'] == 'internal': + cur_names = cert_helpers.read_default_cert_names() + if not fqdn in cur_names: + exit_certificate_not_found_error(fqdn) + new_names = list(filter(lambda x: x != fqdn, cur_names)) + if len(new_names) == 0: + cert_helpers.remove_default_certificate_name(fqdn) + elif cert_helpers.validate_certificate_names(main=new_names[0], sans=new_names[1:], timeout=request.get('sync_timeout', 30)): + cert_helpers.remove_default_certificate_name(fqdn) + else: acme_error = cert_helpers.traefik_last_acme_error_since(tstart) for errline in acme_error.split("\n"): print(agent.SD_ERR + errline, file=sys.stderr) - exit(3) + sys.exit(3) 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) + sys.exit(4) + +def exit_certificate_not_found_error(fqdn): + agent.set_status('validation-failed') + json.dump([{'field': 'fqdn','parameter':'fqdn','value': fqdn,'error':'certificate_not_found'}], fp=sys.stdout) + sys.exit(2) if __name__ == "__main__": main() diff --git a/imageroot/actions/delete-certificate/validate-input.json b/imageroot/actions/delete-certificate/validate-input.json index b68cae4..c031946 100644 --- a/imageroot/actions/delete-certificate/validate-input.json +++ b/imageroot/actions/delete-certificate/validate-input.json @@ -2,19 +2,32 @@ "$schema": "http://json-schema.org/draft-07/schema#", "title": "delete-certificate input", "$id": "http://schema.nethserver.org/traefik/set-certificate-input.json", - "description": "Delete a let's encrypt certificate", + "description": "Delete a configured TLS certificate", "examples": [ { - "fqdn": "example.com" - } + "fqdn": "example.com", + "type": "internal" + }, + { + "fqdn": "blog.example.net", + "type": "custom" + } ], "type": "object", "required": [ - "fqdn" + "fqdn", + "type" ], "properties": { + "type": { + "type": "string", + "enum": [ + "custom", + "internal" + ] + }, "fqdn": { - "type":"string", + "type": "string", "format": "hostname", "title": "A fully qualified domain name" } diff --git a/imageroot/actions/delete-certificate/validate-output.json b/imageroot/actions/delete-certificate/validate-output.json deleted file mode 100644 index 15e5ebd..0000000 --- a/imageroot/actions/delete-certificate/validate-output.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "delete-certificate output", - "$id": "http://schema.nethserver.org/traefik/delete-certificate-output.json", - "description": "Just a boolean value. It is `true` if the routes have been deleted successfully", - "examples": [ - true, - false - ], - "type": "boolean" -} diff --git a/imageroot/actions/set-certificate/20writeconfig b/imageroot/actions/set-certificate/20writeconfig index c6d0173..7d20a76 100755 --- a/imageroot/actions/set-certificate/20writeconfig +++ b/imageroot/actions/set-certificate/20writeconfig @@ -15,16 +15,15 @@ import datetime def main(): tstart = datetime.datetime.now(datetime.UTC) 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', 30)) + if cert_helpers.validate_certificate_names(request['fqdn'], timeout=request.get('sync_timeout', 30)): + cert_helpers.add_default_certificate_name(request['fqdn']) + json.dump({"obtained": True}, fp=sys.stdout) else: - obtained = False - json.dump({"obtained": obtained}, fp=sys.stdout) - if request.get('sync') is not None and obtained is False: acme_error = cert_helpers.traefik_last_acme_error_since(tstart) for errline in acme_error.split('\n'): print(agent.SD_ERR + errline, file=sys.stderr) + # NOTE: this action does not return the usual validation-failed format + json.dump({"obtained": False}, fp=sys.stdout) exit(2) if __name__ == "__main__": diff --git a/imageroot/actions/set-certificate/validate-input.json b/imageroot/actions/set-certificate/validate-input.json index 55f81ba..66c356d 100644 --- a/imageroot/actions/set-certificate/validate-input.json +++ b/imageroot/actions/set-certificate/validate-input.json @@ -4,9 +4,13 @@ "$id": "http://schema.nethserver.org/traefik/set-certificate-input.json", "description": "Request a let's encrypt certificate", "examples": [ - {"fqdn": "example.com"}, - {"fqdn": "example.com", "sync":true}, - {"fqdn": "example.com", "sync":true, "sync_timeout": 300} + { + "fqdn": "example.com" + }, + { + "fqdn": "example.com", + "sync_timeout": 10 + } ], "type": "object", "required": [ @@ -14,20 +18,15 @@ ], "properties": { "fqdn": { - "type":"string", + "type": "string", "format": "hostname", "title": "A fully qualified domain name" }, - "sync": { - "type":"boolean", - "description": "Wait for the certificate to be obtained before return" - }, - "sync_timeout": { - "type":"integer", - "minimum": 1, - "description": "Max number of seconds to wait for the certificate to be obtained", - "default": 120 - } - + "sync_timeout": { + "type": "integer", + "minimum": 1, + "description": "Max number of seconds to wait for the certificate to be obtained", + "default": 30 + } } } diff --git a/imageroot/actions/set-route/20writeconfig b/imageroot/actions/set-route/20writeconfig index 4d65242..6ec6446 100755 --- a/imageroot/actions/set-route/20writeconfig +++ b/imageroot/actions/set-route/20writeconfig @@ -71,7 +71,7 @@ if data.get("host") is not None: # Enable or disable Let's Encrypt certificate if data.get("host") is not None: if data["lets_encrypt"]: - router_https["tls"]["certresolver"] = "acmeServer" + router_https["tls"]["certResolver"] = "acmeServer" # IP addresses allowed to use the router if data.get('ip_allowlist', []) != []: diff --git a/imageroot/pypkg/cert_helpers.py b/imageroot/pypkg/cert_helpers.py index 5b6adc7..0936ab6 100644 --- a/imageroot/pypkg/cert_helpers.py +++ b/imageroot/pypkg/cert_helpers.py @@ -12,6 +12,7 @@ import glob import subprocess import datetime +import select def read_default_cert_names(): """Return the list of host names configured in the @@ -59,7 +60,7 @@ def has_acmejson_name(name): for ocert in acmejson['acmeServer']["Certificates"] or []: if ocert["domain"]["main"] == name or name in ocert["domain"].get("sans", []): return True - except (FileNotFoundError, KeyError): + except (FileNotFoundError, KeyError, json.JSONDecodeError): pass return False @@ -73,27 +74,48 @@ def has_acmejson_cert(main, sans=[]): if ocert["domain"]["main"] == main and set(ocert["domain"].get("sans", [])) == set(sans): return True return False - except (FileNotFoundError, KeyError): + except (FileNotFoundError, KeyError, json.JSONDecodeError): pass 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.""" + certificate matching 'names' appears, an error occurs, 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() if not names: return True # Consider as obtained, if no names are set. elapsed = 0.0 - while elapsed < timeout: - time.sleep(interval) - elapsed += interval - if has_acmejson_cert(names[0], names[1:]): - return True + tstart = datetime.datetime.now(datetime.UTC) + logcli_cmd = [ + "logcli", + "query", + "--tail", + "--limit=1", + "--from=" + tstart.isoformat(), + "--timezone=Local", # use system timezone for output + "--quiet", + "--no-labels", + '{module_id=~"traefik.+"} | json | line_format "{{.MESSAGE}}"' + \ + '| logfmt | providerName="acmeServer.acme" and error!=""' + \ + '| line_format "{{.error}}"', + ] + with subprocess.Popen(logcli_cmd, stdout=subprocess.PIPE, text=True) as logcli_proc: + fdmon = logcli_proc.stdout.fileno() + while elapsed < timeout: + time.sleep(interval) + elapsed += interval + if has_acmejson_cert(names[0], names[1:]): + logcli_proc.terminate() + return True # certificate obtained successfully! + read_fds, _, _ = select.select([fdmon], [], [], 0) # 0 = non blocking + if read_fds: + logcli_proc.terminate() + break # got some error messages from logcli. return False def add_default_certificate_name(main, sans=[]): @@ -201,3 +223,37 @@ def traefik_last_acme_error_since(tstart): except subprocess.CalledProcessError as ex: acme_error = 'traefik_last_acme_error_since(): logcli error - ' + str(ex) return acme_error + +def validate_certificate_names(main, sans=[], timeout=30): + """Issue a certificate request to ACME server and return if it has + been obtained or not.""" + # Check if we already have the same certificate in acme.json: + if has_acmejson_cert(main, sans): + return True + routerconf = { + "http": { + "services": { + "_validation000": { + "loadBalancer": { + "servers": ["ping@internal"] + } + } + }, + "routers": { + "_validation000": { + "rule": f"Host(`{main}`) && Path(`/_validation000`)", + "priority": 100001, + "service": "_validation000", + "entryPoints": ["https"], + "tls": { + "domains": [{"main": main, "sans": sans}], + "certResolver": "acmeServer", + } + } + } + } + } + write_yaml_config(routerconf, "configs/_validation000.yml") + obtained = wait_acmejson_sync(timeout=timeout, interval=1.1, names=[main] + sans) + os.unlink("configs/_validation000.yml") + return obtained diff --git a/tests/20_traefik_certificates_api.robot b/tests/20_traefik_certificates_api.robot index 758a19a..ecf8548 100644 --- a/tests/20_traefik_certificates_api.robot +++ b/tests/20_traefik_certificates_api.robot @@ -19,27 +19,24 @@ Get configured ACME server Request an invalid certificate ${response} = Run task module/${MID}/set-certificate - ... {"fqdn":"example.com"} + ... {"fqdn":"example.com"} rc_expected=2 Should Be Equal As Strings ${response['obtained']} False Get invalid cerficate status - ${response} = Run task module/${MID}/get-certificate {"fqdn": "example.com"} - Should Be Equal As Strings ${response['fqdn']} example.com - Should Be Equal As Strings ${response['obtained']} False - Should Be Equal As Strings ${response['type']} internal + ${response} = Run task module/${MID}/get-certificate {"fqdn": "example.com"} decode_json=${False} + Should Be Equal As Strings ${response} {} Get certificate list - ${response} = Run task module/${MID}/list-certificates null - Should Contain ${response} example.com + ${response} = Run task module/${MID}/list-certificates null decode_json=${False} + Should Be Equal As Strings ${response} [] Get expanded certificate list - ${response} = Run task module/${MID}/list-certificates {"expand_list": true} - Should Be Equal As Strings ${response[0]['fqdn']} example.com - Should Be Equal As Strings ${response[0]['obtained']} False - Should Be Equal As Strings ${response[0]['type']} internal + ${response} = Run task module/${MID}/list-certificates {"expand_list": true} decode_json=${False} + Should Be Equal As Strings ${response} [] Delete certificate - Run task module/${MID}/delete-certificate {"fqdn": "example.com"} + ${response} = Run task module/${MID}/delete-certificate + ... {"fqdn": "example.com","type":"internal"} decode_json=${False} rc_expected=2 Get empty certificates list ${response} = Run task module/${MID}/list-certificates null @@ -89,6 +86,6 @@ Upload a custom certificate Execute Command runagent -m ${MID} python3 -c 'import agent ; agent.set_env("UPLOAD_CERTIFICATE_VERIFY_TYPE", "chain")' Delete custom certificate - Run task module/${MID}/delete-certificate {"fqdn": "test.example.com"} + Run task module/${MID}/delete-certificate {"fqdn": "test.example.com","type":"custom"} ${response} = Execute Command redis-cli --raw EXISTS module/${MID}/certificate/test.example.com Should Be Equal As Integers ${response} 0