cryptolabs-proxy 1.4.0__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.4.0/src/cryptolabs_proxy.egg-info → cryptolabs_proxy-1.5.0}/PKG-INFO +1 -1
  2. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/pyproject.toml +1 -1
  3. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/__init__.py +1 -1
  4. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/config.py +93 -2
  5. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/setup.py +30 -7
  6. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/standard_adoption.py +115 -11
  7. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/docker-compose.yml.j2 +15 -3
  8. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/nginx.conf.j2 +49 -5
  9. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0/src/cryptolabs_proxy.egg-info}/PKG-INFO +1 -1
  10. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/SOURCES.txt +1 -0
  11. cryptolabs_proxy-1.5.0/tests/test_hostname_policy.py +497 -0
  12. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_standard_adoption.py +1 -1
  13. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/LICENSE +0 -0
  14. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/README.md +0 -0
  15. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/setup.cfg +0 -0
  16. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/auth.py +0 -0
  17. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/cli.py +0 -0
  18. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/controller_recovery.py +0 -0
  19. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/custom_config.py +0 -0
  20. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/exporter_manager.py +0 -0
  21. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/identity.py +0 -0
  22. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/migration.py +0 -0
  23. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/release_selection.py +0 -0
  24. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/services.py +0 -0
  25. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/ssl.py +0 -0
  26. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/acme-challenge.conf.j2 +0 -0
  27. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/exporter-auth-routes.conf.j2 +0 -0
  28. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/fleet-api-routes.conf.j2 +0 -0
  29. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/host-forwarding.conf.j2 +0 -0
  30. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/legacy-bootstrap-nginx.conf +0 -0
  31. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/legacy-bootstrap-provenance.json +0 -0
  32. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/standard-service-routes.conf.j2 +0 -0
  33. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/vast-price-manager-auth-blocks.conf.j2 +0 -0
  34. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/templates/vast-price-manager.conf.j2 +0 -0
  35. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/updates.py +0 -0
  36. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy/vpm_prerequisite.py +0 -0
  37. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/dependency_links.txt +0 -0
  38. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/entry_points.txt +0 -0
  39. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/requires.txt +0 -0
  40. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/src/cryptolabs_proxy.egg-info/top_level.txt +0 -0
  41. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_auth.py +0 -0
  42. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_controller_guard_transport.py +0 -0
  43. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_controller_recovery_guard.py +0 -0
  44. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_edge_ownership.py +0 -0
  45. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_exporter_auth_protections.py +0 -0
  46. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_host_forwarding.py +0 -0
  47. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_identity_host_package.py +0 -0
  48. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_image_release_promotion.py +0 -0
  49. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_netbox_link.py +0 -0
  50. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_offline_image.py +0 -0
  51. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_proxy_customer_route_repairs.py +0 -0
  52. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_proxy_lifecycle_review_repairs.py +0 -0
  53. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_proxy_recovery_tls_repairs.py +0 -0
  54. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_release_identifier_boundary.py +0 -0
  55. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_release_publication.py +0 -0
  56. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_release_selection.py +0 -0
  57. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_release_workflow_temp_paths.py +0 -0
  58. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_routes.py +0 -0
  59. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_updates.py +0 -0
  60. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_vast_price_manager.py +0 -0
  61. {cryptolabs_proxy-1.4.0 → cryptolabs_proxy-1.5.0}/tests/test_version_consistency.py +0 -0
  62. {cryptolabs_proxy-1.4.0 → 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.4.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cryptolabs-proxy"
7
- version = "1.4.0"
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.4.0"
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
@@ -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)
@@ -16,7 +16,7 @@ import tarfile
16
16
  import time
17
17
  from urllib.parse import quote, urlsplit
18
18
 
19
- from .config import get_jinja_env, validate_host_forward_groups, render_host_forward_groups, validate_acme_challenge, render_acme_challenge
19
+ from .config import get_jinja_env, validate_host_forward_groups, render_host_forward_groups, validate_acme_challenge, render_acme_challenge, validate_hostname_policy, validate_http_policy, render_policy_healthcheck, policy_healthcheck
20
20
  from .custom_config import render_managed_vpm_config
