diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49a018a..7ad6c1e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - exclude: ^applications/.*/internal/ + exclude: ^(applications/.*/internal/|base-infrastructure/charts/) - id: check-case-conflict - id: detect-private-key diff --git a/applications/argocd/production/applications/alert-hub-backend.yaml b/applications/argocd/production/applications/alert-hub-backend.yaml index 820d736..4a2da72 100644 --- a/applications/argocd/production/applications/alert-hub-backend.yaml +++ b/applications/argocd/production/applications/alert-hub-backend.yaml @@ -10,17 +10,19 @@ spec: source: repoURL: ghcr.io/ifrcgo chart: ifrcgo-alert-hub-helm - targetRevision: 0.1.1-develop.c6f10f53 + targetRevision: 0.1.1-develop.c8139886 helm: valueFiles: - - values-go-deploy.yaml - - values-production.yaml + - values/operators.yaml + - values/go-deploy.yaml + - values/production.yaml valuesObject: app: - ingress: - host: "alerthub-api.ifrc.org" - tls: - secretName: "alerthub-helm-secret-cert" + api: + ingress: + host: "alerthub-api.ifrc.org" + tls: + secretName: "alerthub-helm-secret-cert" env: APP_FRONTEND_HOST: "https://alerthub.ifrc.org" APP_DOMAIN: "alerthub-api.ifrc.org" diff --git a/applications/argocd/production/applications/alert-hub-frontend.yaml b/applications/argocd/production/applications/alert-hub-frontend.yaml index 439c2d1..f34738f 100644 --- a/applications/argocd/production/applications/alert-hub-frontend.yaml +++ b/applications/argocd/production/applications/alert-hub-frontend.yaml @@ -8,22 +8,21 @@ metadata: spec: project: default source: - repoURL: ghcr.io/ifrcgo/alert-hub-web-app - chart: ifrcgo-web-app-nginx-serve - targetRevision: 0.0.1-develop.6a791e5 + repoURL: ghcr.io/ifrcgo + chart: alert-hub-web-app-helm + targetRevision: 0.0.1-develop.c2098b00 helm: parameters: - - name: ingress.host + - name: app.ingress.hostname value: "alerthub.ifrc.org" - - name: ingress.className + - name: app.ingress.ingressClassName value: nginx - - name: ingress.tls.secretName + - name: app.ingress.tls.secretName value: "alerthub-helm-secret-cert" - - name: env.APP_GRAPHQL_API_ENDPOINT + - name: app.env.APP_GRAPHQL_API_ENDPOINT value: https://alerthub-api.ifrc.org/graphql/ valueFiles: - - values.yaml - - values-production.yaml + - values/production.yaml destination: server: https://kubernetes.default.svc namespace: alert-hub diff --git a/applications/argocd/production/applications/montandon-eoapi/application.yaml b/applications/argocd/production/applications/montandon-eoapi/application.yaml index 806f199..36b2475 100644 --- a/applications/argocd/production/applications/montandon-eoapi/application.yaml +++ b/applications/argocd/production/applications/montandon-eoapi/application.yaml @@ -54,17 +54,32 @@ spec: image: tag: 6.1.2 overrideRootPath: "" + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 5 + type: "cpu" + targets: + cpu: 70 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + scaleUp: + stabilizationWindowSeconds: 60 settings: labels: azure.workload.identity/use: "true" resources: requests: memory: "2048Mi" - cpu: "10m" + cpu: "500m" limits: memory: "4096Mi" cpu: "1000m" envVars: + WEB_CONCURRENCY: "4" + DB_MIN_CONN_SIZE: "1" + DB_MAX_CONN_SIZE: "2" ENABLE_TRANSACTIONS_EXTENSIONS: "TRUE" FORWARDED_ALLOW_IPS: "*" PROXY_HEADERS: True @@ -144,7 +159,11 @@ spec: tls: enabled: true secretName: "montandon-eoapi-helm-secret-cert" - replicaCount: 1 + autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 75 extraVolumes: - name: filters configMap: diff --git a/applications/argocd/production/applications/montandon-etl.yaml b/applications/argocd/production/applications/montandon-etl.yaml index ae0ff2b..d0bbe0c 100644 --- a/applications/argocd/production/applications/montandon-etl.yaml +++ b/applications/argocd/production/applications/montandon-etl.yaml @@ -10,7 +10,7 @@ spec: source: repoURL: ghcr.io/ifrcgo/montandon-etl chart: montandon-etl-helm - targetRevision: 0.1.1-develop.c9f69327 + targetRevision: 0.1.1-develop.c3ab8d96 helm: valueFiles: - values/operators.yaml diff --git a/applications/argocd/production/applications/risk-module.yaml b/applications/argocd/production/applications/risk-module.yaml index 3f8e2cc..9c1ee9a 100644 --- a/applications/argocd/production/applications/risk-module.yaml +++ b/applications/argocd/production/applications/risk-module.yaml @@ -10,7 +10,7 @@ spec: source: repoURL: ghcr.io/ifrcgo chart: ifrcgo-risk-module-helm - targetRevision: 0.0.1-develop.c0c63878 + targetRevision: 0.0.1-develop.c35bbf8e helm: valueFiles: - values/operators.yaml @@ -18,10 +18,11 @@ spec: - values/production.yaml valuesObject: app: - ingress: - host: "go-risk-api.ifrc.org" - tls: - secretName: "risk-helm-secret-cert" + api: + ingress: + host: "go-risk-api.ifrc.org" + tls: + secretName: "risk-helm-secret-cert" env: RISK_API_FQDN: "https://go-risk-api.ifrc.org" DJANGO_ALLOWED_HOSTS: "go-risk-api.ifrc.org" diff --git a/applications/argocd/staging/applications/alert-hub-backend.yaml b/applications/argocd/staging/applications/alert-hub-backend.yaml index fd5de0c..04318fd 100644 --- a/applications/argocd/staging/applications/alert-hub-backend.yaml +++ b/applications/argocd/staging/applications/alert-hub-backend.yaml @@ -10,17 +10,19 @@ spec: source: repoURL: ghcr.io/ifrcgo chart: ifrcgo-alert-hub-helm - targetRevision: 0.1.1-develop.c6f10f53 + targetRevision: 0.1.1-develop.c8139886 helm: valueFiles: - - values-go-deploy.yaml - - values-staging.yaml + - values/operators.yaml + - values/go-deploy.yaml + - values/staging.yaml valuesObject: app: - ingress: - host: "alerthub-stage-api.ifrc.org" - tls: - secretName: "alerthub-helm-secret-cert" + api: + ingress: + host: "alerthub-stage-api.ifrc.org" + tls: + secretName: "alerthub-helm-secret-cert" env: APP_FRONTEND_HOST: "https://alerthub-stage.ifrc.org" APP_DOMAIN: "alerthub-stage-api.ifrc.org" diff --git a/applications/argocd/staging/applications/alert-hub-frontend.yaml b/applications/argocd/staging/applications/alert-hub-frontend.yaml index a831356..a16b793 100644 --- a/applications/argocd/staging/applications/alert-hub-frontend.yaml +++ b/applications/argocd/staging/applications/alert-hub-frontend.yaml @@ -8,22 +8,21 @@ metadata: spec: project: default source: - repoURL: ghcr.io/ifrcgo/alert-hub-web-app - chart: ifrcgo-web-app-nginx-serve - targetRevision: 0.0.1-develop.6a791e5 + repoURL: ghcr.io/ifrcgo + chart: alert-hub-web-app-helm + targetRevision: 0.0.1-develop.c2098b00 helm: parameters: - - name: ingress.host + - name: app.ingress.hostname value: "alerthub-stage.ifrc.org" - - name: ingress.className + - name: app.ingress.ingressClassName value: nginx - - name: ingress.tls.secretName + - name: app.ingress.tls.secretName value: "alerthub-helm-secret-cert" - - name: env.APP_GRAPHQL_API_ENDPOINT + - name: app.env.APP_GRAPHQL_API_ENDPOINT value: https://alerthub-stage-api.ifrc.org/graphql/ valueFiles: - - values.yaml - - values-staging.yaml + - values/staging.yaml destination: server: https://kubernetes.default.svc namespace: alert-hub diff --git a/applications/argocd/staging/applications/montandon-eoapi/application.yaml b/applications/argocd/staging/applications/montandon-eoapi/application.yaml index 6a0d7a0..e89b07d 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/application.yaml +++ b/applications/argocd/staging/applications/montandon-eoapi/application.yaml @@ -54,17 +54,32 @@ spec: image: tag: 6.1.2 overrideRootPath: "" + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 3 + type: "cpu" + targets: + cpu: 70 + behavior: + scaleDown: + stabilizationWindowSeconds: 300 + scaleUp: + stabilizationWindowSeconds: 60 settings: labels: azure.workload.identity/use: "true" resources: requests: memory: "2048Mi" - cpu: "10m" + cpu: "500m" limits: memory: "4096Mi" cpu: "1000m" envVars: + WEB_CONCURRENCY: "4" + DB_MIN_CONN_SIZE: "1" + DB_MAX_CONN_SIZE: "2" ENABLE_TRANSACTIONS_EXTENSIONS: "TRUE" FORWARDED_ALLOW_IPS: "*" PROXY_HEADERS: True @@ -137,6 +152,19 @@ spec: ROOT_PATH: "/stac" COLLECTIONS_FILTER_CLS: stac_auth_proxy.montandon_filters:CollectionsFilter ITEMS_FILTER_CLS: stac_auth_proxy.montandon_filters:ItemsFilter + # External-token revocation (go-api issue #2794), injected via montandon_revocation.py. + GOAPI_TOKEN_VERIFY_TTL: "300" + # Short TTL for a fail-open allow served during a go-api outage, so revocation + # resumes within seconds of recovery instead of being masked for a full TTL. + GOAPI_TOKEN_VERIFY_OUTAGE_TTL: "30" + # TTL for a definitive deny (go-api reported the jti inactive/unknown): 24h, so a + # known-revoked token is not re-checked against go-api on every request. + GOAPI_TOKEN_VERIFY_DENY_TTL: "86400" + # Upper bound on cached jti decisions (memory guard on a long-lived pod). + GOAPI_TOKEN_VERIFY_CACHE_MAX: "50000" + GOAPI_TOKEN_VERIFY_TIMEOUT: "3" + GOAPI_TOKEN_VERIFY_FAIL_OPEN: "true" + GOAPI_TOKEN_STATIC_BLACKLIST: "fec23689-bf00-401e-9f9c-a65684f44b5f" # NOTE: Token ID used in hackathon ingress: enabled: true host: "montandon-eoapi-stage.ifrc.org" @@ -144,16 +172,31 @@ spec: tls: enabled: true secretName: "montandon-eoapi-helm-secret-cert" - replicaCount: 1 + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 3 + targetCPUUtilizationPercentage: 75 + resources: + limits: + cpu: 500m + memory: "2048Mi" + requests: + cpu: 200m + memory: "1024Mi" extraVolumes: - - name: filters + - name: custom-modules configMap: - name: stac-auth-proxy-filters + name: stac-auth-proxy-modules extraVolumeMounts: - - name: filters + - name: custom-modules mountPath: /app/src/stac_auth_proxy/montandon_filters.py subPath: montandon_filters.py readOnly: true + - name: custom-modules + mountPath: /app/src/stac_auth_proxy/montandon_revocation.py + subPath: montandon_revocation.py + readOnly: true destination: server: https://kubernetes.default.svc namespace: montandon-eoapi diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py index 46000e2..bb06d16 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py @@ -17,6 +17,13 @@ import httpx +# Apply the external-token revocation monkeypatch to EnforceAuthMiddleware. stac-auth-proxy +# imports this filter module during app construction (before serving requests), which is the +# only Helm-reachable hook to run the patch. Keep this call. +from .montandon_revocation import apply_patch + +apply_patch() + logger = logging.getLogger(__name__) if not (UPSTREAM_URL := os.environ.get("UPSTREAM_URL")): diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py new file mode 100644 index 0000000..f693216 --- /dev/null +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py @@ -0,0 +1,296 @@ +""" +External-token revocation for stac-auth-proxy. + +stac-auth-proxy validates go-api JWTs locally (signature + exp via the go-api JWKS) but +cannot detect a token that go-api has *revoked* before its natural expiry. go-api tracks +revocation centrally and exposes a lightweight introspection endpoint keyed by the token's +`jti`. This module adds that check without forking stac-auth-proxy. + +There is no config hook for token validation in stac-auth-proxy v1.1.1, so we monkeypatch +`EnforceAuthMiddleware.validate_token` at import time. This module is imported by +`montandon_filters.py`, which stac-auth-proxy imports at app-construction time (before it +serves any request) when loading the COLLECTIONS_FILTER_CLS / ITEMS_FILTER_CLS classes. + +Design notes / decisions (see go-api issue #2794): +- Only go-api *external tokens* reach this proxy; every external token carries a `jti`. + A token with no `jti`, or a `jti` go-api does not recognise, is rejected (401). +- A static blacklist (env) is checked first and always wins — a permanent kill-switch that + works even before/without the go-api verify endpoint. +- Decisions are cached per outcome with their own TTL: verified allows briefly, definitive + denies for a long window, fail-open outage allows very briefly; a fail-closed outage is + never cached so recovery is not masked. The cache is size-bounded. +- Fail policy on go-api outage is configurable; default is fail-closed. It is deployed + fail-open initially (until the verify endpoint is live in prod goadmin) then flipped. +- If the patch cannot be applied (upstream symbol changed), we raise at import so the pod + crashes on boot rather than silently serving traffic with revocation disabled. +""" + +import inspect +import logging +import os +import threading +import time +from typing import cast +from urllib.parse import urlparse, urlunparse + +import httpx +from fastapi import HTTPException, status +from stac_auth_proxy.middleware import EnforceAuthMiddleware + +logger = logging.getLogger(__name__) + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _num_env(name: str, default: float, *, minimum: float = 0.0) -> float: + """Parse a numeric env var, failing on boot with a clear message on bad input. + + Empty/unset falls back to ``default``. A non-numeric or out-of-range value raises + RuntimeError (consistent with _verify_url_from_oidc) instead of an opaque + ``float()`` ValueError, and prevents footguns like a negative TTL that would make + every cache entry expire immediately. + """ + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + value = float(raw) + except ValueError: + raise RuntimeError( + "montandon_revocation: %s must be a number, got %r." % (name, raw) + ) from None + if value < minimum: + raise RuntimeError( + "montandon_revocation: %s must be >= %s, got %s." % (name, minimum, value) + ) + return value + + +def _verify_url_from_oidc() -> str: + """Derive the go-api verify URL from the OIDC issuer origin. + + The verify endpoint always lives on the same host as the OIDC provider, so we reuse + OIDC_DISCOVERY_URL rather than taking a separate URL. Raise if it is unset or + unparseable so the pod crashes on boot instead of running without a verify endpoint. + """ + oidc = os.environ.get("OIDC_DISCOVERY_URL") + parts = urlparse(oidc or "") + if not (parts.scheme and parts.netloc): + raise RuntimeError( + "montandon_revocation: cannot derive verify URL; OIDC_DISCOVERY_URL is unset " + "or invalid (%r)." % (oidc,) + ) + return urlunparse( + (parts.scheme, parts.netloc, "/api/v2/external-token/verify/", "", "", "") + ) + + +# --- Configuration (read once at import) ------------------------------------------------ + +VERIFY_URL: str = _verify_url_from_oidc() +CACHE_TTL: float = _num_env("GOAPI_TOKEN_VERIFY_TTL", 300) +# Short TTL for a fail-open "allow" served during a go-api outage, so revocation resumes +# within seconds of go-api recovering instead of being masked for a full CACHE_TTL window. +OUTAGE_CACHE_TTL: float = _num_env("GOAPI_TOKEN_VERIFY_OUTAGE_TTL", 30) +# TTL for a definitive deny (go-api reported the jti inactive/unknown). Long, since a +# revoked/unknown jti effectively never becomes valid again; avoids re-hitting go-api on +# every request from an abandoned or leaked token. Only definitive denials are cached here +# (never a transient fail-closed outage), so recovery is not masked. +DENY_CACHE_TTL: float = _num_env("GOAPI_TOKEN_VERIFY_DENY_TTL", 86400) +VERIFY_TIMEOUT: float = _num_env("GOAPI_TOKEN_VERIFY_TIMEOUT", 3, minimum=0.001) +# Upper bound on cached jti decisions; keeps memory bounded on a long-lived pod that sees +# many distinct (leaked/rotated/one-off) jtis. On overflow we drop expired entries first, +# then the soonest-to-expire. +CACHE_MAX_ENTRIES: int = int(_num_env("GOAPI_TOKEN_VERIFY_CACHE_MAX", 50000, minimum=1)) +FAIL_OPEN: bool = _bool_env("GOAPI_TOKEN_VERIFY_FAIL_OPEN", False) +STATIC_BLACKLIST: frozenset[str] = frozenset( + jti.strip() + for jti in os.environ.get("GOAPI_TOKEN_STATIC_BLACKLIST", "").split(",") + if jti.strip() +) + +# --- In-memory decision cache (per pod) ------------------------------------------------- +# jti -> (allowed, monotonic expiry). Caches both allows (short TTL) and definitive denies +# (long TTL). Single-process/single-event-loop deployment, but guard with a lock anyway +# since we are called from a sync context. +_cache: dict[str, tuple[bool, float]] = {} +_cache_lock = threading.Lock() + +_client = httpx.Client(timeout=VERIFY_TIMEOUT) + + +def _cache_get(jti: str) -> bool | None: + """Return the cached decision (True=allow, False=deny), or None on miss/expiry.""" + with _cache_lock: + entry = _cache.get(jti) + if entry is None: + return None + allowed, expiry = entry + if expiry <= time.monotonic(): + _cache.pop(jti, None) + return None + return allowed + + +def _evict_locked(now: float) -> None: + """Bound cache size. Caller must hold _cache_lock. Drop expired entries first, then, + if still over the cap, the soonest-to-expire ones.""" + for k in [k for k, (_, exp) in _cache.items() if exp <= now]: + _cache.pop(k, None) + overflow = len(_cache) - CACHE_MAX_ENTRIES + if overflow > 0: + for k in sorted(_cache, key=lambda k: _cache[k][1])[:overflow]: + _cache.pop(k, None) + + +def _cache_put(jti: str, allowed: bool, ttl: float) -> None: + if ttl <= 0: + return # decisions with a 0 ttl (anomalies, uncached outages) are never cached + now = time.monotonic() + with _cache_lock: + _cache[jti] = (allowed, now + ttl) + if len(_cache) > CACHE_MAX_ENTRIES: + _evict_locked(now) + + +def _reject(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _fail_policy() -> tuple[bool, float]: + """Decision for an outage: fail-open allows briefly (cached OUTAGE_CACHE_TTL); a + fail-closed deny is NOT cached (ttl 0) so recovery is not masked.""" + return FAIL_OPEN, (OUTAGE_CACHE_TTL if FAIL_OPEN else 0) + + +def _verify(jti: str) -> tuple[bool, float]: + """Ask go-api whether the token is active. + + Returns ``(allowed, cache_ttl)`` where cache_ttl is how long THIS decision may be + cached (0 = do not cache). Outcomes: + - 200 active is True -> (True, CACHE_TTL) verified allow, short TTL + - 200 active is False -> (False, DENY_CACHE_TTL) definitive deny, long TTL + - 200 without a boolean `active` -> (False, 0) anomalous body: reject, don't cache + - 400 -> (False, 0) anomalous, reject but don't cache + - outage / other -> fail policy (see _fail_policy) + + Any unexpected error is treated as an outage rather than propagating, so a token check + can never surface as an HTTP 500 (which would bypass the intended fail policy). + """ + try: + resp = _client.post(VERIFY_URL, json={"jti": jti}) + + if resp.status_code == 200: + active = resp.json().get("active") + if active is True: + return True, CACHE_TTL + if active is False: + return False, DENY_CACHE_TTL + # 200 but no boolean `active` (field drift, interstitial served as 200, ...). + # Do NOT treat as a definitive deny: reject this request but don't cache it, + # so a valid token is not locked out for DENY_CACHE_TTL. + logger.error( + "go-api verify 200 lacked a boolean 'active' (%r); rejecting, not caching", + active, + ) + return False, 0 + if resp.status_code == 400: + # Malformed jti. Should not happen (jti comes from a validated token). Reject. + logger.error("go-api verify returned 400 for jti; rejecting") + return False, 0 + # Any other status (incl. 404 before the endpoint is live) -> outage. + logger.warning( + "go-api verify returned %s; fail_open=%s", resp.status_code, FAIL_OPEN + ) + return _fail_policy() + except Exception as exc: + # Network error, non-JSON body, or anything unexpected -> honour the fail policy + # rather than letting it become a 500. + logger.warning( + "go-api verify call failed (%s); fail_open=%s", type(exc).__name__, FAIL_OPEN + ) + return _fail_policy() + + +def check_revocation(payload: dict | None) -> None: + """Raise HTTPException(401) if the token behind `payload` is revoked/unknown.""" + if payload is None: + # Anonymous / public request, no token to check. + return + + jti = payload.get("jti") + # Require a non-empty string jti. A non-string would be unhashable as a cache key + # (500) or bypass the blacklist; a validated external token always carries a str jti. + if not jti or not isinstance(jti, str): + raise _reject("Token is missing a valid jti") + + # Static blacklist wins over everything (permanent kill-switch). + if jti in STATIC_BLACKLIST: + logger.info("Rejecting statically-blacklisted jti %s", jti) + raise _reject("Token has been revoked") + + cached = _cache_get(jti) + if cached is True: + return + if cached is False: + logger.info("Rejecting cached-inactive jti %s", jti) + raise _reject("Token has been revoked") + + allowed, ttl = _verify(jti) + _cache_put(jti, allowed, ttl) # no-op when ttl <= 0 + if not allowed: + logger.info("Rejecting revoked/unknown jti %s", jti) + raise _reject("Token has been revoked") + + +# --- Monkeypatch -------------------------------------------------------------------------- + + +def apply_patch() -> None: + """Wrap EnforceAuthMiddleware.validate_token with the revocation check. + + Called explicitly from montandon_filters.py at import time (app construction, + before any request is served). Idempotent and raises if the target symbol is + missing, so revocation can never be silently disabled. + """ + original = getattr(EnforceAuthMiddleware, "validate_token", None) + if not callable(original): + raise RuntimeError( + "montandon_revocation: EnforceAuthMiddleware.validate_token not found; " + "stac-auth-proxy internals changed. Refusing to start with revocation disabled." + ) + # Idempotent: return early if already patched, before inspecting the (wrapper) signature. + if getattr(original, "_montandon_revocation_patched", False): + return + # Guard against upstream signature drift: we rely on it returning the JWT payload. + params = list(inspect.signature(original).parameters) + if "auth_header" not in params: + raise RuntimeError( + "montandon_revocation: unexpected validate_token signature %r; " + "refusing to start with revocation disabled." % (params,) + ) + + def validate_token(self, *args, **kwargs): + payload = cast(dict | None, original(self, *args, **kwargs)) + check_revocation(payload) + return payload + + validate_token._montandon_revocation_patched = True # type: ignore[attr-defined] + EnforceAuthMiddleware.validate_token = validate_token # type: ignore[assignment] + logger.info( + "montandon_revocation: patched EnforceAuthMiddleware.validate_token " + "(verify_url=%s, ttl=%ss, fail_open=%s, blacklist=%d)", + VERIFY_URL, + CACHE_TTL, + FAIL_OPEN, + len(STATIC_BLACKLIST), + ) diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/filters-cm.yaml b/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/modules-cm.yaml similarity index 64% rename from applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/filters-cm.yaml rename to applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/modules-cm.yaml index 5fbb4eb..bf92f3b 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/filters-cm.yaml +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/modules-cm.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: ConfigMap metadata: - name: stac-auth-proxy-filters + name: stac-auth-proxy-modules {{- with .Values.commonAnnotations }} annotations: {{- toYaml . | nindent 4 }} @@ -9,3 +9,5 @@ metadata: data: montandon_filters.py: | {{ .Files.Get "files/stac-auth-proxy/montandon_filters.py" | indent 4 }} + montandon_revocation.py: | +{{ .Files.Get "files/stac-auth-proxy/montandon_revocation.py" | indent 4 }} diff --git a/applications/argocd/staging/applications/montandon-etl.yaml b/applications/argocd/staging/applications/montandon-etl.yaml index 2f40e55..1bbcad2 100644 --- a/applications/argocd/staging/applications/montandon-etl.yaml +++ b/applications/argocd/staging/applications/montandon-etl.yaml @@ -10,7 +10,7 @@ spec: source: repoURL: ghcr.io/ifrcgo/montandon-etl chart: montandon-etl-helm - targetRevision: 0.1.1-develop.cc86b979 + targetRevision: 0.1.1-develop.c3ab8d96 helm: valueFiles: - values/operators.yaml @@ -32,7 +32,8 @@ spec: AZURE_STORAGE_ACCOUNT_NAME: montystaging6757 AZURE_STORAGE_MANAGED_IDENTITY: "true" # ETL Load config - EOAPI_STAC_API: "http://montandon-eoapi-stac.montandon-eoapi.svc.cluster.local:8080/" + EOAPI_STAC_API_INTERNAL: "http://montandon-eoapi-stac.montandon-eoapi.svc.cluster.local:8080/" + EOAPI_STAC_API_PUBLIC: "https://montandon-eoapi-stage.ifrc.org/stac" serviceAccount: annotations: azure.workload.identity/client-id : "bcc2593b-4885-46b4-8430-b85b34a22737" diff --git a/applications/argocd/staging/applications/risk-module.yaml b/applications/argocd/staging/applications/risk-module.yaml index 044aa8e..2c2434a 100644 --- a/applications/argocd/staging/applications/risk-module.yaml +++ b/applications/argocd/staging/applications/risk-module.yaml @@ -10,7 +10,7 @@ spec: source: repoURL: ghcr.io/ifrcgo chart: ifrcgo-risk-module-helm - targetRevision: 0.0.1-develop.c0c63878 + targetRevision: 0.0.1-develop.c35bbf8e helm: valueFiles: - values/operators.yaml @@ -18,10 +18,11 @@ spec: - values/staging.yaml valuesObject: app: - ingress: - host: "go-risk-api-stage.ifrc.org" - tls: - secretName: "risk-helm-secret-cert" + api: + ingress: + host: "go-risk-api-stage.ifrc.org" + tls: + secretName: "risk-helm-secret-cert" env: RISK_API_FQDN: "https://go-risk-api-stage.ifrc.org" DJANGO_ALLOWED_HOSTS: "go-risk-api-stage.ifrc.org" diff --git a/applications/go-api/azure-pipelines.yaml b/applications/go-api/azure-pipelines.yaml index 97928fc..463a4f4 100644 --- a/applications/go-api/azure-pipelines.yaml +++ b/applications/go-api/azure-pipelines.yaml @@ -32,7 +32,7 @@ jobs: displayName: "Deploy staging instance of go-api" env: ENVIRONMENT: staging - VERSION: "0.0.2-develop.c8ad9157" + VERSION: "0.0.2-develop.c74dff52" # For Azure CLI AZURE_TENANT_ID: $(TERRAFORM_TENANT_ID) AZURE_CLIENT_ID: $(TERRAFORM_SERVICE_PRINCIPAL_ID) diff --git a/applications/go-api/helm/ifrcgo-helm/values-staging.yaml b/applications/go-api/helm/ifrcgo-helm/values-staging.yaml index fb30545..3184b2e 100644 --- a/applications/go-api/helm/ifrcgo-helm/values-staging.yaml +++ b/applications/go-api/helm/ifrcgo-helm/values-staging.yaml @@ -21,6 +21,8 @@ env: IFRC_TRANSLATION_DOMAIN: https://ifrc-translationapi-staging.azurewebsites.net SENTRY_DSN: https://342632f67ea942c9bde214c0bb7e91be@sentry.northeurope.cloudapp.azure.com/2 SENTRY_SAMPLE_RATE: "0.3" + SESSION_COOKIE_DOMAIN: ".ifrc.org" + CSRF_COOKIE_DOMAIN: ".ifrc.org" api: domain: goadmin-stage.ifrc.org diff --git a/base-infrastructure/charts/ssh-bastion/.helmignore b/base-infrastructure/charts/ssh-bastion/.helmignore new file mode 100644 index 0000000..9cd064d --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/.helmignore @@ -0,0 +1,7 @@ +# Patterns to ignore when building packages. +.DS_Store +.git/ +.gitignore +*.tmproj +# helm-unittest suites — not part of the deployed chart +tests/ diff --git a/base-infrastructure/charts/ssh-bastion/Chart.yaml b/base-infrastructure/charts/ssh-bastion/Chart.yaml new file mode 100644 index 0000000..36ce45e --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: ssh-bastion +description: Cluster-wide SSH bastion / jump host (port-forwarding + ProxyJump), applied by Terraform. +type: application +version: 0.1.0 diff --git a/base-infrastructure/charts/ssh-bastion/files/assert-sshd.sh b/base-infrastructure/charts/ssh-bastion/files/assert-sshd.sh new file mode 100644 index 0000000..ad76c98 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/files/assert-sshd.sh @@ -0,0 +1,36 @@ +#!/bin/sh +# Fail closed. The linuxserver image boots sshd even when our drop-in is ignored (wrong +# mount path, an image change to the include behaviour, etc.), silently falling back to +# insecure defaults. This asserts sshd's *effective* config (`sshd -T`) contains every +# directive from the mounted drop-in; on mismatch it exits non-zero so Kubernetes pulls +# the pod from the LoadBalancer (readiness) and restarts / crash-loops it (startup + +# liveness) instead of accepting connections with unintended settings. +# +# Expected directives are read from the drop-in itself, so this can never drift from the +# config we ship. +DROPIN=/config/sshd/sshd_config.d/100-ifrc-forwarding.conf +PORT=2222 + +# sshd must be accepting connections ... +nc -z 127.0.0.1 "$PORT" || exit 1 + +# ... pass the host keys explicitly (as the image's service does) so `sshd -T` works even +# when this probe runs as a non-root user (otherwise it exits "no hostkeys available"). +h="" +for k in /config/ssh_host_keys/ssh_host_*_key; do + [ -f "$k" ] && h="$h -h $k" +done + +eff=$(sshd.pam -T -f /config/sshd/sshd_config $h 2>/dev/null | tr 'A-Z' 'a-z') + +missing=$(grep -vE '^[[:space:]]*(#|$)' "$DROPIN" | tr 'A-Z' 'a-z' | while IFS= read -r line; do + d=$(printf '%s' "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -n "$d" ] || continue + printf '%s\n' "$eff" | grep -qF "$d" || printf '%s\n' "$d" +done) + +if [ -n "$missing" ]; then + echo "sshd config assertion FAILED (missing directives):" >&2 + printf '%s\n' "$missing" >&2 + exit 1 +fi diff --git a/base-infrastructure/charts/ssh-bastion/files/sshd_config.d/100-ifrc-forwarding.conf b/base-infrastructure/charts/ssh-bastion/files/sshd_config.d/100-ifrc-forwarding.conf new file mode 100644 index 0000000..479b5a6 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/files/sshd_config.d/100-ifrc-forwarding.conf @@ -0,0 +1,38 @@ +# Jump host for port-forwarding / ProxyJump. Key-only auth (PasswordAuthentication +# no, GatewayPorts no, X11Forwarding no are already set by the image defaults). +AllowTcpForwarding yes + +# Agent forwarding is a security downgrade (a compromised host can hijack the connecting +# user's agent) and is unnecessary for a port-forward/ProxyJump host — disable it +# explicitly rather than relying on the default. +AllowAgentForwarding no +{{- if .Values.forwardingOnly }} + +# Forwarding-only: no interactive shell, no command exec, no SFTP/SCP. ForceCommand also +# overrides subsystem requests, so sftp is blocked too. Only -N / -L / -D / -J / -W work. +# This is what prevents a key holder from reading the box (e.g. the host private key). +PermitTTY no +ForceCommand /bin/false +{{- end }} +{{- with .Values.sshd.permitOpen }} + +# Allowlist of permitted forward destinations (narrows how far a key can pivot). +PermitOpen {{ join " " . }} +{{- end }} + +# Authoritative, declarative authorized_keys (mounted read-only). A single fixed file +# instead of the image's PUBLIC_KEY_DIR (which only ever *appends* to a persistent file) +# so that removing a key actually revokes access. +AuthorizedKeysFile /etc/ssh/authorized_keys + +# Auth hardening (internet-exposed LoadBalancer) +PermitRootLogin no +KbdInteractiveAuthentication no +MaxAuthTries 3 +LoginGraceTime 30 +AllowUsers {{ .Values.user }} + +# Audit trail (log key fingerprint per login) + reap dead sessions/tunnels +LogLevel VERBOSE +ClientAliveInterval 300 +ClientAliveCountMax 2 diff --git a/base-infrastructure/charts/ssh-bastion/templates/_helpers.tpl b/base-infrastructure/charts/ssh-bastion/templates/_helpers.tpl new file mode 100644 index 0000000..69d9a21 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/_helpers.tpl @@ -0,0 +1,12 @@ +{{- define "ssh-bastion.name" -}} +ssh-bastion +{{- end -}} + +{{/* Common labels. `app` is also the (immutable) StatefulSet selector, so keep it stable. */}} +{{- define "ssh-bastion.labels" -}} +app: ssh-bastion +app.kubernetes.io/managed-by: {{ .Release.Service | quote }} +{{- with .Values.environment }} +environment: {{ . | quote }} +{{- end }} +{{- end -}} diff --git a/base-infrastructure/charts/ssh-bastion/templates/configmap-authorized-keys.yaml b/base-infrastructure/charts/ssh-bastion/templates/configmap-authorized-keys.yaml new file mode 100644 index 0000000..37ce515 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/configmap-authorized-keys.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ssh-bastion.name" . }}-authorized-keys + labels: + {{- include "ssh-bastion.labels" . | nindent 4 }} +data: + # All public keys concatenated into one authoritative authorized_keys file. + authorized_keys: | + {{- range .Values.keys }} + {{ . }} + {{- end }} diff --git a/base-infrastructure/charts/ssh-bastion/templates/configmap-config.yaml b/base-infrastructure/charts/ssh-bastion/templates/configmap-config.yaml new file mode 100644 index 0000000..ec9e84e --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/configmap-config.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "ssh-bastion.name" . }}-config + labels: + {{- include "ssh-bastion.labels" . | nindent 4 }} +data: + # sshd drop-in. Must be mounted under /config/sshd/sshd_config.d/ — the image runs + # `sshd -f /config/sshd/sshd_config` and only includes drop-ins from that directory; the + # stock /etc/ssh/sshd_config.d/ is not read. + 100-ifrc-forwarding.conf: | + {{- tpl (.Files.Get "files/sshd_config.d/100-ifrc-forwarding.conf") . | nindent 4 }} + # Fail-closed startup/readiness/liveness assertion (see the script for details). + assert-sshd.sh: | + {{- .Files.Get "files/assert-sshd.sh" | nindent 4 }} diff --git a/base-infrastructure/charts/ssh-bastion/templates/networkpolicy.yaml b/base-infrastructure/charts/ssh-bastion/templates/networkpolicy.yaml new file mode 100644 index 0000000..7a77789 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/networkpolicy.yaml @@ -0,0 +1,38 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "ssh-bastion.name" . }} + labels: + {{- include "ssh-bastion.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + app: ssh-bastion + policyTypes: + - Ingress + - Egress + ingress: + # SSH in from anywhere (access is gated by public-key auth). + - ports: + - protocol: TCP + port: {{ .Values.service.port }} + egress: + # Cluster DNS. + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Allowed forward/egress destinations. This is an allowlist, so anything not listed — + # the public internet and the cloud metadata endpoint (169.254.169.254) included — is + # denied by construction. If you broaden a CIDR to cover link-local, add an + # `except: [169.254.0.0/16]` to that ipBlock (it must be a subset of the cidr). + {{- with .Values.networkPolicy.allowedEgressCIDRs }} + - to: + {{- range . }} + - ipBlock: + cidr: {{ . | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/base-infrastructure/charts/ssh-bastion/templates/service.yaml b/base-infrastructure/charts/ssh-bastion/templates/service.yaml new file mode 100644 index 0000000..4a627a5 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/service.yaml @@ -0,0 +1,30 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ssh-bastion.name" . }} + labels: + {{- include "ssh-bastion.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + # Open to the internet; access is gated by SSH public-key auth only (team members do not + # have static source IPs, so no loadBalancerSourceRanges). + {{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP | quote }} + {{- end }} + {{- if ne .Values.service.type "ClusterIP" }} + # Preserve the real client source IP (otherwise SNAT'd to a node IP) so the VERBOSE sshd + # audit log records who connected. + externalTrafficPolicy: Local + {{- end }} + selector: + app: ssh-bastion + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.port }} + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePort }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} diff --git a/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml b/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml new file mode 100644 index 0000000..9d4236d --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/templates/statefulset.yaml @@ -0,0 +1,103 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "ssh-bastion.name" . }} + labels: + {{- include "ssh-bastion.labels" . | nindent 4 }} +spec: + replicas: 1 + serviceName: {{ include "ssh-bastion.name" . }} + selector: + matchLabels: + app: ssh-bastion + template: + metadata: + labels: + app: ssh-bastion + annotations: + # Roll the pod when keys or sshd config change (the container ingests both only at + # start), otherwise a ConfigMap edit applies but the running pod keeps the old data. + checksum/authorized-keys: {{ include (print $.Template.BasePath "/configmap-authorized-keys.yaml") . | sha256sum }} + checksum/config: {{ include (print $.Template.BasePath "/configmap-config.yaml") . | sha256sum }} + spec: + # The bastion never talks to the Kubernetes API — don't mount a cluster credential + # onto an internet-exposed jump host. + automountServiceAccountToken: false + containers: + - name: ssh-bastion + image: {{ .Values.image | quote }} + ports: + - containerPort: {{ .Values.service.port }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + env: + - name: PUID + value: "1000" + - name: PGID + value: "1000" + - name: USER_NAME + value: {{ .Values.user | quote }} + - name: PASSWORD_ACCESS + value: "false" + - name: SUDO_ACCESS + value: "false" + # Gate readiness/liveness until sshd is up and the config assertion passes. If the + # config never applies the pod never starts (CrashLoopBackOff) rather than serving. + startupProbe: + exec: + command: ["sh", "/etc/ifrc/assert-sshd.sh"] + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + # Pull the pod out of the Service/LoadBalancer endpoints if the effective config + # ever regresses — no connections are routed to a misconfigured bastion. + readinessProbe: + exec: + command: ["sh", "/etc/ifrc/assert-sshd.sh"] + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 2 + # Restart (crash-loop) the pod on a runtime config regression. + livenessProbe: + exec: + command: ["sh", "/etc/ifrc/assert-sshd.sh"] + periodSeconds: 60 + timeoutSeconds: 5 + failureThreshold: 3 + volumeMounts: + # Authoritative authorized_keys — sshd reads it via AuthorizedKeysFile (drop-in). + # root-owned read-only file, which satisfies sshd's ownership checks. + - name: authorized-keys + mountPath: /etc/ssh/authorized_keys + subPath: authorized_keys + readOnly: true + - name: config-volume + mountPath: /config + # Must live under /config/sshd/sshd_config.d/ for the image to include it. + - name: config + mountPath: /config/sshd/sshd_config.d/100-ifrc-forwarding.conf + subPath: 100-ifrc-forwarding.conf + readOnly: true + - name: config + mountPath: /etc/ifrc/assert-sshd.sh + subPath: assert-sshd.sh + readOnly: true + volumes: + - name: authorized-keys + configMap: + name: {{ include "ssh-bastion.name" . }}-authorized-keys + - name: config + configMap: + name: {{ include "ssh-bastion.name" . }}-config + # Persists the server host keys across pod restarts (avoids host-key-changed warnings). + volumeClaimTemplates: + - metadata: + name: config-volume + spec: + accessModes: ["ReadWriteOnce"] + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size | quote }} diff --git a/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml b/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml new file mode 100644 index 0000000..86def6a --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/tests/bastion_test.yaml @@ -0,0 +1,179 @@ +suite: ssh-bastion +templates: + - templates/statefulset.yaml + - templates/service.yaml + - templates/configmap-config.yaml + - templates/configmap-authorized-keys.yaml + - templates/networkpolicy.yaml +tests: + - it: mounts the sshd drop-in under /config/sshd/sshd_config.d (not the ignored /etc/ssh path) + template: templates/statefulset.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: config + mountPath: /config/sshd/sshd_config.d/100-ifrc-forwarding.conf + subPath: 100-ifrc-forwarding.conf + readOnly: true + + - it: mounts the authoritative authorized_keys at the AuthorizedKeysFile path + template: templates/statefulset.yaml + asserts: + - contains: + path: spec.template.spec.containers[0].volumeMounts + content: + name: authorized-keys + mountPath: /etc/ssh/authorized_keys + subPath: authorized_keys + readOnly: true + + - it: runs the fail-closed config assertion on all three probes + template: templates/statefulset.yaml + asserts: + - equal: + path: spec.template.spec.containers[0].startupProbe.exec.command + value: ["sh", "/etc/ifrc/assert-sshd.sh"] + - equal: + path: spec.template.spec.containers[0].readinessProbe.exec.command + value: ["sh", "/etc/ifrc/assert-sshd.sh"] + - equal: + path: spec.template.spec.containers[0].livenessProbe.exec.command + value: ["sh", "/etc/ifrc/assert-sshd.sh"] + + - it: rolls the pod when keys or config change (checksum annotations present) + template: templates/statefulset.yaml + asserts: + - exists: + path: spec.template.metadata.annotations["checksum/authorized-keys"] + - exists: + path: spec.template.metadata.annotations["checksum/config"] + + - it: pins the PVC storage class when provided + template: templates/statefulset.yaml + set: + persistence.storageClass: managed-csi-premium + asserts: + - equal: + path: spec.volumeClaimTemplates[0].spec.storageClassName + value: managed-csi-premium + + - it: omits storageClassName when unset (falls back to the cluster default) + template: templates/statefulset.yaml + asserts: + - notExists: + path: spec.volumeClaimTemplates[0].spec.storageClassName + + - it: exposes an internet LoadBalancer that preserves the client source IP + template: templates/service.yaml + set: + service.loadBalancerIP: 20.1.2.3 + service.annotations: + service.beta.kubernetes.io/azure-load-balancer-resource-group: ifrctgo002rg + asserts: + - equal: + path: spec.type + value: LoadBalancer + - equal: + path: spec.externalTrafficPolicy + value: Local + - equal: + path: spec.loadBalancerIP + value: "20.1.2.3" + - equal: + path: metadata.annotations["service.beta.kubernetes.io/azure-load-balancer-resource-group"] + value: ifrctgo002rg + + - it: drives AllowUsers from the single user value + template: templates/configmap-config.yaml + set: + user: svc-jump + asserts: + - matchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "AllowUsers svc-jump" + - matchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "AllowTcpForwarding yes" + - matchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "AuthorizedKeysFile /etc/ssh/authorized_keys" + + - it: enforces forwarding-only by default (no shell / exec / sftp) + template: templates/configmap-config.yaml + asserts: + - matchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "PermitTTY no" + - matchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "ForceCommand /bin/false" + + - it: allows a shell when forwardingOnly is disabled + template: templates/configmap-config.yaml + set: + forwardingOnly: false + asserts: + - notMatchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "ForceCommand" + + - it: renders a PermitOpen allowlist when provided + template: templates/configmap-config.yaml + set: + sshd.permitOpen: + - "10.96.0.1:443" + - "10.0.0.5:5432" + asserts: + - matchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "PermitOpen 10.96.0.1:443 10.0.0.5:5432" + + - it: omits PermitOpen when unset (unrestricted) + template: templates/configmap-config.yaml + asserts: + - notMatchRegex: + path: data["100-ifrc-forwarding.conf"] + pattern: "PermitOpen" + + - it: no NetworkPolicy by default + templates: + - templates/networkpolicy.yaml + asserts: + - hasDocuments: + count: 0 + + - it: NetworkPolicy restricts ingress to SSH and egress to an allowlist (no internet/metadata) + templates: + - templates/networkpolicy.yaml + set: + networkPolicy.enabled: true + asserts: + - isKind: + of: NetworkPolicy + - equal: + path: spec.ingress[0].ports[0].port + value: 2222 + # egress[0] = DNS, egress[1] = allowlisted CIDRs; 0.0.0.0/0 must NOT appear + - equal: + path: spec.egress[1].to[0].ipBlock.cidr + value: 10.0.0.0/8 + - notContains: + path: spec.egress[1].to + content: + ipBlock: + cidr: 0.0.0.0/0 + + - it: concatenates all provided public keys into authorized_keys + template: templates/configmap-authorized-keys.yaml + set: + keys: + - "ssh-ed25519 AAAAKEYONE a@x" + - "ssh-ed25519 AAAAKEYTWO b@y" + asserts: + - matchRegex: + path: data.authorized_keys + pattern: "AAAAKEYONE a@x" + - matchRegex: + path: data.authorized_keys + pattern: "AAAAKEYTWO b@y" diff --git a/base-infrastructure/charts/ssh-bastion/values.yaml b/base-infrastructure/charts/ssh-bastion/values.yaml new file mode 100644 index 0000000..41975c7 --- /dev/null +++ b/base-infrastructure/charts/ssh-bastion/values.yaml @@ -0,0 +1,70 @@ +# renovate: datasource=docker depName=lscr.io/linuxserver/openssh-server versioning=regex:^version-(?\d+)\.(?\d+)_p(?\d+)-r(?\d+)$ +image: "lscr.io/linuxserver/openssh-server:version-10.3_p1-r0" + +# Login user: the image creates this account (USER_NAME) and sshd only permits it +# (AllowUsers, in the drop-in). Both are driven from this single value. +user: user + +# Forwarding-only jump host: no shell, no exec, no SFTP/SCP — only TCP forwarding +# (ssh -N -L / -D and ProxyJump -J / -W). Strongly recommended: it stops a holder of a +# valid key from reading anything on the box, including the sshd host private key (which +# the image necessarily makes readable to the account sshd runs as). Interactive sessions +# get a forced no-op command and exit. +forwardingOnly: true + +sshd: + # Optional allowlist of forward destinations, each "host:port" (globs allowed, e.g. + # "10.0.0.5:5432"). Empty = unrestricted (any). Set this to constrain how far a key + # holder can pivot through the bastion. + permitOpen: [] + +# Free-form environment label (set by Terraform, e.g. "staging" / "production"). +environment: "" + +service: + type: LoadBalancer + port: 2222 + # Only used when type is NodePort; null lets the cluster pick one. + nodePort: null + # Reserved cloud LB IP (set per-cluster). Empty = let the cluster assign one. + loadBalancerIP: "" + # Free-form Service annotations — cloud load-balancer settings etc., provided per-cluster + # (kept generic so the chart isn't tied to any one cloud). + annotations: {} + +# Restrict the pod's network reach (defense-in-depth for the pivot surface). Requires a +# NetworkPolicy-enforcing CNI (Azure CNI with a policy engine, Calico, Cilium, ...); +# no-op on CNIs that don't enforce it (e.g. kind's kindnet). +networkPolicy: + enabled: false + # Egress CIDRs the bastion may reach (in addition to cluster DNS). Defaults to the + # private RFC1918 ranges — enough to port-forward to in-cluster / internal services — + # while excluding the public internet and the cloud metadata endpoint + # (169.254.169.254). Tighten to your pod/service CIDRs for a stricter policy. + allowedEgressCIDRs: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + +persistence: + # StorageClass for the host-key PVC. Empty = use the cluster's default class. Set + # per-cluster when a specific RWO class is required. Persisting the volume keeps sshd + # host keys stable across restarts (no host-key-changed warnings). + storageClass: "" + size: 100Mi + +resources: + requests: + cpu: "0.1" + # Integer byte quantities (0.05Gi / 0.2Gi render as fractional-byte "…m" values that + # Kubernetes warns about). + memory: "50Mi" + limits: + cpu: "1" + memory: "200Mi" + +# Authorized SSH *public* keys, one entry per key. Concatenated into a single, fully +# declarative authorized_keys file — removing an entry revokes that key's access. +# Provided by Terraform (see resources/bastion.tf); empty here so the chart never ships +# keys of its own. +keys: [] diff --git a/base-infrastructure/terraform/.terraform.lock.hcl b/base-infrastructure/terraform/.terraform.lock.hcl index bcf9a7a..63bd469 100644 --- a/base-infrastructure/terraform/.terraform.lock.hcl +++ b/base-infrastructure/terraform/.terraform.lock.hcl @@ -22,43 +22,43 @@ provider "registry.terraform.io/hashicorp/azurerm" { } provider "registry.terraform.io/hashicorp/helm" { - version = "2.5.1" - constraints = "2.5.1" + version = "2.17.0" + constraints = "~> 2.17.0" hashes = [ - "h1:9yMFsXyHAo+mUuMKczNSw44HcZaf1JkMqgOUgJF1dXs=", - "h1:NasRPC0qqlpGqcF3dsSoOFu7uc5hM+zJm+okd8FgrnQ=", - "zh:140b9748f0ad193a20d69e59d672f3c4eda8a56cede56a92f931bd3af020e2e9", - "zh:17ae319466ed6538ad49e011998bb86565fe0e97bc8b9ad7c8dda46a20f90669", - "zh:3a8bd723c21ba70e19f0395ed7096fc8e08bfc23366f1c3f06a9107eb37c572c", - "zh:3aae3b82adbe6dca52f1a1c8cf51575446e6b0f01f1b1f3b30de578c9af4a933", - "zh:3f65221f40148df57d2888e4f31ef3bf430b8c5af41de0db39a2b964e1826d7c", - "zh:650c74c4f46f5eb01df11d8392bdb7ebee3bba59ac0721000a6ad731ff0e61e2", - "zh:930fb8ab4cd6634472dfd6aa3123f109ef5b32cbe6ef7b4695fae6751353e83f", - "zh:ae57cd4b0be4b9ca252bc5d347bc925e35b0ed74d3dcdebf06c11362c1ac3436", - "zh:d15b1732a8602b6726eac22628b2f72f72d98b75b9c6aabceec9fd696fda696a", - "zh:d730ede1656bd193e2aea5302acec47c4905fe30b96f550196be4a0ed5f41936", - "zh:f010d4f9d8cd15936be4df12bf256cb2175ca1dedb728bd3a866c03d2ee7591f", + "h1:K5FEjxvDnxb1JF1kG1xr8J3pNGxoaR3Z0IBG9Csm/Is=", + "zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4", + "zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7", + "zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3", + "zh:3956187ec239f4045975b35e8c30741f701aa494c386aaa04ebabffe7749f81c", + "zh:66a9686d92a6b3ec43de3ca3fde60ef3d89fb76259ed3313ca4eb9bb8c13b7dd", + "zh:88644260090aa621e7e8083585c468c8dd5e09a3c01a432fb05da5c4623af940", + "zh:a248f650d174a883b32c5b94f9e725f4057e623b00f171936dcdcc840fad0b3e", + "zh:aa498c1f1ab93be5c8fbf6d48af51dc6ef0f10b2ea88d67bcb9f02d1d80d3930", + "zh:bf01e0f2ec2468c53596e027d376532a2d30feb72b0b5b810334d043109ae32f", + "zh:c46fa84cc8388e5ca87eb575a534ebcf68819c5a5724142998b487cb11246654", + "zh:d0c0f15ffc115c0965cbfe5c81f18c2e114113e7a1e6829f6bfd879ce5744fbb", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } provider "registry.terraform.io/hashicorp/http" { - version = "3.5.0" + version = "3.6.0" constraints = "~> 3.5" hashes = [ - "h1:8bUoPwS4hahOvzCBj6b04ObLVFXCEmEN8T/5eOHmWOM=", - "zh:047c5b4920751b13425efe0d011b3a23a3be97d02d9c0e3c60985521c9c456b7", - "zh:157866f700470207561f6d032d344916b82268ecd0cf8174fb11c0674c8d0736", - "zh:1973eb9383b0d83dd4fd5e662f0f16de837d072b64a6b7cd703410d730499476", - "zh:212f833a4e6d020840672f6f88273d62a564f44acb0c857b5961cdb3bbc14c90", - "zh:2c8034bc039fffaa1d4965ca02a8c6d57301e5fa9fff4773e684b46e3f78e76a", - "zh:5df353fc5b2dd31577def9cc1a4ebf0c9a9c2699d223c6b02087a3089c74a1c6", - "zh:672083810d4185076c81b16ad13d1224b9e6ea7f4850951d2ab8d30fa6e41f08", + "h1:FekY+4cjIw3QBdpk2dVQ20+t8AeDwCK/VaKOnjfeJvw=", + "zh:0996c7db5d7627bc6ab8c4d217f18fb122d60e99e454812b080ee5695cad1003", + "zh:25b7ee0ba9edc912a00365c776d062ae7c66d94050c6c13038447c8e8b95ddf2", + "zh:29c4ba54add6eee7f1d0034d331ba0f14f3046234b1f7520a537e6444e4521b7", + "zh:30a3aa3ef978f8142daf2ced3f9f1ecda8b0831cdc6911e7e930e95eab191b4f", + "zh:30bf0810acdfe96e799ed9b64cb70e96d6f7c033621e0373b9897513977c49c5", + "zh:4ecaaf3dfd20feaa2b92af521018501bc7d874b6a6642ef86ea4cc3c251d737a", + "zh:661760406e3d5372e6725d18ca80734996f21adabc02d82e36ed6d8db07cad7d", + "zh:70dd9556bd2633082efd9681421f89dd4abdde6fcd84834627fa4b8b8f9e7afe", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b4200f18abdbe39904b03537e1a78f21ebafe60f1c861a44387d314fda69da6", - "zh:843feacacd86baed820f81a6c9f7bd32cf302db3d7a0f39e87976ebc7a7cc2ee", - "zh:a9ea5096ab91aab260b22e4251c05f08dad2ed77e43e5e4fadcdfd87f2c78926", - "zh:d02b288922811739059e90184c7f76d45d07d3a77cc48d0b15fd3db14e928623", + "zh:973a72066866579ac6fa0e0c9e9eee8e77d238cf78f1b1f96ce6536052fa73ce", + "zh:b2ac9463a9499b478147186027fabee65c04b8f6d963a8df3d49241ce5784fe2", + "zh:c6acc05dc3456c0001b5fb99e1b57005f5a3d3d766f9bddfc21ffe8364fa7535", + "zh:f97f2d57ebcefe62f0644f58e8ba68593f8f05baf7856c99953396aedd14e415", ] } diff --git a/base-infrastructure/terraform/resources/.terraform.lock.hcl b/base-infrastructure/terraform/resources/.terraform.lock.hcl index 28b570a..f513129 100644 --- a/base-infrastructure/terraform/resources/.terraform.lock.hcl +++ b/base-infrastructure/terraform/resources/.terraform.lock.hcl @@ -22,41 +22,43 @@ provider "registry.terraform.io/hashicorp/azurerm" { } provider "registry.terraform.io/hashicorp/helm" { - version = "2.5.1" - constraints = "2.5.1" + version = "2.17.0" + constraints = "~> 2.17.0" hashes = [ - "h1:NasRPC0qqlpGqcF3dsSoOFu7uc5hM+zJm+okd8FgrnQ=", - "zh:140b9748f0ad193a20d69e59d672f3c4eda8a56cede56a92f931bd3af020e2e9", - "zh:17ae319466ed6538ad49e011998bb86565fe0e97bc8b9ad7c8dda46a20f90669", - "zh:3a8bd723c21ba70e19f0395ed7096fc8e08bfc23366f1c3f06a9107eb37c572c", - "zh:3aae3b82adbe6dca52f1a1c8cf51575446e6b0f01f1b1f3b30de578c9af4a933", - "zh:3f65221f40148df57d2888e4f31ef3bf430b8c5af41de0db39a2b964e1826d7c", - "zh:650c74c4f46f5eb01df11d8392bdb7ebee3bba59ac0721000a6ad731ff0e61e2", - "zh:930fb8ab4cd6634472dfd6aa3123f109ef5b32cbe6ef7b4695fae6751353e83f", - "zh:ae57cd4b0be4b9ca252bc5d347bc925e35b0ed74d3dcdebf06c11362c1ac3436", - "zh:d15b1732a8602b6726eac22628b2f72f72d98b75b9c6aabceec9fd696fda696a", - "zh:d730ede1656bd193e2aea5302acec47c4905fe30b96f550196be4a0ed5f41936", - "zh:f010d4f9d8cd15936be4df12bf256cb2175ca1dedb728bd3a866c03d2ee7591f", + "h1:K5FEjxvDnxb1JF1kG1xr8J3pNGxoaR3Z0IBG9Csm/Is=", + "zh:06fb4e9932f0afc1904d2279e6e99353c2ddac0d765305ce90519af410706bd4", + "zh:104eccfc781fc868da3c7fec4385ad14ed183eb985c96331a1a937ac79c2d1a7", + "zh:129345c82359837bb3f0070ce4891ec232697052f7d5ccf61d43d818912cf5f3", + "zh:3956187ec239f4045975b35e8c30741f701aa494c386aaa04ebabffe7749f81c", + "zh:66a9686d92a6b3ec43de3ca3fde60ef3d89fb76259ed3313ca4eb9bb8c13b7dd", + "zh:88644260090aa621e7e8083585c468c8dd5e09a3c01a432fb05da5c4623af940", + "zh:a248f650d174a883b32c5b94f9e725f4057e623b00f171936dcdcc840fad0b3e", + "zh:aa498c1f1ab93be5c8fbf6d48af51dc6ef0f10b2ea88d67bcb9f02d1d80d3930", + "zh:bf01e0f2ec2468c53596e027d376532a2d30feb72b0b5b810334d043109ae32f", + "zh:c46fa84cc8388e5ca87eb575a534ebcf68819c5a5724142998b487cb11246654", + "zh:d0c0f15ffc115c0965cbfe5c81f18c2e114113e7a1e6829f6bfd879ce5744fbb", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", ] } provider "registry.terraform.io/hashicorp/http" { - version = "3.5.0" + version = "3.6.0" + constraints = "~> 3.5" hashes = [ - "h1:8bUoPwS4hahOvzCBj6b04ObLVFXCEmEN8T/5eOHmWOM=", - "zh:047c5b4920751b13425efe0d011b3a23a3be97d02d9c0e3c60985521c9c456b7", - "zh:157866f700470207561f6d032d344916b82268ecd0cf8174fb11c0674c8d0736", - "zh:1973eb9383b0d83dd4fd5e662f0f16de837d072b64a6b7cd703410d730499476", - "zh:212f833a4e6d020840672f6f88273d62a564f44acb0c857b5961cdb3bbc14c90", - "zh:2c8034bc039fffaa1d4965ca02a8c6d57301e5fa9fff4773e684b46e3f78e76a", - "zh:5df353fc5b2dd31577def9cc1a4ebf0c9a9c2699d223c6b02087a3089c74a1c6", - "zh:672083810d4185076c81b16ad13d1224b9e6ea7f4850951d2ab8d30fa6e41f08", + "h1:FekY+4cjIw3QBdpk2dVQ20+t8AeDwCK/VaKOnjfeJvw=", + "zh:0996c7db5d7627bc6ab8c4d217f18fb122d60e99e454812b080ee5695cad1003", + "zh:25b7ee0ba9edc912a00365c776d062ae7c66d94050c6c13038447c8e8b95ddf2", + "zh:29c4ba54add6eee7f1d0034d331ba0f14f3046234b1f7520a537e6444e4521b7", + "zh:30a3aa3ef978f8142daf2ced3f9f1ecda8b0831cdc6911e7e930e95eab191b4f", + "zh:30bf0810acdfe96e799ed9b64cb70e96d6f7c033621e0373b9897513977c49c5", + "zh:4ecaaf3dfd20feaa2b92af521018501bc7d874b6a6642ef86ea4cc3c251d737a", + "zh:661760406e3d5372e6725d18ca80734996f21adabc02d82e36ed6d8db07cad7d", + "zh:70dd9556bd2633082efd9681421f89dd4abdde6fcd84834627fa4b8b8f9e7afe", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:7b4200f18abdbe39904b03537e1a78f21ebafe60f1c861a44387d314fda69da6", - "zh:843feacacd86baed820f81a6c9f7bd32cf302db3d7a0f39e87976ebc7a7cc2ee", - "zh:a9ea5096ab91aab260b22e4251c05f08dad2ed77e43e5e4fadcdfd87f2c78926", - "zh:d02b288922811739059e90184c7f76d45d07d3a77cc48d0b15fd3db14e928623", + "zh:973a72066866579ac6fa0e0c9e9eee8e77d238cf78f1b1f96ce6536052fa73ce", + "zh:b2ac9463a9499b478147186027fabee65c04b8f6d963a8df3d49241ce5784fe2", + "zh:c6acc05dc3456c0001b5fb99e1b57005f5a3d3d766f9bddfc21ffe8364fa7535", + "zh:f97f2d57ebcefe62f0644f58e8ba68593f8f05baf7856c99953396aedd14e415", ] } diff --git a/base-infrastructure/terraform/resources/bastion.tf b/base-infrastructure/terraform/resources/bastion.tf new file mode 100644 index 0000000..935c1dc --- /dev/null +++ b/base-infrastructure/terraform/resources/bastion.tf @@ -0,0 +1,69 @@ +# SSH bastion — cluster-wide access jump host. +# This is cluster access infrastructure (not tied to any single application), so it lives +# here in base-infrastructure rather than in an application Helm chart. The Kubernetes +# resources are defined by the local chart at base-infrastructure/charts/ssh-bastion and +# applied via this helm_release (matching how the other cluster components — traefik, +# argocd, cert-manager, etc. — are deployed). +# +# TODO: An older copy of this bastion is still shipped by the go-api Helm chart +# (deploy/helm/ifrcgo-helm/templates/bastion.yaml) and runs in the `default` namespace. +# Both run in parallel for now; users should migrate to the new IP exposed by this +# resource. The go-api copy will be removed in the upcoming go-api updates. +# +# NOTE: after editing anything under charts/ssh-bastion, bump the chart `version` in +# Chart.yaml so the helm provider detects the change and redeploys. + +resource "helm_release" "bastion" { + name = "ssh-bastion" + namespace = "bastion" + create_namespace = true + chart = "${path.module}/../../charts/ssh-bastion" + + depends_on = [ + azurerm_public_ip.bastion, + ] + + # Cluster/environment-specific values. The chart itself stays cloud-agnostic; anything + # Azure/AKS-specific (storage class, LB annotations, reserved IP) is injected here. + values = [yamlencode({ + environment = var.environment + # Authorized SSH *public* keys. Concatenated into a single, declarative authorized_keys + # file by the chart — removing an entry here revokes that key's access. + keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPGAnkQdf5CIpVoqNVJ17AAzUb02gpTltJI5q5SRKxl8 zol@hp", + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDU1XLLPq1J4kFvNyg5eUK8uuW8dtW1f3ALVnYr0nVhldxF0J59XtZbNFBLCVHYZL3NQxYQrucll6LbGaMGKbGsTwtqcxqd2fWlhg7nBnvhOzULYbAru3YfpkgnawGin6Y7qW/MQ3fYmqqm8MB7p5+G4sIL76S2yWbi7lcKWnd87yDTGEEoc8H6i6IwNNVHudvuMA4MzGkSgql7gIC2KuU+s2u9Y6fmE92G39BO454SUgAcCJfhuXukZhU4UN3RVYy+F0MxVeLc0hEJi4sCYcoPKREc0//srNyni7b8G8C+z6t02xrzhWwIORlb8Jr2kmbblp7PFMz4r2qRd8MvXAa5ta6kUvMDg0t52JaDMAGy0IjGZh9PznXbp1LYn7uS5NQh4C/t6Q3TXyJbEiaQaObcmjn6w/DWH6gI7ZRYkPGdlctlNm5MWnhjG9Q/FzRIxvaauSFqgs6bfIUGGaY9i1eNiowVSzDPlP7nH0gJpq+uS5Qdyg69m/XH1DqywPoZY7U= ifrcds\\daniel.tovari@5CG41911RW", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIERqaO+XlqTbvoh88Kuj9c377x77NChWhNP8VpbM1/hf ifrcds\\arun.gandhi@5CG1355NPN", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIN/f/A3qkaTHSdbKn8Hv75YiJvRMEXvWTDdIiR7tyAjJ navin@nav-machine", + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC3FzrQdVh5Qwp5Y6KQGcpqHxKErxCW103iEECuutR/jBZe6X0xjD+cW7e+H8SrUsPQwj87fzOsMAc6v6n+3hdYFa6ekgRG/USEIUR5C/GD1Xjva3Xpp45PasBhJEtYt2ON+dlzwvRyOuv2hvqv2WHBO020ewIlVuQ4pU4Qj5ysvwWGj8GAv/jITiVERmjLTStbFwxeIDT3jQEbwnfV1zZZKiGxIecB/y51nk6oIQ00ZGrYEo5ieWsUSVfLHOX0/lZ0mtrdqxDEgMaCbNaUbICAimsJPamNpoirKc7FoKIKKrLQsK8qE1lClWQEecbW+dgSiwxracooKeWhHq+BkKUCNgEL/C0ff2l9e8sJcLmYZUdPtDCdtUDC8BAlELA5HR6tdCTfFcc0nXltclSSODMnZkQohh5/2fixJTwN5p5csEfBLzbdrturKtT/TbYSoaodg4muPqY4YE5jiJfrHVAGS1DVWz/cRcm1vOxT2V4iW2SNvo8fS2PZOpU5furrvbM= ifrcds\\david.muchatiza@5CG41911S1", + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDGql4RrbxSQTW5QrTh+P+94jGCXOCeZgc23hxL9zFCYQrzL0SMw1F53Z5SFZimIhJswYPqV2pT8L4oTRqIrTCM+looWi7b9/9u+m/KmA+FWbo3u6uRrckkA3nVIKsKHvlOucX2GxE6i+tXdeXEisW49ZpMtuvxMLJ3Eg4MK10d/2d3FKuzTsrxCTlJn8FAE3yOsVow0jdu+381IrkAqRE2GINeQ87hVlQpbo+bL2N/2QZmNjDhBBQkRJLDisW0+UNgo+S9wN7HbpV5LheSJS9wGN7LlmcqlpZFrDO/lVyoMxEQ0588wUI8BVfqAZDEBJPdGtzq513r+5iXEX/9A1Mendlvxfl6ANNRcH9PVZHkRN1dxY3rckQ+Lk3qqIjjfYFYvl5Gybidb1BM2VNWHAuzaDDQzJpeTHIbQnDt7Ke4oX2xWYgyu+kVhqz0HnAV28qMXbMEsrMIrtwl7IjcrorgdduHghZvWFbaJZNtXOfgnf1IYNXkZ9eWPS+Bz9nWMhE= ifrcds\\paola.yela@5CG41911RT", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGJA0ec4Gavc+m1MjEZGoUce51yWouMTRTYJZV3s/jgD rsh@rsh-XPS-15-9510", + ] + # Idle jump host — kept small. Staging gets a slightly higher CPU request (matches the + # sizing the go-api chart overrides used previously); the rest comes from values.yaml. + resources = { + requests = { + cpu = var.environment == "staging" ? "0.2" : "0.1" + } + } + persistence = { + # AKS built-in RWO managed-disk class. + storageClass = "managed-csi" + } + # Defense-in-depth for the pivot surface: allow the pod to reach only cluster-internal + # RFC1918 ranges (enough to port-forward to in-cluster services) — the public internet + # and the cloud metadata endpoint (169.254.169.254) are denied. Requires the AKS + # cluster to have a network-policy engine (azure/calico); it is a harmless no-op + # otherwise. The chart default egress CIDRs (10/8, 172.16/12, 192.168/16) cover the + # standard AKS pod/service ranges. + networkPolicy = { + enabled = true + } + service = { + # Reserved static IP so the bastion endpoint is stable across recreations. + loadBalancerIP = azurerm_public_ip.bastion.ip_address + annotations = { + "service.beta.kubernetes.io/azure-load-balancer-resource-group" = data.azurerm_resource_group.ifrcgo.name + } + } + })] +} diff --git a/base-infrastructure/terraform/resources/helm-eck-operator.tf b/base-infrastructure/terraform/resources/helm-eck-operator.tf new file mode 100644 index 0000000..18f2bda --- /dev/null +++ b/base-infrastructure/terraform/resources/helm-eck-operator.tf @@ -0,0 +1,20 @@ +# Elastic Cloud on Kubernetes (ECK) operator + CRDs. +resource "helm_release" "eck-operator" { + name = "eck-operator" + namespace = "elastic-system" + create_namespace = true + max_history = 10 + + repository = "https://helm.elastic.co" + chart = "eck-operator" + + # WARNING: We run Elasticsearch 7.x, which this operator supports but already + # flags as deprecated (see the deprecation warning in the operator logs). + # Before bumping, confirm the target ECK version still supports ES 7.x — newer + # majors drop it. Do NOT upgrade if ES 7 support is missing. + version = "3.4.1" # https://github.com/elastic/cloud-on-k8s/releases + + depends_on = [ + azurerm_kubernetes_cluster.ifrcgo + ] +} diff --git a/base-infrastructure/terraform/resources/ip.tf b/base-infrastructure/terraform/resources/ip.tf index 330ecf6..576027b 100644 --- a/base-infrastructure/terraform/resources/ip.tf +++ b/base-infrastructure/terraform/resources/ip.tf @@ -12,3 +12,32 @@ resource "azurerm_public_ip" "ifrcgo" { Environment = var.environment } } + +# Traefik Public IP +resource "azurerm_public_ip" "traefik" { + + name = "${local.prefix}TraefikPublicIP" + resource_group_name = data.azurerm_resource_group.ifrcgo.name + location = data.azurerm_resource_group.ifrcgo.location + + allocation_method = "Static" + sku = "Standard" + + tags = { + Environment = var.environment + } +} + +# SSH bastion Public IP (see bastion.tf) — reserved so the bastion endpoint is +# stable across recreations (fixed IP / DNS can be put in front later). +resource "azurerm_public_ip" "bastion" { + name = "${local.prefix}BastionPublicIP" + resource_group_name = data.azurerm_resource_group.ifrcgo.name + location = data.azurerm_resource_group.ifrcgo.location + allocation_method = "Static" + sku = "Standard" + + tags = { + Environment = var.environment + } +} diff --git a/base-infrastructure/terraform/resources/providers.tf b/base-infrastructure/terraform/resources/providers.tf index 1bf9e4e..1b1e68c 100644 --- a/base-infrastructure/terraform/resources/providers.tf +++ b/base-infrastructure/terraform/resources/providers.tf @@ -12,7 +12,7 @@ terraform { } helm = { source = "hashicorp/helm" - version = "=2.5.1" + version = "~> 2.17.0" } kubernetes = { source = "hashicorp/kubernetes" diff --git a/base-infrastructure/terraform/resources/traefik.tf b/base-infrastructure/terraform/resources/traefik.tf new file mode 100644 index 0000000..6f3667a --- /dev/null +++ b/base-infrastructure/terraform/resources/traefik.tf @@ -0,0 +1,85 @@ +# Reference from: "./helm-ingress-nginx.tf" file. + +resource "helm_release" "traefik" { + name = "traefik" + repository = "https://traefik.github.io/charts" + chart = "traefik" + namespace = "traefik" + version = "40.2.0" + create_namespace = true + + depends_on = [ + azurerm_public_ip.traefik, + ] + + values = [yamlencode({ + + deployment = { + replicas = 1 + } + + service = { + annotations = { + "service.beta.kubernetes.io/azure-load-balancer-resource-group" = data.azurerm_resource_group.ifrcgo.name + # AzureLoadBalanacer Health probe path + "service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path" = "/ping" + } + spec = { + loadBalancerIP = azurerm_public_ip.traefik.ip_address + externalTrafficPolicy = "Local" + } + } + + ingressClass = { + enabled = true + # NOTE: Keep false until nginx is fully removed + isDefaultClass = false + } + + providers = { + # NOTE: Uses the same ingressClass as nginx for now, until nginx is fully removed + kubernetesIngress = { + ingressClass = "nginx" + } + kubernetesIngressNGINX = { + enabled = true + } + } + + # Replaces nginx: use-forwarded-headers, real-ip-header, set-real-ip-from + # Trust X-Forwarded-For only from the AKS subnet (10.1.0.0/16) + ports = { + web = { + http = { + redirections = { + entryPoint = { + to = "websecure" + scheme = "https" + permanent = true + } + } + } + forwardedHeaders = { + trustedIPs = [local.aks_subnet_cidr] + } + } + websecure = { + forwardedHeaders = { + trustedIPs = [local.aks_subnet_cidr] + } + } + } + + logs = { + access = { + enabled = true + } + } + + # Not exposing dashboard on plain HTTP + api = { + insecure = false + } + + })] +} diff --git a/renovate.json5 b/renovate.json5 index 781de93..939bcbb 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -15,6 +15,17 @@ ], versioningTemplate: "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}", }, + { + // Container image references (`key = "registry/repo:tag"`) annotated with + // a `# renovate:` comment. Complements the manager above, which only + // matches bare digit-leading versions (no registry/tag colon). + customType: "regex", + managerFilePatterns: ["*"], + matchStrings: [ + '# renovate: datasource=(?\\S+) depName=(?\\S+)(?: versioning=(?\\S+))?\\s*[A-Za-z0-9._-]+\\s*[:=]\\s*"[^"\\s]+:(?[^":\\s]+)"', + ], + versioningTemplate: "{{#if versioning}}{{{versioning}}}{{else}}semver{{/if}}", + }, ], "argocd": { "managerFilePatterns": [