Skip to content

Commit

Permalink
Merge pull request #84 from NethServer/feat-6987
Browse files Browse the repository at this point in the history
fix!: avoid temporary Traefik cert

Refs NethServer/dev#6987
  • Loading branch information
DavidePrincipi authored Mar 6, 2025
2 parents 90122c9 + 51ec776 commit c4c776c
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 94 deletions.
48 changes: 27 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://letsencrypt.org/docs/challenge-types/>. 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
Expand All @@ -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
Expand Down
33 changes: 22 additions & 11 deletions imageroot/actions/delete-certificate/20writeconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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()
23 changes: 18 additions & 5 deletions imageroot/actions/delete-certificate/validate-input.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
11 changes: 0 additions & 11 deletions imageroot/actions/delete-certificate/validate-output.json

This file was deleted.

11 changes: 5 additions & 6 deletions imageroot/actions/set-certificate/20writeconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
29 changes: 14 additions & 15 deletions imageroot/actions/set-certificate/validate-input.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,29 @@
"$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": [
"fqdn"
],
"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
}
}
}
2 changes: 1 addition & 1 deletion imageroot/actions/set-route/20writeconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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', []) != []:
Expand Down
78 changes: 67 additions & 11 deletions imageroot/pypkg/cert_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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=[]):
Expand Down Expand Up @@ -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
Loading

0 comments on commit c4c776c

Please # to comment.