21
21
  from .migration import (DockerEngine, CustomConfigMigrator, MigrationError, SwitchController,
22
22
  build_recreate_request, endpoint_configurations, wait_for_container_healthy,
@@ -139,7 +139,7 @@ def _safe_upstream(words):
139
139
 
140
140
  def _declaration(value):
141
141
  required={'schema_version','domain','tls','release_selection_dir','services','obsolete_overlays','obsolete_owner_labels'}
142
- if not isinstance(value,dict) or set(value)-required-{'host_forward_groups','acme_challenge'} or not required<=set(value) or type(value['schema_version']) is not int or value['schema_version']!=1:
142
+ if not isinstance(value,dict) or set(value)-required-{'host_forward_groups','acme_challenge','hostname_policy','http_policy'} or not required<=set(value) or type(value['schema_version']) is not int or value['schema_version']!=1:
143
143
  raise AdoptionError('declaration_schema_invalid')
144
144
  domain=value['domain']
145
145
  if not isinstance(domain,str) or not DOMAIN.fullmatch(domain) or '..' in domain or domain.endswith('.') or value['tls'] not in ('self-signed','letsencrypt'):
@@ -170,12 +170,74 @@ def _declaration(value):
170
170
  try:
171
171
  groups=validate_host_forward_groups(value.get('host_forward_groups',[]),domain)
172
172
  acme=validate_acme_challenge(value.get('acme_challenge'))
173
+ policy=validate_hostname_policy(value.get('hostname_policy'),domain)
174
+ http_policy=validate_http_policy(value.get('http_policy'),domain,acme)
173
175
  except ValueError as error: raise AdoptionError(str(error)) from None
174
- return {**deepcopy(value),'host_forward_groups':groups,'acme_challenge':acme,'release_selection_dir':str(Path(directory).resolve()),'services':services}
176
+ canonical=deepcopy(value)
177
+ for name,setting in [('hostname_policy',policy),('http_policy',http_policy)]:
178
+ if setting is None: canonical.pop(name,None)
179
+ else: canonical[name]=setting
180
+ return {**canonical,'host_forward_groups':groups,'acme_challenge':acme,'release_selection_dir':str(Path(directory).resolve()),'services':services}
175
181
 
176
182
 
177
183
  def render_standard(declaration):
178
- return get_jinja_env().get_template('nginx.conf.j2').render(domain=declaration['domain'],letsencrypt=declaration['tls']=='letsencrypt',services=declaration['services'],host_forward_groups=declaration.get('host_forward_groups',[]),acme_challenge=declaration.get('acme_challenge')).encode()
184
+ return get_jinja_env().get_template('nginx.conf.j2').render(domain=declaration['domain'],letsencrypt=declaration['tls']=='letsencrypt',services=declaration['services'],host_forward_groups=declaration.get('host_forward_groups',[]),acme_challenge=declaration.get('acme_challenge'),hostname_policy=validate_hostname_policy(declaration.get('hostname_policy'),declaration['domain']),http_policy=validate_http_policy(declaration.get('http_policy'),declaration['domain'],declaration.get('acme_challenge'))).encode()
185
+
186
+
187
+ def _policy_healthcheck(source, declaration):
188
+ try:
189
+ return policy_healthcheck(source.get('Config',{}).get('Healthcheck'),declaration['domain'],declaration.get('hostname_policy'),declaration.get('http_policy'),letsencrypt=declaration['tls']=='letsencrypt')
190
+ except ValueError: raise AdoptionError('source_healthcheck_unmapped') from None
191
+
192
+
193
+ def _guarded_bootstrap_profile(legacy, declaration):
194
+ """Exact observed legacy delta, checked against an independent full fixture.
195
+
196
+ This constructs the supported tree; it never strips unknown source nodes.
197
+ Only the declared primary literal is substituted, in explicit positions.
198
+ """
199
+ domain=declaration['domain']
200
+ tree=parse_nginx(render_managed_vpm_config(legacy,True))
201
+ http=next(c for w,c in tree if w==['http'])
202
+ server=next(c for w,c in http if w==['server'])
203
+ # The captured HTTPS server is the released bootstrap minus HTTP listen.
204
+ server.remove([['listen','80','default_server'],None])
205
+ name=next(n for n in server if n[0]==['server_name','_'])
206
+ name[0][1]=domain
207
+ guard=parse_nginx(('if ($host != '+domain+') { return 444; }').encode())[0]
208
+ server.insert(server.index(name)+1,guard)
209
+ auth=next(n for n in server if n[0]==['location','=','/_auth_check'])
210
+ protected=parse_nginx(b"""
211
+ location = /auth/headers { return 404; }
212
+ location = /auth/headers/ { return 404; }
213
+ location = /auth/exporters/authorize { return 404; }
214
+ location = /auth/exporters/authorize/ { return 404; }
215
+ location = /_exporter_admin {
216
+ internal;
217
+ proxy_pass http://auth_server/auth/exporters/authorize;
218
+ proxy_pass_request_body off;
219
+ proxy_set_header Content-Length "";
220
+ proxy_set_header Cookie $http_cookie;
221
+ proxy_set_header X-Original-URI $request_uri;
222
+ proxy_set_header X-Real-IP $remote_addr;
223
+ proxy_pass_header X-Mgmt-Token;
224
+ }
225
+ """)
226
+ index=server.index(auth)+1;server[index:index]=protected
227
+ login=next(c for w,c in server if w==['location','/auth/'])
228
+ upstream=next(n for n in login if n[0]==['proxy_pass','http://auth_server/auth/'])
229
+ index=login.index(upstream)+1
230
+ login[index:index]=parse_nginx(b'proxy_hide_header X-Mgmt-Token; proxy_cookie_flags fleet_session secure httponly samesite=lax;')
231
+ for route in ('^/vastai-api/(.*)$','^/runpod-api/(.*)$'):
232
+ children=next(c for w,c in server if w==['location','~',route])
233
+ request=next(w for w,c in children if w==['auth_request','/_auth_check'])
234
+ request[1]='/_exporter_admin'
235
+ public=parse_nginx(('server { listen 80 default_server; server_name '+domain+'; if ($host != '+domain+') { return 444; } location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; default_type text/plain; try_files $uri =404; } location / { return 308 https://'+domain+'$request_uri; } }').encode())[0]
236
+ public[1].extend(parse_nginx(get_jinja_env().get_template('vast-price-manager-auth-blocks.conf.j2').render().encode()))
237
+ index=next(i for i,n in enumerate(http) if n[0]==['server'])
238
+ http.insert(index,public)
239
+ http.extend(parse_nginx(render_host_forward_groups(declaration.get('host_forward_groups',[]),legacy_profile=True)))
240
+ return tree
179
241
 
180
242
 
181
243
  class AdoptionEngine(DockerEngine):
@@ -409,10 +471,20 @@ while True: time.sleep(60)
409
471
  self._request('POST','/images/create?fromImage='+quote(reference,safe=''),allowed=(200,))
410
472
  def validate(self,image,candidate,inspect,identity):
411
473
  CustomConfigMigrator(candidate.parent,candidate.parent,engine=self)._validate_candidate(image,candidate,inspect,identity)
412
- def anonymous_auth(self,name,tls):
474
+ def anonymous_auth(self,name,tls,domain=None):
413
475
  # Internal fixed executable probes both auth server and public Nginx.
414
476
  import subprocess
415
477
  probe="import http.client; c=http.client.HTTPConnection('127.0.0.1',8081,timeout=5); c.request('GET','/auth/exporters/authorize'); assert c.getresponse().status==401; c.close(); c=http.client.HTTPSConnection('127.0.0.1',443,context=__import__('ssl')._create_unverified_context(),timeout=5); c.request('GET','/api/versions'); assert c.getresponse().status==401; c.close(); c=http.client.HTTPSConnection('127.0.0.1',443,context=__import__('ssl')._create_unverified_context(),timeout=5); c.request('GET','/auth/vast-price-manager/session'); assert c.getresponse().status==404"
478
+ if domain is not None:
479
+ validate_hostname_policy('primary-only',domain)
480
+ # Connect only to loopback, while TLS SNI and HTTP Host use the
481
+ # same declared literal. Preserve existing local unverified probe.
482
+ connect="class PrimaryHTTPS(http.client.HTTPSConnection):\n def connect(self):\n sock=__import__('socket').create_connection(('127.0.0.1',443),self.timeout); self.sock=self._context.wrap_socket(sock,server_hostname=self.host)\n"
483
+ probe=probe.replace("c=http.client.HTTPSConnection('127.0.0.1',443,", "c=PrimaryHTTPS("+repr(domain)+",443,")
484
+ certificate='/etc/letsencrypt/live/'+domain+'/fullchain.pem' if tls=='letsencrypt' else '/etc/nginx/ssl/server.crt'
485
+ probe=probe.replace("context=__import__('ssl')._create_unverified_context()", "context=ctx")
486
+ connect+="ctx=__import__('ssl').create_default_context(); ctx.load_verify_locations(cafile="+repr(certificate)+");\n"
487
+ probe=probe.replace("import http.client; ", "import http.client\n"+connect+"\n")
416
488
  try:
417
489
  result=subprocess.run(['docker','exec',name,'python3','-c',probe],capture_output=True,timeout=self.timeout)
418
490
  return result.returncode==0
@@ -500,7 +572,7 @@ class StandardAdoption:
500
572
  def visit(items):
501
573
  for words,children in items:
502
574
  if words[0]=='server_name':
503
- if words[1:] in ([declaration['domain']],['_']): words[1:]=['DECLARED_DOMAIN']
575
+ if words[1:]==[declaration['domain']] or words[1:]==['_'] and not declaration.get('hostname_policy'): words[1:]=['DECLARED_DOMAIN']
504
576
  elif words[1:] not in [g['hostnames'] for g in declaration.get('host_forward_groups',[])]: raise AdoptionError('source_domain_unmapped')
505
577
  if children is not None: visit(children)
506
578
  visit(nodes); return nodes
@@ -520,14 +592,22 @@ class StandardAdoption:
520
592
  server[index:index]=parse_nginx(render_acme_challenge(declaration['acme_challenge'],legacy_profile=True))
521
593
  next(children for words,children in profile_tree if words==['http']).extend(deepcopy(forwarding_tree))
522
594
  profile_trees.append(profile_tree)
523
- if any(actual==normalize(p) for p in profile_trees):
595
+ if not declaration.get('hostname_policy') and any(actual==normalize(p) for p in profile_trees):
524
596
  required={'ipmi-monitor','dc-overview','grafana','prometheus','vastai-exporter','runpod-exporter'}
525
597
  if b'location = /vast-pricing' in active: required.add('vast-price-manager')
526
598
  if not required<=set(declaration['services']) or declaration['tls']!='self-signed': raise AdoptionError('legacy_services_or_tls_unmapped')
527
599
  for name in required:
528
600
  if any(declaration['services'][name][k]!=DEFAULT_SERVICES[name][k] for k in ('container_name','port','path')): raise AdoptionError('legacy_target_unmapped')
529
601
  return 'released-bootstrap-1.2.0'
530
- supported=[expected]+[normalize(p) for p in profile_trees]
602
+ if declaration.get('hostname_policy')=='primary-only' and declaration.get('http_policy')=='redirect-primary-https' and declaration['tls']=='self-signed' and not declaration.get('acme_challenge'):
603
+ guarded=_guarded_bootstrap_profile(legacy,declaration)
604
+ if actual==normalize(guarded):
605
+ required={'ipmi-monitor','dc-overview','grafana','prometheus','vastai-exporter','runpod-exporter','vast-price-manager'}
606
+ if not required<=set(declaration['services']): raise AdoptionError('legacy_services_or_tls_unmapped')
607
+ for name in required:
608
+ if any(declaration['services'][name][k]!=DEFAULT_SERVICES[name][k] for k in ('container_name','port','path')): raise AdoptionError('legacy_target_unmapped')
609
+ return 'released-bootstrap-primary-only'
610
+ supported=[expected]+([normalize(p) for p in profile_trees] if not declaration.get('hostname_policy') else [])
531
611
  actual_words=list(_walk(actual)); actual_set={tuple(w) for w in actual_words}
532
612
  closest=min(supported,key=lambda profile:len(actual_set^{tuple(w) for w in _walk(profile)}))
533
613
  known={tuple(w) for w in _walk(closest)}
@@ -538,6 +618,7 @@ class StandardAdoption:
538
618
  def plan(self,settings,*,environment=None):
539
619
  declaration=_declaration(settings); source=self.engine.inspect('cryptolabs-proxy')
540
620
  if not source.get('State',{}).get('Running'): raise AdoptionError('source_not_running')
621
+ healthcheck=_policy_healthcheck(source,declaration)
541
622
  active=self.engine.read_config('cryptolabs-proxy')
542
623
  recorded=ServiceRegistry(self.config_dir).config.get('standard_adoption',{})
543
624
  source_declaration=recorded.get('declaration',declaration)
@@ -568,6 +649,11 @@ class StandardAdoption:
568
649
  'declaration_sha256':sha256(_canonical(declaration)),'candidate_sha256':sha256(candidate),'selection_id':snapshot['selection_id'],'selection_sha256':snapshot['selection_sha256'],
569
650
  'manifest_sha256':snapshot['manifest_sha256'],'image':artifact['reference'],'platform':platform,'origin':artifact['origin'],'source_profile':profile,
570
651
  'environment_sha256':sha256(_canonical(environment or {}))}
