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.
Files changed (62) hide show
  1. {cryptolabs_proxy-1.3.1/src/cryptolabs_proxy.egg-info → cryptolabs_proxy-1.5.0}/PKG-INFO +5 -1
  2. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/README.md +4 -0
  3. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/pyproject.toml +1 -1
  4. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/__init__.py +1 -1
  5. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/config.py +93 -2
  6. cryptolabs_proxy-1.5.0/src/cryptolabs_proxy/controller_recovery.py +284 -0
  7. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/setup.py +30 -7
  8. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/standard_adoption.py +262 -11
  9. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/docker-compose.yml.j2 +15 -3
  10. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/nginx.conf.j2 +49 -5
  11. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/updates.py +19 -6
  12. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0/src/cryptolabs_proxy.egg-info}/PKG-INFO +5 -1
  13. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/SOURCES.txt +4 -0
  14. cryptolabs_proxy-1.5.0/tests/test_controller_guard_transport.py +126 -0
  15. cryptolabs_proxy-1.5.0/tests/test_controller_recovery_guard.py +342 -0
  16. cryptolabs_proxy-1.5.0/tests/test_hostname_policy.py +497 -0
  17. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_proxy_customer_route_repairs.py +7 -2
  18. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_proxy_recovery_tls_repairs.py +1 -1
  19. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_standard_adoption.py +3 -2
  20. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_vast_price_manager.py +6 -0
  21. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/LICENSE +0 -0
  22. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/setup.cfg +0 -0
  23. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/auth.py +0 -0
  24. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/cli.py +0 -0
  25. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/custom_config.py +0 -0
  26. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/exporter_manager.py +0 -0
  27. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/identity.py +0 -0
  28. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/migration.py +0 -0
  29. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/release_selection.py +0 -0
  30. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/services.py +0 -0
  31. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/ssl.py +0 -0
  32. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/acme-challenge.conf.j2 +0 -0
  33. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/exporter-auth-routes.conf.j2 +0 -0
  34. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/fleet-api-routes.conf.j2 +0 -0
  35. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/host-forwarding.conf.j2 +0 -0
  36. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/legacy-bootstrap-nginx.conf +0 -0
  37. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/legacy-bootstrap-provenance.json +0 -0
  38. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/standard-service-routes.conf.j2 +0 -0
  39. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/vast-price-manager-auth-blocks.conf.j2 +0 -0
  40. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/vast-price-manager.conf.j2 +0 -0
  41. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/vpm_prerequisite.py +0 -0
  42. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/dependency_links.txt +0 -0
  43. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/entry_points.txt +0 -0
  44. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/requires.txt +0 -0
  45. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/top_level.txt +0 -0
  46. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_auth.py +0 -0
  47. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_edge_ownership.py +0 -0
  48. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_exporter_auth_protections.py +0 -0
  49. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_host_forwarding.py +0 -0
  50. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_identity_host_package.py +0 -0
  51. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_image_release_promotion.py +0 -0
  52. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_netbox_link.py +0 -0
  53. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_offline_image.py +0 -0
  54. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_proxy_lifecycle_review_repairs.py +0 -0
  55. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_identifier_boundary.py +0 -0
  56. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_publication.py +0 -0
  57. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_selection.py +0 -0
  58. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_release_workflow_temp_paths.py +0 -0
  59. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_routes.py +0 -0
  60. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_updates.py +0 -0
  61. {cryptolabs_proxy-1.3.1 → cryptolabs_proxy-1.5.0}/tests/test_version_consistency.py +0 -0
  62. {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.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.3.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"}
@@ -1,6 +1,6 @@
1
1
  """CryptoLabs Proxy - Unified reverse proxy for CryptoLabs products."""
2
2
 
3
- __version__ = "1.3.1"
3
+ __version__ = "1.5.0"
4
4
 
5
5
  # Export programmatic setup API
6
6
  from .setup import (
@@ -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
- release_selection_dir = release_selection_dir or ServiceRegistry(config_dir).config.get('release_selection_dir')
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', '/etc/letsencrypt:/etc/letsencrypt:ro',
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
- '-v', f'{CONFIG_DIR / "certbot" / "www"}:/var/www/certbot:ro'])
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)