cryptolabs-proxy 1.3.1__tar.gz → 1.5.0__tar.gz
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.
- {cryptolabs_proxy-1.3.1/src/cryptolabs_proxy.egg-info → cryptolabs_proxy-1.5.0}/PKG-INFO +5 -1
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/README.md +4 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/pyproject.toml +1 -1
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/__init__.py +1 -1
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/config.py +93 -2
- cryptolabs_proxy-1.5.0/src/cryptolabs_proxy/controller_recovery.py +284 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/setup.py +30 -7
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/standard_adoption.py +262 -11
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/docker-compose.yml.j2 +15 -3
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/nginx.conf.j2 +49 -5
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/updates.py +19 -6
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0/src/cryptolabs_proxy.egg-info}/PKG-INFO +5 -1
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/SOURCES.txt +4 -0
- cryptolabs_proxy-1.5.0/tests/test_controller_guard_transport.py +126 -0
- cryptolabs_proxy-1.5.0/tests/test_controller_recovery_guard.py +342 -0
- cryptolabs_proxy-1.5.0/tests/test_hostname_policy.py +497 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_proxy_customer_route_repairs.py +7 -2
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_proxy_recovery_tls_repairs.py +1 -1
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_standard_adoption.py +3 -2
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_vast_price_manager.py +6 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/LICENSE +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/setup.cfg +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/auth.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/cli.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/custom_config.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/exporter_manager.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/identity.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/migration.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/release_selection.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/services.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/ssl.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/acme-challenge.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/exporter-auth-routes.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/fleet-api-routes.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/host-forwarding.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/legacy-bootstrap-nginx.conf +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/legacy-bootstrap-provenance.json +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/standard-service-routes.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/vast-price-manager-auth-blocks.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/vast-price-manager.conf.j2 +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/vpm_prerequisite.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/dependency_links.txt +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/entry_points.txt +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/requires.txt +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/top_level.txt +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_auth.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_edge_ownership.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_exporter_auth_protections.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_host_forwarding.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_identity_host_package.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_image_release_promotion.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_netbox_link.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_offline_image.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_proxy_lifecycle_review_repairs.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_identifier_boundary.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_publication.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_selection.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_workflow_temp_paths.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_routes.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_updates.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_version_consistency.py +0 -0
- {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_vpm_prerequisite.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cryptolabs-proxy
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Unified reverse proxy and fleet management landing page for CryptoLabs products
|
|
5
5
|
Author-email: CryptoLabs <support@cryptolabs.co.za>
|
|
6
6
|
License: MIT
|
|
@@ -190,6 +190,10 @@ always-running internal VPM session and reauthentication endpoints cannot fall
|
|
|
190
190
|
through a preserved public <code>/auth/</code> route. Rollback restores the
|
|
191
191
|
original stored baseline exactly.
|
|
192
192
|
|
|
193
|
+
Supported DC monitoring adoption uses the private durable
|
|
194
|
+
[controller adoption guard](docs/controller-adoption-guard.md), including normal
|
|
195
|
+
updater and proxy-adoption exclusion during crash recovery.
|
|
196
|
+
|
|
193
197
|
### Vast Price Manager Fleet sign-in
|
|
194
198
|
|
|
195
199
|
### Vast Price Manager prerequisite
|
|
@@ -162,6 +162,10 @@ always-running internal VPM session and reauthentication endpoints cannot fall
|
|
|
162
162
|
through a preserved public <code>/auth/</code> route. Rollback restores the
|
|
163
163
|
original stored baseline exactly.
|
|
164
164
|
|
|
165
|
+
Supported DC monitoring adoption uses the private durable
|
|
166
|
+
[controller adoption guard](docs/controller-adoption-guard.md), including normal
|
|
167
|
+
updater and proxy-adoption exclusion during crash recovery.
|
|
168
|
+
|
|
165
169
|
### Vast Price Manager Fleet sign-in
|
|
166
170
|
|
|
167
171
|
### Vast Price Manager prerequisite
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cryptolabs-proxy"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
8
8
|
description = "Unified reverse proxy and fleet management landing page for CryptoLabs products"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
|
5
5
|
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
6
6
|
|
|
7
7
|
CONFIG_DIR = Path("/etc/cryptolabs-proxy")
|
|
8
|
+
_UNSET = object()
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def get_jinja_env():
|
|
@@ -17,13 +18,19 @@ def get_jinja_env():
|
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
|
|
20
|
-
def generate_nginx_config(config_dir: Path, domain: str, letsencrypt: bool = False, services: dict = None, *, host_forward_groups=None, acme_challenge=None):
|
|
21
|
+
def generate_nginx_config(config_dir: Path, domain: str, letsencrypt: bool = False, services: dict = None, *, host_forward_groups=None, acme_challenge=None, hostname_policy=_UNSET, http_policy=_UNSET):
|
|
21
22
|
"""Generate nginx.conf from template."""
|
|
22
23
|
from .services import ServiceRegistry
|
|
23
24
|
if host_forward_groups is None:
|
|
24
25
|
host_forward_groups = ServiceRegistry(config_dir).config.get('host_forward_groups', [])
|
|
25
26
|
if acme_challenge is None:
|
|
26
27
|
acme_challenge = ServiceRegistry(config_dir).config.get('acme_challenge')
|
|
28
|
+
if hostname_policy is _UNSET:
|
|
29
|
+
hostname_policy = ServiceRegistry(config_dir).config.get("hostname_policy")
|
|
30
|
+
hostname_policy = validate_hostname_policy(hostname_policy, domain)
|
|
31
|
+
if http_policy is _UNSET:
|
|
32
|
+
http_policy = ServiceRegistry(config_dir).config.get("http_policy")
|
|
33
|
+
http_policy = validate_http_policy(http_policy, domain, acme_challenge)
|
|
27
34
|
acme_challenge = validate_acme_challenge(acme_challenge)
|
|
28
35
|
host_forward_groups = validate_host_forward_groups(host_forward_groups, domain)
|
|
29
36
|
env = get_jinja_env()
|
|
@@ -35,6 +42,8 @@ def generate_nginx_config(config_dir: Path, domain: str, letsencrypt: bool = Fal
|
|
|
35
42
|
services=services or {},
|
|
36
43
|
host_forward_groups=host_forward_groups,
|
|
37
44
|
acme_challenge=acme_challenge,
|
|
45
|
+
hostname_policy=hostname_policy,
|
|
46
|
+
http_policy=http_policy,
|
|
38
47
|
)
|
|
39
48
|
|
|
40
49
|
(config_dir / "nginx.conf").write_text(content)
|
|
@@ -57,7 +66,12 @@ def generate_docker_compose(config_dir: Path, domain: str = None, use_letsencryp
|
|
|
57
66
|
template = env.get_template("docker-compose.yml.j2")
|
|
58
67
|
from .release_selection import load, targets, SelectionError
|
|
59
68
|
from .services import ServiceRegistry
|
|
60
|
-
|
|
69
|
+
registry = ServiceRegistry(config_dir)
|
|
70
|
+
if domain is None: domain = registry.config.get('domain')
|
|
71
|
+
release_selection_dir = release_selection_dir or registry.config.get('release_selection_dir')
|
|
72
|
+
http_policy = validate_http_policy(registry.config.get('http_policy'), domain, registry.config.get('acme_challenge'))
|
|
73
|
+
healthcheck = policy_healthcheck(registry.config.get('standard_adoption',{}).get('healthcheck'),domain,registry.config.get('hostname_policy'),http_policy,letsencrypt=use_letsencrypt)
|
|
74
|
+
health_command = healthcheck['Test'][1] if healthcheck else None
|
|
61
75
|
proxy_image = 'ghcr.io/cryptolabsza/cryptolabs-proxy:latest'
|
|
62
76
|
proxy_origin = None
|
|
63
77
|
if release_selection_dir:
|
|
@@ -73,6 +87,9 @@ def generate_docker_compose(config_dir: Path, domain: str = None, use_letsencryp
|
|
|
73
87
|
release_selection_dir=str(Path(release_selection_dir).resolve()) if release_selection_dir else None,
|
|
74
88
|
proxy_image=proxy_image,
|
|
75
89
|
proxy_origin=proxy_origin,
|
|
90
|
+
http_policy=http_policy,
|
|
91
|
+
policy_healthcheck=health_command,
|
|
92
|
+
policy_healthcheck_config=healthcheck,
|
|
76
93
|
)
|
|
77
94
|
|
|
78
95
|
(config_dir / "docker-compose.yml").write_text(content)
|
|
@@ -143,3 +160,77 @@ def validate_acme_challenge(value):
|
|
|
143
160
|
|
|
144
161
|
def render_acme_challenge(value, *, legacy_profile=False):
|
|
145
162
|
return get_jinja_env().get_template('acme-challenge.conf.j2').render(acme_challenge=value,legacy_forward_profile=legacy_profile).encode()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def validate_hostname_policy(policy, domain):
|
|
166
|
+
"""Only the observed literal-primary $host guard with fixed 444 is supported."""
|
|
167
|
+
import re
|
|
168
|
+
import ipaddress
|
|
169
|
+
if policy is None:
|
|
170
|
+
return None
|
|
171
|
+
if not isinstance(policy, str) or policy != 'primary-only':
|
|
172
|
+
raise ValueError('invalid_hostname_policy')
|
|
173
|
+
literal = re.compile(r'(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+')
|
|
174
|
+
if not isinstance(domain, str) or not literal.fullmatch(domain):
|
|
175
|
+
raise ValueError('hostname_policy_requires_literal_domain')
|
|
176
|
+
try:
|
|
177
|
+
ipaddress.ip_address(domain)
|
|
178
|
+
except ValueError:
|
|
179
|
+
return policy
|
|
180
|
+
raise ValueError('hostname_policy_requires_literal_domain')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_http_policy(policy, domain, acme_challenge=None):
|
|
184
|
+
"""Closed primary HTTPS redirect and local HTTP ACME transport."""
|
|
185
|
+
if policy is None:
|
|
186
|
+
return None
|
|
187
|
+
if not isinstance(policy, str) or policy != 'redirect-primary-https':
|
|
188
|
+
raise ValueError('invalid_http_policy')
|
|
189
|
+
validate_hostname_policy('primary-only', domain)
|
|
190
|
+
if acme_challenge is not None:
|
|
191
|
+
raise ValueError('http_policy_local_acme_conflicts_with_upstream')
|
|
192
|
+
return policy
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def render_policy_healthcheck(domain, hostname_policy=None, http_policy=None, *, letsencrypt=False):
|
|
196
|
+
"""Public HTTPS loopback uses the declared Host/SNI; internal API stays checked.
|
|
197
|
+
|
|
198
|
+
The stored certificate is the explicit local trust anchor. Hostname
|
|
199
|
+
verification remains enabled; browser and forwarding trust are unchanged.
|
|
200
|
+
"""
|
|
201
|
+
validate_hostname_policy(hostname_policy, domain)
|
|
202
|
+
validate_http_policy(http_policy, domain)
|
|
203
|
+
if hostname_policy is None and http_policy is None:
|
|
204
|
+
return None
|
|
205
|
+
validate_hostname_policy('primary-only', domain)
|
|
206
|
+
certificate='/etc/letsencrypt/live/'+domain+'/fullchain.pem' if letsencrypt else '/etc/nginx/ssl/server.crt'
|
|
207
|
+
return ('curl -fsS --cacert '+certificate+' --resolve '+domain+':443:127.0.0.1 https://'+domain+
|
|
208
|
+
'/api/health && curl -f http://127.0.0.1:8080/api/health || exit 1')
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def policy_healthcheck(current, domain, hostname_policy=None, http_policy=None, *, letsencrypt=False):
|
|
212
|
+
"""Keep the exact independently observed verified probe; map image defaults.
|
|
213
|
+
|
|
214
|
+
Recognition compares fixed raw shell syntax, with spaces/tabs only. It
|
|
215
|
+
never accepts arbitrary operations, flags, URLs, credentials or snippets.
|
|
216
|
+
"""
|
|
217
|
+
from copy import deepcopy
|
|
218
|
+
import re
|
|
219
|
+
command=render_policy_healthcheck(domain,hostname_policy,http_policy,letsencrypt=letsencrypt)
|
|
220
|
+
if command is None: return None
|
|
221
|
+
default=['CMD-SHELL','curl -f http://localhost/api/health && curl -f http://localhost:8080/api/health || exit 1']
|
|
222
|
+
if current is None: return {'Test':['CMD-SHELL',command]}
|
|
223
|
+
fields={'Test','Interval','Timeout','StartPeriod','StartInterval','Retries'}
|
|
224
|
+
if not isinstance(current,dict) or set(current)-fields or not isinstance(current.get('Test'),list): raise ValueError('source_healthcheck_unmapped')
|
|
225
|
+
if any(type(v) is not int or not 0<=v<=2**63-1 for k,v in current.items() if k!='Test'): raise ValueError('source_healthcheck_unmapped')
|
|
226
|
+
test=current['Test']
|
|
227
|
+
trusted='curl -fsS --resolve '+domain+':443:127.0.0.1 https://'+domain+'/api/health >/dev/null && curl -fsS http://localhost:8080/api/health >/dev/null || exit 1'
|
|
228
|
+
if len(test)==2 and test[0]=='CMD-SHELL' and isinstance(test[1],str) and len(test[1])<=4096:
|
|
229
|
+
# Shell operators must remain operators, not quoted/escaped curl
|
|
230
|
+
# arguments. Only spaces/tabs may vary in this closed raw skeleton.
|
|
231
|
+
# The validated domain is escaped with every other literal position.
|
|
232
|
+
skeleton=re.escape(trusted).replace(r'\ ',r'[ ]+')
|
|
233
|
+
if re.fullmatch(skeleton,test[1]): return deepcopy(current)
|
|
234
|
+
if test not in (default,['CMD-SHELL',command]): raise ValueError('source_healthcheck_unmapped')
|
|
235
|
+
result=deepcopy(current);result['Test']=['CMD-SHELL',command]
|
|
236
|
+
return result
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Private controller exclusion on the updater's existing auth mount.
|
|
2
|
+
|
|
3
|
+
This module uses only stdlib so its fixed helper can run without auth startup,
|
|
4
|
+
network access, the Docker socket, or the source container's environment.
|
|
5
|
+
"""
|
|
6
|
+
import fcntl
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
|
|
18
|
+
MARKER = 'controller-adoption.json'
|
|
19
|
+
MAX_MARKER = 4096
|
|
20
|
+
GUARD_IDLE_SECONDS = 300
|
|
21
|
+
CAPABILITY = {'schema_version': 1, 'api': 'AdoptionEngine.controller_guard',
|
|
22
|
+
'durable_updater_exclusion': True}
|
|
23
|
+
FIELDS = {'schema_version', 'kind', 'operation_id', 'source_id', 'source_image',
|
|
24
|
+
'auth_mount_sha256', 'owner'}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecoveryError(RuntimeError):
|
|
28
|
+
"""Fixed public failure, without private filesystem or Engine details."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _hold():
|
|
32
|
+
raise RecoveryError('controller_recovery_required')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_record(value):
|
|
36
|
+
if not isinstance(value, dict) or set(value) != FIELDS:
|
|
37
|
+
_hold()
|
|
38
|
+
if type(value['schema_version']) is not int or value['schema_version'] != 1 or value['kind'] != 'dc-controller-adoption':
|
|
39
|
+
_hold()
|
|
40
|
+
patterns = {'operation_id': r'[A-Za-z0-9][A-Za-z0-9_.-]{0,95}',
|
|
41
|
+
'source_id': r'[0-9a-f]{64}',
|
|
42
|
+
'source_image': r'sha256:[0-9a-f]{64}',
|
|
43
|
+
'auth_mount_sha256': r'[0-9a-f]{64}', 'owner': r'[0-9a-f]{64}'}
|
|
44
|
+
for key, pattern in patterns.items():
|
|
45
|
+
if not isinstance(value[key], str) or not re.fullmatch(pattern, value[key]):
|
|
46
|
+
_hold()
|
|
47
|
+
return value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _private(info, *, directory=False):
|
|
51
|
+
return ((stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode))
|
|
52
|
+
and info.st_uid == os.geteuid()
|
|
53
|
+
and stat.S_IMODE(info.st_mode) == (0o700 if directory else 0o600)
|
|
54
|
+
and (directory or info.st_nlink == 1))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def open_directory(directory):
|
|
59
|
+
"""Traverse each parent without following links; create only missing dirs."""
|
|
60
|
+
path = Path(directory)
|
|
61
|
+
if not path.is_absolute() or '..' in path.parts:
|
|
62
|
+
_hold()
|
|
63
|
+
descriptor = os.open('/', os.O_RDONLY | os.O_DIRECTORY)
|
|
64
|
+
try:
|
|
65
|
+
for index, part in enumerate(path.parts[1:]):
|
|
66
|
+
try:
|
|
67
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
os.mkdir(part, 0o700, dir_fd=descriptor)
|
|
70
|
+
os.fsync(descriptor)
|
|
71
|
+
child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=descriptor)
|
|
72
|
+
os.close(descriptor)
|
|
73
|
+
descriptor = child
|
|
74
|
+
if not _private(os.fstat(descriptor), directory=True):
|
|
75
|
+
_hold()
|
|
76
|
+
yield descriptor
|
|
77
|
+
except (OSError, ValueError):
|
|
78
|
+
_hold()
|
|
79
|
+
finally:
|
|
80
|
+
os.close(descriptor)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unique_object(pairs):
|
|
84
|
+
result = {}
|
|
85
|
+
for key, value in pairs:
|
|
86
|
+
if key in result:
|
|
87
|
+
raise ValueError('duplicate field')
|
|
88
|
+
result[key] = value
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_marker(directory_fd):
|
|
93
|
+
try:
|
|
94
|
+
descriptor = os.open(MARKER, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory_fd)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
return None
|
|
97
|
+
except OSError:
|
|
98
|
+
_hold()
|
|
99
|
+
try:
|
|
100
|
+
info = os.fstat(descriptor)
|
|
101
|
+
if not _private(info) or info.st_size > MAX_MARKER:
|
|
102
|
+
_hold()
|
|
103
|
+
data = os.read(descriptor, MAX_MARKER + 1)
|
|
104
|
+
if len(data) > MAX_MARKER:
|
|
105
|
+
_hold()
|
|
106
|
+
try:
|
|
107
|
+
value = json.loads(data, object_pairs_hook=_unique_object)
|
|
108
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
109
|
+
_hold()
|
|
110
|
+
return validate_record(value)
|
|
111
|
+
finally:
|
|
112
|
+
os.close(descriptor)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def require_clear(directory):
|
|
116
|
+
with open_directory(directory) as descriptor:
|
|
117
|
+
if read_marker(descriptor) is not None:
|
|
118
|
+
_hold()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _write_marker(directory_fd, value):
|
|
122
|
+
validate_record(value)
|
|
123
|
+
temporary = '.' + MARKER + '-' + uuid.uuid4().hex
|
|
124
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
125
|
+
0o600, dir_fd=directory_fd)
|
|
126
|
+
try:
|
|
127
|
+
with os.fdopen(descriptor, 'wb') as stream:
|
|
128
|
+
stream.write(json.dumps(value, sort_keys=True, separators=(',', ':')).encode())
|
|
129
|
+
stream.flush()
|
|
130
|
+
os.fsync(stream.fileno())
|
|
131
|
+
os.rename(temporary, MARKER, src_dir_fd=directory_fd, dst_dir_fd=directory_fd)
|
|
132
|
+
os.fsync(directory_fd)
|
|
133
|
+
finally:
|
|
134
|
+
try: os.unlink(temporary, dir_fd=directory_fd)
|
|
135
|
+
except FileNotFoundError: pass
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def open_lock(directory_fd, name):
|
|
139
|
+
descriptor = os.open(name, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_NONBLOCK,
|
|
140
|
+
0o600, dir_fd=directory_fd)
|
|
141
|
+
if not _private(os.fstat(descriptor)):
|
|
142
|
+
os.close(descriptor)
|
|
143
|
+
_hold()
|
|
144
|
+
return descriptor
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@contextmanager
|
|
148
|
+
def updater_lock(directory, name):
|
|
149
|
+
with open_directory(directory) as directory_fd:
|
|
150
|
+
descriptor = None
|
|
151
|
+
try:
|
|
152
|
+
descriptor = open_lock(directory_fd, name)
|
|
153
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
154
|
+
yield
|
|
155
|
+
except OSError:
|
|
156
|
+
_hold()
|
|
157
|
+
finally:
|
|
158
|
+
if descriptor is not None:
|
|
159
|
+
os.close(descriptor)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def request(path, owner, action, timeout):
|
|
163
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
164
|
+
client.settimeout(timeout)
|
|
165
|
+
client.connect(path)
|
|
166
|
+
client.sendall(json.dumps({'owner': owner, 'action': action}).encode() + b'\n')
|
|
167
|
+
response = b''
|
|
168
|
+
while not response.endswith(b'\n'):
|
|
169
|
+
chunk = client.recv(1024)
|
|
170
|
+
if not chunk: _hold()
|
|
171
|
+
response += chunk
|
|
172
|
+
if len(response) > MAX_MARKER: _hold()
|
|
173
|
+
value = json.loads(response)
|
|
174
|
+
if value != {'owner': owner, 'state': 'completed' if action == 'complete' else 'held'}:
|
|
175
|
+
_hold()
|
|
176
|
+
return value
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# This fixed client is executed inside the exact guard helper, never the proxy.
|
|
180
|
+
CLIENT_COMMAND = """import json,socket,sys
|
|
181
|
+
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as c:
|
|
182
|
+
c.settimeout(2); c.connect('/tmp/controller-guard.sock')
|
|
183
|
+
c.sendall(json.dumps({'owner':sys.argv[1],'action':sys.argv[2]}).encode()+b'\\n')
|
|
184
|
+
data=b''
|
|
185
|
+
while not data.endswith(b'\\n'):
|
|
186
|
+
part=c.recv(1024)
|
|
187
|
+
if not part: raise RuntimeError('guard_unavailable')
|
|
188
|
+
data+=part
|
|
189
|
+
if len(data)>4096: raise RuntimeError('guard_unavailable')
|
|
190
|
+
sys.stdout.buffer.write(data)
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def helper_main(directory, socket_path, expected, recover, timeout):
|
|
195
|
+
"""Hold real locks until removal; durable marker outlives this process."""
|
|
196
|
+
validate_record(expected)
|
|
197
|
+
with open_directory(directory) as directory_fd:
|
|
198
|
+
descriptors = []
|
|
199
|
+
try:
|
|
200
|
+
deadline = time.monotonic() + timeout
|
|
201
|
+
for name in ('dispatch.lock', 'operation.lock'):
|
|
202
|
+
descriptor = open_lock(directory_fd, name)
|
|
203
|
+
descriptors.append((name, descriptor, os.fstat(descriptor)))
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
207
|
+
break
|
|
208
|
+
except BlockingIOError:
|
|
209
|
+
if time.monotonic() >= deadline: _hold()
|
|
210
|
+
time.sleep(.02)
|
|
211
|
+
previous = read_marker(directory_fd)
|
|
212
|
+
if previous is not None:
|
|
213
|
+
if not recover or {k: v for k, v in previous.items() if k != 'owner'} != {k: v for k, v in expected.items() if k != 'owner'}:
|
|
214
|
+
_hold()
|
|
215
|
+
_write_marker(directory_fd, expected)
|
|
216
|
+
completed = False
|
|
217
|
+
|
|
218
|
+
def prove():
|
|
219
|
+
# Reopen by no-follow traversal to detect replaced parent/lock
|
|
220
|
+
# inodes. Fixed helper never unlocks its live descriptors.
|
|
221
|
+
with open_directory(directory) as current_fd:
|
|
222
|
+
if (os.fstat(current_fd).st_dev, os.fstat(current_fd).st_ino) != (os.fstat(directory_fd).st_dev, os.fstat(directory_fd).st_ino):
|
|
223
|
+
_hold()
|
|
224
|
+
for name, descriptor, original in descriptors:
|
|
225
|
+
current = os.stat(name, dir_fd=current_fd, follow_symlinks=False)
|
|
226
|
+
if not _private(current) or (current.st_dev, current.st_ino) != (original.st_dev, original.st_ino):
|
|
227
|
+
_hold()
|
|
228
|
+
if (os.fstat(descriptor).st_dev, os.fstat(descriptor).st_ino) != (original.st_dev, original.st_ino):
|
|
229
|
+
_hold()
|
|
230
|
+
actual = read_marker(current_fd)
|
|
231
|
+
if actual != (None if completed else expected): _hold()
|
|
232
|
+
|
|
233
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
|
234
|
+
server.bind(socket_path)
|
|
235
|
+
os.chmod(socket_path, 0o600)
|
|
236
|
+
server.listen(1)
|
|
237
|
+
verified_at = time.monotonic()
|
|
238
|
+
while True:
|
|
239
|
+
remaining = GUARD_IDLE_SECONDS - (time.monotonic() - verified_at)
|
|
240
|
+
if remaining <= 0:
|
|
241
|
+
return # Release locks; persisted marker stays in place.
|
|
242
|
+
server.settimeout(min(remaining, .25))
|
|
243
|
+
try:
|
|
244
|
+
connection, _ = server.accept()
|
|
245
|
+
except socket.timeout:
|
|
246
|
+
continue
|
|
247
|
+
with connection:
|
|
248
|
+
connection.settimeout(min(2, remaining))
|
|
249
|
+
try:
|
|
250
|
+
data = b''
|
|
251
|
+
while not data.endswith(b'\n'):
|
|
252
|
+
part = connection.recv(1024)
|
|
253
|
+
if not part: _hold()
|
|
254
|
+
data += part
|
|
255
|
+
if len(data) > MAX_MARKER: _hold()
|
|
256
|
+
command = json.loads(data)
|
|
257
|
+
if command not in ({'owner': expected['owner'], 'action': 'check'},
|
|
258
|
+
{'owner': expected['owner'], 'action': 'complete'}): _hold()
|
|
259
|
+
prove()
|
|
260
|
+
if time.monotonic() - verified_at >= GUARD_IDLE_SECONDS:
|
|
261
|
+
_hold()
|
|
262
|
+
verified_at = time.monotonic()
|
|
263
|
+
if command['action'] == 'complete':
|
|
264
|
+
if not completed:
|
|
265
|
+
os.unlink(MARKER, dir_fd=directory_fd)
|
|
266
|
+
os.fsync(directory_fd)
|
|
267
|
+
completed = True
|
|
268
|
+
state = 'completed'
|
|
269
|
+
else:
|
|
270
|
+
if completed: _hold()
|
|
271
|
+
state = 'held'
|
|
272
|
+
response = {'owner': expected['owner'], 'state': state}
|
|
273
|
+
except (OSError, ValueError, RecoveryError):
|
|
274
|
+
response = {'state': 'unavailable'}
|
|
275
|
+
try: connection.sendall(json.dumps(response).encode() + b'\n')
|
|
276
|
+
except OSError: pass
|
|
277
|
+
finally:
|
|
278
|
+
for _, descriptor, _ in descriptors: os.close(descriptor)
|
|
279
|
+
try: os.unlink(socket_path)
|
|
280
|
+
except FileNotFoundError: pass
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == '__main__':
|
|
284
|
+
helper_main(sys.argv[1], sys.argv[2], json.loads(sys.argv[3]), sys.argv[4] == 'recover', float(sys.argv[5]))
|
|
@@ -14,7 +14,7 @@ from dataclasses import dataclass
|
|
|
14
14
|
from typing import Optional, Dict, List, Tuple
|
|
15
15
|
from datetime import datetime
|
|
16
16
|
|
|
17
|
-
from .config import generate_nginx_config, generate_docker_compose, CONFIG_DIR
|
|
17
|
+
from .config import generate_nginx_config, generate_docker_compose, validate_hostname_policy, validate_http_policy, render_policy_healthcheck, CONFIG_DIR
|
|
18
18
|
from .ssl import generate_self_signed_cert
|
|
19
19
|
from .services import ServiceRegistry
|
|
20
20
|
from .release_selection import SELECTION_CONTRACT, SelectionError, load as load_selection, targets as selection_targets
|
|
@@ -45,8 +45,12 @@ class ProxyConfig:
|
|
|
45
45
|
# Internal API token - shared secret for service-to-service config API
|
|
46
46
|
internal_api_token: str = ""
|
|
47
47
|
release_selection_dir: Optional[Path] = None
|
|
48
|
+
hostname_policy: Optional[str] = None
|
|
49
|
+
http_policy: Optional[str] = None
|
|
48
50
|
|
|
49
51
|
def __post_init__(self):
|
|
52
|
+
self.hostname_policy = validate_hostname_policy(self.hostname_policy, self.domain)
|
|
53
|
+
self.http_policy = validate_http_policy(self.http_policy, self.domain)
|
|
50
54
|
if not self.auth_secret:
|
|
51
55
|
self.auth_secret = secrets.token_hex(32)
|
|
52
56
|
if not self.internal_api_token:
|
|
@@ -452,6 +456,13 @@ def setup_proxy(
|
|
|
452
456
|
|
|
453
457
|
target_image = 'ghcr.io/cryptolabsza/cryptolabs-proxy:latest'
|
|
454
458
|
recorded = ServiceRegistry(CONFIG_DIR).config
|
|
459
|
+
try:
|
|
460
|
+
policy = config.hostname_policy if config.hostname_policy is not None else recorded.get('hostname_policy')
|
|
461
|
+
config.hostname_policy = validate_hostname_policy(policy, config.domain)
|
|
462
|
+
http_policy = config.http_policy if config.http_policy is not None else recorded.get("http_policy")
|
|
463
|
+
config.http_policy = validate_http_policy(http_policy, config.domain, recorded.get("acme_challenge"))
|
|
464
|
+
except ValueError:
|
|
465
|
+
return False, 'Invalid hostname policy; setup not started'
|
|
455
466
|
if recorded.get('standard_adoption'):
|
|
456
467
|
from .standard_adoption import StandardAdoption
|
|
457
468
|
return StandardAdoption(CONFIG_DIR).reconfigure(config)
|
|
@@ -459,6 +470,8 @@ def setup_proxy(
|
|
|
459
470
|
if config.release_selection_dir is None and recorded_directory:
|
|
460
471
|
config.release_selection_dir = Path(recorded_directory)
|
|
461
472
|
baseline = config.release_selection_dir is not None
|
|
473
|
+
if (config.hostname_policy or config.http_policy) and not baseline:
|
|
474
|
+
return False, 'Hostname policy requires approved standard setup or configuration adoption'
|
|
462
475
|
if baseline and recorded.get('custom_config'):
|
|
463
476
|
from .updates import PROXY_ADOPTION_MESSAGE
|
|
464
477
|
return False, 'Custom Nginx configuration requires host adoption. '+PROXY_ADOPTION_MESSAGE
|
|
@@ -568,16 +581,18 @@ def setup_proxy(
|
|
|
568
581
|
candidate_dir = Path(temporary)
|
|
569
582
|
generate_nginx_config(candidate_dir, config.domain, config.use_letsencrypt and ssl_success, registry.services,
|
|
570
583
|
host_forward_groups=registry.config.get('host_forward_groups', []),
|
|
571
|
-
acme_challenge=registry.config.get('acme_challenge')
|
|
584
|
+
acme_challenge=registry.config.get('acme_challenge'),
|
|
585
|
+
hostname_policy=config.hostname_policy, http_policy=config.http_policy)
|
|
572
586
|
validation = ['docker', 'run', '--rm', '--network', DOCKER_NETWORK_NAME,
|
|
573
587
|
'--entrypoint', '']
|
|
574
588
|
validation.extend(['-v', f'{CONFIG_DIR / "ssl"}:/etc/nginx/ssl:ro',
|
|
575
589
|
'-v', f'{candidate_dir / "nginx.conf"}:/etc/nginx/nginx.conf:ro'])
|
|
576
590
|
if config.use_letsencrypt and ssl_success:
|
|
591
|
+
validation.extend(['-v', '/etc/letsencrypt:/etc/letsencrypt:ro'])
|
|
592
|
+
if config.use_letsencrypt and ssl_success or config.http_policy:
|
|
577
593
|
webroot = CONFIG_DIR / 'certbot' / 'www'
|
|
578
594
|
webroot.mkdir(parents=True, exist_ok=True)
|
|
579
|
-
validation.extend(['-v', '/
|
|
580
|
-
'-v', f'{webroot}:/var/www/certbot:ro'])
|
|
595
|
+
validation.extend(['-v', f'{webroot}:/var/www/certbot:ro'])
|
|
581
596
|
validation.extend([target_image, 'nginx', '-t'])
|
|
582
597
|
try:
|
|
583
598
|
tested = subprocess.run(validation, capture_output=True, timeout=60)
|
|
@@ -614,8 +629,12 @@ def setup_proxy(
|
|
|
614
629
|
'-e', 'RELEASE_SELECTION_REQUIRED=true', '--label', 'io.cryptolabs.update.reference=' + target_image,
|
|
615
630
|
'--label', 'io.cryptolabs.update.origin=' + proxy_target['origin']])
|
|
616
631
|
if config.use_letsencrypt and ssl_success:
|
|
617
|
-
cmd.extend(['-v', '/etc/letsencrypt:/etc/letsencrypt:ro'
|
|
618
|
-
|
|
632
|
+
cmd.extend(['-v', '/etc/letsencrypt:/etc/letsencrypt:ro'])
|
|
633
|
+
if config.use_letsencrypt and ssl_success or config.http_policy:
|
|
634
|
+
cmd.extend(['-v', f'{CONFIG_DIR / "certbot" / "www"}:/var/www/certbot:ro'])
|
|
635
|
+
healthcheck = render_policy_healthcheck(config.domain, config.hostname_policy, config.http_policy, letsencrypt=config.use_letsencrypt and ssl_success)
|
|
636
|
+
if healthcheck:
|
|
637
|
+
cmd.extend(['--health-cmd', healthcheck])
|
|
619
638
|
cmd.append(target_image)
|
|
620
639
|
|
|
621
640
|
log("Freeing ports 80/443...")
|
|
@@ -681,6 +700,10 @@ def setup_proxy(
|
|
|
681
700
|
# Step 6: Initialize service registry
|
|
682
701
|
registry = ServiceRegistry(CONFIG_DIR)
|
|
683
702
|
registry.config["domain"] = config.domain
|
|
703
|
+
if config.hostname_policy is not None:
|
|
704
|
+
registry.config["hostname_policy"] = config.hostname_policy
|
|
705
|
+
if config.http_policy is not None:
|
|
706
|
+
registry.config["http_policy"] = config.http_policy
|
|
684
707
|
registry.config["letsencrypt"] = config.use_letsencrypt and ssl_success
|
|
685
708
|
if baseline:
|
|
686
709
|
registry.config['release_selection_dir'] = str(Path(config.release_selection_dir).resolve())
|
|
@@ -731,7 +754,7 @@ def update_proxy_credentials(
|
|
|
731
754
|
fleet_admin_user=fleet_admin_user, fleet_admin_pass=fleet_admin_pass, auth_secret=auth_secret,
|
|
732
755
|
watchdog_api_key=watchdog_api_key, watchdog_url=watchdog_url,
|
|
733
756
|
internal_api_token=internal_api_token, site_name=existing.get('SITE_NAME', 'DC Overview'),
|
|
734
|
-
release_selection_dir=Path(release_directory)))
|
|
757
|
+
release_selection_dir=Path(release_directory), hostname_policy=registry.config.get('hostname_policy'), http_policy=registry.config.get('http_policy')))
|
|
735
758
|
|
|
736
759
|
# Recreate with new credentials
|
|
737
760
|
subprocess.run(["docker", "rm", "-f", "cryptolabs-proxy"], capture_output=True)
|