652
+ if healthcheck is not None:
653
+ original=source.get('Config',{}).get('Healthcheck')
654
+ plan['healthcheck_sha256']=sha256(_canonical(healthcheck))
655
+ plan['source_healthcheck_sha256']=sha256(_canonical(original))
656
+ plan['healthcheck_action']='preserved-verified-source' if original==healthcheck else 'generated-policy-probe'
571
657
  plan['plan_id']=sha256(_canonical(plan))[:24]
572
658
  return plan
573
659
 
@@ -612,6 +698,8 @@ class StandardAdoption:
612
698
  auth_mount=next(m for m in source['Mounts'] if m['Destination']=='/data/auth')
613
699
  clone['Mounts'].append({'Type':'bind','Source':declaration['release_selection_dir'],'Destination':'/etc/cryptolabs/releases','RW':False})
614
700
  nginx=self.config_dir/'nginx.conf'; request=build_recreate_request(clone,plan['image'],str(nginx))
701
+ if declaration.get('hostname_policy') or declaration.get('http_policy'):
702
+ request['Config']['Healthcheck']=_policy_healthcheck(source,declaration)
615
703
  config=request['Config']; config['Labels']=deepcopy(config.get('Labels') or {})
616
704
  config['Labels'].update({'com.centurylinklabs.watchtower.enable':'false','io.cryptolabs.update.origin':plan['origin'],'io.cryptolabs.update.reference':plan['image']})
