certlord 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- certlord/__init__.py +4 -0
- certlord/adapters/__init__.py +1 -0
- certlord/adapters/api_auth.py +21 -0
- certlord/adapters/auton_receipt.py +71 -0
- certlord/adapters/commands.py +122 -0
- certlord/adapters/cycle_progress.py +77 -0
- certlord/adapters/deployment_lease.py +51 -0
- certlord/adapters/deployment_queue.py +40 -0
- certlord/adapters/dns_policy.py +120 -0
- certlord/adapters/issuance_command.py +38 -0
- certlord/adapters/operations.py +69 -0
- certlord/adapters/pending.py +99 -0
- certlord/adapters/processes.py +75 -0
- certlord/adapters/redis_client.py +38 -0
- certlord/adapters/tls_probe.py +42 -0
- certlord/adapters/tls_verification.py +91 -0
- certlord/adapters/vault_auth.py +43 -0
- certlord/adapters/vault_store.py +123 -0
- certlord/classes/__init__.py +28 -0
- certlord/classes/certbot_handler.py +166 -0
- certlord/classes/config.py +73 -0
- certlord/classes/deployer.py +179 -0
- certlord/classes/exceptions.py +19 -0
- certlord/classes/ssl_cert_auto_object.py +79 -0
- certlord/classes/ssl_checker.py +374 -0
- certlord/classes/vault_crawler.py +192 -0
- certlord/client/__init__.py +1 -0
- certlord/client/api.py +71 -0
- certlord/client/cli.py +105 -0
- certlord/client/textual_tui.py +52 -0
- certlord/client/tui.py +53 -0
- certlord/composition.py +83 -0
- certlord/configuration_schema.py +61 -0
- certlord/modules/__init__.py +28 -0
- certlord/modules/letsencrypt.py +157 -0
- certlord/modules/route_auth.py +24 -0
- certlord/modules/ssl_certs.py +216 -0
- certlord/ports/__init__.py +1 -0
- certlord/ports/certificates.py +51 -0
- certlord/services/__init__.py +1 -0
- certlord/services/api_access.py +18 -0
- certlord/services/certificate_ids.py +40 -0
- certlord/services/certificates.py +367 -0
- certlord/services/cycle_progress.py +69 -0
- certlord/services/identity_definition.py +32 -0
- certlord/services/import_material.py +89 -0
- certlord/services/issuance.py +7 -0
- certlord/services/monitoring.py +75 -0
- certlord/services/observations.py +141 -0
- certlord/services/operation_tracking.py +86 -0
- certlord/services/runtime.py +260 -0
- certlord/services/verification.py +25 -0
- certlord-1.0.0.data/scripts/certlord +165 -0
- certlord-1.0.0.dist-info/METADATA +130 -0
- certlord-1.0.0.dist-info/RECORD +58 -0
- certlord-1.0.0.dist-info/WHEEL +5 -0
- certlord-1.0.0.dist-info/licenses/LICENSE +674 -0
- certlord-1.0.0.dist-info/top_level.txt +1 -0
certlord/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration and integration boundaries for external components."""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Delegate credential verification to HTTPdis; ignore caller-supplied identity headers."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class HttpdisApiAuth(object):
|
|
5
|
+
def authenticate(self, request):
|
|
6
|
+
request.authenticate()
|
|
7
|
+
return request.get_server_vars().get('HTTP_AUTH_USER')
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LocalFixtureAuth(object):
|
|
11
|
+
"""Only wired for explicit loopback development mode."""
|
|
12
|
+
def authenticate(self, request):
|
|
13
|
+
return 'local'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiRequestAccess(object):
|
|
17
|
+
def __init__(self, authenticator, policy):
|
|
18
|
+
self.authenticator, self.policy = authenticator, policy
|
|
19
|
+
|
|
20
|
+
def require(self, request, operation):
|
|
21
|
+
return self.policy.require(self.authenticator.authenticate(request), operation)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Use Auton's structured single-job CLI protocol without forwarding output."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
from certlord.adapters.processes import CommandRunner, CommandInterrupted, CommandTimeout
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidReceipt(ValueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def receipt_target(uri, endpoint, args):
|
|
14
|
+
# Restrict this opt-in contract to one destination and execution mode. Do not
|
|
15
|
+
# let arbitrary CLI flags override identity, output format or submit targets.
|
|
16
|
+
parsed = urlsplit(uri)
|
|
17
|
+
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
|
|
18
|
+
or parsed.username or parsed.password or parsed.query or parsed.fragment
|
|
19
|
+
or parsed.path not in ('', '/')
|
|
20
|
+
or not isinstance(endpoint, str)
|
|
21
|
+
or re.fullmatch(r'[A-Za-z0-9_-]{1,128}', endpoint) is None):
|
|
22
|
+
raise ValueError('Receipt mode requires one plain HTTP origin and endpoint')
|
|
23
|
+
if len(args) % 2 or any(args[i] not in ('--http-timeout', '--delay') for i in range(0, len(args), 2)):
|
|
24
|
+
raise ValueError('Receipt mode only supports --http-timeout and --delay overrides')
|
|
25
|
+
return {'origin': uri.rstrip('/'), 'endpoint': endpoint}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_receipted(runner, command, attempt_id, notify):
|
|
29
|
+
target = command['receipt_target']
|
|
30
|
+
expected = target['endpoint'] + ':' + attempt_id
|
|
31
|
+
deadline = time.monotonic() + runner.timeout
|
|
32
|
+
mode = 'run'
|
|
33
|
+
observed_state = None
|
|
34
|
+
while True:
|
|
35
|
+
remaining = deadline - time.monotonic()
|
|
36
|
+
if remaining <= 0:
|
|
37
|
+
raise CommandTimeout('Auton receipt deadline exceeded')
|
|
38
|
+
bounded = CommandRunner(runner.stop_event, remaining, runner.grace)
|
|
39
|
+
code, output, _ = bounded.run(command['args'] + ['--mode', mode, '--uid', attempt_id], command['env'])
|
|
40
|
+
try:
|
|
41
|
+
if not isinstance(output, bytes) or len(output) > 4 * 1024 * 1024:
|
|
42
|
+
raise ValueError()
|
|
43
|
+
result = json.loads(output)
|
|
44
|
+
if (not isinstance(result, dict) or result.get('uid') != expected
|
|
45
|
+
or type(result.get('code')) is not int or result['code'] not in (200, 400)
|
|
46
|
+
or result.get('status') not in ('new', 'processing', 'complete')):
|
|
47
|
+
raise ValueError()
|
|
48
|
+
complete = result['status'] == 'complete'
|
|
49
|
+
return_code = result.get('return_code')
|
|
50
|
+
if complete and type(return_code) is not int:
|
|
51
|
+
raise ValueError()
|
|
52
|
+
if not complete and (code != 0 or result['code'] != 200 or return_code is not None):
|
|
53
|
+
raise ValueError()
|
|
54
|
+
if complete and (code not in (0, 1) or bool(code) != (result['code'] != 200)):
|
|
55
|
+
raise ValueError()
|
|
56
|
+
except (ValueError, TypeError, UnicodeError):
|
|
57
|
+
raise InvalidReceipt('Auton returned no valid matching job receipt') from None
|
|
58
|
+
# Metadata is constructed from trusted target and matching response identity.
|
|
59
|
+
receipt = dict(target, uid=expected, status=result['status'], return_code=return_code)
|
|
60
|
+
if receipt['status'] != observed_state:
|
|
61
|
+
notify(receipt)
|
|
62
|
+
observed_state = receipt['status']
|
|
63
|
+
if complete:
|
|
64
|
+
# Reject uncertain/interrupted completion even if a provider reports 0.
|
|
65
|
+
successful = (return_code == 0 and result['code'] == 200
|
|
66
|
+
and not result.get('execution_uncertain')
|
|
67
|
+
and result.get('outcome') not in ('job.interrupted', 'job.cancelled'))
|
|
68
|
+
return (0 if successful else 1), receipt
|
|
69
|
+
mode = 'status' # Never resubmit after a missing, failed or ambiguous read.
|
|
70
|
+
if runner.stop_event.wait(min(1.0, max(0, deadline - time.monotonic()))):
|
|
71
|
+
raise CommandInterrupted('Worker stopping')
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
"""Build argv and explicit subprocess environments without running commands."""
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from certlord.classes.config import (AUTON_PROG, CERTBOT_PROG,
|
|
7
|
+
DEFAULT_BECOME_METHOD, DEFAULT_BECOME_OPTS,
|
|
8
|
+
DEFAULT_BECOME_USER, DEFAULT_CERTBOT_ARGS)
|
|
9
|
+
from certlord.classes.exceptions import CertLordConfigError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CommandBuilder(object):
|
|
13
|
+
def __init__(self, config, modconf):
|
|
14
|
+
self.config = config
|
|
15
|
+
self.modconf = modconf
|
|
16
|
+
self._auton_cred = {}
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def _get_become(cfg):
|
|
20
|
+
if not isinstance(cfg, dict) or not cfg.get('enabled'):
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
method = cfg.get('method') or DEFAULT_BECOME_METHOD
|
|
24
|
+
become = [method]
|
|
25
|
+
|
|
26
|
+
if method in DEFAULT_BECOME_OPTS:
|
|
27
|
+
become += DEFAULT_BECOME_OPTS[method]
|
|
28
|
+
|
|
29
|
+
if method == 'sudo':
|
|
30
|
+
become += ['-u', cfg.get('user') or DEFAULT_BECOME_USER]
|
|
31
|
+
|
|
32
|
+
return become
|
|
33
|
+
|
|
34
|
+
def certbot_cmd(self):
|
|
35
|
+
conf = {}
|
|
36
|
+
|
|
37
|
+
if isinstance(self.modconf.get('certbot'), dict):
|
|
38
|
+
conf = self.modconf['certbot']
|
|
39
|
+
|
|
40
|
+
r = {'args': self._get_become(conf.get('become')) + [CERTBOT_PROG],
|
|
41
|
+
'env': None}
|
|
42
|
+
|
|
43
|
+
if not conf:
|
|
44
|
+
r['args'] += DEFAULT_CERTBOT_ARGS
|
|
45
|
+
return r
|
|
46
|
+
|
|
47
|
+
if 'args' not in conf:
|
|
48
|
+
r['args'] += DEFAULT_CERTBOT_ARGS
|
|
49
|
+
else:
|
|
50
|
+
if not isinstance(conf['args'], list):
|
|
51
|
+
raise CertLordConfigError("invalid certbot args configuration")
|
|
52
|
+
|
|
53
|
+
r['args'] += conf['args']
|
|
54
|
+
|
|
55
|
+
if 'search_paths' in conf:
|
|
56
|
+
if not isinstance(conf['search_paths'], list):
|
|
57
|
+
raise CertLordConfigError("invalid certbot search_paths configuration")
|
|
58
|
+
|
|
59
|
+
r['env'] = {'PATH': os.path.pathsep.join(conf['search_paths'])}
|
|
60
|
+
|
|
61
|
+
return r
|
|
62
|
+
|
|
63
|
+
def _get_auton_cred(self):
|
|
64
|
+
if self._auton_cred:
|
|
65
|
+
return self._auton_cred
|
|
66
|
+
|
|
67
|
+
creds = self.config['credentials']['auton']
|
|
68
|
+
uri = creds.get('uri') or os.environ.get('AUTON_URI')
|
|
69
|
+
if not uri:
|
|
70
|
+
raise CertLordConfigError("missing variable AUTON URI")
|
|
71
|
+
|
|
72
|
+
endpoint = creds.get('endpoint') or os.environ.get('AUTON_ENDPOINT')
|
|
73
|
+
if not endpoint:
|
|
74
|
+
raise CertLordConfigError("missing variable AUTON endpoint")
|
|
75
|
+
|
|
76
|
+
auth_user = creds.get('auth-user') or os.environ.get('AUTON_AUTH_USER') or ""
|
|
77
|
+
auth_passwd = creds.get('auth-passwd') or os.environ.get('AUTON_AUTH_PASSWD') or ""
|
|
78
|
+
|
|
79
|
+
self._auton_cred = {'args': ['--uri', uri,
|
|
80
|
+
'--endpoint', endpoint],
|
|
81
|
+
'env': {'AUTON_AUTH_USER': auth_user,
|
|
82
|
+
'AUTON_AUTH_PASSWD': auth_passwd}}
|
|
83
|
+
|
|
84
|
+
return self._auton_cred
|
|
85
|
+
|
|
86
|
+
def auton_cmd(self):
|
|
87
|
+
conf = {}
|
|
88
|
+
|
|
89
|
+
if isinstance(self.modconf.get('auton'), dict):
|
|
90
|
+
conf = self.modconf['auton']
|
|
91
|
+
|
|
92
|
+
r = {'args': self._get_become(conf.get('become')) + [AUTON_PROG],
|
|
93
|
+
'env': dict(self._get_auton_cred()['env'])}
|
|
94
|
+
|
|
95
|
+
if not conf:
|
|
96
|
+
r['args'] += self._get_auton_cred()['args']
|
|
97
|
+
return r
|
|
98
|
+
|
|
99
|
+
if 'args' not in conf:
|
|
100
|
+
r['args'] += self._get_auton_cred()['args']
|
|
101
|
+
else:
|
|
102
|
+
if not isinstance(conf['args'], list):
|
|
103
|
+
raise CertLordConfigError("invalid auton args configuration")
|
|
104
|
+
|
|
105
|
+
r['args'] += conf['args'] + self._get_auton_cred()['args']
|
|
106
|
+
|
|
107
|
+
receipt_mode = conf.get('receipt_mode', False)
|
|
108
|
+
if type(receipt_mode) is not bool:
|
|
109
|
+
raise CertLordConfigError('auton receipt_mode must be boolean')
|
|
110
|
+
if receipt_mode:
|
|
111
|
+
from certlord.adapters.auton_receipt import receipt_target
|
|
112
|
+
credentials = self._get_auton_cred()['args']
|
|
113
|
+
r['receipt_target'] = receipt_target(credentials[1], credentials[3], conf.get('args', []))
|
|
114
|
+
|
|
115
|
+
if 'search_paths' in conf:
|
|
116
|
+
if not isinstance(conf['search_paths'], list):
|
|
117
|
+
raise CertLordConfigError("invalid auton search_paths configuration")
|
|
118
|
+
|
|
119
|
+
r['env']['PATH'] = os.path.pathsep.join(conf['search_paths'])
|
|
120
|
+
|
|
121
|
+
return r
|
|
122
|
+
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bounded cycle evidence in Redis; never a work queue or an execution lease."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
WORKERS = ('certlord-certbot_handler', 'certlord-crawler', 'certlord-deployer')
|
|
8
|
+
|
|
9
|
+
START = """
|
|
10
|
+
local old = redis.call('HGET', KEYS[1], 'current')
|
|
11
|
+
if old and cjson.decode(old).instance_id ~= ARGV[1] then
|
|
12
|
+
redis.call('HSET', KEYS[1], 'previous_instance', old)
|
|
13
|
+
end
|
|
14
|
+
redis.call('HSET', KEYS[1], 'current', ARGV[2])
|
|
15
|
+
return 1
|
|
16
|
+
"""
|
|
17
|
+
FINISH = """
|
|
18
|
+
local old = redis.call('HGET', KEYS[1], 'current')
|
|
19
|
+
if not old or cjson.decode(old).cycle_id ~= ARGV[1] then return 0 end
|
|
20
|
+
redis.call('HSET', KEYS[1], 'current', ARGV[2])
|
|
21
|
+
return 1
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record_value(raw):
|
|
26
|
+
"""Reject corrupt records instead of returning arbitrary Redis content."""
|
|
27
|
+
if raw is None:
|
|
28
|
+
return None
|
|
29
|
+
value = json.loads(raw)
|
|
30
|
+
fields = {'instance_id', 'cycle_id', 'state', 'started_timestamp_seconds',
|
|
31
|
+
'finished_timestamp_seconds', 'duration_seconds'}
|
|
32
|
+
if not isinstance(value, dict) or set(value) != fields:
|
|
33
|
+
raise ValueError('Invalid cycle record')
|
|
34
|
+
for field in ('instance_id', 'cycle_id'):
|
|
35
|
+
if str(uuid.UUID(value[field])) != value[field]:
|
|
36
|
+
raise ValueError('Invalid cycle identity')
|
|
37
|
+
if value['state'] not in ('running', 'returned', 'raised', 'stopped'):
|
|
38
|
+
raise ValueError('Invalid cycle state')
|
|
39
|
+
for field in ('started_timestamp_seconds', 'finished_timestamp_seconds', 'duration_seconds'):
|
|
40
|
+
number = value[field]
|
|
41
|
+
if number is None and field != 'started_timestamp_seconds' and value['state'] == 'running':
|
|
42
|
+
continue
|
|
43
|
+
if type(number) not in (int, float) or not math.isfinite(number) or number < 0:
|
|
44
|
+
raise ValueError('Invalid cycle timestamp or duration')
|
|
45
|
+
if value['state'] == 'running' and field != 'started_timestamp_seconds':
|
|
46
|
+
raise ValueError('Running cycle has completion evidence')
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CycleProgressStore:
|
|
51
|
+
def __init__(self, redis, server_id):
|
|
52
|
+
self.redis = redis
|
|
53
|
+
self.prefix = 'cycle-progress:' + hashlib.sha256(str(server_id).encode()).hexdigest() + ':'
|
|
54
|
+
|
|
55
|
+
def connection(self):
|
|
56
|
+
if len(self.redis.servers) != 1:
|
|
57
|
+
raise ValueError('One authoritative Redis server required')
|
|
58
|
+
return next(iter(self.redis.servers.values()))['conn']
|
|
59
|
+
|
|
60
|
+
def key(self, worker):
|
|
61
|
+
if worker not in WORKERS:
|
|
62
|
+
raise ValueError('Unknown cycle worker')
|
|
63
|
+
return self.prefix + worker
|
|
64
|
+
|
|
65
|
+
def start(self, worker, record):
|
|
66
|
+
return self.connection().eval(START, 1, self.key(worker), record['instance_id'], json.dumps(record)) == 1
|
|
67
|
+
|
|
68
|
+
def finish(self, worker, record):
|
|
69
|
+
return self.connection().eval(FINISH, 1, self.key(worker), record['cycle_id'], json.dumps(record)) == 1
|
|
70
|
+
|
|
71
|
+
def read(self):
|
|
72
|
+
connection = self.connection()
|
|
73
|
+
result = {}
|
|
74
|
+
for worker in WORKERS:
|
|
75
|
+
current, previous = connection.hmget(self.key(worker), 'current', 'previous_instance')
|
|
76
|
+
result[worker] = {'current': record_value(current), 'previous_instance': record_value(previous)}
|
|
77
|
+
return result
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
"""Exclusive bounded deployment lease on one authoritative Redis server."""
|
|
4
|
+
import hashlib
|
|
5
|
+
import math
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from certlord.classes.exceptions import CertLordConfigError
|
|
9
|
+
|
|
10
|
+
OWNED = "return redis.call('GET', KEYS[1]) == ARGV[1] and 1 or 0"
|
|
11
|
+
RENEW = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) end return 0"
|
|
12
|
+
RELEASE = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0"
|
|
13
|
+
ACK = """if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end
|
|
14
|
+
redis.call('SREM', KEYS[2], KEYS[3])
|
|
15
|
+
redis.call('DEL', KEYS[3])
|
|
16
|
+
return 1"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LeaseLost(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DeploymentLease(object):
|
|
24
|
+
def __init__(self, redis, server_id, timeout):
|
|
25
|
+
if len(redis.servers) != 1:
|
|
26
|
+
raise CertLordConfigError('Deployment coordination requires one authoritative Redis server')
|
|
27
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
28
|
+
raise CertLordConfigError('Deployment lease duration must be finite and positive')
|
|
29
|
+
self.conn = next(iter(redis.servers.values()))['conn']
|
|
30
|
+
self.key = 'deployment-lock:' + hashlib.sha256(str(server_id).encode('utf-8')).hexdigest()
|
|
31
|
+
self.timeout_ms = int(math.ceil(timeout * 1000))
|
|
32
|
+
self.token = None
|
|
33
|
+
|
|
34
|
+
def acquire(self):
|
|
35
|
+
token = str(uuid4())
|
|
36
|
+
if self.conn.set(self.key, token, nx=True, px=self.timeout_ms):
|
|
37
|
+
self.token = token
|
|
38
|
+
return True
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
def renew(self):
|
|
42
|
+
return bool(self.token and self.conn.eval(RENEW, 1, self.key, self.token, self.timeout_ms))
|
|
43
|
+
|
|
44
|
+
def release(self):
|
|
45
|
+
if self.token:
|
|
46
|
+
return bool(self.conn.eval(RELEASE, 1, self.key, self.token))
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def acknowledge(self, server_id, key):
|
|
50
|
+
if not self.token or not self.conn.eval(ACK, 3, self.key, server_id, key, self.token):
|
|
51
|
+
raise LeaseLost('Deployment lease lost; pending work retained')
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
"""Generation-specific deployment entries, atomic within each Redis server."""
|
|
4
|
+
import hashlib
|
|
5
|
+
from certlord.services.certificate_ids import require_certificate_id
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from six import iteritems
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def certificate_generation(certificate):
|
|
12
|
+
material = [certificate.get('cert', ''), certificate.get('chain', '')]
|
|
13
|
+
return hashlib.sha256(json.dumps(material, separators=(',', ':')).encode('utf-8')).hexdigest()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DeploymentQueue(object):
|
|
17
|
+
def __init__(self, redis):
|
|
18
|
+
self.redis = redis
|
|
19
|
+
|
|
20
|
+
def enqueue(self, server_id, certificate_id, domain, certificate):
|
|
21
|
+
certificate_id = require_certificate_id(certificate_id)
|
|
22
|
+
identity = [str(server_id), str(certificate_id), domain.lower(),
|
|
23
|
+
certificate.get('cert', ''), certificate.get('chain', '')]
|
|
24
|
+
key = 'deploy:' + hashlib.sha256(json.dumps(identity, separators=(',', ':')).encode('utf-8')).hexdigest()
|
|
25
|
+
payload = json.dumps({'certificate_id': str(certificate_id), 'domain': domain.lower(),
|
|
26
|
+
'generation': certificate_generation(certificate)})
|
|
27
|
+
for _, server in iteritems(self.redis.servers):
|
|
28
|
+
with server['conn'].pipeline(transaction=True) as pipe:
|
|
29
|
+
pipe.hsetnx(key, 'ts', '%.6f' % time.time())
|
|
30
|
+
pipe.hset(key, 'obj', payload)
|
|
31
|
+
pipe.sadd(server_id, key)
|
|
32
|
+
pipe.execute()
|
|
33
|
+
return key
|
|
34
|
+
|
|
35
|
+
def acknowledge(self, server_id, key):
|
|
36
|
+
for _, server in iteritems(self.redis.servers):
|
|
37
|
+
with server['conn'].pipeline(transaction=True) as pipe:
|
|
38
|
+
pipe.srem(server_id, key)
|
|
39
|
+
pipe.delete(key)
|
|
40
|
+
pipe.execute()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
"""DNS authorization policy, independent of HTTP and certificate storage."""
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import dns.exception
|
|
9
|
+
import dns.resolver
|
|
10
|
+
from six import iteritems
|
|
11
|
+
from certlord.classes.exceptions import CertLordConfigError
|
|
12
|
+
from certlord.classes.config import ALLOWED_DNS_RTYPE, DNS_RTYPE_CAA, DEFAULT_DNS_TIMEOUT
|
|
13
|
+
DNS_RDATA_CAA_MATCH = re.compile(r'^\s*[0-9]+\s*issue\s*"(.+)"$', re.I).match
|
|
14
|
+
|
|
15
|
+
class DnsPolicy(object):
|
|
16
|
+
def __init__(self, modconf, resolver=None):
|
|
17
|
+
self.modconf = modconf
|
|
18
|
+
try:
|
|
19
|
+
self.timeout = float(modconf.get('dns_timeout', DEFAULT_DNS_TIMEOUT))
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
raise CertLordConfigError('DNS timeout must be finite and positive')
|
|
22
|
+
if isinstance(modconf.get('dns_timeout'), bool) or not math.isfinite(self.timeout) or self.timeout <= 0:
|
|
23
|
+
raise CertLordConfigError('DNS timeout must be finite and positive')
|
|
24
|
+
self.resolver = resolver if resolver is not None else dns.resolver.Resolver()
|
|
25
|
+
self._allow_dns_resolv = {}
|
|
26
|
+
self._expected_caa_rec = set()
|
|
27
|
+
self._load_allow_dns_resolv()
|
|
28
|
+
self._load_expected_caa_records()
|
|
29
|
+
|
|
30
|
+
def _load_allow_dns_resolv(self):
|
|
31
|
+
if not self.modconf.get('allow_dns_resolv'):
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
for rtype, rregex in iteritems(self.modconf['allow_dns_resolv']):
|
|
35
|
+
if rtype not in ALLOWED_DNS_RTYPE:
|
|
36
|
+
raise CertLordConfigError("invalid allow_dns_resolv type: %s" % rtype)
|
|
37
|
+
|
|
38
|
+
if rtype not in self._allow_dns_resolv:
|
|
39
|
+
self._allow_dns_resolv[rtype] = []
|
|
40
|
+
|
|
41
|
+
for r in rregex:
|
|
42
|
+
self._allow_dns_resolv[rtype].append(re.compile(r).match)
|
|
43
|
+
|
|
44
|
+
if not self._allow_dns_resolv[rtype]:
|
|
45
|
+
del self._allow_dns_resolv[rtype]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load_expected_caa_records(self):
|
|
49
|
+
if not self.modconf.get('expected_caa_records'):
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
for caa_entry in self.modconf['expected_caa_records']:
|
|
53
|
+
if caa_entry is None:
|
|
54
|
+
self._expected_caa_rec.add(None)
|
|
55
|
+
else:
|
|
56
|
+
self._expected_caa_rec.add(re.compile(caa_entry, re.I).match)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _query(self, rname, rtype, deadline):
|
|
60
|
+
remaining = deadline - time.monotonic()
|
|
61
|
+
if remaining <= 0:
|
|
62
|
+
raise dns.exception.Timeout()
|
|
63
|
+
return self.resolver.query(rname, rtype, lifetime=remaining)
|
|
64
|
+
|
|
65
|
+
def check_dns_resolv(self, rname):
|
|
66
|
+
if not self._allow_dns_resolv:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
deadline = time.monotonic() + self.timeout
|
|
70
|
+
for rtype, rregex in iteritems(self._allow_dns_resolv):
|
|
71
|
+
try:
|
|
72
|
+
answers = self._query(rname, rtype, deadline)
|
|
73
|
+
except (dns.resolver.NoAnswer,
|
|
74
|
+
dns.resolver.NXDOMAIN):
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
for rdata in answers:
|
|
78
|
+
txt = rdata.to_text().rstrip('.')
|
|
79
|
+
for r in rregex:
|
|
80
|
+
if r(txt):
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_caa_records(self, rname):
|
|
87
|
+
if not self._expected_caa_rec:
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
rname_splitted = rname.split('.')
|
|
91
|
+
answers = None
|
|
92
|
+
sdom = rname
|
|
93
|
+
|
|
94
|
+
deadline = time.monotonic() + self.timeout
|
|
95
|
+
for i in range(0, len(rname_splitted)):
|
|
96
|
+
sdom = '.'.join(rname_splitted[i:])
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
answers = self._query(sdom, DNS_RTYPE_CAA, deadline)
|
|
100
|
+
except (dns.resolver.NoAnswer,
|
|
101
|
+
dns.resolver.NXDOMAIN):
|
|
102
|
+
continue
|
|
103
|
+
else:
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
if not answers:
|
|
107
|
+
return None in self._expected_caa_rec
|
|
108
|
+
|
|
109
|
+
for rregex in self._expected_caa_rec:
|
|
110
|
+
if rregex is None:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
for rdata in answers:
|
|
114
|
+
m = DNS_RDATA_CAA_MATCH(rdata.to_text())
|
|
115
|
+
if m and rregex(m.group(1)):
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Bind the installed ACME HTTP connector to one issuance attempt."""
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from sonicprobe import helpers
|
|
9
|
+
from certlord.classes.config import ISSUANCE_HEADER, CERTBOT_INSTALLER_CONFIG_OPTION, DEFAULT_INSTALLER_CONFIG
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@contextmanager
|
|
13
|
+
def issuance_command(command, attempt):
|
|
14
|
+
args = list(command['args'])
|
|
15
|
+
env = command.get('env')
|
|
16
|
+
source = (env if env is not None else os.environ).get('CBT_HTTPREQ_INST_CONFIG', DEFAULT_INSTALLER_CONFIG)
|
|
17
|
+
for index, arg in enumerate(args):
|
|
18
|
+
if arg == CERTBOT_INSTALLER_CONFIG_OPTION:
|
|
19
|
+
if index + 1 >= len(args):
|
|
20
|
+
raise ValueError('Missing installer configuration path')
|
|
21
|
+
source = args[index + 1]
|
|
22
|
+
elif arg.startswith(CERTBOT_INSTALLER_CONFIG_OPTION + '='):
|
|
23
|
+
source = arg.split('=', 1)[1]
|
|
24
|
+
conf = helpers.load_conf_yaml_file(source)
|
|
25
|
+
if not isinstance(conf, dict):
|
|
26
|
+
raise ValueError('Invalid installer configuration')
|
|
27
|
+
deploy = conf.setdefault('deploy', {})
|
|
28
|
+
headers = deploy.setdefault('headers', {})
|
|
29
|
+
# Remove differently-cased copies before assigning the canonical header.
|
|
30
|
+
for name in list(headers):
|
|
31
|
+
if name.lower() == ISSUANCE_HEADER.lower():
|
|
32
|
+
del headers[name]
|
|
33
|
+
headers[ISSUANCE_HEADER] = attempt
|
|
34
|
+
with TemporaryDirectory(prefix='certlord-issuance-') as directory:
|
|
35
|
+
path = Path(directory) / 'installer.yml'
|
|
36
|
+
with open(path, 'x', opener=lambda name, flags: os.open(name, flags, 0o600)) as stream:
|
|
37
|
+
yaml.safe_dump(conf, stream)
|
|
38
|
+
yield args + [CERTBOT_INSTALLER_CONFIG_OPTION, str(path)], env
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Bounded read-only observations of current Redis work, never queue repair."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from certlord.services.certificate_ids import valid_certificate_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def operations_snapshot(redis, server_id, limit, max_retries):
|
|
11
|
+
if len(redis.servers) != 1:
|
|
12
|
+
raise ValueError('One authoritative Redis server required')
|
|
13
|
+
connection = next(iter(redis.servers.values()))['conn']
|
|
14
|
+
started = time.time()
|
|
15
|
+
pending = {'create': 0, 'renew': 0, 'delete': 0, 'invalid-dns': 0, 'other': 0}
|
|
16
|
+
retries = blocked = 0
|
|
17
|
+
seen = set()
|
|
18
|
+
for raw_key in connection.scan_iter(match='cert:*', count=100):
|
|
19
|
+
key = raw_key.decode() if isinstance(raw_key, bytes) else raw_key
|
|
20
|
+
if key in seen:
|
|
21
|
+
continue
|
|
22
|
+
seen.add(key)
|
|
23
|
+
if len(seen) > limit:
|
|
24
|
+
raise ValueError('Pending observation limit exceeded')
|
|
25
|
+
if not valid_certificate_id(key[5:]):
|
|
26
|
+
continue
|
|
27
|
+
raw = connection.get(raw_key)
|
|
28
|
+
if raw is None:
|
|
29
|
+
continue
|
|
30
|
+
records = json.loads(raw)['certs']
|
|
31
|
+
for record in records.values():
|
|
32
|
+
status = record.get('status')
|
|
33
|
+
pending[status if status in pending else 'other'] += 1
|
|
34
|
+
retry = record.get('retry', 0)
|
|
35
|
+
if type(retry) is not int or retry < 0:
|
|
36
|
+
raise ValueError('Invalid retry state')
|
|
37
|
+
if status in ('create', 'renew', 'delete'):
|
|
38
|
+
retries += retry
|
|
39
|
+
blocked += int(retry >= max_retries)
|
|
40
|
+
members = set()
|
|
41
|
+
oldest = None
|
|
42
|
+
missing = 0
|
|
43
|
+
for raw_key in connection.sscan_iter(server_id, count=100):
|
|
44
|
+
key = raw_key.decode() if isinstance(raw_key, bytes) else raw_key
|
|
45
|
+
if key in members:
|
|
46
|
+
continue
|
|
47
|
+
members.add(key)
|
|
48
|
+
if len(members) > limit:
|
|
49
|
+
raise ValueError('Deployment observation limit exceeded')
|
|
50
|
+
if not re.fullmatch(r'deploy:[0-9a-f]{64}', key):
|
|
51
|
+
raise ValueError('Invalid deployment queue key')
|
|
52
|
+
timestamp = connection.hget(raw_key, 'ts')
|
|
53
|
+
if timestamp is None:
|
|
54
|
+
missing += 1
|
|
55
|
+
continue
|
|
56
|
+
timestamp = float(timestamp)
|
|
57
|
+
if not math.isfinite(timestamp) or timestamp <= 0:
|
|
58
|
+
raise ValueError('Invalid deployment timestamp')
|
|
59
|
+
oldest = timestamp if oldest is None else min(oldest, timestamp)
|
|
60
|
+
lease_key = 'deployment-lock:' + hashlib.sha256(str(server_id).encode()).hexdigest()
|
|
61
|
+
ttl = connection.pttl(lease_key)
|
|
62
|
+
completed = time.time()
|
|
63
|
+
return {'started_timestamp_seconds': started, 'completed_timestamp_seconds': completed,
|
|
64
|
+
'pending_actions': pending, 'pending_retry_count': retries,
|
|
65
|
+
'pending_at_retry_limit': blocked, 'deployment_entries': len(members),
|
|
66
|
+
'deployment_missing_timestamps': missing,
|
|
67
|
+
'deployment_oldest_age_seconds': None if oldest is None else max(0, completed-oldest),
|
|
68
|
+
'deployment_lease_ttl_seconds': ttl/1000 if ttl >= 0 else None,
|
|
69
|
+
'deployment_lease_state': 'held' if ttl >= 0 else ('absent' if ttl == -2 else 'unbounded')}
|