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/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/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/ip.tf b/base-infrastructure/terraform/resources/ip.tf index 52d2308..576027b 100644 --- a/base-infrastructure/terraform/resources/ip.tf +++ b/base-infrastructure/terraform/resources/ip.tf @@ -27,3 +27,17 @@ resource "azurerm_public_ip" "traefik" { 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/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": [