617
705
  for label in declaration['obsolete_owner_labels']: config['Labels'].pop(label,None)
@@ -698,7 +786,7 @@ class StandardAdoption:
698
786
  guard()
699
787
  def candidate_health(name):
700
788
  guard()
701
- ready=wait_for_proxy_ready(engine,name,lambda:engine.anonymous_auth(name,declaration['tls']),PROXY_READY_TIMEOUT)
789
+ ready=wait_for_proxy_ready(engine,name,lambda:engine.anonymous_auth(name,declaration['tls'],declaration['domain']) if declaration.get('hostname_policy') or declaration.get('http_policy') else engine.anonymous_auth(name,declaration['tls']),PROXY_READY_TIMEOUT)
702
790
  if not ready: engine.stop(name,ignore_missing=True)
703
791
  guard()
704
792
  return ready
@@ -715,7 +803,12 @@ class StandardAdoption:
715
803
  # proven candidate/config and private recovery record for repair.
716
804
  registry.services=declaration['services']; registry.config.pop('custom_config',None)
717
805
  registry.config.update(domain=declaration['domain'],letsencrypt=declaration['tls']=='letsencrypt',release_selection_dir=declaration['release_selection_dir'],host_forward_groups=declaration.get('host_forward_groups',[]),acme_challenge=declaration.get('acme_challenge'))
