1from typing import TYPE_CHECKING, List, Optional, Union
2
3from ..config import Config
4from ..utils import SavedSecret, dump_json
5from .irresource import IRResource
6from .irtlscontext import IRTLSContext
7from .irutils import disable_strict_selectors, hostglob_matches, selector_matches
8
9if TYPE_CHECKING:
10 from .ir import IR # pragma: no cover
11 from .irhttpmappinggroup import IRHTTPMappingGroup
12
13
14class IRHost(IRResource):
15 AllowedKeys = {
16 "acmeProvider",
17 "hostname",
18 "mappingSelector",
19 "metadata_labels",
20 "requestPolicy",
21 "selector",
22 "tlsSecret",
23 "tlsContext",
24 "tls",
25 }
26
27 hostname: str
28 sni: str
29 secure_action: str
30 insecure_action: str
31 insecure_addl_port: Optional[int]
32
33 def __init__(
34 self,
35 ir: "IR",
36 aconf: Config,
37 rkey: str, # REQUIRED
38 name: str, # REQUIRED
39 location: str, # REQUIRED
40 namespace: Optional[str] = None,
41 kind: str = "IRHost",
42 apiVersion: str = "getambassador.io/v3alpha1", # Not a typo! See below.
43 **kwargs,
44 ) -> None:
45
46 new_args = {x: kwargs[x] for x in kwargs.keys() if x in IRHost.AllowedKeys}
47
48 self.context: Optional[IRTLSContext] = None
49
50 super().__init__(
51 ir=ir,
52 aconf=aconf,
53 rkey=rkey,
54 location=location,
55 kind=kind,
56 name=name,
57 namespace=namespace,
58 apiVersion=apiVersion,
59 **new_args,
60 )
61
62 def setup(self, ir: "IR", aconf: Config) -> bool:
63 ir.logger.debug(f"Host {self.name} setting up")
64
65 if not self.get("hostname", None):
66 self.hostname = "*"
67
68 self.sni = self.hostname.rsplit(":", 1)[0]
69
70 tls_ss: Optional[SavedSecret] = None
71 pkey_ss: Optional[SavedSecret] = None
72
73 # Go ahead and cache some things to make life easier later.
74 request_policy = self.get("requestPolicy", {})
75
76 # XXX This will change later!!
77 self.secure_action = "Route"
78
79 insecure_policy = request_policy.get("insecure", {})
80 self.insecure_action = insecure_policy.get("action", "Redirect")
81 self.insecure_addl_port: Optional[int] = insecure_policy.get("additionalPort", None)
82
83 # If we have no mappingSelector, check for selector.
84 mapsel = self.get("mappingSelector", None)
85
86 if not mapsel:
87 mapsel = self.get("selector", None)
88
89 if mapsel:
90 self.mappingSelector = mapsel
91 del self["selector"]
92
93 if self.get("tlsSecret", None):
94 tls_secret = self.tlsSecret
95 tls_name = tls_secret.get("name", None)
96 tls_namespace = tls_secret.get("namespace", None)
97
98 if tls_name:
99 # Either take the name of the secret, or add on the namespace if it exists.
100 # This is important for how the implicit TLSContext handles secret names
101 tls_full_name = tls_name
102 if tls_namespace:
103 ir.logger.debug(
104 f"Host {self.name}: resolving spec.tlsSecret.name.namespace: {tls_name}.{tls_namespace}"
105 )
106 tls_full_name = tls_full_name + "." + tls_namespace
107 else:
108 ir.logger.debug(f"Host {self.name}: resolving spec.tlsSecret.name: {tls_name}")
109
110 tls_ss = self.resolve(ir=ir, secret_name=tls_name, secret_namespace=tls_namespace)
111
112 if tls_ss:
113 # OK, we have a TLS secret! Fire up a TLS context for it, if one doesn't
114 # already exist.
115
116 ctx_name = f"{self.name}-context"
117
118 implicit_tls_exists = ir.has_tls_context(ctx_name)
119 self.logger.debug(
120 f"Host {self.name}: implicit TLSContext {ctx_name} {'exists' if implicit_tls_exists else 'missing'}"
121 )
122
123 host_tls_context_obj = self.get("tlsContext", {})
124 host_tls_context_name = host_tls_context_obj.get("name", None)
125 self.logger.debug(f"Host {self.name}: spec.tlsContext: {host_tls_context_name}")
126
127 host_tls_config = self.get("tls", None)
128 self.logger.debug(f"Host {self.name}: spec.tls: {host_tls_config}")
129
130 # Choose explicit TLS configuration over implicit TLSContext name
131 if implicit_tls_exists and (host_tls_context_name or host_tls_config):
132 self.logger.info(
133 f"Host {self.name}: even though TLSContext {ctx_name} exists in the cluster,"
134 f"it will be ignored in favor of 'tls'/'tlsConfig' specified in the Host."
135 )
136
137 # Even though this is unlikely because we have a oneOf is proto definitions, but just in case the
138 # objects have a different source :shrug:
139 if host_tls_context_name and host_tls_config:
140 self.post_error(
141 f"Host {self.name}: both TLSContext name and TLS config specified, ignoring "
142 f"Host..."
143 )
144 return False
145
146 if host_tls_context_name:
147 # They named a TLSContext, so try to use that. self.save_context will check the
148 # context to make sure it works for us, and save it if so.
149 ir.logger.debug(
150 f"Host {self.name}: resolving spec.tlsContext: {host_tls_context_name}"
151 )
152
153 if not self.save_context(ir, host_tls_context_name, tls_ss, tls_full_name):
154 return False
155
156 elif host_tls_config:
157 # They defined a TLSContext inline, so go set that up if we can.
158 ir.logger.debug(f"Host {self.name}: examining spec.tls {host_tls_config}")
159
160 camel_snake_map = {
161 "alpnProtocols": "alpn_protocols",
162 "cipherSuites": "cipher_suites",
163 "ecdhCurves": "ecdh_curves",
164 "redirectCleartextFrom": "redirect_cleartext_from",
165 "certRequired": "cert_required",
166 "minTlsVersion": "min_tls_version",
167 "maxTlsVersion": "max_tls_version",
168 "certChainFile": "cert_chain_file",
169 "privateKeyFile": "private_key_file",
170 "cacertChainFile": "cacert_chain_file",
171 "crlSecret": "crl_secret",
172 "crlFile": "crl_file",
173 "caSecret": "ca_secret",
174 # 'sni': 'sni' (this field is not required in snake-camel but adding for completeness)
175 }
176
177 # We don't need any camel case in our generated TLSContext
178 for camel, snake in camel_snake_map.items():
179 if camel in host_tls_config:
180 # We use .pop() to actually replace the camelCase name with snake case
181 host_tls_config[snake] = host_tls_config.pop(camel)
182
183 if "min_tls_version" in host_tls_config:
184 if (
185 host_tls_config["min_tls_version"]
186 not in IRTLSContext.AllowedTLSVersions
187 ):
188 self.post_error(
189 f"Host {self.name}: Invalid min_tls_version set in Host.tls: "
190 f"{host_tls_config['min_tls_version']}"
191 )
192 return False
193
194 if "max_tls_version" in host_tls_config:
195 if (
196 host_tls_config["max_tls_version"]
197 not in IRTLSContext.AllowedTLSVersions
198 ):
199 self.post_error(
200 f"Host {self.name}: Invalid max_tls_version set in Host.tls: "
201 f"{host_tls_config['max_tls_version']}"
202 )
203 return False
204
205 tls_context_init = dict(
206 rkey=self.rkey,
207 name=ctx_name,
208 namespace=self.namespace,
209 location=self.location,
210 hosts=[self.hostname],
211 secret=tls_full_name,
212 )
213
214 # Ensure that we handle the secret properly even if tls_secret_namespacing is False in the Module
215 if tls_namespace:
216 tls_context_init["secret_namespacing"] = True
217
218 tls_config_context = IRTLSContext(
219 ir, aconf, **tls_context_init, **host_tls_config
220 )
221
222 # This code was here because, while 'selector' was controlling things to be watched
223 # for, we figured we should update the labels on this generated TLSContext so that
224 # it would actually match the 'selector'. Nothing was actually using that, though, so
225 # we're not doing that any more.
226 #
227 # -----------------------------------------------------------------------------------
228 # # XXX This seems kind of pointless -- nothing looks at the context's labels?
229 # match_labels = self.get('selector', {}).get('matchLabels')
230
231 # if match_labels:
232 # tls_config_context['metadata_labels'] = match_labels
233
234 if tls_config_context.is_active():
235 self.context = tls_config_context
236 tls_config_context.referenced_by(self)
237 tls_config_context.sourced_by(self)
238
239 ir.save_tls_context(tls_config_context)
240 else:
241 self.post_error(
242 f"Host {self.name}: generated TLSContext {tls_config_context.name} from "
243 f"Host.tls is not valid"
244 )
245 return False
246
247 elif implicit_tls_exists:
248 # They didn't say anything explicitly, but it happens that a context with the
249 # correct name for this Host already exists. Save that, if it works out for us.
250 ir.logger.debug(f"Host {self.name}: TLSContext {ctx_name} already exists")
251
252 if not self.save_context(ir, ctx_name, tls_ss, tls_full_name):
253 return False
254 else:
255 ir.logger.debug(f"Host {self.name}: creating TLSContext {ctx_name}")
256
257 new_ctx = dict(
258 rkey=self.rkey,
259 name=ctx_name,
260 namespace=self.namespace,
261 location=self.location,
262 hosts=[self.hostname],
263 secret=tls_full_name,
264 )
265
266 # Ensure that we handle the secret properly even if tls_secret_namespacing is False in the Module
267 if tls_namespace:
268 new_ctx["secret_namespacing"] = True
269
270 ctx = IRTLSContext(ir, aconf, **new_ctx)
271
272 match_labels = self.get("matchLabels")
273
274 if not match_labels:
275 match_labels = self.get("match_labels")
276
277 if match_labels:
278 ctx["metadata_labels"] = match_labels
279
280 if ctx.is_active():
281 self.context = ctx
282 ctx.referenced_by(self)
283 ctx.sourced_by(self)
284
285 ir.save_tls_context(ctx)
286 else:
287 ir.logger.error(
288 f"Host {self.name}: new TLSContext {ctx_name} is not valid"
289 )
290 else:
291 ir.logger.error(
292 f"Host {self.name}: invalid TLS secret {tls_full_name}, marking inactive"
293 )
294 return False
295
296 if self.get("acmeProvider", None):
297 acme = self.acmeProvider
298
299 # The ACME client is disabled if we're running as an intercept agent.
300 if ir.edge_stack_allowed and not ir.agent_active:
301 authority = acme.get("authority", None)
302
303 if authority and (authority.lower() != "none"):
304 # ACME is active, which means that we must have an insecure_addl_port.
305 # Make sure we do -- if no port is set at all, just silently default it,
306 # but if for some reason they tried to force it disabled, be noisy.
307
308 override_insecure = False
309
310 if self.insecure_addl_port is None:
311 # Override silently, since it's not set at all.
312 override_insecure = True
313 elif self.insecure_addl_port < 0:
314 # Override noisily, since they tried to explicitly disable it.
315 self.post_error(
316 "ACME requires insecure.additionalPort to function; forcing to 8080"
317 )
318 override_insecure = True
319
320 if override_insecure:
321 # Force self.insecure_addl_port...
322 self.insecure_addl_port = 8080
323
324 # ...but also update the actual policy dict, too.
325 insecure_policy["additionalPort"] = 8080
326
327 if "action" not in insecure_policy:
328 # No action when we're overriding the additionalPort already means that we
329 # default the action to Reject (the hole-puncher will do the right thing).
330 insecure_policy["action"] = "Reject"
331
332 request_policy["insecure"] = insecure_policy
333 self["requestPolicy"] = request_policy
334
335 pkey_secret = acme.get("privateKeySecret", None)
336
337 if pkey_secret:
338 pkey_name = pkey_secret.get("name", None)
339
340 if pkey_name:
341 ir.logger.debug(f"Host {self.name}: ACME private key name is {pkey_name}")
342
343 pkey_ss = self.resolve(ir=ir, secret_name=pkey_name, secret_namespace=None)
344
345 if not pkey_ss:
346 ir.logger.error(
347 f"Host {self.name}: continuing with invalid private key secret {pkey_name}; ACME will not be able to renew this certificate"
348 )
349 self.post_error(
350 f"continuing with invalid ACME private key secret {pkey_name}; ACME will not be able to renew this certificate"
351 )
352
353 ir.logger.debug(f"Host setup OK: {self}")
354 return True
355
356 # Check a TLSContext name, and save the linked TLSContext if it'll work for us.
357 def save_context(self, ir: "IR", ctx_name: str, tls_ss: SavedSecret, tls_name: str):
358 # First obvious thing: does a TLSContext with the right name even exist?
359 if not ir.has_tls_context(ctx_name):
360 self.post_error(
361 "Host %s: Specified TLSContext does not exist: %s" % (self.name, ctx_name)
362 )
363 return False
364
365 ctx = ir.get_tls_context(ctx_name)
366 assert ctx # For mypy -- we checked above to be sure it exists.
367
368 # Make sure that the TLSContext is "compatible" i.e. it at least has the same cert related
369 # configuration as the one in this Host AND hosts are same as well.
370
371 if ctx.has_secret():
372 secret_name = ctx.secret_name()
373 assert secret_name # For mypy -- if has_secret() is true, secret_name() will be there.
374
375 # This is a little weird. Basically we're going to resolve the secret (which should just
376 # be a cache lookup here) so that we can use SavedSecret.__str__() as a serializer to
377 # compare the configurations.
378 context_ss = self.resolve(ir, secret_name, None)
379
380 self.logger.debug(
381 f"Host {self.name}, ctx {ctx.name}, secret {secret_name}, resolved {context_ss}"
382 )
383
384 if str(context_ss) != str(tls_ss):
385 self.post_error(
386 "Secret info mismatch between Host %s (secret: %s) and TLSContext %s: (secret: %s)"
387 % (self.name, tls_name, ctx_name, secret_name)
388 )
389 return False
390 else:
391 # This will often be a no-op.
392 ctx.set_secret_name(tls_name)
393
394 # TLS config is good, let's make sure the hosts line up too.
395 context_hosts = ctx.get("hosts")
396
397 # Technically a user can set the TLSContext.hosts to include a Host.name thus allowing it to pass
398 # the validation and saved as the context for this Host. Although, this is not intended
399 # or documented behavior it, removing it could break users so we need to mark it as deprecated.
400 # @deprecated - validating TLSContext.hosts against Host.name will be removed, use hostname for matching instead.
401 host_hosts = [self.hostname, self.name]
402
403 if context_hosts:
404 is_valid_hosts = False
405
406 # we exact match here, which requires users being explicit about whether a TLSContext can attach
407 # to a Host. Note: this does not do hostname glob matching.
408 for host_tc in context_hosts:
409 if host_tc in host_hosts:
410 is_valid_hosts = True
411
412 if not is_valid_hosts:
413 self.post_error(
414 "Hosts mismatch between Host %s (accepted hosts: %s) and TLSContext %s (hosts: %s)"
415 % (self.name, host_hosts, ctx_name, context_hosts)
416 )
417 # XXX Shouldn't we return false here?
418 else:
419 ctx["hosts"] = [self.hostname]
420
421 self.logger.debug(f"Host {self.name}, final ctx {ctx.name}: {ctx.as_json()}")
422
423 # All seems good, this context belongs to self now!
424 self.context = ctx
425
426 return True
427
428 def matches_httpgroup(self, group: "IRHTTPMappingGroup") -> bool:
429 """
430 Make sure a given IRHTTPMappingGroup is a match for this Host, meaning
431 that at least one of the following is true:
432
433 - The Host specifies mappingSelector.matchLabels, and the group has matching labels
434 - The group specifies a host glob, and the Host has a matching domain.
435
436 A Mapping that specifies no host can never match a Host that specifies no
437 mappingSelector.
438 """
439
440 groupName = group.get("name") or "None"
441
442 # The synthetic Mappings for diagnostics, readiness, and liveness probes always match all Hosts.
443 # They can all still be disabled if desired via the Ambassador Module resource
444 if groupName in [
445 "GROUP: internal_readiness_probe_mapping",
446 "GROUP: internal_liveness_probe_mapping",
447 "GROUP: internal_diagnostics_probe_mapping",
448 ]:
449 return True
450
451 has_hostname = False
452 host_match = False
453 sel_match = False
454
455 group_regex = group.get("host_regex") or False
456
457 if group_regex:
458 # It matches.
459 has_hostname = True
460 host_match = True
461 self.logger.debug("-- hostname %s group regex => %s", self.hostname, host_match)
462 else:
463 # It is NOT A TYPO that we use group.get("host") here -- whether the Mapping supplies
464 # "hostname" or "host", the Mapping code normalizes to "host" internally.
465
466 # It's possible for group.host_redirect to be None instead of missing, and it's also
467 # conceivably possible for group.host_redirect.host to be "", which we'd rather be
468 # None. Hence we do this two-line dance to massage the various cases.
469 host_redirect = (group.get("host_redirect") or {}).get("host")
470 group_glob = group.get("host") or host_redirect # NOT A TYPO: see above.
471
472 if group_glob:
473 has_hostname = True
474 host_match = hostglob_matches(self.hostname, group_glob)
475 self.logger.debug(
476 "-- hostname %s group glob %s => %s", self.hostname, group_glob, host_match
477 )
478
479 mapsel = self.get("mappingSelector")
480
481 if mapsel:
482 sel_match = selector_matches(self.logger, mapsel, group.get("metadata_labels", {}))
483 self.logger.debug(
484 "-- host sel %s group labels %s => %s",
485 dump_json(mapsel),
486 dump_json(group.get("metadata_labels")),
487 sel_match,
488 )
489
490 if disable_strict_selectors():
491 # User opted-in to existing deprecated behavior (not-recommmended)
492 return host_match or sel_match
493 elif mapsel and has_hostname:
494 # If both mappingSelector and hostname are present, then they must both be a match
495 return host_match and sel_match
496 elif mapsel:
497 # If the Mapping does not provide a hostname, then only the mappingSelector must match
498 return sel_match
499
500 elif has_hostname:
501 # If there is no mappingSelector then it must only match the provided hostname
502 return host_match
503 else:
504 # If there is no mappingSelector or hostname then it cannot match anything
505 return False
506
507 def __str__(self) -> str:
508 request_policy = self.get("requestPolicy", {})
509 insecure_policy = request_policy.get("insecure", {})
510 insecure_action = insecure_policy.get("action", "Redirect")
511 insecure_addl_port = insecure_policy.get("additionalPort", None)
512
513 ctx_name = self.context.name if self.context else "-none-"
514 return "<Host %s for %s ns %s ctx %s ia %s iap %s>" % (
515 self.name,
516 self.hostname or "*",
517 self.namespace,
518 ctx_name,
519 insecure_action,
520 insecure_addl_port,
521 )
522
523 def resolve(
524 self, ir: "IR", secret_name: str, secret_namespace: Union[str, None]
525 ) -> SavedSecret:
526 if secret_namespace:
527 return ir.resolve_secret(self, secret_name, secret_namespace)
528
529 # Try to use our namespace for secret resolution. If we somehow have no
530 # namespace, fall back to the Ambassador's namespace.
531 namespace = self.namespace or ir.ambassador_namespace
532
533 return ir.resolve_secret(self, secret_name, namespace)
534
535
536class HostFactory:
537 @classmethod
538 def load_all(cls, ir: "IR", aconf: Config) -> None:
539 assert ir
540
541 hosts = aconf.get_config("hosts")
542
543 if hosts:
544 for config in hosts.values():
545 ir.logger.debug("HostFactory: creating host for %s" % repr(config.as_dict()))
546
547 host = IRHost(ir, aconf, **config)
548
549 if host.is_active():
550 host.referenced_by(config)
551 host.sourced_by(config)
552
553 ir.logger.debug(f"HostFactory: saving host {host}")
554 ir.save_host(host)
555 else:
556 ir.logger.debug(f"HostFactory: not saving inactive host {host}")
557
558 @classmethod
559 def finalize(cls, ir: "IR", aconf: Config) -> None:
560 # First up: how many Hosts do we have?
561 host_count = len(ir.get_hosts() or [])
562
563 # Do we have any termination contexts for TLS? (If not, we'll need to bring the
564 # fallback cert into play.)
565 #
566 # (This empty_contexts stuff mypy silliness to deal with the fact that ir.get_tls_contexts()
567 # returns a ValuesView[IRTLSContext] rather than a List[IRTLSContext].
568 empty_contexts: List[IRTLSContext] = []
569 contexts: List[IRTLSContext] = list(ir.get_tls_contexts()) or empty_contexts
570
571 found_termination_context = False
572 for ctx in contexts:
573 if ctx.get("hosts"): # not None and not the empty list
574 found_termination_context = True
575 break
576
577 ir.logger.debug(
578 f"HostFactory: Host count %d, %s TLS termination contexts"
579 % (host_count, "with" if found_termination_context else "no")
580 )
581
582 # OK, do we have any Hosts?
583 if host_count == 0:
584 # Nope. First up, scream if we _do_ have termination contexts...
585 if found_termination_context:
586 ir.post_error(
587 "No Hosts defined, but TLSContexts exist that terminate TLS. The TLSContexts are being ignored."
588 )
589
590 # If we don't have a fallback secret, don't try to use it.
591 #
592 # We use the Ambassador's namespace here because we'll be creating the
593 # fallback Host in the Ambassador's namespace.
594 fallback_ss = ir.resolve_secret(
595 ir.ambassador_module, "fallback-self-signed-cert", ir.ambassador_namespace
596 )
597
598 host: IRHost
599
600 if not fallback_ss:
601 ir.aconf.post_notice(
602 "No TLS termination and no fallback cert -- defaulting to cleartext-only."
603 )
604 ir.logger.debug("HostFactory: creating cleartext-only default host")
605
606 host = IRHost(
607 ir,
608 aconf,
609 rkey="-internal",
610 name="default-host",
611 location="-internal-",
612 hostname="*",
613 requestPolicy={"insecure": {"action": "Route"}},
614 )
615 else:
616 ir.logger.debug(f"HostFactory: creating TLS-enabled default Host")
617
618 host = IRHost(
619 ir,
620 aconf,
621 rkey="-internal",
622 name="default-host",
623 location="-internal-",
624 hostname="*",
625 tlsSecret={"name": "fallback-self-signed-cert"},
626 )
627
628 if not host.is_active():
629 ir.post_error(
630 "Synthesized default host is inactive? %s" % dump_json(host.as_dict())
631 )
632 else:
633 host.referenced_by(ir.ambassador_module)
634 host.sourced_by(ir.ambassador_module)
635
636 ir.logger.debug(f"HostFactory: saving host {host}")
637 ir.save_host(host)
View as plain text