806
+ for name in ('hostname_policy','http_policy'):
807
+ if name in declaration: registry.config[name]=declaration[name]
808
+ else: registry.config.pop(name,None)
718
809
  registry.config['standard_adoption']={'plan_id':identity,'serving_sha256':_runtime_identity(engine.inspect('cryptolabs-proxy')),'declaration_sha256':plan['declaration_sha256'],'selection_sha256':plan['selection_sha256'],'auth_mount':{k:auth_mount[k] for k in ('Type','Name','Source','Destination') if k in auth_mount},'declaration':declaration}
810
+ if declaration.get('hostname_policy') or declaration.get('http_policy'):
811
+ registry.config['standard_adoption']['healthcheck']=deepcopy(request['Config']['Healthcheck'])
719
812
  try: registry.save()
720
813
  except OSError:
721
814
  write(backup/'outcome.json',_canonical({'state':'candidate_retained','reason':'registry_forward_repair_required'}))
@@ -750,6 +843,8 @@ class StandardAdoption:
750
843
  declaration=deepcopy(registry.config['standard_adoption']['declaration'])
751
844
  declaration.update(domain=config.domain,tls='letsencrypt' if config.use_letsencrypt else 'self-signed',services={n:{k:v for k,v in s.items() if k in ('container_name','path','port','display_name')} for n,s in registry.services.items()},obsolete_overlays=[],obsolete_owner_labels=[])
752
845
  if config.release_selection_dir: declaration['release_selection_dir']=str(config.release_selection_dir)
846
+ if config.hostname_policy is not None: declaration['hostname_policy']=config.hostname_policy
847
+ if config.http_policy is not None: declaration['http_policy']=config.http_policy
753
848
  source=self.engine.inspect('cryptolabs-proxy'); current={e.split('=',1)[0]:e.split('=',1)[1] for e in source['Config'].get('Env',[]) if '=' in e}
754
849
  environment={}
755
850
  for name,value in [('FLEET_ADMIN_USER',config.fleet_admin_user),('FLEET_ADMIN_PASS',config.fleet_admin_pass),('SITE_NAME',config.site_name),('WATCHDOG_API_KEY',config.watchdog_api_key),('WATCHDOG_URL',config.watchdog_url)]:
@@ -758,8 +853,17 @@ class StandardAdoption:
758
853
  # default generated values in ProxyConfig are not rotation intent.
759
854
  normalized=_declaration(declaration)
760
855
  selected=targets(load(normalized['release_selection_dir']),self.engine.image(source['Image'])['Os']+'/'+self.engine.image(source['Image'])['Architecture'])['cryptolabs-proxy']
856
+ health_current=True
857
+ if normalized.get('hostname_policy') or normalized.get('http_policy'):
858
+ # No-op is a positive preservation result; validate both the
859
+ # runtime probe and its saved policy metadata before claiming it.
860
+ runtime_health=_policy_healthcheck(source,normalized)
861
+ recorded_health=registry.config['standard_adoption'].get('healthcheck')
862
+ if recorded_health is None: raise AdoptionError('recorded_healthcheck_unmapped')
863
+ saved_health=_policy_healthcheck({'Config':{'Healthcheck':recorded_health}},normalized)
864
+ health_current=(runtime_health==source['Config'].get('Healthcheck') and saved_health==recorded_health and runtime_health==saved_health)
761
865
  labels=source['Config'].get('Labels') or {}
762
- if not environment and sha256(self.engine.read_config('cryptolabs-proxy'))==sha256(render_standard(normalized)) and labels.get('io.cryptolabs.update.reference')==selected['reference'] and labels.get('com.centurylinklabs.watchtower.enable')=='false':
866
+ if health_current and not environment and sha256(self.engine.read_config('cryptolabs-proxy'))==sha256(render_standard(normalized)) and labels.get('io.cryptolabs.update.reference')==selected['reference'] and labels.get('com.centurylinklabs.watchtower.enable')=='false':
763
867
  return True,'Adopted proxy configuration already current; state preserved'
764
868
  plan=self.plan(declaration,environment=environment)
765
869
  result=self.adopt(declaration,plan['plan_id'],environment=environment)
@@ -774,5 +878,5 @@ class StandardAdoption:
774
878
  registry=ServiceRegistry(self.config_dir)
775
879
  source=self.engine.inspect('cryptolabs-proxy')
776
880
  environment={e.split('=',1)[0]:e.split('=',1)[1] for e in source['Config'].get('Env',[]) if '=' in e}
777
- config=ProxyConfig(registry.config['domain'],use_letsencrypt=registry.config.get('letsencrypt',False),fleet_admin_user=environment.get('FLEET_ADMIN_USER','admin'),fleet_admin_pass=environment.get('FLEET_ADMIN_PASS',''),site_name=environment.get('SITE_NAME',''),watchdog_api_key=environment.get('WATCHDOG_API_KEY',''),watchdog_url=environment.get('WATCHDOG_URL',''),release_selection_dir=Path(registry.config['release_selection_dir']))
881
+ config=ProxyConfig(registry.config['domain'],use_letsencrypt=registry.config.get('letsencrypt',False),fleet_admin_user=environment.get('FLEET_ADMIN_USER','admin'),fleet_admin_pass=environment.get('FLEET_ADMIN_PASS',''),site_name=environment.get('SITE_NAME',''),watchdog_api_key=environment.get('WATCHDOG_API_KEY',''),watchdog_url=environment.get('WATCHDOG_URL',''),release_selection_dir=Path(registry.config['release_selection_dir']),hostname_policy=registry.config.get('hostname_policy'),http_policy=registry.config.get('http_policy'))
778
882
  return self.reconfigure(config)
@@ -19,6 +19,8 @@ services:
19
19
  {% endif %}
20
20
  {% if use_letsencrypt %}
21
21
  - ./certbot/conf:/etc/letsencrypt:ro
22
+ {% endif %}
23
+ {% if use_letsencrypt or http_policy %}
22
24
  - ./certbot/www:/var/www/certbot:ro
23
25
  {% endif %}
24
26
  environment:
@@ -29,10 +31,20 @@ services:
29
31
  networks:
30
32
  - cryptolabs
31
33
  healthcheck:
34
+ {% if policy_healthcheck %}
35
+ test: ["CMD-SHELL", {{ policy_healthcheck | tojson }}]
36
+ {% else %}
32
37
  test: ["CMD", "curl", "-f", "http://localhost/api/health"]
33
- interval: 30s
34
- timeout: 10s
35
- retries: 3
38
+ {% endif %}
39
+ interval: {{ (policy_healthcheck_config.Interval | string + 'ns') if policy_healthcheck_config and 'Interval' in policy_healthcheck_config else '30s' }}
40
+ timeout: {{ (policy_healthcheck_config.Timeout | string + 'ns') if policy_healthcheck_config and 'Timeout' in policy_healthcheck_config else '10s' }}
41
+ retries: {{ policy_healthcheck_config.Retries if policy_healthcheck_config and 'Retries' in policy_healthcheck_config else 3 }}
42
+ {% if policy_healthcheck_config and 'StartPeriod' in policy_healthcheck_config %}
43
+ start_period: {{ policy_healthcheck_config.StartPeriod }}ns
44
+ {% endif %}
45
+ {% if policy_healthcheck_config and 'StartInterval' in policy_healthcheck_config %}
46
+ start_interval: {{ policy_healthcheck_config.StartInterval }}ns
47
+ {% endif %}
36
48
  labels:
37
49
  com.centurylinklabs.watchtower.enable: "{{ 'false' if release_selection_dir else 'true' }}"
38
50
  {% if release_selection_dir %}
@@ -28,12 +28,28 @@ http {
28
28
  # Rate limiting
29
29
  limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
30
30
 
31
- {% if letsencrypt %}
31
+ {% if letsencrypt or http_policy %}
32
32
  # Redirect HTTP to HTTPS
33
33
  server {
34
- listen 80;
34
+ listen 80{% if hostname_policy %} default_server{% endif %};
35
35
  server_name {{ domain }};
36
+ {% if hostname_policy %}
37
+ if ($host != {{ domain }}) {
38
+ return 444;
39
+ }
40
+ {% endif %}
36
41
 
42
+ {% if http_policy %}
43
+ location ^~ /.well-known/acme-challenge/ {
44
+ root /var/www/certbot;
45
+ default_type text/plain;
46
+ try_files $uri =404;
47
+ }
48
+ location / {
49
+ return 308 https://{{ domain }}$request_uri;
50
+ }
51
+ {% include "vast-price-manager-auth-blocks.conf.j2" %}
52
+ {% else %}
37
53
  location /.well-known/acme-challenge/ {
38
54
  root /var/www/certbot;
39
55
  }
@@ -41,23 +57,41 @@ http {
41
57
  location / {
42
58
  return 301 https://$host$request_uri;
43
59
  }
60
+ {% endif %}
44
61
  }
45
62
 
46
63
  server {
47
- listen 443 ssl http2;
64
+ listen 443 ssl http2{% if hostname_policy %} default_server{% endif %};
48
65
  server_name {{ domain }};
66
+ {% if hostname_policy %}
67
+ if ($host != {{ domain }}) {
68
+ return 444;
69
+ }
70
+ {% endif %}
49
71
 
72
+ {% if letsencrypt %}
50
73
  ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
51
74
  ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
52
75
  ssl_protocols TLSv1.2 TLSv1.3;
53
76
  ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
54
77
  ssl_prefer_server_ciphers off;
55
78
  ssl_session_cache shared:SSL:10m;
79
+ {% else %}
80
+ ssl_certificate /etc/nginx/ssl/server.crt;
81
+ ssl_certificate_key /etc/nginx/ssl/server.key;
82
+ ssl_protocols TLSv1.2 TLSv1.3;
83
+ ssl_ciphers HIGH:!aNULL:!MD5;
84
+ {% endif %}
56
85
  {% else %}
57
86
  server {
58
- listen 80;
59
- listen 443 ssl http2;
87
+ listen 80{% if hostname_policy %} default_server{% endif %};
88
+ listen 443 ssl http2{% if hostname_policy %} default_server{% endif %};
60
89
  server_name {{ domain }};
90
+ {% if hostname_policy %}
91
+ if ($host != {{ domain }}) {
92
+ return 444;
93
+ }
94
+ {% endif %}
61
95
 
62
96
  ssl_certificate /etc/nginx/ssl/server.crt;
63
97
  ssl_certificate_key /etc/nginx/ssl/server.key;
@@ -91,6 +125,10 @@ http {
91
125
  # Auth server endpoints (public)
92
126
  location /auth/ {
93
127
  proxy_pass http://127.0.0.1:8081/auth/;
128
+ {% if http_policy %}
129
+ proxy_hide_header X-Mgmt-Token;
130
+ proxy_cookie_flags fleet_session secure httponly samesite=lax;
131
+ {% endif %}
94
132
  proxy_http_version 1.1;
95
133
  proxy_set_header Host $host;
96
134
  proxy_set_header X-Real-IP $remote_addr;
@@ -98,6 +136,12 @@ http {
98
136
  proxy_set_header X-Forwarded-Proto $scheme;
99
137
  }
100
138
 
139
+ {% if http_policy %}
140
+ location = /auth/headers { return 404; }
141
+ location = /auth/headers/ { return 404; }
142
+ location = /auth/exporters/authorize { return 404; }
143
+ location = /auth/exporters/authorize/ { return 404; }
144
+ {% endif %}
101
145
  # Internal auth verification endpoint (for auth_request)
102
146
  location = /_auth_check {
103
147
  internal;